build.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. #!/usr/bin/env node
  2. /**
  3. * build.js — 构建流水线:src/ 多个小模块 → dist/ 一个可下发到 AutoX 的单文件脚本
  4. *
  5. * 为什么需要构建(Zakas《编写可维护的JavaScript》第 13/15 章):
  6. * 人维护代码时希望**一个文件只做一件事**(所以 src/ 拆成 11 个模块);
  7. * 但 AutoX 通过 MCP 下发的是"一整段脚本",没法带一堆 require 依赖。
  8. * 于是中间加一步 concat:开发时按模块读,部署时是一个自包含文件。
  9. * 两边都拿到自己最舒服的形态。
  10. *
  11. * 构建时会做两道自检:
  12. * 1. 语法检查(new Function)——避免把语法错误的脚本发到手机上才发现;
  13. * 2. **MCP 危险调用黑名单预检**——桥那边一旦命中就会要求人工确认,
  14. * 这属于"部署期的坑",应该在构建期就挡住,而不是每次运行都卡一下。
  15. *
  16. * 用法:
  17. * node tools/build.js # 构建 dist/tiktok-auto.js
  18. * node tools/build.js --self-test # 构建只跑自检的变体
  19. * node tools/build.js --inject '{"target":{"keyword":"cat"}}'
  20. */
  21. 'use strict';
  22. const fs = require('fs');
  23. const path = require('path');
  24. const ROOT = path.join(__dirname, '..');
  25. const SRC_DIR = path.join(ROOT, 'src');
  26. const DIST_DIR = path.join(ROOT, 'dist');
  27. const OUT_FILE = path.join(DIST_DIR, 'tiktok-auto.js');
  28. const VERSION = '1.0.0';
  29. /**
  30. * 模块顺序 = 依赖顺序。
  31. * bridge 不依赖任何模块(logger 是运行时取用),但它要被 wait 引用,所以排在 wait 之前;
  32. * tiktok.js 在**加载时**就读取 layout/logger,所以它必须排在它们之后;
  33. * 其余模块只在函数内部按名字取用,因此对顺序不敏感。
  34. */
  35. const MODULE_ORDER = [
  36. 'logger',
  37. 'bridge',
  38. 'config',
  39. 'wait',
  40. 'layout',
  41. 'ui',
  42. 'popups',
  43. 'tiktok',
  44. 'player',
  45. 'comment',
  46. 'flow'
  47. ];
  48. const ENTRY_MODULE = 'main';
  49. const SELFTEST_MODULE = '__selftest';
  50. const INJECT_MARKER = '/*TTA_INJECTED_CONFIG*/null';
  51. /**
  52. * 与 MCP 桥一致的"危险调用"规则。
  53. * 匹配前先按驼峰拆词,否则 smsManager 这类标识符会因为缺少词边界而漏报。
  54. * 正则**不能带 g** —— 带 g 的 RegExp.test 会保留 lastIndex,重复调用结果会交替翻转。
  55. */
  56. const DANGEROUS_RULES = [
  57. { name: 'sms', pattern: /\bsms\b/i },
  58. { name: 'telephony', pattern: /\btelephony\b/i },
  59. { name: 'call(', pattern: /\bcall\s*\(/i },
  60. { name: 'deleteFile', pattern: /\bdelete\s+file\b/i },
  61. { name: 'remove(', pattern: /\bremove\s*\(/i }
  62. ];
  63. function splitIdentifiers(code) {
  64. return code
  65. .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
  66. .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
  67. }
  68. function readModule(name) {
  69. const file = path.join(SRC_DIR, `${name}.js`);
  70. if (!fs.existsSync(file)) {
  71. throw new Error(`缺少模块文件:${file}`);
  72. }
  73. return fs.readFileSync(file, 'utf8');
  74. }
  75. function moduleBlock(name) {
  76. return [
  77. '',
  78. `/* ============================== src/${name}.js ============================== */`,
  79. readModule(name)
  80. ].join('\n');
  81. }
  82. function buildEntry(options) {
  83. if (options.customEntry) {
  84. return ['', '/* 自定义入口(调试/探针用) */', options.customEntry].join('\n');
  85. }
  86. if (options.selfTest) {
  87. return [
  88. '',
  89. '/* 自检模式:只跑纯函数断言,不启动 App、不发评论 */',
  90. 'var failedCount = TikTokAuto.selftest.report();',
  91. 'console.log(failedCount === 0 ? "[TTA]SELFTEST_ALL_PASS" : "[TTA]SELFTEST_HAS_FAILURE");',
  92. 'console.log("__END__");'
  93. ].join('\n');
  94. }
  95. return [
  96. '',
  97. '/* PC 侧下发时可替换下面这一行的 null 来注入配置覆盖项 */',
  98. `var INJECTED_CONFIG = ${INJECT_MARKER};`,
  99. '/* 被界面宿主唤起时(engines.execScriptFile 带 --config/--run-id 参数),',
  100. ' * fromExecArgv 会把参数翻译成「配置路径 + 桥的开关」;独立运行时是空操作。 */',
  101. 'TikTokAuto.main.run(TikTokAuto.bridge.fromExecArgv(INJECTED_CONFIG));'
  102. ].join('\n');
  103. }
  104. function build(options = {}) {
  105. const modules = options.selfTest
  106. ? MODULE_ORDER.concat([SELFTEST_MODULE])
  107. : MODULE_ORDER.concat([ENTRY_MODULE]);
  108. const header = [
  109. `/*! tiktok-auto v${VERSION}`,
  110. ` * built ${new Date().toISOString()}`,
  111. ` * modules: ${modules.join(', ')}`,
  112. ' * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。',
  113. ' */'
  114. ].join('\n');
  115. const body = modules.map(moduleBlock).join('\n');
  116. const source = [header, '(function () {', 'var TikTokAuto = {};', body, buildEntry(options), '}());', ''].join('\n');
  117. assertSyntax(source);
  118. assertNoDangerousCalls(source);
  119. return { source, modules };
  120. }
  121. function assertSyntax(source) {
  122. try {
  123. // 只为语法检查,不执行
  124. new Function(source);
  125. } catch (error) {
  126. throw new Error(`构建产物语法检查失败:${error.message}`);
  127. }
  128. }
  129. function assertNoDangerousCalls(source) {
  130. const flattened = splitIdentifiers(source);
  131. const hits = DANGEROUS_RULES.filter((rule) => rule.pattern.test(flattened));
  132. if (hits.length > 0) {
  133. throw new Error(
  134. '构建产物命中 MCP 危险调用黑名单:' + hits.map((h) => h.name).join(', ') +
  135. '。这会让每次下发都要求人工确认,请在 src/ 里改写这些调用。'
  136. );
  137. }
  138. }
  139. function writeDist(source) {
  140. fs.mkdirSync(DIST_DIR, { recursive: true });
  141. fs.writeFileSync(OUT_FILE, source, 'utf8');
  142. }
  143. function parseArgs(argv) {
  144. const options = { selfTest: false, inject: null, quiet: false };
  145. for (let i = 0; i < argv.length; i += 1) {
  146. const arg = argv[i];
  147. if (arg === '--self-test') {
  148. options.selfTest = true;
  149. } else if (arg === '--inject') {
  150. options.inject = JSON.parse(argv[i + 1]);
  151. i += 1;
  152. } else if (arg === '--quiet') {
  153. options.quiet = true;
  154. }
  155. }
  156. return options;
  157. }
  158. function main() {
  159. const options = parseArgs(process.argv.slice(2));
  160. const { source, modules } = build(options);
  161. if (options.inject) {
  162. const injected = JSON.stringify(options.inject);
  163. if (!source.includes(INJECT_MARKER)) {
  164. throw new Error('找不到注入占位符,构建产物异常');
  165. }
  166. writeDist(source.replace(INJECT_MARKER, `/*TTA_INJECTED_CONFIG*/${injected}`));
  167. } else {
  168. writeDist(source);
  169. }
  170. if (!options.quiet) {
  171. console.log(`已构建 ${OUT_FILE}`);
  172. console.log(` 模块 ${modules.length} 个:${modules.join(' → ')}`);
  173. console.log(` 体积 ${Buffer.byteLength(source, 'utf8')} 字节`);
  174. console.log(' 语法检查:通过;危险调用预检:通过');
  175. }
  176. }
  177. module.exports = {
  178. ROOT,
  179. OUT_FILE,
  180. MODULE_ORDER,
  181. INJECT_MARKER,
  182. build,
  183. writeDist,
  184. parseArgs,
  185. main
  186. };
  187. if (require.main === module) {
  188. main();
  189. }