AI_CONTEXT.md 37 KB

项目 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(69 条),含版本迁移
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)

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 推到设备

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 变了"当失败。正确做法:

md5sum dist/tiktok-auto.js
adb shell "md5sum /sdcard/Scripts/tiktok-demo/tiktok-auto/dist/tiktok-auto.js"
# 两边一致才算推上去

4.4 看真机日志

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,把叠着播放页/评论面板清掉
  • 正解:

    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 里窗口名只写包名,看不出属主进程,容易误以为"主进程持有"
  • 正解:想确认残留就用窗口计数

    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,并区分正常流/错误流:

    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)会提前返回(★ 高频)

  • 现象:点开列表/切页签后立刻报"没读到行",但人眼明明看到了
  • 根因:它的判据是"可见文案连续两次不变",而加载中的页面和打开前的签名一样(骨架先出、行后加载)
  • 正解:凡是"点完要等新内容",判据必须是"新内容真的出现了"

    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 流程与状态机

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,由调用方决定滚动还是收工

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提取功能设置是 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 交付前(缺一不可)

# 引擎改动
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 各模块详细设计与实测记录 持续更新

冲突时以本文档为准(它是最新实测结论的汇总)。