build.js 8.4 KB

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