#!/usr/bin/env node /** * build.js — 构建流水线:src/ 多个小模块 → dist/ 一个可在 AutoX 里跑的单文件 * * 与 tiktok-auto 的构建思路一致(Zakas ch13/15):开发时一个文件只做一件事, * 部署时是一个自包含文件——AutoX 拿到的是"一整段脚本",带不了一堆 require。 * * 两道自检: * 1. 语法检查(new Function)——设备端没有 linter,PC 侧不查就是把语法错误发到手机上才发现; * 2. **"ui" 指令位置检查**——UI 模式要求 "ui"; 出现在脚本最前面, * 一旦被注释放到后面,界面会以非 UI 模式执行,报一堆不着边际的错。 * * 用法: * node tools/build.js * node tools/build.js --version 2026.10.11 */ 'use strict'; const fs = require('fs'); const path = require('path'); const ROOT = path.join(__dirname, '..'); const PROJECT_ROOT = path.join(ROOT, '..'); /* AutoX 项目根目录(project.json 所在处) */ const SRC_DIR = path.join(ROOT, 'src'); const DIST_DIR = path.join(ROOT, 'dist'); const OUT_FILE = path.join(DIST_DIR, 'tiktok-app.js'); let VERSION = '2026.10.13'; const INJECT_MARKER = '2026.10.13'; /** * 模块顺序 = 依赖顺序。 * theme 定义 U(几乎所有模块都用它),store 定义 Store(widgets/service 用), * records 定义运行记录(任务卡与记录页在**顶层**就要读它), * device/service 定义数据源,session 定义运行态(页面与 main 都用), * widgets 定义组件(页面用),pages 定义 Pages(main 用)——main 必须最后。 */ const MODULE_ORDER = [ 'theme', 'store', 'records', // 运行记录:任务卡/记录页/结果文件都读它 'sources', // ID提取的取材清单:不定长列表,与 records 同样走独立 storage key 'device', 'service', 'fingerprint', // 配置指纹:与 tiktok-auto/src/fingerprint.js 同算法(断点续跑用) 'engine', // 执行引擎桥接:界面与 tiktok-auto 之间的文件通道 'session', 'resume', // 断点续跑:读断点/判断能否续/清空(只读引擎写的进度文件) 'widgets', 'dialog', 'floatball', // 全局悬浮球:用 floaty 开窗,只依赖 theme/store/device/session 'page-id', 'page-dm', // 同时含 UID私信页与养号页(养号只有一张卡,单开文件太碎) 'page-video', 'page-tasks', // 任务列表(一级页,四张任务卡) 'page-records', // 运行记录(一级页 + 运行详情二级页) 'page-login', 'page-mine', 'main' ]; 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 build() { const header = [ `/*! tiktok-app v${VERSION}`, ` * built ${new Date().toISOString()}`, ` * modules: ${MODULE_ORDER.join(', ')}`, ' * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。', ' */' ].join('\n'); const body = MODULE_ORDER.map(moduleBlock).join('\n'); const source = [ '"ui";', header, '(function () {', body, '', '/* 入口 */', 'App.main();', '}());', '' ].join('\n'); assertUiDirective(source); assertSyntax(source); assertNoLeftoverMarkers(source); return { source, modules: MODULE_ORDER }; } /** "ui" 指令必须是第一条语句,否则脚本会以非 UI 模式执行。 */ function assertUiDirective(source) { const firstLine = source.split('\n')[0].trim(); if (firstLine !== '"ui";') { throw new Error(`"ui"; 指令必须位于第一行,当前第一行是:${firstLine}`); } } function assertSyntax(source) { try { // 只为语法检查,不执行 new Function(source); } catch (error) { throw new Error(`构建产物语法检查失败:${error.message}`); } } /** 防止半成品被发到设备上:占位符和调试残留在这里挡住。 */ function assertNoLeftoverMarkers(source) { const offenders = []; if (/\bTODO\b/.test(source)) { offenders.push('TODO'); } if (/\bFIXME\b/.test(source)) { offenders.push('FIXME'); } if (/debugger\s*;/.test(source)) { offenders.push('debugger'); } if (offenders.length > 0) { throw new Error('构建产物含未完成标记:' + offenders.join(', ')); } } /** * 项目入口检查(本界面是**整个 AutoX 项目的启动入口**)。 * * 为什么要在这里查:AutoX 的 UI 模式由**入口文件**的 `"ui";` 开启, * 而被 require 的模块里写 `"ui";` 是无效的(只是一句字符串语句)。 * 也就是说,**入口文件的第一行写错,界面就会以非 UI 模式执行**, * 在设备上报一堆不着边际的错 —— 而那种错从日志上完全看不出根因。 * 这里在 PC 侧就把它挡住。 * * 检查三件事:project.json 的 main 指向的文件存在、第一行是 `"ui";`、 * 并且它确实 require 了本界面产物。 */ function assertProjectEntry() { const projectPath = path.join(PROJECT_ROOT, 'project.json'); const rootDir = PROJECT_ROOT; if (!fs.existsSync(projectPath)) { throw new Error(`找不到项目清单 ${projectPath}`); } const project = JSON.parse(fs.readFileSync(projectPath, 'utf8')); const entryRel = String(project.main || '').replace(/^\.\//, ''); if (!entryRel) { throw new Error('project.json 里没有 "main" 字段,AutoX 不知道从哪启动'); } const entryPath = path.join(rootDir, entryRel); if (!fs.existsSync(entryPath)) { throw new Error(`project.json 的 main 指向的文件不存在:${entryRel}`); } const entrySource = fs.readFileSync(entryPath, 'utf8'); const firstLine = entrySource.split('\n')[0].trim(); if (firstLine !== '"ui";') { throw new Error( `入口 ${entryRel} 的第一行必须是 "ui";(当前是 ${JSON.stringify(firstLine)})。` + 'UI 模式由入口文件开启,写在被 require 的模块里无效。'); } if (entrySource.indexOf('tiktok-app/dist/tiktok-app.js') < 0) { throw new Error(`入口 ${entryRel} 看起来没有加载界面产物(未找到 tiktok-app/dist/tiktok-app.js)`); } return entryRel; } function writeDist(source) { fs.mkdirSync(DIST_DIR, { recursive: true }); fs.writeFileSync(OUT_FILE, source, 'utf8'); } function parseArgs(argv) { const options = { quiet: false }; for (let i = 0; i < argv.length; i += 1) { if (argv[i] === '--version' && argv[i + 1]) { VERSION = argv[i + 1]; i += 1; } else if (argv[i] === '--quiet') { options.quiet = true; } } return options; } function main() { const options = parseArgs(process.argv.slice(2)); const { source, modules } = build(); /* 把版本号注入到 Service.SCRIPT_VERSION,让界面显示的就是构建版本。 * 注意判据必须是「正则能否匹配」,不能比较替换前后的字符串—— * 当 --version 恰好等于源码里的占位值(首版就是这种情况)时, * 替换前后内容完全相同,用 !== 判定会误报注入失败。 */ const versionPattern = new RegExp(`Service\\.SCRIPT_VERSION = "${INJECT_MARKER}"`); if (!versionPattern.test(source)) { throw new Error('版本注入失败:src/service.js 里的 SCRIPT_VERSION 占位符没找到'); } const withVersion = source.replace(versionPattern, `Service.SCRIPT_VERSION = "${VERSION}"`); /* 入口检查放在写盘**之前**:检查不过就不该留下一个新产物 */ const entryRel = assertProjectEntry(); writeDist(withVersion); if (!options.quiet) { console.log(`已构建 ${OUT_FILE}`); console.log(` 版本 ${VERSION}`); console.log(` 模块 ${modules.length} 个:${modules.join(' → ')}`); console.log(` 体积 ${Buffer.byteLength(withVersion, 'utf8')} 字节`); console.log(' 检查:ui 指令位置通过;语法通过;无未完成标记'); console.log(` 入口:project.json → ${entryRel}(第一行 "ui"; —— 界面为启动入口)`); } } module.exports = { build, writeDist, MODULE_ORDER, OUT_FILE, VERSION }; if (require.main === module) { main(); }