# 项目 AI 上下文(交接给 VS Code / GitHub Copilot) > **这份文档的目的**:让没有历史对话的 AI(或新同事)能安全地改这个项目,不重蹈已经踩过的坑。 > > **一切结论都来自真机实测**(Pixel 5a / Android 14 / TikTok 47.1.4 / AutoX 7)。标「实测」的都是跑过的,标「推断」的说明没验证。 --- ## 0. 这份文档怎么用 | 你的任务 | 先读哪节 | |---|---| | 第一次接触这个项目 | 1 → 2 → 3 | | 要改代码 | **5(硬规则)** → **6(坑库)** → 7 | | 报错排查 | 10 | | 改界面字段 / 加取材源 | 7.1 / 7.2 | | 找选择器 | 8 | | 决定"这个能不能做" | 9 | **最重要的一句**:这个项目的坑几乎都是**「看起来正常但结果是错的」**——没有报错、没有崩溃,只是数据不对或功能静默失效。所以第 6 节比第 5 节更重要。 --- ## 1. 项目速览 给 TikTok 做自动化:**跑数据** + **批量提取用户 ID**,界面用 AutoX 的 UI 模式,业务逻辑用无障碍操作 TikTok App。 ``` ┌─────────────────────────────────────────────┐ │ tiktok-app 界面(UI 模式) │ │ 登录激活 → 选任务 → 配参数 → 点「开始运行」 │ └───────────────┬─────────────────────────────┘ │ 通过 4 个 JSON 文件通信 ▼ ┌─────────────────────────────────────────────┐ │ tiktok-auto 引擎(无界面脚本) │ │ 两个 task: │ │ · video 搜索 → 观看 → 评论 │ │ · id 取材 → 过滤 → 去重 → 落盘 → 换数字uid│ └─────────────────────────────────────────────┘ │ 无障碍 API 操作 TikTok ▼ com.zhiliaoapp.musically ``` **两条链路共用同一套基础设施**:日志、桥接、等待原语、页面状态机、守护线程、停止信号、护栏。 ### 三条硬约束(不可协商) 1. **纯无障碍操作**,不注入、不 hook、不改 TikTok。选这个方案的原因:可维护(TikTok 改版只需改选择器) 2. **引擎必须停在 TikTok 前台**,否则页面全 `unknown`。所以有悬浮球(让用户能切走再回来) 3. **单跑**:同一台手机同一时间只跑一个任务(物理上没法并发操作一个 App) --- ## 2. 架构与运行链路 ### 2.1 三层结构 | 层 | 目录 | 产物 | 运行环境 | |---|---|---|---| | 界面 | `tiktok-app/src/`(19 模块) | `tiktok-app/dist/tiktok-app.js` | AutoX UI 模式 | | 引擎 | `tiktok-auto/src/`(19 模块) | `tiktok-auto/dist/tiktok-auto.js` | AutoX 无界面脚本 | | 独立脚本 | 根目录 `tiktok_stats.js` 等 | 不构建,直接用 | PC Node 或 手机 AutoX | **构建方式是「单文件拼接」**:`src/` 多模块 → 拼成一个自包含文件。 原因:AutoX 只能下发一整段脚本,不能带 `require`。 ### 2.2 启动入口 ``` project.json 的 "main" → ./main.js main.js 第一行必须是 "ui"; main.js 只做两件事:加载 tiktok-app/dist/tiktok-app.js + 可操作的报错 ``` > ★ `"ui";` 只对**入口文件**有效。写在被 `require` 的模块里只是一句字符串。 ### 2.3 界面 ↔ 引擎 的通信(6 个文件) | 文件 | 方向 | 内容 | |---|---|---| | `/sdcard/tiktok_app/engine-config.json` | 界面 → 引擎 | 完整配置(含 `runtime.runId`,引擎拿它跟 `--run-id` 比对) | | `/sdcard/tiktok_app/run-state.json` | 引擎 → 界面 | `v` / `status` / `phase` / `done` / `summary` / `engine.capabilities` | | `/sdcard/tiktok_app/engine-beat.json` | 引擎 → 界面 | 心跳:每 2 秒整份重写,`beatAt` + `stopSeenAt` | | `/sdcard/tiktok_app/stop.flag` | 界面 → 引擎 | **内容 = runId**(不是空文件) | | `/sdcard/tiktok_app/run-log.jsonl` | 引擎 → 界面 | 逐行 JSON,界面读回做日志页 | | `/sdcard/tiktok_app/ball-rect.json` | 悬浮球 → 引擎 | 避让矩形 | (另有 `run-config.json`:界面写给**人**看的配置快照,没有任何程序读它。) **为什么走文件而不是进程内通信**:子脚本看不到父脚本的任何全局变量(实测)。 **四条容易踩的**: - `stop.flag` 用「内容 = runId」而不是「文件存在」——删除有竞态,而且 `files.remove` 命中 MCP 的危险调用黑名单 - 读 `run-state.json` **必须比对 runId**,否则"刚点开始就显示上一轮进度" - `engine-beat.json` 同理要比对 runId;★ 但"**没有本轮心跳**"≠"**引擎不支持心跳**"——后者只看 `run-state.engine.capabilities.heartbeat`。这两件事混在一起,每次启动的头两秒都会被误降成 v1 - `run-log.jsonl` 而不是 `.json`——半截行只丢一行 协议细节(状态迁移表、全部阈值、兼容规则):`docs/PROTOCOL.md`。 ### 2.4 停止机制 `StopSignal` 从**唯一的等待原语** `wait.tick()` 抛出 → 全流程可中断、业务代码零改动。 界面三段(超时值全部来自 `Protocol.SPEC.thresholds`): 1. 写 `stop.flag` → 状态条「**正在通知引擎**」(引擎还没回话) 2. 引擎承认收到(心跳里的 `stopSeenAt`,或它自己报了 `stopping`)→「**正在收尾**」 3. 引擎写出终态 → 复位 兜底:ack 等 3 秒、收尾再等 10 秒,合计 13 秒;v1(无心跳)引擎沿用旧的单段 8 秒。 **心跳线程的独立 ack 是关键**——主流程可能正卡在一个 25 秒的 watch 里,只有独立线程能立刻回话。 --- ## 3. 目录地图 ``` tiktok-scripts/ ├── project.json AutoX 项目描述(main → ./main.js) ├── main.js ★ 入口:加载界面产物 + 报错兜底 │ ├── tiktok-app/ 界面工程 │ ├── src/ 22 个模块(见下表) │ ├── dist/tiktok-app.js 构建产物 │ ├── tools/build.js 构建 + 自检(ui 位置 / 语法 / 未完成标记 / Protocol 已进产物) │ ├── tools/smoke.js ★ 526 项结构自检(PC 侧,stub 环境) │ ├── tools/audit-values.js 六场景取值审计 │ ├── tools/protocol-check.js 两侧 protocol.js 的一致性 / 自洽性检查 │ └── tools/fake-engine.js 假引擎(验证界面而不真跑 TikTok;会跳心跳) │ ├── tiktok-auto/ 引擎 │ ├── src/ 19 个模块(见下表) │ ├── dist/tiktok-auto.js 构建产物 │ ├── tools/build.js 构建 + 语法检查 + 危险调用预检 │ └── tools/pc-selftest-id.js ★ 68 项纯函数自检(秒级) │ ├── tiktok_stats.js 独立脚本:抓点赞/评论数/全量评论(PC Node) ├── tiktok-uid和视频短链解析.js 独立脚本:短链 → 视频 → 作者 uid(687 行) ├── tiktok_extract_cookie.js 独立脚本:登录态提取(已不需要,见 9) │ ├── docs/AI_CONTEXT.md ← 你正在读的这份 ├── PLAN_*.md / REQ_*.md 历史方案(可读,但结论以本文档为准) └── _probe/ 真机探测脚本与截图(可清理) ``` ### 3.1 界面模块(22) | 模块 | 职责 | |---|---| | `theme.js` | 色值令牌、尺寸、字号(**令牌缺键 = 静默视觉删除**) | | `store.js` | ★ 配置默认值的**唯一来源** `Store.FIELDS`(69 条),含版本迁移 | | `sources.js` | ID 提取的取材清单(**独立 storage key,不进 FIELDS**) | | `records.js` | 运行记录(同上,独立 key) | | `device.js` | 设备能力探测(包名、权限、无障碍) | | `service.js` | 卡密 / 版本 / 更新检查 | | `fingerprint.js` | 配置指纹(与引擎侧同算法,断点续跑用) | | `protocol.js` | ★ 运行期协议:状态枚举 / 迁移表 / 阈值(引擎侧有一份同样的拷贝) | | `engine.js` | ★ 界面配置 → 引擎配置的映射;起引擎;读状态与心跳 | | `session.js` | 运行态(**纯内存**,不落盘),含握手四档文案 | | `resume.js` | 断点续跑(只读引擎写的进度文件) | | `widgets.js` | ★ 控件工厂(`W.*`),所有输入框的唯一入口 | | `dialog.js` | 自绘弹窗(UI 模式禁止阻塞式 dialogs) | | `floatball.js` | ★ 悬浮球(两个 overlay 窗口,见 6.B) | | `page-*.js` | 6 个页面 | | `main.js` | App 装配、路由、生命周期 | ### 3.2 引擎模块(25 个业务模块 + `main` 入口,构建输出 26) ``` logger → protocol → bridge → config → wait → layout → ui → popups → tiktok → player → comment → flow ↓ parse → uidset → filters → profile → idsource → resolve → idflow └──────────── ID 提取(task=id)────────────┘ ↓ main ``` `main.js` 不在依赖图里,它是**入口与收尾**:解析参数 → 按 `config.task` 分派到 `flow.runSession`(视频)或 `idflow.runSession`(ID 提取)→ 保证 `__END__`、关线程、 输出 RESULT、上报终态。 | 模块 | 职责 | 关键点 | |---|---|---| | `wait.js` | ★ 唯一等待原语 | `waitFor` / `poll` / `waitQuiet(settle)` / `tick`(停止检查点) | | `layout.js` | ✦ 无障碍 API 适配层 | **所有平台怪癖都关在这里** | | `ui.js` | 点击四级降级 / 输入+回读 | | | `tiktok.js` | ★ 选择器 + 页面状态机 | `idPageState()` / `pageState()` / 各处 `*Rows()` | | `bridge.js` | 状态上报 / 停止信号 | 未启用时零开销(PC 自检靠这个) | | `parse.js` | 纯函数 | 计数缩写 / 语言判定 / 区间 / 用户名归一 | | `uidset.js` | 去重集合 + 结果文件 | 每 20 个刷盘;只追加不删 | | `filters.js` | 判定纯函数 | **读不到资料 ≠ 不符合** | | `profile.js` | 用户主页导航与读取 | `waitProfile` 两段等待;头像像素判定 | | `idsource.js` | 五种取材源 | 列表型 / 文本型 / 逐个点开型 | | `resolve.js` | username → **数字 uid** | 三级降级,见 6.F | | `idflow.js` | ID 提取编排 | 护栏、停止、收尾 | --- ## 4. 构建与自检 ### 4.1 命令(Windows Git Bash) ```bash export MSYS_NO_PATHCONV=1 NODE="C:/Users/61554/.workbuddy/binaries/node/versions/22.22.2-6/node.exe" # 如果 node 已在 PATH 里,把 $NODE 换成 node 即可 # 改完引擎 cd tiktok-auto && $NODE tools/build.js $NODE tools/pc-selftest-id.js # 期望:通过 114 项 $NODE tools/pc-selftest-bridge.js # 期望:BRIDGE_SELFTEST_PASS(45 项) # 改完界面 cd tiktok-app && $NODE tools/build.js $NODE tools/smoke.js # 期望:通过 526 项 $NODE tools/audit-values.js # 期望:AUDIT_CLEAN # 动过 protocol.js(两侧任意一份) $NODE tools/protocol-check.js # 期望:PROTOCOL_CLEAN(35 项) ``` **★ 顺序不能反**:`smoke.js` / `audit-values.js` require 的是 **dist**, 改了 `src/` 不 build 照样全绿 —— 那是假绿。 **`build.js` 自带的两道检查**(写盘前跑): - 产物语法检查(`new Function(src)`) - 危险调用预检(扫描 `remove(` / `call(` 等会命中 MCP 黑名单的调用) 界面侧还有:入口首行 `"ui";`、确实加载了产物、无 `TODO/FIXME` 标记。 ### 4.2 推到设备 ```bash adb connect <你的云机>:<端口> # 引擎(界面启动时用这个路径) adb push tiktok-auto/dist/tiktok-auto.js \ /sdcard/Scripts/tiktok-demo/tiktok-auto/dist/tiktok-auto.js # 界面 adb push tiktok-app/dist/tiktok-app.js \ /sdcard/Scripts/tiktok-demo/tiktok-app/dist/tiktok-app.js # 启动界面 adb shell "am start -n org.autojs.autoxjs.v7/org.autojs.autojs.external.open.RunIntentActivity \ -d file:///sdcard/Scripts/tiktok-demo/main.js" ``` > ⚠️ **不要用 MCP 下发脚本**(120 秒上限,长脚本会被截断)。用上面的 `am start`。 ### 4.3 核验推送成功 `build.js` 会写时间戳,所以**每次构建的 md5 都不同**——不能拿"md5 变了"当失败。正确做法: ```bash md5sum dist/tiktok-auto.js adb shell "md5sum /sdcard/Scripts/tiktok-demo/tiktok-auto/dist/tiktok-auto.js" # 两边一致才算推上去 ``` ### 4.4 看真机日志 ```bash adb logcat -c # 先清 adb shell "am start -n ... -d file:///sdcard/Scripts/xxx.js" adb logcat -d | grep -E "\[TTA\]|\[app\]" # 引擎 / 界面 # 或者读结构化日志(更好) adb shell "cat /sdcard/tiktok_app/run-log.jsonl" ``` --- ## 5. 硬规则(改代码前必须遵守) ### 5.1 构建层 | 规则 | 后果 | |---|---| | **新增模块前查重名 function** | 单文件拼接,同名 `function` **静默互相覆盖**(真出过:`keysOf` 覆盖了 store 的,把卡密读坏,全程零报错) | | 检查命令 | `for f in src/*.js; do grep -o '^function [a-zA-Z_]*' "$f"; done \| sort \| uniq -d` | | 入口 `"ui";` 必须第一行 | 否则以非 UI 模式执行 + 一堆不着边际的报错 | ### 5.2 界面层 | 规则 | 说明 | |---|---| | **默认值只在 `Store.FIELDS` 定义** | 别处硬编码 = 下次改默认值时漏改 | | 取值只用 `getEnum/getNumber/getText/getBool` | 直接读 storage 会绕过类型归一 | | **一项一控件** | 复合项 → 两行;区间 → 一行两项(`W.rangeRow`,联动只刷兄弟那格) | | **输入框只有 `W.inlineField` 一个入口** | 它负责 `writing` 标志(`try/finally` 复位)、数字框退格不写、失焦提交 + 正规化回写 | | `commitPendingInputs()` 放在 `renderContent()` 与 `currentTask()` | 否则"输入完直接点运行"会丢最后一次编辑 | | **不定长列表用独立 storage key** | 不进 `Store.FIELDS`(那是定长配置表) | | 加枚举 → **两处一起改**(FIELDS 与控件) | 只改一处 = 选项点了没反应 | | `U.spacer()` 不能用于横向布局;不用 `weight=1` 撑标签 | 布局塌 | | 图标只用 `U+25xx` 区段 | 其他字符在设备上可能是豆腐块 | | **UI 模式下禁止阻塞式 `dialogs.*`** | 会静默返回 `undefined`,什么都不弹 | ### 5.3 引擎层 | 规则 | 说明 | |---|---| | **所有等待都走 `wait.*`** | 只有它会在 `tick` 里检查停止信号,业务代码才能零改动支持停止 | | **不允许 `sleep()`** | 同上,`sleep` 不可中断 | | 模块之间**不在加载时取引用** | 一律在函数体内按名字取(`TikTokAuto.xxx.yyy`),所以模块顺序不影响运行 | | 平台怪癖只写在 `layout.js` | 散落到业务里就无法统一适配 | --- ## 6. 坑库(★ 最重要的一节) > 每条格式:**现象 → 根因 → 正解**。 > 这一节里 90% 的坑都「不报错」,只是结果错。改代码前扫一遍你这节要动的部分。 ### 6.A 构建与加载 **A1|同名 function 静默覆盖** - 现象:加了新模块后,某个完全无关的功能坏了,**零报错** - 根因:`build.js` 是单文件拼接,所有模块共享顶层作用域 - 正解:新增模块前跑 5.1 的查重命令;内部辅助函数加模块前缀(`sourcesKeysOf` 而不是 `keysOf`) **A2|`build.js` 每次都改 md5** - 现象:以为推送失败 - 根因:产物写了构建时间戳 - 正解:不要用 md5 判断"是否需要推送",只在推送后**双向核对一致性** ### 6.B AutoX 运行时 **B0|运行中 `uiautomator dump` 会静默返回**上一次**的文件(★ 2026-11 栽过一次)** - 现象:脚本明明在跑、状态条在走,`uiautomator dump` 返回 `rc=0`,读出来的界面却是上一帧 —— 按钮还写着「开始运行」,实际早就是「停止运行」了 - 根因:dump 要等无障碍**空闲**,而运行中的界面每秒刷新一次文字、悬浮球还有 900ms 呼吸环 → 等不到 idle,抛 `ERROR: could not get idle state`。**但命令仍然返回 0,旧文件也仍然在** - 最坑的地方:习惯性写的 `>/dev/null 2>&1` 正好把那条错误吞掉,于是你以为读到了当前界面, 据此做的判断全是错的(我据此误判过"界面没进运行态",其实是读的旧文件) - 正解:**运行中看界面用 `screencap -p` 截图**(截图不依赖 idle),别用 dump; 确实需要 dump 就先结束运行 - 判据:dump 出来的文本里还是「开始运行」、而顶部运行条明明在计时 → 就是读了旧文件 **B1|`app.launchPackage()` 会重置页面(★ 最容易误判)** - 现象:用户手动停在视频评论区,一跑脚本就回到首页,然后报"你不在播放页" - 根因:`launchPackage` 不是"切到前台",而是"重新打开 App"——即使已在前台也会再发 `startActivity`,把叠着播放页/评论面板清掉 - 正解: ```js if (foregroundPackage() === pkg) { return true; } // 已在前台,什么都不做 app.launchPackage(pkg); ``` **B2|Activity 关闭 ≠ 脚本结束** - 现象:界面关了,脚本还在跑(`:script` 进程存活),悬浮球浮在桌面上 - 根因:UI 脚本跑在 `:script` 进程的线程里,Activity finish 不终止脚本 - 正解:`events.on("exit")` + 轮询 `activity.isFinishing()/isDestroyed()` 兜底清理(见 `main.js` 的 `bindLifecycleCleanup`) **B3|overlay 窗口归创建它的进程所有** - 事实:进程被 SIGKILL,Android 会回收它的全部窗口,**不需要代码参与** - 陷阱:`dumpsys window windows` 里窗口名只写包名,**看不出属主进程**,容易误以为"主进程持有" - 正解:想确认残留就用窗口计数 ```bash adb shell "dumpsys window windows | grep -cE 'Window\{.*autojs'" # 启动界面约 6(界面 3 + 球 3);只剩球 3;干净 0 ``` **B4|`http.get` 在这个 AutoX 版本是坏的** - 现象:`TypeError: Cannot find function newCall in object ib.z@...` - 根因:OkHttp 绑定冲突 - 正解:用 Java `HttpURLConnection`,并**区分正常流/错误流**: ```js var stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream(); ``` 只用 `getInputStream()` 的话,4xx 会抛异常,把「404 账号不存在」误判成「网络错误」 **B5|`engines.execScriptFile` 的参数会变成对象** - 现象:`argv[0]` 取不到值 - 根因:`arguments: ["--config", path]` 传进去变成 `{"0":"--config", "1":...}` - 正解:按 `argv[i]` 取,不要当数组用 **B6|子脚本看不到父脚本的全局变量** - 正解:通道只能是文件(见 2.3) **B7|`threads.shutDownAll()` 不波及父脚本的 timer** - 正解:停止要用 `StopSignal`(见 2.4),不要指望关线程 **B8|UI 回调里的异常不报错、不弹窗、不结束脚本** - 正解:回调内部必须自己 `try/catch` 并留痕,否则"事件到了、代码没执行、什么都没有" **B9|`setOnClickListener(null)` 会吃掉点击** - 现象:某块区域点了没反应 - 正解:每块可点区域各挂一次同一个回调,不要用 `null` 占位 **B10|键盘弹起会让整个界面位移** - 现象:脚本点到了键盘的数字键,"造"出一个值 - 正解:每次弹窗/输入后**重新 dump** 取坐标;收键盘用 `keyevent 111`;跑任务时不要 `uiautomator dump`(会撞掉无障碍服务) **B11|悬浮球必须是两个固定几何的窗口** - 现象:点击展开时"先跳一下"(旧画面被摆到新位置) - 根因:改窗口尺寸由系统**立刻**生效,而视图重排要等下一帧,中间几帧就是错位 - 正解:**两个 overlay 窗口、尺寸位置恒定**,展开/收起只改显隐和 flag,不改任何几何 - 附带:必须走裸 `WindowManager`(`floaty` 有去不掉的 12dp 内边距、同时只能绘制一个);必须带 `FLAG_LAYOUT_IN_SCREEN`;**球窗口要后加**(同类型按加入顺序叠放) **B12|`requestScreenCapture(false)` 是静默的** - 事实:AutoX 的无障碍服务带 `CAPABILITY_CAN_TAKE_SCREENSHOT`,申请当场返回 `true`,不弹 MediaProjection 框 - 陷阱:**不申请**就直接 `captureScreen()` 会抛 `No screen capture permission` - 正解:第一次用之前申请一次,结果缓存;失败只提示一次(别每个账号刷一行错误) ### 6.C 页面判定与选择器 **C1|`settle()`(waitQuiet)会提前返回(★ 高频)** - 现象:点开列表/切页签后立刻报"没读到行",但人眼明明看到了 - 根因:它的判据是"可见文案连续两次不变",而**加载中的页面和打开前的签名一样**(骨架先出、行后加载) - 正解:**凡是"点完要等新内容",判据必须是"新内容真的出现了"** ```js waitFor('rows', () => rows().length > 0 ? true : null, timeout, tick) ``` **C2|数字会先渲染成占位的 0(★)** - 现象:同一个账号两次运行分别读到粉丝 `78` 和 `0` - 根因:页面先渲染骨架再填数字 - 正解:**连续两次读到一致才采信**;三组全 0 是可疑值,再等 1.5 秒复核 - 关联:这直接决定数量门槛的判定(读成 0 会被"粉丝≥1000"筛掉) **C3|两种"用户列表页"的行结构不同** | 页面 | uid 在 | 行解析器 | |---|---|---| | 粉丝/关注列表 | `TextView.txt_desc` | `userListRows()` | | 搜索结果用户页 | `TextView.tv_username` | `usersResultRows()` | - 现象:搜索取材永远 0 行,日志里 `userRows 就绪` 和 `listRows 超时` 同时出现 - 根因:`profile.listRows()` 写死成粉丝列表那个解析器 - 正解:按当前页面分派(`isListPage()` 同时认两种页) **C4|点列表行不能用 `node.click()`** - 根因:它落在容器**中心**,那里常是空白,有时是右侧 Follow 按钮邻域(误触会把用户**关注**掉) - 正解:按坐标点**行左侧 30%**(用户名文字上) **C5|用户名被 FID 隔离符包裹** - 事实:`\u2068` / `\u2069`(肉眼不可见),`layout.sanitize` **不剥**(那不是零宽字符) - 正解:`parse.username()` 里单独剥 `[\u2066-\u2069\u202a-\u202e]`,再剥 `@` **C6|这一片的资源 id 是语义化的** - 与信息流那批混淆 id(`e4g`/`cn6`)**完全不同**,可以直接按 id 定位(见第 8 节) **C7|用户页没有"视频数"** - 事实:只有 关注 / 粉丝 / 获赞 三组数字 - 正解:视频量门槛只能"放行 + 告知",不能当过滤条件 **C8|评论区只有昵称、没有 uid** - 正解:评论者必须**逐个点头像**进主页(约 5~8 秒/个)。这就是为什么"评论者"和"评论者的粉丝"在界面上是两条独立取材 **C9|头像只能靠像素法** - 事实:头像 `ImageView` 无 text / 无 desc / 不可点 - 正解:5×5 采样算高饱和像素占比。实测默认剪影 `0.040`、真头像 `0.600`,阈值 `0.12` - 局限:**纯黑白照片头像会误判**,运行时如实告知 **C10|首次打开粉丝列表会弹 Facebook 授权窗** - 文案 `Don’t allow`(撇号是 `U+2019`),会盖住整个列表 - 正解:`openList` 里专门点掉它再等一次 **C11|找入口失败要重试,不要立刻报错** - 现象:从搜索结果进主页后报"找不到 fans 入口",重跑又正常 - 根因:标题出来了但数字区还在渲染,`statEntry()` 返回 null - 正解:等 3 秒再找一次 **C12|点赞按钮与分享按钮共用同一个 resource-id** - 事实:两个都是 `g8g`,只有 desc 不同 - 正解:**只能按 desc 定位**,不要用 id - 状态判据:未赞 `Like video.` 开头;已赞 desc 变成 `Video liked`、且里面 ImageView 的 `selected=true` (两个判据都留:desc 受本地化影响,selected 不受文案影响) **C13|关注按钮"消失"就是已关注** - 事实:关注后 `Follow <作者名>` 这个 Button **整个不存在了**(不是变成 `Following`) - 陷阱:页面没渲染完也找不到按钮 —— 两者必须区分 - 正解:用作者头像(desc 形如 `<作者名> profile`)的存在性做交叉判断: 头像在 + 按钮不在 = 已关注;两个都不在 = 页面还没好 - 另外:首页顶部那个 `Following` 页签是 **FrameLayout**,限定 `className('android.widget.Button')` 就能排除 **C14|卡片"看得见"要看容器,不能看文案(★ 2026-10-28,这条让 `cat` 从 0 个变成 2 个)** - 现象:关键词 `cat` 报「结果页已无更多卡片」,但页面上明明有卡片;`fashion` 正常 - 根因:结果卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 `[8,1242]→[536,1920+]`)。 结果页上只要多一条通栏广告或一行筛选 chips,网格被推下去,文案就落到**屏幕外**(实测 y=2099 > 1920)。 旧判据 `bottomOf(node) <= H - 12` 于是把这些**看得见、点得着**的卡片全丢掉 - 正解:文案照读(无障碍树里有,只是不在可视区),**能不能点由容器决定**: 容器与屏幕要有实质交集(露出 ≥8% 屏高);点击点由 `layout.center` 用 `visibleRect` 夹取,永远在屏内 - 配套:`cardContainer` 的高度上限从 80% 放宽到 **105%** —— 卡片贴边时 `bounds()` 会被裁切得又大又怪, 卡在 80% 上会把整排卡片判成"没有容器" **C15|结果页混着三种"长得像视频卡"的东西** | 类型 | 点开之后 | 处理 | |---|---|---| | 广告**视频** | 正常播放页(带 `Ad` + `Shop now`) | 正常看,能点赞/关注 | | **照片轮播帖** | 照片查看器,**没有播放页** | 用信息行的 `Photo` 标记提前判掉(约 2 秒,别等满 20 秒) | | **商品/带货卡** | 商品详情页(`Add to cart` / `Buy Now`) | 打不开就当"非视频"跳过 | - 判据:照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView,视频帖是「`Video` · …」; 限定**文案完全等于** `Photo` 且在下半屏,避免误伤正文里出现 Photo 的视频 - **打不开 ≠ 出错**:这类跳过只记 warning,不进 `errors`(否则"4 个视频里有 1 张照片帖"看起来像故障) - 卡片用尽同理:**一个都没收到才报错**,收到过就记 warning 并给出"换关键词/调小目标"的建议 **C16|浮层按钮会冒充卡片** - 现象:授权弹窗的 `Customize Settings` / `Confirm My Choices` 被当成卡片点开,白等 20 秒 - 正解:两条硬判据 —— ① 节点必须在 `GridView`/`RecyclerView` 里(`insideResultGrid()`) ② 必须能找到"卡片尺寸"的可点容器(找不到时 `cardContainer` 会退回节点本身,那就不是卡片) ### 6.D 流程与状态机 **D0|心跳必须是**独立线程**,而且不能借道 `checkStop` / `report`(★ 2026-11)** - 现象:界面在引擎做 25 秒 watch 时无法判断"它还活着",只能靠 4 秒超时猜,误判成"引擎已退出" - 根因:状态只在主流程调 `report()` 时才写,长等待期间一片空白 - 正解:`bridge.heartbeatStart()` 起独立 `threads.start`,每 2 秒**整份重写** `engine-beat.json` - ★ 三条不能破的规矩: 1. **不许调 `checkStop()`** —— 命中时它会 `report(stopping)` + 抛 `StopSignal`;抛进心跳线程,收尾那 10 秒就再没人写心跳(那正是最需要它的时候) 2. **不许调 `report()`** —— `last` 是跨线程共享的可变对象、整份覆盖写,借道会把 `phase` 写回旧值 3. **用裸 `sleep`** —— `wait.tick` 同理会抛 `StopSignal`。这是 `wait.js` 那条"裸 sleep 只能出现在本文件"的唯一受控例外 - ★ `stopSeenAt` 必须 **sticky**:心跳是整份覆盖写,每拍都从 0 开始的话,2 秒后就把"看见了停止请求"抹掉,而界面是 1 Hz 轮询 —— 有概率整整错过一拍 **D1|阶段性的"停止"不能共用(★ 踩了两次)** - 现象 ①:设了「提取满 3 个」,抓满后 uid 解析一个都换不到 - 现象 ②:抓取用满 30 分钟,83 个 uid 全部没换 - 根因:`shouldStop() = limitReached() || timeUp()`,而**抓取阶段的"够了/到点了"与收尾阶段的"必须做完"是相反的要求** - 正解:收尾阶段只继承**外部强制**(用户叫停、自己的时长预算),不继承**业务完成**条件 **D2|不要在入口做全局的页面清理** - 现象:「当前屏幕」取材必然失败 - 根因:`runSession` 一启动就 `ensureFeed` 把页面退回首页,而各取材源自己会导航 - 正解:**清理动作放在真正需要它的地方**。`anchor`/`search` 走 `prepareSearch`(内部有 ensureFeed);`screen`/`video(screen)` **不动页面**;`text` 不需要 UI **D3|深度展开要分两阶段** - 根因:边遍历边展开会嵌套两层列表上下文,打开 B 列表时 A 的滚动位置就丢了 - 正解:先收完基础层并记下"这些人要展开",再逐个打开 **D4|"读不到资料" ≠ "不符合"** - 正解: - 设了门槛却**读不到** → 拦下(否则等于"打不开的人全都通过") - 平台**根本不提供**(视频数)→ 放行 + 告知 **D5|"不限"必须有物理护栏** - `limit = 0` 的语义是"不限",但没有上限等于跑到没电 - 正解:抓取时长预算 + 单列表最大翻屏数 + 连续 2 屏无新 uid 判到底;到点时**如实报告** **D6|时长预算要按"个数"反算,不能拍一个固定值** ``` 预算 = clamp(max(用户设的下限, limit × 20秒 + 5分钟), 4小时) ``` - 20 秒是"每个**收录**"的折算(含被跳过的),实测收录率约 37% - 解析阶段另有独立预算:`count × 3秒 + 1分钟` **D7|点"已选中"的页签会让列表重刷(★)** - 现象:卡片读到了、下一句又变 0,最后报「已无更多卡片」 - 根因:「Top」是结果页默认页签,而默认配置的 `resultTab` 也是 Top —— 等于每次都点一次当前页签。 TikTok 会因此**重刷整个网格**,而 `waitQuiet` 判不出"正在重刷"(签名和点之前一样) - 正解:`resultTabSelected()` 已经是目标页签就**跳过点击**;真要切页签时,判据必须是"卡片重新出现了" - 顺带:主循环读到 0 张卡时**先等 5 秒再滚**(真的到底时不该白等满 20 秒) **D8|去重只按文案会死循环** - 现象:同一张照片帖被反复点开、反复跳过,直到 guard 用完 - 根因:同一张卡的文案在不同时刻读到**不同的截断**(`swagger #fashion …` vs `… #fashion #fyp`), 按 caption 去重认不出是同一张;而 `chooseCard` 还有"全都试过就退回全量再挑"的兜底 —— 那就是死循环入口 - 正解:caption + **容器矩形**双重去重(容差 40px);全试过时返回 null,由调用方决定滚动还是收工 **D9|断点续跑是「条目级」,不是「位置级」(★ 2026-10-28)** - 为什么不做屏/行游标:TikTok 列表内容会变、滚动位置无法复原。记"第几屏第几行" 只会漏掉别人或重复劳动;「哪条取材走完了」才是稳定事实 - 未完成的取材从头重走,靠已收集合跳过重复的人(列表型几乎零成本, 视频作者/评论者要 6~8 秒/个,必须在提示条与日志里如实写) - 断点文件 `<结果目录>/id_progress.json`,`state ∈ running/stopped/done/none`; **清空断点 = 覆盖写 `state:'none'`**,绝不删文件(`remove(` 命中危险黑名单) - 配置指纹覆盖 `sources/filter/output`;**不覆盖** `limit/resolveUid/dryRun/filter.videos/guard` —— 多算一个字段,用户改个"至少运行几分钟"就会白丢断点 - 判定顺序:无断点 → 已跑完 → 演练 → 配置变 → 结果文件没了 → 才允许续; **全部在 `launchApp` 之前**,拒绝时零成本,不折腾用户手机 **D10|预载去重集合会让 walkList 误判"列表到底"(★ 真 bug)** - 现象:跨轮去重第二轮跑同一账号,一个新人都不收;续跑更是必然如此 - 根因:`idle` 同时被"本屏没有新 uid"和"翻屏后签名不变"累加; 预载历史后前两屏全是旧人 → `idle=2` → 在列表顶部就收工 - 正解:**只认"翻不翻得动"**(`walkStep` 纯函数,PC 可回归)。 一屏全是旧人只说明"旧人多",不代表列表到头 - 连带:`cardContainer` 那类"上限 80%"的经验在这里同样适用 —— 判据要落在**物理事实**(滚不动了)上,而不是"业务上有没有新东西" **D11|失败的那条取材不能标 `done`** - `idsource.run` 打不开列表时记 error 后**返回 0、不抛异常** - 一律标 done 的后果:续跑再也不会重试它,用户以为"这条取材就是没数据" - 正解:本轮新增了 error → 保持 `running`(下次再试);否则 → `done`; 另外"没抛错但一个人都没取到"要单独给一条 warning(列表空 / 「当前屏幕」页面不对) **D12|`Engine.prepare` 的校验必须按任务分支(★ 新增任务必查)** - 现象:养号点「开始运行」→ **脚本直接退出、界面回桌面,零提示**,日志文件一个都没生成 - 根因:`prepare` 末尾那句「关键词为空」的校验读 `built.config.target.keyword`, 而养号的配置里**没有 `target`**(它没有搜索目标)→ `TypeError: Cannot read property "keyword" from undefined` - 只有 logcat 能看到:`ScriptEngineService: TypeError ... tiktok-app.js#3270` - 正解:新增任务时,`Engine.prepare` 必须像 `main.js` 的 `runByTask` 一样加分支早返回。 smoke 里有一条 `养号 prepare 不抛异常` 专防这个回归(PC 上会得到同一个异常) **D13|AutoX 里 `gesture()` 只在主脚本有效(★ 别指望它)** 同一坐标、同一时机,三种点击方式的实测: | 方式 | 主脚本(RunIntentActivity 直跑) | 子脚本(`execScriptFile`) | |---|---|---| | `gesture(120, [[x,y],[x,y]])` | ✅ | ❌ **无效** | | `click(x, y)` 无障碍坐标 | ✅ | ✅ | | `shell('input tap x y')` | — | ❌ `SecurityException: Injecting input event` | | `shell('input tap x y', true)` | — | ❌ `Error opening shell: 'su'` | **引擎永远是子脚本**,所以只有无障碍点击可用。曾一度误判「TikTok 吞掉了坐标点击」而把 `ui.tapPoint` 改成 `gesture`,方向是错的——真正的故障是键盘没弹导致输入框判据不命中。 **D14|评论发送键在无障碍树里不存在** - 首页信息流的评论面板打开后,右侧竖排动作栏的节点**仍在树里**且与输入框同高 - 把输入框整行右侧的所有节点 dump 过:只有动作栏那六个 + 每条评论自己的赞/表情 + 作者头像, **没有发送键**(截图上那个粉色 ↑ 是自绘的,不暴露给无障碍) - 后果:评论发不出去。视频链路也受影响(同一个键)——这是 TikTok 改版的既有回归 - 曾经的假阳性:按「输入框下方取最右」会稳定命中**点赞按钮**(`id=g8g`), 点它 → 面板收起 + 视频被点赞 → 业务侧判成「评论成功」。现在已排除动作栏 + 严格验证 **D15|`force-stop` AutoX 会让无障碍服务「enabled but not running」** - 反复 `am force-stop org.autojs.autoxjs.v7` 之后,服务停在那里不恢复, 界面弹「无法开始运行 · 无障碍服务未开启」,脚本报 `Accessibility service is enabled but not running, this may be an Android bug` - 恢复办法(不用重启手机): ```bash adb shell "settings put secure enabled_accessibility_services '其他服务'" adb shell "settings put secure enabled_accessibility_services 'org.autojs.autoxjs.v7/com.story.real.store.service.PeAccessibilityService:其他服务'" ``` 即「移除再加回」触发系统重绑 - **测试期建议**:重跑脚本用 `am start ... RunIntentActivity -d file://...` 覆盖, **不要**用 force-stop **D16|`MIGRATIONS` 的键是「从哪个版本升」,不是「升到哪个版本」(★ 静默跳过)** - 执行逻辑是 `for (v = fromVersion; v < SCHEMA_VERSION; v++) MIGRATIONS[v]` - 所以「v8 → v9」的迁移要写成 `8:`。写成 `9:` 时:`fromVersion=8` 查的是不存在的 `MIGRATIONS[8]` → **静默跳过**,设备被标成 v9 而值还是旧的 - 排查成本很高:迁移函数本身完全正确(PC 上单测通过),只是永远不会被调用 - 补救:把 `SCHEMA_VERSION` 再抬一版,迁移键写 `9:`(v9→v10),让老设备补执行 - ★ **还有第二层坑(v11 才真正修好)**:判断写成 `restMin === 100`,而设备上存的可能是 字符串 `"100"` → 匹配不上 → 迁移"跑了但没改",**版本却被抬上去**了,从此永久卡在旧值。 症状是「界面写 100~500、引擎按 10~40 跑」—— 两边不一致,比单纯显示旧默认更难查。 正解:条件用 `Number(...)` 比较,并且**抬版本时一律加一条补执行的迁移** (只认那一对精确的旧默认值,用户自己调过的不动) **D17|`Store.load()` 迁移后不写回存储** - `load()` 内部会跑迁移并修正非法值,但只返回内存 config、**没有 `Store.save()`** - 后果:这次启动迁移过了,下次启动又从旧值重新迁移;更糟的是引擎读配置时可能拿到 「内存已改、存储未改」的两份不一致数据 - 修法:在 `Store.init()` 里 `load()` 之后补一次 `Store.save()`(此时 `state` 已设好,不会递归) - **`Store.load()` 里不能直接 save** —— 那时 `Store.state` 还没赋值,`save` 会回头调 `init` 死循环 **D18|AutoX 会按「入口脚本路径」复用旧代码(★ 推了新版却没生效的真因)** - 现象:`adb push` 了新产物、**md5 与本地完全一致**,但界面行为/数值还是旧的 (实测:只渲染出一张脚本卡、私信休息时间仍显示 100~500) - 真因:入口还是 `/sdcard/Scripts/tiktok-demo/main.js` 这个**路径**,AutoX 复用了 上一次的脚本;把入口复制成 `main2.js` 再启动,**立刻**变成新行为 - 判据/做法:确认"产物已更新但行为没变"时,**换一个入口文件名**启动一次 (`adb shell cp main.js main3.js` 再 `am start ... -d file:///.../main3.js`), 或者先在 AutoX 里把项目关掉重开 - ⚠️ 顺带纠正一条早期结论:设备时区是 PDT,`ls -la` 的 mtime 与主机 `date` **差 15 小时**。 拿主机时间比设备 mtime 会得出"这是 15 小时前的旧文件"的错误结论(曾经就这么误判过)。 判断"文件是不是刚写的"要用 `adb shell date` 取设备时间,或直接比 `md5sum` **D19|脚本二取材:必须等主页三组数字读全,`settle()` 挡不住** - 现象:点底部 Profile 后 1 秒 `where() === 'profile'` 就通过,紧接着 `openList('fans')` 报「用户主页上找不到 fans 入口(等了 3 秒仍没有)」 - 根因:username 比数字区早得多,而 fans 入口挂在数字上;`settle()` 的判据是 "文案连续两次不变",**加载中与渲染完的签名可能一样** → 提前返回 - 正解:复用 `profile.waitProfile()`(判据是 username + 关注/粉丝/获赞**三组都读到**, 读不到只警告不失败),再 `openList`;`openList` 失败还给它一次 2 秒后的重试 **D20|「脚本卡单选互斥」必须由页面做** - `W.scriptHeader` 只管画和报"点完该是什么状态",**互斥是页面的责任**(组件注释写明了) - 私信页漏了这一步:两个脚本可以同时勾上 → `buildDmConfig` 只能挑一个跑 → 用户以为跑的是「自己粉丝私信」,实际跑「已提 UID 私信」,拿一份可能过期的 UID 文件发出去, **界面上看不出任何异常** - 正解:`toggleDmScript(key, next)` 勾一个就清另一个;并在 `buildDmConfig` 里 "两个都勾着"时给一条 note(不要静默挑一个) **D21|私信发送键依赖「键盘弹起」,拿不到就再点一次输入框** - 发送键是"输入框缩短后空出来的那块 72×72 ImageView",只在键盘弹起时存在 - 而 `ui.typeInto` 的那次点击**有时被输入法吞掉**(文字进去了、键盘没起), 于是发送键不存在 → 报「输入后找不到发送键」,**时好时坏** - 正解:`ensureSendButton()` 先等 1.5 秒;拿不到就按输入框中心再点一次强制聚焦,再等 4 秒 ### 6.E 数据与配置 | 编号 | 现象 | 正解 | |---|---|---| | E1 | 改了默认值但界面没变 | 默认值只在 `Store.FIELDS`;改完看是否需要版本迁移 | | E2 | 控件改完重启又变回旧值 | 检查是否漏了 `commitPendingInputs`,或字段路径拼错(**拼错 = 静默丢值**) | | E3 | 某个颜色突然没了 | `Theme` 令牌**缺键 = 静默的视觉删除** | | E4 | 弹窗点"确定"后界面错乱 | 弹窗"确定"不要预先关窗,先执行动作再关 | | E5 | 新加的枚举选项点了没用 | 枚举要**两处一起改**(FIELDS + 控件) | | E6 | 空串默认值补不上 | 只有**版本迁移**能补(`Store.MIGRATIONS`) | | E7 | 函数返回值少一个字段 | 会**静默错配**,调用方拿到 `undefined` 继续跑 | ### 6.F 网络与接口(username → 数字 uid) 三级降级,**每条只探一次**(第一个账号证明不可用后,本轮不再试): | 级 | 路径 | 2026-10-20 实测 | |---|---|---| | ① | `www.tiktok.com/api/user/detail/?uniqueId=` | **200 但响应体为空**(风控静默拒绝,最容易被误判成"接口还好") | | ② | `www.tiktok.com/node/share/user/@handle` | **403** | | ③ | `www.tiktok.com/@handle` 整页 SSR | ✅ 200 / 约 388KB / 约 2 秒 / `JSON.parse` 仅 7ms | **走不通的路(别再试)**: | 路径 | 结果 | |---|---| | `tikwm.com/api/user/info` | Cloudflare 挑战页 | | `m.tiktok.com/@user` | 404(那条短链只认 `/v/.html`) | | `www.tiktok.com/oembed` | 只有昵称与 handle,**没有数字 id** | | AutoX 的 `http.get` | 本机坏的(见 B4) | **三个概念不要混**: | | 例子 | 界面上可见 | 用途 | |---|---|---|---| | uniqueId / handle | `beautifulsoul78869` | ✅ | 能搜到人、能 @ | | nickname | `🌹Beautiful Soul💀` | ✅ | 显示名,可重名可改 | | **id(数字 uid)** | `254801657882882048` | ❌ | 平台主键,对接/入库要它 | --- ## 7. 常见改动的标准步骤 ### 7.1 界面加一个配置项 1. `store.js` 的 `FIELDS` 加一条(**唯一默认值来源**) 2. `page-*.js` 加控件(输入框只用 `W.inlineField` / `W.numberField`) 3. `engine.js` 的 `buildConfig`/`buildIdConfig` 加映射 4. `tools/smoke.js` 补断言: - 字段总数(`ID提取功能设置是 23 个字段`) - **映射正确**(改值 → 引擎配置里出现对应值) 5. `$NODE tools/build.js && $NODE tools/smoke.js` 6. 推设备 + 真机确认落盘、重启后读回 > 第 4 步的"映射断言"很关键:只断言字段数不变,映射写错了照样全绿。 ### 7.2 引擎加一个取材源 1. `idsource.js` 里加分支,**先判断前置状态** 2. 选遍历形态: - 列表型(能逐屏读行)→ `walkList` - 文本型(一次性)→ 直接逐行 `ctx.onRow` - 逐个点开型(只有昵称)→ 点进去读 3. **去重与过滤交给 `ctx.onRow`**,不要在源里自己做(否则 `duplicates` 统计永远是 0) 4. 界面 `sources.js` 加对应 kind + `page-id.js` 加选项 5. 补 `pc-selftest-id.js`(纯函数部分)与 `smoke.js`(映射部分) ### 7.3 改选择器 1. 先跑 `_probe/probe-dump.js` 在真机上 dump 当前页面 2. 改 `tiktok.js` 里对应的 `*Rows()` / `*Node()` 3. **优先按资源 id,文案只作兜底**(文案有本地化和 A/B 风险) 4. 真机小样本验证(3~5 个)再批量 --- ## 8. 平台实测锚点(TikTok 47.1.4 / 1080×1920) > 这一片的 id 是**语义化**的,和信息流那批混淆 id 完全不同。 | 页面 | 控件 | 内容 | |---|---|---| | 用户主页 | `Button` 文案 `@xxx` | **uid/handle**(被 `\u2068/\u2069` 包裹) | | | `TextView` 两两成组(`t_r` 数字 / `t_q` 标签) | Following / Followers / Likes | | | `Button`(长文本) | 个签 | | 粉丝/关注列表 | `TextView.txt_desc` | **uid** | | | `TextView.txt_user_name` | 昵称 | | | 顶部三页签 `Following N`/`Followers N`/`Suggested` | 可直接切,不必退回主页 | | 搜索用户列表 | `TextView.tv_username` | **uid** | | | `TextView.tv_aweme_id` | 昵称(**id 名违反直觉**) | | | `TextView.tv_desc` | `594 followers · 2.5K likes`(行内就有计数) | | 播放页 | 底部 `Button` 文案 `@xxx` | 作者 uid | | 播放页·点赞 | `Button` desc `Like video. 2.4M likes` | 未赞;里面 ImageView `selected=false` | | 播放页·已赞 | 同一个 `Button` desc → **`Video liked`** | ImageView `selected=**true**`(不是 `Unlike`) | | 播放页·关注 | `Button` desc `Follow <作者名>` | 已关注时**整个按钮消失**(不是改文案) | | 播放页·作者头像 | `ImageView` desc `<作者名> profile` | 用来区分「已关注」与「页面没渲染完」 | | 评论区 | `Button.title` | 只有昵称,**没有 uid** | | | `ImageView.blm`(约 72×72,左侧) | 评论者头像(可点进主页) | | 用户主页头像 | `ImageView` 192×192 @ `[856,168][1048,360]` | 无 text/desc/不可点 → 像素法 | **三条导航事实**: 1. 从列表进用户页再返回,**列表位置保持**(首行与坐标都不变)→ 可以"读一屏→逐个进→退回来接着读" 2. 粉丝列表是从主页点进来的**覆盖页**,顶部三页签可直接切 3. 首次打开粉丝列表会弹 Facebook 授权窗(见 C10) **性能实测**(决定取材怎么设计): | 模式 | 速度 | 说明 | |---|---|---| | 不过滤 | ~1 秒/个 | 直接从列表抄 uid,不开用户页 | | 开过滤 | ~8 秒/个 | 每个候选都要开用户页读资料 | | 评论者 | ~5~8 秒/个 | 只有昵称,必须点头像进主页 | | 换数字 uid | ~2.5 秒/个 | 要下整页 SSR | --- ## 9. 已知局限(写在文档里,别当 bug 修) | 项 | 情况 | |---|---| | 无头像判定 | 像素法,**纯黑白照片头像会误判**,运行时已告知 | | 视频量门槛 | TikTok 用户页不显示视频数 → 放行 + 告知 | | 提取区域(国家) | 用户页不显示国家,没有可靠数据源 → 不做 | | AI 画像(性别/年龄) | 需要模型 → 不做 | | 云端去重 | 服务端未接入 → 降级本地 + 告知 | | 登录态 | **对抓取零贡献**,相关代码与配置项已删,不要重新引入 | | 暂停/恢复 | AutoX 的 `ScriptEngine` 只有 `forceStop()`,没有 pause → 只能协作式实现。**已决定不做**(2026-10-28),不要再实现,见 `PLAN_pause_resume.md` | | 单跑 | 同一台手机物理上无法并发操作一个 App | | `:script` 进程 | 界面退出后进程会留着空转(AutoX 自己的行为),不影响使用 | --- ## 10. 排障速查 | 症状 | 先查 | |---|---| | 界面白屏 / 一堆怪错 | `main.js` 第一行是不是 `"ui";`;`main.js` 是否真的 require 了产物 | | 点「开始运行」被弹窗拦下 | 校验顺序:卡密 → 权限 → 已选脚本 → 引擎是否接入 → 是否在跑 | | 引擎起来了但页面全 unknown | TikTok 不在前台(引擎必须待在前台) | | 取材 0 行 | ① 页面对不对(`screen` 源要求停在列表/评论区)② 解析器是否分派对了(C3)③ 首屏是否还没加载完(C1) | | 数量读成 0 | 占位值(C2) | | 该收的没收 | 过滤条件 + 日志里的"跳过 @xxx:原因" | | uid 没生成 | 是否被 limit/timeUp 掐断(D1);看日志最后三行 | | 悬浮球残留 | 数窗口(B3);`后台保留` 是设计,彻底退出用「我的」→ 退出应用 | | 推送后行为没变 | md5 双向核对(4.3);是不是推错了路径(界面 vs 引擎) | **读日志的正确姿势**:优先读 `/sdcard/tiktok_app/run-log.jsonl`(结构化),比 logcat 干净。 --- ## 11. 给 AI 的操作规程 ### 11.1 动手前 1. 读第 5 节(硬规则)+ 你要动的那个模块在 3.1/3.2 的"关键点" 2. 扫一遍第 6 节里**和你改动相关**的部分(尤其 C、D 两组) 3. 确认这件事在不在第 9 节的"已知局限"里——**在里面就别修**,那是刻意不做的 ### 11.2 改的时候 - **改默认值只改 `Store.FIELDS`**,别在页面里硬编码 - **加等待一律用 `wait.waitFor` 且判据是"新内容出现"**,不要用 `settle` 等"安静" - **加停止检查点**:长循环里调 `ctx.shouldStop()` 或 `wait.tick()` - **失败要可诊断**:日志里写清"为什么跳过",不要只写"跳过" - **不要造假数据**:读不到就返回 `null`,由上层决定拦还是放行 ### 11.3 交付前(缺一不可) ```bash # 引擎改动 cd tiktok-auto && $NODE tools/build.js && $NODE tools/pc-selftest-id.js && $NODE tools/pc-selftest-bridge.js # 界面改动 cd tiktok-app && $NODE tools/build.js && $NODE tools/smoke.js && $NODE tools/audit-values.js # 协议改动(protocol.js 任一侧) $NODE tools/protocol-check.js ``` - 断言失败时**先判断是回归还是断言需要更新**(例如新增一个基础设施计时器会让"计时器数量"断言从 1 变 2——那是断言要改,不是功能坏了) - 涉及真机行为的改动,**必须真机验证**(PC 自检只能验结构,验不了"页面上真正发生了什么") ### 11.4 报告结果时 说清楚三件事: 1. **改了什么**(文件 + 行为) 2. **怎么验证的**(哪条命令 / 哪段日志 / 真机看到什么) 3. **没解决的**(明确列出,不要含糊过去) --- ## 附录 A:术语表 | 词 | 含义 | |---|---| | 取材源(source) | 从哪儿找人:指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕 | | uid(本项目语境) | 通常指 **handle**(`@xxx`);"数字 uid" 会明确写"数字" | | 收录 / 跳过 / 重复 | 通过过滤 / 被过滤 / 已在去重集合里 | | 深度展开 | 对已收到的人"再取他的粉丝列表",最多 2 层 | | 收尾阶段 | 抓取完成后的工作(当前是:username → 数字 uid) | | 护栏 | 物理上限(时长、翻屏数),防止"不限"跑到没电 | ## 附录 B:历史文档 | 文件 | 内容 | 状态 | |---|---|---| | `PLAN_id_extract.md` | ID 提取执行方案 | 已实施 | | `REQ_id_extract_v2.md` | 三段式需求重整(38 字段的逐条处置) | 已实施 | | `PLAN_comment_completeness.md` | 评论完整性 | 未实施 | | `PLAN_pause_resume.md` | 协作式暂停 | **已取消**(用户决定不做,只作历史记录) | | `README.md` / `tiktok-app/README.md` / `tiktok-auto/README.md` | 各模块详细设计与实测记录 | 持续更新 | > 冲突时以**本文档**为准(它是最新实测结论的汇总)。