|
|
@@ -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` | 各模块详细设计与实测记录 | 持续更新 |
|
|
|
+
|
|
|
+> 冲突时以**本文档**为准(它是最新实测结论的汇总)。
|