Browse Source

新增可迁移的 AI 上下文文档(供 VS Code / GitHub Copilot 使用)

目的:把项目的架构、硬规则、约 40 条实测坑整理成自包含文档,
让没有历史对话的 AI 能安全改代码,不重蹈覆辙。

两份,分工不同:

.github/copilot-instructions.md(83 行)
  Copilot 会自动加载它,所以保持简短:构建命令、8 条硬规则、
  11 条高频坑、不要做的事、交付要求、文档索引。

docs/AI_CONTEXT.md(696 行,11 节 + 2 附录)
  完整上下文,按需查阅:
  0 怎么用 / 1 项目速览 / 2 架构与通信链路 / 3 目录地图与模块表
  4 构建与自检 / 5 硬规则 / 6 坑库(★ 分 6 组约 40 条)
  7 常见改动的标准步骤 / 8 平台实测锚点 / 9 已知局限
  10 排障速查 / 11 给 AI 的操作规程 / 附录术语与历史文档

坑库的组织方式:每条写「现象 → 根因 → 正解」,并按主题分组
(构建加载 / AutoX 运行时 / 页面判定与选择器 / 流程与状态机 /
数据配置 / 网络接口)。因为这一节的坑 90% 都「不报错,只是结果错」,
所以它比硬规则更重要。

特意写进去的三类内容:
- 不要做的事:不引登录态、不做视频量门槛、不做国家判定、不用 MCP 下发长脚本
- 已知局限 ≠ bug:无头像像素法会误判黑白照片、视频数平台不提供等
- 断言失败时先判断是回归还是断言该更新(例如新增基础设施计时器会让
  "计时器数量"断言从 1 变 2)

写之前核准过的事实:引擎 18 业务模块 + main、界面 19 模块、
smoke 414 项、pc-selftest 68 项、AUDIT_CLEAN、FIELDS 68 条、
TikTok 47.1.4 的实测锚点与各模式速度(1s / 8s / 5-8s / 2.5s 每个)。
文档里不含任何真实卡密或密钥。
梦境 1 tuần trước cách đây
mục cha
commit
3f8fe4c670
2 tập tin đã thay đổi với 785 bổ sung và 0 xóa
  1. 83 0
      .github/copilot-instructions.md
  2. 702 0
      docs/AI_CONTEXT.md

+ 83 - 0
.github/copilot-instructions.md

