/*! tiktok-auto v1.0.0 * built 2026-11-01T06:48:56.247Z * modules: logger, protocol, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, nurture, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, dm, dmflow, 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/protocol.js ============================== */ /* =========================================================================== * protocol.js —— 界面 ↔ 引擎 的运行期协议(唯一事实来源) * * 这个文件解决的是"两边各写一套枚举、靠魔法数字猜死活"的老问题。 * 它只放**纯数据 + 纯判定**:不读文件、不写文件、不起线程、不看时间。 * 所以界面侧与引擎侧可以各存一份完整拷贝,行为完全一致。 * * ★ 两份拷贝的纪律:`tiktok-app/src/protocol.js` 与 * `tiktok-auto/src/protocol.js` **除第 1 行(挂载点)外必须逐字节相同**。 * 界面侧是 `var Protocol = …`,引擎侧是 `TikTokAuto.protocol = …`。 * `tiktok-app/tools/protocol-check.js` 会剥掉第 1 行后逐字节比对, * 任何一边单独改都会立刻被抓出来。 * * ★ 为什么状态判定要放在这里而不是各自实现: * `isTerminalStatus` 曾经只存在于界面(engine.js),引擎侧自己写 * `'finished' / 'stopped' / 'failed'` 三元推导(main.js)。两边漏改一个 * 就会出现"引擎以为结束了、界面还在等"这种零报错的悬挂。 * =========================================================================== */ TikTokAuto.protocol = (function () { 'use strict'; /** 协议版本。加字段不改语义不用抬;改语义 / 改判定规则必须抬。 */ var VERSION = 2; /* ------------------------------------------------------------ 状态枚举 */ var STATUS = { STARTING: 'starting', RUNNING: 'running', STOPPING: 'stopping', FINISHED: 'finished', STOPPED: 'stopped', FAILED: 'failed' }; /** 终态:到了这里就没有出边(再回到活跃态一律算 regress)。 */ var TERMINAL = { finished: true, stopped: true, failed: true }; /** 活跃态:运行条要显示、判活逻辑要关心的三档。 */ var ACTIVE = { starting: true, running: true, stopping: true }; /** * 合法迁移表:from → 允许去的 to。 * * 空串代表"还没有状态"(本轮的第一次上报)。 * `starting` 的出边特别宽:配置无效会在启动瞬间直接 failed, * 极短任务可能 starting 之后直接终态,这两种都是真实的。 */ var EDGES = { '': { starting: true }, starting: { starting: true, running: true, stopping: true, finished: true, stopped: true, failed: true }, running: { running: true, stopping: true, finished: true, stopped: true, failed: true }, stopping: { stopping: true, finished: true, stopped: true, failed: true }, finished: {}, stopped: {}, failed: {} }; /* ------------------------------------------------------------ 阈值 */ /** * 所有超时都在这里,界面侧从 `Protocol.SPEC.thresholds` 取, * 引擎侧心跳间隔也从这里取 —— 不允许在业务代码里再写字面量。 * * 语义补充(光看名字看不出来的): * · beatFreshMs 只用于"加速判死"(见界面 pollEngine),**不产生文案** * · startAckSoftMs 只是日志与文案降级(「启动中(较慢)」),不改状态 * · stopAckMs / stopFinishMs 是两段:先等"看见了停止请求",再等"真的收完尾" * · legacy* 是无心跳(v1)引擎的回退值,就是改造前在用的数字 */ var THRESHOLDS = { /* 心跳 */ beatIntervalMs: 2000, beatFreshMs: 8000, beatDeadMs: 15000, /* 启动握手 */ startAckSoftMs: 6000, startAckMaxMs: 15000, /* 停止两段 */ stopAckMs: 3000, stopFinishMs: 10000, /* v1 回退组(老引擎 / 心跳线程起不来) */ legacyChildGraceMs: 4000, legacyStopTimeoutMs: 8000, legacyNoBeatDeadMs: 30000 }; var SPEC = { version: VERSION, status: STATUS, terminal: TERMINAL, active: ACTIVE, edges: EDGES, thresholds: THRESHOLDS }; /* ------------------------------------------------------------ 工具 */ /* 单文件拼接后所有模块共享一个作用域,而 `Object.prototype.hasOwnProperty` * 的 call 形式会命中引擎侧构建黑名单里的那种写法(连注释都会被扫到), * 所以这里统一走 apply。 */ function owns(object, key) { return Object.prototype.hasOwnProperty.apply(object, [key]) === true; } /** 把任意输入变成字符串状态;null / undefined → 空串(= 还没有状态)。 */ function statusOf(value) { if (value === undefined || value === null) { return ''; } return String(value); } /* ------------------------------------------------------------ 判定 */ /** * 是否终态。 * ★ 未知状态一律返回 false —— 界面绝不能因为读到一个不认识的 status * 就把正在跑的运行结束掉(前向兼容第一条)。 */ function isTerminal(status) { var key = statusOf(status); return owns(TERMINAL, key) === true && TERMINAL[key] === true; } /** 是否活跃态(运行条要显示的那三档)。未知状态同样返回 false。 */ function isActive(status) { var key = statusOf(status); return owns(ACTIVE, key) === true && ACTIVE[key] === true; } /** * 三分类,给日志和排障用。 * @returns 'active' | 'terminal' | 'unknown' */ function levelOf(status) { var key = statusOf(status); if (isTerminal(key)) { return 'terminal'; } if (isActive(key)) { return 'active'; } return 'unknown'; } /** * 迁移合法性。 * * @returns 'ok' 表里有的合法迁移 * 'idempotent' 原地重复上报(引擎允许重复写同一个状态) * 'regress' 从终态往外走 —— **必须拒绝** * 'unknown' 表里没有的组合,多半是前向兼容来的新状态, * 调用方应当"放宽"(不据此结束运行), * 只在日志里留一句 */ function legality(from, to) { var a = statusOf(from); var b = statusOf(to); if (a === b) { return 'idempotent'; } if (isTerminal(a)) { return 'regress'; } if (owns(EDGES, a) === true && owns(EDGES[a], b) === true) { return 'ok'; } return 'unknown'; } /* ------------------------------------------------------------ 导出 */ return { VERSION: VERSION, STATUS: STATUS, SPEC: SPEC, statusOf: statusOf, isTerminal: isTerminal, isActive: isActive, levelOf: levelOf, legality: legality }; })(); /* ============================== 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'; /* 心跳:独立文件、独立写者。和 run-state 分开是因为后者由主流程按条目写, * 两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。 */ var DEFAULT_BEAT = DEFAULT_DIR + '/engine-beat.json'; /** * 检查停止标志的最小间隔。 * 每次 tick(默认 150ms)都去 stat 一次文件是没必要的高频 IO; * 500ms 的响应延迟对人来说与"立刻"无异。 */ var STOP_POLL_MIN_MS = 500; /** 心跳间隔的兜底值:protocol 模块没加载时也能跑(此时 v=0,宿主会走 v1 回退)。 */ var BEAT_INTERVAL_FALLBACK_MS = 2000; var state = { enabled: false, runId: '', statePath: DEFAULT_STATE, stopFlagPath: DEFAULT_STOP_FLAG, logPath: DEFAULT_LOG, beatPath: DEFAULT_BEAT, lastStopCheckMs: 0, /* 本轮开始的时刻:宿主拿它算"启动握手超时",与 run-state.updatedAt 无关 */ startedAt: 0, /* run-state 的写入序号,单调递增。宿主据此判断"同一毫秒内的两次写" */ seq: 0 }; var last = null; /** * 心跳线程的运行态。 * * ★ 三条硬规则(实现时踩过,写在这里防止被"优化"掉): * ① **不得调用 checkStop()** —— 它会在命中时 report(stopping) 并抛 StopSignal, * 抛进心跳线程就让"收尾那 10 秒"再也没有人写心跳,而那正是最需要它的时候。 * ② **不得调用 report()** —— report 的 `last` 是跨线程共享的可变对象、整份覆盖写, * 心跳借道会把 phase 写回旧值。 * ③ **用裸 sleep,不用 wait.tick()** —— 理由同 ①。 * `wait.js` 头注释说"裸 sleep 只允许出现在本文件",这里是受控例外。 */ var beat = { thread: null, running: false, seq: 0, /* 看见停止请求的时刻。★ 一旦写入就**不再归零**(sticky): * 心跳是整份覆盖写,如果每次都从 0 开始,2 秒后就把 ack 抹掉了, * 而宿主是 1 Hz 轮询 —— 有概率整整错过一拍。 */ seenAt: 0 }; /** * 「用户要求停止」。**刻意用异常抛出**: * 停止请求可能发生在任意深度的等待里(等页面就绪、等播放开始、等结果卡片……), * 逐层返回码需要改动几十处调用点且极容易漏掉一处就变成"点了停止但不停"。 * 抛出后由 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; } else if (token === '--resume') { /* 裸开关,不取值:断点续跑由界面点「继续」时带上 */ options.resume = true; } else if (token === '--checkpoint') { options.checkpointPath = 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; } /* 续跑开关只改 `id.resume`,**不动 sources** —— * `merge` 是深合并,所以不会把配置里的取材清单覆盖掉。 */ if (options.resume) { injected.id = injected.id || {}; injected.id.resume = injected.id.resume || {}; injected.id.resume.enabled = true; if (options.checkpointPath) { injected.id.resume.path = options.checkpointPath; } } return injected; } /** 本次运行的 runId(断点里要记下"这是哪次运行写的",界面靠它把记录与断点对上) */ function currentRunId() { return state.runId || ''; } /** * 日志落盘(界面侧「执行日志」的来源)。 * * 一行一条 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 那边已经吞了一层,这里再兜一层 */ } } /** 协议版本号。protocol 模块缺失时是 0 —— 宿主据此走 v1 回退,不会认错。 */ function protocolVersion() { return TikTokAuto.protocol ? TikTokAuto.protocol.VERSION : 0; } function beatIntervalMs() { var spec = TikTokAuto.protocol ? TikTokAuto.protocol.SPEC : null; var ms = spec && spec.thresholds ? spec.thresholds.beatIntervalMs : 0; return ms > 0 ? ms : BEAT_INTERVAL_FALLBACK_MS; } /** * 写一拍心跳。 * * 只做两件事:看一眼停止标志(记下"看见了")、整份重写心跳文件。 * 不 report、不抛异常、不碰 run-state。 */ function heartbeatOnce() { var content = ''; try { if (files.exists(state.stopFlagPath)) { content = String(files.read(state.stopFlagPath)).replace(/\s/g, ''); } } catch (error) { /* 读不到就当"还没看见":下一拍再试,绝不因为一次读失败就清掉 seenAt */ content = ''; } if (content !== '' && content === state.runId) { /* sticky:只在第一次记时刻 */ if (beat.seenAt === 0) { beat.seenAt = Date.now(); } } beat.seq += 1; try { files.write(state.beatPath, JSON.stringify({ v: protocolVersion(), runId: state.runId, seq: beat.seq, beatAt: Date.now(), stopSeenAt: beat.seenAt })); } catch (error) { /* 心跳写不进去不该影响流程:宿主那边会表现为"心跳过期", * 它有自己的兜底(判死 → 结束运行),不需要引擎来救 */ } } /** * 起心跳线程。 * @returns 是否真的起来了 —— 起不来时把它写进 capabilities,宿主会退回 v1 阈值 */ function heartbeatStart() { if (!state.enabled) { return false; } if (beat.running) { return true; } beat.running = true; beat.seq = 0; beat.seenAt = 0; /* 先同步写一拍:宿主在启动握手阶段就有东西可读,不用等 2 秒 */ heartbeatOnce(); var interval = beatIntervalMs(); try { beat.thread = threads.start(function () { while (beat.running) { /* 先睡再写:开场那一拍上面已经写过,避免同一毫秒写两次 */ sleep(interval); if (!beat.running) { break; } heartbeatOnce(); } }); } catch (error) { beat.running = false; beat.thread = null; log('warn', '心跳线程启动失败,宿主将退回无心跳判定:' + error); return false; } return true; } /** 停心跳。只是放下旗子,线程自己在下一拍前后退出(不强制中断)。 */ function heartbeatStop() { beat.running = false; beat.thread = null; } function isHeartbeatUp() { return beat.running === true; } /** 打开桥。没有 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.beatPath = options.beatPath || DEFAULT_BEAT; state.lastStopCheckMs = 0; state.startedAt = Date.now(); state.seq = 0; /* ★ 清空上一轮的日志:界面在结束时整份读走, * 不清的话 D3 上会混进上一次运行的行(而且越跑越长)。 */ try { files.write(state.logPath, ''); } catch (error) { /* 清不掉也不致命:界面会按 t 只取本轮时间窗内的行 */ } if (TikTokAuto.logger && TikTokAuto.logger.setSink) { TikTokAuto.logger.setSink(appendLog); } /* ★ 顺序:先起心跳、拿结果,再写第一次状态。 * 反过来会先告诉宿主"有心跳"再改成"没有",宿主可能在那两秒里按心跳判活, * 而实际上根本没有心跳线程。 */ var heartbeatUp = heartbeatStart(); last = { v: protocolVersion(), runId: state.runId, status: 'starting', phase: '', done: 0, total: 0, seq: 0, startedAt: state.startedAt, updatedAt: 0, /* task 此刻还不知道(要等 config.resolve),main 会调 setTask 补上 */ engine: { task: '', capabilities: { heartbeat: heartbeatUp } } }; report({}); log('info', '已连接到界面宿主,runId=' + state.runId + ' / 心跳=' + (heartbeatUp ? '开' : '关')); } /** * 告诉宿主"这次跑的是哪个任务"。 * 只是补一个字段并立刻落盘一次:宿主用它做排障展示, * 也让"引擎起来了但配置是错的"这种情况在状态里看得见。 */ function setTask(task) { if (!state.enabled || !last) { return; } if (!last.engine) { last.engine = { task: '', capabilities: { heartbeat: isHeartbeatUp() } }; } last.engine.task = String(task || ''); report({}); } 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]; } } state.seq += 1; last.v = protocolVersion(); last.seq = state.seq; last.startedAt = state.startedAt; 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; } var patch = { status: status, phase: '', summary: summary || null, message: describe(status, summary) }; /* ID 提取的 `done` 是"已收个数",而最后一次 `report` 往往发生在收下一个人之前 —— * 于是终态里 done 会比实际少一个(实测 11 个却报 10)。 * 收尾时按 summary 校正一次,免得界面/记录上的数字自相矛盾。 */ if (summary && typeof summary.extractedTotal === 'number') { /* 续跑时 extracted 只是"本轮新增",累计数才和界面上的进度一致 */ patch.done = summary.extractedTotal; } else if (summary && typeof summary.extracted === 'number') { patch.done = summary.extracted; } report(patch); log('info', '终态已上报:' + status); } /** * 养号的三个动作计数拼成一句话。 * 只写"真的做了"的个数(跳过的不算),否则用户会以为自己点了 N 个赞、 * 实际全是"本来就已经赞过"。 */ function nurtureActions(summary) { return '点赞 ' + summary.liked + ' / 关注 ' + summary.followed + ' / 评论 ' + summary.commented; } /** 给界面看的一句人话,不要求界面自己去解析 summary 结构 */ function describe(status, summary) { /* ID 提取与视频链路的 summary 形状不同(提取个数 vs 视频个数), * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。 * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */ var isIdTask = summary && typeof summary.extracted === 'number'; /* 养号有自己的形状(轮数 + 三个动作计数),既不是"提取 N 个"也不是"处理 N 个视频"。 * 判据用 summary 自己带的 task 字段,不靠调用方传——这样以后加任务不用再动这里。 */ var isNurture = summary && summary.task === 'nurture' && typeof summary.rounds === 'number'; /* 私信:关注与私信是两个不同的动作,要分开说 */ var isDmTask = summary && summary.task === 'dm' && typeof summary.sent === 'number'; var dmTail = function () { var tail = ''; if (summary.skipped > 0) { tail += ',跳过 ' + summary.skipped + ' 个'; } if (summary.failed > 0) { tail += ',失败 ' + summary.failed + ' 个'; } if (summary.dryRun) { tail += '(演练 ' + (summary.dryRunCount || 0) + ' 条,未真发)'; } /* 人是哪儿来的要说清:脚本二跑完只说"关注 3 个/私信 2 条", * 用户没法确认它到底是不是从自己的粉丝里取的人。 */ if (summary.sourceKind === 'ownFans') { tail += '(取材自你自己的粉丝列表,' + (summary.totalUids || 0) + ' 人)'; } return tail; }; /* 续跑时"数字"要给累计口径,并明确说这是续跑 —— * 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */ var total = summary && typeof summary.extractedTotal === 'number' ? summary.extractedTotal : (summary ? summary.extracted : 0); var resumedTag = summary && summary.resumed ? '(续跑)' : ''; var thisRound = summary && summary.resumed && summary.extracted !== total ? ',本轮新增 ' + summary.extracted + ' 个' : ''; if (status === 'stopped') { if (isDmTask) { return '已停止:关注 ' + summary.followed + ' 个 / 私信 ' + summary.sent + ' 条' + dmTail(); } if (isNurture) { return '已停止:浏览 ' + summary.rounds + ' 轮 / ' + nurtureActions(summary); } return isIdTask ? '已停止' + resumedTag + ':累计 ' + total + ' 个 ID' + thisRound + '(可点「继续」接着跑)' : '已停止'; } if (status === 'finished') { if (isDmTask) { return '完成:关注 ' + summary.followed + ' 个 / 私信 ' + summary.sent + ' 条' + dmTail(); } if (isNurture) { /* 时长先到要说出来 —— 用户下一步该调的是「浏览时长」而不是别的 */ var why = summary.stoppedBy === 'time' ? '(时长先到,轮数没用满)' : ''; return '完成:浏览 ' + summary.rounds + ' 轮 / ' + nurtureActions(summary) + why; } if (isIdTask) { var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : ''; /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */ var mapped = summary.mapFile ? '(已生成 uid 映射)' : ''; return '完成' + resumedTag + ':提取 ' + total + ' 个 ID' + thisRound + tail + mapped; } var watched = summary ? summary.attemptedVideos : 0; var posted = summary ? summary.postedComments : 0; /* 只写"真的做了"的:跳过(已赞/已关注)不计入,否则用户会以为 * 自己点了 N 个赞,实际全是"本来就已经赞过"。 */ var liked = summary && summary.likedVideos ? summary.likedVideos : 0; var followed = summary && summary.followedAuthors ? summary.followedAuthors : 0; var actions = []; if (liked > 0 || (summary && summary.likesRequested)) { actions.push('点赞 ' + liked); } if (followed > 0 || (summary && summary.followsRequested)) { actions.push('关注 ' + followed); } actions.push('评论 ' + posted); return '完成:处理 ' + watched + ' 个视频 / ' + actions.join(' / '); } 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, DEFAULT_BEAT: DEFAULT_BEAT, StopSignal: StopSignal, isStopSignal: isStopSignal, readExecArgv: readExecArgv, parseArgs: parseArgs, fromExecArgv: fromExecArgv, isEnabled: isEnabled, checkStop: checkStop, currentRunId: currentRunId, protocolVersion: protocolVersion, setTask: setTask, heartbeatStart: heartbeatStart, heartbeatStop: heartbeatStop, isHeartbeatUp: isHeartbeatUp, /* 单独导出"写一拍":PC 自检要能在不真起线程的情况下驱动心跳, * 排障时也想手动补一拍。业务代码**不应该**调它(会打乱 seq 的含义)。 */ heartbeatOnce: heartbeatOnce, 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 = { /** * 任务类型。缺省 'video' 是**刻意的向后兼容**: * 老配置(PC 侧下发、设备上残留的 config.json)里没有这个字段, * 它们本来就是视频链路的,所以默认值必须还是 video, * 否则一次升级会把所有老用法打成"未知任务"。 */ task: 'video', target: { packageName: 'com.zhiliaoapp.musically', keyword: 'fashion', maxVideos: 1, resultTab: 'Top', pickStrategy: 'random' }, /** * ID 提取(task = 'id')。形状与界面 `sources.js` 对齐: * `sources` 是**不定长清单**,每条自带起点与深度。 */ id: { sources: [], filter: { private: false, noAvatar: false, langRegion: 'any', nicknameCheck: 'any', follow: { min: 0, max: 0 }, fans: { min: 0, max: 0 }, likes: { min: 0, max: 0 }, videos: { min: 0, max: 0 } }, output: { dir: '/sdcard/Download/tiktok_app', historyPath: '/sdcard/Download/tiktok_app/uid_extracted.txt', dedupeHistory: true, dryRun: false, limit: 0, /** * 抓完之后是否把 username 换成 TikTok 的**数字 uid**,另存一份映射文件。 * * 默认开:数字 uid 在 App 界面上任何地方都读不到,不在这里换就得事后另跑一次。 * 每个约 2.5 秒(要下整页 SSR 约 388KB),所以只要 handle 的场景可以关掉省时间。 */ resolveUid: true }, /** * 物理护栏。界面里 `limit = 0` 的语义是"不限", * 但真跑起来没有上限等于跑到没电 —— 所以引擎自己有个时长上限。 * 到点时**如实报告**,不装作抓完了。 */ guard: { /** 抓取阶段最长多久(不含收尾) */ maxRuntimeMs: 30 * 60 * 1000, /** * 解析数字 uid 阶段**自己的**预算。 * * 为什么必须独立:抓取很容易把 `maxRuntimeMs` 用满 * (实测 83 个候选 × 每人约 8 秒 = 30 分钟), * 而解析 uid 是**收尾工作**,恰恰在这之后才做。 * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。 */ resolveMaxMs: 10 * 60 * 1000 }, /** * 断点续跑。默认**关**:只有界面明确点「继续」才会带 `--resume` 进来。 * * 为什么不默认开:一份手写的 engine-config.json 如果默认就去读断点, * 会出现"我只想从头跑一次,结果它接着上次跑"这种反直觉行为。 * 而且 `state:'none'`/`'done'` 时它本来也不会续 —— 但**默认关**更安全。 * * path 留空则由 `output.dir` 派生(`checkpoint.pathOf`), * 这样用户改产物目录时断点跟着走,不会出现"产物在新目录、断点在旧目录"。 */ resume: { enabled: false, path: '' } }, watch: { minMs: 10000, maxMs: 35000 }, /** * 点赞 / 关注:与 comments 平级的两个动作开关。 * * 界面的「执行操作」是四项多选(关注 / 私信 / 点赞 / 评论), * 这四项各自映射到一个 enabled —— 用户没勾就不做,不能因为"顺手"就点上。 * * 与 ID 提取里的 `id.filter.likes`(按获赞数量筛账号)**不是一回事**, * 只是名字撞了,层级不同。 */ likes: { enabled: true }, follows: { enabled: true }, comments: { /* 是否执行评论动作。关掉就是「只浏览不发评论」—— * 界面把「执行操作」里的评论开关映射到这里: * 用户没勾评论就不该发,哪怕评论池配好了。 */ enabled: true, pool: [ 'Love this!', 'So pretty', 'Great style', 'This is amazing', 'Need this look' ], avoidRepeatWithinRun: true, strictPostCheck: false, verifyInList: true }, /** * 养号(task = 'nurture'):在首页信息流里随机浏览 + 按概率互动。 * * 与视频链路共用 `watch` / `comments` / `probe` 不够——养号有两处语义不同: * ① **没有目标**:视频链路是"搜关键词看 N 个",养号是"往下刷", * 所以没有 target.keyword / maxVideos,换成 rounds 与 browse 双约束 * ② **单次观看时长要短**:视频链路为了"观看有效"默认 10~35 秒, * 养号模拟的是"划过去看一眼",5~25 秒之间随机更像真人 * * 概率是**小数**(0.3 = 30%),界面那边是档位(r30),由 engine.js 换算。 * 顶层 `comments` 段(文案池、是否校验)在这里同样生效——一次只跑一个任务,不会打架。 */ nurture: { /** 单次操作的**轮数**(界面「单次操作 10~30」)。与 browse 各自独立,谁先到谁停 */ rounds: { min: 10, max: 30 }, /** 浏览时长(界面「浏览时长 3~8 分钟」) */ browse: { minMs: 3 * 60 * 1000, maxMs: 8 * 60 * 1000 }, /** 单个视频看多久。养号是"划过去看一眼",不该按视频链路的 10~35 秒 */ watch: { minMs: 5000, maxMs: 25000 }, /** 做完一个动作后的随机停顿(真人不会点完立刻滑) */ pause: { minMs: 800, maxMs: 2500 }, likeRate: 0.30, commentRate: 0.10, followRate: 0.15 }, /** * UID 私信(task = 'dm')。 * * 界面的「关注数量 100~200」「私信数量 20~30」是**两组独立额度**, * 作用在**两批完全不同的人**上:前 `follow.max` 个只关注,接下来 * `send.max` 个只发私信。所以 `max` 是**确定的数字**(界面在区间里随机取一次 * 再传下来),不是区间。 * * 「休息时间」是**每处理完一个 UID 之后**的间隔,也在这里传确定的区间。 */ dm: { /** * 's1' = 已提 UID 私信(读文本文件) * 's2' = 自己粉丝私信(打开自己的主页 → 粉丝列表取材,不用 uidPath) */ script: 's1', /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释(s2 忽略) */ uidPath: '/sdcard/Download/tiktok_app/uid_list.txt', follow: { enabled: true, max: 0 }, send: { enabled: true, max: 0 }, /** 每个 UID 之后休息多久(v9 起界面默认 10~40 秒) */ rest: { minMs: 10000, maxMs: 40000 }, /** 'follow_dm' | 'follow' | 'dm' —— 按什么去重(跨轮,读 dm_history.txt) */ dedupeMode: 'follow_dm', /** 首视频筛选与首视频动作(脚本一的两个设置) */ filterDate: '7d', firstAction: 'both', /** 私信文案池(界面「随机私信文本」文件里读出来的每一行) */ pool: [], /** 评论文案池(首视频评论用,同样从文件读) */ commentPool: [], /** * 物理护栏:时长到点就收尾,别指望"跑完自然停"。 * 关注 150 + 私信 25,每个约 30~80 秒(搜索+进页+动作+休息), * 一轮 1.5~4 小时;给 5 小时上限兜住"UID 文件特别长"的情况。 */ guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 } }, timeouts: { appLaunchMs: 25000, pageReadyMs: 15000, /** * 搜索结果首屏 / 切页签重刷的等待上限。 * * 2026-10-28 实测:关键词 `cat` 从点搜索到网格出现约 **18 秒** * (8 秒时截图还是空的,再等 10 秒才有卡)。原来 20 秒只剩 2 秒余量, * 网络稍慢就会判成"没有可用卡片"。放到 30 秒。 */ searchResultsMs: 30000, 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, /* 本轮运行的标识。界面写进 engine-config.json,引擎把命令行里的 * `--run-id` 拿来比对 —— 用来挡住"上一次的引擎迟到启动、 * 却读到这一轮的配置并真的跑起来"。 * 独立运行(没有宿主)时为 '',校验会自动跳过。 */ runId: '' } }; /** 配置错误:单独类型,便于上层区分"配置写错了"和"运行时炸了" */ 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) { /* —— ID 提取任务:校验的是"取材清单",不是搜索关键词 —— * 早先这个函数硬性要求 target.keyword 非空,那是视频链路的前提。 * ID 提取可以不搜索(从粉丝列表、文本文件、当前屏幕取材), * 所以按 task 分支,而不是给关键词塞一个假默认值把校验糊过去。 */ if (config.task === 'id') { if (!(config.id && config.id.sources instanceof Array)) { throw new ConfigError('id.sources 必须是数组(取材清单)', config.id && config.id.sources); } if (config.id.sources.length === 0) { throw new ConfigError('id.sources 不能为空,至少需要一条取材', 0); } var limit = Number(config.id.output && config.id.output.limit); if (limit < 0) { throw new ConfigError('id.output.limit 不能为负数', limit); } return config; } /* —— 养号任务:没有搜索目标,校验的是"轮数 / 时长 / 概率" —— * ★ 这一支必须放在 `var target = config.target` **之前**: * 下面那段会硬性要求 `target.keyword` 非空,那是视频链路的前提。 * 养号根本不搜索,落到那里就会被判成"配置错误",而且报的是 * "target.keyword 不能为空"这种让人完全摸不着头脑的原因。 */ if (config.task === 'nurture') { var nurture = config.nurture || {}; var rounds = nurture.rounds || {}; var browse = nurture.browse || {}; if (!(Number(rounds.min) >= 1)) { throw new ConfigError('nurture.rounds.min 必须 >= 1', rounds.min); } if (Number(rounds.max) < Number(rounds.min)) { throw new ConfigError('nurture.rounds.max 不能小于 rounds.min', rounds); } if (!(Number(browse.maxMs) > 0)) { throw new ConfigError('nurture.browse.maxMs 必须 > 0', browse.maxMs); } /* 要发评论就必须有文案 —— 养号页补了「评论文案文件」正是为了这件事。 * 池子为空却把 commentRate 设成 10%,跑起来只会一轮轮地告警。 */ if (config.comments && config.comments.enabled && (!config.comments.pool || config.comments.pool.length === 0)) { throw new ConfigError('要发评论就必须有评论文案(comments.pool 为空)', 0); } return config; } /* —— UID 私信:没有搜索目标,校验的是"有没有活可干、数量是否合法" —— */ if (config.task === 'dm') { var dm = config.dm || {}; var followMax = Number(dm.follow && dm.follow.max); var sendMax = Number(dm.send && dm.send.max); var followOn = dm.follow && dm.follow.enabled === true; var sendOn = dm.send && dm.send.enabled === true; if (dm.script !== 's1' && dm.script !== 's2') { throw new ConfigError('dm.script 只能是 "s1" 或 "s2"', dm.script); } /* 脚本一靠外部文本取材,路径空着必然一个都读不到 —— 提前报, * 而不是先启动 App、跑到一半才发现"没有可用账号"。 */ if (dm.script === 's1' && !dm.uidPath) { throw new ConfigError('脚本一必须指定 UID 文本(dm.uidPath 为空)', dm.uidPath); } if (!followOn && !sendOn) { throw new ConfigError('「进行关注」与「进行私信」都关着,没有可执行的动作', { follow: followOn, send: sendOn }); } if (followOn && !(followMax >= 1)) { throw new ConfigError('dm.follow.max 必须 >= 1(开着关注却没有数量)', followMax); } if (sendOn && !(sendMax >= 1)) { throw new ConfigError('dm.send.max 必须 >= 1(开着私信却没有数量)', sendMax); } if (sendOn && (!dm.pool || dm.pool.length === 0)) { throw new ConfigError('要发私信就必须有文案(「随机私信文本」那个文件是空的)', 0); } if (!(Number(dm.rest && dm.rest.maxMs) > 0)) { throw new ConfigError('dm.rest.maxMs 必须 > 0', dm.rest && dm.rest.maxMs); } return 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; } /** * 配置与本次运行必须对得上。 * * 挡的是这一种真实故障:上一轮的引擎因为冷编译慢、迟到启动, * 醒来时读到的已经是**这一轮**刚写的 engine-config —— 它照新配置跑起来, * 而界面以为这一轮还没起来(于是两份流程同时操作一台手机)。 * 配置里绑一个 runId 就能让它当场收手。 * * ★ 只在**双方都非空**时比较:独立运行时命令行没有 `--run-id`, * `bridge.currentRunId()` 是空串,此时必须放行,否则引擎永远跑不起来。 */ function assertRunIdMatches(config) { var wanted = config && config.runtime ? String(config.runtime.runId || '') : ''; var current = ''; if (TikTokAuto.bridge && TikTokAuto.bridge.currentRunId) { current = String(TikTokAuto.bridge.currentRunId() || ''); } if (wanted === '' || current === '') { return; } if (wanted !== current) { throw new ConfigError('配置属于另一次运行(runId 不匹配)', { configRunId: wanted, engineRunId: current }); } } /** * 解析最终生效的配置。 * * @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); assertRunIdMatches(config); if (config.task === 'id') { logger.info('config', '生效配置(ID提取):' + JSON.stringify({ sources: config.id.sources.length, filter: config.id.filter, limit: config.id.output.limit, dedupeHistory: config.id.output.dedupeHistory, dryRun: config.id.output.dryRun })); } else if (config.task === 'nurture') { /* 养号没有关键词,报 video 那套字段(keyword/maxVideos)等于什么都没说。 * 排障时最需要的恰恰是"概率和轮数到底生效成了多少"。 */ var nur = config.nurture; logger.info('config', '生效配置(养号):' + JSON.stringify({ rounds: [nur.rounds.min, nur.rounds.max], browseMaxMin: Math.round(nur.browse.maxMs / 60000), watchMs: [nur.watch.minMs, nur.watch.maxMs], rates: { like: nur.likeRate, comment: nur.commentRate, follow: nur.followRate }, commentEnabled: config.comments.enabled, commentCount: config.comments.pool.length, dryRun: config.runtime.dryRun })); } else if (config.task === 'dm') { /* 私信同样没有关键词,落到下面的 video 分支会打印 * `{"keyword":"fashion",...}` —— 排障时完全误导(曾经真被这条日志带偏过, * 以为跑的是视频链路)。这里报"从哪儿取人、发多少、歇多久"。 */ var dm = config.dm; logger.info('config', '生效配置(私信):' + JSON.stringify({ script: dm.script, source: dm.script === 's2' ? '自己的粉丝列表' : ('UID 文本 ' + dm.uidPath), follow: dm.follow, send: dm.send, restSec: [Math.round(dm.rest.minMs / 1000), Math.round(dm.rest.maxMs / 1000)], dedupeMode: dm.dedupeMode, textCount: dm.pool.length, dryRun: config.runtime.dryRun })); } else { 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'; /** * 在坐标上点一下。 * * ★ 用无障碍坐标点击 `click(x, y)`,**不要**用 `gesture()`(2026-10-28 实测) * * 踩过的弯路,记下来免得再走: * 那天排查"点评论按钮没反应",一度以为 TikTok 吞掉了无障碍坐标点击, * 于是把这里改成 `gesture(120, [[x,y],[x,y]])` —— 主脚本里探针确实点开了, * 但**引擎(`execScriptFile` 起的子脚本)里 gesture 是无效的**: * 同坐标下 gesture / click / shell 三种方式全部失败,而 `click` 在主脚本里也有效。 * * 真正的故障其实在别处(键盘没弹 → 输入框停在屏幕底部 → `panelInput()` 判据不命中), * 跟点击方式无关。引擎里后来一直用 `click` 打开评论面板,每次都能成功 —— 这就是证据。 */ 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; } /* 走 gesture 而不是 click —— 理由见 tapPoint 上面那段(TikTok 吞无障碍坐标点击)。 */ tapPoint(point.x, point.y); return finish(story, true, 'gesture tap(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' }; } /* 先让输入框**真正获得焦点**。 * * ★ 两种手段都上(2026-10-28 实测):无障碍 ACTION_CLICK 对输入框时灵时不灵, * 坐标点击在子脚本(引擎)里也可能被 TikTok 吞掉。两个都试、都没有副作用 * (点输入框只会让它聚焦)。 * * **必须聚焦**,不只是为了输入 —— 输入框不聚焦就不会弹键盘, * 而首页信息流的评论面板里,**"发送"按钮只有在键盘弹起时才存在**。 * 少了这一步,整条评论链路会断在"定位不到发送按钮",而日志完全看不出是这个原因。 */ try { node.click(); } catch (ignoredClick) { /* 输入框不可点也没关系,下一种手段继续 */ } TikTokAuto.wait.tick(150); var point = layout.center(node); if (point) { tapPoint(point.x, point.y); TikTokAuto.wait.tick(250); } 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.', '点赞']; /** 已点赞后同一个按钮的 desc(实测是 `Video liked`,不是 `Unlike`) */ var LIKED_DESC_KEYS = ['Video liked', '已点赞', 'Unlike']; /** 关注按钮 desc 前缀(后面跟作者名)。`Followers` 会被 followButton 里单独排除。 */ var FOLLOW_DESC_KEYS = ['Follow ', '关注']; var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment']; var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音']; /** * 右侧竖排动作栏的 desc 前缀(作者 / 点赞 / 评论 / 收藏 / 分享 / 音乐)。 * 用于把"和输入框同高的动作栏按钮"从发送键候选里排掉 —— 详见 isActionBarButton。 */ var ACTION_BAR_DESC_PREFIXES = [ 'Follow', 'Following', 'Unfollow', 'Like video', 'Video liked', 'Read or add comments', 'Add or remove this video', 'Share video', 'Sound:' ]; var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos']; /** * 结果页卡片搜索区的上边界(占屏高比例)。 * * 实测布局:搜索框 → 页签栏 y 152~232 → GridView 从 y=232 开始(屏高 1920)。 * 取 0.13(=250)刚好卡在网格起始线下方,把搜索框和页签排掉。 * 详见 videoCards() 的注释(用 0.3 会漏掉滚动后的上排卡片)。 */ var ROWS_TOP_RATIO = 0.13; 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; } /** * 底部导航的「Profile」项 —— 用来打开**自己的**主页(脚本二要自己的粉丝列表)。 * * 判据刻意很松:`desc === 'Profile'` 精确匹配 + 中心落在屏幕下缘。 * 不限定 class(它是 FrameLayout,和历史记忆里"首页顶部 Following 页签是 * FrameLayout"同一族,限定 Button 会一个都找不到),也不认文案 —— * 底部导航这一项没有 text。 * * 为什么还要判"在下缘":右侧竖排作者头像的 desc 是 `xxx profile`(小写、 * 带用户名前缀),精确匹配 `Profile` 本来就排除掉了;但万一新版把它改成 * 纯 `Profile`,这一条能挡住"点到了头像"这种最坏情况。 */ function profileNav() { var node = layout.findOnce(desc('Profile')); if (!node) { return null; } var point = layout.center(node); if (!point || point.y < screenHeight() * 0.88) { return null; } return node; } // ---------------------------------------------------------------- 搜索页 /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */ 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); /* 上限从 0.8 放宽到 1.05(2026-10-28):卡片滚动到贴着屏幕上下边缘时, * 容器会有一部分在屏幕外,`bounds()` 报的高度于是**被裁切得又大又怪**, * 实测超过 80% 后被这里挡掉 → 整排卡片变成"找不到容器" → 上层误判 * "卡片用尽"。点击点由 `layout.center` 用 visibleRect 夹取,仍然安全。 */ if (cardHeight >= height * 0.18 && cardHeight <= height * 1.05) { return cursor; } cursor = layout.clickableAncestor(cursor); depth += 1; } return anchor; } /** * 这个节点是不是在"结果网格"里(GridView / RecyclerView 的子孙)。 * * 为什么需要:结果页上会浮出各种**不是视频卡但同样又宽又有文案**的东西 —— * 授权弹窗的 `Customize Settings` / `Confirm My Choices`、商品卡的 `Shop now`、 * 「Users」分区的表头。它们能通过"宽 + 有文案"的判据,被当成卡片点开白等 20 秒。 * 而真正的结果卡片一定在滚动容器里,弹窗不出现在其中。 */ function insideResultGrid(node) { var cursor = node; for (var depth = 0; depth < 8; depth += 1) { try { cursor = cursor.parent(); } catch (ignored) { return false; } if (!cursor) { return false; } var name = ''; try { name = String(cursor.className() || ''); } catch (ignoredToo) { name = ''; } if (name.indexOf('GridView') >= 0 || name.indexOf('RecyclerView') >= 0) { return true; } } return false; } /** * 当前页面是"照片轮播帖"吗(不是视频)? * * 真机实测(2026-10-28):照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView, * 视频帖是「`Video` · …」。照片帖**没有播放页**,`onPlayerPage()` 也认不出来, * 于是 openCard 会白等满 20 秒(playerReadyMs)再失败。 * * 判据限定文案**完全等于** `Photo` 且位于屏幕下半部分(信息行), * 避免误伤说明文字里出现 Photo 的普通视频。 */ function photoPost() { var height = screenHeight(); var hits = findByLabel(className('android.widget.TextView'), function (node) { return (layout.text(node) || '') === 'Photo' && topOf(node) > height * 0.5; }); return hits.length > 0 ? hits[0] : null; } /** * 结果网格里当前可见的卡片(按屏幕从上到下)。 * * 判据(2026-10-28 用 AutoX 无障碍树真机核对过): * - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉; * - **必须在 GridView/RecyclerView 里**(排掉浮层/弹窗按钮,如 `Customize Settings`); * - **必须能找到一个"卡片尺寸"的可点容器**(`cardContainer` 找不到时会退回节点 * 本身,那种情况说明它只是一个孤立按钮,不是卡片); * - 可见性看**容器**,不看文案。 * * ★ 为什么不再要求"文案在屏幕内"(这次最关键的修正): * 卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 8,1242 → 536,1920+)。 * 结果页上只要多一条通栏广告或一行筛选 chips,网格就被推下去,文案跟着落到 * **屏幕外**(实测 y=2099 > 1920)。旧的 `bottomOf(node) <= height - 12` 于是把这些 * **明明看得见、点得着**的卡片全部丢掉 —— 表现成"只处理了 1 个就说卡片用尽"。 * * 正确做法:文案照读(无障碍树里它在,只是不在可视区),能不能点由**容器**决定; * 点击点由 `layout.center` 用 `visibleRect` 夹取,永远落在屏幕内。 */ function videoCards() { var width = screenWidth(); var height = screenHeight(); var gridTop = height * ROWS_TOP_RATIO; /* 页签栏底部,网格从这里开始 */ var minVisible = height * 0.08; /* 露出来的太少就不点(点下去容易点到邻居) */ var cards = []; var seen = {}; var hits = findByLabel(className('android.widget.Button'), function (node) { return widthOf(node) >= width * 0.35 && insideResultGrid(node); }); for (var i = 0; i < hits.length; i += 1) { var caption = layout.text(hits[i]) || layout.desc(hits[i]); if (!caption) { continue; } var container = cardContainer(hits[i]); if (!container || container === hits[i]) { /* 找不到卡片尺寸的可点容器 → 这是个孤立按钮(弹窗按钮、分区表头等) */ continue; } var boxTop = topOf(container); var boxBottom = bottomOf(container); if (boxBottom <= gridTop) { /* 整个容器都在页签栏之上 —— 不是结果卡片 */ continue; } if (boxTop >= height) { /* 完全在屏幕下方,看不见也点不着 */ continue; } if (Math.min(boxBottom, height) - Math.max(boxTop, gridTop) < minVisible) { /* 只露出一条缝,点下去很容易落到相邻卡片上 */ continue; } var key = caption + '@' + boxTop; if (seen[key]) { continue; } seen[key] = true; cards.push({ anchor: hits[i], container: container, caption: caption, top: boxTop }); } 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; } /** * 「已点赞」状态的点赞按钮。 * * ★ 实测(2026-10-28,TikTok 47.1.4):点过赞之后同一个 `Button` 的 desc * 从 `Like video. 2.4M likes` 变成 **`Video liked`**(不是 `Unlike`), * 而里面那个 ImageView 的 `selected` 从 false 变成 true。 * * 两个判据都要留:desc 受本地化影响,selected 是状态位不受文案影响 —— * 任一命中就算已赞,比只认一个更耐改版。 */ function likedButton() { var hits = findByLabel(className('android.widget.Button'), function (node) { return startsWithAny(layout.desc(node), LIKED_DESC_KEYS); }); return hits.length > 0 ? hits[0] : null; } /** * 点赞按钮里的图标节点(用它读 `selected`)。 * 实测:未赞 selected=false,已赞 selected=true。 */ function likeIcon() { var button = likeButton() || likedButton(); if (!button) { return null; } var icons = button.find(className('android.widget.ImageView')); return icons.size() > 0 ? icons.get(0) : null; } /** 'liked' | 'unliked' | 'unknown' */ function likeState() { if (likedButton() !== null) { return 'liked'; } if (likeButton() !== null) { return 'unliked'; } /* 按钮本身找不到时,退回看图标状态位(有些版本用同一个 desc,只翻 selected) */ var icon = null; try { icon = likeIcon(); } catch (ignored) { icon = null; } if (icon !== null && icon.selected()) { return 'liked'; } return 'unknown'; } /** * 播放页的「关注作者」按钮。 * * 实测 desc 形如 `Follow Marcgagilang`(`Follow` + 空格 + 作者名), * 是 `android.widget.Button`。 * ⚠️ 首页顶部那个 `Following` 页签是 **FrameLayout**,不是 Button, * 所以限定 className 就能把它排除掉,不必额外按坐标过滤。 */ function followButton() { var hits = findByLabel(className('android.widget.Button'), function (node) { var value = layout.desc(node); if (!startsWithAny(value, FOLLOW_DESC_KEYS)) { return false; } /* `Followers` 是粉丝列表的入口,不是关注按钮 —— 前缀匹配会误命中 */ return value.indexOf('Followers') !== 0; }); return hits.length > 0 ? hits[0] : null; } /** * 作者头像节点。用来区分「已关注(按钮消失)」和「页面还没渲染」。 * 实测 desc 形如 `Marcgagilang profile`。 */ function authorAvatar() { var hits = findByLabel(className('android.widget.ImageView'), function (node) { var value = layout.desc(node); return value !== '' && / profile$/i.test(value); }); return hits.length > 0 ? hits[0] : null; } /** * 信息流当前视频的指纹 —— **只用来判断"上滑有没有真的换视频"**。 * * ★ 它**不能**当业务上的"视频身份"用(判断是不是同一个视频): * 点赞之后 `Like video. 340.2K likes` 会变成 `Video liked`,指纹必然变化。 * 所以它天生只适合回答"变了没有",不适合回答"这是不是同一个"。 * * 「会不会重复操作同一个视频」不靠指纹,靠状态: * · 已经点过赞 → `likeState() === 'liked'` → interact.like 自己会 skip * · 已经关注 → `followState() === 'following'` → interact.follow 自己会 skip * 那两条是地面真相,比任何拼出来的指纹都可靠。 * * 信息流的树里**同时存在上/下两个视频**的节点(实测:上一个在 y=-702、 * 当前在 y=1149、下一个在 y=3069),所以这里只能用已经过 `isVisible` 的 * likeButton/authorAvatar/commentButton —— 它们只返回当前那个视频的节点。 */ function feedSignature() { var parts = []; var avatar = authorAvatar(); if (avatar) { parts.push('a:' + layout.desc(avatar)); } var like = likeButton() || likedButton(); if (like) { parts.push('l:' + layout.desc(like)); } var comment = commentButton(); if (comment) { parts.push('c:' + layout.desc(comment)); } return parts.join('|'); } /** * 'following' | 'not-following' | 'unknown' * * ★ 「已关注」的唯一表现是**按钮整个消失**(实测),所以不能只看"找不到按钮" * —— 页面没渲染完也找不到。用作者头像是否存在来区分这两种情况: * 头像在、按钮不在 = 已关注;两个都不在 = 页面还没好。 */ function followState() { if (followButton() !== null) { return 'not-following'; } if (authorAvatar() !== null) { return 'following'; } return 'unknown'; } /** * 播放页判据。 * * 教训(实测踩到):**信息流首页也有 "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 screenH = screenHeight(); /* ① 经典位置:键盘弹起之后,输入框会被顶到屏幕中部。 */ var mid = findByLabel(className('android.widget.EditText'), function (node) { var top = topOf(node); return top > screenH * 0.45 && top < screenH * 0.85; }); if (mid.length > 0) { return mid[0]; } /* ② 键盘**没弹起**时,输入框停在屏幕底部(实测 y=1816 @1080×1920,即 0.95H), * 完全落不进上面那个区间。 * * 这是首页信息流里特有的情形:点右侧评论按钮打开面板,输入框不会自动获得 * 焦点、键盘也就不弹;而播放页里点的是内联输入框,它天生带焦点、键盘必弹。 * 所以只按位置判的话,**信息流里每一次都会判成"面板没打开"** —— * 而面板明明已经开了,输入框就在底部等着被点。 * * 这时只能靠"评论区头部计数"来确认面板确实开着(那是面板独有的元素), * 再把 EditText 认成输入框。视频链路上这条分支不会命中(① 已经返回了)。 */ if (listCommentCount() !== null) { var any = findByLabel(className('android.widget.EditText'), function () { return true; }); if (any.length > 0) { return any[0]; } } return 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 改版时改配置即可,不用改代码。 */ /** * 这个节点是不是右侧竖排动作栏(点赞/评论/收藏/分享/作者/音乐)里的按钮。 * * ★ 为什么需要它(2026-10-28):评论面板一打开,输入框会上移到屏幕中部, * 正好和那六个按钮**同高**,光靠坐标完全分不开 —— 实测按"输入框右端" * 找到的是 `Follow xxx` 和 `Like video.`,点下去是关注/点赞,评论根本没发。 * * 而这六个按钮每一个都有明确的 desc 前缀,发送键没有。这是唯一稳定的分界。 */ function isActionBarButton(node) { var value = layout.desc(node); if (!value) { return false; } for (var i = 0; i < ACTION_BAR_DESC_PREFIXES.length; i += 1) { if (value.indexOf(ACTION_BAR_DESC_PREFIXES[i]) === 0) { return true; } } return false; } /** * 按"输入框那一行"找发送按钮。 * * 判据只有两条,都落在几何事实上: * ① 节点的**中心 y** 落在输入框上下边界之间(= 同一行) * ② 节点的**右边界**贴着输入框右端(或更右) * * ★ 为什么不能用"输入框下方那一行"(2026-10-28 血泪): * 面板打开后,**播放页那套右侧竖排动作栏(点赞/评论/收藏/分享)的无障碍节点 * 仍然留在树里**,y 范围(实测 1218~1822)与面板区域大幅重叠。 * 按"下方取最右"会稳定命中**点赞按钮**(id=`g8g`),后果是: * 面板被收起 + 视频被点了个赞,而业务侧因为"面板关了"判定成评论发送成功。 * 假阳性比失败更糟 —— 用户以为发出去了。 * * 而"中心 y 在输入框行内"这条能干净地排掉右侧竖排: * 点赞按钮 1218~1338 的中心是 1278,落在输入框 1115~1222 之外。 * * ★ 发送键**只在键盘弹起时才存在**。所以调用方必须先让输入框聚焦 * (`ui.typeInto` 里已经做了),否则这里返回 null 是正常的 —— * 那种情况会如实报"定位不到发送按钮",不会乱点。 */ function sendButtonOnInputRow() { var input = panelInput(); if (!input) { logger.warn('comment', '没有面板输入框,无法按行定位发送按钮'); return null; } var ib = input.bounds(); var inputMidY = (ib.top + ib.bottom) / 2; var screenW = screenWidth(); var best = null; var bestDist = null; var groups = ['android.widget.Button', 'android.widget.ImageView']; for (var g = 0; g < groups.length; g += 1) { var hits = findByLabel(className(groups[g]), function (node) { /* ★ 先排掉右侧竖排动作栏(2026-10-28 血泪): * 面板打开后输入框会上移到中部,正好和那六个按钮**同高**, * 光靠几何分不开。但它们每一个都有明确的 desc 前缀, * 而发送键没有 —— 这是唯一稳定可靠的分界。 */ if (isActionBarButton(node)) { return false; } var nb = node.bounds(); var midY = (nb.top + nb.bottom) / 2; if (midY < ib.top || midY > ib.bottom) { return false; // 不在输入框这一行 } if (nb.right < ib.right - 60) { return false; // 不在输入框右端 } if (nb.left < screenW * 0.5) { return false; // 只看右半边 } if (nb.bottom - nb.top < 20 || nb.right - nb.left < 20) { return false; // 太小的装饰不收 } try { return node.clickable() === true; } catch (ignored) { return false; } }); /* 同一行里可能有多个(清空、表情…),取**离输入框中心最近**的那个。 * 用局部变量记距离 —— 绝不能往 AutoX 的 UiObject 上挂自定义属性: * 那是 Java 宿主对象,赋值会在运行期抛异常,而且崩得很安静 * (界面只看到"未开始")。 */ for (var i = 0; i < hits.length; i += 1) { var hb = hits[i].bounds(); var dist = Math.abs((hb.top + hb.bottom) / 2 - inputMidY); if (bestDist === null || dist < bestDist) { bestDist = dist; best = hits[i]; } } if (best) { break; } } if (!best) { /* 排障:输入框这一行右侧到底有什么 */ logger.warn('comment', '输入框 b=[' + ib.left + ',' + ib.top + ',' + ib.right + ',' + ib.bottom + '] 这一行的右侧没有可点按钮(键盘没弹起?)'); var probes = []; try { var probeClasses = ['android.widget.Button', 'android.widget.ImageView', 'android.view.View', 'android.widget.FrameLayout']; for (var p = 0; p < probeClasses.length; p += 1) { var cands = className(probeClasses[p]).find(); for (var c = 0; c < cands.size(); c += 1) { var nb2 = cands.get(c).bounds(); var mid2 = (nb2.top + nb2.bottom) / 2; if (mid2 < ib.top - 40 || mid2 > ib.bottom + 40) { continue; } if (nb2.left < screenW * 0.5) { continue; } probes.push(String(probeClasses[p]).split('.').pop() + '[' + nb2.left + ',' + nb2.top + ',' + nb2.right + ',' + nb2.bottom + ']' + (isActionBarButton(cands.get(c)) ? '(动作栏)' : '')); } } } catch (ignoredProbe) { } logger.warn('comment', '这一行右侧实际有:' + (probes.join(' ') || '(空)')); return null; } logger.info('comment', '发送按钮:按输入框这一行定位 id=' + layout.shortId(best) + ' desc=' + JSON.stringify(layout.desc(best)) + ' b=' + JSON.stringify(best.bounds())); return best; } 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; } } /* 先用几何判据(输入框那一行 + 右端)—— 它对"工具行 desc 变成 @资源id" * 和"右侧动作栏节点残留在树里"这两个新版变化都免疫。 */ var rowButton = sendButtonOnInputRow(); if (rowButton) { return rowButton; } var anchor = toolRowAnchor(); if (!anchor) { 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"。 * * 为什么需要它:实测发现**视频页那个评论角标不一定会立刻刷新** * (发完评论后仍读回旧值),而评论列表头部的计数是即时的。 * 所以"计数自增"这条证据要用列表头部的那个,而不是角标的。 */ /** * 从评论区头部的文案里取出评论数。**两种格式都要认**: * * · 旧版:`150 comments` / `1,203 comments` —— 数字在前 * · 新版:`Comments 785` —— 数字在后,而且与页签名在**同一个 TextView** 里 * (2026-10 实测:评论区拆成了 `Comments` / `Reviews` 两个页签) * * ★ 只认旧格式的后果非常隐蔽:首页信息流里点开评论面板后, * `listCommentCount()` 返回 null → `panelInput()` 的第二条分支进不去 → * 整个面板被判成"没打开",而屏幕上明明已经有评论列表和输入框。 * 日志里只会看到一句"面板没打开(该视频可能关闭了评论)",完全是误导。 */ function parseCommentCountLabel(value) { var text = String(value || '').replace(/^\s+|\s+$/g, ''); var newer = text.match(/^Comments?\s+([\d][\d,.]*)$/i); if (newer) { return parseInt(String(newer[1]).replace(/[^\d]/g, ''), 10); } var legacy = text.match(/^([\d][\d,.]*)\s*(?:comments?|条评论)$/i); if (legacy) { return parseInt(String(legacy[1]).replace(/[^\d]/g, ''), 10); } return null; } function listCommentCount() { var hits = findByLabel(className('android.widget.TextView'), function (node) { return parseCommentCountLabel(layout.text(node)) !== null; }); if (hits.length === 0) { return null; } return parseCommentCountLabel(layout.text(hits[0])); } /** * 评论面板是否处于打开状态。 * 注意:点内联输入框打开的是"快捷面板"(有中部输入框), * 点评论角标打开的是"列表面板"(没有中部输入框,但有 "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; } // ============================================================ ID 提取 /* * 下面这一整段是 2026-10-20 在真机上逐页 dump 出来的锚点 * (Pixel 5a / 1080x1920 / TikTok 47.1.4 / 英文界面)。**每条都是实测值**, * 不是猜的 —— 猜出来的选择器在这个项目里从来没有一次是对的。 * * ★ 一个重要发现:这一片的**资源 id 是语义化的**(`tv_username` / `txt_desc` / * `user_avatar`),与信息流那批混淆 id(`e4g`/`cn6`)完全不同。 * 原因是这些页面来自 TikTok 的搜索/社交业务模块,用的是另一套布局。 * 所以这里优先按 id 定位,文案只作为兜底(改版时 id 比文案更稳)。 * * 实测到的三处文本陷阱: * ① 用户名被 **FID 隔离符** 包裹:显示 `⁨ajman_fashion_week⁩`(U+2068/U+2069), * 肉眼看不见,`layout.sanitize` 也不剥(那不是零宽字符)→ 统一走 `parse.username`; * ② 搜索用户列表里 **`tv_aweme_id` 装的是昵称**(id 名违反直觉),`tv_username` 才是 uid; * ③ 粉丝列表与搜索列表的同义控件**id 不同**: * 搜索列表 `tv_username`/`tv_desc`,粉丝列表 `txt_desc`(uid)/`txt_user_name`(昵称)。 */ /** 是否在用户主页:有一个文案形如 "@xxx" 的可点 Button,且下面跟着三组数字 */ function profileUsernameNode() { var hits = findByLabel(className('android.widget.Button'), function (node) { var value = layout.text(node); return value.charAt(0) === '@' && value.length > 1 && value.length < 40 && topOf(node) < screenHeight() * 0.3; }); return hits.length > 0 ? hits[0] : null; } /** 用户主页的昵称(在 @xxx 上面那一行) */ function profileNicknameNode() { var userNode = profileUsernameNode(); if (!userNode) { return null; } var userTop = topOf(userNode); var hits = findByLabel(className('android.widget.Button'), function (node) { var box = node.bounds(); return box.top < userTop && box.bottom > userTop - screenHeight() * 0.12 && layout.text(node) !== '' && layout.text(node).charAt(0) !== '@'; }); return hits.length > 0 ? hits[0] : null; } /** 用户页顶部三组数字的标签文案。英文界面实测为这三个词。 */ var STAT_LABELS = { 'Following': 'follow', 'Followers': 'fans', 'Likes': 'likes', '关注': 'follow', '粉丝': 'fans', '获赞': 'likes', 'Abonnements': 'follow', 'Abonnés': 'fans', "J'aime": 'likes' }; /** * 用户页顶部三组数字:`{ follow: 343, fans: 594, likes: 2491 }`。 * * 结构(实测):每个数字是一个 LinearLayout,里面两个 TextView —— * 数字在上、标签在下。所以按"标签找容器、再取容器里的数字"读。 * 注意**没有视频数**:这个页面只显示 关注/粉丝/获赞 三组,这是平台行为。 */ function statNumbers() { var out = { follow: null, fans: null, likes: null }; var parse = TikTokAuto.parse; var labels = findByLabel(className('android.widget.TextView'), function () { return true; }); for (var i = 0; i < labels.length; i += 1) { var labelText = layout.text(labels[i]); var key = STAT_LABELS[labelText]; if (!key) { continue; } /* 标签必须在页面上部(三组数字区),否则可能是别处长文本里恰好含这个词 */ if (topOf(labels[i]) > screenHeight() * 0.25) { continue; } var container = parentOf(labels[i]); var value = container ? numberInside(container) : null; if (value === null) { value = parse.count(labelText); } if (out[key] === null) { out[key] = value; } } return out; } function parentOf(node) { try { return node.parent(); } catch (ignored) { return null; } } /** 在一个容器里找那个"纯数字"的 TextView,解析成整数 */ function numberInside(container) { var parse = TikTokAuto.parse; var found = null; var walk = [container]; var guard = 0; while (walk.length > 0 && guard < 30) { guard += 1; var node = walk.shift(); if (!node) { continue; } var value = layout.text(node); if (value !== '' && /^[\d,.\s]+[kKmMbBwW万萬亿億]?$/.test(value)) { var parsed = parse.count(value); if (parsed !== null && (found === null || parsed > found)) { found = parsed; } } var count = 0; try { count = node.childCount(); } catch (ignored) { count = 0; } for (var i = 0; i < count; i += 1) { try { walk.push(node.child(i)); } catch (ignoredToo) { /* 遍历中失效就跳过 */ } } } return found; } /** 用户主页的个签:页面中上部一段较长的可点文本(实测是个 Button,不是 TextView) */ function profileSignatureNode() { var hits = findByLabel(className('android.widget.Button'), function (node) { var value = layout.text(node); var box = node.bounds(); return value.length >= 4 && value.charAt(0) !== '@' && box.top > screenHeight() * 0.22 && box.top < screenHeight() * 0.34 && (box.right - box.left) > screenWidth() * 0.4; }); return hits.length > 0 ? hits[0] : null; } /** * 私密账号提示。 * 实测:私密账号的主页会在视频区位置显示一行提示("This account is private" 等)。 * 这里只在**视频网格明显不存在**时才认这条,避免把正常账号的某条视频文案误判成提示。 */ function privateHint() { var keys = ['private', "isn't available", 'no videos', 'No videos']; var texts = visibleTexts(0); for (var i = 0; i < texts.length; i += 1) { for (var j = 0; j < keys.length; j += 1) { if (String(texts[i]).toLowerCase().indexOf(String(keys[j]).toLowerCase()) >= 0) { return texts[i]; } } } return ''; } /** 用户主页的视频网格格子数(判断"有没有内容"用,不用于视频量门槛) */ function videoGridCount() { var hits = findByLabel(className('android.widget.FrameLayout'), function (node) { var point = layout.center(node); return point !== null && point.y > screenHeight() * 0.33; }); return hits.length; } /** 用户主页上的「关注/粉丝」入口(点它打开列表页) */ function statEntry(which) { var wanted = which === 'following' ? 'follow' : 'fans'; var labels = findByLabel(className('android.widget.TextView'), function () { return true; }); for (var i = 0; i < labels.length; i += 1) { if (STAT_LABELS[layout.text(labels[i])] !== wanted) { continue; } if (topOf(labels[i]) > screenHeight() * 0.25) { continue; } /* 可点的是外层容器,标签自己不可点 */ return layout.clickableAncestor(labels[i]) || parentOf(labels[i]); } return null; } /** * 粉丝/关注列表页的页签("Following 343" / "Followers 594" / "Suggested")。 * * 实测这个列表是**从用户页点进来的一张覆盖页**,顶部有三个页签, * 可以直接在两个页签之间切换 —— 所以"先收粉丝再收关注"不必来回退。 */ function userListTabs() { var out = []; var hits = findByLabel(className('android.widget.TextView'), function (node) { var value = layout.text(node); return /^(Following|Followers|Suggested|关注|粉丝|推荐)\b/.test(value) && topOf(node) < screenHeight() * 0.16; }); for (var i = 0; i < hits.length; i += 1) { var value = layout.text(hits[i]); var key = value.indexOf('Follower') === 0 || value.indexOf('粉丝') === 0 ? 'fans' : (value.indexOf('Suggested') === 0 || value.indexOf('推荐') === 0 ? 'suggested' : 'following'); out.push({ key: key, label: value, node: layout.clickableAncestor(hits[i]) || hits[i] }); } return out; } /** 当前列表页选中的是哪个页签(用 selected 属性判,实测可用) */ function selectedListTab() { var tabs = userListTabs(); for (var i = 0; i < tabs.length; i += 1) { var selected = false; try { selected = tabs[i].node.selected() === true || tabs[i].node.isSelected() === true; } catch (ignored) { selected = false; } if (selected) { return tabs[i].key; } } /* selected 读不到时退回文案判断:选中态的页签文案与未选中态无差异, * 所以这里只能返回 unknown,由调用方用"点完再看内容变没变"确认。 */ return 'unknown'; } /** * 粉丝/关注列表里的每一行。 * * 实测结构:`LinearLayout`(整行可点)> `TextView txt_user_name`(昵称) * + `TextView txt_desc`(**uid**)+ `Button ubp`(Follow)。 * 注意与搜索用户列表**id 不同**(那边是 tv_username/tv_desc)。 * * 返回的 `node` 是行容器,点它进用户页;`x` 是"安全点击的横向位置"—— * 靠左,避开右侧的 Follow 按钮(实测按钮左边界 x≈872)。 */ function userListRows() { var parse = TikTokAuto.parse; var rows = []; var uidNodes = findByLabel(className('android.widget.TextView'), function (node) { return resourceIdOf(node).indexOf('txt_desc') >= 0 && layout.text(node) !== ''; }); /* 有的版本 uid 放在 txt_user_name、昵称放在 txt_desc,所以按"哪个像 uid"再兜一层 */ if (uidNodes.length === 0) { uidNodes = findByLabel(className('android.widget.TextView'), function (node) { return resourceIdOf(node).indexOf('txt_user_name') >= 0 && parse.looksLikeUsername(layout.text(node)); }); } for (var i = 0; i < uidNodes.length; i += 1) { var uidNode = uidNodes[i]; var uid = parse.username(layout.text(uidNode)); if (!parse.looksLikeUsername(uid)) { continue; } var row = findRowContainer(uidNode); var box = row ? row.bounds() : uidNode.bounds(); rows.push({ uid: uid, nickname: nicknameOf(uidNode, row), node: row || uidNode, top: box.top, x: Math.round(box.left + (box.right - box.left) * 0.3), y: Math.round((box.top + box.bottom) / 2) }); } rows.sort(function (a, b) { return a.top - b.top; }); return rows; } function resourceIdOf(node) { return layout.resourceId(node); } /** 从 uid 文本往上找"整行"容器(高度落在一行的高度区间内) */ function findRowContainer(uidNode) { var cursor = uidNode; var depth = 0; while (cursor && depth < 5) { var box = cursor.bounds(); var rowHeight = box.bottom - box.top; if (layout.isClickable(cursor) && rowHeight >= screenHeight() * 0.05 && rowHeight <= screenHeight() * 0.12) { return cursor; } cursor = parentOf(cursor); depth += 1; } return layout.clickableAncestor(uidNode) || parentOf(uidNode); } /** 行里的昵称:找同一容器内的另一个 TextView(不是 uid 那个) */ function nicknameOf(uidNode, row) { if (!row) { return ''; } var found = ''; var walk = [row]; var guard = 0; while (walk.length > 0 && guard < 40) { guard += 1; var node = walk.shift(); if (!node) { continue; } var id = resourceIdOf(node); var value = layout.text(node); if (value !== '' && (id.indexOf('txt_user_name') >= 0 || id.indexOf('tv_aweme_id') >= 0)) { found = value; break; } var count = 0; try { count = node.childCount(); } catch (ignored) { count = 0; } for (var i = 0; i < count; i += 1) { try { walk.push(node.child(i)); } catch (ignoredToo) { /* 跳过 */ } } } return found; } /** * 搜索结果的「用户」列表行。 * * 实测结构:`Button`(整行可点)> `TextView tv_username`(uid) * + `TextView tv_aweme_id`(**昵称**,id 名违反直觉)+ `TextView tv_desc`("594 followers · 2.5K likes")。 * * 额外好处:`tv_desc` 里就有粉丝数与获赞数 —— 所以按数量筛选时 * **这一条来源不必逐个打开用户页**(其余来源没有这个信息)。 */ function usersResultRows() { var parse = TikTokAuto.parse; var rows = []; var uidNodes = findByLabel(className('android.widget.TextView'), function (node) { return resourceIdOf(node).indexOf('tv_username') >= 0 && layout.text(node) !== ''; }); for (var i = 0; i < uidNodes.length; i += 1) { var uid = parse.username(layout.text(uidNodes[i])); if (!parse.looksLikeUsername(uid)) { continue; } var box = uidNodes[i].bounds(); var desc = ''; var nickname = ''; var rowNode = null; /* 向上两层找行容器,再在容器里读昵称与计数 */ var cursor = uidNodes[i]; for (var up = 0; up < 4 && cursor; up += 1) { cursor = parentOf(cursor); if (cursor && layout.isClickable(cursor)) { rowNode = cursor; break; } } if (rowNode) { var children = findByLabelInside(rowNode, className('android.widget.TextView')); for (var j = 0; j < children.length; j += 1) { var id = resourceIdOf(children[j]); var value = layout.text(children[j]); if (id.indexOf('tv_aweme_id') >= 0) { nickname = value; } else if (id.indexOf('tv_desc') >= 0) { desc = value; } } var rowBox = rowNode.bounds(); box = { left: rowBox.left, top: rowBox.top, right: rowBox.right, bottom: rowBox.bottom }; } rows.push({ uid: uid, nickname: nickname, node: rowNode || uidNodes[i], top: box.top, fans: parseFollowerLine(desc, 'followers'), likes: parseFollowerLine(desc, 'likes'), x: Math.round(box.left + (box.right - box.left) * 0.3), y: Math.round((box.top + box.bottom) / 2) }); } rows.sort(function (a, b) { return a.top - b.top; }); return rows; } /** 在一个节点内部找符合条件的子孙(不含自身之外的范围) */ function findByLabelInside(rootNode, selector) { var out = []; var collection = selector.find(); for (var i = 0; i < collection.size(); i += 1) { var node = collection.get(i); var cursor = node; var depth = 0; var inside = false; while (cursor && depth < 12) { if (cursor === rootNode) { inside = true; break; } cursor = parentOf(cursor); depth += 1; } if (inside) { out.push(node); } } return out; } /** * 从 "594 followers · 2.5K likes" 里分别抠出两个数。 * 分隔符实测是 " · "(中点),但不同语言/版本可能不同,所以按**关键词**切而不是按分隔符。 */ function parseFollowerLine(text, which) { var parse = TikTokAuto.parse; if (!text) { return null; } var parts = String(text).split(/[·•|]/); for (var i = 0; i < parts.length; i += 1) { var part = parts[i]; var lower = part.toLowerCase(); if (which === 'followers' && (lower.indexOf('follower') >= 0 || lower.indexOf('粉丝') >= 0)) { return parse.count(part); } if (which === 'likes' && (lower.indexOf('like') >= 0 || lower.indexOf('获赞') >= 0)) { return parse.count(part); } } return null; } /** 播放页底部的作者 uid(实测:`Button l2m` 的 text = "@2131823375") */ function playerAuthorUid() { var parse = TikTokAuto.parse; var hits = findByLabel(className('android.widget.Button'), function (node) { var value = layout.text(node); return value.charAt(0) === '@' && parse.looksLikeUsername(value); }); return hits.length > 0 ? parse.username(layout.text(hits[0])) : ''; } /** * 评论区里的每一条:**只有昵称,没有 uid**(实测 2026-10-20)。 * * 所以"从评论区取评论者"必须逐个点头像/昵称进用户页 —— 成本比列表高一个数量级。 * 这里返回每个人可点的节点(头像 ImageView `blm`)与昵称,供上层决定怎么进。 */ function commentRowers() { var out = []; var avatars = findByLabel(className('android.widget.ImageView'), function (node) { return resourceIdOf(node).indexOf('blm') >= 0; }); for (var i = 0; i < avatars.length; i += 1) { var box = avatars[i].bounds(); /* 头像必须在评论列表区域内(屏幕中下部),不是视频右上角的作者头像 */ if (box.top < screenHeight() * 0.38) { continue; } out.push({ node: avatars[i], top: box.top, x: Math.round((box.left + box.right) / 2), y: Math.round((box.top + box.bottom) / 2) }); } out.sort(function (a, b) { return a.top - b.top; }); return out; } /** 结果页的某个页签当前是否选中(Users 页签用) */ function resultTabSelected(name) { var node = resultTab(name); if (!node) { return false; } try { return node.selected() === true || node.isSelected() === true; } catch (ignored) { return false; } } /** * ID 提取这条链路自己在用的页面判定。 * * **刻意与 pageState() 分开**:pageState 是视频链路的判据, * 往里加分支就有可能把"播放页 vs 结果页"的老判定带歪(方案 §12 明确要求回归)。 * 新开一个函数,视频链路一行都不用动。 * * @returns 'profile' | 'user-list' | 'users-results' | 'comment-panel' | 'search' | 'feed' | 'other' */ function idPageState() { if (commentPanelOpen()) { return 'comment-panel'; } if (profileUsernameNode() !== null) { return 'profile'; } if (userListTabs().length >= 2 && userListRows().length > 0) { return 'user-list'; } if (usersResultRows().length > 0) { return 'users-results'; } if (searchInput() !== null) { return 'search'; } if (bottomNavPresent()) { return 'feed'; } return 'other'; } // ---------------------------------------------------------------- 页面状态 /** * 判定当前处于哪个页面。 * 顺序即优先级:播放页最特征化先判,信息流最宽松最后判。 */ 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, profileNav: profileNav, resultTab: resultTab, resultTabStripPresent: resultTabStripPresent, videoCards: videoCards, commentButton: commentButton, likeButton: likeButton, likedButton: likedButton, likeIcon: likeIcon, likeState: likeState, followButton: followButton, authorAvatar: authorAvatar, feedSignature: feedSignature, parseCommentCountLabel: parseCommentCountLabel, followState: followState, onPlayerPage: onPlayerPage, photoPost: photoPost, backButton: backButton, commentCount: commentCount, inlineInput: inlineInput, panelInput: panelInput, toolRowAnchor: toolRowAnchor, sendButton: sendButton, listCommentCount: listCommentCount, commentPanelOpen: commentPanelOpen, commentListContains: commentListContains, visibleTexts: visibleTexts, describeScreen: describeScreen, /* —— ID 提取(2026-10-20 真机实测锚点,见上面的长注释) —— */ profileUsernameNode: profileUsernameNode, profileNicknameNode: profileNicknameNode, profileSignatureNode: profileSignatureNode, statNumbers: statNumbers, statEntry: statEntry, privateHint: privateHint, videoGridCount: videoGridCount, userListTabs: userListTabs, selectedListTab: selectedListTab, userListRows: userListRows, usersResultRows: usersResultRows, playerAuthorUid: playerAuthorUid, commentRowers: commentRowers, resultTabSelected: resultTabSelected, idPageState: idPageState }; }()); /* ============================== 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, * guard, // 页面守卫,默认"还在播放页"(见下) * minMs/maxMs // 覆盖 config.watch 的观看时长区间 * } * @returns 观看报告(对象,不是布尔——上层需要知道"为什么/可信度如何") * * ★ 为什么 `guard` 必须可注入(2026-10-28 真机实测): * 默认守卫是 `onPlayerPage()`,而它的判据里要求**有返回箭头、没有底部导航栏**—— * 这两条在首页信息流上正好相反(没有返回箭头、有 Home/Profile 导航)。 * 所以在信息流里跑 watch 时,默认守卫会恒为 false,3 次采样(约 3 秒)就 * 以 `left-player-page` 提前退出,一个视频都看不完。 * 养号传 `pageState() === 'feed'` 进来即可,视频链路不传、行为完全不变。 */ function watch(config, options) { var settings = options || {}; var step = settings.step || 'watch'; var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; var guard = settings.guard || function () { return tiktok.onPlayerPage(); }; var minMs = settings.minMs === undefined ? config.watch.minMs : settings.minMs; var maxMs = settings.maxMs === undefined ? config.watch.maxMs : settings.maxMs; var targetMs = randomInt(minMs, 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(区间 ' + minMs + '-' + maxMs + 'ms),证据源=' + probe.mode); while (Date.now() < deadline) { // (a) 页面身份守卫:不在播放页就不是"在看视频" if (!guard()) { 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; /* 先花最多 4 秒分辨"打开的是不是视频"。 * * 搜索结果里混着照片轮播帖,它的信息行是 `Photo · 1w ago`,**永远不会有播放页**, * 而 `onPlayerPage()` 也认不出来 —— 不提前判掉就要白等满 playerReadyMs(20 秒)。 * 真机实测:一轮 4 个目标里撞到 1 个照片帖,就白白浪费掉 20 秒。 * * 认出是视频(或两种都不是)之后仍走原来的等待,不改变正常路径的行为。 */ var kind = TikTokAuto.wait.waitFor(step + ':kind', function () { if (tiktok.onPlayerPage()) { return 'player'; } if (tiktok.photoPost()) { return 'photo'; } return null; }, 4000, config.probe.pollTickMs); if (kind === 'photo') { logger.info(step, '打开的是照片帖(没有播放页),跳过'); return false; } 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; } /* 两个入口,按页面形态选: * · 播放页(从搜索结果/主页点进来的)底部有一条内联输入框 "Add comment..." * · **首页信息流底部是导航栏**(Home/Friends/Inbox/Profile),没有内联输入框 * —— 只能点右侧那个 "Read or add comments. N comments" 按钮。 * 以前只有第一种,于是养号在信息流里发评论会直接报"找不到评论入口"。 */ var entry = tiktok.inlineInput(); var via = 'inline-input'; if (!entry) { entry = tiktok.commentButton(); via = 'comment-button'; } if (!entry) { logger.error(story, '找不到评论入口(内联输入框与评论按钮都没有)'); return false; } logger.info(story, '打开评论面板(入口=' + via + ')'); var ready = false; if (via === 'comment-button') { ready = openByCommentButton(config, entry, story); if (!ready) { /* 换一种点击手段再试 —— 入口节点可能已经失效(页面重排过) */ entry = tiktok.commentButton(); if (entry) { TikTokAuto.ui.clickNode(entry, story); } } } else { TikTokAuto.ui.clickNode(entry, story); } if (ready) { return true; } var opened = TikTokAuto.wait.waitFor(story, function () { return tiktok.panelInput() !== null ? true : null; }, config.timeouts.panelReadyMs, config.probe.pollTickMs); if (!opened) { /* 把当时所有输入框的位置记下来。"点了没反应"和"面板开了但认不出输入框" * 是两种完全不同的故障,而在日志里它们长得一模一样 —— 没有这行就只能靠猜。 */ var seen = []; try { var ets = className('android.widget.EditText').find(); for (var e = 0; e < ets.size(); e += 1) { var box = ets.get(e).bounds(); seen.push(box.top + '~' + box.bottom); } logger.warn(story, '面板没打开;此刻 EditText ' + ets.size() + ' 个 @ ' + seen.join(', ') + '(判据区间 ' + Math.floor(TikTokAuto.layout.screen().height * 0.45) + '~' + Math.floor(TikTokAuto.layout.screen().height * 0.85) + ')'); } catch (ignored) { logger.warn(story, '面板没打开;连输入框都列举失败'); } /* 有的视频关闭了评论 / 点到了别的地方。把话说清楚, * 否则上层只看到"评论发不出去"却不知道为什么。 */ logger.warn(story, '点了入口但面板没打开(该视频可能关闭了评论)'); } return opened === true; } /** * 通过点右侧那个 "Read or add comments" 按钮打开面板(首页信息流里唯一的入口)。 * * ★ 两个都必须做,缺一个就会"点击成功但面板死活不出来"(2026-10-28 真机实测): * * ① **点之前先等页面安静**。watch 刚结束、或者面板刚收起时,这一下点击会被吞掉。 * 症状极其迷惑:点击返回成功、`panelInput()` 却 8 秒都等不到;而隔一秒手动点 * 同一个坐标,400ms 就出面板。这和 `verifyInCommentList` 里"发布后立刻点角标 * 会被吞"是同一类问题,处理手法也一样(settle + 重试)。 * * ② **不能用 node.click()**。那个无障碍 ACTION_CLICK 返回 true,但 TikTok 不响应。 * 这一竖条按钮又窄(宽 128px)位置又固定,坐标点击才可靠。 */ function openByCommentButton(config, entry, story) { var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; var attempts = 0; var maxAttempts = 2; var ready = false; while (!ready && attempts < maxAttempts) { attempts += 1; TikTokAuto.wait.waitQuiet(function () { return TikTokAuto.layout.signature(); }, { maxMs: 1500, tickMs: 200, stableTimes: 2 }); var point = TikTokAuto.layout.center(entry); if (point) { logger.info(story, '第 ' + attempts + ' 次坐标点击评论入口 @(' + point.x + ',' + point.y + ')'); TikTokAuto.ui.tapPoint(point.x, point.y); } else { logger.warn(story, '算不出评论入口的中心点,改用无障碍点击'); TikTokAuto.ui.clickNode(entry, story); } ready = TikTokAuto.wait.waitFor(story, function () { return tiktok.panelInput() !== null ? true : null; }, 4000, config.probe.pollTickMs) === true; if (!ready) { /* 页面可能重排过,旧节点已失效 —— 重新找一个再试 */ entry = tiktok.commentButton(); if (!entry) { logger.warn(story, '重试时找不到评论入口了'); break; } } } return ready; } /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 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: '定位不到发送按钮' }; } /* ★ 发送键必须用**坐标点击**(2026-10-28 实测): * 它是个纯图标按钮,无障碍 ACTION_CLICK 打上去会落到祖先容器 —— * 实测点到了屏幕正中 (540,1465),那是评论列表的内容区,等于什么都没发。 * 而它形状固定、位置刚从输入框推出来,坐标点最准。 */ var sendPoint = TikTokAuto.layout.center(button); if (sendPoint) { logger.info(story, '坐标点击发送键 @(' + sendPoint.x + ',' + sendPoint.y + ')'); TikTokAuto.ui.tapPoint(sendPoint.x, sendPoint.y); } else { 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.commentPanelOpen()) { return null; } /* 播放页要等内联输入框真的回来 —— 这比"面板关了"更严(说明回到了视频页)。 * 但**信息流底部是导航栏、本来就没有内联输入框**:那里再等它就永远等不到, * 明明发成功了也会被判成"面板没有收起"。所以那种形态只认"回到了 feed"。 */ if (tiktok.inlineInput() !== null) { return true; } return tiktok.pageState() === 'feed' ? 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/interact.js ============================== */ /** * interact.js — 播放页的两个轻量互动动作:**点赞** 与 **关注作者** * * ## 为什么单独一个模块 * 它们和评论一样是"在播放页上点一下、然后验证真的生效",但比评论简单得多: * 不用开面板、不用输入、不用回读。所以不塞进 comment.js(那是评论发布的地盘)。 * * ## 实测锚点(2026-10-28 / TikTok 47.1.4 / 1080×1920) * * | 元素 | 类 | content-desc | 备注 | * |---|---|---|---| * | 点赞(未赞) | Button | `Like video. 2.4M likes` | 里面的 ImageView selected=false | * | 点赞(已赞) | Button | **`Video liked`** | ImageView selected=**true** | * | 关注 | Button | `Follow <作者名>` | 已关注时**整个按钮消失** | * | 作者头像 | ImageView | `<作者名> profile` | 用来区分"已关注"和"页面没渲染" | * * 点赞按钮和分享按钮**共用同一个 resource-id**(`g8g`),所以只能靠 desc 区分, * 不要试图用 id 定位。 * * ## 两条安全原则 * * ① **状态不明就不点。** 读不到"当前是否已赞"时,点一下可能把已赞的取消掉 —— * 和用户意图正好相反。宁可跳过并如实记录。 * ② **点了必须验。** 点击成功不代表生效(可能被弹窗挡、可能点偏)。 * 只有状态真的变了才算数,否则记失败,不虚报。 * * 依赖:TikTokAuto.tiktok / ui / wait / logger */ TikTokAuto.interact = (function () { 'use strict'; function logger() { return TikTokAuto.logger; } /** * 给当前视频点赞。 * * @returns { ok, skipped?, via, reason? } */ function like(step) { var tiktok = TikTokAuto.tiktok; var tag = step || 'like'; var state = tiktok.likeState(); if (state === 'liked') { return { ok: true, skipped: true, via: 'already-liked', reason: '这个视频已经点过赞' }; } if (state !== 'unliked') { return { ok: false, via: 'state-unknown', reason: '读不到点赞状态,跳过(不盲点:可能是已赞,点一下会变成取消)' }; } var button = tiktok.likeButton(); if (!button) { return { ok: false, via: 'button-missing', reason: '找不到点赞按钮' }; } TikTokAuto.ui.clickNode(button, tag); /* 验证:desc 从 `Like video.` 变成 `Video liked`(或图标 selected 翻转) */ var done = TikTokAuto.wait.waitFor(tag + ':verify', function () { return tiktok.likeState() === 'liked' ? true : null; }, 6000, 300); if (done === true) { logger().info(tag, '点赞成功(状态已变为已赞)'); return { ok: true, via: 'state-changed' }; } logger().warn(tag, '点赞后状态没有变化(可能被弹窗挡住或点偏了)'); return { ok: false, via: 'not-verified', reason: '点击后没有变成已赞' }; } /** * 关注当前视频的作者。 * * @returns { ok, skipped?, via, reason? } */ function follow(step) { var tiktok = TikTokAuto.tiktok; var tag = step || 'follow'; var state = tiktok.followState(); if (state === 'following') { return { ok: true, skipped: true, via: 'already-following', reason: '已经关注过这个作者' }; } if (state !== 'not-following') { /* 头像和按钮都读不到 = 页面还没渲染好。 * 这时不点:点到空白处可能触发别的动作(播放/暂停),没有意义。 */ return { ok: false, via: 'page-not-ready', reason: '作者头像与关注按钮都读不到,页面可能还没渲染完' }; } var button = tiktok.followButton(); if (!button) { return { ok: false, via: 'button-missing', reason: '找不到关注按钮' }; } TikTokAuto.ui.clickNode(button, tag); /* 验证:关注按钮**整个消失**(实测就是这种表现,不是改成 Following) */ var done = TikTokAuto.wait.waitFor(tag + ':verify', function () { return tiktok.followState() === 'following' ? true : null; }, 6000, 300); if (done === true) { logger().info(tag, '关注成功(关注按钮已消失)'); return { ok: true, via: 'button-gone' }; } logger().warn(tag, '关注后按钮仍在,可能没生效'); return { ok: false, via: 'not-verified', reason: '点击后关注按钮仍然存在' }; } return { like: like, follow: follow }; }()); /* ============================== 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'; var pkg = config.target.packageName; /* ★ 已经在前台就**绝对别动它**。 * * `app.launchPackage()` 不是"切到前台",而是"重新打开这个 app" —— * 即使 app 已经在前台,它也会再发一次 startActivity,把叠在主界面上的 * 页面(视频播放页、评论面板、用户列表)清掉,退回首页。 * * 对「当前屏幕」类取材这是致命的:用户手动把 TikTok 停在评论区, * 那正是这个取材源的全部前提,而引擎一启动就把它退成了 feed, * 然后再报"取材设置为当前屏幕,但当前不在播放页"。 * 实测(2026-10-22):日志里 `launch 就绪(56ms)` 看着毫秒级完成、 * 什么都没干,实际上页面已经被重置了 —— 时间是 56ms,副作用却不可逆。 */ if (TikTokAuto.layout.foregroundPackage() === pkg) { logger().info(step, '已在前台,保持当前页面不动'); return true; } logger().step(step, '启动 ' + pkg); app.launchPackage(pkg); var ready = TikTokAuto.wait.waitFor(step, function () { return TikTokAuto.layout.foregroundPackage() === pkg ? 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; } /** * 等结果网格真的渲染出卡片(而不是页面框架出来了就算完)。 * * @param timeoutMs 可选。不传用 `searchResultsMs`(20 秒,够首屏/切页签重刷)。 * 主循环里"滚动之前"的那次只值几秒 —— 到底时不该白等 20 秒。 * @returns 卡片数组(成功)或 null(超时) */ function waitForCards(config, timeoutMs) { var step = 'waitForCards'; return TikTokAuto.wait.waitFor(step, function () { var cards = TikTokAuto.tiktok.videoCards(); return cards.length > 0 ? cards : null; }, timeoutMs || config.timeouts.searchResultsMs, config.probe.pollTickMs); } function selectResultTab(config) { var step = 'selectResultTab'; var name = config.target.resultTab; if (!name) { return false; } var tiktok = TikTokAuto.tiktok; /* ★ 已经是目标页签就**不要点**(2026-10-28 实测)。 * * 「Top」是结果页的默认页签,而我们默认配置的 resultTab 也是 Top —— * 等于每次运行都会点一次当前页签。点已选中的页签会让 TikTok 重刷整个网格: * 卡片先消失、再重新渲染,中间那段时间 videoCards() 返回 0。 * 后面 `waitQuiet` 又判断不出"列表正在重刷"(签名和点之前一样), * 于是直接进了主循环 → 读到 0 张卡 → 滚一下 → 还是 0 → 报「已无更多卡片」。 * 整个流程零报错地失败,看着像"这个关键词没有视频"。 */ if (tiktok.resultTabSelected(name)) { logger().info(step, '页签已经是 ' + name + ',跳过点击(点了会重刷列表)'); return true; } var tab = tiktok.resultTab(name); if (!tab) { logger().warn(step, '找不到页签 ' + name + ',沿用当前页签'); return false; } logger().info(step, '切换到页签 ' + name); TikTokAuto.ui.clickNode(tab, step); /* 切页签一定会重刷网格 → 判据必须是"卡片真的重新出现了", * 不能再用 waitQuiet(它会把"还在加载"当成"已经安静")。 */ if (!waitForCards(config)) { logger().warn(step, '切到 ' + name + ' 后没等到卡片'); return false; } 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 }); } // ------------------------------------------------------------ 选片与观看 /** * 从当前屏的卡片里挑一张没试过的。 * * @param visited 已处理过的 caption 列表(用于跨屏去重) * @param tried 已尝试过的**容器矩形**列表 * * ★ 为什么还要按矩形去重(2026-10-28 修): * 同一张卡片的文案在不同时刻会读到**不同的截断**:一次是 `swagger #fashion …`, * 另一次是 `… #fashion #outfitinspo #fyp`(前面被省略号截掉)。按 caption 去重认不出 * 是同一张,于是"点开→不是视频→跳过→又点到它"循环 11 次才被 guard 拦住。 * 容器矩形在没滚动时是稳定的,用它兜住这一层。 * * 也去掉了"全都试过就退回全量再挑一遍"的兜底 —— 那个兜底正是死循环的入口。 * 全试过时返回 null,由调用方决定是滚动还是收工。 */ function chooseCard(config, cards, visited, tried) { var fresh = []; for (var i = 0; i < cards.length; i += 1) { if (visited.indexOf(cards[i].caption) !== -1) { continue; } if (cardAlreadyTried(cards[i], tried)) { continue; } fresh.push(cards[i]); } if (fresh.length === 0) { return null; } if (config.target.pickStrategy === 'first') { return fresh[0]; } return fresh[Math.floor(Math.random() * fresh.length)]; } /** 容器矩形是否已在"试过"名单里(容差 40px,容忍布局微抖) */ function cardAlreadyTried(card, tried) { if (!tried || tried.length === 0) { return false; } var box = null; try { box = card.container.bounds(); } catch (ignored) { return false; } if (!box) { return false; } for (var i = 0; i < tried.length; i += 1) { var one = tried[i]; if (Math.abs(one.left - box.left) <= 40 && Math.abs(one.top - box.top) <= 40 && Math.abs(one.right - box.right) <= 40) { return true; } } return false; } /** 把一张卡的容器矩形记进"试过"名单 */ function rememberCard(card, tried) { try { var box = card.container.bounds(); if (box) { tried.push({ left: box.left, top: box.top, right: box.right }); } } catch (ignored) { /* 记不上就记不上,caption 那一层去重仍在 */ } } 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; var unknownStreak = 0; while (Date.now() < deadline && backs < 6) { var state = tiktok.pageState(); if (state === 'results') { return true; } if (state === 'unknown') { /* ★ 连续 unknown 也要按返回(2026-10-28 修)。 * * 原来是"unknown 就只等 500ms、绝不按返回",于是卡在 * **照片轮播帖 / 商品页**这类 pageState 认不出的页面时, * 15 秒预算会被 500ms 的等待耗完,一次返回都没按过, * 最后报「未能回到结果页,跳过后续取片」——整个循环就此中断。 * * 页面刚切换的头 1.5 秒确实是 unknown(还在渲染),所以先让两次; * 之后就必须动。 */ unknownStreak += 1; if (unknownStreak < 3) { TikTokAuto.wait.tick(500); continue; } } else { unknownStreak = 0; } 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, /* 四个动作各自的成功计数:只有"真的做了"才加,跳过不算 */ likedVideos: 0, followedAuthors: 0, /* 用户是否勾了这两个动作 —— 用来决定结果文案里要不要提它们 * (勾了但全是"已赞"时显示 0 是有意义的信息;没勾就干脆不提) */ likesRequested: config.likes && config.likes.enabled === true, followsRequested: config.follows && config.follows.enabled === true, details: [], /* 不算失败但值得说的事(例:搜索结果里混着商品卡,点开不是视频就跳过)。 * 必须在这里初始化 —— 后面直接 push,不初始化就是运行期 TypeError, * 而 AutoX 上这种异常不弹窗、不报错,只表现成"流程莫名停住"。 */ warnings: [], 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 tried = []; 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) { /* ★ 先"等"再"滚"(2026-10-28 修)。 * * 读到 0 张卡有两种可能:① 真的没有更多了 ② 列表正在重排/重刷 * (切页签、刚翻过一页、图片加载中)。直接滚会把 ② 当成 ① 而收工。 * 这里只给 5 秒 —— 真的到底时不该白等满 20 秒。 */ cards = waitForCards(config, 5000) || []; } if (cards.length === 0) { scrollResults(config); cards = waitForCards(config) || []; if (cards.length === 0) { /* ★ 卡片用尽不是"错误"(2026-10-28 改)。 * * 搜索结果少的时候(例:关键词 cat 只有 3~4 张视频卡, * 中间还夹着 1 张商品卡)会正常用完。以前这里 push 一条 errors, * 于是"处理了 3 个视频、目标 4 个"看起来像出了故障。 * * 真正该报错的是**一个都没收到** —— 那说明选词/解析真有问题。 */ if (summary.attemptedVideos === 0) { summary.errors.push('结果页没有可用的视频卡片'); } else { summary.warnings.push('结果页卡片已用尽:本轮处理 ' + summary.attemptedVideos + ' 个(目标 ' + total + ' 个),换个关键词或调小目标数量'); } break; } } var card = chooseCard(config, cards, visited, tried); /* 当前屏的卡片全试过了(含"试过但打不开"的)→ 滚一屏再看。 * 绝不能像旧版那样"全都试过就退回全量再挑" —— 那是死循环的入口: * 同一张照片帖会被反复点开、反复跳过,直到 guard 用完。 */ if (!card) { scrollResults(config); cards = waitForCards(config) || []; card = chooseCard(config, cards, visited, tried); if (!card) { if (summary.attemptedVideos === 0) { summary.errors.push('结果页没有可用的视频卡片'); } else { summary.warnings.push('结果页卡片已用尽:本轮处理 ' + summary.attemptedVideos + ' 个(目标 ' + total + ' 个),换个关键词或调小目标数量'); } break; } } visited.push(card.caption); rememberCard(card, tried); if (!openCard(config, card)) { /* ★ 打不开 ≠ 出错(2026-10-28 实测)。 * * 搜索结果里混着两种"长得像视频卡"的东西: * · 广告**视频** —— 点进去是正常播放页(有 Ad 标记 + Shop now),能正常看 * · 照片轮播帖 / 商品卡 —— 根本没有播放页 * (照片帖约 2 秒就能判出来,见 player.waitPlayerReady 的 :kind 预判) * * 以前这里 push 一条 errors,于是"4 个视频里有 1 张照片帖"会让整轮 * 记录看起来像出了故障。现在改成:记一条 warning、跳过、继续下一张。 */ summary.warnings.push('跳过非视频卡片:' + String(card.caption).slice(0, 30)); logger().info('flow', '点开后没有播放页,当作非视频卡片跳过:' + 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.likes && config.likes.enabled) { detail.like = TikTokAuto.interact.like('like'); if (detail.like.ok && !detail.like.skipped) { summary.likedVideos += 1; } } else { detail.like = { ok: true, skipped: true, reason: '本次未启用点赞动作' }; } if (config.follows && config.follows.enabled) { detail.follow = TikTokAuto.interact.follow('follow'); if (detail.follow.ok && !detail.follow.skipped) { summary.followedAuthors += 1; } } else { detail.follow = { ok: true, skipped: true, reason: '本次未启用关注动作' }; } /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式 * (界面上的「执行操作」没勾评论就是这个意思)。 */ 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/nurture.js ============================== */ /** * nurture.js — 养号:在首页信息流里随机浏览 + 按概率点赞 / 关注 / 评论 * * 与视频链路(flow.js)的区别只有一句话:**没有目标**。 * 视频链路是"搜关键词 → 点进指定数量的视频",养号是"打开信息流 → 一个接一个往下刷"。 * 所以这里复用了 flow 的应用级动作(`launchApp` / `ensureFeed`)与 * player / interact / comment 三个能力模块,自己只负责**编排节奏**。 * * ## 双约束:轮数与时长,谁先到谁停 * * 界面给的是「单次操作 10~30」(轮数)与「浏览时长 3~8 分钟」。两者**各自独立**, * 不是"用一项算出另一项": * · 轮数先满 —— 真人式收工,正常结束 * · 时长先到 —— 可能刚好卡在某个视频中间,如实说明"做了 N 轮,没用满轮数" * 所以结果里记 `stoppedBy`,不假装两个都达标。 * * ## 为什么每轮都要重新确认"还在信息流" * * 实测信息流里会滑到这些东西:**照片轮播帖**(没有播放)、**带货卡** * (`Creator earns commission`)、以及可能的**直播**。前两种滑走就好, * 直播会让 `pageState()` 变成别的值、上滑也未必切得动。 * 每轮开工前确认一次,不对就交回 `ensureFeed` 拉回(它自己会按返回,多次失败会重启应用)。 * * 依赖:TikTokAuto.flow / player / interact / comment / tiktok / wait / bridge */ TikTokAuto.nurture = (function () { 'use strict'; /** 连续这么多次上滑都换不了视频,就认为卡死了 */ var SWIPE_FAIL_LIMIT = 3; function logger() { return TikTokAuto.logger; } /** 概率命中。rate 是 0~1;0 或非法值一律不命中(宁可不做,不可乱做) */ function hit(rate) { var value = Number(rate); if (!(value > 0)) { return false; } return Math.random() < value; } function progress(phase, done, total) { TikTokAuto.bridge.report({ status: 'running', phase: phase, done: done, total: total }); } /** * 上滑换下一个视频。 * * 判据是"**新内容真的出现了**",不是"滑动手势发出去了"。 * 手势会被吞:网络慢、正在加载、恰好卡在上一帧,都会出现"滑了但没动"。 */ function nextVideo() { var tiktok = TikTokAuto.tiktok; var view = TikTokAuto.layout.screen(); var before = tiktok.feedSignature(); TikTokAuto.ui.dragScreen( Math.floor(view.width * 0.5), Math.floor(view.height * 0.78), Math.floor(view.width * 0.5), Math.floor(view.height * 0.28), 280 ); var changed = TikTokAuto.wait.waitFor('nextVideo', function () { var now = tiktok.feedSignature(); if (now === '' || now === before) { return null; } return true; }, 6000, 250); return changed === true; } /** 做完一个动作后随机停顿一下(真人不会点完立刻滑) */ function pause(config) { var lo = config.nurture.pause.minMs; var hi = config.nurture.pause.maxMs; TikTokAuto.wait.tick(TikTokAuto.player.randomInt(lo, hi)); } /** * 一轮:看一个视频 + 按概率互动。 * * @returns detail(结构与 video 链路的 details[] 同族,便于记录页复用) */ function oneRound(config, index, usedComments, summary) { var tiktok = TikTokAuto.tiktok; var settings = config.nurture; var detail = { index: index + 1 }; /* 照片帖没有播放页,也没有"播放中"这回事 —— 拿 watch 去等 * `playbackStartMs`(12 秒)纯属白等。短停留即可,点赞/关注照样能做。 */ var photo = tiktok.photoPost() !== null; detail.photoPost = photo; if (photo) { var dwell = TikTokAuto.player.randomInt(1500, 4000); TikTokAuto.wait.tick(dwell); detail.watch = { completed: true, actualMs: dwell, probeMode: 'photo-post' }; summary.photoPosts += 1; } else { detail.watch = TikTokAuto.player.watch(config, { step: 'watch', /* 信息流没有返回箭头、有底部导航 —— `onPlayerPage()` 在这里恒为 false, * 不注入这个守卫的话 watch 会在 3 秒内以 left-player-page 自杀。 */ guard: function () { return tiktok.pageState() === 'feed'; }, minMs: settings.watch.minMs, maxMs: settings.watch.maxMs }); } summary.watchedMs += detail.watch.actualMs || 0; /* 动作顺序:点赞 → 关注 → **评论放最后**。 * 评论会打开面板盖住右侧动作栏,之后再点赞就得多一步收面板。 */ if (hit(settings.likeRate)) { summary.likeHits += 1; detail.like = TikTokAuto.interact.like('like'); if (detail.like.ok && !detail.like.skipped) { summary.liked += 1; } pause(config); } if (hit(settings.followRate)) { summary.followHits += 1; detail.follow = TikTokAuto.interact.follow('follow'); if (detail.follow.ok && !detail.follow.skipped) { summary.followed += 1; } pause(config); } if (config.comments.enabled && hit(settings.commentRate)) { summary.commentHits += 1; 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.commented += 1; } else { /* 失败了**必须把面板收掉**:面板盖在信息流上时, * 后面的上滑会变成"在评论列表里滚动",整个流程就歪了。 */ TikTokAuto.comment.closePanel('comment:cleanup'); if (!detail.comment.ok) { summary.warnings.push('评论没发出去:' + (detail.comment.reason || detail.comment.via || '未知')); } } pause(config); } return detail; } /** * @param config 生效配置(含 `config.nurture` 与顶层 `config.comments`) * @returns summary */ function runSession(config) { var log = logger(); var settings = config.nurture; var summary = { ok: false, task: 'nurture', /* 实际做完的轮数 / 本轮随机出来的目标轮数 */ rounds: 0, targetRounds: 0, /** 真正在看视频上花掉的毫秒数(不含动作与停顿) */ watchedMs: 0, /* 真的做成功的个数:跳过(已赞/已关注)不计入 */ liked: 0, followed: 0, commented: 0, /* 概率命中的次数:用来区分"没打算做"和"想做但失败了" */ likeHits: 0, followHits: 0, commentHits: 0, photoPosts: 0, /** 'rounds' | 'time' | 'aborted' —— 是哪个约束先到 */ stoppedBy: '', details: [], /* 不算失败但值得说的事。必须在这里初始化:后面直接 push, * 不初始化就是运行期 TypeError,而 AutoX 上这种异常不弹窗、只表现成"流程停住"。 */ warnings: [], errors: [] }; progress('启动应用', 0, 0); if (!TikTokAuto.flow.launchApp(config)) { summary.errors.push('启动应用失败'); return summary; } progress('进入信息流', 0, 0); if (!TikTokAuto.flow.ensureFeed(config)) { summary.errors.push('未能进入信息流首页'); return summary; } var targetRounds = TikTokAuto.player.randomInt(settings.rounds.min, settings.rounds.max); summary.targetRounds = targetRounds; var deadline = Date.now() + settings.browse.maxMs; var beganAt = Date.now(); log.step('nurture', '养号开始:目标 ' + targetRounds + ' 轮 / 最长 ' + Math.round(settings.browse.maxMs / 60000) + ' 分钟;概率 点赞 ' + Math.round(settings.likeRate * 100) + '% / 关注 ' + Math.round(settings.followRate * 100) + '% / 评论 ' + Math.round(settings.commentRate * 100) + '%'); var usedComments = []; var swipeFailures = 0; while (summary.rounds < targetRounds && Date.now() < deadline) { var state = TikTokAuto.tiktok.pageState(); if (state !== 'feed') { /* 滑进了直播 / 广告落地页 / 评论面板,或者页面还在加载。 * 交回 ensureFeed 拉回信息流 —— 别在这里自己写一套"返回几次"的逻辑。 */ log.warn('nurture', '当前不在信息流(' + state + '),尝试回到信息流'); if (!TikTokAuto.flow.ensureFeed(config)) { summary.warnings.push('中途离开信息流且回不去,提前结束(做了 ' + summary.rounds + ' 轮)'); summary.stoppedBy = 'aborted'; break; } } progress('第 ' + (summary.rounds + 1) + ' / ' + targetRounds + ' 轮', summary.rounds, targetRounds); summary.details.push(oneRound(config, summary.rounds, usedComments, summary)); summary.rounds += 1; if (!nextVideo()) { swipeFailures += 1; log.warn('nurture', '上滑后内容没变(连续第 ' + swipeFailures + ' 次)'); if (swipeFailures >= SWIPE_FAIL_LIMIT) { summary.warnings.push('连续 ' + swipeFailures + ' 次上滑都换不了视频,提前结束(做了 ' + summary.rounds + ' 轮)'); summary.stoppedBy = 'aborted'; break; } } else { swipeFailures = 0; } } /* 如实说明是哪个约束先到 —— 不要笼统说"完成"。 * 轮数先满 vs 时长先到,对用户下一步该调哪个参数完全是指向不同。 */ if (!summary.stoppedBy) { var byTime = Date.now() >= deadline; var byRounds = summary.rounds >= targetRounds; summary.stoppedBy = byTime && byRounds ? 'rounds' : (byRounds ? 'rounds' : 'time'); } if (summary.stoppedBy === 'time') { summary.warnings.push('浏览时长到了(' + Math.round(settings.browse.maxMs / 60000) + ' 分钟),只做了 ' + summary.rounds + '/' + targetRounds + ' 轮 —— 想多做几轮请把时长上限调大'); } summary.ok = summary.rounds > 0; if (!summary.ok && summary.errors.length === 0) { summary.errors.push('一轮都没能完成(信息流没有加载出来?)'); } log.step('nurture', '养号结束:' + summary.rounds + ' 轮,用时 ' + Math.round((Date.now() - beganAt) / 1000) + ' 秒,真正浏览 ' + Math.round(summary.watchedMs / 1000) + ' 秒;点赞 ' + summary.liked + ' / 关注 ' + summary.followed + ' / 评论 ' + summary.commented + '(' + summary.stoppedBy + ' 先到)'); return summary; } return { runSession: runSession, nextVideo: nextVideo, hit: hit, SWIPE_FAIL_LIMIT: SWIPE_FAIL_LIMIT }; }()); /* ============================== src/parse.js ============================== */ /** * parse.js — 文本 → 数值 / 语言 / 区间的**纯函数**解析 * * ## 为什么单独一个模块 * 这三件事都是「界面上的设置真的会拦人」这一主张的证据链: * · 数量门槛要先把 "1.6M" 变成 1600000 才谈得上比较; * · 语言区域要靠昵称/个签里的字符构成来判; * · 区间判定要能表达"不限"和"读不到"这两种不同的"没有值"。 * 它们全部与界面无关,所以能 100% 离线自检 —— 这是整个 ID 提取里 * **唯一一处可以用断言证明"逻辑对了"** 的部分。 * * ## 关于数字缩写(真机实测) * TikTok 的计数一律是缩写形态,实测到的写法: * "594"、"2,491"、"1.6M"、"253.9M"、"245K"、"371.7K" * 中文界面下会是 "1.2万" / "1.5亿"。所以后缀要同时认 K/M/B 与 万/亿。 * * 依赖:无(纯函数,不取任何模块引用) */ TikTokAuto.parse = (function () { 'use strict'; /* ---------------------------------------------------------- 数字 */ /** 后缀倍数。键统一小写;`二`的两种写法(万/萬)都认。 */ var SUFFIX = { '': 1, 'k': 1000, 'm': 1000000, 'b': 1000000000, 'w': 10000, /* 万:中文界面 "1.2万" */ '万': 10000, '萬': 10000, '亿': 100000000, '億': 100000000 }; /** * 把一个计数字符串解析成整数。 * * 返回 **null** 表示"读不到",与 **0** 严格区分: * "0 followers" 是真实的值(一个粉丝都没有),"读不到"是另一回事 —— * 数量门槛的判定要靠这个区别决定是"拦下"还是"放行"。 * * @param raw 原始文本,如 "1.6M followers" / "2,491" / "1.2万" * @returns {number|null} */ function count(raw) { if (raw === null || raw === undefined) { return null; } /* 统一全角数字与全角逗号:某些语言/输入法会给出全角 */ var text = String(raw) .replace(/[0-9]/g, function (ch) { return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0); }) .replace(/[,]/g, ','); /* 抓「数字 + 可选小数 + 可选后缀」。前面的自由文本("1.6M followers")不影响。 * 后缀只认白名单里的字符,避免把 "594 followers" 的 f 当成后缀。 */ var matched = text.match(/(\d[\d,]*(?:\.\d+)?)\s*([kKmMbBwW万萬亿億]?)/); if (!matched) { return null; } var number = Number(String(matched[1]).replace(/,/g, '')); if (isNaN(number)) { return null; } var suffix = String(matched[2] || '').toLowerCase(); var factor = SUFFIX[suffix]; if (factor === undefined) { /* 后缀不在白名单(例如 "594 followers" 的 f 已不会被捕获)→ 按无后缀处理 */ factor = 1; } return Math.round(number * factor); } /* ---------------------------------------------------------- 语言 */ /** CJK 汉字(含扩展 A 与兼容区)与假名 —— 判定"中文内容"用 */ var CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]/; var LATIN = /[A-Za-z\u00c0-\u024f]/; /* 含带音标的拉丁字母(西/法/德用户昵称很常见) */ /** * 估算一段文本的语种。只返回三类,够用且不猜。 * * 判定规则(不是拍脑袋,理由在下面): * · **只统计字母类字符**,忽略数字/标点/emoji —— 昵称里 emoji 占比极高, * 算进分母会把"中文昵称 + 三个 emoji"判成"非中文"。 * · 中文门槛 **30%**:中文用户常混英文单词("小美的Fashion"), * 门槛设成 50% 会把这类人漏掉。 * · 英文门槛 **70%**:反过来,英文用户极少混中文, * 所以"仅英文"要更严格 —— 否则一个 "Fashion 小美" 会同时进两个桶。 * * @returns {string} "zh" | "en" | "mixed" | "none" */ function language(raw) { var text = String(raw === null || raw === undefined ? '' : raw); if (text === '') { return 'none'; } var cjk = 0; var latin = 0; for (var i = 0; i < text.length; i += 1) { var ch = text.charAt(i); if (CJK.test(ch)) { cjk += 1; } else if (LATIN.test(ch)) { latin += 1; } } var total = cjk + latin; if (total === 0) { return 'none'; /* 纯 emoji / 纯数字 / 阿拉伯文等 */ } var zhRatio = cjk / total; var enRatio = latin / total; if (zhRatio >= 0.3) { return 'zh'; } if (enRatio >= 0.7) { return 'en'; } return 'mixed'; } /** * 语言是否匹配"仅中文 / 仅英文 / 通杀"。 * * `mixed` 在两边都不算匹配 —— 说不清是哪一边的,就不该被任何一边选中。 * 但 `none`(纯 emoji 昵称)在"通杀"下通过:通杀本来就不筛。 */ function languageMatches(want, actual) { if (!want || want === 'any') { return true; } if (want === 'zh') { return actual === 'zh'; } if (want === 'en') { return actual === 'en'; } return true; } /* ---------------------------------------------------------- 区间 */ /** * 区间判定。 * * 三种状态必须分开表达,这是这个函数存在的理由: * · **不限**(min/max 都是 0)→ 永远通过,连值都不用读; * · **有值** → 落在 [min, max] 内(0 表示该侧不设限); * · **读不到**(value === null)→ **不通过**。 * * 为什么读不到要拦:用户明确设了门槛,说明他在意这个条件; * 读不到就无法证明符合,宁可少收并在日志里说清楚 —— * 若这里放行,"过滤"就是形同虚设,而用户以为它生效了。 * * @returns {boolean} */ function inRange(value, min, max) { var low = Number(min) || 0; var high = Number(max) || 0; if (low <= 0 && high <= 0) { return true; } if (value === null || value === undefined) { return false; } var number = Number(value); if (isNaN(number)) { return false; } if (low > 0 && number < low) { return false; } if (high > 0 && number > high) { return false; } return true; } /** 该区间是否"什么都没设"(用来判断要不要为此打开用户页) */ function isRangeOpen(min, max) { return (Number(min) || 0) <= 0 && (Number(max) || 0) <= 0; } /* ---------------------------------------------------------- 用户名 */ /** * 把 "@username" 归一成 "username"。 * * 实测发现用户名文本被 **FID 隔离符** 包裹(U+2068 / U+2069): * 显示为 `⁨ajman_fashion_week⁩`,肉眼完全看不出,但直接当文件名/比对键就会出错。 * `layout.sanitize` 处理了 U+200b-200f 等,**没有**处理 2068/2069(它们不是零宽字符, * 是双向文本隔离符),所以这里单独剥一次。 * * 同时剥掉全角 @ 与首尾空白。 */ function username(raw) { if (raw === null || raw === undefined) { return ''; } return String(raw) .replace(/[\u2066-\u2069\u202a-\u202e]/g, '') .replace(/\s+/g, '') .replace(/^[@@]+/, ''); } /** * 一个字符串是否像 TikTok 用户名(字母数字下划线点,长度 2~30)。 * * 注意**不能**先走 username() —— 那个函数会去掉所有空白(清洗用途), * 于是 "This is a caption" 会被压成 "Thisisacaption" 而"看起来像用户名"。 * 判定必须**保留原始空白**:真正的用户名里不会有空格。 */ function looksLikeUsername(raw) { if (raw === null || raw === undefined) { return false; } var name = String(raw) .replace(/[\u2066-\u2069\u202a-\u202e]/g, '') .replace(/^[\s@@]+|[\s]+$/g, ''); if (name.length < 2 || name.length > 30) { return false; } if (/\s/.test(name)) { return false; /* 中间有空白 → 是文案,不是用户名 */ } return /^[A-Za-z0-9_.]+$/.test(name); } /* ---------------------------------------------------------- 文件名 */ /** 两位补零 */ function pad2(value) { return value < 10 ? '0' + value : String(value); } /** * 结果文件名:`前缀_YYYYMMDD_HHmm.txt` * * 方案 §6.1 是对规格书的有意识偏离:规格书只有日期(`uid_20261013.txt`), * 同一天跑两次会互相覆盖 —— 而"同一天调两次参数"恰恰是最常见的用法。 */ function timestampName(prefix, date) { var d = date || new Date(); return prefix + '_' + d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) + '_' + pad2(d.getHours()) + pad2(d.getMinutes()) + '.txt'; } return { count: count, language: language, languageMatches: languageMatches, inRange: inRange, isRangeOpen: isRangeOpen, username: username, looksLikeUsername: looksLikeUsername, timestampName: timestampName }; }()); /* ============================== src/uidset.js ============================== */ /** * uidset.js — 去重集合与结果文件 * * ## 三种"已经见过" * 1. **本轮已收**:这次运行里已经写进产物文件的; * 2. **历史已收**:之前各轮跑过的(`uid_extracted.txt`,只追加不删除); * 3. **输入里已有**:文本取材那份文件本身就列着的("裂变"时,"输入里的人"不该再算新收获)。 * 三者合成一个集合,`add` 之后就不再重复。区别只在**装载来源**,判定逻辑是同一个。 * * ## 为什么落盘是"追加"而不是"最后一次性写" * 用户点了停止、或脚本被系统杀掉时,已经抓到的部分不能全丢。 * 所以每收 N 个就 append 一次 —— 代价是几条多余的系统调用,收益是"停在哪都留着"。 * * ## 为什么不用 files 的删除接口 * 引擎产物要过 MCP 的危险调用黑名单(`build.js` 的 `DANGEROUS_RULES` 里有一条按 * 词边界匹配 `remove` 加左括号),命中后每次下发都要求人工确认。 * 所以这里**只追加不删除** —— 需要"重来"时由用户自己在文件管理器里删。 * * 依赖:TikTokAuto.logger(调用时取) */ TikTokAuto.uidset = (function () { 'use strict'; /** 每收这么多个就 append 一次。20 是"丢掉的最多几个"与"写盘频率"的折中。 */ var FLUSH_EVERY = 20; /** * 去重集合。 * * 内部用普通对象而不是 Set:Rhino 对 ES6 Set 的支持在各版本不一致, * 而对象字面量的键查找在所有版本上都是稳的。uid 只会是字母数字下划线点,不会撞 `__proto__`。 */ function create() { var seen = {}; var count = 0; return { /** 见过没有 */ has: function (uid) { return seen[uid] === true; }, /** 记一笔;返回 true 表示这是新加的(之前没见过) */ add: function (uid) { if (seen[uid] === true) { return false; } seen[uid] = true; count += 1; return true; }, /** 已记多少(含历史装载的) */ size: function () { return count; } }; } /** * 读一批文本行,返回去重后的 uid 数组。 * 跳过空行、`#` 注释行、以及带 @ 的行(有些用户会直接粘贴 "@name",我们容忍这种输入)。 */ function readUidFile(path) { var out = []; try { if (!path || !files.exists(path)) { return out; } var lines = String(files.read(path)).split(/\r?\n/); for (var i = 0; i < lines.length; i += 1) { var uid = TikTokAuto.parse.username(lines[i]); if (uid !== '' && uid.charAt(0) !== '#') { out.push(uid); } } } catch (error) { TikTokAuto.logger.warn('uidset', '读取 ' + path + ' 失败:' + error); } return out; } /** * 从历史文件装载去重集合。 * 文件不存在 = 第一次跑,不是错误。 */ function loadHistory(set, path) { var list = readUidFile(path); for (var i = 0; i < list.length; i += 1) { set.add(list[i]); } TikTokAuto.logger.info('uidset', '历史去重装载 ' + list.length + ' 条(' + path + ')'); return list.length; } /** * 结果写入器。 * * 两个文件同时写: * · `本次`:这一轮新收的(新建,给人看/给下游吃); * · `累计`:所有轮次的(追加,跨轮去重的基准)。 * * @param options { runPath, historyPath, dryRun, historyEnabled } */ function createWriter(options) { var pending = []; var collected = []; var written = 0; var flushFailed = false; function flush() { if (pending.length === 0) { return; } var block = pending.join('\n') + '\n'; pending = []; if (options.dryRun) { return; /* 演练模式:不碰任何文件 */ } try { files.append(options.runPath, block); if (options.historyEnabled) { files.append(options.historyPath, block); } } catch (error) { /* 写盘失败要留痕但不能中断 —— 用户更需要"继续抓"而不是"因为一个文件写不进去全停"。 * 只报一次,避免几百条一模一样的警告把日志刷满。 */ if (!flushFailed) { flushFailed = true; TikTokAuto.logger.error('uidset', '写结果文件失败:' + error); } } } return { /** 收一个 uid。调用方保证已经过去重。 */ write: function (uid) { pending.push(uid); /* 同时留一份在内存里:收尾阶段要拿它去换数字 uid(见 resolve.js)。 * 从文件读一遍也行,但那样得处理"文件还没 flush 完"的时序问题, * 而这里的量级(几十到几千)远没到需要省内存的地步。 */ collected.push(uid); written += 1; if (pending.length >= FLUSH_EVERY) { flush(); } }, /** 本轮收到的全部 uid(按收取顺序) */ list: function () { return collected.slice(0); }, /** 已写多少个 */ count: function () { return written; }, /** 收尾:把剩下的刷出去。**收到停止信号时也必须调它**,否则"停了但文件是空的"。 */ close: function () { flush(); return written; }, /** 是否发生过写盘失败(给 summary 用) */ hadFailure: function () { return flushFailed; } }; } /** 确保目录存在(结果文件与累计文件同目录) */ function ensureDir(dir) { try { files.ensureDir(dir + '/'); return true; } catch (error) { TikTokAuto.logger.error('uidset', '创建目录失败 ' + dir + ':' + error); return false; } } return { FLUSH_EVERY: FLUSH_EVERY, create: create, readUidFile: readUidFile, loadHistory: loadHistory, createWriter: createWriter, ensureDir: ensureDir }; }()); /* ============================== src/fingerprint.js ============================== */ /** * fingerprint.js — 配置指纹(判断"断点还有效吗") * * ## 为什么要它 * 断点续跑的前提是"配置没变过"。取材换了关键词、过滤条件调了门槛, * 上次"已完成"的判定就不再成立 —— 接着跑会得到口径不一致的数据,而用户看不出来。 * * ## 为什么不用 hash 库 * 项目里没有任何现成的 hash / 稳定序列化工具,而 AutoX 环境里能用的 * 也就 Rhino 那点东西(没有 `Math.imul`、没有 ES6 Map/Set 的稳定行为)。 * 所以自己写两个小函数:**稳定序列化 + djb2**。 * * ## 覆盖哪些字段(这是设计决策,不是随手挑的) * * | 覆盖 | 为什么 | * |---|---| * | `sources[]` 的全部取值字段(**顺序敏感**) | 换账号/关键词/深度/删条目都改变"取哪些人" | * | `filter` 的 private/noAvatar/langRegion/nicknameCheck/follow/fans/likes | 直接决定谁被拦掉 | * | `output` 的 dir/historyPath/dedupeHistory | dir 决定产物位置;后两个决定 `seen` 的初始内容 | * * | 不覆盖 | 为什么 | * |---|---| * | `output.limit` | 约束"跑多少个"而不是"取哪些人";续跑仍按累计数生效 | * | `output.resolveUid` | 只影响收尾是否多产一份映射文件 | * | `output.dryRun` | 演练不写产物,**调用方直接禁续跑**,不必进指纹 | * | `filter.videos` | `filters.judge` 只对它出 soft 提示,不参与判定 | * | `guard.*` / `timeouts` / `probe` / `runtime` | 与"取哪些人"无关 | * * ## ★ 这个文件在界面侧有一份逐字节相同的实现 * `tiktok-app/src/fingerprint.js`。**改这里必须同步改那边**, * 否则两侧算出的指纹不同 → 界面说"可以续跑"、引擎说"配置变了"。 * `tools/pc-selftest-id.js` 里有一组断言专门比对两份实现的结果。 */ TikTokAuto.fingerprint = (function () { 'use strict'; var VERSION = 1; /** * 是不是数组。 * * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时 * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。 * * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号 * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。 * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。 */ function isArray(v) { if (typeof Array.isArray === 'function') { return Array.isArray(v); } return Object.prototype.toString.apply(v, []) === '[object Array]'; } /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */ function ownKeys(v) { var keys = []; for (var k in v) { if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) { keys.push(k); } } keys.sort(); return keys; } /** * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。 * * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串, * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。 */ function canon(v) { if (v === null || v === undefined) { return 'n'; } if (isArray(v)) { var parts = []; for (var i = 0; i < v.length; i += 1) { parts.push(canon(v[i])); } return '[' + parts.join(',') + ']'; } if (typeof v === 'object') { var keys = ownKeys(v); var out = []; for (var j = 0; j < keys.length; j += 1) { out.push(keys[j] + ':' + canon(v[keys[j]])); } return '{' + out.join(',') + '}'; } if (typeof v === 'number') { return 'd' + String(v); } if (typeof v === 'boolean') { return v ? 'T' : 'F'; } return 's' + JSON.stringify(String(v)); } /** * djb2-xor 摘要。 * * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分, * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。 */ function digest(text) { var s = String(text); var h = 5381; for (var i = 0; i < s.length; i += 1) { h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0; /* |0 截成 32 位,避免精度丢失 */ } return (h >>> 0).toString(36) + '-' + s.length.toString(36); } /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */ function ofSource(one, index) { var s = one || {}; return { key: s.key || ('i' + index + ':' + (s.kind || '')), kind: s.kind || '', anchors: s.anchors || '', lists: isArray(s.lists) ? s.lists : [], searchType: s.searchType || '', keyword: s.keyword || '', videoSource: s.videoSource || '', videoKeyword: s.videoKeyword || '', path: s.path || '', expand: s.expand || '' }; } /** * 算 `id` 段的指纹。 * * **分项存**(sources / filter / output 各一份)而不只存一个总串: * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。 * * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`, * 引擎传解析后的 `config.id` —— 两者是同一份数据) */ function ofIdSection(id) { var section = id || {}; var list = isArray(section.sources) ? section.sources : []; var sources = []; for (var i = 0; i < list.length; i += 1) { sources.push(ofSource(list[i], i)); } var f = section.filter || {}; var o = section.output || {}; var sourcesHash = digest(canon(sources)); var filterHash = digest(canon({ private: f.private === true, noAvatar: f.noAvatar === true, langRegion: f.langRegion || 'any', nicknameCheck: f.nicknameCheck || 'any', follow: f.follow || {}, fans: f.fans || {}, likes: f.likes || {} })); var outputHash = digest(canon({ dir: o.dir || '', historyPath: o.historyPath || '', dedupeHistory: o.dedupeHistory !== false })); return { v: VERSION, sources: sourcesHash, filter: filterHash, output: outputHash, all: digest(sourcesHash + filterHash + outputHash) }; } /** * 比两份指纹。 * @returns { same, changed: ['sources'|'filter'|'output'] } */ function compare(a, b) { if (!a || !b || a.v !== VERSION || b.v !== VERSION) { return { same: false, changed: ['sources', 'filter', 'output'] }; } var changed = []; if (a.sources !== b.sources) { changed.push('sources'); } if (a.filter !== b.filter) { changed.push('filter'); } if (a.output !== b.output) { changed.push('output'); } return { same: changed.length === 0, changed: changed }; } return { VERSION: VERSION, canon: canon, digest: digest, ofIdSection: ofIdSection, compare: compare }; }()); /* ============================== src/checkpoint.js ============================== */ /** * checkpoint.js — ID 提取的断点("跑到哪了"的唯一真相) * * ## 粒度:取材条目级,不是屏/行级 * * TikTok 的列表内容会变、滚动位置无法复原。记"第几屏第几行"只会造成 * 漏掉别人或重复劳动;而「哪条取材已经走完」是稳定事实。 * 所以:**跳过已完成的取材;未完成的那条从头重走,靠已收集合跳人**。 * * ## 文件 * `/id_progress.json`,与产物同目录(用户清产物时看得见)。 * 全局只保留一份(全局单跑,多份会让"哪份有效"变成判断题)。 * * ## 几条硬约束(都踩过或差点踩) * * 1. **只能覆盖写,不能删文件** —— `files.remove` 命中 MCP 危险调用黑名单 * (见 `tools/build.js` 的 `DANGEROUS_RULES` 与 `uidset.js` 文件头)。 * 「清空断点」= 覆盖写成 `state:"none"`。 * 2. **read 必须容错** —— 写到一半被杀会留下半截 JSON。读不懂就当"没有断点", * 绝不抛异常:产物还在,丢的只是"能不能接着跑"。 * 3. **不存已收 uid 列表** —— 结果文件本身就是那份清单,重复存只会出现两份不一致。 * 4. **写盘不追每一条 uid** —— 粒度是取材条目级,每收 1 个就写只换来"少重走 19 个人"。 * 节奏是:取材边界 + 每 20 个(与产物刷盘同拍)+ 收尾。 * * 依赖:TikTokAuto.logger、TikTokAuto.fingerprint(仅 compare 用) */ TikTokAuto.checkpoint = (function () { 'use strict'; /** 文件名固定:不放用户配置里,避免"换个名字就找不到断点" */ var DEFAULT_NAME = 'id_progress.json'; var VERSION = 1; function logger() { return TikTokAuto.logger; } /** * 是不是数组。优先 `Array.isArray`;退回用 `.apply` 而不是 `Function.prototype.call` * —— 后者那个关键字加左括号会命中 MCP 危险调用黑名单(见 `tools/build.js`)。 */ function isArray(v) { if (typeof Array.isArray === 'function') { return Array.isArray(v); } return Object.prototype.toString.apply(v, []) === '[object Array]'; } function dirOf(path) { var s = String(path || ''); var i = s.lastIndexOf('/'); return i > 0 ? s.substring(0, i + 1) : ''; } /** 断点文件路径(由结果目录派生) */ function pathOf(output) { var dir = (output && output.dir) || '/sdcard/Download/tiktok_app'; return dir + '/' + DEFAULT_NAME; } /** * 新建一份断点。 * * @param runId 当前这次运行(界面靠它把记录与断点精确对上) * @param sig 指纹(`fingerprint.ofIdSection(config.id)`) * @param runFile 结果文件绝对路径(续跑时复用它,不新开文件) * @param sources `[{key, kind, label}]`,顺序与 config.id.sources 一致 */ function empty(runId, sig, runFile, sources) { var list = []; for (var i = 0; i < (sources || []).length; i += 1) { var s = sources[i] || {}; list.push({ key: s.key || ('i' + i + ':' + (s.kind || '')), kind: s.kind || '', label: s.label || '', status: 'pending', /* pending | running | done */ screens: 0, handled: 0, added: 0 }); } var now = Date.now(); return { v: VERSION, state: 'running', /* running | stopped | done | none */ runId: runId || '', beganAt: now, updatedAt: now, sig: sig || null, runFile: runFile || '', mapFile: '', limitBase: 0, counts: { extracted: 0, skipped: 0, duplicates: 0, openedProfiles: 0 }, sources: list, expand: { queue: [], done: [] } }; } /** 读断点。**任何解析问题都返回 null**(当作没有断点),绝不抛 */ function read(path) { try { if (!path || !files.exists(path)) { return null; } var text = String(files.read(path) || ''); if (text.replace(/\s/g, '') === '') { return null; } var cp = JSON.parse(text); if (!cp || cp.v !== VERSION) { return null; } /* 半截文件可能缺字段:补齐,别让调用方到处判空 */ if (!isArray(cp.sources)) { cp.sources = []; } if (!cp.expand || typeof cp.expand !== 'object') { cp.expand = { queue: [], done: [] }; } if (!isArray(cp.expand.queue)) { cp.expand.queue = []; } if (!isArray(cp.expand.done)) { cp.expand.done = []; } return cp; } catch (error) { logger().warn('checkpoint', '断点文件读不懂(当作没有断点):' + error); return null; } } /** 覆盖写整份断点。返回是否写成功(失败只记 warn,不影响主流程) */ function save(path, cp) { if (!cp) { return false; } try { var dir = dirOf(path); if (dir) { files.ensureDir(dir); } cp.updatedAt = Date.now(); files.write(path, JSON.stringify(cp)); return true; } catch (error) { logger().warn('checkpoint', '断点写盘失败(不影响已抓到的结果):' + error); return false; } } /** 「清空断点」= 覆盖成空壳。**不删文件**(`files.remove` 是危险调用) */ function clearStub() { return { v: VERSION, state: 'none', updatedAt: Date.now() }; } /** 指纹不符时给用户一句人话,说清是哪一段变了 */ function nameOf(key) { if (key === 'sources') { return '取材'; } if (key === 'filter') { return '过滤条件'; } if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; } return key; } /** * 能不能续跑?—— 顺序很重要,每一步的失败都要给可操作的话。 * * 全部判定都在**启动 App 之前**做完:拒绝时零成本返回,不折腾用户手机。 * * @param prev 上一次读到的断点(或 null) * @param sig 当前配置指纹 * @param runFileExists 上次的结果文件还在不在 * @param opts { dryRun } * @returns { mode:'resume'|'fresh'|'reject', reason, message, changed } */ function planResume(prev, sig, runFileExists, opts) { var options = opts || {}; if (!prev || prev.v !== VERSION || prev.state === 'none') { return { mode: 'fresh', reason: 'no-checkpoint', message: '', changed: [] }; } if (prev.state === 'done') { return { mode: 'fresh', reason: 'last-complete', message: '', changed: [] }; } if (options.dryRun) { return { mode: 'reject', reason: 'dry-run', changed: [], message: '断点失效:演练模式不写产物,无法续跑' }; } var cmp = TikTokAuto.fingerprint.compare(prev.sig, sig); if (!cmp.same) { var names = []; for (var i = 0; i < cmp.changed.length; i += 1) { names.push(nameOf(cmp.changed[i])); } return { mode: 'reject', reason: 'config-changed', changed: cmp.changed, message: '断点失效:' + names.join('、') + '已改动,只能重新开始' }; } if (!runFileExists) { return { mode: 'reject', reason: 'run-file-missing', changed: [], message: '断点失效:上次的结果文件已不存在(' + (prev.runFile || '') + ')' }; } return { mode: 'resume', reason: 'ok', message: '', changed: [] }; } /* ------------------------------------------------------------ 取材条目 */ function findSource(cp, key) { if (!cp || !isArray(cp.sources)) { return null; } for (var i = 0; i < cp.sources.length; i += 1) { if (cp.sources[i].key === key) { return cp.sources[i]; } } return null; } function sourceDone(cp, key) { var one = findSource(cp, key); return !!(one && one.status === 'done'); } /** * 更新一条取材的状态。 * @param extra 可选 { screens, handled, added } */ function markSource(cp, key, status, extra) { var one = findSource(cp, key); if (!one) { return null; } one.status = status; if (extra) { if (extra.screens !== undefined) { one.screens = extra.screens; } if (extra.handled !== undefined) { one.handled = extra.handled; } if (extra.added !== undefined) { one.added = extra.added; } } return one; } /** 已完成 / 共几条(给界面写提示条用) */ function sourceProgress(cp) { var done = 0; var total = cp && isArray(cp.sources) ? cp.sources.length : 0; for (var i = 0; i < total; i += 1) { if (cp.sources[i].status === 'done') { done += 1; } } return { done: done, total: total }; } /** 上次停在哪儿(提示条上那句"停在「xxx」") */ function stoppedAt(cp) { if (!cp || !isArray(cp.sources)) { return ''; } for (var i = 0; i < cp.sources.length; i += 1) { if (cp.sources[i].status !== 'done') { return cp.sources[i].label || ''; } } return ''; } /** * 取一条取材的记录(找不到返回 null)。 * * 续跑时要用它上次翻了多少屏来**放大** `maxScreens` 预算: * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。 */ function sourceOf(cp, key) { return findSource(cp, key); } /** 上次这条取材翻了多少屏(没记录就 0) */ function screensOf(cp, key) { var one = findSource(cp, key); return one ? (one.screens || 0) : 0; } /* ------------------------------------------------------------ 深度展开 */ function expandKey(uid, which) { return String(uid) + '|' + String(which); } function enqueueExpand(cp, uid, which) { if (!cp || !uid) { return false; } var key = expandKey(uid, which); if (cp.expand.done.indexOf(key) >= 0) { return false; /* 上次已经展开过,别重复排 */ } for (var i = 0; i < cp.expand.queue.length; i += 1) { if (expandKey(cp.expand.queue[i].uid, cp.expand.queue[i].which) === key) { return false; } } cp.expand.queue.push({ uid: String(uid), which: String(which) }); return true; } function expandDone(cp, uid, which) { return !!cp && cp.expand && cp.expand.done.indexOf(expandKey(uid, which)) >= 0; } function markExpandDone(cp, uid, which) { if (!cp || !cp.expand) { return; } var key = expandKey(uid, which); if (cp.expand.done.indexOf(key) < 0) { cp.expand.done.push(key); } } /** * 这次还要展开谁。 * * @param queue 本轮内存里攒的待展开项(`[{uid, which}]`,由 handleRow 边收边入队) * @returns 过滤掉"上次已完成"之后的那部分 */ function pendingExpand(cp, queue) { var out = []; for (var i = 0; i < (queue || []).length; i += 1) { var one = queue[i]; if (!expandDone(cp, one.uid, one.which)) { out.push(one); } } return out; } /* ------------------------------------------------------------ 收尾判定 */ /** 所有取材都 done,且展开队列里没有未完成的 → 这一轮真的做完了 */ function allDone(cp) { if (!cp || !isArray(cp.sources) || cp.sources.length === 0) { return false; } for (var i = 0; i < cp.sources.length; i += 1) { if (cp.sources[i].status !== 'done') { return false; } } for (var j = 0; j < cp.expand.queue.length; j += 1) { if (!expandDone(cp, cp.expand.queue[j].uid, cp.expand.queue[j].which)) { return false; } } return true; } /** * 「提取满 N 个就停」—— 续跑时必须**按累计数**算。 * * 续跑的人均语义是"这一轮凑够 N 个",那就变成设了 100 个、跑了 60 个被停、 * 接着再收 100 个 = 一共 160 个。所以基数是"上次已落盘的条数", * 而那个数以**结果文件的行数**为准(文件是真相,不信 JSON 里的计数)。 * * @param cp 断点(读 `limitBase`) * @param limit 用户设的上限(0 = 不限) * @param currentExtracted 本轮新增 */ function limitReached(cp, limit, currentExtracted) { if (!(limit > 0)) { return false; } var base = (cp && cp.limitBase) || 0; return (base + (currentExtracted || 0)) >= limit; } return { DEFAULT_NAME: DEFAULT_NAME, VERSION: VERSION, pathOf: pathOf, empty: empty, read: read, save: save, clearStub: clearStub, planResume: planResume, nameOf: nameOf, sourceDone: sourceDone, markSource: markSource, sourceProgress: sourceProgress, stoppedAt: stoppedAt, sourceOf: sourceOf, screensOf: screensOf, enqueueExpand: enqueueExpand, expandDone: expandDone, markExpandDone: markExpandDone, pendingExpand: pendingExpand, allDone: allDone, limitReached: limitReached }; }()); /* ============================== src/filters.js ============================== */ /** * filters.js — 「只要这样的账号」的判定 * * 这一层是**纯函数**:给它一份采集到的资料 + 一份过滤设置,它只回答"要 / 不要 / 为什么"。 * 不点屏幕、不读写文件、不知道 TikTok 长什么样。所以它能被 100% 离线自检, * 而"界面上的 21 项设置真的会拦人"这件事也就有了证据。 * * ## 一条重要的区分:**数据缺失**不等于**不符合** * 三种"没有值"必须分开处理,否则过滤要么形同虚设,要么乱杀: * * ① 用户没设这个门槛 → 放行(设置里是 0,语义就是"不限") * ② 设了,但平台根本不提供 → **放行 + 记一条 warn**(例如"视频量":真机实测用户页 * 顶部只有 关注/粉丝/获赞 三组数字,没有视频数) * ③ 设了,本应能读到却读不到 → **拦下**(读不到就无法证明符合,宁可少收) * * ②和③看起来像,但责任不同:②是我们做不到,不该惩罚用户抓到的人;③是数据异常, * 放行会让过滤失去意义而用户以为它生效了。 * * 依赖:TikTokAuto.parse、TikTokAuto.logger(调用时取) */ TikTokAuto.filters = (function () { 'use strict'; /** * 这份过滤设置是否需要打开用户页才能判。 * * 用途是**快路径**:所有过滤都关着时(最常见的第一种用法), * 直接从列表页把 uid 抄下来就行 —— 不必为每个人开一次用户页。 * 实测一次用户页打开约 3~5 秒,100 个人就是 5~8 分钟,差一个数量级。 * * 注意 `videos` 不参与这个判断:它读不到(见文件头 ②), * 为此打开用户页也只是白花时间。 */ function needsProfile(filter) { var f = filter || {}; if (f.private === true || f.noAvatar === true) { return true; } if (f.langRegion && f.langRegion !== 'any') { return true; } var groups = ['follow', 'fans', 'likes']; for (var i = 0; i < groups.length; i += 1) { var range = f[groups[i]] || {}; if (!TikTokAuto.parse.isRangeOpen(range.min, range.max)) { return true; } } return false; } /** 取该判定的取样文本:昵称、个签,或两者(昵称检测下拉的三个取值)。 */ function sampleText(profile, mode) { var nick = profile.nickname || ''; var sign = profile.signature || ''; if (mode === 'nick') { return nick; } if (mode === 'sign') { return sign; } /* "通杀":两段合起来判 —— 分开判会让"昵称中文、个签全英文"的人两边都不算 */ return nick + ' ' + sign; } /** * 单条判定的结果。 * `soft` 表示"我们做不到,放行但要告知"(对应文件头的 ②)。 */ function pass() { return { ok: true, reason: '', soft: '' }; } function reject(reason) { return { ok: false, reason: reason, soft: '' }; } function softPass(reason) { return { ok: true, reason: '', soft: reason }; } /** * 判定一个账号要不要。 * * @param profile 采集到的资料;`null` 表示"这次没有打开用户页"(快路径) * —— 此时只有不看资料就能判的项目生效,其余全部放行 * @param filter 过滤设置 * @returns { ok, reason, soft } * reason 是"为什么不要"(人话,会进日志) * soft 是"放行了但你应该知道"(进 summary 的 warnings,只报一次) */ function judge(profile, filter) { var f = filter || {}; var parse = TikTokAuto.parse; if (!profile) { /* 没有资料,两种情况必须分开: * · 这份设置**本来就不需要**资料(快路径)→ 放行; * · 需要资料却拿不到(用户页打不开、读不出)→ **拦下**。 * 第二种如果放行,等于"打不开的人全都通过" —— 过滤看着在跑, * 实际放进来一批根本没检查过的账号,比不过滤更糟(用户以为筛过了)。 */ if (needsProfile(f)) { return reject('打不开用户页,读不到任何资料'); } return pass(); } /* ① 私密账户。列表页读不到,只能打开用户页看提示文案。 */ if (f.private === true && profile.isPrivate === true) { return reject('私密账号'); } /* ② 无头像。真机实测头像节点不可读(无文案、不可点), * 只能靠像素法 —— 见 profile.js 里的说明。读不到时放行并告知。 */ if (f.noAvatar === true) { if (profile.hasAvatar === false) { return reject('无头像'); } if (profile.hasAvatar === null) { return { ok: true, reason: '', soft: '无头像检测:头像读取不可用,本次未生效' }; } } /* ③ 语言区域:按昵称/个签的字符构成判(见 parse.language 的规则说明) */ if (f.langRegion && f.langRegion !== 'any') { var text = sampleText(profile, f.nicknameCheck || 'any'); var lang = parse.language(text); if (lang === 'none') { /* 纯 emoji / 纯数字昵称:没有语言线索。用户明确要筛语言,就不该混进来。 */ return reject('看不出语言(昵称与个签都没有文字)'); } if (!parse.languageMatches(f.langRegion, lang)) { return reject('语言不符:' + lang + ' ≠ ' + f.langRegion); } } /* ④ 三组数量门槛。真机实测用户页顶部是 关注 / 粉丝 / 获赞, * 数字形态是缩写("1.6M"),parse.count 负责解析。 */ var checks = [ { key: 'follow', label: '关注量' }, { key: 'fans', label: '粉丝量' }, { key: 'likes', label: '获赞量' } ]; for (var i = 0; i < checks.length; i += 1) { var one = checks[i]; var range = f[one.key] || {}; if (parse.isRangeOpen(range.min, range.max)) { continue; } if (!parse.inRange(profile[one.key], range.min, range.max)) { var actual = profile[one.key]; return reject(one.label + '不在区间内(' + (actual === null || actual === undefined ? '读不到' : actual) + ')'); } } /* ⑤ 视频量:平台不提供(实测),放行 + 告知。见文件头 ②。 */ var videoRange = f.videos || {}; if (!parse.isRangeOpen(videoRange.min, videoRange.max)) { return softPass('视频量:用户页不显示该数字,本次未参与过滤'); } return pass(); } /** 给人看的一句话:这次到底在筛什么(进日志与界面状态条) */ function describe(filter) { var f = filter || {}; var parts = []; if (f.private === true) { parts.push('排除私密'); } if (f.noAvatar === true) { parts.push('排除无头像'); } if (f.langRegion && f.langRegion !== 'any') { parts.push(f.langRegion === 'zh' ? '仅中文' : '仅英文'); } var labels = { follow: '关注', fans: '粉丝', likes: '获赞' }; var keys = ['follow', 'fans', 'likes']; for (var i = 0; i < keys.length; i += 1) { var range = f[keys[i]] || {}; if (TikTokAuto.parse.isRangeOpen(range.min, range.max)) { continue; } parts.push(labels[keys[i]] + ' ' + (range.min || 0) + '~' + (range.max || '∞')); } return parts.length === 0 ? '不过滤(拿到就收)' : parts.join(','); } return { needsProfile: needsProfile, judge: judge, describe: describe }; }()); /* ============================== src/profile.js ============================== */ /** * profile.js — 在 TikTok 里"走到某个账号的主页并读出来" * * ## 这一层管什么 * 只管**导航**与**读取**:打开搜索、进用户主页、点开粉丝列表、翻一屏、退回去。 * 它不判断"要不要这个人"(那是 filters.js),也不管"从哪来"(idsource.js)。 * * ## 真机实测出来的三条导航事实(2026-10-20 / TikTok 47.1.4) * 1. **用户主页 → 粉丝列表是一张覆盖页**,顶部有三个页签(Following / Followers / Suggested), * 可以直接在页签间切换 —— 所以"先收粉丝再收关注"不必退回主页再进。 * 2. **从列表进用户页再返回,列表位置会保持**(实测首行与坐标都不变)。 * 这条决定了翻页策略:可以"读一屏 → 逐个进 → 退回来接着读下一屏", * 不必先把整屏 uid 存起来再统一处理。 * 3. **列表行里就有 uid**(`txt_desc`),不必为了拿 uid 去开用户页。 * 用户页只在**需要更多资料**(数量、语言、私密、头像)时才开 —— 差一个数量级的时间。 * * ## 头像为什么要截屏 * 头像是 ImageView,**没有 text、没有 desc、不可点**,无障碍树里读不到任何线索。 * 实测对比:默认头像(灰色人形剪影)高饱和像素占比 0.035,真头像 0.472 —— * 分离度足够,所以用像素法。**已知局限:纯黑白照片的头像会被误判成"无头像"**, * 这一点会如实写进 README 与界面说明,不假装没有。 * * 依赖:TikTokAuto.layout / tiktok / ui / wait / logger / parse */ TikTokAuto.profile = (function () { 'use strict'; /** 头像采样网格:5x5。25 个点足够区分剪影与照片,又不会让截图开销变成瓶颈。 */ var AVATAR_GRID = 5; /** 高饱和像素占比超过它就认为"有头像"。实测默认头像 0.035 / 真头像 0.472。 */ var AVATAR_SATURATED_RATIO = 0.12; function logger() { return TikTokAuto.logger; } /** 等页面安静下来(签名连续几次不变),比固定 sleep 可靠 */ function settle(maxMs) { TikTokAuto.wait.waitQuiet(function () { return TikTokAuto.layout.signature(); }, { maxMs: maxMs || 2500, tickMs: 200, stableTimes: 2 }); } // ------------------------------------------------------------ 读取主页 /** * 把当前用户主页读成一份资料。 * * 读不到的字段一律给 **null**(而不是 0 或空串): * "读不到"与"就是 0"在过滤判定里含义完全不同(见 filters.js 文件头)。 * * @returns { uid, nickname, signature, follow, fans, likes, isPrivate, hasAvatar, gridCount } */ function readProfile() { var tiktok = TikTokAuto.tiktok; var parse = TikTokAuto.parse; var userNode = tiktok.profileUsernameNode(); var uid = userNode ? parse.username(TikTokAuto.layout.text(userNode)) : ''; var nicknameNode = tiktok.profileNicknameNode(); var signatureNode = tiktok.profileSignatureNode(); var stats = readStatsStable(); var hint = tiktok.privateHint(); var grid = tiktok.videoGridCount(); return { uid: uid, nickname: nicknameNode ? TikTokAuto.layout.text(nicknameNode) : '', signature: signatureNode ? TikTokAuto.layout.text(signatureNode) : '', follow: stats.follow, fans: stats.fans, likes: stats.likes, /* 私密:读到了提示文案才算私密;读不到就是"看起来是公开的"。 * 反过来判("没读到就是私密")会把所有正常账号都排除掉。 */ isPrivate: hint !== '' && grid < 3, privateHint: hint, /* 头像:null = 判定不可用(截屏没授权 / 找不到头像节点) */ hasAvatar: readAvatarState(), gridCount: grid }; } /** * 读三组数字,直到**连续两次一致**才采信。 * * ★ 这是实测踩出来的:点开用户主页后,页面会先渲染骨架、再把数字填进去, * 中间那一小段时间读到的数字是**占位的 0**。 * 同一个账号(@user3670597276145)两次运行分别读到 78 与 0, * 而 78 才是真的 —— 如果拿 0 去比"粉丝量 ≥ 1000",它就被冤枉地跳过了。 * 页面"安静"不代表数字已经就位,只有"两次读到一样"才是可靠的信号。 */ function readStatsStable() { var tiktok = TikTokAuto.tiktok; TikTokAuto.wait.tick(350); var previous = tiktok.statNumbers(); for (var i = 0; i < 3; i += 1) { TikTokAuto.wait.tick(350); var current = tiktok.statNumbers(); if (current.follow === previous.follow && current.fans === previous.fans && current.likes === previous.likes) { /* 三组全是 0 是**可疑值**:既可能是真新号,也可能是占位还没换掉。 * 多等 1.5 秒复核一次 —— 真新号复核后还是 0,不影响结果; * 占位的话这一刻通常已经填上真值了(实测把 2019 粉丝读成 0 过)。 */ if (current.follow === 0 && current.fans === 0 && current.likes === 0) { TikTokAuto.wait.tick(1500); var recheck = tiktok.statNumbers(); if (recheck.follow !== 0 || recheck.fans !== 0 || recheck.likes !== 0) { logger().info('profile', '三组全 0 复核后拿到真值:' + recheck.follow + '/' + recheck.fans + '/' + recheck.likes); return recheck; } } return current; } previous = current; } logger().warn('profile', '三组数字连续三次都没稳定,采用最后一次的值(可能是中间态)'); return previous; } /** * 截屏授权状态:null = 还没试过,true = 已就绪,false = 不可用。 * * ★ 真机实测(2026-10-20):**`requestScreenCapture(false)` 是静默的** —— * AutoX 的无障碍服务带 `CAPABILITY_CAN_TAKE_SCREENSHOT`,申请当场返回 true, * 不弹 MediaProjection 确认框,1 秒内完成,随后 `captureScreen()` 直接可用。 * 而**不主动申请**时 `captureScreen()` 会抛 `No screen capture permission` —— * 这就是早先"排除无头像一直不生效、每个账号还刷一条错误"的全部原因。 * * 代价与风险:某些设备/ROM 上申请会弹一次系统授权框并阻塞。 * 所以申请失败就锁定"不可用"并**只告知一次**,不让它把整轮拖住或刷屏。 */ var screenCaptureReady = null; function ensureScreenCapture() { if (screenCaptureReady !== null) { return screenCaptureReady; } if (typeof requestScreenCapture !== "function") { screenCaptureReady = false; logger().warn('profile', '这个运行时不支持截屏申请,本次运行的头像判定会跳过'); return false; } try { screenCaptureReady = requestScreenCapture(false) === true; } catch (error) { screenCaptureReady = false; logger().warn('profile', '申请截屏授权失败,本次运行的头像判定会跳过:' + error); return false; } if (screenCaptureReady) { logger().info('profile', '截屏授权已就绪,头像判定可用'); } else { logger().warn('profile', '截屏授权未获批准,本次运行的头像判定会跳过'); } return screenCaptureReady; } /** * 头像是默认剪影吗? * * ★ "判为无头像"必须**再采一次**才采信。 * 实测发现:刚进用户页时(`waitProfile` 只等到 `@username` 出现,约 585ms), * 头像图片往往还在下载,截到的是**灰色占位块** → 高饱和 0.000 → 误判"无头像" * → 一个明明有头像的账号被错误跳过。 * 有色的情况不必二次确认(灰底不会自己变彩色),所以这个额外开销只落在 * "疑似无头像"的那部分账号上,约多花 400ms。 * * @returns true(有头像)/ false(默认剪影)/ null(判不了) */ function readAvatarState() { if (!ensureScreenCapture()) { return null; } var node = avatarNode(); if (!node) { return null; } var first = sampleAvatar(node); if (first === null || first === true) { return first; } /* 第一次判为"无色":等图片加载完再确认一次 */ TikTokAuto.wait.tick(400); var second = sampleAvatar(node); if (second === null) { return first; } if (second !== first) { logger().info('profile', '头像两次采样不一致(' + first + ' → ' + second + '),采用第二次 —— 第一次多半是图片还没加载'); } return second; } /** * 对头像区域采样一次,返回"是否有头像"。 * @returns true / false / null(拿不到节点或截屏失败) */ function sampleAvatar(node) { var box = node.bounds(); var saturated = 0; var total = 0; var image = null; try { image = captureScreen(); } catch (error) { /* 申请过了却还是拿不到(被系统撤销 / ROM 限制):不是"没头像", * 是"这次判不了" —— 必须返回 null,由上层如实告知。 */ screenCaptureReady = false; logger().warn('profile', '截屏不可用,后续账号不再尝试头像判定:' + error); return null; } if (!image) { screenCaptureReady = false; return null; } var stepX = (box.right - box.left) / (AVATAR_GRID + 1); var stepY = (box.bottom - box.top) / (AVATAR_GRID + 1); for (var row = 1; row <= AVATAR_GRID; row += 1) { for (var col = 1; col <= AVATAR_GRID; col += 1) { var x = Math.round(box.left + stepX * col); var y = Math.round(box.top + stepY * row); var pixel = 0; try { pixel = image.pixel(x, y); } catch (error2) { continue; } total += 1; if (isSaturated(pixel)) { saturated += 1; } } } if (total === 0) { return null; } var ratio = saturated / total; logger().info('profile', '头像像素判定:高饱和 ' + saturated + '/' + total + ' = ' + ratio.toFixed(3) + '(阈值 ' + AVATAR_SATURATED_RATIO + ')'); return ratio >= AVATAR_SATURATED_RATIO; } /** ARGB int → 饱和度是否够高。s = (max-min)/max,避开明亮的白色背景。 */ function isSaturated(pixel) { var r = (pixel >> 16) & 0xff; var g = (pixel >> 8) & 0xff; var b = pixel & 0xff; var max = Math.max(r, Math.max(g, b)); var min = Math.min(r, Math.min(g, b)); if (max < 40) { return false; /* 太暗:黑边/阴影,不算色彩 */ } return (max - min) / max >= 0.25; } /** * 用户主页的头像节点。 * 判据:可见 ImageView、尺寸 140~280px、位于页面上部 1/3。 * 实测新版布局在右上角([856,168][1048,360]),老布局可能在左上,所以两侧都认。 */ function avatarNode() { var layout = TikTokAuto.layout; var best = null; var bestArea = 0; var collection = className('android.widget.ImageView').find(); for (var i = 0; i < collection.size(); i += 1) { var node = collection.get(i); if (!layout.isVisible(node)) { continue; } var box = node.bounds(); var width = box.right - box.left; var height = box.bottom - box.top; if (width < 140 || width > 280 || height < 140 || height > 280) { continue; } if (box.top > device.height * 0.33) { continue; } /* 取面积最大的那个:页面上部可能还有别的图标,头像一定是最大的 */ if (width * height > bestArea) { bestArea = width * height; best = node; } } return best; } // ------------------------------------------------------------ 导航 /** 当前在哪个页面(ID 提取这条链路自己的判据) */ function where() { return TikTokAuto.tiktok.idPageState(); } /** * 打开一个账号的主页(走搜索)。 * * 为什么不直接拼 URL 打开:TikTok 的 deeplink 需要签名参数, * 而且"用户在用的这个 TikTok 版本"才是唯一可靠的入口。 * 走搜索虽然慢(约 8 秒),但实测稳定。 * * @returns 资料对象;失败返回 null */ function openBySearch(uid, config) { var step = 'openProfile'; var tiktok = TikTokAuto.tiktok; var log = logger(); var parse = TikTokAuto.parse; var wanted = parse.username(uid); if (wanted === '') { return null; } log.step(step, '搜索并打开 @' + wanted); if (!ensureFeed(config)) { return null; } if (!openSearch(config)) { return null; } if (!submit(wanted, config)) { return null; } /* 切到 Users 页签:不切的话看到的是视频,找不到人 */ var usersTab = tiktok.resultTab('Users'); if (usersTab) { TikTokAuto.ui.clickNode(usersTab, step + ':usersTab'); /* ★ 等**结果行真的出现**,不能只等"页面安静": * 页签条一渲染出来签名就稳定了,而结果列表还要联网加载 1~2 秒。 * 这里如果不等,就会稳定地误报"搜索结果里找不到该账号"(实测踩到)。 */ TikTokAuto.wait.waitFor(step + ':rows', function () { return tiktok.usersResultRows().length > 0 ? true : null; }, config.timeouts.searchResultsMs, config.probe.pollTickMs); } var row = findRowByUid(wanted); if (!row) { log.warn(step, '搜索结果里找不到 @' + wanted + '(当前可见 ' + tiktok.usersResultRows().length + ' 行)'); return null; } tapRow(row, step + ':row'); var arrived = waitProfile(step); if (!arrived) { /* 实测:`node.click()` 打在整行容器上会落在**中心**, * 而搜索用户行的中心那块是空白(有时还是 Follow 按钮的邻域)—— * 点了返回 true 却没进主页。左侧 30% 处(用户名文字上)才稳定。 * 所以这里再补一次"按坐标点",只重试一次,不做无限重试。 */ log.info(step, '第一次点击没进主页,改用行内坐标重试'); tapRow(row, step + ':row-retry'); arrived = waitProfile(step + ':retry'); } if (!arrived) { log.warn(step, '点了搜索结果但没有进入用户主页(当前 ' + where() + ')'); return null; } /* 主页渲染完成后,数字区可能还差一帧。读之前再确认一次"三组数字里至少有一个读到了", * 否则会把"还没渲染"当成"这个账号没有数据"。最多再等 3 秒。 */ TikTokAuto.wait.waitFor(step + ':stats', function () { var stats = tiktok.statNumbers(); return (stats.follow !== null || stats.fans !== null || stats.likes !== null) ? true : null; }, 3000, 200); return readProfile(); } /** 搜索页 → 输入 → 提交。复用 flow 里已经验证过的锚点,但关键词由这里给。 */ function openSearch(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 () { var state = TikTokAuto.tiktok.idPageState(); return (state === 'search' || state === 'users-results') ? true : null; }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true; } function submit(keyword, config) { var step = 'submitKeyword'; var tiktok = TikTokAuto.tiktok; 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, keyword, step); if (!typed.ok) { return false; } var button = tiktok.searchSubmit(); if (!button) { logger().error(step, '找不到搜索提交按钮'); return false; } TikTokAuto.ui.clickNode(button, step); return TikTokAuto.wait.waitFor(step, function () { var state = tiktok.idPageState(); return (state === 'users-results' || TikTokAuto.tiktok.resultTabStripPresent()) ? true : null; }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true; } /** * 打开**自己**的主页(底部导航的 Profile)。 * * 为什么必须先回信息流:底部导航只在首页有。当前若停在搜索页 / 播放页 / * 别人的主页,屏幕上根本没有那个入口 —— 直接找必然失败,而且报的是 * "找不到 Profile 入口"这种看不出原因的话。 * * 为什么不用 openBySearch(自己的用户名):引擎里没有"我是谁"这个信息 * (不带登录态、也不应该去猜),而底部导航这一步是零成本且永远正确的。 */ function openOwnProfile(config) { var step = 'openOwnProfile'; var log = logger(); if (!ensureFeed(config)) { log.warn(step, '回不到信息流,打不开自己的主页'); return false; } var nav = TikTokAuto.tiktok.profileNav(); if (!nav) { log.warn(step, '底部导航上找不到 Profile 入口'); return false; } TikTokAuto.ui.clickNode(nav, step); var ready = TikTokAuto.wait.waitFor(step, function () { return where() === 'profile' ? true : null; }, config.timeouts.pageReadyMs, config.probe.pollTickMs); if (ready !== true) { log.warn(step, '点了 Profile 但没进入用户主页'); return false; } /* ★ 必须等**三组数字**读全,不能用 settle(3000)(2026-10-29 真机踩到)。 * * 「username 出现」比「数字渲染完」早得多:实测点完 Profile 1 秒就 * `where() === 'profile'` 通过,而 fans 入口挂在数字上 —— 这时 `statEntry('fans')` * 找不到,上层看到的是"用户主页上找不到 fans 入口(等了 3 秒仍没有)", * 一次运行就此失败。`settle()` 在动态页面上会提前返回(它的判据是 * "文案连续两次不变",而加载中与渲染完的签名可能一样),挡不住这件事。 * * `waitProfile` 的判据正好是「username + 关注/粉丝/获赞三组都读到」, * 而且它读不到时**只警告不失败**(有的账号确实不显示某一组)。 */ waitProfile(step + ':stats'); return true; } /** 在当前搜索结果里按 uid 找行(找不到返回 null,不猜第一行) */ function findRowByUid(uid) { var rows = TikTokAuto.tiktok.usersResultRows(); for (var i = 0; i < rows.length; i += 1) { if (rows[i].uid === uid) { return rows[i]; } } return null; } /** 保证停在信息流首页(复用 flow 的成熟实现,避免两套退出逻辑) */ function ensureFeed(config) { return TikTokAuto.flow.ensureFeed(config) === true; } /** * 在用户主页上打开「粉丝/关注」列表。 * * ★ 这里**不能**用 `settle()` 判就绪:点完之后列表要联网加载, * 而 `waitQuiet` 的判据是"可见文案连续两次不变" —— 加载中的列表和 * 打开前的页面**签名是一样的**,于是它会立刻就返回(实测 872ms 就回来了), * 然后被判成"打不开列表"。正确做法是等**列表真的有行**。 * * @param which 'fans' | 'following' */ function openList(which, config) { var step = 'openList'; var tiktok = TikTokAuto.tiktok; var wanted = which === 'following' ? 'following' : 'fans'; var entry = tiktok.statEntry(wanted); if (!entry) { /* ★ 找不到入口时**再等一次**,不要立刻报失败。 * 实测(用户 2026-10-21 反馈):从搜索结果进主页后,标题已经出来了, * 但数字区还在渲染,这时 `statEntry()` 返回 null,直接判"找不到 fans 入口", * 用户看到的现象就是"还没加载出来就已经返回了"。重跑往往就正常 —— 典型的时序问题。 * 这里最多等 3 秒,够渲染完;真没有(比如私密号不显示列表)就如实失败。 */ logger().info(step, '暂时找不到 ' + wanted + ' 入口,等页面渲染完再试'); TikTokAuto.wait.waitFor(step + ':entry', function () { entry = tiktok.statEntry(wanted); return entry !== null ? true : null; }, 3000, 300); } if (!entry) { logger().warn(step, '用户主页上找不到 ' + wanted + ' 入口(等了 3 秒仍没有)'); return false; } logger().step(step, '打开列表:' + wanted); TikTokAuto.ui.clickNode(entry, step); var ready = TikTokAuto.wait.waitFor(step, function () { return where() === 'user-list' ? true : null; }, config.timeouts.pageReadyMs, config.probe.pollTickMs); if (ready === true) { return true; } /* 与粉丝列表相关的一个真机实况:首次打开会弹「给 TikTok 访问你的 Facebook 好友列表」 * 的授权窗,盖住整个列表。它不是错误页,但会让"列表就绪"永远等不到。 * 这里点掉它再等一次 —— 只做一轮,不无限重试。 */ if (dismissFriendPrompt()) { logger().info(step, '点掉了好友列表授权窗,再等一次'); ready = TikTokAuto.wait.waitFor(step + ':retry', function () { return where() === 'user-list' ? true : null; }, config.timeouts.pageReadyMs, config.probe.pollTickMs); } return ready === true; } /** * 点掉「Facebook 好友列表」授权窗的"不允许"。 * * 实测这个弹窗的文案是英文 `Don’t allow`(撇号是 U+2019,不是 ASCII 单引号, * 直接比对 'Don\'t allow' 是匹配不上的 —— 这里按**包含**比对并同时认几种写法)。 * 找不到就当没有弹窗,返回 false,由上层决定下一步。 */ function dismissFriendPrompt() { var layout = TikTokAuto.layout; var keywords = ['Facebook', '好友列表', 'friends list']; var buttons = className('android.widget.Button').find(); var hint = false; for (var i = 0; i < buttons.size(); i += 1) { var text = layout.text(buttons.get(i)); if (text === '') { continue; } if (text.indexOf('allow') >= 0 || text.indexOf('允许') >= 0 || text.indexOf('允許') >= 0) { hint = true; continue; } for (var k = 0; k < keywords.length; k += 1) { if (text.indexOf(keywords[k]) >= 0) { hint = true; break; } } } if (!hint) { return false; } /* 有授权窗:找那个"不允许 / Don't allow",它一定比"OK"靠左(实测 [260,1114][539,1209] vs [540,1114][819,1209]) */ var target = null; for (var j = 0; j < buttons.size(); j += 1) { var label = layout.text(buttons.get(j)); if (/don.t allow|不允许|不允許|稍后|稍後|not now|no thanks/i.test(label)) { if (target === null || buttons.get(j).bounds().left < target.bounds().left) { target = buttons.get(j); } } } if (!target) { return false; } logger().info('profile', '点掉授权窗:' + JSON.stringify(TikTokAuto.layout.text(target))); TikTokAuto.ui.clickNode(target, 'dismissFriendPrompt'); settle(2000); return true; } /** * 在列表页切页签。已在目标页签上时直接返回 true(不重复点)。 * @param which 'fans' | 'following' */ function switchListTab(which) { var step = 'switchTab'; var tabs = TikTokAuto.tiktok.userListTabs(); var wanted = which === 'following' ? 'following' : 'fans'; for (var i = 0; i < tabs.length; i += 1) { if (tabs[i].key !== wanted) { continue; } if (TikTokAuto.tiktok.selectedListTab() === wanted) { logger().info(step, '已在 ' + wanted + ' 页签上'); return true; } logger().info(step, '切到 ' + wanted + ' 页签(' + tabs[i].label + ')'); TikTokAuto.ui.clickNode(tabs[i].node, step); settle(3000); return true; } logger().warn(step, '找不到 ' + wanted + ' 页签'); return false; } /** * 当前在不在"一个可以逐行取 uid 的列表页"上。 * * 有两种,长得不一样、资源 id 也不同,但对上层来说都是"一屏一屏的用户行": * · `user-list` 粉丝/关注列表(`txt_desc` 装 uid) * · `users-results` 搜索结果的用户页签(`tv_username` 装 uid) * 上层不该关心是哪种 —— 这正是这个判据存在的意义。 */ function isListPage(state) { var now = state || where(); return now === 'user-list' || now === 'users-results'; } /** * 列表当前可见的每一行(已按屏幕从上到下排序)。 * * ★ **按当前页面选解析器**,这是实测踩出来的一个"看起来能跑但永远 0 行"的 bug: * 当初这里写死成 `userListRows()`(粉丝列表的解析器,找 `txt_desc`), * 而搜索结果页的用户行是 `tv_username` —— 于是"取材=搜索结果"时, * 上一步 `usersResultRows()` 明明读到了行(日志里 `userRows` 就绪), * 下一步 `listRows()` 却返回空,最后表现为"处理 0 行、一个都没抓到"。 * 两种页面的行结构不同,但**对调用方是同一件事**,所以分派必须留在这里。 */ function listRows() { if (where() === 'users-results') { return TikTokAuto.tiktok.usersResultRows(); } return TikTokAuto.tiktok.userListRows(); } /** * 列表向下翻一屏。 * * 起点刻意选在屏幕中部偏下、终点在中部偏上:太靠下会落到 Follow 按钮上 * (按钮吃掉手势,页面纹丝不动 —— 这个坑在界面上踩过一次,这里避开)。 * * @returns 翻之前那一屏的 uid 签名,供调用方判断"翻完有没有新东西" */ function scrollList() { var layout = TikTokAuto.layout; var screen = layout.screen(); TikTokAuto.ui.dragScreen( Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.78), Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.3), 420 ); settle(2500); } /** 点列表里的一行,进入该用户主页 */ function openRow(row) { var step = 'openRow'; if (!row) { return false; } logger().info(step, '点开 @' + row.uid); tapRow(row, step); return waitProfile(step); } /** * 等用户主页**真的渲染出来**。 * * ★ 不能只等 `settle()`(页面安静):点开主页后骨架先出现、`@username` * 那几个字后出现,中间页面是"安静的" —— 于是 settle 会提前返回, * 紧接着 readProfile() 读到一份**空资料**。 * 实测后果:一个粉丝数为 0 的账号被当成"读不到资料",或被误判过滤。 * 判据必须是"@username 节点已经存在"。 */ /** * 等用户主页**真的渲染完**,而不是"标题出来了就算到"。 * * ★ 两段等待,缺一不可(实测踩出来的): * * ① 等 `@username` 出现 —— 这一步很快(约 585ms),因为标题先渲染; * ② 再等顶部三组数字(关注/粉丝/获赞)**至少读到两个**。 * * 只做 ① 会出两类问题,而且都很难查: * · 数字区还是占位的 0 → 把"粉丝 78"读成"粉丝 0"→ 按数量门槛被错误跳过; * · 数字区根本没渲染 → `statEntry()` 找不到"粉丝/关注"那个可点入口 * → 报"用户主页上找不到 fans 入口"(用户看到的就是"还没加载出来就返回了")。 * * 第 ② 段是**尽力等**:有些账号(私密号、无数据的新号)可能只有一组数字, * 所以超时也照常返回 true —— 不能因为等不到三组就把正常账号判成失败。 */ function waitProfile(step) { var tiktok = TikTokAuto.tiktok; var arrived = TikTokAuto.wait.waitFor(step + ':profile', function () { return tiktok.profileUsernameNode() !== null ? true : null; }, 10000, 300); if (arrived !== true) { return false; } /* 第二段:数字区。**三组都要读到**,10 秒上限。 * * 为什么要求三组而不是两组:TikTok 会先把数字渲染成占位的 0, * 再逐组换成真值。要求"两组"时,`粉丝 0 / 关注 0 / 获赞 null` 这种 * 半渲染状态就算通过了 —— 实测把 @.1523615 的 2019 粉丝读成了 0, * 而这类错误会直接影响数量门槛的判定(读成 0 就会被"粉丝≥1000"筛掉)。 */ var statsReady = TikTokAuto.wait.waitFor(step + ':stats', function () { var stats = tiktok.statNumbers(); return (stats.follow !== null && stats.fans !== null && stats.likes !== null) ? true : null; }, 10000, 300); if (statsReady !== true) { /* 不是错误:确实有账号不显示某一组(新号可能没有获赞)。 * 但要在日志里留痕,因为后面的"读不到数量"多半是这里没等到。 */ var last = tiktok.statNumbers(); logger().info('profile', '三组数字 10 秒内没读全(关注 ' + last.follow + ' / 粉丝 ' + last.fans + ' / 获赞 ' + last.likes + '),继续'); } return true; } /** * 点一行用户条目。 * * 用**坐标**而不是 `node.click()`:整行容器的 click() 在真机上常常落到 * "行内第一个可点元素"(也就是右侧那个 Follow 按钮),会把用户关注掉 —— * 这是不能接受的副作用。所以点在行左侧 30% 处(用户名文字上),远离按钮。 * 实测这一处既能进主页,也不会误触关注。 */ function tapRow(row, step) { if (row.x === undefined || row.y === undefined) { TikTokAuto.ui.clickNode(row.node, step || 'tapRow'); return; } TikTokAuto.ui.tapPoint(row.x, row.y); } /** 从用户主页退回列表(列表位置会保持,实测);两种列表页都认 */ function backToList() { var step = 'backToList'; var deadline = Date.now() + 10000; while (Date.now() < deadline) { var state = where(); if (isListPage(state)) { return true; } if (state === 'profile' || state === 'comment-panel') { TikTokAuto.ui.goBack(step); settle(2000); continue; } /* 已经退到搜索页/结果页/信息流:说明退过头了(列表已经不在栈上)。 * 这种情况不该再往回退 —— 再退就退到首页去了。 */ return false; } return isListPage(); } /** 从列表退回用户主页 */ function backToProfile() { var step = 'backToProfile'; var deadline = Date.now() + 8000; while (Date.now() < deadline) { var state = where(); if (state === 'profile') { return true; } TikTokAuto.ui.goBack(step); settle(2000); } return where() === 'profile'; } /** * 从用户主页退回评论区。 * * 与 backToProfile/backToList 是一组:评论区取材时每个评论者都要 * "点头像 → 读 → 退回来",退错一层(退到播放页而不是评论区)就会 * 让下一轮找不到评论者。所以这里按状态确认,不靠数次数。 */ function backToCommentPanel() { var step = 'backToComment'; var deadline = Date.now() + 10000; while (Date.now() < deadline) { var state = where(); if (state === 'comment-panel') { return true; } if (state === 'player') { /* 退到了播放页:把评论面板重新点开,而不是继续退 */ var button = TikTokAuto.tiktok.commentButton(); if (button) { TikTokAuto.ui.clickNode(button, step + ':reopen'); settle(2500); if (where() === 'comment-panel') { return true; } } return false; } TikTokAuto.ui.goBack(step); settle(2000); } return where() === 'comment-panel'; } return { readProfile: readProfile, readAvatarState: readAvatarState, avatarNode: avatarNode, where: where, ensureFeed: ensureFeed, openBySearch: openBySearch, openOwnProfile: openOwnProfile, findRowByUid: findRowByUid, openList: openList, switchListTab: switchListTab, listRows: listRows, isListPage: isListPage, /** 给外部用的"等主页就绪":等 @username + 三组数字,见 waitProfile 的注释 */ waitProfilePublic: function () { return waitProfile('waitProfile'); }, scrollList: scrollList, openRow: openRow, backToList: backToList, backToProfile: backToProfile, backToCommentPanel: backToCommentPanel, settle: settle, dismissFriendPrompt: dismissFriendPrompt, AVATAR_SATURATED_RATIO: AVATAR_SATURATED_RATIO }; }()); /* ============================== src/idsource.js ============================== */ /** * idsource.js — 五种「从哪儿找人」,以及深度展开 * * ## 为什么是五种而不是四张脚本卡 * 旧的四个脚本卡是「起点 × 深度」的 4 个**不完整组合**。把它们拆开后发现, * 真正不同的只有一件事:**候选 ID 从哪儿来**。于是收敛成五种起点 * (指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕), * 深度(再取这些人的粉丝)是所有起点共用的一个选项。 * * ## 三种遍历模式 * 五种起点在"怎么走"上只有三种形态,实现都归到这三条路径上: * · **列表型**(指定账号 / 搜索用户 / 当前屏幕)—— 逐屏读行,可翻页; * · **文本型**(文本文件)—— 一次性读出每一行; * · **逐个点开型**(搜索视频作者 / 评论区)—— 屏幕上的条目没有 uid, * 只能点进去看(视频页底部有 `@uid`,评论者要靠头像进用户页)。 * * ## 深度展开为什么分两阶段 * "取这批人的粉丝"如果边遍历边展开,会嵌套两层列表上下文 —— * 打开 B 列表时 A 的滚动位置就丢了。所以做成: * ① 先把基础层收完,边收边记下"这些人要展开"; * ② 再对这批人逐个打开其粉丝/关注列表。 * 两层都线性,没有嵌套,也就没有"回到上一层时不知道走到哪"的问题。 * **上限固定两层**:100 粉丝 × 每人 100 粉丝 = 1 万次开页,真机上不现实。 * * 依赖:TikTokAuto.tiktok / profile / filters / parse / logger / wait */ TikTokAuto.idsource = (function () { 'use strict'; /** 翻一个列表最多翻多少屏。防止"永远有新内容"(推荐流)把一轮拖成无限长。 */ var MAX_SCREENS = 60; /** * 连续这么多次"翻屏后内容不变"就认为到头了(有的列表末尾会重复推荐位)。 * * ★ 注意判据是**翻不动**,不是"没有新 uid"(2026-10-28 修,见 walkStep 的注释)。 */ var IDLE_SCREENS_TO_STOP = 2; function logger() { return TikTokAuto.logger; } /** * 一屏处理完之后决定下一步。纯函数,便于在 PC 上回归(`tools/pc-selftest-id.js`)。 * * ★ 这里修的是一个**真 bug**(2026-10-28)。 * * 旧实现有两个地方同时给 `idle` 加一: * ① 本屏 `fresh` 为空(全是已收过的人)→ idle + 1 * ② 翻屏后签名不变 → idle + 1 * 而 `idflow` 会先把 `uid_extracted.txt` 预载进 `seen`。于是**第二轮跑同一个账号**时, * 前两屏全是历史 → idle=2 → **在列表顶部就判"到底"**,一条新人都拿不到。 * 断点续跑要把上次的产物也预载进 `seen`,这个误判会从"偶发"变成"必然"。 * * 正解:**只看"翻得动翻不动"**。一屏全是旧人说明"旧人多",不代表列表到头; * 只有"滚动之后签名没变"才是真的到头。 * * @param state { screens, stuck } * @param view { sigBefore, sigAfter, rowsAfter, maxScreens } * @returns { action: 'continue'|'break', state, reason } */ function walkStep(state, view) { var stuck = state.stuck; var screens = state.screens; if (view.rowsAfter === 0) { /* 翻完这一屏读不到行了:可能还在加载,也可能真的到头。 * 不在这里下结论 —— 交给下一轮循环开头的"空行即结束"处理 * (这样"加载慢"和"列表结束"两个判断只在一处)。 */ return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } }; } if (view.sigAfter === view.sigBefore) { stuck += 1; if (stuck >= IDLE_SCREENS_TO_STOP) { return { action: 'break', reason: '翻屏后内容不变,认为列表到底', state: { screens: screens, stuck: stuck } }; } } else { /* 内容变了 → 列表还在往下走。**不管这一屏里有没有新人**都归零。 */ stuck = 0; } if (screens >= view.maxScreens) { return { action: 'break', reason: '达到单列表翻屏上限 ' + view.maxScreens + ' 屏', state: { screens: screens, stuck: stuck } }; } return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } }; } /** 一行 uid 的临时载体(列表行带昵称与计数,用户页带更多资料,统一成这个形状) */ function rowOf(uid, nickname, extra) { var one = { uid: uid, nickname: nickname || '' }; if (extra) { for (var key in extra) { if (extra.hasOwnProperty(key)) { one[key] = extra[key]; } } } return one; } /* ============================================================ 列表型 */ /** * 在"一个可滚动的用户列表"上逐行推进。 * * 关键设计:**用 uid 集合决定"哪一行还要处理",不用行下标**。 * 因为每次点进用户页再退回来,列表的节点树都重建过一遍(引用失效), * 只有 uid 是稳的。 * * 但**停止判据不能用 uid 集合** —— 那会把"全是旧人"误判成"列表到底"(见 walkStep)。 * * @param ctx { seen, onRow(row), shouldStop(), maxScreens?, stat?, config? } * maxScreens:本次最多翻多少屏(续跑时按上次已翻的屏数放大,见 idflow) * stat:传入则回填 { screens, handled },供断点记录 * @returns 处理了多少行(本屏新收的) */ function walkList(ctx) { var log = logger(); var handled = 0; var maxScreens = ctx.maxScreens || MAX_SCREENS; var state = { screens: 0, stuck: 0 }; while (state.screens < maxScreens) { if (ctx.shouldStop()) { log.info('idsource', '达到停止条件,结束当前列表'); break; } state.screens += 1; var rows = TikTokAuto.profile.listRows(); if (rows.length === 0 && state.screens === 1) { /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空": * 从用户页点开粉丝列表、或从结果页切到用户页签后, * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 —— * 直接 break 就等于"一条都没抓到"。 */ TikTokAuto.wait.waitFor('listRows', function () { return TikTokAuto.profile.listRows().length > 0 ? true : null; }, ctx.config ? ctx.config.timeouts.pageReadyMs : 8000, 300); rows = TikTokAuto.profile.listRows(); } if (rows.length === 0) { log.info('idsource', '当前屏没有读到用户行,结束列表'); break; } /* 一行一行处理:只处理没见过的人(见过的直接跳过,但**不因此判定列表到底**)。 * 需要资料时会点进用户页再退回(实测位置保持)。 */ var stopped = false; for (var i = 0; i < rows.length; i += 1) { if (ctx.shouldStop()) { stopped = true; break; } if (ctx.seen.has(rows[i].uid)) { continue; } ctx.onRow(rows[i]); ctx.seen.add(rows[i].uid); /* 处理过就算见过,避免同一行被反复点开 */ handled += 1; } if (stopped) { break; } /* 翻一屏,用"内容有没有变"判断是否到头 */ var before = signatureOfRows(rows); TikTokAuto.profile.scrollList(); var afterRows = TikTokAuto.profile.listRows(); var step = walkStep(state, { sigBefore: before, sigAfter: signatureOfRows(afterRows), rowsAfter: afterRows.length, maxScreens: maxScreens }); state = step.state; if (step.action === 'break') { log.info('idsource', step.reason); break; } } if (ctx.stat) { ctx.stat.screens = state.screens; ctx.stat.handled = handled; } return handled; } /** 一屏 uid 的签名,用来判断"翻完到底有没有变" */ function signatureOfRows(rows) { var parts = []; for (var i = 0; i < rows.length; i += 1) { parts.push(rows[i].uid); } return parts.join('|'); } /* ============================================================ 文本型 */ /** * 从本地文本文件读 uid。 * * 两条容忍(都是为了让用户不用改自己的文件): * · 行首的 `@` 会被剥掉(有人习惯直接粘贴 "@name"); * · `#` 开头的行当注释跳过。 */ function readTextSource(source) { var list = TikTokAuto.uidset.readUidFile(source.path); logger().info('idsource', '文本取材:' + source.path + ' 读到 ' + list.length + ' 行'); var rows = []; for (var i = 0; i < list.length; i += 1) { rows.push(rowOf(list[i], '')); } return rows; } /* ============================================================ 逐个点开型 */ /** * 搜索结果里的视频 → 逐个点开,读播放页底部的作者 uid。 * * 实测:搜索结果的视频卡片上只有**作者昵称**,没有 uid; * 播放页底部有一个 `Button` 文案是 `@uid` —— 这是唯一能拿到 uid 的地方。 * 所以每个视频要"点开 → 读 → 退回",约 6 秒一个。 */ function walkSearchVideos(source, ctx) { var log = logger(); var handled = 0; var idle = 0; for (var screen = 0; screen < MAX_SCREENS; screen += 1) { if (ctx.shouldStop()) { break; } var cards = TikTokAuto.tiktok.videoCards(); if (cards.length === 0) { /* 0 张卡可能是"到底了",也可能是"列表正在重排/图片在加载"。 * 先给 5 秒,等不到再判定到底 —— 直接 break 会把慢当成空。 */ cards = TikTokAuto.flow.waitForCards(ctx.config, 5000) || []; } if (cards.length === 0) { log.info('idsource', '结果页没有卡片了'); break; } var opened = 0; for (var i = 0; i < cards.length; i += 1) { if (ctx.shouldStop()) { return handled; } var card = cards[i]; var marker = 'card:' + String(card.caption).slice(0, 40); if (ctx.seen.has(marker)) { continue; } ctx.seen.add(marker); if (!TikTokAuto.flow.openCard(ctx.config, card)) { log.warn('idsource', '打开视频失败:' + marker); TikTokAuto.flow.returnToResults(ctx.config); continue; } var uid = TikTokAuto.tiktok.playerAuthorUid(); if (uid) { ctx.onRow(rowOf(uid, String(card.caption).slice(0, 30))); handled += 1; opened += 1; } else { log.warn('idsource', '播放页读不到作者 uid'); } TikTokAuto.flow.returnToResults(ctx.config); } if (opened === 0) { idle += 1; if (idle >= IDLE_SCREENS_TO_STOP) { log.info('idsource', '连续几屏都读不到作者,结束'); break; } } else { idle = 0; } TikTokAuto.flow.scrollResults(ctx.config); } return handled; } /** * 评论区 → 逐个点评论者的头像进用户页读 uid。 * * 实测这条最贵:评论区只显示昵称,没有 uid(`Button title` 是昵称, * 旁边那个 `@uid` 是固定节点、不是评论者的)。所以每个评论者都要 * "点头像 → 读 → 退回",约 8 秒一个。这也是为什么方案里 * 把"评论者"和"评论者的粉丝"分开成两条取材,而不是默认都开。 */ function walkCommenters(source, ctx) { var log = logger(); var handled = 0; for (var screen = 0; screen < MAX_SCREENS; screen += 1) { if (ctx.shouldStop()) { break; } var rows = TikTokAuto.tiktok.commentRowers(); if (rows.length === 0) { log.info('idsource', '评论区没有更多可见条目'); break; } var opened = 0; var before = signatureOfNodes(rows); for (var i = 0; i < rows.length; i += 1) { if (ctx.shouldStop()) { return handled; } /* 头像节点每次都会重建,用"位置 + 序号"当标记 —— 换一屏位置就会变 */ var marker = 'cmt:' + rows[i].top + ':' + (screen * 100 + i); if (ctx.seen.has(marker)) { continue; } ctx.seen.add(marker); TikTokAuto.ui.tapPoint(rows[i].x, rows[i].y); /* ★ 用 waitProfile(等 @username + 三组数字),而不是 settle + where()。 * * 早先这里是"等页面安静 3 秒,再看 where() 是不是 profile": * · settle 会提前返回(骨架先出、内容后加载,签名看起来没变); * · 而 where() 要求 `@username` 已经渲染出来。 * 两头一凑,就成了"人明明已经进主页了,却被判成没进去"—— * 实测日志里连续两条"点评论者头像没有进入用户主页",然后一条都没取到。 */ if (!TikTokAuto.profile.waitProfilePublic()) { log.warn('idsource', '点评论者头像没有进入用户主页(等了 10 秒)'); continue; } var profile = TikTokAuto.profile.readProfile(); if (profile.uid) { /* 资料已经在手上了,带上 `_profile` 让编排层不必再开一次用户页 */ ctx.onRow(rowOf(profile.uid, profile.nickname, { _profile: profile })); handled += 1; opened += 1; } TikTokAuto.profile.backToCommentPanel(); } if (opened === 0) { break; } /* 评论区滚动 */ var layout = TikTokAuto.layout; var screenSize = layout.screen(); TikTokAuto.ui.dragScreen( Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.8), Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.4), 420 ); TikTokAuto.profile.settle(2000); if (signatureOfNodes(TikTokAuto.tiktok.commentRowers()) === before) { log.info('idsource', '评论区滚动后内容不变,结束'); break; } } return handled; } function signatureOfNodes(rows) { var parts = []; for (var i = 0; i < rows.length; i += 1) { parts.push(rows[i].top); } return parts.join(','); } /* ============================================================ 入口 */ /** * 把一个取材源跑完,逐行交给 `onRow`。 * * @param source 取材项(与界面 sources.js 的形状一致) * @param ctx { config, seen, onRow(row), shouldStop() } * @returns 处理的行数 */ function run(source, ctx) { var log = logger(); log.step('idsource', '取材开始:' + describe(source)); if (source.kind === 'text') { /* 文本源也走 `onRow`,**不在这一层自己判去重** —— * 去重与过滤是编排层(idflow)的职责,在这里再做一遍会让 * `duplicates` 统计漏掉(实测:跨轮去重跳了 3 个,summary 里却是 0)。 */ var rows = readTextSource(source); var handled = 0; for (var i = 0; i < rows.length; i += 1) { if (ctx.shouldStop()) { break; } ctx.onRow(rows[i]); handled += 1; } return handled; } if (source.kind === 'screen') { /* 当前屏幕:不做任何导航,直接把眼前这一屏当列表读。 * * 但要先确认眼前确实是"一个用户列表" —— 否则 walkList 会一屏一屏地读, * 最后报"处理 0 行",用户完全不知道是页面不对。 */ var state = TikTokAuto.profile.where(); if (!TikTokAuto.profile.isListPage(state)) { log.warn('idsource', '当前屏幕取材:需要先把页面停在「用户列表」(粉丝/关注/搜索用户)上,' + '引擎不会替你跳转。现在停在「' + state + '」'); return 0; } return walkList(ctx); } if (source.kind === 'anchor') { return runAnchor(source, ctx); } if (source.kind === 'search') { if (source.searchType === 'videos') { if (!prepareSearch(source.keyword, ctx.config)) { return 0; } TikTokAuto.flow.selectResultTab({ target: { resultTab: 'Top' }, timeouts: ctx.config.timeouts, probe: ctx.config.probe }); return walkSearchVideos(source, ctx); } if (!prepareSearch(source.keyword, ctx.config)) { return 0; } return walkList(ctx); } if (source.kind === 'video') { if (!prepareVideo(source, ctx.config)) { return 0; } return walkCommenters(source, ctx); } log.warn('idsource', '未知的取材起点:' + source.kind); return 0; } /** * 指定账号:打开它的主页 → 按 `lists` 逐个开列表 → 每处走一遍 walkList。 * * `anchors` 为空表示"不限定账号,从当前屏幕取" —— 这是界面允许的状态, * 对应 v6 时代「主播 ID 留空」的语义。 */ function runAnchor(source, ctx) { var log = logger(); var parse = TikTokAuto.parse; var anchors = String(source.anchors || '').split('#'); var handled = 0; var accounts = []; for (var i = 0; i < anchors.length; i += 1) { var name = parse.username(anchors[i]); if (name !== '') { accounts.push(name); } } if (accounts.length === 0) { log.info('idsource', '没有指定账号 → 直接把当前屏幕当列表'); return walkList(ctx); } for (var a = 0; a < accounts.length; a += 1) { if (ctx.shouldStop()) { break; } var profile = TikTokAuto.profile.openBySearch(accounts[a], ctx.config); if (!profile) { log.warn('idsource', '打不开账号 @' + accounts[a] + ',跳过'); continue; } ctx.onSeenProfile(profile); var lists = source.lists && source.lists.length > 0 ? source.lists : ['fans']; for (var l = 0; l < lists.length; l += 1) { if (ctx.shouldStop()) { return handled; } var which = lists[l] === 'home' ? 'fans' : lists[l]; if (!TikTokAuto.profile.openList(which, ctx.config)) { log.warn('idsource', '打不开 ' + lists[l] + ' 列表'); continue; } TikTokAuto.profile.switchListTab(which); handled += walkList(ctx); TikTokAuto.profile.backToProfile(); } } return handled; } /** 搜索关键词并停在结果页(供列表型与逐个点开型共用) */ function prepareSearch(keyword, config) { var log = logger(); if (!TikTokAuto.profile.ensureFeed(config)) { return false; } var tiktok = TikTokAuto.tiktok; var entry = tiktok.searchEntry(); if (!entry) { log.error('idsource', '首页找不到搜索入口'); return false; } TikTokAuto.ui.clickNode(entry, 'idsource:search'); if (TikTokAuto.wait.waitFor('idsource:search', function () { var state = tiktok.idPageState(); return (state === 'search' || state === 'users-results') ? true : null; }, config.timeouts.pageReadyMs, config.probe.pollTickMs) !== true) { return false; } var input = tiktok.searchInput(); if (!input) { return false; } TikTokAuto.ui.clickNode(input, 'idsource:input'); TikTokAuto.wait.tick(250); if (!TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, 'idsource:type').ok) { return false; } var submit = tiktok.searchSubmit(); if (!submit) { return false; } TikTokAuto.ui.clickNode(submit, 'idsource:submit'); if (TikTokAuto.wait.waitFor('idsource:results', function () { return tiktok.resultTabStripPresent() ? true : null; }, config.timeouts.searchResultsMs, config.probe.pollTickMs) !== true) { return false; } /* 用户搜索要切到 Users 页签;视频搜索留在 Top */ var usersTab = tiktok.resultTab('Users'); if (usersTab && !tiktok.resultTabSelected('Users')) { TikTokAuto.ui.clickNode(usersTab, 'idsource:usersTab'); /* 等结果行真的出现(页签条先渲染、行后加载,只等"安静"会误判为空) */ TikTokAuto.wait.waitFor('idsource:userRows', function () { return tiktok.usersResultRows().length > 0 ? true : null; }, config.timeouts.searchResultsMs, config.probe.pollTickMs); } return true; } /** * 打开一个视频(播放页),供评论区取材用。 * `videoSource = "screen"` 表示用户自己已经把视频停在屏幕上,不动它。 */ function prepareVideo(source, config) { var log = logger(); if (source.videoSource !== 'keyword') { var state = TikTokAuto.tiktok.pageState(); if (state === 'player' || state === 'comment-panel') { log.info('idsource', '已在播放页,直接用当前屏幕的视频'); return true; } /* 这里的提示必须是**可操作**的:用户能做的只有三件事 —— 自己先把页面停好、 * 改用关键词模式、或者换一个取材源。不能只说"不在播放页"。 */ log.warn('idsource', '「当前屏幕」需要用你手上已经打开的那个视频,引擎不会替你跳转。\n' + ' 请先在 TikTok 里打开视频(或直接打开它的评论区),再点「开始运行」。\n' + ' 当前页面是「' + state + '」。'); return false; } if (!prepareSearch(source.videoKeyword, config)) { return false; } /* Top 页签是视频;取第一张卡点开 */ var cards = TikTokAuto.tiktok.videoCards(); if (cards.length === 0 && !TikTokAuto.flow.waitForCards(config)) { return false; } cards = TikTokAuto.tiktok.videoCards(); if (cards.length === 0) { return false; } if (!TikTokAuto.flow.openCard(config, cards[0])) { return false; } /* 打开评论面板 */ var button = TikTokAuto.tiktok.commentButton(); if (!button) { log.warn('idsource', '播放页找不到评论按钮'); return false; } TikTokAuto.ui.clickNode(button, 'idsource:comments'); return TikTokAuto.wait.waitFor('idsource:panel', function () { return TikTokAuto.tiktok.commentPanelOpen() ? true : null; }, config.timeouts.panelReadyMs, config.probe.pollTickMs) === true; } /** 一句人话摘要(日志与状态条用) */ function describe(source) { var parse = TikTokAuto.parse; if (source.kind === 'anchor') { var list = source.anchors ? source.anchors.split('#')[0] : '当前屏幕'; return '账号 ' + list + ' 的 ' + (source.lists || []).join('+'); } if (source.kind === 'search') { return (source.searchType === 'videos' ? '视频作者' : '用户') + '「' + source.keyword + '」'; } if (source.kind === 'video') { return source.videoSource === 'keyword' ? '评论区「' + source.videoKeyword + '」' : '当前屏幕的评论区'; } if (source.kind === 'text') { return '文本 ' + String(source.path).split('/').pop(); } return '当前屏幕'; } return { MAX_SCREENS: MAX_SCREENS, IDLE_SCREENS_TO_STOP: IDLE_SCREENS_TO_STOP, run: run, walkList: walkList, /* 纯函数,供 PC 回归(`tools/pc-selftest-id.js`)—— * 停止判据是"翻不动了"而不是"没有新人",这条必须能被离线断言。 */ walkStep: walkStep, readTextSource: readTextSource, describe: describe }; }()); /* ============================== src/resolve.js ============================== */ /** * resolve.js — 把 username(@handle)换成 TikTok 的数字 uid * * ## 为什么需要它 * 抓取产物里存的是 **username**(`beautifulsoul78869`)—— 那是能搜到人、能 @ 的标识。 * 但有些下游(对接接口、入库、跟外部系统对账)要的是 **数字 uid**(`254801657882882048`)。 * 两者完全不同: * * uniqueId / handle beautifulsoul78869 能搜到人、能 @,界面上可见 * nickname 🌹Beautiful Soul💀 显示名,可重名、可随时改 * id(本文的 uid) 254801657882882048 平台内部主键,界面上**读不到** * * 数字 uid 在 App 界面上任何地方都不显示,所以只能靠 HTTP 拿。 * * ## 三条取数路径(照 `tiktok-uid和视频短链解析.js` 的分级思路) * * ① `www.tiktok.com/api/user/detail/?uniqueId=` 官方详情接口,最轻(本应几百字节) * ② `www.tiktok.com/node/share/user/@` 老的分享接口 * ③ `www.tiktok.com/@` 的整页 SSR 最重(约 388KB),但**最可靠** * * 2026-10-20 真机实测:**①② 都已经不可用** —— * ① 返回 `HTTP 200` 但**响应体为空**(风控静默拒绝,最容易让人误以为"接口还在") * ② 返回 `403` * ③ 200 / 388KB / 约 2 秒 / `JSON.parse` 仅 7ms * * 所以路径按"轻 → 重"逐个降级,且**每条只探一次**:某个账号证明 ① 不可用之后, * 本次运行内不再尝试 ① —— 否则每个账号都要白付两次失败请求。 * 这样将来接口恢复了,第一次就会自动走轻的那条;恢复不了也不会拖慢。 * * ## 试过但走不通的(别再试) * · `tikwm.com/api/user/info` —— Cloudflare 挑战页("Just a moment...") * · `m.tiktok.com/@user` —— 404(那条短链只认 `/v/.html`) * · `www.tiktok.com/oembed?url=...` —— 只有昵称与 handle,**没有数字 id** * · AutoX 的 `http.get` —— 本机这个版本是坏的: * `TypeError: Cannot find function newCall`(OkHttp 绑定冲突)→ 走 Java `HttpURLConnection` * * 依赖:TikTokAuto.logger、TikTokAuto.wait(节流与停止响应)、TikTokAuto.parse */ TikTokAuto.resolve = (function () { 'use strict'; var UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; /** 整页近 400KB,慢网下 20 秒是合理的上限;接口那两条给 8 秒就够。 */ var API_TIMEOUT_MS = 8000; var PAGE_TIMEOUT_MS = 20000; /** * 两次请求之间的间隔区间。 * * 为什么不并发:这是网页版接口,短时间高频请求很容易被要求过验证码; * 而我们是"一次几十个"的量级,串行 + 随机间隔的代价可以接受(每个约 2.5 秒)。 */ var GAP_MIN_MS = 400; var GAP_MAX_MS = 900; /** * 本次运行里各条路径是否已被证明不可用。 * * 这是**性能关键**:接口①② 实测必失败,若不记住,每个账号要多付两次失败请求; * 记住之后只有第一个账号付这个代价。 */ var deadPaths = { api: false, share: false }; /** ttwid 只取一次(接口风控用;主页 HTML 不需要它) */ var ttwidCache = null; var ttwidTried = false; function logger() { return TikTokAuto.logger; } /** * 读一个连接的响应体。 * * ★ 必须区分**正常流与错误流**:`getInputStream()` 在 4xx/5xx 时会直接抛异常, * 于是「404 账号不存在」会被误判成「网络错误」。 * 从 `getErrorStream()` 读出来的 body 才是真正的原因(有时还带 JSON 错误码)。 */ function readBody(conn) { var stream = null; try { var code = conn.getResponseCode(); stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream(); if (!stream) { return { code: code, body: '' }; } var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, 'UTF-8')); var buffer = new java.lang.StringBuilder(); var line; while ((line = reader.readLine()) !== null) { buffer.append(line).append('\n'); } reader.close(); return { code: code, body: String(buffer.toString()) }; } finally { try { if (stream) { stream.close(); } } catch (ignored) { } } } /** * 发一个 GET。 * @returns { ok, status, body, error } */ function get(url, headers, timeoutMs) { var conn = null; try { conn = new java.net.URL(url).openConnection(); conn.setConnectTimeout(timeoutMs); conn.setReadTimeout(timeoutMs); conn.setInstanceFollowRedirects(true); conn.setRequestProperty('User-Agent', UA); conn.setRequestProperty('Accept-Language', 'en-US,en;q=0.9'); if (headers) { for (var key in headers) { if (headers.hasOwnProperty(key)) { conn.setRequestProperty(key, headers[key]); } } } var result = readBody(conn); return { ok: true, status: result.code, body: result.body }; } catch (error) { return { ok: false, status: 0, body: '', error: String(error) }; } finally { /* 一定要断开:不关的话连接会一直占着,几十个账号之后就容易出问题 */ try { if (conn) { conn.disconnect(); } } catch (ignoredToo) { } } } /** * 取 ttwid(TikTok 首页下发,接口风控用)。 * 取不到不算错 —— 只是那两条接口大概也走不通,直接降级到主页 HTML。 */ function ttwid() { if (ttwidTried) { return ttwidCache; } ttwidTried = true; var conn = null; try { conn = new java.net.URL('https://www.tiktok.com/').openConnection(); conn.setConnectTimeout(API_TIMEOUT_MS); conn.setReadTimeout(API_TIMEOUT_MS); conn.setRequestProperty('User-Agent', UA); conn.getResponseCode(); var fields = conn.getHeaderFields(); var cookies = fields ? fields.get('Set-Cookie') : null; if (cookies) { for (var i = 0; i < cookies.size(); i += 1) { var one = String(cookies.get(i)); var idx = one.indexOf('ttwid='); if (idx !== -1) { ttwidCache = one.substring(idx).split(';')[0]; /* 形如 ttwid=xxx */ break; } } } } catch (error) { logger().warn('resolve', '取 ttwid 失败(不影响后续降级):' + error); } finally { try { if (conn) { conn.disconnect(); } } catch (ignored) { } } return ttwidCache; } /** 从 JSON 对象里找 `userInfo.user`(别写死键路径,层级改过) */ function findUser(node, depth) { if (depth > 8 || node === null || typeof node !== 'object') { return null; } if (node.userInfo && node.userInfo.user && node.userInfo.user.id) { return node.userInfo.user; } for (var key in node) { if (node.hasOwnProperty(key)) { var hit = findUser(node[key], depth + 1); if (hit) { return hit; } } } return null; } /** JSON 解析失败时用正则兜一层(整页 SSR 结构改版时不至于全废) */ function findUserByRegex(html, handle) { var escaped = String(handle).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); var matched = html.match( new RegExp('"uniqueId":"' + escaped + '"[^}]*?"id":"(\\d+)"') ) || html.match( new RegExp('"id":"(\\d+)","shortId":"[^"]*","uniqueId":"' + escaped + '"') ); if (!matched) { return null; } return { id: matched[1], uniqueId: handle }; } /** 统一校验:拿到的确实是这个 handle 的用户(网页版可能重定向到别名) */ function accept(user, wanted) { if (!user || !user.id) { return null; } var got = TikTokAuto.parse.username(user.uniqueId || ''); if (got !== '' && got.toLowerCase() !== String(wanted).toLowerCase()) { logger().warn('resolve', '返回的是另一个账号(' + got + ' ≠ ' + wanted + '),丢弃'); return null; } return { ok: true, uid: String(user.id), nickname: user.nickname || '', secUid: user.secUid || '' }; } /* ---------------------------------------------------------- 三条路径 */ /** ① 官方用户详情接口 */ function viaApi(handle) { var headers = { 'Accept': 'application/json, text/plain, */*', 'Referer': 'https://www.tiktok.com/@' + encodeURIComponent(handle) }; var cookie = ttwid(); if (cookie) { headers['Cookie'] = cookie; } var res = get('https://www.tiktok.com/api/user/detail/?uniqueId=' + encodeURIComponent(handle), headers, API_TIMEOUT_MS); if (!res.ok || res.status !== 200 || res.body === '') { return null; } var data = null; try { data = JSON.parse(res.body); } catch (error) { return null; } return findUser(data, 0); } /** ② 老的分享接口 */ function viaShare(handle) { var res = get('https://www.tiktok.com/node/share/user/@' + encodeURIComponent(handle) + '?uniqueId=' + encodeURIComponent(handle), { 'Accept': 'application/json, text/plain, */*' }, API_TIMEOUT_MS); if (!res.ok || res.status !== 200 || res.body === '') { return null; } var data = null; try { data = JSON.parse(res.body); } catch (error) { return null; } return findUser(data, 0); } /** ③ 主页整页 SSR(实测唯一可用的一条) */ function viaProfileHtml(handle) { var res = get('https://www.tiktok.com/@' + encodeURIComponent(handle), { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, PAGE_TIMEOUT_MS); if (!res.ok || res.status !== 200 || res.body === '') { return null; } var matched = res.body.match( /