build.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. #!/usr/bin/env node
  2. /**
  3. * build.js — 构建流水线:src/ 多个小模块 → dist/ 一个可在 AutoX 里跑的单文件
  4. *
  5. * 与 tiktok-auto 的构建思路一致(Zakas ch13/15):开发时一个文件只做一件事,
  6. * 部署时是一个自包含文件——AutoX 拿到的是"一整段脚本",带不了一堆 require。
  7. *
  8. * 两道自检:
  9. * 1. 语法检查(new Function)——设备端没有 linter,PC 侧不查就是把语法错误发到手机上才发现;
  10. * 2. **"ui" 指令位置检查**——UI 模式要求 "ui"; 出现在脚本最前面,
  11. * 一旦被注释放到后面,界面会以非 UI 模式执行,报一堆不着边际的错。
  12. *
  13. * 用法:
  14. * node tools/build.js
  15. * node tools/build.js --version 2026.10.11
  16. */
  17. 'use strict';
  18. const fs = require('fs');
  19. const path = require('path');
  20. const ROOT = path.join(__dirname, '..');
  21. const PROJECT_ROOT = path.join(ROOT, '..'); /* AutoX 项目根目录(project.json 所在处) */
  22. const SRC_DIR = path.join(ROOT, 'src');
  23. const DIST_DIR = path.join(ROOT, 'dist');
  24. const OUT_FILE = path.join(DIST_DIR, 'tiktok-app.js');
  25. let VERSION = '2026.10.13';
  26. const INJECT_MARKER = '2026.10.13';
  27. /**
  28. * 模块顺序 = 依赖顺序。
  29. * theme 定义 U(几乎所有模块都用它),store 定义 Store(widgets/service 用),
  30. * records 定义运行记录(任务卡与记录页在**顶层**就要读它),
  31. * device/service 定义数据源,session 定义运行态(页面与 main 都用),
  32. * widgets 定义组件(页面用),pages 定义 Pages(main 用)——main 必须最后。
  33. */
  34. const MODULE_ORDER = [
  35. 'theme',
  36. 'store',
  37. 'records', // 运行记录:任务卡/记录页/结果文件都读它
  38. 'sources', // ID提取的取材清单:不定长列表,与 records 同样走独立 storage key
  39. 'device',
  40. 'service',
  41. 'fingerprint', // 配置指纹:与 tiktok-auto/src/fingerprint.js 同算法(断点续跑用)
  42. 'protocol', // 运行期协议:状态枚举/迁移表/阈值,引擎侧有一份同样的拷贝(见 tools/protocol-check.js)
  43. 'engine', // 执行引擎桥接:界面与 tiktok-auto 之间的文件通道
  44. 'session',
  45. 'resume', // 断点续跑:读断点/判断能否续/清空(只读引擎写的进度文件)
  46. 'widgets',
  47. 'dialog',
  48. 'floatball', // 全局悬浮球:用 floaty 开窗,只依赖 theme/store/device/session
  49. 'page-id',
  50. 'page-dm', // 同时含 UID私信页与养号页(养号只有一张卡,单开文件太碎)
  51. 'page-video',
  52. 'page-tasks', // 任务列表(一级页,四张任务卡)
  53. 'page-records', // 运行记录(一级页 + 运行详情二级页)
  54. 'page-login',
  55. 'page-mine',
  56. 'main'
  57. ];
  58. function readModule(name) {
  59. const file = path.join(SRC_DIR, `${name}.js`);
  60. if (!fs.existsSync(file)) {
  61. throw new Error(`缺少模块文件:${file}`);
  62. }
  63. return fs.readFileSync(file, 'utf8');
  64. }
  65. function moduleBlock(name) {
  66. return [
  67. '',
  68. `/* ============================== src/${name}.js ============================== */`,
  69. readModule(name)
  70. ].join('\n');
  71. }
  72. function build() {
  73. const header = [
  74. `/*! tiktok-app v${VERSION}`,
  75. ` * built ${new Date().toISOString()}`,
  76. ` * modules: ${MODULE_ORDER.join(', ')}`,
  77. ' * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。',
  78. ' */'
  79. ].join('\n');
  80. const body = MODULE_ORDER.map(moduleBlock).join('\n');
  81. const source = [
  82. '"ui";',
  83. header,
  84. '(function () {',
  85. body,
  86. '',
  87. '/* 入口 */',
  88. 'App.main();',
  89. '}());',
  90. ''
  91. ].join('\n');
  92. assertUiDirective(source);
  93. assertSyntax(source);
  94. assertNoLeftoverMarkers(source);
  95. assertProtocolPresent(source);
  96. return { source, modules: MODULE_ORDER };
  97. }
  98. /** "ui" 指令必须是第一条语句,否则脚本会以非 UI 模式执行。 */
  99. function assertUiDirective(source) {
  100. const firstLine = source.split('\n')[0].trim();
  101. if (firstLine !== '"ui";') {
  102. throw new Error(`"ui"; 指令必须位于第一行,当前第一行是:${firstLine}`);
  103. }
  104. }
  105. function assertSyntax(source) {
  106. try {
  107. // 只为语法检查,不执行
  108. new Function(source);
  109. } catch (error) {
  110. throw new Error(`构建产物语法检查失败:${error.message}`);
  111. }
  112. }
  113. /** 防止半成品被发到设备上:占位符和调试残留在这里挡住。 */
  114. function assertNoLeftoverMarkers(source) {
  115. const offenders = [];
  116. if (/\bTODO\b/.test(source)) { offenders.push('TODO'); }
  117. if (/\bFIXME\b/.test(source)) { offenders.push('FIXME'); }
  118. if (/debugger\s*;/.test(source)) { offenders.push('debugger'); }
  119. if (offenders.length > 0) {
  120. throw new Error('构建产物含未完成标记:' + offenders.join(', '));
  121. }
  122. }
  123. /**
  124. * 协议模块必须真的进了产物。
  125. *
  126. * ★ 这是补一个**静默失败**:`build()` 只遍历 `MODULE_ORDER`,
  127. * 新建 `src/protocol.js` 却忘了加进清单时,构建会**成功**,
  128. * 产物里根本没有它,直到运行期 `Protocol is not defined` 才炸。
  129. * 同理,忘了往 `__FEIZHOU__` 白名单里加,`smoke.js` 拿到的是 undefined,
  130. * 那一整批协议断言会被"跳过"而不是失败 —— 这里一起挡住。
  131. */
  132. function assertProtocolPresent(source) {
  133. /* 挂载行就是"这个文件被拼进来了"的证据:产物里没有它,
  134. * 说明 MODULE_ORDER 漏了 protocol,或者文件被改名了 —— 两种都要当场拦。 */
  135. if (source.indexOf('var Protocol = (function () {') < 0) {
  136. throw new Error(
  137. '构建产物里没有 Protocol 模块:src/protocol.js 没进 MODULE_ORDER?'
  138. );
  139. }
  140. if (source.indexOf('Protocol: Protocol') < 0) {
  141. throw new Error(
  142. '构建产物里 __FEIZHOU__ 没有暴露 Protocol:PC 自检将无法断言协议模块'
  143. );
  144. }
  145. }
  146. /**
  147. * 项目入口检查(本界面是**整个 AutoX 项目的启动入口**)。
  148. *
  149. * 为什么要在这里查:AutoX 的 UI 模式由**入口文件**的 `"ui";` 开启,
  150. * 而被 require 的模块里写 `"ui";` 是无效的(只是一句字符串语句)。
  151. * 也就是说,**入口文件的第一行写错,界面就会以非 UI 模式执行**,
  152. * 在设备上报一堆不着边际的错 —— 而那种错从日志上完全看不出根因。
  153. * 这里在 PC 侧就把它挡住。
  154. *
  155. * 检查三件事:project.json 的 main 指向的文件存在、第一行是 `"ui";`、
  156. * 并且它确实 require 了本界面产物。
  157. */
  158. function assertProjectEntry() {
  159. const projectPath = path.join(PROJECT_ROOT, 'project.json');
  160. const rootDir = PROJECT_ROOT;
  161. if (!fs.existsSync(projectPath)) {
  162. throw new Error(`找不到项目清单 ${projectPath}`);
  163. }
  164. const project = JSON.parse(fs.readFileSync(projectPath, 'utf8'));
  165. const entryRel = String(project.main || '').replace(/^\.\//, '');
  166. if (!entryRel) {
  167. throw new Error('project.json 里没有 "main" 字段,AutoX 不知道从哪启动');
  168. }
  169. const entryPath = path.join(rootDir, entryRel);
  170. if (!fs.existsSync(entryPath)) {
  171. throw new Error(`project.json 的 main 指向的文件不存在:${entryRel}`);
  172. }
  173. const entrySource = fs.readFileSync(entryPath, 'utf8');
  174. const firstLine = entrySource.split('\n')[0].trim();
  175. if (firstLine !== '"ui";') {
  176. throw new Error(
  177. `入口 ${entryRel} 的第一行必须是 "ui";(当前是 ${JSON.stringify(firstLine)})。` +
  178. 'UI 模式由入口文件开启,写在被 require 的模块里无效。');
  179. }
  180. if (entrySource.indexOf('tiktok-app/dist/tiktok-app.js') < 0) {
  181. throw new Error(`入口 ${entryRel} 看起来没有加载界面产物(未找到 tiktok-app/dist/tiktok-app.js)`);
  182. }
  183. return entryRel;
  184. }
  185. function writeDist(source) {
  186. fs.mkdirSync(DIST_DIR, { recursive: true });
  187. fs.writeFileSync(OUT_FILE, source, 'utf8');
  188. }
  189. function parseArgs(argv) {
  190. const options = { quiet: false };
  191. for (let i = 0; i < argv.length; i += 1) {
  192. if (argv[i] === '--version' && argv[i + 1]) {
  193. VERSION = argv[i + 1];
  194. i += 1;
  195. } else if (argv[i] === '--quiet') {
  196. options.quiet = true;
  197. }
  198. }
  199. return options;
  200. }
  201. function main() {
  202. const options = parseArgs(process.argv.slice(2));
  203. const { source, modules } = build();
  204. /* 把版本号注入到 Service.SCRIPT_VERSION,让界面显示的就是构建版本。
  205. * 注意判据必须是「正则能否匹配」,不能比较替换前后的字符串——
  206. * 当 --version 恰好等于源码里的占位值(首版就是这种情况)时,
  207. * 替换前后内容完全相同,用 !== 判定会误报注入失败。 */
  208. const versionPattern = new RegExp(`Service\\.SCRIPT_VERSION = "${INJECT_MARKER}"`);
  209. if (!versionPattern.test(source)) {
  210. throw new Error('版本注入失败:src/service.js 里的 SCRIPT_VERSION 占位符没找到');
  211. }
  212. const withVersion = source.replace(versionPattern, `Service.SCRIPT_VERSION = "${VERSION}"`);
  213. /* 入口检查放在写盘**之前**:检查不过就不该留下一个新产物 */
  214. const entryRel = assertProjectEntry();
  215. writeDist(withVersion);
  216. if (!options.quiet) {
  217. console.log(`已构建 ${OUT_FILE}`);
  218. console.log(` 版本 ${VERSION}`);
  219. console.log(` 模块 ${modules.length} 个:${modules.join(' → ')}`);
  220. console.log(` 体积 ${Buffer.byteLength(withVersion, 'utf8')} 字节`);
  221. console.log(' 检查:ui 指令位置通过;语法通过;无未完成标记');
  222. console.log(` 入口:project.json → ${entryRel}(第一行 "ui"; —— 界面为启动入口)`);
  223. }
  224. }
  225. module.exports = { build, writeDist, MODULE_ORDER, OUT_FILE, VERSION };
  226. if (require.main === module) {
  227. main();
  228. }