@@ -0,0 +1,83 @@
+# Copilot 工作指令 — tiktok-scripts
+
+> 自动加载的精简规则。**完整上下文见 `docs/AI_CONTEXT.md`**(改代码前请读对应章节)。
+
+## 这是什么
+
+给 TikTok 做自动化的 AutoX 项目,三层:
+
+| 层 | 目录 | 产物 | 备注 |
+|---|---|---|---|
+| 界面(UI 模式) | `tiktok-app/src/` | `dist/tiktok-app.js` | 19 模块 |
+| 引擎(无界面) | `tiktok-auto/src/` | `dist/tiktok-auto.js` | 18 业务模块 + `main` 入口;两个 task:`video` / `id` |
+| 独立脚本 | 根目录 `tiktok_stats.js` 等 | 不构建 | PC Node 跑 |
+
+构建 = **单文件拼接**(AutoX 只能下发一整段脚本,不支持 require)。
+
+## 构建与自检(改完必跑)
+
+```bash
+NODE="C:/Users/61554/.workbuddy/binaries/node/versions/22.22.2-6/node.exe"  # 或直接用 node
+
+cd tiktok-auto && $NODE tools/build.js && $NODE tools/pc-selftest-id.js   # 期望 68 项通过
+cd tiktok-app  && $NODE tools/build.js && $NODE tools/smoke.js && $NODE tools/audit-values.js
+                                                                          # 期望 414 项通过 + AUDIT_CLEAN
+```
+
+## 硬规则(违反会静默出问题)
+
+1. **新增模块前查重名函数**:`for f in src/*.js; do grep -o '^function [a-zA-Z_]*' "$f"; done | sort | uniq -d`
+   单文件拼接下同名 `function` 会**静默互相覆盖**。
+2. **默认值只在 `tiktok-app/src/store.js` 的 `FIELDS` 定义**,别处禁止硬编码。
+3. **输入框只走 `W.inlineField` / `W.numberField`**,不要自己造 EditText。
+4. **等待一律用 `wait.waitFor`**,禁止 `sleep()`;判据必须是「新内容真的出现了」,**不要用 `settle()` 判"点完是否成功"**。
+5. **`"ui";` 只能是入口文件 `main.js` 的第一行**。
+6. UI 模式下**禁止阻塞式 `dialogs.*`**(会静默返回 undefined),用项目自绘的 `Dialog.*`。
+7. 模块之间**不在加载时取彼此引用**,一律在函数体内按名字取(`TikTokAuto.xxx.yyy`)。
+8. 平台怪癖只写在 `tiktok-auto/src/layout.js`,不要散落到业务代码里。
+
+## 高频坑(都是「不报错,只是结果错」)
+
+| 坑 | 正解 |
+|---|---|
+| `app.launchPackage()` 会把已在前台的 App 重置到首页,毁掉用户准备好的页面 | 先判 `foregroundPackage() === pkg`,是就**什么都不做** |
+| `settle()` 会提前返回(骨架先出、行后加载,签名看起来没变) | 等「行数 > 0」这类**新内容出现**的判据 |
+| 数字先渲染成占位的 `0` | 连续两次读到一致才采信;三组全 `0` 再等 1.5 秒复核 |
+| 点列表行用 `node.click()` 会落在容器中心(可能是 Follow 按钮,误触会把用户关注掉) | 按坐标点**行左侧 30%** |
+| 两种用户列表页的行结构不同(粉丝列表 `txt_desc` / 搜索用户 `tv_username`) | `profile.listRows()` 必须**按当前页面分派** |
+| 抓取阶段的「够了/到点了」不能用来终止收尾工作 | 收尾阶段只继承「用户叫停」和它**自己的**时长预算 |
+| 在入口做全局 `ensureFeed` 会破坏「当前屏幕」类取材 | 各取材源自己保证前置状态;`screen` 源**不动页面** |
+| `http.get` 在这个 AutoX 版本是坏的(OkHttp 冲突) | 用 Java `HttpURLConnection`,并区分 `getErrorStream()` |
+| `engines.execScriptFile` 的 `arguments` 到了子脚本变成对象 | 按 `argv[i]` 取,别当数组 |
+| 子脚本看不到父脚本的全局变量 | 只能走文件通信(见 `docs/AI_CONTEXT.md` 2.3) |
+| UI 回调里的异常不报错、不弹窗、不结束脚本 | 回调内部必须自己 try/catch 并留痕 |
+| `build.js` 写时间戳,每次 md5 都不同 | 不要用 md5 判断是否需要推送,只在推送后**双向核对** |
+
+完整坑库(约 40 条,分 6 组)见 `docs/AI_CONTEXT.md` 第 6 节。
+
+## 不要做的事
+
+- ❌ 不要引入登录态/Cookie 抓取——**已验证对抓取零贡献**,相关代码已删
+- ❌ 不要试图给提取加"视频量门槛"——TikTok 用户页**不显示视频数**
+- ❌ 不要试图判断账号所属国家——用户页没有这个信息
+- ❌ 不要用 MCP 下发长脚本(120 秒上限会截断),用 `am start` 启动
+- ❌ 不要把「已知局限」当 bug 修(见 `docs/AI_CONTEXT.md` 第 9 节)
+
+## 交付要求
+
+必须做:
+
+1. 跑完上面对应的自检命令
+2. 断言失败时**先判断是回归还是断言该更新**(例如新增一个基础设施计时器会让"计时器数量"断言从 1 变 2)
+3. 涉及真机行为的改动**必须真机验证**——PC 自检只能验结构,验不了页面上真正发生了什么
+
+报告时讲清三件事:**改了什么 / 怎么验证的 / 没解决什么**。
+
+## 文档索引
+
+| 文件 | 内容 |
+|---|---|
+| `docs/AI_CONTEXT.md` | ★ 完整上下文:架构、坑库、标准改法、实测锚点、排障 |
+| `tiktok-app/README.md` | 界面:屏幕路由、控件约定、悬浮球实现 |
+| `tiktok-auto/README.md` | 引擎:视频链路 + ID 提取链路(第 3.5 节) |
+| `PLAN_id_extract.md` / `REQ_id_extract_v2.md` | ID 提取的历史方案(已实施) |

+ 702 - 0
docs/AI_CONTEXT.md

