PLAN_id_extract.md 32 KB

PLAN · ID提取 执行功能方案(待评审 · 未实施)

范围:tiktok-app(界面)↔ tiktok-auto(执行引擎)两端。 目标:让「任务 › ID提取」从只有页面变成点了运行真的会去 TikTok 里抓 ID。 依据:《实施规格书_飞舟脚本_2026-10-13》(资料库 JZf9xTgLXBFgjXJ4dJWVsU)。 现状核对时间:2026-10-15;设备 TikTok 47.1.4(Pixel 5a / 1080×1920 / Android 14)。


0. 一句话结论

界面这一侧已经做完了,缺的全在执行侧。 四个脚本卡的 40 个配置字段、单选互斥、 运行态、停止、记录、日志回传通道全都现成;真正没有的是「拿着这些配置去 TikTok 里干活」 的那套流程。所以本方案的重点不是页面,而是新增一条与「关键词视频」并列的执行链路, 以及为它补上引擎目前完全没有的三类能力:用户页读取、列表取材、特征过滤。


1. 现状:已有什么、缺什么(都有代码依据)

1.1 已有的(不用动)

能力 位置 状态
四张脚本卡 + 单选互斥 tiktok-app/src/page-id.js Pages.toggleIdScript ✅
功能设置 14 项(40 个字段) store.js → tabs.id.features.* / tabs.id.scripts.s*.* ✅ 全部落盘、可回读
运行前置校验 5 步 main.js App.requestRun ✅ 顺序照规格书 §2.5
界面↔引擎文件通道 engine.js:engine-config.json / run-state.json / stop.flag / run-log.jsonl ✅ 已真机验证
停止(可中断全流程) 引擎 bridge.js 的 StopSignal 从 wait.tick() 抛出 ✅
进度显示 引擎 report({phase}) → 界面状态条 ✅ 只看 phase 文案,done/total 不显示
运行记录 / D3 详情 / 结果文件行 records.js(resultText / resultFile / logs) ✅ 字段已留好

1.2 缺的(本方案要做的)

缺口 说明
① 脚本没声明引擎 Pages.ID_SCRIPTS 四条都没有 engine 字段 → 点运行被 Dialog.engineMissing 拦下。page-video.js 里是 engine: "tiktok-auto"
② 界面配置构建器只认视频页 Engine.buildConfig() 写死读 tabs.video.*;Engine.prepare() 里那条「关键词为空就拦」也只对视频成立
③ 引擎只有一条业务链路 tiktok-auto/src/flow.js 的 runSession = 搜索→挑视频→看→评论。没有用户页、没有列表、没有 ID 概念
④ pageState() 认不出用户页/列表页 现在只有 feed / search / results / player / comment-panel / unknown
⑤ 没有产物落盘 规格书 §5 要求结果是 uid_YYYYMMDD.txt;引擎现在只发评论、不写业务文件
⑥ 没有去重集合 「主播去重 / UID 博主去重 / 云端去重」三项都没有承载者

2. 核心设计:把四张脚本卡统一成「取材源」

规格书只描述了 UI,没说"四个脚本到底在做什么"。把四个脚本的设置逐项读一遍, 能看出它们其实只在回答同一个问题:候选 ID 从哪儿来。

2.1 取材源(Source)抽象

一切 ID 提取 =  取材源(一个或多个) → 候选用户 → 打开用户页 → 采集 → 过滤 → 收录
                     ↑                                              ↑
              四个脚本的唯一差别                          14 项功能设置在这里生效

四个脚本各自的设置项,逐条落到「取材源」上:

脚本 卡里的设置 对应取材源(Source kind)
一 · 粉丝页提 ID 进入页面(粉丝页/评论页/主页/关注页) listOf(anchor, enterPage):fans / following / comment / home
自定关注或粉丝页面提 ID 是否走「作者主页 → 切页签 → 列表」这条入口
自定页面提 ID userSearch(keyword) —— 直接在搜索页的 Users 页签里抓
主播 ID(# 分隔,可留空) 取材起点;留空 = 从版面现场取(见 §2.3 的歧义)
使用文本 ID(路径) fromText(path) —— 绕过取材,直接把文本里的 uid 当候选
主播去重(去重模式) 不是取材源,影响的是收录阶段(见 §6)
二 · 评论页提 ID 自定评论页面提 ID commentersOf(video)
提取评论的人的粉丝 ID 上一步的候选再展开一层:fansOf(commenter)
三 · 文本 UID 裂变 文本 UID 裂变提 ID + 裂变文本路径 fromText(path) → 每个 uid 展开 fansOf/followingOf = 裂变
UID 博主去重 收录阶段
四 · 关键词视频 关键词 + 关键词视频提 ID searchVideos(keyword) → 视频 → 作者
关键词提取主播粉丝 ID 作者再展开 fansOf(anchor)
关键词提取评论区 ID commentersOf(video)
关键词提取评论的人的粉丝 ID 再展开一层

2.2 于是四个脚本变成三行代码

s1: anchors = 主播ID(或现场) ; 候选 = listOf(anchors, enterPage)  | userSearch(kw)
s2: 候选 = commentersOf(视频)          [+ fansOf(每个评论者)]
s3: 候选 = fansOf/followingOf(fromText(裂变文本里的 uid))
s4: 候选 = authorsOf(searchVideos(关键词)) + fansOf(作者) + commentersOf(视频) + fansOf(评论者)

fansOf / followingOf / commentersOf / searchVideos / searchUsers / fromText 六个取材源覆盖全部四个脚本,且每一个都是「返回一串 uid + 滚动翻页」的同一形态。 这条抽象是整个方案的地基:如果它成立,四个脚本只需 6 个取材源 + 1 条公共流水线; 如果不成立(比如四个脚本的翻页行为互不相同),工程量会翻好几倍。 所以 §11 的第一条探测 就是来验它的。

2.3 三处语义不确定,需要拍板(不能靠猜)

  1. 脚本一的「主播 ID」留空是什么意思? 字面是"不限定主播、从页面现场取"。 我的解释:从脚本一进 App 后落到的那个页面直接开始抓(粉丝页/关注页/评论页), 即"用户已经自己把列表翻到了某处"。候选实现:留空时先跑一次 listOf 的页面判定, 判定不到就报错说清楚"请先进入某个列表页或填写主播 ID"。
  2. 「自定关注或粉丝页面提 ID」与「进入页面」的关系:前者是"要不要走主播主页", 后者是"进哪一页"。但脚本一两项默认是三处开关同时为真(customFansPage=true 且 enterPage=fans),无法从配置区分"从主页进"与"直接在粉丝页列表上抓"。 我的解释:customFansPage = 是否先打开主播主页再切到对应页签; 关掉 = 假定用户已经在那个列表页上,直接抓。
  3. 「裂变」的确切含义:我的解释是「文本里的 uid → 这些人的粉丝/关注 → 那些人的 ID」, 即"一层展开"。如果实际意思是"文本里是关键词,去搜人再展开",那 fromText 要换成 searchUsers。这两种在设置项文案上都说得通,必须确认。

3. 执行骨架(引擎侧新增 idflow.runSession)

与现有 flow.runSession 并列,同一份基础设施(应用启动、弹窗守护、坐标点击降级、 wait.tick() 的停止、bridge.report() 的状态上报)。

runSession(config):
  report(phase="启动应用")            ← 复用 flow.launchApp
  report(phase="进入信息流")          ← 复用 flow.ensureFeed
  if !ensureFeed: fail("未能进入信息流首页")

  sources = buildSources(config.id)   ← §2.2 的六种,按脚本组合成一个队列
  seen = UidSet.load(config.id.dedupe)   ← §6
  out  = ResultWriter.open(config.id.output)

  for source in sources:              ← 取材源按顺序(不是并发:同一台手机只有一个前台)
    for batch in source.batches():    ← 每次翻一屏
      for uid in batch:
        if uid in seen: continue
        if matchesTextIdMode(uid): continue
        report(phase="正在读取 @{uid}(已收 N 个)")

        profile = profile.read(uid)              ← §4
        verdict = filters.judge(profile, config.id.filter)   ← §5(纯函数)
        if !verdict.ok: log("跳过 @{uid}:{verdict.reason}"); continue

        seen.add(uid); out.write(uid)
        if limit && out.count >= limit: goto done

      if !source.next(batch): break    ← 列表到底了
    ← 一个取材源耗尽就换下一个,不是失败

done:
  out.close()                          ← 写 /sdcard/Download/tiktok_app/uid_YYYYMMDD_HHmm.txt
  UidSet.save(seen)
  finish(status="finished", summary={extracted: N, skipped: M, resultFile: "…"})

三个与视频链路的真实差异,必须在实现时处理:

  1. 总量未知。 粉丝列表可能几百万,total 不存在。处理: report({phase}) 里带「已收 N 个 / 上限 M(0 = 不限)」, 界面状态条只读 phase(已确认 main.js:574),所以界面零改动。
  2. "不限"是危险的。 extractLimit = 0 的语义是"不限制"(store.js 里写明), 但真跑起来没有上限就等于"跑到没电"。处理:保留界面语义,引擎另加两个物理护栏: 单次运行的最大时长(runtime.maxRuntimeMs,默认给一个值如 30 分钟)与 单列表最大翻屏数。到护栏时如实报告「因达到取材上限而结束」,不偷偷装作抓完了。
  3. 停止要更细。 用户可能在"打开用户页"或"翻列表"的任意时刻点停。 现有 StopSignal 从 wait.tick() 抛出已覆盖,但收尾必须写文件 —— 收到停止信号时要走 out.close() 再抛,否则用户"停了但结果文件是空的"。

4. 引擎新增能力之一:用户页读取(src/profile.js)

「ID」在 TikTok 里就是用户名(@username)。要在用户页上读到的东西:

字段 用途 数据源 可行性
uid 产物本体 用户页顶部的 @xxx 文本控件 待实测(控件位置/文案格式)
nickname 昵称检测 / 语言判定 昵称 TextView 待实测
signature 个签检测 / 语言判定 个签 TextView 待实测
follow / fans / likes / videos 四组数量门槛 用户页顶部四组「数字 + 标签」 待实测(数字是缩写形态,见下)
isPrivate 过滤私密账户 私密账号的提示文案 待实测
hasAvatar 过滤无头像账户 头像 ImageView 待实测(可能要像素法,见下)
region / flag 提取区域 ? 无可靠数据源,见 §5.3
avatarBitmap AI 头像识别 头像 需要模型,见 §5.4

数字缩写解析是一个独立的小模块(parseCount("1.2M") → 1200000), 必须覆盖 TikTok 的多种写法(1.2M / 12.3K / 1,234 / 1.2万) 并且语言/地区不同写法不同。这是纯函数,可以 100% 离线自检 —— 值得单独写测试。

无头像的判定很可能要像素法(头像位是灰色占位图): player.js 里已经有 probe.usePixels 那套截图取像素的基础设施可以复用。 这是方案里唯一一处"功能设置需要像素"的地方,会是探测清单里的重点。


5. 功能设置 14 项:逐项可行性分级

规格书只给了 UI,没说怎么实现。逐项给出能做什么、靠什么做、做不到怎么办。 分三级:A = 这次能做;B = 能做但依赖 §11 的实测结论;C = 本次不做,运行时如实告知。

5.1 A 级(可做,方案确定)

项 实现方式
过滤私密账户 用户页/列表页读到"私密"提示 → 丢(B 级的实测给具体文案)
关注量 / 粉丝量 / 获赞量 / 视频量 区间 读数字 → parseCount → 落区间判定。纯函数,可离线全量自检
提取符合 N 个 ID 后停止 计数上限(0 = 不限,但受 §3 的物理护栏约束)
语言区域(通杀/仅中文/仅英文) 按昵称 + 个签的字符集判定:CJK 字符占比 → 中文;拉丁字母占比 → 英文。纯本地、零依赖、可自检
昵称个签检测(通杀/仅检测昵称/仅检测个签) 我理解为上面各项判定的取样字段:通杀 = 昵称+个签都看;仅昵称 = 只看昵称;仅个签 = 只看个签。⚠️ 这条解释需要确认(见 §14 待拍板第 3 条)
主播去重 / UID 博主去重 收录阶段的 UidSet(§6)
云端去重 见 §5.5

5.2 B 级(能做,但要先实测)

项 依赖的实测结论
过滤无头像账户 头像控件能否用文本/语义判定;不行就走像素法(player.js 的像素基础设施)
进入页面(粉丝页/评论页/主页/关注页) 用户页的页签条锚点与实际标签文案
提取数量 / 列表翻页 列表页的滚动容器、每屏行数、返回后位置是否保持

5.3 「提取区域」(国家简码 JP#TW#US)——这是最需要拍板的一项

实话:TikTok 用户页不显示国家。 所以这一项没有可靠数据源。三条可选路径:

路径 覆盖率 依赖 说明
(a) 昵称/个签里的国旗 emoji 或国家名匹配 低(只有写了的用户) 无 纯本地可做,但大量用户会被判成"未知"
(b) 与「语言区域」合并,用语言替代地区 中 无 语义被悄悄改变,不能这么干
(c) 云端按 uid 查地区库 看库 服务端 要建/买一份 uid→地区 的数据,成本在服务端
(d) 本次不做 — — 界面上保留该项,运行时明确告知「本次不会按区域过滤」

推荐 (d) + 预留 (c):默认不做、如实告知;同时把 regionCodes 原样传进引擎配置, 将来 (c) 就绪时不用改界面。不要做 (b) —— 那是把一个"看起来生效"的假功能交给用户。

5.4 AI 头像识别 / AI 性别 / AI 年龄 —— C 级,本次不做

需要人脸检测 + 性别/年龄模型。三条路:本地 TFLite(要确认 AutoX 能否加载模型、 模型从哪来)、云端 API(要服务端 + 上传头像,涉及隐私与成本)、不做。

推荐本次不做,理由:这三项在 14 项里最边缘,缺失不影响主链路(取材→过滤→产物完整可用), 而"为了凑齐设置项去接一个没验证过的模型"风险远大于收益。 落地方式与现有的「点赞/关注/私信尚未实现」完全一致:Engine.unsupportedNotes() 逐条如实告知, 用户勾了就在启动时提示一次 + 记进 run-config.json 的 notes。 (现有的 unsupportedNotes() 是写死看视频页的,改造成按 task 分派时顺手支持 id。)

5.5 「云端去重」——先定契约,未配置就退化为本地

现状(page-id.js 的"连通测试"按钮)已经如实说「当前未配置云端去重服务地址」。 所以本方案要的是接口契约 + 明确的退化行为:

POST  {cloudBaseUrl}/dedupe/check
body  {"table":"<表名>","uids":["a","b",...]}     ← 批量,减少往返
resp  {"new":["a"],"dup":["b"]}                    ← 只保留 new
  • 未配置 cloudBaseUrl → 只用本地 UidSet,并在运行日志里写一行 「云端去重未配置,本次仅本地去重」。不静默。
  • 界面新增一个 cloudBaseUrl 字段(现在只有表名)。
  • 网络失败 → 降级为本地去重 + 记一条 WARN,不中断整轮取材 (取材成本很高,不能因为去重服务抖一下就全丢)。

6. 产物与去重

6.1 文件

文件 内容 说明
/sdcard/Download/tiktok_app/uid_20261015_1831.txt 本次提取的 uid,一行一个 规格书 §5 写的是 uid_20261013.txt(只有日期)。我建议加时分:同一天跑两次不会互相覆盖 —— 这是对规格书的有意识偏离,理由与代价都很清楚
/sdcard/Download/tiktok_app/uid_extracted.txt 累计已提取过的 uid(去重基准) 跨轮去重靠它。只追加、不删除(files.remove 命中引擎的危险调用黑名单,见 build.js 的 DANGEROUS_RULES)
run-config.json 完整配置快照 已有通道,ID 提取的配置也走它(追溯用)
run-log.jsonl 逐行日志 已有通道

产物格式细节:纯文本、一行一个、\n 结尾、不带 @(下游 UID 私信页直接读它当输入, uid_list.txt 那套约定就是纯 uid)。要不要写表头注释行?不写 —— 下游是逐行读的, 写 # 注释要靠每个下游都记得跳过(readLines 现在确实跳 #,但别赌)。

6.2 去重集合(src/uidset.js)

  • 载入:读 uid_extracted.txt(可能几十万行)→ 建 Set。内存里是 Set,落盘是追加。
  • 一轮内的去重与跨轮的去重是同一个集合("主播去重"的 ID 模式就是这个)。
  • 文本模式(脚本一的 dedupe: text)与 ID 模式的差别:文本模式是按 textIdPath / fissionTextPath 那份输入文本去重,即"输入里已经有的就不算新"。 两种模式的集合来源不同,实现上是同一个 UidSet 的两个装载源。
  • 落盘时机:分批追加(每 N 个写一次)而不是结束时一次性写 —— 用户点了停止、或脚本被杀,已提取的部分不能全丢。

7. 引擎侧改动清单

7.1 新增模块(都要进 tools/build.js 的 MODULE_ORDER)

新文件 职责 行数估计
src/profile.js 用户页资料采集(§4) ~250
src/sources.js 六个取材源 + 列表翻页(§2.2) ~350
src/filters.js 14 项判定的纯函数实现(§5) ~250
src/uidset.js 去重集合 + 结果文件读写(§6) ~180
src/idflow.js ID 提取编排(§3) ~300

7.2 改现有模块

文件 改动
src/tiktok.js ① pageState() 新增 profile / user-list / users-results 三个状态;② 新增用户页锚点、列表锚点、页签锚点;③ 新增 usernameOf / statNumbers / privateHint / avatarNode 读取
src/config.js DEFAULTS 新增 id 段(§8 的契约)、task 字段、timeouts 加用户页/列表相关的上限
src/main.js 入口按 config.task 分派:video → flow.runSession,id → idflow.runSession;__END__/RESULT/终态上报的逻辑两路共用(不能各写一份)
src/ui.js 列表翻页需要 scrollList(现有 dragScreen 够用就复用,不新增)
src/__selftest.js 新增 parseCount / filters.judge / uidset 的纯函数自检
tools/build.js MODULE_ORDER 插入新模块(顺序:filters/uidset 无依赖可早放,profile/sources 依赖 tiktok,idflow 放最后)

7.3 一个必须注意的构建约束

引擎产物是单文件 concat,模块顺序由 MODULE_ORDER 决定,且各模块在加载时就取 TikTokAuto.xxx 的常量引用(var layout = TikTokAuto.layout;)。新模块必须遵守同一约定, 且不能在加载时调用别的模块的函数(那时可能还没定义)。


8. 界面 ↔ 引擎的新契约(engine-config.json)

新增顶层 task 字段用于分派,新增 id 段承载全部设置:

{
  "task": "id",                      // 新增:"video" | "id";缺省时按 "video"(向后兼容)
  "id": {
    "script": "s1",                  // s1..s4,用于日志与记录
    "source": {                      // ← §2 的取材源,字段随脚本给不同的子集
      "kind": "fans",                // fans|following|home|comment|userSearch|textFission|keywordVideo
      "enterPage": "fans",
      "anchorIds": ["123","456"],    // 界面是 "123#456" 字符串,界面侧拆好再传
      "userSearchKeyword": "",
      "fissionTextPath": "/sdcard/Download/tiktok_app/uid_list.txt",
      "keyword": "fashion",
      "includeAnchorFans": false,
      "includeCommentArea": false,
      "includeCommenterFans": false,
      "customFansPage": true,
      "customPage": false
    },
    "useTextId": { "enabled": false, "path": "…" },     // 脚本一:绕过取材直接用文本
    "dedupe":    { "enabled": true, "mode": "id" },      // mode: id | text
    "filter": {
      "privateAccounts": true, "noAvatar": true,
      "regionCodes": ["JP","TW","US"],                   // ← 传下去但本次不生效(§5.3),
                                                          //    但要写进 run-config 以便追溯
      "langRegion": "any", "nicknameCheck": "any",
      "aiAvatar": false, "aiGender": "male", "aiAgeMax": 20,
      "follow": {"min":0,"max":500000}, "fans": {"min":0,"max":500000},
      "likes":  {"min":0,"max":500000}, "videos":{"min":0,"max":500000}
    },
    "limit": 0,                                            // 0 = 不限(受 §3 的物理护栏)
    "output": { "dir": "/sdcard/Download/tiktok_app" }
  },
  "runtime": { "configPath": "…", "dryRun": false, "maxRuntimeMs": 1800000 }
}

配置映射要做成"按 task 分派":

Engine.buildConfig(taskKey)   → video: 现有逻辑原样搬成 buildVideoConfig()
                              → id:    新增 buildIdConfig()(读 tabs.id.*)
Engine.prepare(taskKey)       → 前置校验也按 task 分派
Engine.unsupportedNotes(taskKey)

⚠️ 改签名会碰到两处调用:main.js:855(Engine.prepare())与 tools/smoke.js:571(自检里直接调 Engine.prepare()) —— 自检要一起改, 否则离线检查会在这一步红掉(这是好事,说明它真的在守这件事)。


9. 界面侧改动清单(共 6 处,都很小)

  1. page-id.js:Pages.ID_SCRIPTS 四条的 name 后加 engine: "tiktok-auto" → 解掉 F「引擎尚未接入」拦截。
  2. engine.js:buildConfig / prepare / unsupportedNotes 三个函数按 task 分派(§8)。
  3. engine.js 的 buildIdConfig:读 tabs.id.* 40 个字段 → §8 的结构;同时 把 anchorIds 的 # 拆成数组、把区间字段组装成 {min,max}。
  4. 前置校验(id 专属):
    • 脚本一勾了「使用文本 ID」但文件不存在 → 拦住并说清路径(新校验,视频页没有对应项);
    • 脚本三的「裂变文本」路径为空或文件不存在 → 同上;
    • 脚本四选了但关键词为空 → 拦住(与视频页同形)。 这三条都走现有的 Dialog.runBlocked / Dialog.engineUnavailable,不新增弹窗类型。
  5. main.js 的 App.startRun:把 task.task 传给 Engine.prepare()(一行); 收尾时把引擎 summary 里的 resultFile / extracted 写进记录 (Records.add 已有 resultFile 字段,不用改数据结构)。
  6. records.js 的文案:任务卡的「上次 …」现在走 resultLine, 引擎回传 resultText = "提取 128 个 ID" 即可与设计稿一致(上次 10:24 · 提取 128 个 ID)。

不需要改的:状态条(只读 phase)、Session(done/total 不显示)、 四个脚本卡与功能设置的任何控件(字段已齐)、Page-id 的滚动结构。


10. 顺带发现的两处与规格书不一致(与执行无关,仅记录)

项 规格书 代码 建议
脚本三「文本 UID 裂变提 ID」默认 ✗ true(store.js) 待确认:若照规格书改成 ✗,脚本三展开后全为空,用户会以为坏了
脚本四「关键词视频提 ID」默认 ✗ true 同上

两处都是"按规格书改会让页面看起来没内容"的冲突,与浮窗那次 「§10.1 说不透明度 1.0 / §10.9 又建议降透明度」是同一类问题。本方案不动它们, 只在这里记一笔,等用户拍板。


11. 必须先做的真机探测(写代码之前)

tiktok-auto/tools/diag-*.js 已经有一套成熟的探针模式(--eval 加载全部模块、 只换入口代码)。照这个模式写一个 diag-profile.js,一次性把下面 9 条测清楚。 每一条都给出「什么结果算通过」,探测结论直接决定 §2 的地基是否成立。

# 要测什么 通过判据 决定什么
1 用户页能否稳定读到 @username 反复打开 10 个不同用户页,10/10 读到且格式一致 整个方案的地基
2 粉丝页/关注页列表结构与滚动 能读到列表每行的 uid;连续 3 次滚动都能拿到新行(不重复) §2.2 的 listOf
3 从用户页返回后列表位置是否保持 返回后原行还在原位(不用重新滚回) 翻页策略;不保持则要"先收集一整屏再逐个开"
4 用户页四组数字的控件形态 能定位到 关注/粉丝/获赞/视频 四个数字;拿到原始字符串(验证缩写形态) §4 的 parseCount 与四组门槛
5 私密账户在列表页与用户页的可见特征 两者的 txt 能区分;列表页也能判(否则只能打开后判) 过滤私密账户的效率
6 头像控件的可判定性 先看有没有语义/文本可判;不行则截图取像素,验证占位图与真头像的像素差异可量化 过滤无头像账户
7 搜索结果页 Users 页签的行为 点 Users 后出现的是用户列表(不是视频);能读 uid 脚本一「自定页面提 ID」
8 评论列表里能否读到评论者的 uid 无需进用户页就能读(否则每个评论者都要开一次页,成本翻倍) 脚本二/四的效率
9 昵称/个签控件 + 中文占比判定 能分别读到昵称与个签;CJK 占比在中文/英文账号上确实分离 语言区域判定

探测产出:一份 _probe/ID_EXTRACT_PROBE.md(实测锚点表 + 结论 + 失败项的处理办法), 与 tiktok.js 头注释里那张"实测锚点"表同形 —— 引擎所有选择器都基于真机实测, 这是这个项目一直以来的做法。


12. 分阶段实施顺序(每阶段可独立验收)

阶段 内容 验收标准 能否独立回退
P0 §11 的 9 条真机探测 9 条都有明确结论;不成立的条目给出替代方案 只碰 _probe/,零风险
P1 引擎基础设施:uidset / filters / parseCount(纯函数)+ __selftest + build.js 模块表 设备端 --self-test 全绿;parseCount 覆盖各种缩写写法 新增文件,不影响现有流程
P2 tiktok.js 扩展(用户页/列表锚点 + 三个新 pageState)+ profile.js 读取 能在真机上把某个 uid 的资料读成一行结构化日志 现有 pageState 加分支可能影响视频链路 → 必须回归跑一次视频流程
P3 sources.js + idflow.js(先只做脚本一一条取材源)+ 界面 6 处改动 真机:从粉丝页抓到 ≥10 个真实 uid,落盘成文件,界面记录里有「提取 N 个 ID」 界面加 engine 声明后即可跑;回退就是删掉声明,回到被拦状态
P4 补齐脚本二/三/四的取材源 + 云端去重契约 + 停止收尾写文件 四个脚本各跑通一次;运行中点停止 → 文件里有已经抓到的部分 按取材源独立,出问题只关掉那个源
P5 文档 + 记忆 + 自检补全(smoke 的 ID 提取断言、audit 的字段覆盖) smoke 全绿 + audit CLEAN —

为什么 P3 只做脚本一:四个脚本共用同一条流水线,第一条打通就能验证 §2 的整个抽象。 如果脚本一跑通了,后面三个基本是加取材源(每个 ~80 行);如果脚本一就卡住, 说明抽象有问题,此时还没写另外三个的来源,改起来的代价小得多。


13. 验证计划

层 手段 覆盖什么
纯函数(PC + 设备) tiktok-auto/src/__selftest.js 新增:parseCount 各种书写、filters.judge 各种组合、uidset 的装载/追加/去重 「14 项设置真的会拦人」这件事可以 100% 离线证明
界面(PC) tiktok-app/tools/smoke.js 新增:buildIdConfig 的映射(40 字段 → 引擎配置)、id 的三条前置校验、unsupportedNotes("id") 的如实告知 配置不会在映射中丢字段(这个项目踩过"路径拼错 = 静默丢值")
界面(PC) tools/audit-values.js 新增字段是否都有默认值、是否都能回读
真机(受控) tools/diag-*.js 探针 + --dry-run(只取材、不写产物文件) 选择器在真实 TikTok 上是否还准
真机(端到端) 界面点运行 → 抓 10 个 → 校验产物文件「行数正确、无空白行、无重复、不带 @」 全链路

dryRun 的语义要新增一条:取材照做、但不写产物文件(现在 dryRun 只作用于"评论不发送")。 这样能在不污染产物、不消耗去重集合的前提下反复调选择器。


14. 需要拍板的决策点

  1. §2.3 的三处语义:①「主播 ID 留空」= 从现场页面抓?②customFansPage = 先开主播主页再切页签? ③「裂变」= uid 一层展开(取这些人的粉丝/关注)? —— 尤其 ③,两种解释的实现完全不同。
  2. §5.3「提取区域」怎么办:推荐本次不做 + 如实告知(预留云端路径), 备选是"按昵称/个签里的国旗 emoji 匹配(覆盖率低)"。
  3. §5.4「AI 头像识别 / AI 性别 / AI 年龄」:推荐本次不做 + 如实告知, 备选是接本地模型或云端 API。
  4. §6.1 产物文件名:规格书是 uid_20261013.txt,我建议加时分(uid_20261013_1831.txt)。 要不要偏离?
  5. §3 的物理护栏:extractLimit = 0(不限)时,引擎给不给"最大运行时长 / 最大翻屏数"? 我建议给(默认 30 分钟),并在到达时如实说明。这个默认值要多少?

15. 明确不做的(避免以后被当成"漏了")

项 原因
并发多个取材源 同一台手机只有一个前台,TikTok 是真机操作,没有并发可言(与"全局单跑"同源)
绕开界面直接跑 界面是唯一入口(卡密 / 权限 / 单选 / 单跑都在这层),不给第二条路
用 API 抓 ID 「ID提取」的语义就是"在真机 TikTok 上按页面取材";评论数那套 tikwm 链路是另一件事,不混进来
改四个脚本卡与功能设置的控件 40 个字段已齐、已落盘、已可回读,一行都不动
本期实现 regionCodes / AI 三项 见 §5.3 / §5.4,如实告知而不是做个假开关
动 §10 里那两处默认值 与执行无关,等拍板

附:一页速览

             界面(已完成)                          引擎(本方案要做的)
   ┌────────────────────────────┐        ┌──────────────────────────────────┐
   │ 4 张脚本卡(单选)           │        │ main.js  按 task 分派             │
   │ 功能设置 14 项 / 40 字段     │        │   ├ video → flow.runSession(已有)│
   │ 运行态 / 停止 / 记录 / 日志  │        │   └ id    → idflow.runSession ★新 │
   └─────────────┬──────────────┘        └──────────────────────────────────┘
                 │ engine-config.json(新增 task + id 段)        ↑
                 └───────────────────────────────────────────────┘
                                      取材源(6 种)→ 用户页 → 过滤(14 项)→ 去重 → 产物
                                      ★tiktok.js 新增 profile / user-list / users-results 状态

依赖顺序:先跑 §11 的 9 条探测 → 再写 P1 的纯函数 → 再 P3 打通脚本一。 最大的不确定性不是工程量,而是 §11 第 1 条(用户页能否稳定读到 uid)—— 它不成立的话,整个"在真机上按页面提取 ID"的路线都要重新考虑。