run.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. #!/usr/bin/env node
  2. /**
  3. * run.js — PC 侧执行器:构建 → 下发到真机 → 收集日志 → 汇总断言
  4. *
  5. * 把"人肉复制脚本到手机再点运行"变成一条命令,并且**返回可判断的退出码**,
  6. * 这样它既能给人用,也能接 CI。
  7. *
  8. * 用法示例:
  9. * node tools/run.js --self-test # 只跑设备端纯函数自检(不碰界面)
  10. * node tools/run.js --dry-run # 走完整流程但不真的发评论
  11. * node tools/run.js --keyword cat --videos 1 # 换关键词跑一条
  12. * node tools/run.js --push-config # 把配置推到手机固定路径
  13. * node tools/run.js # 正式跑(会真的发评论)
  14. *
  15. * 注意:MCP 单次下发有 120s 上限。maxVideos 较大时请分批运行,
  16. * 别指望一次下发里连看十条视频。
  17. */
  18. 'use strict';
  19. const fs = require('fs');
  20. const path = require('path');
  21. const { execFileSync } = require('child_process');
  22. const build = require('./build');
  23. const mcp = require('./mcp');
  24. const CONFIG_LOCAL = path.join(build.ROOT, 'config', 'tiktok-auto.config.json');
  25. const DEVICE_CONFIG_PATH = '/sdcard/Download/tiktok-auto/config.json';
  26. const MAX_DEVICE_TIMEOUT_MS = 120000;
  27. function parseArgs(argv) {
  28. const options = {
  29. selfTest: false,
  30. pushConfig: false,
  31. dryRun: false,
  32. keyword: null,
  33. videos: null,
  34. configFile: null,
  35. timeoutMs: 115000,
  36. deviceId: null,
  37. noBuild: false
  38. };
  39. for (let i = 0; i < argv.length; i += 1) {
  40. const arg = argv[i];
  41. switch (arg) {
  42. case '--self-test': options.selfTest = true; break;
  43. case '--push-config': options.pushConfig = true; break;
  44. case '--dry-run': options.dryRun = true; break;
  45. case '--keyword': options.keyword = argv[i + 1]; i += 1; break;
  46. case '--videos': options.videos = Number(argv[i + 1]); i += 1; break;
  47. case '--config': options.configFile = argv[i + 1]; i += 1; break;
  48. case '--timeout': options.timeoutMs = Number(argv[i + 1]); i += 1; break;
  49. case '--device': options.deviceId = argv[i + 1]; i += 1; break;
  50. case '--eval': options.evalFile = argv[i + 1]; i += 1; break;
  51. case '--raw': options.raw = true; break;
  52. case '--no-build': options.noBuild = true; break;
  53. default: break;
  54. }
  55. }
  56. return options;
  57. }
  58. /** adb 只用于两件事:推配置、查设备;脚本下发一律走 MCP */
  59. function adb(args, options = {}) {
  60. const full = options.serial ? ['-s', options.serial].concat(args) : args;
  61. return execFileSync('adb', full, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
  62. }
  63. function resolveSerial(explicit) {
  64. if (explicit) {
  65. return explicit;
  66. }
  67. if (process.env.ADB_SERIAL) {
  68. return process.env.ADB_SERIAL;
  69. }
  70. const output = adb(['devices']);
  71. const line = output.split(/\r?\n/).slice(1).find((row) => row.includes('\tdevice'));
  72. return line ? line.split('\t')[0].trim() : null;
  73. }
  74. function pushConfig(serial) {
  75. const target = DEVICE_CONFIG_PATH;
  76. console.log(`推送配置 → ${target}`);
  77. adb(['shell', `mkdir -p ${path.posix.dirname(target)}`], { serial });
  78. adb(['push', CONFIG_LOCAL, target], { serial });
  79. console.log('配置推送完成。设备上的脚本会优先读取它。');
  80. }
  81. function buildInjected(options) {
  82. const injected = {};
  83. const fromFile = options.configFile
  84. ? JSON.parse(fs.readFileSync(options.configFile, 'utf8'))
  85. : null;
  86. if (options.dryRun) {
  87. injected.runtime = Object.assign(injected.runtime || {}, { dryRun: true });
  88. }
  89. if (options.keyword) {
  90. injected.target = Object.assign(injected.target || {}, { keyword: options.keyword });
  91. }
  92. if (options.videos) {
  93. injected.target = Object.assign(injected.target || {}, { maxVideos: options.videos });
  94. }
  95. if (!fromFile) {
  96. return Object.keys(injected).length > 0 ? injected : null;
  97. }
  98. // 文件里的配置覆盖命令行注入,避免"命令行比配置文件还权威"的反直觉行为
  99. const merged = Object.assign({}, injected, fromFile);
  100. merged.target = Object.assign({}, injected.target, fromFile.target);
  101. merged.runtime = Object.assign({}, injected.runtime, fromFile.runtime);
  102. return merged;
  103. }
  104. function compile(options) {
  105. if (options.evalFile) {
  106. // 调试模式:加载全部模块,但入口换成指定文件里的代码,
  107. // 这样就能在真机上直接调用模块里的函数做诊断,而不用去猜。
  108. const code = fs.readFileSync(options.evalFile, 'utf8');
  109. return build.build({ customEntry: code + '\nconsole.log("__END__");' }).source;
  110. }
  111. const { source } = build.build({ selfTest: options.selfTest });
  112. let finalSource = source;
  113. const injected = options.selfTest ? null : buildInjected(options);
  114. if (injected) {
  115. finalSource = source.replace(build.INJECT_MARKER, `/*TTA_INJECTED_CONFIG*/${JSON.stringify(injected)}`);
  116. console.log('注入配置覆盖项:' + JSON.stringify(injected));
  117. }
  118. return finalSource;
  119. }
  120. function parseLogs(logs) {
  121. const report = { result: null, ended: false, warnings: [], errors: [], steps: [] };
  122. for (const line of logs || []) {
  123. if (line.includes('__END__') && !line.includes('AUTOMCP::')) {
  124. report.ended = true;
  125. }
  126. const resultMatch = line.match(/\[TTA\]RESULT (.*)$/);
  127. if (resultMatch) {
  128. try {
  129. report.result = JSON.parse(resultMatch[1]);
  130. } catch (error) {
  131. report.warnings.push('RESULT 行解析失败:' + resultMatch[1]);
  132. }
  133. }
  134. const stepMatch = line.match(/\[TTA\]\+(\d+)ms\[(STEP|WARN|ERROR|INFO)\]/);
  135. if (stepMatch) {
  136. if (stepMatch[2] === 'WARN') { report.warnings.push(line); }
  137. if (stepMatch[2] === 'ERROR') { report.errors.push(line); }
  138. if (stepMatch[2] === 'STEP') { report.steps.push(line); }
  139. }
  140. }
  141. return report;
  142. }
  143. /**
  144. * 判定"这次算不算成功"。
  145. * dryRun 下本来就不会有评论发出去,所以判据要跟着换——
  146. * 否则脚本明明跑通了,退出码却是失败,会误导 CI。
  147. */
  148. function isSuccess(report, options) {
  149. const result = report.result;
  150. if (!result || result.ok !== true) {
  151. return false;
  152. }
  153. if (options.dryRun) {
  154. return result.attemptedVideos > 0;
  155. }
  156. const anySkipped = (result.details || []).some((d) => d.comment && d.comment.skipped);
  157. return anySkipped ? result.attemptedVideos > 0 : result.postedComments > 0;
  158. }
  159. function summarize(report, options) {
  160. console.log('\n──────── 设备端日志 ────────');
  161. if (report.steps.length > 0) {
  162. console.log('步骤:');
  163. report.steps.forEach((line) => console.log(' ' + line.replace(/^.*?\[TTA\]/, '')));
  164. }
  165. if (report.warnings.length > 0) {
  166. console.log(`警告 ${report.warnings.length} 条:`);
  167. report.warnings.forEach((line) => console.log(' ' + line.replace(/^.*?\[TTA\]/, '')));
  168. }
  169. if (report.errors.length > 0) {
  170. console.log(`错误 ${report.errors.length} 条:`);
  171. report.errors.forEach((line) => console.log(' ' + line.replace(/^.*?\[TTA\]/, '')));
  172. }
  173. console.log('\n──────── 结论 ────────');
  174. if (options.selfTest) {
  175. const pass = (report.stdOut || []).some((line) => line.includes('SELFTEST_ALL_PASS'));
  176. console.log(pass ? '✅ 自检全部通过' : '❌ 自检存在失败项');
  177. return pass;
  178. }
  179. if (!report.result) {
  180. console.log('❌ 没有拿到结构化结果(脚本可能提前中断)');
  181. return false;
  182. }
  183. const result = report.result;
  184. console.log(`流程 ok=${result.ok} 尝试视频=${result.attemptedVideos} 成功评论=${result.postedComments}`);
  185. (result.details || []).forEach((detail, index) => {
  186. const watch = detail.watch || {};
  187. const comment = detail.comment || {};
  188. console.log(` #${index + 1} ${detail.caption}`);
  189. console.log(` 观看 ${watch.actualMs}ms/目标 ${watch.requestedMs}ms ${watch.completed ? '完成' : '未完成'}` +
  190. ` 证据源=${watch.probeMode}${watch.degraded ? '(降级)' : ''}`);
  191. console.log(` 评论 ok=${comment.ok} via=${comment.via}` +
  192. (comment.confidence ? ` 置信度=${comment.confidence}` : '') +
  193. ` 计数 ${comment.countBefore}→${comment.countAfter}` +
  194. ` 文案=${JSON.stringify(comment.text)}`);
  195. });
  196. if ((result.errors || []).length > 0) {
  197. console.log(' 错误:');
  198. result.errors.forEach((error) => console.log(' - ' + error));
  199. }
  200. return isSuccess(report, options);
  201. }
  202. async function main() {
  203. const options = parseArgs(process.argv.slice(2));
  204. const serial = resolveSerial(null);
  205. console.log('──────── 环境 ────────');
  206. try {
  207. const status = await mcp.health();
  208. console.log(`MCP 桥:${status.ok ? '正常' : '异常'},已连接设备 ${status.devices} 台`);
  209. } catch (error) {
  210. console.error('MCP 桥不可达,请确认 VS Code 扩展已激活:' + error.message);
  211. process.exit(2);
  212. }
  213. if (options.pushConfig) {
  214. if (!serial) {
  215. console.error('没有找到 adb 设备,无法推送配置');
  216. process.exit(2);
  217. }
  218. pushConfig(serial);
  219. return;
  220. }
  221. // 设备没挂上就先连一下(MCP 不会隐式建连)
  222. const devices = await mcp.listDevices();
  223. if (!Array.isArray(devices) || devices.length === 0) {
  224. console.log('设备未连接,尝试 device_connect(mode=adb)…');
  225. await mcp.connect(serial || undefined);
  226. }
  227. console.log('\n──────── 构建 ────────');
  228. let source;
  229. if (options.noBuild && fs.existsSync(build.OUT_FILE)) {
  230. source = fs.readFileSync(build.OUT_FILE, 'utf8');
  231. console.log('复用已有产物 ' + build.OUT_FILE);
  232. } else {
  233. source = compile(options);
  234. build.writeDist(source);
  235. console.log(`构建完成:${build.OUT_FILE}(${Buffer.byteLength(source, 'utf8')} 字节)`);
  236. }
  237. console.log('\n──────── 下发执行 ────────');
  238. const timeoutMs = Math.min(options.timeoutMs, MAX_DEVICE_TIMEOUT_MS);
  239. console.log(`下发 ${Buffer.byteLength(source, 'utf8')} 字节,超时上限 ${timeoutMs}ms`);
  240. const started = Date.now();
  241. const response = await mcp.runCode(source, { timeoutMs, deviceId: options.deviceId });
  242. const cost = Date.now() - started;
  243. if (response.status === 'needs_confirmation') {
  244. console.error('⚠️ 脚本命中危险调用黑名单,需要人工确认。confirmToken=' + response.confirmToken);
  245. process.exit(3);
  246. }
  247. if (response.error) {
  248. console.error('设备端错误:' + response.error);
  249. }
  250. console.log(`状态=${response.status} 往返=${cost}ms 日志 ${(response.logs || []).length} 行`);
  251. const report = parseLogs(response.logs);
  252. report.stdOut = response.logs || [];
  253. if (options.raw) {
  254. console.log('\n──────── 原始日志 ────────');
  255. (response.logs || []).forEach((line) => console.log(line));
  256. }
  257. const ok = options.raw ? (response.status === 'ok') : summarize(report, options);
  258. if (response.status !== 'ok') {
  259. console.log(`\n⚠️ status=${response.status},注意这不是"成功",请先看上文的错误/警告。`);
  260. }
  261. process.exit(ok ? 0 : 1);
  262. }
  263. main().catch((error) => {
  264. console.error('执行失败:' + (error && error.stack ? error.stack : error));
  265. process.exit(1);
  266. });