@@ -0,0 +1,702 @@
+# 项目 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 界面 ↔ 引擎 的通信(4 个文件)
+
+| 文件 | 方向 | 内容 |
+|---|---|---|
+| `/sdcard/tiktok_app/engine-config.json` | 界面 → 引擎 | 完整配置 |
+| `/sdcard/tiktok_app/run-state.json` | 引擎 → 界面 | `status` / `phase` / `done` / `summary` |
+| `/sdcard/tiktok_app/stop.flag` | 界面 → 引擎 | **内容 = runId**(不是空文件) |
+| `/sdcard/tiktok_app/run-log.jsonl` | 引擎 → 界面 | 逐行 JSON,界面读回做日志页 |
+| `/sdcard/tiktok_app/ball-rect.json` | 悬浮球 → 引擎 | 避让矩形 |
+
+**为什么走文件而不是进程内通信**:子脚本看不到父脚本的任何全局变量(实测)。
+
+**三条容易踩的**:
+
+- `stop.flag` 用「内容 = runId」而不是「文件存在」——删除有竞态,而且 `files.remove` 命中 MCP 的危险调用黑名单
+- 读 `run-state.json` **必须比对 runId**,否则"刚点开始就显示上一轮进度"
+- `run-log.jsonl` 而不是 `.json`——半截行只丢一行
+
+### 2.4 停止机制
+
+`StopSignal` 从**唯一的等待原语** `wait.tick()` 抛出 → 全流程可中断、业务代码零改动。
+
+界面两步:写 flag → 显示「正在收尾」→ 等引擎上报 `stopped` 才复位(约 1 秒,8 秒超时兜底)。
+
+---
+
+## 3. 目录地图
+
+```
+tiktok-scripts/
+├── project.json             AutoX 项目描述(main → ./main.js)
+├── main.js                  ★ 入口:加载界面产物 + 报错兜底
+│
+├── tiktok-app/              界面工程
+│   ├── src/                 19 个模块(见下表)
+│   ├── dist/tiktok-app.js   构建产物
+│   ├── tools/build.js       构建 + 两道自检(ui 位置 / 语法 / 未完成标记)
+│   ├── tools/smoke.js       ★ 414 项结构自检(PC 侧,stub 环境)
+│   ├── tools/audit-values.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 界面模块(19)
+
+| 模块 | 职责 |
+|---|---|
+| `theme.js` | 色值令牌、尺寸、字号(**令牌缺键 = 静默视觉删除**) |
+| `store.js` | ★ 配置默认值的**唯一来源** `Store.FIELDS`(68 条),含版本迁移 |
+| `sources.js` | ID 提取的取材清单(**独立 storage key,不进 FIELDS**) |
+| `records.js` | 运行记录(同上,独立 key) |
+| `device.js` | 设备能力探测(包名、权限、无障碍) |
+| `service.js` | 卡密 / 版本 / 更新检查 |
+| `engine.js` | ★ 界面配置 → 引擎配置的映射;起引擎 |
+| `session.js` | 运行态(**纯内存**,不落盘) |
+| `widgets.js` | ★ 控件工厂(`W.*`),所有输入框的唯一入口 |
+| `dialog.js` | 自绘弹窗(UI 模式禁止阻塞式 dialogs) |
+| `floatball.js` | ★ 悬浮球(两个 overlay 窗口,见 6.B) |
+| `page-*.js` | 6 个页面 |
+| `main.js` | App 装配、路由、生命周期 |
+
+### 3.2 引擎模块(18 个业务模块 + `main` 入口,构建输出 19)
+
+```
+logger → 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        # 期望:通过 68 项
+
+# 改完界面
+cd tiktok-app && $NODE tools/build.js
+$NODE tools/smoke.js                 # 期望:通过 414 项
+$NODE tools/audit-values.js          # 期望:AUDIT_CLEAN
+```
+
+**`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 运行时
+
+**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 秒再找一次
+
+### 6.D 流程与状态机
+
+**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分钟`
+
+### 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/<videoId>.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提取功能设置是 22 个字段`)
+   - **映射正确**(改值 → 引擎配置里出现对应值)
+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.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 → 暂停只能协作式(未实施,见 `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
+
+# 界面改动
+cd tiktok-app && $NODE tools/build.js && $NODE tools/smoke.js && $NODE tools/audit-values.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` | 各模块详细设计与实测记录 | 持续更新 |
+
+> 冲突时以**本文档**为准(它是最新实测结论的汇总)。