| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- #!/usr/bin/env node
- /**
- * run.js — PC 侧执行器:构建 → 下发到真机 → 收集日志 → 汇总断言
- *
- * 把"人肉复制脚本到手机再点运行"变成一条命令,并且**返回可判断的退出码**,
- * 这样它既能给人用,也能接 CI。
- *
- * 用法示例:
- * node tools/run.js --self-test # 只跑设备端纯函数自检(不碰界面)
- * node tools/run.js --dry-run # 走完整流程但不真的发评论
- * node tools/run.js --keyword cat --videos 1 # 换关键词跑一条
- * node tools/run.js --push-config # 把配置推到手机固定路径
- * node tools/run.js # 正式跑(会真的发评论)
- *
- * 注意:MCP 单次下发有 120s 上限。maxVideos 较大时请分批运行,
- * 别指望一次下发里连看十条视频。
- */
- 'use strict';
- const fs = require('fs');
- const path = require('path');
- const { execFileSync } = require('child_process');
- const build = require('./build');
- const mcp = require('./mcp');
- const CONFIG_LOCAL = path.join(build.ROOT, 'config', 'tiktok-auto.config.json');
- const DEVICE_CONFIG_PATH = '/sdcard/Download/tiktok-auto/config.json';
- const MAX_DEVICE_TIMEOUT_MS = 120000;
- function parseArgs(argv) {
- const options = {
- selfTest: false,
- pushConfig: false,
- dryRun: false,
- keyword: null,
- videos: null,
- configFile: null,
- timeoutMs: 115000,
- deviceId: null,
- noBuild: false
- };
- for (let i = 0; i < argv.length; i += 1) {
- const arg = argv[i];
- switch (arg) {
- case '--self-test': options.selfTest = true; break;
- case '--push-config': options.pushConfig = true; break;
- case '--dry-run': options.dryRun = true; break;
- case '--keyword': options.keyword = argv[i + 1]; i += 1; break;
- case '--videos': options.videos = Number(argv[i + 1]); i += 1; break;
- case '--config': options.configFile = argv[i + 1]; i += 1; break;
- case '--timeout': options.timeoutMs = Number(argv[i + 1]); i += 1; break;
- case '--device': options.deviceId = argv[i + 1]; i += 1; break;
- case '--eval': options.evalFile = argv[i + 1]; i += 1; break;
- case '--raw': options.raw = true; break;
- case '--no-build': options.noBuild = true; break;
- default: break;
- }
- }
- return options;
- }
- /** adb 只用于两件事:推配置、查设备;脚本下发一律走 MCP */
- function adb(args, options = {}) {
- const full = options.serial ? ['-s', options.serial].concat(args) : args;
- return execFileSync('adb', full, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
- }
- function resolveSerial(explicit) {
- if (explicit) {
- return explicit;
- }
- if (process.env.ADB_SERIAL) {
- return process.env.ADB_SERIAL;
- }
- const output = adb(['devices']);
- const line = output.split(/\r?\n/).slice(1).find((row) => row.includes('\tdevice'));
- return line ? line.split('\t')[0].trim() : null;
- }
- function pushConfig(serial) {
- const target = DEVICE_CONFIG_PATH;
- console.log(`推送配置 → ${target}`);
- adb(['shell', `mkdir -p ${path.posix.dirname(target)}`], { serial });
- adb(['push', CONFIG_LOCAL, target], { serial });
- console.log('配置推送完成。设备上的脚本会优先读取它。');
- }
- function buildInjected(options) {
- const injected = {};
- const fromFile = options.configFile
- ? JSON.parse(fs.readFileSync(options.configFile, 'utf8'))
- : null;
- if (options.dryRun) {
- injected.runtime = Object.assign(injected.runtime || {}, { dryRun: true });
- }
- if (options.keyword) {
- injected.target = Object.assign(injected.target || {}, { keyword: options.keyword });
- }
- if (options.videos) {
- injected.target = Object.assign(injected.target || {}, { maxVideos: options.videos });
- }
- if (!fromFile) {
- return Object.keys(injected).length > 0 ? injected : null;
- }
- // 文件里的配置覆盖命令行注入,避免"命令行比配置文件还权威"的反直觉行为
- const merged = Object.assign({}, injected, fromFile);
- merged.target = Object.assign({}, injected.target, fromFile.target);
- merged.runtime = Object.assign({}, injected.runtime, fromFile.runtime);
- return merged;
- }
- function compile(options) {
- if (options.evalFile) {
- // 调试模式:加载全部模块,但入口换成指定文件里的代码,
- // 这样就能在真机上直接调用模块里的函数做诊断,而不用去猜。
- const code = fs.readFileSync(options.evalFile, 'utf8');
- return build.build({ customEntry: code + '\nconsole.log("__END__");' }).source;
- }
- const { source } = build.build({ selfTest: options.selfTest });
- let finalSource = source;
- const injected = options.selfTest ? null : buildInjected(options);
- if (injected) {
- finalSource = source.replace(build.INJECT_MARKER, `/*TTA_INJECTED_CONFIG*/${JSON.stringify(injected)}`);
- console.log('注入配置覆盖项:' + JSON.stringify(injected));
- }
- return finalSource;
- }
- function parseLogs(logs) {
- const report = { result: null, ended: false, warnings: [], errors: [], steps: [] };
- for (const line of logs || []) {
- if (line.includes('__END__') && !line.includes('AUTOMCP::')) {
- report.ended = true;
- }
- const resultMatch = line.match(/\[TTA\]RESULT (.*)$/);
- if (resultMatch) {
- try {
- report.result = JSON.parse(resultMatch[1]);
- } catch (error) {
- report.warnings.push('RESULT 行解析失败:' + resultMatch[1]);
- }
- }
- const stepMatch = line.match(/\[TTA\]\+(\d+)ms\[(STEP|WARN|ERROR|INFO)\]/);
- if (stepMatch) {
- if (stepMatch[2] === 'WARN') { report.warnings.push(line); }
- if (stepMatch[2] === 'ERROR') { report.errors.push(line); }
- if (stepMatch[2] === 'STEP') { report.steps.push(line); }
- }
- }
- return report;
- }
- /**
- * 判定"这次算不算成功"。
- * dryRun 下本来就不会有评论发出去,所以判据要跟着换——
- * 否则脚本明明跑通了,退出码却是失败,会误导 CI。
- */
- function isSuccess(report, options) {
- const result = report.result;
- if (!result || result.ok !== true) {
- return false;
- }
- if (options.dryRun) {
- return result.attemptedVideos > 0;
- }
- const anySkipped = (result.details || []).some((d) => d.comment && d.comment.skipped);
- return anySkipped ? result.attemptedVideos > 0 : result.postedComments > 0;
- }
- function summarize(report, options) {
- console.log('\n──────── 设备端日志 ────────');
- if (report.steps.length > 0) {
- console.log('步骤:');
- report.steps.forEach((line) => console.log(' ' + line.replace(/^.*?\[TTA\]/, '')));
- }
- if (report.warnings.length > 0) {
- console.log(`警告 ${report.warnings.length} 条:`);
- report.warnings.forEach((line) => console.log(' ' + line.replace(/^.*?\[TTA\]/, '')));
- }
- if (report.errors.length > 0) {
- console.log(`错误 ${report.errors.length} 条:`);
- report.errors.forEach((line) => console.log(' ' + line.replace(/^.*?\[TTA\]/, '')));
- }
- console.log('\n──────── 结论 ────────');
- if (options.selfTest) {
- const pass = (report.stdOut || []).some((line) => line.includes('SELFTEST_ALL_PASS'));
- console.log(pass ? '✅ 自检全部通过' : '❌ 自检存在失败项');
- return pass;
- }
- if (!report.result) {
- console.log('❌ 没有拿到结构化结果(脚本可能提前中断)');
- return false;
- }
- const result = report.result;
- console.log(`流程 ok=${result.ok} 尝试视频=${result.attemptedVideos} 成功评论=${result.postedComments}`);
- (result.details || []).forEach((detail, index) => {
- const watch = detail.watch || {};
- const comment = detail.comment || {};
- console.log(` #${index + 1} ${detail.caption}`);
- console.log(` 观看 ${watch.actualMs}ms/目标 ${watch.requestedMs}ms ${watch.completed ? '完成' : '未完成'}` +
- ` 证据源=${watch.probeMode}${watch.degraded ? '(降级)' : ''}`);
- console.log(` 评论 ok=${comment.ok} via=${comment.via}` +
- (comment.confidence ? ` 置信度=${comment.confidence}` : '') +
- ` 计数 ${comment.countBefore}→${comment.countAfter}` +
- ` 文案=${JSON.stringify(comment.text)}`);
- });
- if ((result.errors || []).length > 0) {
- console.log(' 错误:');
- result.errors.forEach((error) => console.log(' - ' + error));
- }
- return isSuccess(report, options);
- }
- async function main() {
- const options = parseArgs(process.argv.slice(2));
- const serial = resolveSerial(null);
- console.log('──────── 环境 ────────');
- try {
- const status = await mcp.health();
- console.log(`MCP 桥:${status.ok ? '正常' : '异常'},已连接设备 ${status.devices} 台`);
- } catch (error) {
- console.error('MCP 桥不可达,请确认 VS Code 扩展已激活:' + error.message);
- process.exit(2);
- }
- if (options.pushConfig) {
- if (!serial) {
- console.error('没有找到 adb 设备,无法推送配置');
- process.exit(2);
- }
- pushConfig(serial);
- return;
- }
- // 设备没挂上就先连一下(MCP 不会隐式建连)
- const devices = await mcp.listDevices();
- if (!Array.isArray(devices) || devices.length === 0) {
- console.log('设备未连接,尝试 device_connect(mode=adb)…');
- await mcp.connect(serial || undefined);
- }
- console.log('\n──────── 构建 ────────');
- let source;
- if (options.noBuild && fs.existsSync(build.OUT_FILE)) {
- source = fs.readFileSync(build.OUT_FILE, 'utf8');
- console.log('复用已有产物 ' + build.OUT_FILE);
- } else {
- source = compile(options);
- build.writeDist(source);
- console.log(`构建完成:${build.OUT_FILE}(${Buffer.byteLength(source, 'utf8')} 字节)`);
- }
- console.log('\n──────── 下发执行 ────────');
- const timeoutMs = Math.min(options.timeoutMs, MAX_DEVICE_TIMEOUT_MS);
- console.log(`下发 ${Buffer.byteLength(source, 'utf8')} 字节,超时上限 ${timeoutMs}ms`);
- const started = Date.now();
- const response = await mcp.runCode(source, { timeoutMs, deviceId: options.deviceId });
- const cost = Date.now() - started;
- if (response.status === 'needs_confirmation') {
- console.error('⚠️ 脚本命中危险调用黑名单,需要人工确认。confirmToken=' + response.confirmToken);
- process.exit(3);
- }
- if (response.error) {
- console.error('设备端错误:' + response.error);
- }
- console.log(`状态=${response.status} 往返=${cost}ms 日志 ${(response.logs || []).length} 行`);
- const report = parseLogs(response.logs);
- report.stdOut = response.logs || [];
- if (options.raw) {
- console.log('\n──────── 原始日志 ────────');
- (response.logs || []).forEach((line) => console.log(line));
- }
- const ok = options.raw ? (response.status === 'ok') : summarize(report, options);
- if (response.status !== 'ok') {
- console.log(`\n⚠️ status=${response.status},注意这不是"成功",请先看上文的错误/警告。`);
- }
- process.exit(ok ? 0 : 1);
- }
- main().catch((error) => {
- console.error('执行失败:' + (error && error.stack ? error.stack : error));
- process.exit(1);
- });
|