build.js 8.6 KB

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