build.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  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. 'engine', // 执行引擎桥接:界面与 tiktok-auto 之间的文件通道
  43. 'session',
  44. 'widgets',
  45. 'dialog',
  46. 'floatball', // 全局悬浮球:用 floaty 开窗,只依赖 theme/store/device/session
  47. 'page-id',
  48. 'page-dm', // 同时含 UID私信页与养号页(养号只有一张卡,单开文件太碎)
  49. 'page-video',
  50. 'page-tasks', // 任务列表(一级页,四张任务卡)
  51. 'page-records', // 运行记录(一级页 + 运行详情二级页)
  52. 'page-login',
  53. 'page-mine',
  54. 'main'
  55. ];
  56. function readModule(name) {
  57. const file = path.join(SRC_DIR, `${name}.js`);
  58. if (!fs.existsSync(file)) {
  59. throw new Error(`缺少模块文件:${file}`);
  60. }
  61. return fs.readFileSync(file, 'utf8');
  62. }
  63. function moduleBlock(name) {
  64. return [
  65. '',
  66. `/* ============================== src/${name}.js ============================== */`,
  67. readModule(name)
  68. ].join('\n');
  69. }
  70. function build() {
  71. const header = [
  72. `/*! tiktok-app v${VERSION}`,
  73. ` * built ${new Date().toISOString()}`,
  74. ` * modules: ${MODULE_ORDER.join(', ')}`,
  75. ' * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。',
  76. ' */'
  77. ].join('\n');
  78. const body = MODULE_ORDER.map(moduleBlock).join('\n');
  79. const source = [
  80. '"ui";',
  81. header,
  82. '(function () {',
  83. body,
  84. '',
  85. '/* 入口 */',
  86. 'App.main();',
  87. '}());',
  88. ''
  89. ].join('\n');
  90. assertUiDirective(source);
  91. assertSyntax(source);
  92. assertNoLeftoverMarkers(source);
  93. return { source, modules: MODULE_ORDER };
  94. }
  95. /** "ui" 指令必须是第一条语句,否则脚本会以非 UI 模式执行。 */
  96. function assertUiDirective(source) {
  97. const firstLine = source.split('\n')[0].trim();
  98. if (firstLine !== '"ui";') {
  99. throw new Error(`"ui"; 指令必须位于第一行,当前第一行是:${firstLine}`);
  100. }
  101. }
  102. function assertSyntax(source) {
  103. try {
  104. // 只为语法检查,不执行
  105. new Function(source);
  106. } catch (error) {
  107. throw new Error(`构建产物语法检查失败:${error.message}`);
  108. }
  109. }
  110. /** 防止半成品被发到设备上:占位符和调试残留在这里挡住。 */
  111. function assertNoLeftoverMarkers(source) {
  112. const offenders = [];
  113. if (/\bTODO\b/.test(source)) { offenders.push('TODO'); }
  114. if (/\bFIXME\b/.test(source)) { offenders.push('FIXME'); }
  115. if (/debugger\s*;/.test(source)) { offenders.push('debugger'); }
  116. if (offenders.length > 0) {
  117. throw new Error('构建产物含未完成标记:' + offenders.join(', '));
  118. }
  119. }
  120. /**
  121. * 项目入口检查(本界面是**整个 AutoX 项目的启动入口**)。
  122. *
  123. * 为什么要在这里查:AutoX 的 UI 模式由**入口文件**的 `"ui";` 开启,
  124. * 而被 require 的模块里写 `"ui";` 是无效的(只是一句字符串语句)。
  125. * 也就是说,**入口文件的第一行写错,界面就会以非 UI 模式执行**,
  126. * 在设备上报一堆不着边际的错 —— 而那种错从日志上完全看不出根因。
  127. * 这里在 PC 侧就把它挡住。
  128. *
  129. * 检查三件事:project.json 的 main 指向的文件存在、第一行是 `"ui";`、
  130. * 并且它确实 require 了本界面产物。
  131. */
  132. function assertProjectEntry() {
  133. const projectPath = path.join(PROJECT_ROOT, 'project.json');
  134. const rootDir = PROJECT_ROOT;
  135. if (!fs.existsSync(projectPath)) {
  136. throw new Error(`找不到项目清单 ${projectPath}`);
  137. }
  138. const project = JSON.parse(fs.readFileSync(projectPath, 'utf8'));
  139. const entryRel = String(project.main || '').replace(/^\.\//, '');
  140. if (!entryRel) {
  141. throw new Error('project.json 里没有 "main" 字段,AutoX 不知道从哪启动');
  142. }
  143. const entryPath = path.join(rootDir, entryRel);
  144. if (!fs.existsSync(entryPath)) {
  145. throw new Error(`project.json 的 main 指向的文件不存在:${entryRel}`);
  146. }
  147. const entrySource = fs.readFileSync(entryPath, 'utf8');
  148. const firstLine = entrySource.split('\n')[0].trim();
  149. if (firstLine !== '"ui";') {
  150. throw new Error(
  151. `入口 ${entryRel} 的第一行必须是 "ui";(当前是 ${JSON.stringify(firstLine)})。` +
  152. 'UI 模式由入口文件开启,写在被 require 的模块里无效。');
  153. }
  154. if (entrySource.indexOf('tiktok-app/dist/tiktok-app.js') < 0) {
  155. throw new Error(`入口 ${entryRel} 看起来没有加载界面产物(未找到 tiktok-app/dist/tiktok-app.js)`);
  156. }
  157. return entryRel;
  158. }
  159. function writeDist(source) {
  160. fs.mkdirSync(DIST_DIR, { recursive: true });
  161. fs.writeFileSync(OUT_FILE, source, 'utf8');
  162. }
  163. function parseArgs(argv) {
  164. const options = { quiet: false };
  165. for (let i = 0; i < argv.length; i += 1) {
  166. if (argv[i] === '--version' && argv[i + 1]) {
  167. VERSION = argv[i + 1];
  168. i += 1;
  169. } else if (argv[i] === '--quiet') {
  170. options.quiet = true;
  171. }
  172. }
  173. return options;
  174. }
  175. function main() {
  176. const options = parseArgs(process.argv.slice(2));
  177. const { source, modules } = build();
  178. /* 把版本号注入到 Service.SCRIPT_VERSION,让界面显示的就是构建版本。
  179. * 注意判据必须是「正则能否匹配」,不能比较替换前后的字符串——
  180. * 当 --version 恰好等于源码里的占位值(首版就是这种情况)时,
  181. * 替换前后内容完全相同,用 !== 判定会误报注入失败。 */
  182. const versionPattern = new RegExp(`Service\\.SCRIPT_VERSION = "${INJECT_MARKER}"`);
  183. if (!versionPattern.test(source)) {
  184. throw new Error('版本注入失败:src/service.js 里的 SCRIPT_VERSION 占位符没找到');
  185. }
  186. const withVersion = source.replace(versionPattern, `Service.SCRIPT_VERSION = "${VERSION}"`);
  187. /* 入口检查放在写盘**之前**:检查不过就不该留下一个新产物 */
  188. const entryRel = assertProjectEntry();
  189. writeDist(withVersion);
  190. if (!options.quiet) {
  191. console.log(`已构建 ${OUT_FILE}`);
  192. console.log(` 版本 ${VERSION}`);
  193. console.log(` 模块 ${modules.length} 个:${modules.join(' → ')}`);
  194. console.log(` 体积 ${Buffer.byteLength(withVersion, 'utf8')} 字节`);
  195. console.log(' 检查:ui 指令位置通过;语法通过;无未完成标记');
  196. console.log(` 入口:project.json → ${entryRel}(第一行 "ui"; —— 界面为启动入口)`);
  197. }
  198. }
  199. module.exports = { build, writeDist, MODULE_ORDER, OUT_FILE, VERSION };
  200. if (require.main === module) {
  201. main();
  202. }