#!/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); });