Files

492 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* aipage_pack.js — 把一个本地 HTML 目录(或单文件)打包成符合 aicanvas
* McpImport 规范的 .aipage 压缩包,供 tencent-docs MCP 的导入流程使用。
*
* 设计原则:
* - tencent-docs skill 自持「打包 + 导入」全链路,
* 上游 skill(如 smart-page)只负责输出 HTML,不再关心 manifest/zip。
* - 跨平台:纯 Node.js>= 14),零 npm 依赖。
* 手写 ZIPstore 模式,method=0),同时兼容 macOS / Linux / Windows
* 原生 cmd / PowerShell(无需 bash / Git Bash / WSL)。
*
* Usage:
* node aipage_pack.js --html <html_path> [--title <title>] [--output <out_path>]
* node aipage_pack.js --dir <html_dir> [--title <title>] [--output <out_path>]
*
* Behaviors:
* 1. 创建临时打包目录
* 2. 将入口 HTML 复制为 index.htmlaipage 硬要求)
* 3. 复制同级 assets/ 目录(如存在),目录模式下复制整个目录的全部文件
* 4. 生成 manifest.json(标题安全转义;未传 --title 时自动从 <title> 提取,
* 再 fallback 用文件名/目录名)
* 5. 生成 janus.manifest.json(固定内容)
* 6. 扁平化 zip 打包(zip 内无顶层目录),后缀强制为 .aipage
* 7. 输出结构化结果(供 SKILL 内 agent 直接解析):
* AIPAGE_PATH=...
* AIPAGE_SIZE=...
* AIPAGE_MD5=...
* AIPAGE_TITLE=...
*
* Exit codes:
* 0 成功
* 1 参数错误
* 2 源 HTML / 目录不存在或不合法
* 3 打包失败 / 内部错误
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const zlib = require('zlib');
// ─────────────────────────────────────────────────────────────────────────────
// 1. 参数解析
// ─────────────────────────────────────────────────────────────────────────────
function usage(exitCode) {
const msg = [
'Usage:',
' node aipage_pack.js --html <html_path> [--title <title>] [--output <out_path>]',
' node aipage_pack.js --dir <html_dir> [--title <title>] [--output <out_path>]',
'',
' --html 单个 HTML 文件路径(推荐:smart-page 等上游产物)',
' --dir 已组织好的 HTML 目录路径,目录中必须有且仅有一个 .html / .htm 入口',
' --title 可选,文档标题;缺省时自动读 <title> 标签,再 fallback 用文件名/目录名',
' --output 可选,输出 .aipage 路径;缺省为 <tmpdir>/<stem>.aipage',
'',
'示例:',
' node aipage_pack.js --html "output/立项方案.html"',
' node aipage_pack.js --html "output/邀请函.html" --title "邀请函" --output /tmp/x.aipage',
' node aipage_pack.js --dir "output/site" --title "站点演示"',
'',
].join('\n');
process.stderr.write(msg);
process.exit(typeof exitCode === 'number' ? exitCode : 1);
}
function parseArgs(argv) {
const opts = { html: '', dir: '', title: '', output: '' };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case '--html':
opts.html = argv[++i] || '';
break;
case '--dir':
opts.dir = argv[++i] || '';
break;
case '--title':
opts.title = argv[++i] || '';
break;
case '--output':
opts.output = argv[++i] || '';
break;
case '-h':
case '--help':
usage(0);
break;
default:
process.stderr.write(`aipage_pack.js: unknown argument: ${a}\n`);
usage(1);
}
}
return opts;
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. 工具函数
// ─────────────────────────────────────────────────────────────────────────────
function fail(code, msg) {
process.stderr.write(`aipage_pack.js: ${msg}\n`);
process.exit(code);
}
function md5OfFile(filePath) {
const h = crypto.createHash('md5');
h.update(fs.readFileSync(filePath));
return h.digest('hex');
}
function sizeOfFile(filePath) {
return fs.statSync(filePath).size;
}
function extractHtmlTitle(htmlPath) {
let html = '';
try {
html = fs.readFileSync(htmlPath, 'utf8');
} catch (_) {
return '';
}
const m = html.match(/<title>([\s\S]*?)<\/title>/i);
return m ? m[1].trim() : '';
}
function rmrf(p) {
if (!fs.existsSync(p)) return;
// Node 14.14+ 支持 rmSync({recursive:true}); 兼容更早版本回退到 rmdirSync
try {
fs.rmSync(p, { recursive: true, force: true });
} catch (_) {
fs.rmdirSync(p, { recursive: true });
}
}
function mkTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function copyFile(src, dst) {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
}
// 递归复制目录内容到 dstDir(不包含 dstDir 自身的创建)
function copyDirContents(srcDir, dstDir) {
fs.mkdirSync(dstDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const ent of entries) {
const sp = path.join(srcDir, ent.name);
const dp = path.join(dstDir, ent.name);
if (ent.isDirectory()) {
copyDirContents(sp, dp);
} else if (ent.isFile()) {
fs.copyFileSync(sp, dp);
}
// 软链/特殊文件直接忽略,避免打入压缩包污染
}
}
// 递归收集打包目录下所有相对路径(POSIX 风格,给 zip 用)
function listFilesRel(rootDir) {
const result = [];
function walk(absDir, relDir) {
const entries = fs.readdirSync(absDir, { withFileTypes: true });
for (const ent of entries) {
const abs = path.join(absDir, ent.name);
const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
if (shouldExcludeName(ent.name)) continue;
if (ent.isDirectory()) {
walk(abs, rel);
} else if (ent.isFile()) {
result.push({ abs, rel });
}
}
}
walk(rootDir, '');
return result;
}
// 排除 macOS / Windows 副产物(与原 .sh 保持一致)
function shouldExcludeName(name) {
if (name === '__MACOSX') return true;
if (name === '.DS_Store') return true;
if (name === 'Thumbs.db') return true;
if (name.startsWith('._')) return true;
return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. 手写 ZIPstore 模式 + DEFLATE 模式自动选择,扁平、无目录条目、无外部依赖)
// 采用 ZIP 标准(PKZIP appnote 6.3.x),仅使用 method=0/8、CRC32、本地头/中央目录头/EOCD。
// 不支持 Zip64aipage 单文件不会大到需要 Zip64)。
// ─────────────────────────────────────────────────────────────────────────────
// CRC32(标准多项式 0xEDB88320),构建查表,处理 Buffer
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
// 把 JS Date 转为 DOS 时间/日期
function toDosDateTime(date) {
const year = Math.max(1980, date.getFullYear());
const dosTime =
((date.getHours() & 0x1f) << 11) |
((date.getMinutes() & 0x3f) << 5) |
((Math.floor(date.getSeconds() / 2)) & 0x1f);
const dosDate =
(((year - 1980) & 0x7f) << 9) |
(((date.getMonth() + 1) & 0x0f) << 5) |
(date.getDate() & 0x1f);
return { dosTime, dosDate };
}
/**
* 创建符合 aipage 要求的扁平 zip。
* @param {string} outPath 输出 .aipage 文件路径
* @param {{abs:string, rel:string}[]} files 待打包文件列表,rel 必须是 POSIX 风格相对路径
*/
function buildZip(outPath, files) {
const localChunks = [];
const centralChunks = [];
let offset = 0;
const now = new Date();
const { dosTime, dosDate } = toDosDateTime(now);
for (const f of files) {
const data = fs.readFileSync(f.abs);
const nameBuf = Buffer.from(f.rel, 'utf8');
const crc = crc32(data);
const uncompressedSize = data.length;
// 选择压缩算法:默认 DEFLATE(method=8),但若压缩反而变大则回退到 STORE(method=0
let method = 8;
let compressed = zlib.deflateRawSync(data, { level: 9 });
if (compressed.length >= uncompressedSize) {
method = 0;
compressed = data;
}
const compressedSize = compressed.length;
// ── Local file header (30 bytes + name + extra(0))
const lfh = Buffer.alloc(30);
lfh.writeUInt32LE(0x04034b50, 0); // signature
lfh.writeUInt16LE(20, 4); // version needed
// bit 11: UTF-8 file name; 其它位为 0(无加密、无 data descriptor
lfh.writeUInt16LE(0x0800, 6); // general purpose bit flag
lfh.writeUInt16LE(method, 8); // compression method
lfh.writeUInt16LE(dosTime, 10);
lfh.writeUInt16LE(dosDate, 12);
lfh.writeUInt32LE(crc, 14);
lfh.writeUInt32LE(compressedSize, 18);
lfh.writeUInt32LE(uncompressedSize, 22);
lfh.writeUInt16LE(nameBuf.length, 26);
lfh.writeUInt16LE(0, 28); // extra length
localChunks.push(lfh, nameBuf, compressed);
// ── Central directory header (46 bytes + name + extra(0) + comment(0))
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(0x02014b50, 0); // signature
cdh.writeUInt16LE(20, 4); // version made by
cdh.writeUInt16LE(20, 6); // version needed
cdh.writeUInt16LE(0x0800, 8); // general purpose bit flag
cdh.writeUInt16LE(method, 10); // compression method
cdh.writeUInt16LE(dosTime, 12);
cdh.writeUInt16LE(dosDate, 14);
cdh.writeUInt32LE(crc, 16);
cdh.writeUInt32LE(compressedSize, 20);
cdh.writeUInt32LE(uncompressedSize, 24);
cdh.writeUInt16LE(nameBuf.length, 28);
cdh.writeUInt16LE(0, 30); // extra length
cdh.writeUInt16LE(0, 32); // comment length
cdh.writeUInt16LE(0, 34); // disk number start
cdh.writeUInt16LE(0, 36); // internal file attrs
cdh.writeUInt32LE(0, 38); // external file attrs
cdh.writeUInt32LE(offset, 42); // relative offset of local header
centralChunks.push(cdh, nameBuf);
offset += lfh.length + nameBuf.length + compressed.length;
}
const centralStart = offset;
const centralBuf = Buffer.concat(centralChunks);
const centralSize = centralBuf.length;
// ── End of central directory record
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); // signature
eocd.writeUInt16LE(0, 4); // disk number
eocd.writeUInt16LE(0, 6); // disk with central dir
eocd.writeUInt16LE(files.length, 8); // entries on this disk
eocd.writeUInt16LE(files.length, 10); // total entries
eocd.writeUInt32LE(centralSize, 12); // central dir size
eocd.writeUInt32LE(centralStart, 16); // central dir offset
eocd.writeUInt16LE(0, 20); // comment length
const out = Buffer.concat([Buffer.concat(localChunks), centralBuf, eocd]);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, out);
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. 主流程
// ─────────────────────────────────────────────────────────────────────────────
function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.html && opts.dir) {
process.stderr.write('aipage_pack.js: --html 与 --dir 二选一,不可同时使用\n');
usage(1);
}
if (!opts.html && !opts.dir) {
process.stderr.write('aipage_pack.js: 必须指定 --html 或 --dir\n');
usage(1);
}
// ── 创建临时打包目录 ────────────────────────────────────────────────
const packDir = mkTempDir('aipage_pack_');
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
rmrf(packDir);
};
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
let stem = '';
let entryHtmlInPack = '';
try {
if (opts.html) {
if (!fs.existsSync(opts.html) || !fs.statSync(opts.html).isFile()) {
fail(2, `HTML 不存在: ${opts.html}`);
}
const htmlAbs = path.resolve(opts.html);
const htmlAbsDir = path.dirname(htmlAbs);
const base = path.basename(htmlAbs);
const ext = path.extname(base).toLowerCase();
stem = (ext === '.html' || ext === '.htm') ? base.slice(0, -ext.length) : base;
copyFile(htmlAbs, path.join(packDir, 'index.html'));
const assetsDir = path.join(htmlAbsDir, 'assets');
if (fs.existsSync(assetsDir) && fs.statSync(assetsDir).isDirectory()) {
copyDirContents(assetsDir, path.join(packDir, 'assets'));
}
entryHtmlInPack = path.join(packDir, 'index.html');
} else {
if (!fs.existsSync(opts.dir) || !fs.statSync(opts.dir).isDirectory()) {
fail(2, `目录不存在: ${opts.dir}`);
}
const htmlDirAbs = path.resolve(opts.dir);
// 找入口 HTML:优先 index.html / index.htm,其次唯一 *.html / *.htm
let entry = '';
if (fs.existsSync(path.join(htmlDirAbs, 'index.html'))) {
entry = path.join(htmlDirAbs, 'index.html');
} else if (fs.existsSync(path.join(htmlDirAbs, 'index.htm'))) {
entry = path.join(htmlDirAbs, 'index.htm');
} else {
const candidates = fs.readdirSync(htmlDirAbs)
.filter((n) => /\.html?$/i.test(n))
.map((n) => path.join(htmlDirAbs, n))
.filter((p) => fs.statSync(p).isFile());
if (candidates.length === 1) {
entry = candidates[0];
} else if (candidates.length === 0) {
fail(2, `目录下找不到 HTML 入口: ${htmlDirAbs}`);
} else {
fail(2, `目录下存在多个 HTML,请显式 --html 指定: ${candidates.join(' ')}`);
}
}
stem = path.basename(htmlDirAbs);
copyDirContents(htmlDirAbs, packDir);
const entryName = path.basename(entry);
if (entryName !== 'index.html') {
const src = path.join(packDir, entryName);
const dst = path.join(packDir, 'index.html');
// 入口归一化为 index.html;如果同名 index.html 与入口同名(理论不会到这里)则跳过
if (src !== dst) {
fs.renameSync(src, dst);
}
}
entryHtmlInPack = path.join(packDir, 'index.html');
}
// ── 推导 TITLE ──────────────────────────────────────────────────
let title = opts.title;
if (!title) {
title = extractHtmlTitle(entryHtmlInPack) || stem;
}
// ── 默认 OUT ───────────────────────────────────────────────────
let zipOut = opts.output;
if (!zipOut) {
zipOut = path.join(os.tmpdir(), `${stem}.aipage`);
}
// 后缀强制 .aipage
const lower = zipOut.toLowerCase();
if (lower.endsWith('.aipage')) {
// 保持原样
} else if (lower.endsWith('.page')) {
zipOut = zipOut.slice(0, -'.page'.length) + '.aipage';
} else if (lower.endsWith('.zip')) {
zipOut = zipOut.slice(0, -'.zip'.length) + '.aipage';
} else {
zipOut = zipOut + '.aipage';
}
fs.mkdirSync(path.dirname(zipOut), { recursive: true });
if (fs.existsSync(zipOut)) {
fs.unlinkSync(zipOut);
}
// ── 生成 manifest.json ────────────────────────────────────────
const manifest = { entry: 'index.html', title, version: '1.0' };
fs.writeFileSync(
path.join(packDir, 'manifest.json'),
JSON.stringify(manifest, null, 2),
'utf8',
);
// ── 生成 janus.manifest.json(固定内容)─────────────────────
fs.writeFileSync(
path.join(packDir, 'janus.manifest.json'),
'{"version":"1.0.0","render_engine":"native","scene":""}',
'utf8',
);
// ── 校验 ─────────────────────────────────────────────────────
if (!fs.existsSync(path.join(packDir, 'index.html'))) {
fail(3, 'missing index.html');
}
if (!fs.existsSync(path.join(packDir, 'manifest.json'))) {
fail(3, 'missing manifest.json');
}
try {
JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
} catch (_) {
fail(3, 'manifest.json 不是合法 JSON');
}
// ── 打包(扁平化)────────────────────────────────────────────
const files = listFilesRel(packDir);
if (files.length === 0) {
fail(3, '打包目录为空');
}
buildZip(zipOut, files);
if (!fs.existsSync(zipOut)) {
fail(3, `zip 没产出: ${zipOut}`);
}
const size = sizeOfFile(zipOut);
const md5 = md5OfFile(zipOut);
// ── 结构化输出(与 .sh 完全一致)────────────────────────────
process.stdout.write(`AIPAGE_PATH=${zipOut}\n`);
process.stdout.write(`AIPAGE_SIZE=${size}\n`);
process.stdout.write(`AIPAGE_MD5=${md5}\n`);
process.stdout.write(`AIPAGE_TITLE=${title}\n`);
} finally {
cleanup();
}
}
main();