/*! tiktok-auto v1.0.0 * built 2026-10-13T15:48:23.018Z * modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, main * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。 */ (function () { var TikTokAuto = {}; /* ============================== src/logger.js ============================== */ /** * logger.js — 结构化日志 * * 为什么单独成模块:设备脚本出错时,唯一能依赖的就是回传的日志。 * 把日志格式固定下来(`[TTA][步骤名] 内容`),PC 侧就能按前缀过滤、按步骤计时, * 而不是靠人肉读一堆散乱的 console.log。 * * 2026-10-13 增加**文件落盘通道**(sink):界面侧的「运行记录 → 详情的执行日志」 * 要看的就是这些行。之所以不做成"界面去抓 console",是因为 * console 是另一个进程的 stdout,界面根本读不到(见 bridge.js 的通道说明)。 * sink 由 bridge 在启用宿主模式时装上,**独立运行时为空 → 零开销**。 * * 依赖:无。始终通过 TikTokAuto.logger 取用,因此可以被放在 concat 顺序的最前面。 */ TikTokAuto.logger = (function () { 'use strict'; var PREFIX = '[TTA]'; var startedAt = Date.now(); /** 文件落盘回调,由 bridge 注入:function(level, step, message) */ var sink = null; function stamp() { return ('+' + (Date.now() - startedAt) + 'ms'); } function write(level, step, message) { var line = PREFIX + stamp() + '[' + level + '][' + step + '] ' + message; console.log(line); if (sink) { /* 落盘失败绝不能影响流程:日志是"顺带记录的",不是"必须成功的"。 */ try { sink(level, step, message); } catch (ignored) { /* 刻意吞掉:sink 抛错会把一次正常的日志调用变成流程中断 */ } } } return { /** 装上文件落盘通道(传 null 卸载) */ setSink: function (fn) { sink = typeof fn === 'function' ? fn : null; }, /** 记录一次进入某步骤 */ step: function (name, message) { write('STEP', name, message === undefined ? '' : message); }, info: function (step, message) { write('INFO', step, message); }, /** 可恢复的问题:值得记下来,但不中断流程 */ warn: function (step, message) { write('WARN', step, message); }, error: function (step, message) { write('ERROR', step, message); }, /** 机器可读的结构化结果,PC 侧用它做断言 */ result: function (payload) { console.log(PREFIX + 'RESULT ' + JSON.stringify(payload)); }, /** 从脚本开始到现在的耗时,用于定位"哪一步慢" */ elapsedMs: function () { return Date.now() - startedAt; } }; }()); /* ============================== src/bridge.js ============================== */ /** * bridge.js — 与「界面宿主」之间的桥 * * 为什么需要它:这个脚本有两种运行方式—— * ① 独立运行(PC 侧下发、或直接打开产物):自己读配置,跑完打印 `__END__`; * ② 被界面宿主唤起(`engines.execScriptFile`,见 tiktok-app): * 配置由宿主生成,宿主还要能看见**进度**、能**叫停**。 * * 两种方式共用同一套流程,差别只在「上下文从哪来、状态往哪去」,所以抽成这个模块: * 流程代码只调 `bridge.report()` / `bridge.checkStop()`,不必知道自己在被谁驱动。 * **未启用时两者都是空操作**,独立运行零开销、零行为变化。 * * 通道全部走**文件**,不走共享内存 —— 宿主与子引擎是两个独立的 ScriptEngine, * 脚本级全局变量互不可见(实测:父脚本读不到子引擎里的任何对象)。 * 文件是这里唯一可靠的通道。 * * 四个约定文件(默认都在 /sdcard/tiktok_app/ 下): * run-config.json 宿主 → 引擎:界面收集的完整配置快照(人可读,非本模块读取) * engine-config.json 宿主 → 引擎:映射后的引擎配置(由 --config 交给 config.resolve) * run-state.json 引擎 → 宿主:进度与终态(本模块写) * stop.flag 宿主 → 引擎:停止请求(本模块只读) * * ★ stop.flag 里放的是**本次运行的 runId**,不是空文件、也不是布尔。 * 好处一:不必删除它(`files.remove` 命中 MCP 危险调用黑名单;删除本身也有竞态)。 * 好处二:上一轮残留的标志不会误杀本轮 —— runId 不同,自然不匹配。 * * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合) */ TikTokAuto.bridge = (function () { 'use strict'; var DEFAULT_DIR = '/sdcard/tiktok_app'; var DEFAULT_STATE = DEFAULT_DIR + '/run-state.json'; var DEFAULT_STOP_FLAG = DEFAULT_DIR + '/stop.flag'; var DEFAULT_LOG = DEFAULT_DIR + '/run-log.jsonl'; /** * 检查停止标志的最小间隔。 * 每次 tick(默认 150ms)都去 stat 一次文件是没必要的高频 IO; * 500ms 的响应延迟对人来说与"立刻"无异。 */ var STOP_POLL_MIN_MS = 500; var state = { enabled: false, runId: '', statePath: DEFAULT_STATE, stopFlagPath: DEFAULT_STOP_FLAG, logPath: DEFAULT_LOG, lastStopCheckMs: 0 }; var last = null; /** * 「用户要求停止」。**刻意用异常抛出**: * 停止请求可能发生在任意深度的等待里(等页面就绪、等播放开始、等结果卡片……), * 逐层返回码需要改动几十处调用点且极容易漏掉一处就变成"点了停止但不停"。 * 抛出后由 main 统一兜住,语义明确(名字里带 Signal,不是 Error 那种"出错了")。 */ function StopSignal() { this.name = 'StopSignal'; this.message = '收到停止请求'; this.stack = new Error(this.message).stack; } StopSignal.prototype = new Error(); StopSignal.prototype.constructor = StopSignal; /** * 判断自有属性。 * 刻意写成 `.apply(...)` 而不是「属性名 + 点 + call」的形式——见 config.js 里的说明: * MCP 桥的危险调用黑名单会按词边界匹配「call + 左括号」,`.apply` 语义等价却不会被误判。 */ function owns(object, key) { return Object.prototype.hasOwnProperty.apply(object, [key]) === true; } function isStopSignal(error) { return !!(error && error.name === 'StopSignal'); } /** * 取引擎启动参数。 * Rhino 会把传进来的数组包装成 `{"0":..,"1":..}` 这种对象(实测), * 所以既接受真数组,也按数字下标收集一份。 */ function readExecArgv() { var list = []; try { var argv = engines.myEngine().execArgv; if (argv instanceof Array) { return argv; } for (var i = 0; argv && argv[i] !== undefined; i += 1) { list.push(argv[i]); } } catch (ignored) { // 独立运行时 execArgv 为空/不可用,按"没有参数"处理 } return list; } /** 解析 `--key value` 形式的参数(不支持 `--key=value`,保持单一写法) */ function parseArgs(argv) { var options = {}; for (var i = 0; i < argv.length; i += 1) { var token = String(argv[i]); if (token === '--config') { options.configPath = argv[i + 1]; i += 1; } else if (token === '--state') { options.statePath = argv[i + 1]; i += 1; } else if (token === '--stop-flag') { options.stopFlagPath = argv[i + 1]; i += 1; } else if (token === '--run-id') { options.runId = argv[i + 1]; i += 1; } else if (token === '--log') { options.logPath = argv[i + 1]; i += 1; } } return options; } /** * 入口用:把命令行参数翻译成「配置覆盖项 + 桥的开关」。 * * @param base 构建时注入的配置(可为 null) * @returns 应当交给 main.run 的注入项 */ function fromExecArgv(base) { var injected = base || {}; var options = parseArgs(readExecArgv()); if (options.runId) { enable(options); } if (options.configPath) { injected.runtime = injected.runtime || {}; injected.runtime.configPath = options.configPath; } return injected; } /** * 日志落盘(界面侧「执行日志」的来源)。 * * 一行一条 JSON(jsonl):界面按行解析,坏行跳过即可 —— * 比整份 JSON 更抗"写着写着被杀进程"(半行只丢一行,不会让整份文件不可读)。 * 用 append 而不是重写:日志是**流**,每写一次整文件会让长流程变成 O(n²) 的 IO。 */ function appendLog(level, step, message) { try { files.append(state.logPath, JSON.stringify({ t: Date.now(), level: level, step: step, text: (step ? '[' + step + '] ' : '') + message }) + '\n'); } catch (error) { /* 落盘失败不该影响流程:logger 那边已经吞了一层,这里再兜一层 */ } } /** 打开桥。没有 runId 时**不启用**——runId 是停止标志比对的依据,缺了它无法判定是谁的请求。 */ function enable(options) { state.enabled = true; state.runId = String(options.runId); state.statePath = options.statePath || DEFAULT_STATE; state.stopFlagPath = options.stopFlagPath || DEFAULT_STOP_FLAG; state.logPath = options.logPath || DEFAULT_LOG; state.lastStopCheckMs = 0; /* ★ 清空上一轮的日志:界面在结束时整份读走, * 不清的话 D3 上会混进上一次运行的行(而且越跑越长)。 */ try { files.write(state.logPath, ''); } catch (error) { /* 清不掉也不致命:界面会按 t 只取本轮时间窗内的行 */ } if (TikTokAuto.logger && TikTokAuto.logger.setSink) { TikTokAuto.logger.setSink(appendLog); } last = { runId: state.runId, status: 'starting', phase: '', done: 0, total: 0, updatedAt: 0 }; report({}); log('info', '已连接到界面宿主,runId=' + state.runId); } function isEnabled() { return state.enabled; } /** * 写一次状态。宿主每秒读一次这个文件。 * 失败只记 WARN:状态上报失败不该让正在跑的流程崩掉。 */ function report(patch) { if (!state.enabled || !last) { return; } for (var key in patch) { if (owns(patch, key)) { last[key] = patch[key]; } } last.runId = state.runId; last.updatedAt = Date.now(); try { files.write(state.statePath, JSON.stringify(last)); } catch (error) { log('warn', '状态上报失败:' + error); } } /** 写终态。宿主靠它结束「运行中」并弹出结果。 */ function finish(status, summary) { if (!state.enabled) { return; } report({ status: status, phase: '', summary: summary || null, message: describe(status, summary) }); log('info', '终态已上报:' + status); } /** 给界面看的一句人话,不要求界面自己去解析 summary 结构 */ function describe(status, summary) { if (status === 'stopped') { return '已停止'; } if (status === 'finished') { var watched = summary ? summary.attemptedVideos : 0; var posted = summary ? summary.postedComments : 0; return '完成:处理 ' + watched + ' 个视频 / 发出 ' + posted + ' 条评论'; } var reason = summary && summary.errors && summary.errors.length > 0 ? summary.errors[0] : '未知原因'; return '未完成:' + reason; } /** * 停止检查。**由唯一的等待原语 `wait.tick` 调用**, * 于是所有长等待都能被打断,而流程代码一处都不用改。 * 命中即抛 StopSignal。 */ function checkStop() { if (!state.enabled) { return; } var now = Date.now(); if (now - state.lastStopCheckMs < STOP_POLL_MIN_MS) { return; } state.lastStopCheckMs = now; var content = ''; try { if (!files.exists(state.stopFlagPath)) { return; } content = String(files.read(state.stopFlagPath)).replace(/\s/g, ''); } catch (error) { log('warn', '读取停止标志失败:' + error); return; } if (content !== '' && content === state.runId) { report({ status: 'stopping', phase: '正在收尾', message: '收到停止请求,正在收尾' }); log('warn', '收到停止请求(runId 匹配),中断当前等待'); throw new StopSignal(); } } function log(level, message) { if (TikTokAuto.logger && TikTokAuto.logger[level]) { TikTokAuto.logger[level]('bridge', message); } } return { DEFAULT_DIR: DEFAULT_DIR, DEFAULT_STATE: DEFAULT_STATE, DEFAULT_STOP_FLAG: DEFAULT_STOP_FLAG, DEFAULT_LOG: DEFAULT_LOG, StopSignal: StopSignal, isStopSignal: isStopSignal, readExecArgv: readExecArgv, parseArgs: parseArgs, fromExecArgv: fromExecArgv, isEnabled: isEnabled, checkStop: checkStop, report: report, finish: finish, describe: describe, appendLog: appendLog }; }()); /* ============================== src/config.js ============================== */ /** * config.js — 配置的默认值、设备端加载、合并与校验 * * 设计依据(Zakas《编写可维护的JavaScript》第 9 章「使用配置对象」): * 所有"会变的业务数据"——搜索关键词、评论文案、观看时长、超时阈值、开关—— * 都集中在配置文件里;代码只负责读,不负责写死。 * 于是**改文案/改时长不需要动代码**,也不需要重新构建。 * * 配置来源优先级(后者覆盖前者): * 1. DEFAULTS(本文件,保证"没有配置文件也能跑") * 2. 设备上的 JSON 文件(默认 /sdcard/Download/tiktok-auto/config.json) * 3. 运行时注入的 payload(PC 侧下发脚本时携带,便于免推送做实验) * * 依赖:TikTokAuto.logger */ TikTokAuto.config = (function () { 'use strict'; var DEFAULT_DEVICE_PATH = '/sdcard/Download/tiktok-auto/config.json'; var DEFAULTS = { target: { packageName: 'com.zhiliaoapp.musically', keyword: 'fashion', maxVideos: 1, resultTab: 'Top', pickStrategy: 'random' }, watch: { minMs: 10000, maxMs: 35000 }, comments: { /* 是否执行评论动作。关掉就是「只浏览不发评论」—— * 界面把「执行操作」里的评论开关映射到这里: * 用户没勾评论就不该发,哪怕评论池配好了。 */ enabled: true, pool: [ 'Love this!', 'So pretty', 'Great style', 'This is amazing', 'Need this look' ], avoidRepeatWithinRun: true, strictPostCheck: false, verifyInList: true }, timeouts: { appLaunchMs: 25000, pageReadyMs: 15000, searchResultsMs: 20000, playerReadyMs: 20000, playbackStartMs: 12000, panelReadyMs: 8000, postMs: 15000, countRefreshMs: 6000 }, probe: { pollTickMs: 150, livenessIntervalMs: 2500, stallTolerance: 3, useAudio: true, usePixels: false }, guard: { enabled: true, intervalMs: 1200, firstScanTimeoutMs: 4000, dismissLabels: [ 'Not now', 'No thanks', 'Skip', 'Later', 'Cancel', 'Close', '稍后', '暂不', '取消', '关闭', '以后再说' ] }, runtime: { configPath: DEFAULT_DEVICE_PATH, maxRuntimeMs: 0, dryRun: false } }; /** 配置错误:单独类型,便于上层区分"配置写错了"和"运行时炸了" */ function ConfigError(message, detail) { this.name = 'ConfigError'; this.message = message; this.detail = detail; this.stack = new Error(message).stack; } ConfigError.prototype = new Error(); ConfigError.prototype.constructor = ConfigError; /** * 判断自有属性。 * * 这里刻意写成 `.apply(...)` 而不是更常见的「属性名 + 点 + 名字为 call 的方法」那种形式。 * 原因:MCP 桥会对下发的脚本做危险调用黑名单扫描,其中一条规则按词边界匹配 * 「名字为 call 的方法 + 左括号」的片段。`.apply` 语义完全等价,却不会被误判成 * 危险代码、从而让每次下发都多要一次人工确认。 */ function owns(object, key) { return Object.prototype.hasOwnProperty.apply(object, [key]) === true; } function isPlainObject(value) { return value !== null && typeof value === 'object' && !(value instanceof Array); } /** 深拷贝(Rhino 没有 Object.assign,也不该依赖它) */ function clone(value) { if (value instanceof Array) { var list = []; for (var i = 0; i < value.length; i += 1) { list.push(clone(value[i])); } return list; } if (isPlainObject(value)) { var copy = {}; for (var key in value) { if (owns(value, key)) { copy[key] = clone(value[key]); } } return copy; } return value; } /** * 深合并:对象逐键递归,数组整体替换。 * 数组"整体替换"是刻意的——评论池这类配置如果被逐项合并, * 用户就永远删不掉默认评论了。 */ function merge(base, override) { if (!isPlainObject(override)) { return clone(override); } var result = clone(base); for (var key in override) { if (!owns(override, key)) { continue; } var value = override[key]; if (isPlainObject(value) && isPlainObject(result[key])) { result[key] = merge(result[key], value); } else { result[key] = clone(value); } } return result; } function readDeviceJson(path) { var logger = TikTokAuto.logger; try { if (typeof files === 'undefined' || !files.exists(path)) { logger.info('config', '未找到配置文件,使用内置默认值:' + path); return null; } var raw = files.read(path); var parsed = JSON.parse(raw); logger.info('config', '已加载配置文件:' + path); return parsed; } catch (error) { // 配置写坏了必须让用户知道,但不应因此让整脚本无法启动—— // 用默认值继续跑,同时把问题显著地抛到日志里。 logger.error('config', '配置文件解析失败,改用默认值:' + error); return null; } } function validate(config) { var target = config.target; if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') { throw new ConfigError('target.keyword 不能为空', target.keyword); } /* 「评论池为空」只有在**要发评论**时才算配置错误: * 只浏览不发评论是合法的一次运行(界面上没勾评论就是这种情形)。 */ if (config.comments.enabled && config.comments.pool.length === 0) { throw new ConfigError('comments.pool 不能为空,至少需要一条评论文案', 0); } if (config.watch.minMs > config.watch.maxMs) { throw new ConfigError('watch.minMs 不能大于 watch.maxMs', { minMs: config.watch.minMs, maxMs: config.watch.maxMs }); } if (config.target.maxVideos < 1) { throw new ConfigError('target.maxVideos 必须 >= 1', config.target.maxVideos); } return config; } /** * 解析最终生效的配置。 * * @param injected PC 侧注入的覆盖项(可为 null) */ function resolve(injected) { var logger = TikTokAuto.logger; var fromDefaults = clone(DEFAULTS); var path = (injected && injected.runtime && injected.runtime.configPath) || DEFAULTS.runtime.configPath; var fromDevice = readDeviceJson(path) || {}; var config = merge(merge(fromDefaults, fromDevice), injected || {}); validate(config); logger.info('config', '生效配置:' + JSON.stringify({ keyword: config.target.keyword, maxVideos: config.target.maxVideos, resultTab: config.target.resultTab, watchMs: [config.watch.minMs, config.watch.maxMs], commentEnabled: config.comments.enabled, commentCount: config.comments.pool.length, dryRun: config.runtime.dryRun })); return config; } return { DEFAULTS: DEFAULTS, DEFAULT_DEVICE_PATH: DEFAULT_DEVICE_PATH, readDeviceJson: readDeviceJson, merge: merge, resolve: resolve, ConfigError: ConfigError }; }()); /* ============================== src/wait.js ============================== */ /** * wait.js — 唯一的等待原语 * * 核心主张:**脚本里不允许出现裸 sleep 当作"等待"**。 * * tick(轮询节拍) = "多久检查一次" → 允许存在,默认 150ms * sleep(盲等) = "赌它 X 秒后会好" → 禁止用作等待手段 * * 为什么这是稳定性的关键:网络抖动时页面可能 300ms 才渲染,也可能 8s 才渲染。 * 写 `sleep(2000)` 的脚本在快的时候白等 1.7s,在慢的时候直接失败; * 而 `waitFor(条件, 超时)` 在快的时候立刻返回,慢的时候一直等到条件成立。 * 这把"网络有多慢"从"脚本能不能跑通"里彻底解耦。 * * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合) */ TikTokAuto.wait = (function () { 'use strict'; var DEFAULT_TICK_MS = 150; function now() { return Date.now(); } /** * 轮询节拍。 * * ★ 这里是**唯一**的等待出口,所以「用户点了停止」的检查放在这一处就够了 —— * 全流程(等待应用启动、等待播放、等待结果卡片、等待评论区……共几十处等待) * 都会经过 tick,于是都能被打断,业务代码一行都不用改。 * bridge 未启用时是空操作(独立运行时零开销)。 */ function tick(ms) { if (TikTokAuto.bridge) { TikTokAuto.bridge.checkStop(); } sleep(ms === undefined ? DEFAULT_TICK_MS : ms); } /** * 反复求值 predicate 直到它返回"真值"或超时。 * * predicate 的返回值语义: * - null / false → 条件未满足,继续轮询 * - 其它任何值(含 0!) → 条件已满足,原样返回该值 * 之所以不用 `if (value)` 判断,是为了允许 predicate 合法地返回 0 / '' 这类"假值结果"。 * * @returns 满足时的返回值;超时返回 null */ function poll(predicate, timeoutMs, tickMs) { var deadline = now() + timeoutMs; var step = tickMs === undefined ? DEFAULT_TICK_MS : tickMs; var last = null; for (;;) { try { var value = predicate(); if (value !== null && value !== false && value !== undefined) { return value; } last = value; } catch (error) { last = error; } if (now() >= deadline) { return null; } tick(Math.min(step, Math.max(1, deadline - now()))); } } /** * 带日志与语义名字的 poll —— 业务代码应该用这个,而不是裸 poll。 * 超时会记 WARN(而不是静默返回 null),这样"卡在哪一步"永远有据可查。 */ function waitFor(name, predicate, timeoutMs, tickMs) { var began = now(); var value = poll(predicate, timeoutMs, tickMs); var cost = now() - began; if (value === null) { TikTokAuto.logger.warn('waitFor', name + ' 超时(等了 ' + cost + 'ms / 上限 ' + timeoutMs + 'ms)'); } else { TikTokAuto.logger.info('waitFor', name + ' 就绪(' + cost + 'ms)'); } return value; } /** 等待条件成立,并把成立时的值原样返回;语义等价于 waitFor */ function until(condition, timeoutMs, tickMs) { return poll(condition, timeoutMs, tickMs); } /** 等待某个东西消失(例如弹窗、加载圈) */ function untilGone(isPresent, timeoutMs, tickMs) { return poll(function () { return isPresent() ? null : true; }, timeoutMs, tickMs); } /** * 等待页面"安静"下来。 * * 判据不是时间,而是**可见文案的拼接签名连续 N 次不变**。 * 这比 sleep 可靠:加载中会不断有新节点出现,签名就会变; * 真正稳定后才返回,因此天然适配"网络慢 → 渲染慢"。 * * @param sample () => string,页面签名 * @param options { maxMs, tickMs, stableTimes } */ function waitQuiet(sample, options) { var settings = options || {}; var maxMs = settings.maxMs === undefined ? 8000 : settings.maxMs; var tickMs = settings.tickMs === undefined ? 250 : settings.tickMs; var stableTimes = settings.stableTimes === undefined ? 3 : settings.stableTimes; var deadline = now() + maxMs; var previous = null; var sameCount = 0; while (now() < deadline) { var current = sample(); if (current === previous) { sameCount += 1; if (sameCount >= stableTimes) { return true; } } else { sameCount = 0; previous = current; } tick(tickMs); } return false; } return { DEFAULT_TICK_MS: DEFAULT_TICK_MS, tick: tick, poll: poll, waitFor: waitFor, until: until, untilGone: untilGone, waitQuiet: waitQuiet }; }()); /* ============================== src/layout.js ============================== */ /** * layout.js — 对 AutoX 无障碍 API 的适配层(Boundary Adapter) * * 依据《代码整洁之道》第 8 章「边界」: * 第三方/平台 API 一定有自己的脾气(`auto.root` 是属性不是函数、 * `text()` 是快照、离屏节点占了 40%+…)。把所有这些脾气**关在这一个文件里**, * 业务代码就只面对一组干净、语义明确的函数。 * 平台 API 变了 → 只改这里。 * * 依赖:TikTokAuto.logger、TikTokAuto.wait */ TikTokAuto.layout = (function () { 'use strict'; var SAFE_MARGIN = 8; function screen() { return { width: device.width, height: device.height }; } /** 当前应用窗口的根节点。注意:是属性 `auto.root`,不是函数 `auto().root()` */ function root() { return auto.root; } /** 前台包名。**不能**用 currentPackage()——软键盘弹出时它返回输入法包名 */ function foregroundPackage() { var node = root(); return node ? node.packageName() : null; } function findAll(selector) { return selector.find(); } function findOnce(selector) { var collection = selector.find(); return collection && collection.size() > 0 ? collection.get(0) : null; } /** * 收集整棵树的节点(含离屏)。 * AutoX 的 `find()` 只从根往下找**可见**节点,拿不到离屏节点; * 而我们需要离屏信息来做"下面还有内容 → 该滚动"的判断。 */ function collectAll(maxNodes) { var limit = maxNodes || 1200; var result = []; var start = root(); if (!start) { return result; } var stack = [start]; while (stack.length > 0 && result.length < limit) { var node = stack.pop(); if (!node) { continue; // 遍历过程中节点可能失效,child(i) 会返回 null } result.push(node); var count = node.childCount(); for (var i = 0; i < count; i += 1) { stack.push(node.child(i)); } } return result; } /** 可见性判据:优先用原生 isVisibleToUser(),退化为"与屏幕求交" */ function isVisible(node) { try { if (typeof node.isVisibleToUser === 'function') { return node.isVisibleToUser(); } } catch (ignored) { // 落到下面的几何判据 } try { if (typeof node.visibleToUser === 'function') { return node.visibleToUser(); } } catch (ignoredToo) { // 落到下面的几何判据 } return visibleRect(node) !== null; } /** 与屏幕求交后的可见矩形;完全在屏幕外时返回 null */ function visibleRect(node) { var box = node.bounds(); var view = screen(); var left = Math.max(box.left, 0); var top = Math.max(box.top, 0); var right = Math.min(box.right, view.width); var bottom = Math.min(box.bottom, view.height); if (right - left <= 0 || bottom - top <= 0) { return null; } return { left: left, top: top, right: right, bottom: bottom }; } /** * 可点击坐标 = **可见交集矩形的中心**,再夹到安全区内。 * * 为什么要交集而不是几何中心:被状态栏切掉一半的节点,几何中心可能落在 y=0 * (点到了状态栏),交集中心才是真正能摸到的地方。 */ function center(node) { var rect = visibleRect(node); var view = screen(); if (!rect) { return null; } var x = Math.round((rect.left + rect.right) / 2); var y = Math.round((rect.top + rect.bottom) / 2); return { x: Math.min(Math.max(x, SAFE_MARGIN), view.width - SAFE_MARGIN), y: Math.min(Math.max(y, SAFE_MARGIN), view.height - SAFE_MARGIN) }; } /** * 去掉文案里的**不可见格式字符**(零宽空格、LTR/RTL 标记、软连字符等)。 * * 这是真机踩出来的坑:评论列表头部的计数实际文本是 "‎150 comments", * 前面挂着一个 U+200E(LEFT-TO-RIGHT MARK)——肉眼完全看不出来, * 但 `/^\s*\d+ comments$/` 这种带锚点的正则就是匹配不上, * 于是"评论列表确认"这条最强证据永远拿不到,只能退化到弱证据。 * * 放在适配层里统一处理:平台这类噪音只在这里吸掉一次,业务代码就干净了。 */ function sanitize(value) { if (value === null || value === undefined) { return ''; } return String(value) .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff\u00ad]/g, '') .replace(/\s+/g, ' ') .trim(); } function text(node) { var value = ''; try { value = node.text(); } catch (ignored) { value = ''; } return sanitize(value); } function desc(node) { var value = ''; try { value = node.desc(); } catch (ignored) { value = ''; } return sanitize(value); } /** 节点的"可读标签":文案优先,没有就看内容描述 */ function label(node) { return text(node) || desc(node); } function resourceId(node) { try { var id = node.id(); return id ? String(id) : ''; } catch (ignored) { return ''; } } function shortId(node) { var id = resourceId(node); var parts = id.split('/'); return parts.length > 1 ? parts[parts.length - 1] : id; } function classNameOf(node) { try { return String(node.className()); } catch (ignored) { return ''; } } function shortClass(node) { var parts = classNameOf(node).split('.'); return parts[parts.length - 1]; } function isClickable(node) { try { return node.clickable() === true; } catch (ignored) { return false; } } function isScrollable(node) { try { return node.scrollable() === true; } catch (ignored) { return false; } } function isEditable(node) { return /EditText/i.test(classNameOf(node)); } function parent(node) { try { return node.parent(); } catch (ignored) { return null; } } /** 从 node 向上找到第一个可点击的祖先(不含自身) */ function clickableAncestor(node, maxDepth) { var depth = 0; var limit = maxDepth === undefined ? 8 : maxDepth; var cursor = parent(node); while (cursor && depth < limit) { if (isClickable(cursor)) { return cursor; } cursor = parent(cursor); depth += 1; } return null; } /** 节点在屏幕上的矩形是否与给定纵向区间有明显重叠 */ function overlapsBand(node, top, bottom, minRatio) { var rect = visibleRect(node); if (!rect) { return false; } var overlap = Math.min(rect.bottom, bottom) - Math.max(rect.top, top); if (overlap <= 0) { return false; } var ratio = minRatio === undefined ? 0.6 : minRatio; return overlap >= (rect.bottom - rect.top) * ratio; } /** 扁平化渲染,用于把当前界面一行一个控件打进日志(排障时非常有用) */ function describe(nodes, limit) { var lines = []; var max = limit === undefined ? nodes.length : Math.min(limit, nodes.length); for (var i = 0; i < max; i += 1) { var node = nodes[i]; var point = center(node); lines.push([ shortClass(node), shortId(node), point ? '(' + point.x + ',' + point.y + ')' : '(offscreen)', isClickable(node) ? 'clk' : '-', JSON.stringify(label(node)).slice(0, 48) ].join(' ')); } return lines.join(' | '); } /** * 页面签名 = 所有可见节点文案/描述的拼接。 * 用途:判断"页面变没变""是否已经稳定",完全不依赖时间。 */ function signature() { var nodes = collectAll(1500); var parts = []; for (var i = 0; i < nodes.length; i += 1) { if (!isVisible(nodes[i])) { continue; } var value = label(nodes[i]); if (value) { parts.push(value); } } return parts.join('\u0001'); } return { SAFE_MARGIN: SAFE_MARGIN, screen: screen, root: root, foregroundPackage: foregroundPackage, findAll: findAll, findOnce: findOnce, collectAll: collectAll, isVisible: isVisible, visibleRect: visibleRect, center: center, text: text, desc: desc, label: label, sanitize: sanitize, resourceId: resourceId, shortId: shortId, classNameOf: classNameOf, shortClass: shortClass, isClickable: isClickable, isScrollable: isScrollable, isEditable: isEditable, parent: parent, clickableAncestor: clickableAncestor, overlapsBand: overlapsBand, describe: describe, signature: signature }; }()); /* ============================== src/ui.js ============================== */ /** * ui.js — 通用交互动作 * * 这一层只回答一个问题:"怎么把一次『点击』做成功"。 * 它不认识 TikTok,也不认识业务流程(那是 tiktok.js / flow.js 的事)—— * 这是《代码整洁之道》第 3 章「只做一件事」的落地。 * * 点击的四级降级(都走无障碍,**不需要 Root**): * 1. node.click() * 2. 最近的可点击祖先 click() ← 大多数"点不动"的真凶:文案节点本身不可点 * 3. 手势 click(可见交集中心) * 4. 手势 Tap(可见交集中心) * 并且返回"真正点到了谁",让调用方可以核对,而不是默默点错地方。 * * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.wait */ TikTokAuto.ui = (function () { 'use strict'; function tapPoint(x, y) { click(x, y); } /** * 点击一个节点,逐级降级直到成功。 * @returns {ok, via, targetCls, targetLabel, point} */ /* ---- 悬浮球避让(规格书 §10.9③)---- * * 全局悬浮球浮在**所有应用之上**(包括 TikTok),所以坐标手势如果正好落在球的 * 窗口矩形里,点到的是球、不是 TikTok —— 实测就是这样:搜索建议列表的某一条 * 正好压在球位,`click(x,y)` 把球的扇形面板点了出来(用户看到的是"面板自己弹出来")。 * * 规格书给的缓解办法是「脚本侧点击区域避开球位」,这里就做这一件事: * ① 界面把球的窗口矩形写进 ball-rect.json(球的窗口比球大一圈,见 floatball.js); * ② 本模块在**走坐标手势之前**读它,命中就把落点挪到同节点内、球区之外的角落; * ③ 整块节点都在球区里(挪不开)→ 放弃这次点击并记 WARN, * 让日志能解释"这次为什么没点成",而不是默默点到了球上。 * * ⚠️ 注意 `node.click()` 全程不受影响:那是无障碍点击,不产生屏幕坐标, * 压根碰不到悬浮窗 —— 所以避让只挂在手势这条兜底路径上。 */ var BALL_RECT_PATH = '/sdcard/tiktok_app/ball-rect.json'; var ballRectCache = { at: 0, rect: null }; function ballRect() { var now = Date.now(); if (now - ballRectCache.at < 500) { return ballRectCache.rect; } ballRectCache.at = now; ballRectCache.rect = null; try { if (!files.exists(BALL_RECT_PATH)) { return null; } var raw = JSON.parse(String(files.read(BALL_RECT_PATH))); if (!raw || raw.hidden || raw.open) { return null; } /* 隐藏 / 扇形展开时不挡点击判断 */ if (typeof raw.x !== 'number' || typeof raw.w !== 'number') { return null; } ballRectCache.rect = raw; } catch (error) { /* 读不到就当没有球:宁可偶尔点到球,也不要因为一个坏文件让整个脚本点不动 */ ballRectCache.rect = null; } return ballRectCache.rect; } function inside(rect, x, y) { return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; } /** * 把落点挪出球区。挪不动返回 null。 * 候选点是「节点四角内缩」:同一节点内的任何位置对这次点击都等价, * 而角落最可能远离屏幕中部的球位。 */ function shiftOffBall(point, node, rect) { if (!inside(rect, point.x, point.y)) { return point; } var layout = TikTokAuto.layout; /* 复用 layout.visibleRect:它已经处理了"可见区域/被遮挡"这些边界情况, * 自己再写一份 node.bounds() 会与 center() 的口径不一致。 */ var b = layout.visibleRect(node); if (!b) { return null; } var pad = 6; var cands = [ { x: b.left + pad, y: b.top + pad }, { x: b.right - pad, y: b.top + pad }, { x: b.left + pad, y: b.bottom - pad }, { x: b.right - pad, y: b.bottom - pad } ]; for (var i = 0; i < cands.length; i++) { if (cands[i].x > 0 && cands[i].y > 0 && !inside(rect, cands[i].x, cands[i].y)) { return cands[i]; } } return null; } function clickNode(node, step) { var story = step || 'click'; var layout = TikTokAuto.layout; var targetClass = layout.shortClass(node); var targetLabel = layout.label(node); if (node.click()) { return finish(story, true, 'node.click()', targetClass, targetLabel, layout.center(node)); } var ancestor = layout.clickableAncestor(node); if (ancestor && ancestor.click()) { return finish(story, true, 'clickableAncestor.click()', targetClass, targetLabel, layout.center(ancestor)); } var point = layout.center(node); if (point) { var rect = ballRect(); if (rect) { var safe = shiftOffBall(point, node, rect); if (!safe) { return finish(story, false, 'blocked-by-floatball', targetClass, targetLabel, point); } point = safe; } click(point.x, point.y); return finish(story, true, 'gesture click(visibleCenter)', targetClass, targetLabel, point); } return finish(story, false, 'no-clickable-target', targetClass, targetLabel, null); } function finish(step, ok, via, targetClass, targetLabel, point) { var logger = TikTokAuto.logger; var where = point ? '(' + point.x + ',' + point.y + ')' : '(n/a)'; logger.info(step, '点击 ' + (ok ? '成功' : '失败') + ' via=' + via + ' target=' + targetClass + ' ' + JSON.stringify(targetLabel).slice(0, 40) + ' ' + where); return { ok: ok, via: via, targetClass: targetClass, targetLabel: targetLabel, point: point }; } /** * 往一个**已知节点**写入文本,并重新查找节点回读校验。 * * 必须回读:AutoX 的 UiObject 是 find 那一刻的快照, * setText 之后立刻 .text() 读到的还是旧值,直接判定"写入失败"是经典误判。 */ function typeInto(node, value, step) { var logger = TikTokAuto.logger; var layout = TikTokAuto.layout; var story = step || 'typeInto'; if (!node) { logger.error(story, '输入目标为空'); return { ok: false, via: 'no-target' }; } // 先让它真正获得焦点,否则部分输入法/控件不会提交文本 var point = layout.center(node); if (point) { click(point.x, point.y); TikTokAuto.wait.tick(200); } node.setText(value); TikTokAuto.wait.tick(300); var actual = readEditableText(value); var ok = actual === value; if (!ok) { logger.warn(story, '回读不一致,重试一次。期望=' + JSON.stringify(value) + ' 实际=' + JSON.stringify(actual)); node.setText(value); TikTokAuto.wait.tick(400); actual = readEditableText(value); ok = actual === value; } logger.info(story, '写入' + (ok ? '成功' : '失败') + ' 实际=' + JSON.stringify(actual)); return { ok: ok, via: 'setText+readback', actual: actual }; } /** 便捷版:传选择器,内部自己找节点 */ function setText(selector, value, step) { return typeInto(TikTokAuto.layout.findOnce(selector), value, step); } /** 回读:取与期望值相等的输入框文本;没有就返回最长的那条(便于排查) */ function readEditableText(expected) { var layout = TikTokAuto.layout; var collection = className('android.widget.EditText').find(); var longest = ''; for (var i = 0; i < collection.size(); i += 1) { var value = layout.text(collection.get(i)); if (value === expected) { return value; } if (String(value).length > longest.length) { longest = value; } } return longest; } function isEditableText(expected) { return readEditableText(expected) === expected; } /** * 滑动。 * 注意函数名不能叫 `swipe`——那会遮蔽 AutoX 的全局 `swipe()`,导致自我递归。 */ function dragScreen(x1, y1, x2, y2, durationMs) { swipe(x1, y1, x2, y2, durationMs === undefined ? 320 : durationMs); } /** * 返回上一页。 * 注意:只在**明确的页面迁移**里调用(如 播放页 → 结果页)。 * 永远不要把它当成"兜底清理"——那会把正常页面也退掉。 */ function goBack(step) { TikTokAuto.logger.info(step || 'back', '按下返回'); back(); } return { tapPoint: tapPoint, clickNode: clickNode, typeInto: typeInto, setText: setText, readEditableText: readEditableText, isEditableText: isEditableText, dragScreen: dragScreen, goBack: goBack }; }()); /* ============================== src/popups.js ============================== */ /** * popups.js — 干扰弹窗的「检测 / 执行分离」 * * 依据 AutoX 真机经验(也是《代码整洁之道》第 3 章的"指令查询分离"): * - 把「看」和「动」拆成两组函数。guard 线程负责"动", * 主流程只用只读的 popupPresent() 去**等**它清干净,绝不抢它的活。 * - **绝不 back() 正常页面**。登录页/权限页的兜底必须命中"登录方式"这类语境关键词, * 否则在「我的」页看到"立即登录"就会把正常页面退掉。 * * 对未知弹窗的策略是**记录而不是猜**: * 命中已知签名 → 点掉;看起来像弹窗但不在名单里 → 只记 WARN + dump 界面, * 由人来决定要不要加进名单。比"猜着点"安全得多。 * * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait */ TikTokAuto.popups = (function () { 'use strict'; /** * 已知的阻塞型弹窗签名。 * contains 里任一命中即认为出现;dismiss 决定点哪个按钮。 */ var KNOWN_DIALOGS = [ { name: 'save-login-info', contains: ['Save login info', 'Save your login info', '记住登录'], dismiss: ['Not now', 'Cancel', '稍后', '取消'] }, { name: 'notifications-permission', contains: ['Turn on notifications', 'get notified', '打开通知'], dismiss: ['Not now', 'No thanks', 'Skip', '稍后', '暂不'] }, { name: 'rate-app', contains: ['Enjoying TikTok', 'Rate this app', 'rate your experience'], dismiss: ['Not now', 'No thanks', 'Later', '稍后'] }, { name: 'update-app', contains: ['Update TikTok', 'A new version'], dismiss: ['Not now', 'Later', '稍后'] }, { name: 'open-in-app', contains: ['Open in app', 'Continue in app'], dismiss: ['Cancel', 'Not now', '取消'] } ]; /** 只读:扫描当前应用窗口,返回命中的已知弹窗名 + 疑似未知弹窗 */ function scan() { var layout = TikTokAuto.layout; var visibleLabels = collectVisibleLabels(); var joined = visibleLabels.join(' \u0001 '); var hits = []; for (var i = 0; i < KNOWN_DIALOGS.length; i += 1) { var dialog = KNOWN_DIALOGS[i]; if (containsAny(joined, dialog.contains)) { hits.push(dialog); } } return { known: hits, looksBlocking: hits.length > 0 || looksLikeUnknownModal(visibleLabels) }; } function containsAny(haystack, needles) { for (var i = 0; i < needles.length; i += 1) { if (haystack.indexOf(needles[i]) !== -1) { return true; } } return false; } function collectVisibleLabels() { var layout = TikTokAuto.layout; var nodes = layout.collectAll(1200); var labels = []; for (var i = 0; i < nodes.length; i += 1) { if (!layout.isVisible(nodes[i])) { continue; } var value = layout.label(nodes[i]); if (value) { labels.push(value); } } return labels; } /** * "看起来像弹窗"的启发式判据(仅用于告警,不驱动动作): * 可见文案很少(弹窗通常只有标题 + 一两个按钮),却又出现了否定的动作词。 * 正常页面(信息流/结果页)动辄几十条文案,不会命中。 */ function looksLikeUnknownModal(labels) { var dismissLabels = TikTokAuto.config ? TikTokAuto.config.DEFAULTS.guard.dismissLabels : ['Not now', 'Cancel', '稍后', '取消']; if (labels.length > 8) { return false; } return containsAny(labels.join('\u0001'), dismissLabels); } /** 只读:是否还有阻塞弹窗(主流程用它"等"guard 清场) */ function popupPresent() { return scan().looksBlocking; } /** 执行:尝试点掉一个已知弹窗。返回是否真的动了手 */ function dismissKnown(step) { var logger = TikTokAuto.logger; var layout = TikTokAuto.layout; var found = scan(); if (found.known.length === 0) { return false; } for (var i = 0; i < found.known.length; i += 1) { var dialog = found.known[i]; for (var j = 0; j < dialog.dismiss.length; j += 1) { var button = findExactLabel(dialog.dismiss[j]); if (button) { logger.info(step || 'popup', '关闭已知弹窗 ' + dialog.name + ' → 点击 ' + JSON.stringify(dialog.dismiss[j])); TikTokAuto.ui.clickNode(button, step || 'popup'); TikTokAuto.wait.tick(400); return true; } } logger.warn(step || 'popup', '命中弹窗 ' + dialog.name + ' 但找不到关闭按钮,跳过'); } return false; } /** 按精确文案/描述找可见节点;文案优先于描述 */ function findExactLabel(value) { var layout = TikTokAuto.layout; var nodes = layout.collectAll(1200); for (var i = 0; i < nodes.length; i += 1) { if (layout.text(nodes[i]) === value && layout.isVisible(nodes[i])) { return nodes[i]; } } for (var j = 0; j < nodes.length; j += 1) { if (layout.desc(nodes[j]) === value && layout.isVisible(nodes[j])) { return nodes[j]; } } return null; } /** * 启动守护线程:周期性清掉已知弹窗。 * * 返回一个句柄,其中 passes 计数让主流程确认"它已经上岗了"—— * 否则主流程可能在守护线程第一次巡检之前就撞上弹窗。 */ function startGuard(config, logger) { var state = { passes: 0, dismissed: 0, stopped: false }; if (!config.guard.enabled) { logger.info('guard', '守护线程已通过配置关闭'); state.passes = 1; return state; } var thread = threads.start(function () { while (!state.stopped) { try { if (dismissKnown('guard')) { state.dismissed += 1; } state.passes += 1; } catch (error) { /* 用户叫停时会从 wait.tick 抛出 StopSignal。守护线程不该继续巡检, * 也不该每一轮都记一条 WARN 把日志刷满 —— 直接收工。 */ if (TikTokAuto.bridge && TikTokAuto.bridge.isStopSignal(error)) { logger.info('guard', '收到停止请求,守护线程收工'); break; } logger.warn('guard', '巡检异常:' + error); } sleep(config.guard.intervalMs); } }); state.thread = thread; logger.info('guard', '守护线程已启动,间隔 ' + config.guard.intervalMs + 'ms'); return state; } function stopGuard(state, logger) { if (!state) { return; } state.stopped = true; logger.info('guard', '守护线程已停止,累计清场 ' + state.dismissed + ' 次'); } return { KNOWN_DIALOGS: KNOWN_DIALOGS, scan: scan, popupPresent: popupPresent, dismissKnown: dismissKnown, findExactLabel: findExactLabel, startGuard: startGuard, stopGuard: stopGuard }; }()); /* ============================== src/tiktok.js ============================== */ /** * tiktok.js — TikTok 的应用知识(选择器 + 页面状态判定) * * 这个文件是**唯一**允许出现"TikTok 具体长什么样"的地方。 * 换 App、或 TikTok 改版时,只动这里,flow.js 一行都不用改。 * * 所有选择器都基于**真机实测**的锚点,并且在实测基础上刻意选了"语义锚点"而不是 * 混淆过的资源 id——TikTok 的 id 形如 `e4g`/`cn6`/`e8x`,每次改版都会重排; * 而 "Read or add comments" 这类无障碍文案相当稳定。 * 少数确实只能靠 id 的地方(发送按钮)走 config.selectors 覆盖,便于不改代码修。 * * 实测锚点(1080x1920,com.zhiliaoapp.musically): * 信息流搜索入口 ImageView desc="Search" [968,50,1080,162] * 搜索页输入框 EditText (top < 12%H) * 搜索页提交 Button text="Search" (left > 80%W) * 结果页分类页签 text/desc ∈ {Top,Users,Videos,...} y ≈ 152..232 * 结果页卡片 Button(卡片文案) width ≥ 35%W, top > 30%H * 播放页 "Read or add comments" + "Like video." 同时存在 * 播放页评论入口 Button desc="Read or add comments. N comments" * 播放页内联输入 EditText desc/text="Add comment..." * 评论面板输入 EditText top ∈ [50%H,80%H] * 面板工具行 desc="Stickers" / "Mention someone" / "Microphone" * 面板发送按钮 工具行同高、最右、且 right ≤ W-8 的可点击 Button(实测 id=cn6) * * 依赖:TikTokAuto.logger、TikTokAuto.layout */ TikTokAuto.tiktok = (function () { 'use strict'; var PKG = 'com.zhiliaoapp.musically'; var COMMENT_DESC_KEYS = ['Read or add comments', '评论']; var LIKE_DESC_KEYS = ['Like video.', '点赞']; var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment']; var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音']; var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos']; var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证 var logger = TikTokAuto.logger; function screenWidth() { return device.width; } function screenHeight() { return device.height; } function startsWithAny(value, keys) { for (var i = 0; i < keys.length; i += 1) { if (value.indexOf(keys[i]) === 0) { return true; } } return false; } function equalsAny(value, keys) { for (var i = 0; i < keys.length; i += 1) { if (value === keys[i]) { return true; } } return false; } function widthOf(node) { var box = node.bounds(); return box.right - box.left; } function topOf(node) { return node.bounds().top; } function bottomOf(node) { return node.bounds().bottom; } /** 按"文案或描述命中"收集可见节点 */ function findByLabel(selector, predicate) { var result = []; var collection = selector.find(); for (var i = 0; i < collection.size(); i += 1) { var node = collection.get(i); if (predicate(node) && layout.isVisible(node)) { result.push(node); } } return result; } // ---------------------------------------------------------------- 信息流 /** 信息流右上角的搜索入口 */ function searchEntry() { var hits = findByLabel(className('android.widget.ImageView'), function (node) { var point = layout.center(node); return layout.desc(node) === 'Search' && point !== null && point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.85; }); return hits.length > 0 ? hits[0] : null; } /** 信息流就绪判据:底部导航存在 */ function bottomNavPresent() { return layout.findOnce(desc('Home')) !== null && layout.findOnce(desc('Profile')) !== null; } // ---------------------------------------------------------------- 搜索页 /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */ function searchInput() { var hits = findByLabel(className('android.widget.EditText'), function (node) { return topOf(node) < screenHeight() * 0.12; }); return hits.length > 0 ? hits[0] : null; } function searchSubmit() { var hits = findByLabel(className('android.widget.Button'), function (node) { var point = layout.center(node); return layout.text(node) === 'Search' && point !== null && point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.8; }); return hits.length > 0 ? hits[0] : null; } /** 搜索页特征:搜索历史行(带删除按钮)或"发现"按钮 */ function searchHistoryPresent() { return layout.findOnce(desc('Close')) !== null || layout.findOnce(desc('Find')) !== null; } // ---------------------------------------------------------------- 结果页 /** 结果页顶部分类页签 */ function resultTab(name) { var hits = findByLabel(className('android.widget.TextView'), function (node) { return equalsAny(layout.text(node) || layout.desc(node), [name]) && topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16; }); if (hits.length > 0) { return hits[0]; } // 页签的文案节点本身常常不可点,真正可点的是外层 FrameLayout var frames = findByLabel(className('android.widget.FrameLayout'), function (node) { return equalsAny(layout.desc(node) || layout.text(node), [name]) && topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16; }); return frames.length > 0 ? frames[0] : null; } function resultTabStripPresent() { var found = 0; for (var i = 0; i < RESULT_TABS.length; i += 1) { if (resultTab(RESULT_TABS[i])) { found += 1; } } return found >= 3; } /** * 从卡片文案锚点向上找"卡片容器"。 * 为什么不能直接点文案:文案节点多数不可点,而它的可点祖先可能是**整屏容器**—— * 点下去等于点在屏幕中间,什么也不会发生(实测踩过)。 * 因此要求容器高度落在合理的卡片区间内,否则认为锚点自己就是最佳目标。 */ function cardContainer(anchor) { var height = screenHeight(); var cursor = layout.clickableAncestor(anchor); var depth = 0; while (cursor && depth < 6) { var cardHeight = bottomOf(cursor) - topOf(cursor); if (cardHeight >= height * 0.18 && cardHeight <= height * 0.8) { return cursor; } cursor = layout.clickableAncestor(cursor); depth += 1; } return anchor; } /** * 结果页的视频卡片列表。 * * 判据(都来自实测踩坑): * - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉; * - top > 30%H,据此排掉顶部搜索框; * - 底部被导航栏切掉半截的先不收,避免点到一半的位置。 */ function videoCards() { var width = screenWidth(); var height = screenHeight(); var cards = []; var seen = {}; var hits = findByLabel(className('android.widget.Button'), function (node) { return widthOf(node) >= width * 0.35 && topOf(node) > height * 0.3 && bottomOf(node) <= height - 12; }); for (var i = 0; i < hits.length; i += 1) { var caption = layout.text(hits[i]) || layout.desc(hits[i]); if (!caption) { continue; } var key = caption + '@' + topOf(hits[i]); if (seen[key]) { continue; } seen[key] = true; cards.push({ anchor: hits[i], container: cardContainer(hits[i]), caption: caption, top: topOf(hits[i]) }); } cards.sort(function (a, b) { return a.top - b.top; }); return cards; } // ---------------------------------------------------------------- 播放页 function commentButton() { var hits = findByLabel(className('android.widget.Button'), function (node) { return startsWithAny(layout.desc(node), COMMENT_DESC_KEYS); }); return hits.length > 0 ? hits[0] : null; } function likeButton() { var hits = findByLabel(className('android.widget.Button'), function (node) { return startsWithAny(layout.desc(node), LIKE_DESC_KEYS); }); return hits.length > 0 ? hits[0] : null; } /** * 播放页判据。 * * 教训(实测踩到):**信息流首页也有 "Like video." 和 "Read or add comments" 这一竖排**, * 所以只看这两个会一路把首页判成播放页,整个流程直接跑歪。 * 真正能区分"从搜索结果点进来的播放页"的是: * - 左上角有返回箭头(首页没有) * - 没有底部导航栏(首页有 Home/Friends/Inbox/Profile) * 四条同时成立才算播放页。 */ function onPlayerPage() { return commentButton() !== null && likeButton() !== null && backButton() !== null && !bottomNavPresent(); } function backButton() { var hits = findByLabel(className('android.widget.ImageView'), function (node) { return layout.desc(node) === 'Back'; }); return hits.length > 0 ? hits[0] : null; } /** 从 "Read or add comments. 150 comments" 里把 150 抠出来 */ function commentCount() { var button = commentButton(); if (!button) { return null; } var matched = layout.desc(button).match(/([\d][\d,.\s]*)\s*(?:comments?|条评论|评论)/i); if (!matched) { return null; } return parseInt(String(matched[1]).replace(/[^\d]/g, ''), 10); } /** 视频页底部的内联评论输入框(面板未打开时就是它) */ function inlineInput() { var hits = findByLabel(className('android.widget.EditText'), function (node) { return equalsAny(layout.text(node) || layout.desc(node), COMMENT_PLACEHOLDER) && topOf(node) > screenHeight() * 0.8; }); return hits.length > 0 ? hits[0] : null; } /** 评论面板里的输入框(打开面板后会出现在屏幕中部) */ function panelInput() { var hits = findByLabel(className('android.widget.EditText'), function (node) { var top = topOf(node); return top > screenHeight() * 0.45 && top < screenHeight() * 0.85; }); return hits.length > 0 ? hits[0] : null; } function toolRowAnchor() { for (var i = 0; i < TOOL_ROW_ANCHORS.length; i += 1) { var node = layout.findOnce(desc(TOOL_ROW_ANCHORS[i])); if (node && layout.isVisible(node)) { return node; } } return null; } /** * 评论发送按钮。 * * 它没有可用的文案(实测其 content-desc 是一个无关的账号串),图标是粉色箭头, * 所以只能靠**结构**定位: * 面板里"工具行"(表情/提及/语音那一行)的同高区域里, * 最靠右、且不属于右侧竖排动作栏(动作栏 right == 屏宽)的那个可点击按钮。 * * 优先用 config.selectors.sendButtonId(若配置里给了 id), * 这样 TikTok 改版时改配置即可,不用改代码。 */ function sendButton(config) { var override = config && config.selectors && config.selectors.sendButtonId; if (override) { var byId = layout.findOnce(id(override)); if (byId && layout.isVisible(byId)) { logger.info('comment', '发送按钮:使用配置 id ' + override); return byId; } } var anchor = toolRowAnchor(); if (!anchor) { logger.warn('comment', '找不到面板工具行,无法结构化定位发送按钮'); return null; } var bandTop = topOf(anchor); var bandBottom = bottomOf(anchor); var width = screenWidth(); var best = null; var hits = findByLabel(className('android.widget.Button'), function (node) { var point = layout.center(node); if (point === null) { return false; } var box = node.bounds(); if (box.top < bandTop || box.top > bandBottom) { return false; // 不在工具行高度带里 } if (box.right > width - layout.SAFE_MARGIN) { return false; // 属于右侧竖排动作栏 } return box.left >= width * 0.5; // 必须是右半边 }); for (var i = 0; i < hits.length; i += 1) { if (best === null || hits[i].bounds().left > best.bounds().left) { best = hits[i]; } } if (best) { logger.info('comment', '发送按钮:结构化定位 id=' + layout.shortId(best) + ' b=' + JSON.stringify(best.bounds())); } return best; } // ---------------------------------------------------------------- 评论列表 /** * 评论列表面板顶部的计数,形如 "150 comments"。 * * 为什么需要它:实测发现**视频页那个评论角标不一定会立刻刷新** * (发完评论后仍读回旧值),而评论列表头部的计数是即时的。 * 所以"计数自增"这条证据要用列表头部的那个,而不是角标的。 */ function listCommentCount() { var hits = findByLabel(className('android.widget.TextView'), function (node) { return /^[\d,\.]+\s*(comments?|条评论)$/i.test(layout.text(node)); }); if (hits.length === 0) { return null; } return parseInt(layout.text(hits[0]).replace(/[^\d]/g, ''), 10); } /** * 评论面板是否处于打开状态。 * 注意:点内联输入框打开的是"快捷面板"(有中部输入框), * 点评论角标打开的是"列表面板"(没有中部输入框,但有 "N comments" 头部)。 * 两种都要认,否则 back() 会漏按、面板关不掉。 */ function commentPanelOpen() { return panelInput() !== null || listCommentCount() !== null; } /** * 评论列表里是否出现了某条完全一致的文案。 * * 这里刻意**不设数量上限**:早先为了省事写成"只看前 25 条可见文本", * 结果遍历顺序是"先视频页、后评论面板",25 条全被视频页的文案占满了, * 评论条目根本没轮到,于是最强的那条证据永远拿不到。 * 可见 TextView 也就几百个,全扫一遍的开销远小于"判错"的代价。 */ function commentListContains(value) { var hits = findByLabel(className('android.widget.TextView'), function (node) { return layout.text(node) === value; }); return hits.length > 0; } /** 当前屏幕上所有可见的文本文案(按屏幕顺序) */ function visibleTexts(limit) { var hits = findByLabel(className('android.widget.TextView'), function () { return true; }); var texts = []; for (var i = 0; i < hits.length; i += 1) { var value = layout.text(hits[i]); if (value && value.length >= 2) { texts.push(value); } if (limit && texts.length >= limit) { break; } } return texts; } // ---------------------------------------------------------------- 页面状态 /** * 判定当前处于哪个页面。 * 顺序即优先级:播放页最特征化先判,信息流最宽松最后判。 */ function pageState() { // 评论面板要最先判:面板一盖上来,播放页的返回箭头就被遮住了, // 于是 onPlayerPage() 会变 false,整个页面掉进 unknown 再也出不来 // (实测踩到:脚本自己打开评论列表校验后没关掉,下一轮就彻底迷路)。 if (commentPanelOpen()) { return 'comment-panel'; } if (onPlayerPage()) { return 'player'; } if (resultTabStripPresent()) { return 'results'; } // 搜索页判据:顶部有输入框,且**没有**结果页的页签条。 // 早先还要求存在搜索历史行,结果"历史为空 / 已被清空"时整页被判成 unknown, // 于是 ensureFeed 一路僵住。页签条的有无已经足够区分搜索页和结果页。 if (searchInput() !== null && !resultTabStripPresent() && !bottomNavPresent()) { return 'search'; } if (searchEntry() !== null && bottomNavPresent()) { return 'feed'; } return 'unknown'; } /** 排障用:把当前可见的关键节点打出来 */ function describeScreen(tag) { var nodes = []; var classes = ['android.widget.Button', 'android.widget.EditText', 'android.widget.ImageView', 'android.widget.TextView']; for (var i = 0; i < classes.length; i += 1) { var collection = className(classes[i]).find(); for (var j = 0; j < collection.size(); j += 1) { if (layout.isVisible(collection.get(j))) { nodes.push(collection.get(j)); } } } logger.info(tag || 'screen', layout.describe(nodes, 40)); } return { PKG: PKG, pageState: pageState, searchEntry: searchEntry, searchInput: searchInput, searchSubmit: searchSubmit, searchHistoryPresent: searchHistoryPresent, bottomNavPresent: bottomNavPresent, resultTab: resultTab, resultTabStripPresent: resultTabStripPresent, videoCards: videoCards, commentButton: commentButton, likeButton: likeButton, onPlayerPage: onPlayerPage, backButton: backButton, commentCount: commentCount, inlineInput: inlineInput, panelInput: panelInput, toolRowAnchor: toolRowAnchor, sendButton: sendButton, listCommentCount: listCommentCount, commentPanelOpen: commentPanelOpen, commentListContains: commentListContains, visibleTexts: visibleTexts, describeScreen: describeScreen }; }()); /* ============================== src/player.js ============================== */ /** * player.js — 「看视频」这件事:播放存活探测 + 有界观看 * * 需求原话是"随机观看 10-35 秒,且不单靠 sleep"。 * 为什么裸 sleep 不行:sleep(20000) 只保证**墙上时钟**走了 20 秒, * 它不关心这 20 秒里视频是否卡在加载、是否弹了窗、是否被切走。 * 网络一抖,sleep 出来的"观看"就是假的。 * * 这里的做法:把"观看"实现成一个**带守卫的截止时间循环**—— * 1. 先等"播放真的开始"(有界等待,而不是假定它已经开始); * 2. 观看期间持续做两件事: * a) 校验"还在播放页"(页面身份漂移就立刻记账,连续漂移就放弃并上报); * b) 按固定节拍采样**播放存活证据**,连续多次无证据即判定卡顿,尝试一次唤醒; * 3. 循环里那个 sleep 是**轮询节拍**(多久检查一次),不是"等待多少秒"。 * * 播放存活证据(按可用性排序,可配置): * - useAudio : AudioManager.isMusicActive() —— 不依赖截图权限,实测可用 * - usePixels: captureScreen() 两点像素差 —— 最强,但 Android 14 需要 MediaProjection 授权 * 两者都不可用时,脚本**不会假装成功**:会退化为"页面守卫",并在结果里 * 明确标记 probeMode='page-guard-only',让上层知道这次的"观看"可信度较低。 * * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.tiktok、TikTokAuto.wait、TikTokAuto.ui */ TikTokAuto.player = (function () { 'use strict'; var PIXEL_SAMPLE_POINTS = [ [0.5, 0.35], [0.5, 0.5], [0.5, 0.65], [0.25, 0.45], [0.75, 0.45] ]; // ------------------------------------------------------------------ 证据源 function createAudioProbe() { var manager = null; try { if (typeof context !== 'undefined' && context) { manager = context.getSystemService('audio'); } } catch (error) { manager = null; } if (!manager) { return null; } return { name: 'audio', read: function () { try { return manager.isMusicActive() === true; } catch (error) { return false; } } }; } function createPixelProbe() { var first = null; function grab() { var image = captureScreen(); if (!image) { return null; } var width = image.getWidth(); var height = image.getHeight(); var samples = []; for (var i = 0; i < PIXEL_SAMPLE_POINTS.length; i += 1) { var x = Math.floor(width * PIXEL_SAMPLE_POINTS[i][0]); var y = Math.floor(height * PIXEL_SAMPLE_POINTS[i][1]); samples.push(image.pixel(x, y)); } return samples.join(','); } try { first = grab(); } catch (error) { return null; // 没有 MediaProjection 授权,直接放弃这条证据源 } if (first === null) { return null; } return { name: 'pixels', read: function () { try { var now = grab(); return now !== null && now !== first; } catch (error) { return false; } } }; } /** * 组装可用的探测源。 * 返回的 probeMode 会一路带到最终结果里——**不要**把降级悄悄藏起来。 */ function createProbe(config) { var sources = []; if (config.probe.useAudio) { var audio = createAudioProbe(); if (audio) { sources.push(audio); } } if (config.probe.usePixels) { var pixels = createPixelProbe(); if (pixels) { sources.push(pixels); } } return { mode: sources.length === 0 ? 'page-guard-only' : sources.map(function (s) { return s.name; }).join('+'), degraded: sources.length === 0, /** 任一证据源报"在动"就算活着 */ alive: function () { for (var i = 0; i < sources.length; i += 1) { if (sources[i].read()) { return true; } } return false; } }; } // ------------------------------------------------------------------ 等待播放 /** 等播放真正开始(而不是假定点开就在播) */ function waitPlaybackStart(config, step) { var logger = TikTokAuto.logger; var probe = createProbe(config); if (probe.degraded) { logger.warn(step, '没有可用的播放证据源(无音频/无截图权限),退化为页面守卫模式'); return { started: false, probe: probe }; } var started = TikTokAuto.wait.until(function () { return probe.alive() ? true : null; }, config.timeouts.playbackStartMs, config.probe.pollTickMs); if (started) { logger.info(step, '检测到播放已开始(证据源 ' + probe.mode + ')'); } else { logger.warn(step, '等待 ' + config.timeouts.playbackStartMs + 'ms 未检测到播放证据,仍继续(可能是静音视频)'); } return { started: started === true, probe: probe }; } // ------------------------------------------------------------------ 观看 function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } /** * 观看视频。 * * @param config 生效配置 * @param options { step, onInterrupt } * @returns 观看报告(对象,不是布尔——上层需要知道"为什么/可信度如何") */ function watch(config, options) { var settings = options || {}; var step = settings.step || 'watch'; var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; var targetMs = randomInt(config.watch.minMs, config.watch.maxMs); var start = waitPlaybackStart(config, step); var probe = start.probe; var beganAt = Date.now(); var deadline = beganAt + targetMs; var tickMs = config.probe.pollTickMs; var nextLivenessAt = beganAt + config.probe.livenessIntervalMs; var stalls = 0; var offPage = 0; var maxOffPage = 3; var nudges = 0; var aborted = null; logger.info(step, '开始观看,计划 ' + targetMs + 'ms(区间 ' + config.watch.minMs + '-' + config.watch.maxMs + 'ms),证据源=' + probe.mode); while (Date.now() < deadline) { // (a) 页面身份守卫:不在播放页就不是"在看视频" if (!tiktok.onPlayerPage()) { offPage += 1; logger.warn(step, '已离开播放页(第 ' + offPage + ' 次检测)'); if (offPage >= maxOffPage) { aborted = 'left-player-page'; break; } } else { offPage = 0; } // (b) 播放存活采样 if (Date.now() >= nextLivenessAt) { nextLivenessAt = Date.now() + config.probe.livenessIntervalMs; if (!probe.degraded) { if (probe.alive()) { stalls = 0; } else { stalls += 1; logger.warn(step, '未检测到播放证据(连续 ' + stalls + '/' + config.probe.stallTolerance + ')'); if (stalls >= config.probe.stallTolerance) { // 只尝试一次唤醒,不无限戳屏幕——那会把页面点出别的东西 if (nudges < 1) { nudge(step); nudges += 1; stalls = 0; } else { logger.warn(step, '判定卡顿且唤醒无效,提前结束观看'); aborted = 'stalled'; break; } } } } } TikTokAuto.wait.tick(tickMs); } var actualMs = Date.now() - beganAt; var report = { requestedMs: targetMs, actualMs: actualMs, probeMode: probe.mode, degraded: probe.degraded, nudges: nudges, aborted: aborted, completed: aborted === null && actualMs >= targetMs - tickMs }; if (report.completed) { logger.info(step, '观看完成 ' + actualMs + 'ms / 目标 ' + targetMs + 'ms'); } else { logger.warn(step, '观看未按计划完成:' + JSON.stringify(report)); } return report; } /** 轻点视频区域一次,尝试恢复播放(不会点到右侧动作栏) */ function nudge(step) { var layout = TikTokAuto.layout; var point = { x: Math.floor(layout.screen().width * 0.5), y: Math.floor(layout.screen().height * 0.4) }; TikTokAuto.logger.info(step, '尝试轻点画面恢复播放 @(' + point.x + ',' + point.y + ')'); TikTokAuto.ui.tapPoint(point.x, point.y); TikTokAuto.wait.tick(300); } /** 等播放页就绪(点开卡片之后、开始计时之前) */ function waitPlayerReady(config, step) { var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; var ready = TikTokAuto.wait.waitFor(step, function () { return tiktok.onPlayerPage() ? true : null; }, config.timeouts.playerReadyMs, config.probe.pollTickMs); if (ready) { TikTokAuto.wait.waitQuiet(function () { return TikTokAuto.layout.signature(); }, { maxMs: 3000, tickMs: 250, stableTimes: 3 }); } return ready === true; } return { createProbe: createProbe, waitPlaybackStart: waitPlaybackStart, waitPlayerReady: waitPlayerReady, watch: watch, randomInt: randomInt }; }()); /* ============================== src/comment.js ============================== */ /** * comment.js — 评论:挑文案 → 打开面板 → 输入 → 发送 → **验证真的发出去了** * * 关键设计:发送后必须**验证**,而且要"多条证据同时成立"才敢说成功。 * 只看着"面板关掉了"就宣布成功,是这类脚本最典型的假阳性来源 * (面板也可能因为别的原因关掉,而评论被平台拒了)。 * * 实测有效的两条证据: * 1. 评论计数自增: "Read or add comments. 149 comments" → "150 comments" * 2. 输入框被清空并恢复占位符 "Add comment...",且面板收起 * 默认以"计数自增"为强证据;计数有延迟时退回到第 2 条(可用 * comments.strictPostCheck 强制只认第 1 条)。 * * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait、TikTokAuto.tiktok */ TikTokAuto.comment = (function () { 'use strict'; var PLACEHOLDER = 'Add comment...'; /** 从评论池里随机挑一条,尽量不重复(池子用完了就允许重复并告警) */ function pickText(config, used, step) { var pool = config.comments.pool; var candidates = []; for (var i = 0; i < pool.length; i += 1) { if (!config.comments.avoidRepeatWithinRun || used.indexOf(pool[i]) === -1) { candidates.push(pool[i]); } } if (candidates.length === 0) { TikTokAuto.logger.warn(step || 'comment', '评论池已用尽,本轮允许重复'); candidates = pool; } return candidates[Math.floor(Math.random() * candidates.length)]; } /** 打开评论面板:点内联输入框 → 等面板输入框出现 */ function openPanel(config, step) { var story = step || 'openCommentPanel'; var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; if (tiktok.panelInput() !== null) { logger.info(story, '评论面板已经是打开状态'); return true; } var inline = tiktok.inlineInput(); if (!inline) { logger.error(story, '找不到评论入口(内联输入框)'); return false; } TikTokAuto.ui.clickNode(inline, story); var ready = TikTokAuto.wait.waitFor(story, function () { return tiktok.panelInput() !== null ? true : null; }, config.timeouts.panelReadyMs, config.probe.pollTickMs); return ready === true; } /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 back) */ function closePanel(step) { var tiktok = TikTokAuto.tiktok; if (!tiktok.commentPanelOpen()) { return false; } TikTokAuto.logger.info(step || 'closeCommentPanel', '收起评论面板'); back(); TikTokAuto.wait.until(function () { return tiktok.commentPanelOpen() ? null : true; }, 4000, 200); return true; } /** * 发送一条评论。 * * @returns {ok, via, text, countBefore, countAfter, reason} */ function post(config, text, step) { var story = step || 'postComment'; var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; var countBefore = tiktok.commentCount(); var before = { countBefore: countBefore }; if (!openPanel(config, story)) { return { ok: false, via: 'panel-not-opened', text: text, reason: '评论面板未打开' }; } var input = tiktok.panelInput(); var typed = TikTokAuto.ui.typeInto(input, text, story); if (!typed.ok) { return { ok: false, via: 'type-failed', text: text, reason: '输入框回读不一致' }; } if (config.runtime.dryRun) { logger.warn(story, 'dryRun 模式:已输入但**不发送**,清空并收起面板。待发文案=' + JSON.stringify(text)); var restore = tiktok.panelInput(); if (restore) { restore.setText(''); } TikTokAuto.wait.tick(300); closePanel(story); return { ok: true, via: 'dry-run', text: text, countBefore: countBefore, countAfter: countBefore, skipped: true }; } var button = tiktok.sendButton(config); if (!button) { return { ok: false, via: 'send-button-not-found', text: text, reason: '定位不到发送按钮' }; } TikTokAuto.ui.clickNode(button, story); return awaitOutcome(config, text, before, story); } /** * 等待并判定"评论是否真的发出去了",输出**带置信度**的结论。 * * 证据分三级,从强到弱: * 高 comment-list —— 评论列表里真的出现了这条文案(地面真相) * 中 comment-count —— 列表头部计数自增 * 低 panel-closed —— 面板收起 + 输入框复位为占位符 * * 为什么不能只用计数(实测踩到):视频页那个评论角标不一定会立刻刷新, * 明明发成功却读回旧值。反过来,只看"面板关了"又可能被平台拒评骗过。 * 所以用 waitFor 逐级升级证据,并把最终置信度**如实**报给上层。 */ function awaitOutcome(config, text, before, step) { var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; // 必要条件:面板收起且输入框复位 var closed = TikTokAuto.wait.waitFor(step, function () { if (tiktok.panelInput() !== null) { return null; } return tiktok.inlineInput() !== null ? true : null; }, config.timeouts.postMs, config.probe.pollTickMs); if (closed === null) { logger.error(step, '发送后面板未收起,疑似未发布成功'); closePanel(step); return { ok: false, via: 'panel-still-open', text: text, reason: '发送后面板没有收起' }; } // 中级证据:评论数自增。 // **必须"等"而不是"看一眼"**——实测角标有延迟:发布返回后立刻读还是旧值, // 大约 1~2 秒后才 +1。看一眼就下结论的话,明明发成功了也会被判成弱证据。 var counted = TikTokAuto.wait.waitFor(step, function () { var after = tiktok.commentCount(); if (after === null || before.countBefore === null) { return null; } return after !== before.countBefore ? after : null; }, config.timeouts.countRefreshMs, 400); if (counted !== null) { logger.info(step, '评论已发布(计数自增)' + before.countBefore + '→' + counted + ' ' + JSON.stringify(text)); return { ok: true, via: 'comment-count', confidence: 'medium', text: text, countBefore: before.countBefore, countAfter: counted }; } // 高级证据:去评论列表里找这条文案(地面真相) if (config.comments.verifyInList) { var inList = verifyInCommentList(config, text, before, step); if (inList) { logger.info(step, '评论已发布(列表确认 via=' + inList.via + ')内容=' + JSON.stringify(text) + ' 计数=' + inList.count); return { ok: true, via: 'comment-list', confidence: 'high', text: text, countBefore: before.countBefore, countAfter: inList.count }; } if (config.comments.strictPostCheck) { logger.error(step, '严格校验开启但评论列表里找不到该文案,判定为失败'); return { ok: false, via: 'not-found-in-list', text: text, countBefore: before.countBefore, countAfter: tiktok.commentCount(), reason: '评论列表里找不到刚发布的文案' }; } } logger.warn(step, '评论已发布,但只有弱证据(面板收起)。计数=' + before.countBefore + '→' + tiktok.commentCount() + ' 内容=' + JSON.stringify(text)); return { ok: true, via: 'panel-closed-and-input-reset', confidence: 'low', text: text, countBefore: before.countBefore, countAfter: tiktok.commentCount() }; } /** * 打开评论列表,找这条评论是否在列表里。 * 返回 {count} 或 null。无论成败都会把面板收回去,不污染后续步骤。 * * 为什么要"重试 + 等页面安静":紧跟着发布之后点评论角标,面板还在收起动画里, * 这一下点击会被吞掉(实测:列表死活不出来,等到超时)。 * 所以每次点击前先等签名稳定,并且允许最多点三次。 */ function verifyInCommentList(config, text, before, step) { var tiktok = TikTokAuto.tiktok; var logger = TikTokAuto.logger; var clickAttempts = 0; var maxClicks = 3; var deadline = Date.now() + config.timeouts.panelReadyMs; while (Date.now() < deadline) { var headerCount = tiktok.listCommentCount(); if (headerCount !== null) { // 最强:列表里真的出现了这条文案 if (tiktok.commentListContains(text)) { closePanel(step); return { via: 'text-in-list', count: headerCount }; } // 次强:列表头部计数变了(说明确实多了一条) if (before.countBefore !== null && headerCount !== before.countBefore) { closePanel(step); return { via: 'list-count', count: headerCount }; } } else if (clickAttempts < maxClicks) { settle(); var button = tiktok.commentButton(); if (!button) { logger.warn(step, '找不到评论角标,跳过列表校验'); return null; } clickAttempts += 1; logger.info(step, '第 ' + clickAttempts + ' 次打开评论列表'); TikTokAuto.ui.clickNode(button, step); } TikTokAuto.wait.tick(400); } logger.warn(step, '评论列表校验未通过(点了 ' + clickAttempts + ' 次,列表计数=' + tiktok.listCommentCount() + ',发布前计数=' + before.countBefore + ')'); closePanel(step); return null; } /** 等页面签名稳定下来(收起动画结束),再去做下一次点击 */ function settle() { TikTokAuto.wait.waitQuiet(function () { return TikTokAuto.layout.signature(); }, { maxMs: 1500, tickMs: 200, stableTimes: 2 }); } return { PLACEHOLDER: PLACEHOLDER, pickText: pickText, openPanel: openPanel, closePanel: closePanel, post: post, awaitOutcome: awaitOutcome, verifyInCommentList: verifyInCommentList }; }()); /* ============================== src/flow.js ============================== */ /** * flow.js — 业务流程编排 * * 这里只写"先做什么、再做什么、失败了怎么办",不写"怎么点"(ui.js) * 也不写"TikTok 长什么样"(tiktok.js)。 * 每一段都是一次"进入某页面 → 等它真的就绪 → 做事 → 校验"的小闭环, * 因此任何一步卡住都能从日志里立刻看出卡在哪。 * * 收敛原则(《代码整洁之道》第 3 章):每个函数只做一件事,且**只在一个抽象层级上**。 * 所以下面几乎没有嵌套循环和条件,读起来就是需求本身。 * * 依赖:TikTokAuto.logger / tiktok / player / comment / ui / wait / popups */ TikTokAuto.flow = (function () { 'use strict'; function logger() { return TikTokAuto.logger; } // ------------------------------------------------------------ 启动与就位 function launchApp(config) { var step = 'launch'; logger().step(step, '启动 ' + config.target.packageName); app.launchPackage(config.target.packageName); var ready = TikTokAuto.wait.waitFor(step, function () { return TikTokAuto.layout.foregroundPackage() === config.target.packageName ? true : null; }, config.timeouts.appLaunchMs, config.probe.pollTickMs); return ready === true; } /** * 保证停在信息流首页。 * * 早先的写法有两个坑(实测踩到): * 1. 一上来就 `waitFor(feed, 15s)` —— 明明已经在播放页,却先白等 15 秒; * 2. 循环里最后按完 back 不回头复查,4 次退完刚好差一步,然后误判"退不回去"去重启。 * 现在改成:**先看再动**,每退一步都等页面真的换了(按签名变化判断,不是固定 sleep), * 并且只在确认当前不是首页时才按返回——避免把正常页面退掉或退出 App。 */ function ensureFeed(config) { var step = 'ensureFeed'; var log = logger(); var tiktok = TikTokAuto.tiktok; var tiktokLayout = TikTokAuto.layout; var deadline = Date.now() + 25000; var unknownRounds = 0; while (Date.now() < deadline) { var state = tiktok.pageState(); if (state === 'feed') { log.info(step, '已处于信息流首页'); return true; } if (state === 'unknown') { // 页面可能还在渲染。但连续多轮都认不出来,说明有东西盖在上面 // (弹窗、半透明遮罩、异常页),这时**必须**试着按返回脱离, // 否则就是原地等到超时、然后"重启大法"。 unknownRounds += 1; if (unknownRounds >= 4) { log.warn(step, '页面状态连续 ' + unknownRounds + ' 轮未知,按返回尝试脱离'); back(); TikTokAuto.wait.waitQuiet(function () { return tiktokLayout.signature(); }, { maxMs: 2000, tickMs: 200, stableTimes: 2 }); unknownRounds = 0; } else { TikTokAuto.wait.tick(700); } continue; } unknownRounds = 0; log.info(step, '当前在 ' + state + ',返回上一页'); back(); TikTokAuto.wait.waitQuiet(function () { return tiktokLayout.signature(); }, { maxMs: 2500, tickMs: 200, stableTimes: 2 }); } log.warn(step, '多次返回仍未到首页,重新启动应用'); launchApp(config); return TikTokAuto.wait.waitFor(step, function () { return tiktok.pageState() === 'feed' ? true : null; }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true; } // ------------------------------------------------------------ 搜索 function openSearchPage(config) { var step = 'openSearch'; var entry = TikTokAuto.tiktok.searchEntry(); if (!entry) { logger().error(step, '首页找不到搜索入口'); return false; } TikTokAuto.ui.clickNode(entry, step); return TikTokAuto.wait.waitFor(step, function () { if (TikTokAuto.tiktok.pageState() === 'search') { return true; } // 结果页也算"搜索这条链走通了",直接复用 return TikTokAuto.tiktok.pageState() === 'results' ? true : null; }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true; } function submitKeyword(config) { var step = 'submitKeyword'; var tiktok = TikTokAuto.tiktok; if (tiktok.pageState() === 'results') { logger().info(step, '已经在结果页,跳过输入'); return true; } var input = tiktok.searchInput(); if (!input) { logger().error(step, '找不到搜索输入框'); return false; } TikTokAuto.ui.clickNode(input, step); TikTokAuto.wait.tick(250); var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, config.target.keyword, step); if (!typed.ok) { return false; } var submit = tiktok.searchSubmit(); if (!submit) { logger().error(step, '找不到搜索提交按钮'); return false; } TikTokAuto.ui.clickNode(submit, step); return TikTokAuto.wait.waitFor(step, function () { return tiktok.pageState() === 'results' ? true : null; }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true; } /** 等结果网格真的渲染出卡片(而不是页面框架出来了就算完) */ function waitForCards(config) { var step = 'waitForCards'; return TikTokAuto.wait.waitFor(step, function () { var cards = TikTokAuto.tiktok.videoCards(); return cards.length > 0 ? cards : null; }, config.timeouts.searchResultsMs, config.probe.pollTickMs); } function selectResultTab(config) { var step = 'selectResultTab'; var name = config.target.resultTab; if (!name) { return false; } var tab = TikTokAuto.tiktok.resultTab(name); if (!tab) { logger().warn(step, '找不到页签 ' + name + ',沿用当前页签'); return false; } logger().info(step, '切换到页签 ' + name); TikTokAuto.ui.clickNode(tab, step); TikTokAuto.wait.waitQuiet(function () { return TikTokAuto.layout.signature(); }, { maxMs: 4000, tickMs: 250, stableTimes: 3 }); return true; } /** 结果页滚动加载更多卡片 */ function scrollResults(config) { var layout = TikTokAuto.layout; var screen = layout.screen(); logger().info('scrollResults', '结果页下拉加载更多'); TikTokAuto.ui.dragScreen( Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.8), Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.35), 400 ); TikTokAuto.wait.waitQuiet(function () { return TikTokAuto.layout.signature(); }, { maxMs: 4000, tickMs: 250, stableTimes: 3 }); } // ------------------------------------------------------------ 选片与观看 function chooseCard(config, cards, visited) { var fresh = []; for (var i = 0; i < cards.length; i += 1) { if (visited.indexOf(cards[i].caption) === -1) { fresh.push(cards[i]); } } var pool = fresh.length > 0 ? fresh : cards; if (config.target.pickStrategy === 'first') { return pool[0]; } return pool[Math.floor(Math.random() * pool.length)]; } function openCard(config, card) { var step = 'openCard'; logger().step(step, '打开视频:' + JSON.stringify(String(card.caption).slice(0, 40))); TikTokAuto.ui.clickNode(card.container, step); return TikTokAuto.player.waitPlayerReady(config, step); } /** * 从播放页回到结果页。 * * 必须允许连按多次:校验评论时会把评论列表打开,此时状态是 comment-panel, * 一次 back 只够收起面板,得再退一次才真正回到结果页。 */ function returnToResults(config) { var step = 'returnToResults'; var log = logger(); var tiktok = TikTokAuto.tiktok; var tiktokLayout = TikTokAuto.layout; var deadline = Date.now() + config.timeouts.pageReadyMs; var backs = 0; while (Date.now() < deadline && backs < 5) { var state = tiktok.pageState(); if (state === 'results') { return true; } if (state === 'unknown') { TikTokAuto.wait.tick(500); continue; } backs += 1; log.info(step, '当前在 ' + state + ',返回上一页'); back(); TikTokAuto.wait.waitQuiet(function () { return tiktokLayout.signature(); }, { maxMs: 2500, tickMs: 200, stableTimes: 2 }); } log.warn(step, '未能回到结果页,跳过后续取片'); return false; } // ------------------------------------------------------------ 主流程 /** * 上报进度给界面宿主(未接宿主时是空操作,见 bridge.js)。 * 只在这里包一层是为了让下面的流程读起来仍是"业务语言", * 不必到处重复 status:'running' 这种传输细节。 */ function progress(phase, done, total) { TikTokAuto.bridge.report({ status: 'running', phase: phase, done: done, total: total }); } /** * 跑一轮完整会话:启动 → 搜索 → 逐个视频「看 → 评」。 * @returns 结构化结果,会被 main.js 打印成一行 RESULT 供 PC 侧断言 */ function runSession(config) { var summary = { ok: false, keyword: config.target.keyword, requestedVideos: config.target.maxVideos, attemptedVideos: 0, postedComments: 0, details: [], errors: [] }; var total = config.target.maxVideos; progress('\u542f\u52a8\u5e94\u7528', 0, total); if (!launchApp(config)) { summary.errors.push('启动应用失败'); return summary; } progress('\u8fdb\u5165\u4fe1\u606f\u6d41', 0, total); if (!ensureFeed(config)) { summary.errors.push('未能进入信息流首页'); return summary; } progress('\u641c\u7d22 ' + config.target.keyword, 0, total); if (!openSearchPage(config) || !submitKeyword(config)) { summary.errors.push('搜索流程失败'); return summary; } if (!waitForCards(config)) { summary.errors.push('搜索结果里没有可用卡片'); return summary; } selectResultTab(config); var visited = []; var usedComments = []; var guard = 0; while (summary.attemptedVideos < config.target.maxVideos && guard < config.target.maxVideos * 3) { guard += 1; progress('\u6b63\u5728\u5904\u7406\u7b2c ' + (summary.attemptedVideos + 1) + '/' + total + ' \u4e2a\u89c6\u9891', summary.attemptedVideos, total); var cards = TikTokAuto.tiktok.videoCards(); if (cards.length === 0) { scrollResults(config); cards = TikTokAuto.tiktok.videoCards(); if (cards.length === 0) { summary.errors.push('结果页已无更多卡片'); break; } } var card = chooseCard(config, cards, visited); visited.push(card.caption); if (!openCard(config, card)) { summary.errors.push('打开视频失败:' + String(card.caption).slice(0, 30)); returnToResults(config); continue; } var detail = { caption: String(card.caption).slice(0, 60) }; detail.watch = TikTokAuto.player.watch(config, { step: 'watch' }); /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式 * (界面上的「执行操作」没勾评论就是这个意思)。 */ if (config.comments.enabled) { var text = TikTokAuto.comment.pickText(config, usedComments, 'comment'); detail.comment = TikTokAuto.comment.post(config, text, 'comment'); if (detail.comment.ok && !detail.comment.skipped) { usedComments.push(text); summary.postedComments += 1; } } else { detail.comment = { ok: true, skipped: true, reason: '本次未启用评论动作' }; } summary.attemptedVideos += 1; summary.details.push(detail); progress('\u5df2\u5b8c\u6210 ' + summary.attemptedVideos + '/' + total, summary.attemptedVideos, total); returnToResults(config); } summary.ok = summary.attemptedVideos > 0; return summary; } return { launchApp: launchApp, ensureFeed: ensureFeed, openSearchPage: openSearchPage, submitKeyword: submitKeyword, waitForCards: waitForCards, selectResultTab: selectResultTab, scrollResults: scrollResults, chooseCard: chooseCard, openCard: openCard, returnToResults: returnToResults, runSession: runSession }; }()); /* ============================== src/main.js ============================== */ /** * main.js — 入口与收尾 * * 入口只负责三件事:读配置、开守护线程、跑流程。 * 收尾负责四件**必须保证**的事: * 1. 无论成功失败都打印 `__END__`(否则 PC 侧执行器会一直等到超时); * 2. 关掉守护线程(`threads.shutDownAll()`,否则进程不退出); * 3. 打印一行机器可读的 RESULT,让 PC 侧能直接断言而不是解析自然语言日志; * 4. 向界面宿主上报终态(被界面唤起时才有这一步,见 bridge.js)。 * * 依赖:全部模块 */ TikTokAuto.main = (function () { 'use strict'; var RESULT_MARKER = '__END__'; function emptySummary(reason) { return { ok: false, attemptedVideos: 0, postedComments: 0, details: [], errors: [reason] }; } /** * 去掉占位原因。 * `emptySummary('未开始')` 的那条只在"一次都没跑起来"时才有意义; * 流程已经跑过一段后再追加真实原因,留着它会让界面把"未开始"显示成第一条错误。 */ function dropPlaceholder(errors) { var kept = []; for (var i = 0; i < errors.length; i += 1) { if (errors[i] !== '未开始') { kept.push(errors[i]); } } return kept; } /** * @param injected PC 侧注入的配置覆盖项 * @returns 结构化结果 */ function run(injected) { var log = TikTokAuto.logger; var bridge = TikTokAuto.bridge; var summary = emptySummary('未开始'); var guardState = null; var config = null; /* 是"用户叫停"还是"出错了",收尾时的上报状态不同 */ var stoppedByUser = false; try { log.step('main', '=== TikTok 自动互动开始 ==='); bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 }); try { config = TikTokAuto.config.resolve(injected); } catch (configError) { log.error('main', '配置无效:' + configError.message + ' / detail=' + JSON.stringify(configError.detail)); summary = emptySummary('配置无效:' + configError.message); return summary; } bridge.report({ phase: '启动应用', total: config.target.maxVideos }); guardState = TikTokAuto.popups.startGuard(config, log); var guardUp = TikTokAuto.wait.waitFor('guard', function () { return guardState.passes >= 1 ? true : null; }, config.guard.firstScanTimeoutMs, 200); if (!guardUp) { log.warn('guard', '守护线程首次巡检未确认,继续执行(不阻塞主流程)'); } summary = TikTokAuto.flow.runSession(config); log.step('main', '=== 流程结束,耗时 ' + log.elapsedMs() + 'ms ==='); } catch (error) { if (bridge.isStopSignal(error)) { /* 停止是**正常收尾**,不是崩溃:不记 ERROR、不把堆栈当异常信息 */ stoppedByUser = true; log.warn('main', '收到停止请求,提前结束本次运行'); summary.ok = false; summary.errors = dropPlaceholder(summary.errors); summary.errors.push('用户停止'); } else { var detail = error && error.stack ? String(error.stack) : String(error); log.error('main', '未捕获异常:' + detail); summary.ok = false; summary.errors.push(String(error)); } } finally { TikTokAuto.popups.stopGuard(guardState, log); log.result(summary); /* 先把终态交给宿主,再收线程:顺序反了的话,界面可能永远等不到"结束", * 状态条会一直显示"运行中"。 */ bridge.finish( stoppedByUser ? 'stopped' : (summary.ok ? 'finished' : 'failed'), summary ); // 顺序很重要:先让日志刷出去,再收线程,最后打结束标记 try { threads.shutDownAll(); } catch (ignored) { // 没有线程在跑时该调用可能抛错,忽略即可 } console.log(RESULT_MARKER); } return summary; } return { RESULT_MARKER: RESULT_MARKER, run: run }; }()); /* PC 侧下发时可替换下面这一行的 null 来注入配置覆盖项 */ var INJECTED_CONFIG = /*TTA_INJECTED_CONFIG*/null; /* 被界面宿主唤起时(engines.execScriptFile 带 --config/--run-id 参数), * fromExecArgv 会把参数翻译成「配置路径 + 桥的开关」;独立运行时是空操作。 */ TikTokAuto.main.run(TikTokAuto.bridge.fromExecArgv(INJECTED_CONFIG)); }());