# PLAN · ID提取 执行功能方案(**已实施** · 2026-10-20 起可跑) > ✅ **状态:已实施并在真机验证过**(`task = "id"`,六模块 + 三段式界面)。 > 本文件保留作为设计依据;实现取舍与实测锚点见 `tiktok-auto/README.md` 第 3.5 节。 > 范围:`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` 段承载全部设置: ```jsonc { "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"的路线都要重新考虑。