#!/usr/bin/env node /** * build.js — 构建流水线:src/ 多个小模块 → dist/ 一个可下发到 AutoX 的单文件脚本 * * 为什么需要构建(Zakas《编写可维护的JavaScript》第 13/15 章): * 人维护代码时希望**一个文件只做一件事**(所以 src/ 拆成 11 个模块); * 但 AutoX 通过 MCP 下发的是"一整段脚本",没法带一堆 require 依赖。 * 于是中间加一步 concat:开发时按模块读,部署时是一个自包含文件。 * 两边都拿到自己最舒服的形态。 * * 构建时会做两道自检: * 1. 语法检查(new Function)——避免把语法错误的脚本发到手机上才发现; * 2. **MCP 危险调用黑名单预检**——桥那边一旦命中就会要求人工确认, * 这属于"部署期的坑",应该在构建期就挡住,而不是每次运行都卡一下。 * * 用法: * node tools/build.js # 构建 dist/tiktok-auto.js * node tools/build.js --self-test # 构建只跑自检的变体 * node tools/build.js --inject '{"target":{"keyword":"cat"}}' */ 'use strict'; const fs = require('fs'); const path = require('path'); const ROOT = path.join(__dirname, '..'); const SRC_DIR = path.join(ROOT, 'src'); const DIST_DIR = path.join(ROOT, 'dist'); const OUT_FILE = path.join(DIST_DIR, 'tiktok-auto.js'); const VERSION = '1.0.0'; /** * 模块顺序 = 依赖顺序。 * bridge 不依赖任何模块(logger 是运行时取用),但它要被 wait 引用,所以排在 wait 之前; * tiktok.js 在**加载时**就读取 layout/logger,所以它必须排在它们之后; * 其余模块只在函数内部按名字取用,因此对顺序不敏感。 */ const MODULE_ORDER = [ 'logger', 'bridge', 'config', 'wait', 'layout', 'ui', 'popups', 'tiktok', 'player', 'comment', 'flow', /* —— ID 提取(task = 'id')—— * 顺序按依赖走:parse 是纯函数地基;uidset/filters 只依赖 parse; * profile 调 tiktok 与 flow;idsource 调 profile;idflow 调全部。 * 这些模块**都不在加载时取别的模块的引用**(一律在函数体内按名字取), * 所以这里的顺序主要是给人读的,不满足也不会炸 —— 但仍然按依赖排。 */ 'parse', 'uidset', 'filters', 'profile', 'idsource', 'idflow' ]; const ENTRY_MODULE = 'main'; const SELFTEST_MODULE = '__selftest'; const INJECT_MARKER = '/*TTA_INJECTED_CONFIG*/null'; /** * 与 MCP 桥一致的"危险调用"规则。 * 匹配前先按驼峰拆词,否则 smsManager 这类标识符会因为缺少词边界而漏报。 * 正则**不能带 g** —— 带 g 的 RegExp.test 会保留 lastIndex,重复调用结果会交替翻转。 */ const DANGEROUS_RULES = [ { name: 'sms', pattern: /\bsms\b/i }, { name: 'telephony', pattern: /\btelephony\b/i }, { name: 'call(', pattern: /\bcall\s*\(/i }, { name: 'deleteFile', pattern: /\bdelete\s+file\b/i }, { name: 'remove(', pattern: /\bremove\s*\(/i } ]; function splitIdentifiers(code) { return code .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); } function readModule(name) { const file = path.join(SRC_DIR, `${name}.js`); if (!fs.existsSync(file)) { throw new Error(`缺少模块文件:${file}`); } return fs.readFileSync(file, 'utf8'); } function moduleBlock(name) { return [ '', `/* ============================== src/${name}.js ============================== */`, readModule(name) ].join('\n'); } function buildEntry(options) { if (options.customEntry) { return ['', '/* 自定义入口(调试/探针用) */', options.customEntry].join('\n'); } if (options.selfTest) { return [ '', '/* 自检模式:只跑纯函数断言,不启动 App、不发评论 */', 'var failedCount = TikTokAuto.selftest.report();', 'console.log(failedCount === 0 ? "[TTA]SELFTEST_ALL_PASS" : "[TTA]SELFTEST_HAS_FAILURE");', 'console.log("__END__");' ].join('\n'); } return [ '', '/* PC 侧下发时可替换下面这一行的 null 来注入配置覆盖项 */', `var INJECTED_CONFIG = ${INJECT_MARKER};`, '/* 被界面宿主唤起时(engines.execScriptFile 带 --config/--run-id 参数),', ' * fromExecArgv 会把参数翻译成「配置路径 + 桥的开关」;独立运行时是空操作。 */', 'TikTokAuto.main.run(TikTokAuto.bridge.fromExecArgv(INJECTED_CONFIG));' ].join('\n'); } function build(options = {}) { const modules = options.selfTest ? MODULE_ORDER.concat([SELFTEST_MODULE]) : MODULE_ORDER.concat([ENTRY_MODULE]); const header = [ `/*! tiktok-auto v${VERSION}`, ` * built ${new Date().toISOString()}`, ` * modules: ${modules.join(', ')}`, ' * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。', ' */' ].join('\n'); const body = modules.map(moduleBlock).join('\n'); const source = [header, '(function () {', 'var TikTokAuto = {};', body, buildEntry(options), '}());', ''].join('\n'); assertSyntax(source); assertNoDangerousCalls(source); return { source, modules }; } function assertSyntax(source) { try { // 只为语法检查,不执行 new Function(source); } catch (error) { throw new Error(`构建产物语法检查失败:${error.message}`); } } function assertNoDangerousCalls(source) { const flattened = splitIdentifiers(source); const hits = DANGEROUS_RULES.filter((rule) => rule.pattern.test(flattened)); if (hits.length > 0) { throw new Error( '构建产物命中 MCP 危险调用黑名单:' + hits.map((h) => h.name).join(', ') + '。这会让每次下发都要求人工确认,请在 src/ 里改写这些调用。' ); } } function writeDist(source) { fs.mkdirSync(DIST_DIR, { recursive: true }); fs.writeFileSync(OUT_FILE, source, 'utf8'); } function parseArgs(argv) { const options = { selfTest: false, inject: null, quiet: false }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--self-test') { options.selfTest = true; } else if (arg === '--inject') { options.inject = JSON.parse(argv[i + 1]); i += 1; } else if (arg === '--quiet') { options.quiet = true; } } return options; } function main() { const options = parseArgs(process.argv.slice(2)); const { source, modules } = build(options); if (options.inject) { const injected = JSON.stringify(options.inject); if (!source.includes(INJECT_MARKER)) { throw new Error('找不到注入占位符,构建产物异常'); } writeDist(source.replace(INJECT_MARKER, `/*TTA_INJECTED_CONFIG*/${injected}`)); } else { writeDist(source); } if (!options.quiet) { console.log(`已构建 ${OUT_FILE}`); console.log(` 模块 ${modules.length} 个:${modules.join(' → ')}`); console.log(` 体积 ${Buffer.byteLength(source, 'utf8')} 字节`); console.log(' 语法检查:通过;危险调用预检:通过'); } } module.exports = { ROOT, OUT_FILE, MODULE_ORDER, INJECT_MARKER, build, writeDist, parseArgs, main }; if (require.main === module) { main(); }