Jelajahi Sumber

ID提取页清语义(A 档):删 7 个重复/无指向字段,功能设置分三组

只改界面,执行引擎仍未接入 —— 点「开始运行」仍被 Dialog.engineMissing 明确拦下。

数据层(SCHEMA_VERSION 6 → 7,7 删 / 5 增)
- 删 4 个与卡名或下拉同义反复的开关:「自定关注或粉丝页面提 ID」
  (与「从哪个页面取」说同一件事)、「自定评论页面提 ID」、「文本 UID 裂变提 ID」、
  「关键词视频提 ID」(后三个与卡名一字不差)
- 删 s1 的「主播去重 + 去重模式」与 s3 的「UID 博主去重」:去重收到功能设置,
  全页只出现一次(原来散在三处、关系不明)
- 增:s1.customPageKeyword(搜索用户的关键词,原来勾上没说搜什么)、
  s2.videoSource + videoKeyword、features.dedupeLocal + dedupeHistory
- 新枚举 expandList / videoSource;s3.enterPage 收窄到 expandList(只有粉丝/关注)——
  旧值域里的「评论页」「主页」在"展开一个列表"这个语义下并不存在
- MIGRATIONS[6] 三条,每条都写进 problems:不静默改用户配置

页面层
- s1:主路径(从哪个页面取 + 主播账号)在前,搜索用户/文本作为额外来源在后 + 一句说明
- s2:换成「视频从哪来」(当前屏幕 / 关键词搜索),选关键词才出输入框
- s3:只剩「展开哪个列表」+「裂变文本」
- s4:「关键词主播」→「关键词」,三个额外取材范围加 16dp 缩进,层级看得出来
- 功能设置分三组:只要这样的账号 / 数值门槛 / 去重与输出
- 「提取区域」「AI 画像识别」改为灰行 +「暂不支持」:不给一个能开却什么也不会发生的开关。
  值仍保留在配置里,将来接上直接生效
- 「昵称个签检测」→「取样范围」(它是语言的取样字段,不是独立过滤项);
  修掉「提取区域」那句与实际控件矛盾的「勾选后…」;「0 = 不限」写进文案

控件
- W.checkRow 支持 disabled(不可点时挂空监听,不用 null —— null 会让点击冒泡到卡片)
- 新增 W.mutedRow:灰行 + 「暂不支持」胶囊 + 点一行看原因

自检
- smoke 380 → 386 项,含「删掉的 7 个字段确实已从字段表移除」
  (只从页面删不够:字段还在就会被导出与审计继续当成有值)
- audit:10 条写死旧文案的断言按 v7 更新;新增「枚举驱动的条件渲染」扫描
  (原来只把所有 bool 打开扫一遍,选「关键词搜索」才出现的输入框扫不到);
  v7 迁移独立成用例(造一份 v6 存档,验三条迁移 + problems 各一条)
- 386 项全绿 + AUDIT_CLEAN;真机验证 v6 存档自动迁移、三组与灰行渲染正常

依据:REQ_id_extract_v2.md(10 条诊断 + 38 个字段逐条处置)
梦境 2 minggu lalu
induk
melakukan
8972654b57

+ 37 - 23
tiktok-app/README.md

@@ -73,7 +73,7 @@ tiktok-app/
 ├── tools/
 │   ├── build.js        # src/ → dist/ 单文件(含三道检查)
 │   ├── android-stub.js # 最小 Android/AutoX 环境模拟
-│   ├── smoke.js        # 产物离线自检(380 项断言)
+│   ├── smoke.js        # 产物离线自检(386 项断言)
 │   └── audit-values.js # 界面取值审计(六个场景)
 └── dist/tiktok-app.js  # ⚠️ 构建产物,不要直接编辑
 ```
@@ -108,7 +108,7 @@ tiktok-app/
 | **B1/B2** | **任务列表(含运行态)** | `30:102` / `30:203` | `Pages.tasks` + `W.taskCard` | ✅ 真机 |
 | C1 | ID提取(首屏) | `3:1` | `Pages.id` + `W.backHeader` | ✅ 真机 |
 | C2/C3 | ID提取 · 脚本二三 / 脚本四 | `16:483` / `16:589` | 同上(滚动位置不同) | ✅ 真机 |
-| C4/C5 | ID提取 · 功能设置(含滚到底) | `16:706` / `16:965` | 同上(14 项) | ✅ 真机 |
+| C4/C5 | ID提取 · 功能设置(含滚到底) | `16:706` / `16:965` | 同上(v7 起分三组) | ✅ 真机 |
 | C1r/F5 | 运行中 / 脚本报错 | `16:138` / `30:1119` | 全局运行条 + `Dialog.scriptError` | ✅ 真机(运行条)/ PC 自检(F5) |
 | C6/C7 | UID私信(含脚本一展开) | `3:534` / `7:11` | `Pages.dm` | ✅ 真机 |
 | C8 | 养号 | `3:469` | `Pages.nurture` | ✅ 真机 |
@@ -238,37 +238,51 @@ tiktok-app/
 | — 右侧滚动指示条(`x=367 w=3`) | **未实现**:它需要滚动监听,收益小、风险高(AutoX 里构造 `OnScrollChangeListener` 的兼容性没验过);底部渐隐已经把"还能滚"表达出来了 |
 | — §7 技术栈表 | 运行形态 = **AutoX.js `ui` 模块**(纯代码建 View);卡密/权限/存储/结果文件/更新检查全部沿用本项目既有实现(本地卡密、`storages`、`files`) |
 
-### ID提取页的四张脚本卡
+### ID提取页的四张脚本卡(v7 清语义)
 
 四张卡是**单选组**(设计稿写明「同一时间仅可运行一个脚本」),
 默认一个都不选 —— 稿子 16:23 专门画了「未选择脚本」那一屏,说明"还没选"是合法的初始状态。
-每张卡展开后是**自己的一套设置项**(稿子 16:483 / 16:589 逐项对齐):
 
-| 卡 | 展开后的设置项 | 稿子节点 |
-|---|---|---|
-| 脚本一 · 粉丝页提 ID | ☑ 自定关注或粉丝页面提 ID、☑ 自定页面提 ID、☑ 主播去重 + 去重方式、进入页面、**主播 ID**、☑ 使用文本 ID + 路径 | `3:244`(`16:470` / `16:479` / `3:266`) |
-| 脚本二 · 评论页提 ID | ☑ 自定评论页面提 ID、☑ 提取评论的人的粉丝 ID | `16:483` |
-| 脚本三 · 文本 UID 裂变提 ID | ☑ 文本 UID 裂变提 ID、☑ UID 博主去重、进入页面、**裂变文本** | `16:483` |
-| 脚本四 · 关键词视频提 ID | ☑ 关键词视频提 ID、**关键词主播**、☑ 提取主播粉丝 ID、☑ 提取评论区 ID、☑ 提取评论人的粉丝 ID | `16:589` |
+> **v7(2026-10-17)按《需求重整 v2》把这一页的语义清理了一遍**:
+> 删掉 4 个与卡名/下拉同义反复的开关、把 3 处去重收到「功能设置」只出现一次、
+> 给 2 个"勾上却无所指"的开关补上它缺的输入。完整诊断(10 条问题)与 38 个字段的
+> 逐条处置表见根目录 `REQ_id_extract_v2.md`。**只是界面,执行引擎仍未接入。**
+
+| 卡 | 展开后的设置项(v7) |
+|---|---|
+| 脚本一 · 粉丝页提 ID | 从哪个页面取(下拉)· **主播账号** · ☐ 搜索用户提 ID + 关键词 · ☐ 使用文本 ID + 路径 |
+| 脚本二 · 评论页提 ID | 视频从哪来(当前屏幕 / 关键词搜索)· ☐ 再取评论者的粉丝 ID |
+| 脚本三 · 文本 UID 裂变提 ID | **展开哪个列表**(粉丝 / 关注)· **裂变文本** |
+| 脚本四 · 关键词视频提 ID | **关键词** · ☐ 作者的粉丝 ID · ☐ 评论区里的 ID · ☐ 评论者的粉丝 ID |
+
+功能设置按**三组**排:`只要这样的账号` / `数值门槛` / `去重与输出`。
+其中「提取区域」「AI 画像识别(头像·性别·年龄)」当前没有承载者 →
+**灰行 + 「暂不支持」标记**(`W.mutedRow`),不给可操作控件:
+给一个"开得起来、却什么也不会发生"的开关,比不给更糟。
 
-几点实现说明(都写在 `page-id.js` 的注释里):
+几点实现说明(都写在 `page-id.js` / `store.js` 的注释里):
 
 - **`Pages.ID_DETAILS` 是 key → 构建函数的表**,不是一长串 `if`。
-  四个脚本做的事各不相同(粉丝页 / 评论页 / 文本 UID / 关键词视频),设置项也就各不相同;
   表让「哪张卡有什么」一眼可见。早先只有脚本一有内容,另外三张展开是**空的** ——
   用户点开折叠箭头会以为坏了。
 - **`buildToggleRow` 抽出「开关 + 标签 +(勾上才出现的右侧控件)」**。
-  这种形态在四张卡里反复出现,而它有一个必须一起做对的细节:
+  这种形态在卡里反复出现,而它有一个必须一起做对的细节:
   右侧控件要在**关闭时移除**。各写各的很容易只 addView、不管关闭分支。
-- **脚本三的「进入页面」默认是「关注页」**,与脚本一的「粉丝页」不同(稿子的取值)。
-  这类"同一控件在不同卡片上默认值不同"最容易写成共用一个默认值,自检里各有断言。
-- **脚本四的三个「提取…」默认都关**:默认行为只剩「提视频作者的 ID」这一件事,最可预期。
-  反过来(默认全开)会让人不知道它到底抓了什么。
-- **`anchorIds`(主播 ID)标了 `optional: true`**:留空 = 不限定主播、从页面现场取,
+  自检里的判据用 `W.pendingFields`(本页注册过的输入控件)而不是界面文本 ——
+  行内输入框的 placeholder 是**画出来的**,不是 TextView 的 text,按文本找永远找不到。
+- **v7 起两张卡不再共用 `enterPage`**:脚本三改用 `expandList`(只有粉丝/关注两值)。
+  旧值域里的「评论页」「主页」在"展开一个列表"这个语义下并不存在 ——
+  同一个下拉名在两处指两件事,是最容易读糊的一类问题。
+- **脚本四的三个「提取…」用缩进(16dp 左内边距)表达层级**:
+  它们是"在作者之外再取",与上面的关键词不是平级。加符号(`└`)会占字宽、标签一长就被挤。
+- **脚本三「展开哪个列表」默认关注、脚本一「从哪个页面取」默认粉丝页**(稿子的取值)。
+  这类"同类控件在不同卡片上默认值不同"最容易写成共用一个默认值,自检里各有断言。
+- **`anchorIds`(主播账号)标了 `optional: true`**:留空 = 不限定、从当前屏幕的列表直接抓,
   这是真实且合理的状态。审计靠这个标记把「合理的空」和「漏了默认值」分开,
-  所以不能塞一个像 `ID1#ID2#ID3` 那样的假默认值(那是**占位提示**,填进去会被当真去搜)。
-- 新增字段**不需要版本迁移**:都是"缺了就用默认值"的字段,没有 v1 那种
-  「空串其实等于未设置」的语义差异,所以 `SCHEMA_VERSION` 不动。
+  所以不能塞一个像 `账号1#账号2#账号3` 那样的假默认值(那是**占位提示**,填进去会被当真去搜)。
+- **v7 引入了版本迁移 `MIGRATIONS[6]`**,三件事都不许静默:① 脚本三值域收窄,
+  旧「评论页/主页」→ 关注列表;② 卡片里的「主播去重」继承到功能设置的「本地去重」;
+  ③ 旧去重模式「UID 模式」→「跨轮去重」打开。每一条都写进 `problems`,用户能看到改了什么。
 
 ### 切屏与运行态
 
@@ -929,7 +943,7 @@ AutoX 的 `floaty.window` —— 而 `floaty` 恰恰做不到"两个窗口同时
    必须存在、第一行是 `"ui";`、且确实加载了界面产物。这类错误在设备上的表现是
    「以非 UI 模式执行 + 一堆不着边际的报错」,从日志里**看不出根因**,所以必须在 PC 侧拦住。
 
-`tools/smoke.js` 配合 `tools/android-stub.js` 在 PC 上把产物**真实跑一遍**(380 项断言)——
+`tools/smoke.js` 配合 `tools/android-stub.js` 在 PC 上把产物**真实跑一遍**(386 项断言)——
 v6 新增的部分:任务卡直跑 / F3 全局单跑确认(取消与「停止并开始」两条路径)/ 校验顺序
 (卡密 → 权限 → 已选脚本 → 引擎 → 是否在跑)/ 运行记录落盘与上限 / F5 报错态与红色运行条 /
 D3 日志读取(坏行只丢自己)/ `Records` 文案纯函数 / **悬浮球 80+ 项**
@@ -968,7 +982,7 @@ D3 日志读取(坏行只丢自己)/ `Records` 文案纯函数 / **悬浮球
 
 ```bash
 $ node tools/smoke.js
-通过 380 项
+通过 386 项
 SMOKE_ALL_PASS
 ```
 

+ 393 - 219
tiktok-app/dist/tiktok-app.js

@@ -1,6 +1,6 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-15T10:12:23.961Z
+ *  built 2026-10-17T08:34:01.683Z
  *  modules: theme, store, records, device, service, engine, session, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -441,7 +441,12 @@ var U = Theme.util;
 
 var Store = {};
 
-Store.SCHEMA_VERSION = 6;
+/* v7(2026-10-17):ID提取页**清语义**(需求重整 A 档)。
+ * 删掉 4 个与卡片同义反复的开关、把 3 处去重收到「功能设置」只出现一次、
+ * 给两个"勾了却无所指"的开关补上它缺的输入、s3 的「进入页面」换成只含粉丝/关注两值的
+ * `expandList`(原来 4 个值里有 2 个在"展开哪个列表"这个语义下是非法值)。
+ * 迁移见 MIGRATIONS[6]。 */
+Store.SCHEMA_VERSION = 7;
 Store.STORAGE_NAME = "feizhou_app";
 Store.STORAGE_KEY = "config";
 
@@ -453,6 +458,13 @@ Store.TEXT_DIR = "/sdcard/Download/tiktok_app";
 Store.options = {
     dedupe:      ["ID 模式", "UID 模式"],
     enterPage:   ["粉丝页", "评论页", "主页", "关注页"],
+    /* 「从哪个页面取」与「展开哪个列表」是**两件事**,v7 之前共用一个 enterPage,
+     * 于是 s3(文本裂变)里能选到「评论页」—— 而在"展开文本里这些人的列表"这个语义下,
+     * 评论页根本不是合法值。分开之后 s3 只能选粉丝 / 关注。 */
+    expandList:  ["粉丝列表", "关注列表"],
+    /* 脚本二「视频从哪来」:原来只有一个"自定评论页面提 ID"开关,勾上却没说
+     * 从哪条视频开始 —— 补上这个来源维度,那个开关才有意义。 */
+    videoSource: ["当前屏幕", "关键词搜索"],
     aiGender:    ["男", "女"],
     toggle:      ["开启", "关闭"],
     dedupeMode:  ["关注+私信去重", "仅关注去重", "仅私信去重"],
@@ -475,6 +487,9 @@ Store.enum = {
      * 新增候选一律**追加在末尾**:位置即下拉里的顺序,插在中间会让老用户
      * 下次点开下拉时发现选项换了地方。 */
     enterPage:   { "fans": "粉丝页", "comment": "评论页", "home": "主页", "follow": "关注页" },
+    /* v7:只含"能列出一串人的页"。s3 用它,与 enterPage 语义分开。 */
+    expandList:  { "fans": "粉丝列表", "follow": "关注列表" },
+    videoSource: { "screen": "当前屏幕", "keyword": "关键词搜索" },
     aiGender:    { "male": "男", "female": "女" },
     toggle:      { "on": "开启", "off": "关闭" },
     dedupeMode:  { "follow_dm": "关注+私信去重", "follow": "仅关注去重", "dm": "仅私信去重" },
@@ -587,17 +602,18 @@ F.push(
  * 这一屏(并配了「请先选择要运行的脚本」的拦截弹窗),说明"还没选"是一个
  * 真实且合法的初始状态。四个选项里替用户随便挑一个,反而是越权。
  * 脚本一默认展开,因为它承载的设置行最多,收起会让人以为它没内容。 */
+/* ——— 脚本一 · 粉丝页提 ID ———
+ * v7 清理:
+ *   · 删「自定关注或粉丝页面提 ID」—— 它和下面的「从哪个页面取」说的是同一件事,
+ *     留着只会让用户以为"关掉它"和"取消勾选这张卡"有区别;
+ *   · 删「主播去重 + 去重模式」—— 去重收到「功能设置」,全页只出现一次;
+ *   · 「自定页面提 ID」补上它缺的关键词输入(原来勾上之后无所指);
+ *   · 「进入页面」改叫「从哪个页面取」(它取的是人,不是"进入"某页)。 */
 F.push.apply(F, cardFields("tabs.id.scripts.s1", false, true));
 F.push(
-    /* ★ 2026-10-13 规格书 §3 新增的两项(画布 C1 的脚本一展开态):
-     * 「自定关注或粉丝页面提 ID」默认✓、「自定页面提 ID」默认✗。
-     * 它们是**取材入口**的两个开关,与「进入页面」下拉不是一回事:
-     * 前者决定要不要走自定义入口,后者决定进哪一页。 */
-    { path: "tabs.id.scripts.s1.customFansPage", type: "bool", def: true },
-    { path: "tabs.id.scripts.s1.customPage",     type: "bool", def: false },
-    /* 去重是个开关,去重方式才是值 —— 早先只有后者,用户没法把去重整个关掉。 */
-    { path: "tabs.id.scripts.s1.dedupeEnabled", type: "bool", def: true },
-    { path: "tabs.id.scripts.s1.dedupe",    type: "enum", group: "dedupe",    def: "id" },
+    /* 搜索用户:原来是个纯开关,勾上却没说搜什么 —— 补关键词。留空 = 没配,optional */
+    { path: "tabs.id.scripts.s1.customPage",        type: "bool", def: false },
+    { path: "tabs.id.scripts.s1.customPageKeyword", type: "text", def: "", optional: true },
     { path: "tabs.id.scripts.s1.enterPage", type: "enum", group: "enterPage", def: "fans" },
     /* 主播 ID:可填多个,用 # 隔开(设计稿 16:479 的输入提示)。
      * 留空 = 不限定主播、从页面现场取 —— 这是真实且合理的状态,所以标 optional,
@@ -607,29 +623,33 @@ F.push(
     { path: "tabs.id.scripts.s1.textIdPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" }
 );
 
-/* ——— 脚本二 · 评论页提 ID(设计稿 16:483)——— */
+/* ——— 脚本二 · 评论页提 ID(设计稿 16:483)———
+ * v7:「自定评论页面提 ID」这个纯开关换成了**视频从哪来**。
+ * 原开关勾上只说"用自定义的评论页面",却没说那条视频是谁 —— 无从执行。 */
 F.push.apply(F, cardFields("tabs.id.scripts.s2", false, false));
 F.push(
-    { path: "tabs.id.scripts.s2.customCommentPage", type: "bool", def: true },
-    { path: "tabs.id.scripts.s2.commenterFans",     type: "bool", def: false }
+    { path: "tabs.id.scripts.s2.videoSource",   type: "enum", group: "videoSource", def: "screen" },
+    { path: "tabs.id.scripts.s2.videoKeyword",  type: "text", def: "fashion" },
+    { path: "tabs.id.scripts.s2.commenterFans", type: "bool", def: false }
 );
 
-/* ——— 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483)——— */
+/* ——— 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483)———
+ * v7:删「文本 UID 裂变提 ID」(与卡名一字不差,勾不勾都说不通)与「UID 博主去重」
+ * (与脚本一的去重是同一件事,已收到功能设置)。
+ * 「进入页面」换成 expandList —— 这里问的是"展开文本里这些人的哪个列表",
+ * 只有粉丝/关注两个合法答案。 */
 F.push.apply(F, cardFields("tabs.id.scripts.s3", false, false));
 F.push(
-    { path: "tabs.id.scripts.s3.uidFission",      type: "bool", def: true },
-    { path: "tabs.id.scripts.s3.uidDedupe",       type: "bool", def: true },
-    /* 脚本三默认从「关注页」进 —— 与脚本一的「粉丝页」不同,这是设计稿的取值 */
-    { path: "tabs.id.scripts.s3.enterPage",       type: "enum", group: "enterPage", def: "follow" },
+    { path: "tabs.id.scripts.s3.enterPage",       type: "enum", group: "expandList", def: "follow" },
     { path: "tabs.id.scripts.s3.fissionTextPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" }
 );
 
 /* ——— 脚本四 · 关键词视频提 ID(设计稿 16:589)———
+ * v7:删「关键词视频提 ID」(与卡名一字不差)。
  * 「关键词」给的是可直接跑的业务默认值而不是空串:空着的输入框既看不出该填什么,
  * 也让「未配置」和「配了空值」无法区分(与视频功能页的关键词同一套规则)。 */
 F.push.apply(F, cardFields("tabs.id.scripts.s4", false, false));
 F.push(
-    { path: "tabs.id.scripts.s4.keywordEnabled", type: "bool", def: true },
     { path: "tabs.id.scripts.s4.keyword",        type: "text", def: "fashion" },
     /* 三个「提取…」是额外的取材范围,默认都关:默认行为只剩「提视频作者的 ID」这一件事,
      * 最可预期。要更多来源自己勾 —— 反过来(默认全开)会让人不知道它到底抓了什么。 */
@@ -652,6 +672,11 @@ F.push(
      * 「还没填表名」是一个真实且合理的状态,不该被塞一个假默认值。
      * 取值审计靠这个标记把它和「漏了默认值」区分开。 */
     { path: "tabs.id.features.cloudTable",     type: "text", def: "", optional: true },
+    /* v7:本地去重从脚本卡收到这里,全页只出现一次。
+     * 「跨轮去重」是另一件事:与历史已提取的 ID 比对,所以单独一个开关 ——
+     * 原来用「ID 模式 / UID 模式」两个词表达它,用户根本看不出那是在说"要不要跟历史比"。 */
+    { path: "tabs.id.features.dedupeLocal",    type: "bool", def: true },
+    { path: "tabs.id.features.dedupeHistory",  type: "bool", def: false },
     { path: "tabs.id.features.aiAvatar",       type: "bool", def: false },
     { path: "tabs.id.features.aiGender",       type: "enum", group: "aiGender", def: "male" },
     { path: "tabs.id.features.aiAgeMax",       type: "int", min: 0, max: 100, def: 20 },
@@ -902,6 +927,45 @@ Store.MIGRATIONS = {
         } else if (oldTab === "mine") {
             assignPath(cfg, "activeTab", "mine");
         }
+    },
+
+    /**
+     * v6 → v7:ID提取页清语义(需求重整 A 档)。
+     *
+     * 两件必须做的,都是"类型系统看不出来的语义差异":
+     *
+     * ① **s3 的「进入页面」值域收窄**。原来是共用 `enterPage` 的四个值
+     *    (粉丝页/评论页/主页/关注页),但这个字段在脚本三里的意思是
+     *    "展开文本里这些人的哪个**列表**" —— 评论页和主页根本不是列表。
+     *    存量值是这两个的,一律落到「关注列表」(脚本三的设计稿默认值)。
+     *    不处理的话,`normalizeField` 会把非法枚举悄悄退回默认值,
+     *    用户只会看到"我选过的选项自己变了",而不知道发生了什么。
+     *
+     * ② **去重从卡片搬到功能设置**。旧的 `dedupeEnabled` 是用户对"要不要去重"
+     *    的真实表达,不能因为控件搬家就丢掉 —— 把它继承到 `dedupeLocal`。
+     *    旧 `dedupe`(ID 模式 / UID 模式)表达的其实是"跟不跟历史比",
+     *    映射到新增的 `dedupeHistory`("UID 模式"= 按输入文本比 = 留了个文本基准,
+     *    语义上更接近"跨轮")。这条映射**不完美**,所以写进 problems 里说清楚。
+     */
+    6: function (cfg, problems, src) {
+        var oldEnter = readPath(src, "tabs.id.scripts.s3.enterPage");
+        if (oldEnter === "comment" || oldEnter === "home") {
+            assignPath(cfg, "tabs.id.scripts.s3.enterPage", "follow");
+            problems.push("脚本三「进入页面」= " + oldEnter +
+                          " 在「展开哪个列表」的语义下不成立,已改为「关注列表」");
+        }
+
+        var oldDedupe = readPath(src, "tabs.id.scripts.s1.dedupeEnabled");
+        if (typeof oldDedupe === "boolean") {
+            assignPath(cfg, "tabs.id.features.dedupeLocal", oldDedupe);
+            problems.push("「主播去重」已并入功能设置 →「本地去重」,旧值 " + oldDedupe + " 已沿用");
+        }
+
+        var oldMode = readPath(src, "tabs.id.scripts.s1.dedupe");
+        if (oldMode === "text") {
+            assignPath(cfg, "tabs.id.features.dedupeHistory", true);
+            problems.push("旧去重模式「UID 模式」→「跨轮去重」已打开(按输入文本比对历史)");
+        }
     }
 };
 
@@ -2819,9 +2883,19 @@ W.settingRow = function (label, rightView, gapDp) {
  *
  * 命中区含整个标签:只让 16dp 的方框可点,真机上很难戳中。
  */
-W.checkRow = function (label, checked, onToggle) {
+/**
+ * 勾选行。
+ *
+ * @param disabled 置灰且不可点。**为「功能还没做,但字段要保留」的场景准备的**:
+ *                 给了界面一个"开得起来、却什么也不会发生"的开关,比不给更糟
+ *                 (用户会以为它生效了)。置灰至少是诚实的。
+ *                 勾选状态**照常显示**——它代表"用户上次的选择",不是"现在能改的选择"。
+ */
+W.checkRow = function (label, checked, onToggle, disabled) {
     var box = W.checkbox(checked, 16);
-    var labelView = U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false);
+    var off = !!disabled;
+    var labelView = U.text(label, Theme.m.labelSize,
+                           off ? Theme.hex.textMuted : Theme.hex.textSecondary, false);
     labelView.setSingleLine(true);
 
     var line = U.row(false);
@@ -2830,15 +2904,60 @@ W.checkRow = function (label, checked, onToggle) {
     line.addView(labelView);
     line.setPadding(0, U.dp(4), 0, U.dp(4));
 
-    line.setOnClickListener(function () {
-        var next = !box.isChecked();
-        box.set(next);
-        onToggle(next);
-    });
+    if (off) {
+        line.setAlpha(0.55);
+        /* 不可点:这里挂一个**空监听**而不是不挂 —— 不挂的话父容器(卡片)
+         * 的点击会继续冒泡上去,用户点这一行会触发别处的动作。
+         * 注意 setOnClickListener(function(){}) 与 setOnClickListener(null) 行为不同:
+         * 后者等于"没有监听器",照样冒泡(见 README 铁律)。 */
+        line.setOnClickListener(function () { });
+    } else {
+        line.setOnClickListener(function () {
+            var next = !box.isChecked();
+            box.set(next);
+            onToggle(next);
+        });
+    }
 
     return { root: line, isChecked: box.isChecked };
 };
 
+/**
+ * 灰行:**只读展示 + 「暂不支持」标记**。
+ *
+ * 专门给"设置项在界面上占着位置、但执行侧还没有能力实现"的项用
+ * (提取区域 / AI 头像识别 / AI 性别 / AI 年龄)。
+ *
+ * 为什么不是"灰掉的勾选框":勾选框本身就在说"这个可以改",
+ * 灰只是说"现在不能改",两者叠起来仍然在暗示它是个可选项。
+ * 而这里的事实是"它现在根本没有效果" —— 所以不给可操作控件,
+ * 只把用户上次填的值显示出来(保留值、将来接上就能用),点一行看原因。
+ */
+W.mutedRow = function (label, valueText, onTap) {
+    var line = U.row(false);
+    var labelView = U.text(label, Theme.m.labelSize, Theme.hex.textMuted, false);
+    labelView.setSingleLine(true);
+    line.addView(labelView);
+    line.addView(U.flexible());
+
+    if (valueText) {
+        var valueView = U.text(valueText, Theme.m.chipSize, Theme.hex.textFaint, false);
+        valueView.setSingleLine(true);
+        line.addView(valueView);
+        line.addView(U.hgap(6));
+    }
+
+    line.addView(W.chip("暂不支持", "mute").root);
+    line.setPadding(0, U.dp(4), 0, U.dp(4));
+
+    if (onTap) {
+        line.setOnClickListener(onTap);
+    } else {
+        line.setOnClickListener(function () { });
+    }
+    return { root: line };
+};
+
 /* ------------------------------------------------------------ 胶囊 / 标签 */
 
 /* 胶囊的四套色调集中在这里,W.chip 与 W.repaintChip 共用,
@@ -5661,12 +5780,15 @@ Pages.registerScripts = function (tab, list) {
     Pages.SCRIPTS_OF[tab] = list;
 };
 
-/* 四张脚本卡的文案与摘要。摘要只在折叠态显示,展开后由具体设置行替代。 */
+/* 四张脚本卡的文案与摘要。摘要只在折叠态显示,展开后由具体设置行替代。
+ * v7 顺手把 s2 的摘要从「评论裂变」改成「评论者」——
+ * "裂变"这个词在脚本二里其实只表示"再取评论者的粉丝",
+ * 而它在脚本三里又是另一种意思,同一个词两种含义最容易读糊。 */
 Pages.ID_SCRIPTS = [
-    { key: "s1", name: "\u811A\u672C\u4E00 \u00B7 \u7C89\u4E1D\u9875\u63D0 ID" },
-    { key: "s2", name: "\u811A\u672C\u4E8C \u00B7 \u8BC4\u8BBA\u9875\u63D0 ID", summary: "\u8BC4\u8BBA\u88C2\u53D8" },
-    { key: "s3", name: "\u811A\u672C\u4E09 \u00B7 \u6587\u672C UID \u88C2\u53D8\u63D0 ID", summary: "\u6587\u672C UID" },
-    { key: "s4", name: "\u811A\u672C\u56DB \u00B7 \u5173\u952E\u8BCD\u89C6\u9891\u63D0 ID", summary: "\u5173\u952E\u8BCD\u89C6\u9891" }
+    { key: "s1", name: "脚本一 · 粉丝页提 ID" },
+    { key: "s2", name: "脚本二 · 评论页提 ID", summary: "评论者" },
+    { key: "s3", name: "脚本三 · 文本 UID 裂变提 ID", summary: "文本 UID" },
+    { key: "s4", name: "脚本四 · 关键词视频提 ID", summary: "关键词视频" }
 ];
 
 Pages.registerScripts("id", Pages.ID_SCRIPTS);
@@ -5739,168 +5861,194 @@ function buildToggleRow(opts) {
 }
 
 /**
- * 脚本一 · 粉丝页提 ID(设计稿 3:244,2026-10-13 按新稿补齐两项)。
- *
- * 七项:自定关注或粉丝页面提 ID(✓)+ 自定页面提 ID(✗)→ 主播去重(开关 + 去重模式)
- *      → 进入页面 → 主播 ID → 使用文本 ID(开关 + 路径 + 选择)。
+ * 脚本一 · 粉丝页提 ID(设计稿 3:244;v7 清语义)。
  *
- * 前两项是**取材入口**的开关,与「进入页面」下拉不是一回事:
- * 前者决定"要不要走自定义入口",后者决定"进哪一页"。
- * 稿子把它们排在主播去重之前,顺序照抄 —— 取材范围先于去重策略。
+ * v7 改了三处,都是"让每个控件只有一种含义":
+ *   · 删「自定关注或粉丝页面提 ID」—— 它与下面的「从哪个页面取」指的是同一件事,
+ *     两个控件表达一个意思,用户只能猜"关掉它"和"取消勾选这张卡"有什么区别;
+ *   · 删「主播去重 + 去重模式」—— 去重收到「功能设置」,全页只出现一次;
+ *   · 「自定页面提 ID」补上关键词输入 —— 原来勾上之后没说搜什么,无从执行。
  *
- * 「主播去重」和「进入页面」在稿子里同属主播去重那一组的两个下拉,
- * 这里仍拆成两行:一个开关只关自己的下拉,否则用户分不清
- * 「去重方式 = ID 模式」到底属于哪个设置。
+ * 顺序改成「主路径 → 额外来源」:先把"从哪个页面取 + 主播账号"放一起,
+ * 这两项合起来才构成一次完整取材;搜索用户与文本是两个并列的额外入口。
  */
 function buildIdScript1Detail() {
     var base = "tabs.id.scripts.s1.";
     var rows = [];
 
-    rows.push(W.checkRow("\u81EA\u5B9A\u5173\u6CE8\u6216\u7C89\u4E1D\u9875\u9762\u63D0 ID",
-                         Store.getBool(base + "customFansPage"),
-                         function (on) { Store.set(base + "customFansPage", on); }).root);
-
-    rows.push(W.checkRow("\u81EA\u5B9A\u9875\u9762\u63D0 ID",
-                         Store.getBool(base + "customPage"),
-                         function (on) { Store.set(base + "customPage", on); }).root);
-
-    rows.push(buildToggleRow({
-        path: base + "dedupeEnabled",
-        label: "\u4E3B\u64AD\u53BB\u91CD",
-        rerender: true,
-        rightOf: function () { return W.dropdown("dedupe", base + "dedupe").root; }
-    }));
-
-    rows.push(W.settingRow("\u8FDB\u5165\u9875\u9762",
+    /* —— 主路径:主播账号 → 它的哪个列表 —— */
+    rows.push(W.settingRow("从哪个页面取",
         W.dropdown("enterPage", base + "enterPage").root, 8));
 
-    /* 主播 ID:可填多个,用 # 隔开;留空表示不限定、从页面现场取 */
-    rows.push(W.settingRow("\u4E3B\u64AD ID",
+    /* 主播账号:可填多个,用 # 隔开;留空表示不限定、从当前屏幕的列表直接抓 */
+    rows.push(W.settingRow("主播账号",
         W.inlineField({
             path: base + "anchorIds",
-            placeholder: "ID1#ID2#ID3",
+            placeholder: "账号1#账号2#账号3",
             widthDp: 148
         }).root, 8));
 
-    /* 使用文本 ID:勾上后从本地文本读,不再现场抽取。
-     * 这一行的右侧是「路径输入框 + 选择按钮」,比其它行多一个控件,
-     * 所以不用 buildToggleRow(它只接受一个右侧控件)。 */
-    var useBox = W.checkRow("\u4F7F\u7528\u6587\u672C ID",
+    rows.push(W.hint("留空 = 从当前屏幕上的列表直接抓", false));
+
+    /* —— 额外来源 1:搜索用户(补上缺的关键词,否则勾了也不知道搜什么) —— */
+    var pageBox = W.checkRow("搜索用户提 ID",
+                             Store.getBool(base + "customPage"),
+                             function (on) {
+        Store.set(base + "customPage", on);
+        /* 勾上之后右侧要多出关键词输入框,只能重建这一页 */
+        Pages.requestRerender();
+    });
+    var pageRow = U.row();
+    pageRow.addView(pageBox.root);
+    if (Store.getBool(base + "customPage")) {
+        pageRow.addView(U.flexible());
+        pageRow.addView(U.hgap(8));
+        pageRow.addView(W.inlineField({
+            path: base + "customPageKeyword",
+            placeholder: "搜索关键词",
+            widthDp: 100,
+            align: "right"
+        }).root);
+    }
+    rows.push(pageRow);
+
+    /* —— 额外来源 2:本地文本 —— */
+    var useBox = W.checkRow("使用文本 ID",
                             Store.getBool(base + "useTextId"),
                             function (on) {
         Store.set(base + "useTextId", on);
-        /* 勾上之后右侧要多出「选择文件」,只能重建这一页 */
+        /* 勾上之后右侧要多出「路径 + 选择文件」,只能重建这一页 */
         Pages.requestRerender();
     });
 
     /* 路径本身就可在栏位里直接改(可以手输,不一定非走「选择文件」) */
     var pathField = W.inlineField({
         path: base + "textIdPath",
-        placeholder: "\u6587\u672C\u8DEF\u5F84",
+        placeholder: "文本路径",
         widthDp: 110,
         align: "right"
     });
 
     var useRow = U.row();
     useRow.addView(useBox.root);
-    useRow.addView(U.flexible());
-    useRow.addView(pathField.root);
     if (Store.getBool(base + "useTextId")) {
+        useRow.addView(U.flexible());
+        useRow.addView(pathField.root);
         useRow.addView(U.hgap(6));
         useRow.addView(W.filePickerButton(base + "textIdPath", pathField.paint).root);
     }
     rows.push(useRow);
 
+    rows.push(W.hint("以上两项是额外的取材来源,可与主播账号同时使用", false));
+
     var detail = U.column();
     W.stack(detail, rows, 12);
     return detail;
 }
 
-/** 脚本二 · 评论页提 ID(设计稿 16:483)。两项都是开关,都改变「去哪取材」。 */
+/**
+ * 脚本二 · 评论页提 ID(设计稿 16:483;v7 换掉一个纯开关)。
+ *
+ * v7:「自定评论页面提 ID」勾上只说"用自定义的评论页面",却没说**是哪条视频的**
+ * —— 执行时无从下手。换成一个真正决定取材起点的维度:视频从哪来。
+ * ("当前屏幕"= 用户已经自己停在某个评论区,这是最常见也最省事的用法。)
+ */
 function buildIdScript2Detail() {
     var base = "tabs.id.scripts.s2.";
-    var rows = [
-        buildToggleRow({
-            path: base + "customCommentPage",
-            label: "\u81EA\u5B9A\u8BC4\u8BBA\u9875\u9762\u63D0 ID",
-            rerender: false
-        }),
-        buildToggleRow({
-            path: base + "commenterFans",
-            label: "\u63D0\u53D6\u8BC4\u8BBA\u7684\u4EBA\u7684\u7C89\u4E1D ID",
-            rerender: false
-        })
-    ];
+    var rows = [];
+
+    rows.push(W.settingRow("视频从哪来",
+        W.dropdown("videoSource", base + "videoSource", function () {
+            /* 选「关键词搜索」才需要关键词输入,得重建这一页 */
+            Pages.requestRerender();
+        }).root, 8));
+
+    if (Store.get("tabs.id.scripts.s2.videoSource") === "keyword") {
+        rows.push(W.settingRow("搜索关键词",
+            W.inlineField({
+                path: base + "videoKeyword",
+                placeholder: "关键词",
+                widthDp: 148
+            }).root, 8));
+    } else {
+        rows.push(W.hint("当前屏幕 = 用你此刻停在的那条视频的评论区", false));
+    }
+
+    rows.push(buildToggleRow({
+        path: base + "commenterFans",
+        label: "再取评论者的粉丝 ID",
+        rerender: false
+    }));
+
     var detail = U.column();
     W.stack(detail, rows, 12);
     return detail;
 }
 
 /**
- * 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483)。
+ * 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483;v7 删两个开关)。
  *
- * 「裂变文本」用的是 `W.filePicker` —— 设计稿那一行就是「标签 + 路径 + 选择」,
- * 与 UID私信页的「随机私信内容」完全同形,没必要再写一个。
+ * v7:
+ *   · 删「文本 UID 裂变提 ID」—— 与卡片名一字不差,勾不勾都说不通;
+ *   · 删「UID 博主去重」—— 与脚本一的去重是同一件事,已收到「功能设置」;
+ *   · 「进入页面」→「展开哪个列表」,并换成只含粉丝/关注两个值的 `expandList`
+ *     (原来那套 4 个值里有「评论页」「主页」,在"展开一个列表"的语义下根本不存在)。
  */
 function buildIdScript3Detail() {
     var base = "tabs.id.scripts.s3.";
-    var rows = [
-        buildToggleRow({
-            path: base + "uidFission",
-            label: "\u6587\u672C UID \u88C2\u53D8\u63D0 ID",
-            rerender: false
-        }),
-        buildToggleRow({
-            path: base + "uidDedupe",
-            label: "UID \u535A\u4E3B\u53BB\u91CD",
-            rerender: false
-        }),
-        W.settingRow("\u8FDB\u5165\u9875\u9762",
-            W.dropdown("enterPage", base + "enterPage").root, 8),
-        W.filePicker("\u88C2\u53D8\u6587\u672C", base + "fissionTextPath", "\u9009\u62E9").root
-    ];
+    var rows = [];
+
+    rows.push(W.settingRow("展开哪个列表",
+        W.dropdown("expandList", base + "enterPage").root, 8));
+
+    rows.push(W.filePicker("裂变文本", base + "fissionTextPath", "选择").root);
+
+    rows.push(W.hint("对文本里的每个 UID,取它上面选的那个列表", false));
+
     var detail = U.column();
     W.stack(detail, rows, 12);
     return detail;
 }
 
 /**
- * 脚本四 · 关键词视频提 ID(设计稿 16:589)。
+ * 脚本四 · 关键词视频提 ID(设计稿 16:589;v7 删一个开关 + 补层级)。
  *
- * 一行关键词 + 四个开关。四个开关是「取材范围」的层层扩大:
- * 视频作者 → 作者的粉丝 → 评论区的人 → 评论人的粉丝。
- * 层层嵌套的措辞容易看糊,所以顺序按「从近到远」排,与设计稿一致。
+ * v7:
+ *   · 删「关键词视频提 ID」(与卡名一字不差);
+ *   · 三个「提取…」原来是四项平级排列,读不出"第 1 项是根、后 3 项是扩展",
+ *     更读不出"评论区与作者粉丝是同一层、评论者的粉丝更深一层" ——
+ *     现在加缩进 + 一句说明,让层级在视觉上成立。
  */
 function buildIdScript4Detail() {
     var base = "tabs.id.scripts.s4.";
-    var rows = [
-        buildToggleRow({
-            path: base + "keywordEnabled",
-            label: "\u5173\u952E\u8BCD\u89C6\u9891\u63D0 ID",
-            rerender: false
-        }),
-        W.settingRow("\u5173\u952E\u8BCD\u4E3B\u64AD",
-            W.inlineField({
-                path: base + "keyword",
-                placeholder: "\u5173\u952E\u8BCD",
-                widthDp: 148
-            }).root, 8),
-        buildToggleRow({
-            path: base + "anchorFans",
-            label: "\u5173\u952E\u8BCD\u63D0\u53D6\u4E3B\u64AD\u7C89\u4E1D ID",
-            rerender: false
-        }),
-        buildToggleRow({
-            path: base + "commentArea",
-            label: "\u5173\u952E\u8BCD\u63D0\u53D6\u8BC4\u8BBA\u533A ID",
-            rerender: false
-        }),
-        buildToggleRow({
-            path: base + "commenterFans",
-            label: "\u5173\u952E\u8BCD\u63D0\u53D6\u8BC4\u8BBA\u7684\u4EBA\u7684\u7C89\u4E1D ID",
-            rerender: false
-        })
+    var rows = [];
+
+    rows.push(W.settingRow("关键词",
+        W.inlineField({
+            path: base + "keyword",
+            placeholder: "关键词",
+            widthDp: 148
+        }).root, 8));
+
+    rows.push(W.hint("默认只提取视频作者的 ID;下面三项是额外的取材范围", false));
+
+    var extras = [
+        { path: "anchorFans",    label: "作者的粉丝 ID" },
+        { path: "commentArea",   label: "评论区里的 ID" },
+        { path: "commenterFans", label: "评论者的粉丝 ID" }
     ];
+    for (var i = 0; i < extras.length; i++) {
+        var row = buildToggleRow({
+            path: base + extras[i].path,
+            label: extras[i].label,
+            rerender: false
+        });
+        /* 缩进表达层级:这三项是"在作者之外再取",与上面的关键词不是平级。
+         * 用左内边距而不是加 "└" 之类的符号 —— 符号要占字宽,标签一长就会被挤。 */
+        row.setPadding(U.dp(16), U.dp(4), 0, U.dp(4));
+        rows.push(row);
+    }
+
     var detail = U.column();
     W.stack(detail, rows, 12);
     return detail;
@@ -5961,72 +6109,129 @@ function buildIdScriptCard(script) {
 
 /* ------------------------------------------------------------ 功能设置 */
 
+/** 卡内分组标题。
+ * 比 W.sectionTitle 轻一档:功能设置是**一张卡里的三个块**,不是三个页面段,
+ * 用页面级标题会把三组切成三个互不相干的区域。 */
+function featureGroup(title) {
+    var view = U.text(title, 11, Theme.hex.textMuted, true);
+    view.setPadding(0, U.dp(8), 0, 0);
+    return view;
+}
+
 /**
- * 功能设置卡(设计稿 16:733)。
+ * 功能设置卡(设计稿 16:733;v7 重排为三组)。
  *
- * 说明两处刻意的处理:
- *   · 「提取区域」的输入提示与两行说明都保留,因为国家简码的写法(# 分隔)
- *     不看说明是猜不出来的;
- *   · 关注量 / 粉丝量 / 获赞量 / 视频量用 `W.rangeRow` ——
- *     「最小值 ~ 最大值」一个区间占一行,但**两个数各自一个胶囊、各自输入**。
- *     这与 UID私信页的「关注上限 / 私信上限」并不冲突:那边是两个**各自独立成立**的量,
- *     该分开;这里的 min/max 是**同一个量的下界与上界**,拆两行反而像两个不相干的设置。
- *     一行放下也让这张卡短了一半,四个门槛不用滚很久才看完。
+ * v7 做了三件事:
+ *   ① **分三组**:原来 19 个字段平铺,混了"过滤条件 / 检测方式 / 待支持项"三种性质,
+ *      用户读不出哪些会真的丢人。现在按「只要这样的账号 / 数值门槛 / 去重与输出」分组。
+ *   ② **置灰两项**:提取区域、AI 画像(头像·性别·年龄)当前没有承载者 ——
+ *      不再给"能开却无效"的控件,改成灰行 + 「暂不支持」标记,点一行看原因。
+ *      值仍保留在配置里,将来接上直接生效。
+ *   ③ **去重收口**:脚本卡里的「主播去重 + 去重模式」「UID 博主去重」全部搬到这里,
+ *      用「本地去重 / 跨轮去重」两个说人话的开关表达(全页只出现一次)。
+ *
+ * 关注量/粉丝量/获赞量/视频量仍用 `W.rangeRow` —— 「最小值 ~ 最大值」一个区间占一行,
+ * 但两个数各自一个胶囊、各自输入。与 UID私信页的「关注上限 / 私信上限」不冲突:
+ * 那边是两个**各自独立成立**的量,该分开;这里的 min/max 是**同一个量的下界与上界**,
+ * 拆两行反而像两个不相干的设置。
  */
 function buildIdFeatures() {
     var card = W.card(false);
     var base = "tabs.id.features.";
-    var rows = [];
+    var blocks = [];
 
-    /* —— 过滤账户:设计稿里是两个勾选框并排 —— */
-    var privateRow = W.checkRow("\u8FC7\u6EE4\u79C1\u5BC6\u8D26\u6237",
-                                Store.getBool(base + "filterPrivate"),
+    /* ══════════════════ 组 1 · 只要这样的账号 ══════════════════ */
+    blocks.push(featureGroup("只要这样的账号"));
+
+    /* 过滤私密 / 无头像:两个勾选框并排(设计稿 16:743) */
+    var privateRow = W.checkRow("过滤私密账户", Store.getBool(base + "filterPrivate"),
                                 function (on) { Store.set(base + "filterPrivate", on); });
-    var noAvatarRow = W.checkRow("\u8FC7\u6EE4\u65E0\u5934\u50CF\u8D26\u6237",
-                                 Store.getBool(base + "filterNoAvatar"),
+    var noAvatarRow = W.checkRow("过滤无头像账户", Store.getBool(base + "filterNoAvatar"),
                                  function (on) { Store.set(base + "filterNoAvatar", on); });
     var filterLine = U.row();
+    /* weight=1 在这里是安全的:两个控件同源、都只是"勾选框+短标签",
+     * 不会出现一边 MATCH_PARENT 把另一边挤成 0 宽的情况。 */
     U.params(privateRow.root, 0, -2, 1);
     U.params(noAvatarRow.root, 0, -2, 1);
     filterLine.addView(privateRow.root);
     filterLine.addView(noAvatarRow.root);
-    rows.push(filterLine);
-
-    /* —— 提取区域:国家简码 + 条款按钮在同一行(设计稿 16:743) —— */
-    var regionNote = W.chip("\u542B\u5BF9\u5E94\u533A\u57DFID\u6761\u6B3E", "accent");
-    regionNote.root.setOnClickListener(function () {
-        Dialog.alert("\u533A\u57DF ID \u6761\u6B3E",
-            "\u52FE\u9009\u540E\uFF0C\u53EA\u63D0\u53D6\u7B26\u5408\u4E0A\u65B9\u56FD\u5BB6\u7B80\u7801\u7684\u8D26\u53F7\u3002\n\n" +
-            "\u56FD\u5BB6\u7B80\u7801\uFF1A\u5982\u65E5\u672C\u586B JP\uFF1B\u4E2D\u56FD\u53F0\u6E7E\uFF1ATW\uFF1B" +
-            "\u591A\u56FD\u7528 # \u9694\u5F00\u3002\n\u7559\u7A7A\u5219\u5339\u914D\u6240\u6709\u56FD\u5BB6\u3002");
-    });
+    blocks.push(filterLine);
 
-    var regionRow = U.row();
-    regionRow.addView(U.text("\u63D0\u53D6\u533A\u57DF", Theme.m.labelSize, Theme.hex.textSecondary, false));
-    regionRow.addView(U.flexible());
-    regionRow.addView(W.textField(base + "regionCodes", "\u56FD\u5BB6\u7B80\u7801", 108).root);
-    regionRow.addView(U.hgap(6));
-    regionRow.addView(regionNote.root);
-    rows.push(regionRow);
-
-    rows.push(W.hint("\u56FD\u5BB6\u7B80\u7801\uFF1A\u5982\u65E5\u672C\u586B JP\uFF1B\u4E2D\u56FD\u53F0\u6E7E\uFF1ATW\uFF0C\u591A\u56FD\u5219\u7528 # \u9694\u5F00", false));
-    rows.push(W.hint("\u5982\uFF1AJP#TW#CN#US\uFF0C\u4E0D\u586B\u5219\u5339\u914D\u6240\u6709\u56FD\u5BB6", false));
-
-    /* —— 语言 / 昵称检测:两组「标签 + 下拉」并排。
-     * 弹性占位放在**两组之间**,而不是每组各放一个 ——
-     * 后者会把标签和它自己的下拉框拆到两端,读起来不知道哪个配哪个。 */
+    /* 语言区域 + 取样范围。
+     * 「取样范围」原来叫「昵称个签检测」,与「语言区域」并排却看不出两者的关系 ——
+     * 它其实是语言的**取样字段**(在昵称里查 / 在个签里查 / 都查),
+     * 而不是一个独立的过滤条件。改名之后这一行的读法就成立了。 */
     var langRow = U.row();
-    langRow.addView(U.text("\u8BED\u8A00\u533A\u57DF", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    langRow.addView(U.text("语言区域", Theme.m.labelSize, Theme.hex.textSecondary, false));
     langRow.addView(U.hgap(8));
     langRow.addView(W.dropdown("langRegion", base + "langRegion").root);
     langRow.addView(U.flexible());
-    langRow.addView(U.text("\u6635\u79F0\u4E2A\u7B7E\u68C0\u6D4B", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    langRow.addView(U.text("取样范围", Theme.m.labelSize, Theme.hex.textSecondary, false));
     langRow.addView(U.hgap(8));
     langRow.addView(W.dropdown("nicknameCheck", base + "nicknameCheck").root);
-    rows.push(langRow);
+    blocks.push(langRow);
+
+    /* —— 以下两项:执行侧还没有承载者 ——
+     * 不给可操作控件。原来「提取区域」是个输入框 + 一个 chip,说明里还写着"勾选后…",
+     * 但那一行根本没有勾选框 —— 文案与控件自相矛盾,是最容易被当成 bug 的一处。 */
+    blocks.push(W.mutedRow("提取区域", Store.getText(base + "regionCodes"), function () {
+        Dialog.alert("提取区域 · 暂不支持",
+            "TikTok 的用户页**不显示账号所属国家**,没有可靠的地方能读到它,"
+            + "所以这一项目前不会生效。\n\n"
+            + "你填的国家简码(" + Store.getText(base + "regionCodes") + ")已经保留,"
+            + "将来接入地区数据源后会直接生效,不需要重新填。\n\n"
+            + "当前版本的过滤只有:私密、无头像、语言,以及下面的四组数量门槛。");
+    }).root);
+
+    blocks.push(W.mutedRow("AI 画像识别", "头像 · 性别 · 年龄", function () {
+        Dialog.alert("AI 画像识别 · 暂不支持",
+            "AI 头像识别 / AI 性别 / AI 年龄需要人脸与属性模型(本地模型或云端接口),"
+            + "当前版本没有承载者。\n\n"
+            + "这三项原来的设置值都已保留,将来接入后直接生效。\n\n"
+            + "把关掉的道理和「提取区域」一样:与其给一个开了也不发生任何事的开关,"
+            + "不如明确告诉你它现在不参与。");
+    }).root);
+
+    /* ══════════════════ 组 2 · 数值门槛 ══════════════════ */
+    blocks.push(featureGroup("数值门槛"));
+
+    var metrics = [
+        { key: "follow", label: "关注量" },
+        { key: "fans",   label: "粉丝量" },
+        { key: "likes",  label: "获赞量" },
+        { key: "videos", label: "视频量" }
+    ];
+    for (var i = 0; i < metrics.length; i++) {
+        var m = metrics[i];
+        blocks.push(W.rangeRow(base + m.key + "Min", base + m.key + "Max", m.label, "~").root);
+    }
 
-    /* —— 云端去重:勾选后才出现表名与连通测试 —— */
-    var cloudBox = W.checkRow("\u4E91\u7AEF\u53BB\u91CD", Store.getBool(base + "cloudDedupe"),
+    /* ══════════════════ 组 3 · 去重与输出 ══════════════════ */
+    blocks.push(featureGroup("去重与输出"));
+
+    /* 本地去重 / 跨轮去重。
+     * 这两个原来是三处控件(脚本一的「主播去重 + 去重模式」、脚本三的「UID 博主去重」),
+     * 分布在两张卡里,用户根本不知道它们是不是同一件事。现在收到一处、说人话。 */
+    var localBox = W.checkRow("本地去重", Store.getBool(base + "dedupeLocal"),
+                              function (on) { Store.set(base + "dedupeLocal", on); });
+    var historyBox = W.checkRow("跨轮去重", Store.getBool(base + "dedupeHistory"),
+                                function (on) {
+        Store.set(base + "dedupeHistory", on);
+        Pages.requestRerender();      /* 打开后会多一行说明 */
+    });
+    var dedupeRow = U.row();
+    U.params(localBox.root, 0, -2, 1);
+    U.params(historyBox.root, 0, -2, 1);
+    dedupeRow.addView(localBox.root);
+    dedupeRow.addView(historyBox.root);
+    blocks.push(dedupeRow);
+
+    if (Store.getBool(base + "dedupeHistory")) {
+        blocks.push(W.hint("跨轮 = 与历史已提取的 ID 比对,重复的跳过", false));
+    }
+
+    /* 云端去重:勾选后才出现表名与连通测试 */
+    var cloudBox = W.checkRow("云端去重", Store.getBool(base + "cloudDedupe"),
                               function (on) {
         Store.set(base + "cloudDedupe", on);
         Pages.requestRerender();
@@ -6035,63 +6240,32 @@ function buildIdFeatures() {
         var cloudRow = U.row();
         cloudRow.addView(cloudBox.root);
         cloudRow.addView(U.flexible());
-        cloudRow.addView(W.textField(base + "cloudTable", "\u8868\u540D\uFF1A\u5B57\u7B26", 100).root);
+        cloudRow.addView(W.textField(base + "cloudTable", "表名:字符", 100).root);
         cloudRow.addView(U.hgap(6));
-        var testBtn = W.chip("\u8FDE\u901A\u6D4B\u8BD5", "accent");
+        var testBtn = W.chip("连通测试", "accent");
         testBtn.root.setOnClickListener(function () {
             var table = Store.getText(base + "cloudTable");
             /* 如实报告:没有服务端地址时就说没配,不假装测试通过 */
-            Dialog.alert("\u8FDE\u901A\u6D4B\u8BD5",
-                table === "" ? "\u8BF7\u5148\u586B\u5199\u8868\u540D\u3002"
-                             : "\u8868\u540D\uFF1A" + table + "\n\n\u5F53\u524D\u672A\u914D\u7F6E\u4E91\u7AEF\u53BB\u91CD\u670D\u52A1\u5730\u5740\uFF0C\u65E0\u6CD5\u771F\u5B9E\u6D4B\u8BD5\u3002");
+            Dialog.alert("连通测试",
+                table === "" ? "请先填写表名。"
+                             : "表名:" + table + "\n\n当前未配置云端去重服务地址,无法真实测试。");
         });
         cloudRow.addView(testBtn.root);
-        rows.push(cloudRow);
+        blocks.push(cloudRow);
     } else {
-        rows.push(cloudBox.root);
-    }
-
-    /* —— AI 头像识别:勾选后 AI 性别才可改 —— */
-    var aiRow = U.row();
-    var aiBox = W.checkRow("AI \u5934\u50CF\u8BC6\u522B", Store.getBool(base + "aiAvatar"),
-                           function (on) {
-        Store.set(base + "aiAvatar", on);
-        Pages.requestRerender();
-    });
-    aiRow.addView(aiBox.root);
-    aiRow.addView(U.flexible());
-    if (Store.getBool(base + "aiAvatar")) {
-        aiRow.addView(U.text("AI\u6027\u522B", Theme.m.labelSize, Theme.hex.textSecondary, false));
-        aiRow.addView(U.hgap(8));
-        aiRow.addView(W.dropdown("aiGender", base + "aiGender").root);
+        blocks.push(cloudBox.root);
     }
-    rows.push(aiRow);
 
-    rows.push(W.settingRow("AI \u5E74\u9F84 >",
-        W.numberField(base + "aiAgeMax", "AI \u5E74\u9F84").root, 6));
-
-    /* —— 四组数量门槛:一行一个区间,两个数各自独立可改(设计稿 16:781) —— */
-    var metrics = [
-        { key: "follow", label: "\u5173\u6CE8\u91CF" },
-        { key: "fans",   label: "\u7C89\u4E1D\u91CF" },
-        { key: "likes",  label: "\u83B7\u8D5E\u91CF" },
-        { key: "videos", label: "\u89C6\u9891\u91CF" }
-    ];
-    for (var i = 0; i < metrics.length; i++) {
-        var m = metrics[i];
-        rows.push(W.rangeRow(base + m.key + "Min", base + m.key + "Max", m.label, "~").root);
-    }
-
-    /* —— 提取数量上限 —— */
+    /* 提取上限。0 的语义写在文案里 —— 只说"0",用户会以为是"提取 0 个" */
     var stopRow = U.row();
-    stopRow.addView(U.text("\u63D0\u53D6\u7B26\u5408", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    stopRow.addView(U.text("提取满", Theme.m.labelSize, Theme.hex.textSecondary, false));
     stopRow.addView(U.flexible());
-    stopRow.addView(W.numberField(base + "extractLimit", "\u63D0\u53D6\u7B26\u5408").root);
+    stopRow.addView(W.numberField(base + "extractLimit", "提取符合").root);
     stopRow.addView(U.hgap(6));
-    stopRow.addView(U.text("\u4E2A ID \u540E\u505C\u6B62", Theme.m.labelSize, Theme.hex.textMuted, false));
-    rows.push(stopRow);
+    stopRow.addView(U.text("个 ID 后停止(0 = 不限)", Theme.m.labelSize, Theme.hex.textMuted, false));
+    blocks.push(stopRow);
 
-    W.stack(card, rows, 12);
+    W.stack(card, blocks, 12);
     return card;
 }
 

+ 247 - 192
tiktok-app/src/page-id.js

@@ -24,12 +24,15 @@ Pages.registerScripts = function (tab, list) {
     Pages.SCRIPTS_OF[tab] = list;
 };
 
-/* 四张脚本卡的文案与摘要。摘要只在折叠态显示,展开后由具体设置行替代。 */
+/* 四张脚本卡的文案与摘要。摘要只在折叠态显示,展开后由具体设置行替代。
+ * v7 顺手把 s2 的摘要从「评论裂变」改成「评论者」——
+ * "裂变"这个词在脚本二里其实只表示"再取评论者的粉丝",
+ * 而它在脚本三里又是另一种意思,同一个词两种含义最容易读糊。 */
 Pages.ID_SCRIPTS = [
-    { key: "s1", name: "\u811A\u672C\u4E00 \u00B7 \u7C89\u4E1D\u9875\u63D0 ID" },
-    { key: "s2", name: "\u811A\u672C\u4E8C \u00B7 \u8BC4\u8BBA\u9875\u63D0 ID", summary: "\u8BC4\u8BBA\u88C2\u53D8" },
-    { key: "s3", name: "\u811A\u672C\u4E09 \u00B7 \u6587\u672C UID \u88C2\u53D8\u63D0 ID", summary: "\u6587\u672C UID" },
-    { key: "s4", name: "\u811A\u672C\u56DB \u00B7 \u5173\u952E\u8BCD\u89C6\u9891\u63D0 ID", summary: "\u5173\u952E\u8BCD\u89C6\u9891" }
+    { key: "s1", name: "脚本一 · 粉丝页提 ID" },
+    { key: "s2", name: "脚本二 · 评论页提 ID", summary: "评论者" },
+    { key: "s3", name: "脚本三 · 文本 UID 裂变提 ID", summary: "文本 UID" },
+    { key: "s4", name: "脚本四 · 关键词视频提 ID", summary: "关键词视频" }
 ];
 
 Pages.registerScripts("id", Pages.ID_SCRIPTS);
@@ -102,168 +105,194 @@ function buildToggleRow(opts) {
 }
 
 /**
- * 脚本一 · 粉丝页提 ID(设计稿 3:244,2026-10-13 按新稿补齐两项)。
+ * 脚本一 · 粉丝页提 ID(设计稿 3:244;v7 清语义)。
  *
- * 七项:自定关注或粉丝页面提 ID(✓)+ 自定页面提 ID(✗)→ 主播去重(开关 + 去重模式)
- *      → 进入页面 → 主播 ID → 使用文本 ID(开关 + 路径 + 选择)。
+ * v7 改了三处,都是"让每个控件只有一种含义":
+ *   · 删「自定关注或粉丝页面提 ID」—— 它与下面的「从哪个页面取」指的是同一件事,
+ *     两个控件表达一个意思,用户只能猜"关掉它"和"取消勾选这张卡"有什么区别;
+ *   · 删「主播去重 + 去重模式」—— 去重收到「功能设置」,全页只出现一次;
+ *   · 「自定页面提 ID」补上关键词输入 —— 原来勾上之后没说搜什么,无从执行。
  *
- * 前两项是**取材入口**的开关,与「进入页面」下拉不是一回事:
- * 前者决定"要不要走自定义入口",后者决定"进哪一页"。
- * 稿子把它们排在主播去重之前,顺序照抄 —— 取材范围先于去重策略。
- *
- * 「主播去重」和「进入页面」在稿子里同属主播去重那一组的两个下拉,
- * 这里仍拆成两行:一个开关只关自己的下拉,否则用户分不清
- * 「去重方式 = ID 模式」到底属于哪个设置。
+ * 顺序改成「主路径 → 额外来源」:先把"从哪个页面取 + 主播账号"放一起,
+ * 这两项合起来才构成一次完整取材;搜索用户与文本是两个并列的额外入口。
  */
 function buildIdScript1Detail() {
     var base = "tabs.id.scripts.s1.";
     var rows = [];
 
-    rows.push(W.checkRow("\u81EA\u5B9A\u5173\u6CE8\u6216\u7C89\u4E1D\u9875\u9762\u63D0 ID",
-                         Store.getBool(base + "customFansPage"),
-                         function (on) { Store.set(base + "customFansPage", on); }).root);
-
-    rows.push(W.checkRow("\u81EA\u5B9A\u9875\u9762\u63D0 ID",
-                         Store.getBool(base + "customPage"),
-                         function (on) { Store.set(base + "customPage", on); }).root);
-
-    rows.push(buildToggleRow({
-        path: base + "dedupeEnabled",
-        label: "\u4E3B\u64AD\u53BB\u91CD",
-        rerender: true,
-        rightOf: function () { return W.dropdown("dedupe", base + "dedupe").root; }
-    }));
-
-    rows.push(W.settingRow("\u8FDB\u5165\u9875\u9762",
+    /* —— 主路径:主播账号 → 它的哪个列表 —— */
+    rows.push(W.settingRow("从哪个页面取",
         W.dropdown("enterPage", base + "enterPage").root, 8));
 
-    /* 主播 ID:可填多个,用 # 隔开;留空表示不限定、从页面现场取 */
-    rows.push(W.settingRow("\u4E3B\u64AD ID",
+    /* 主播账号:可填多个,用 # 隔开;留空表示不限定、从当前屏幕的列表直接抓 */
+    rows.push(W.settingRow("主播账号",
         W.inlineField({
             path: base + "anchorIds",
-            placeholder: "ID1#ID2#ID3",
+            placeholder: "账号1#账号2#账号3",
             widthDp: 148
         }).root, 8));
 
-    /* 使用文本 ID:勾上后从本地文本读,不再现场抽取。
-     * 这一行的右侧是「路径输入框 + 选择按钮」,比其它行多一个控件,
-     * 所以不用 buildToggleRow(它只接受一个右侧控件)。 */
-    var useBox = W.checkRow("\u4F7F\u7528\u6587\u672C ID",
+    rows.push(W.hint("留空 = 从当前屏幕上的列表直接抓", false));
+
+    /* —— 额外来源 1:搜索用户(补上缺的关键词,否则勾了也不知道搜什么) —— */
+    var pageBox = W.checkRow("搜索用户提 ID",
+                             Store.getBool(base + "customPage"),
+                             function (on) {
+        Store.set(base + "customPage", on);
+        /* 勾上之后右侧要多出关键词输入框,只能重建这一页 */
+        Pages.requestRerender();
+    });
+    var pageRow = U.row();
+    pageRow.addView(pageBox.root);
+    if (Store.getBool(base + "customPage")) {
+        pageRow.addView(U.flexible());
+        pageRow.addView(U.hgap(8));
+        pageRow.addView(W.inlineField({
+            path: base + "customPageKeyword",
+            placeholder: "搜索关键词",
+            widthDp: 100,
+            align: "right"
+        }).root);
+    }
+    rows.push(pageRow);
+
+    /* —— 额外来源 2:本地文本 —— */
+    var useBox = W.checkRow("使用文本 ID",
                             Store.getBool(base + "useTextId"),
                             function (on) {
         Store.set(base + "useTextId", on);
-        /* 勾上之后右侧要多出「选择文件」,只能重建这一页 */
+        /* 勾上之后右侧要多出「路径 + 选择文件」,只能重建这一页 */
         Pages.requestRerender();
     });
 
     /* 路径本身就可在栏位里直接改(可以手输,不一定非走「选择文件」) */
     var pathField = W.inlineField({
         path: base + "textIdPath",
-        placeholder: "\u6587\u672C\u8DEF\u5F84",
+        placeholder: "文本路径",
         widthDp: 110,
         align: "right"
     });
 
     var useRow = U.row();
     useRow.addView(useBox.root);
-    useRow.addView(U.flexible());
-    useRow.addView(pathField.root);
     if (Store.getBool(base + "useTextId")) {
+        useRow.addView(U.flexible());
+        useRow.addView(pathField.root);
         useRow.addView(U.hgap(6));
         useRow.addView(W.filePickerButton(base + "textIdPath", pathField.paint).root);
     }
     rows.push(useRow);
 
+    rows.push(W.hint("以上两项是额外的取材来源,可与主播账号同时使用", false));
+
     var detail = U.column();
     W.stack(detail, rows, 12);
     return detail;
 }
 
-/** 脚本二 · 评论页提 ID(设计稿 16:483)。两项都是开关,都改变「去哪取材」。 */
+/**
+ * 脚本二 · 评论页提 ID(设计稿 16:483;v7 换掉一个纯开关)。
+ *
+ * v7:「自定评论页面提 ID」勾上只说"用自定义的评论页面",却没说**是哪条视频的**
+ * —— 执行时无从下手。换成一个真正决定取材起点的维度:视频从哪来。
+ * ("当前屏幕"= 用户已经自己停在某个评论区,这是最常见也最省事的用法。)
+ */
 function buildIdScript2Detail() {
     var base = "tabs.id.scripts.s2.";
-    var rows = [
-        buildToggleRow({
-            path: base + "customCommentPage",
-            label: "\u81EA\u5B9A\u8BC4\u8BBA\u9875\u9762\u63D0 ID",
-            rerender: false
-        }),
-        buildToggleRow({
-            path: base + "commenterFans",
-            label: "\u63D0\u53D6\u8BC4\u8BBA\u7684\u4EBA\u7684\u7C89\u4E1D ID",
-            rerender: false
-        })
-    ];
+    var rows = [];
+
+    rows.push(W.settingRow("视频从哪来",
+        W.dropdown("videoSource", base + "videoSource", function () {
+            /* 选「关键词搜索」才需要关键词输入,得重建这一页 */
+            Pages.requestRerender();
+        }).root, 8));
+
+    if (Store.get("tabs.id.scripts.s2.videoSource") === "keyword") {
+        rows.push(W.settingRow("搜索关键词",
+            W.inlineField({
+                path: base + "videoKeyword",
+                placeholder: "关键词",
+                widthDp: 148
+            }).root, 8));
+    } else {
+        rows.push(W.hint("当前屏幕 = 用你此刻停在的那条视频的评论区", false));
+    }
+
+    rows.push(buildToggleRow({
+        path: base + "commenterFans",
+        label: "再取评论者的粉丝 ID",
+        rerender: false
+    }));
+
     var detail = U.column();
     W.stack(detail, rows, 12);
     return detail;
 }
 
 /**
- * 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483)。
+ * 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483;v7 删两个开关)。
  *
- * 「裂变文本」用的是 `W.filePicker` —— 设计稿那一行就是「标签 + 路径 + 选择」,
- * 与 UID私信页的「随机私信内容」完全同形,没必要再写一个。
+ * v7:
+ *   · 删「文本 UID 裂变提 ID」—— 与卡片名一字不差,勾不勾都说不通;
+ *   · 删「UID 博主去重」—— 与脚本一的去重是同一件事,已收到「功能设置」;
+ *   · 「进入页面」→「展开哪个列表」,并换成只含粉丝/关注两个值的 `expandList`
+ *     (原来那套 4 个值里有「评论页」「主页」,在"展开一个列表"的语义下根本不存在)。
  */
 function buildIdScript3Detail() {
     var base = "tabs.id.scripts.s3.";
-    var rows = [
-        buildToggleRow({
-            path: base + "uidFission",
-            label: "\u6587\u672C UID \u88C2\u53D8\u63D0 ID",
-            rerender: false
-        }),
-        buildToggleRow({
-            path: base + "uidDedupe",
-            label: "UID \u535A\u4E3B\u53BB\u91CD",
-            rerender: false
-        }),
-        W.settingRow("\u8FDB\u5165\u9875\u9762",
-            W.dropdown("enterPage", base + "enterPage").root, 8),
-        W.filePicker("\u88C2\u53D8\u6587\u672C", base + "fissionTextPath", "\u9009\u62E9").root
-    ];
+    var rows = [];
+
+    rows.push(W.settingRow("展开哪个列表",
+        W.dropdown("expandList", base + "enterPage").root, 8));
+
+    rows.push(W.filePicker("裂变文本", base + "fissionTextPath", "选择").root);
+
+    rows.push(W.hint("对文本里的每个 UID,取它上面选的那个列表", false));
+
     var detail = U.column();
     W.stack(detail, rows, 12);
     return detail;
 }
 
 /**
- * 脚本四 · 关键词视频提 ID(设计稿 16:589)。
+ * 脚本四 · 关键词视频提 ID(设计稿 16:589;v7 删一个开关 + 补层级)。
  *
- * 一行关键词 + 四个开关。四个开关是「取材范围」的层层扩大:
- * 视频作者 → 作者的粉丝 → 评论区的人 → 评论人的粉丝。
- * 层层嵌套的措辞容易看糊,所以顺序按「从近到远」排,与设计稿一致。
+ * v7:
+ *   · 删「关键词视频提 ID」(与卡名一字不差);
+ *   · 三个「提取…」原来是四项平级排列,读不出"第 1 项是根、后 3 项是扩展",
+ *     更读不出"评论区与作者粉丝是同一层、评论者的粉丝更深一层" ——
+ *     现在加缩进 + 一句说明,让层级在视觉上成立。
  */
 function buildIdScript4Detail() {
     var base = "tabs.id.scripts.s4.";
-    var rows = [
-        buildToggleRow({
-            path: base + "keywordEnabled",
-            label: "\u5173\u952E\u8BCD\u89C6\u9891\u63D0 ID",
-            rerender: false
-        }),
-        W.settingRow("\u5173\u952E\u8BCD\u4E3B\u64AD",
-            W.inlineField({
-                path: base + "keyword",
-                placeholder: "\u5173\u952E\u8BCD",
-                widthDp: 148
-            }).root, 8),
-        buildToggleRow({
-            path: base + "anchorFans",
-            label: "\u5173\u952E\u8BCD\u63D0\u53D6\u4E3B\u64AD\u7C89\u4E1D ID",
-            rerender: false
-        }),
-        buildToggleRow({
-            path: base + "commentArea",
-            label: "\u5173\u952E\u8BCD\u63D0\u53D6\u8BC4\u8BBA\u533A ID",
-            rerender: false
-        }),
-        buildToggleRow({
-            path: base + "commenterFans",
-            label: "\u5173\u952E\u8BCD\u63D0\u53D6\u8BC4\u8BBA\u7684\u4EBA\u7684\u7C89\u4E1D ID",
-            rerender: false
-        })
+    var rows = [];
+
+    rows.push(W.settingRow("关键词",
+        W.inlineField({
+            path: base + "keyword",
+            placeholder: "关键词",
+            widthDp: 148
+        }).root, 8));
+
+    rows.push(W.hint("默认只提取视频作者的 ID;下面三项是额外的取材范围", false));
+
+    var extras = [
+        { path: "anchorFans",    label: "作者的粉丝 ID" },
+        { path: "commentArea",   label: "评论区里的 ID" },
+        { path: "commenterFans", label: "评论者的粉丝 ID" }
     ];
+    for (var i = 0; i < extras.length; i++) {
+        var row = buildToggleRow({
+            path: base + extras[i].path,
+            label: extras[i].label,
+            rerender: false
+        });
+        /* 缩进表达层级:这三项是"在作者之外再取",与上面的关键词不是平级。
+         * 用左内边距而不是加 "└" 之类的符号 —— 符号要占字宽,标签一长就会被挤。 */
+        row.setPadding(U.dp(16), U.dp(4), 0, U.dp(4));
+        rows.push(row);
+    }
+
     var detail = U.column();
     W.stack(detail, rows, 12);
     return detail;
@@ -324,72 +353,129 @@ function buildIdScriptCard(script) {
 
 /* ------------------------------------------------------------ 功能设置 */
 
+/** 卡内分组标题。
+ * 比 W.sectionTitle 轻一档:功能设置是**一张卡里的三个块**,不是三个页面段,
+ * 用页面级标题会把三组切成三个互不相干的区域。 */
+function featureGroup(title) {
+    var view = U.text(title, 11, Theme.hex.textMuted, true);
+    view.setPadding(0, U.dp(8), 0, 0);
+    return view;
+}
+
 /**
- * 功能设置卡(设计稿 16:733)。
+ * 功能设置卡(设计稿 16:733;v7 重排为三组)。
+ *
+ * v7 做了三件事:
+ *   ① **分三组**:原来 19 个字段平铺,混了"过滤条件 / 检测方式 / 待支持项"三种性质,
+ *      用户读不出哪些会真的丢人。现在按「只要这样的账号 / 数值门槛 / 去重与输出」分组。
+ *   ② **置灰两项**:提取区域、AI 画像(头像·性别·年龄)当前没有承载者 ——
+ *      不再给"能开却无效"的控件,改成灰行 + 「暂不支持」标记,点一行看原因。
+ *      值仍保留在配置里,将来接上直接生效。
+ *   ③ **去重收口**:脚本卡里的「主播去重 + 去重模式」「UID 博主去重」全部搬到这里,
+ *      用「本地去重 / 跨轮去重」两个说人话的开关表达(全页只出现一次)。
  *
- * 说明两处刻意的处理:
- *   · 「提取区域」的输入提示与两行说明都保留,因为国家简码的写法(# 分隔)
- *     不看说明是猜不出来的;
- *   · 关注量 / 粉丝量 / 获赞量 / 视频量用 `W.rangeRow` ——
- *     「最小值 ~ 最大值」一个区间占一行,但**两个数各自一个胶囊、各自输入**。
- *     这与 UID私信页的「关注上限 / 私信上限」并不冲突:那边是两个**各自独立成立**的量,
- *     该分开;这里的 min/max 是**同一个量的下界与上界**,拆两行反而像两个不相干的设置。
- *     一行放下也让这张卡短了一半,四个门槛不用滚很久才看完。
+ * 关注量/粉丝量/获赞量/视频量仍用 `W.rangeRow` —— 「最小值 ~ 最大值」一个区间占一行,
+ * 但两个数各自一个胶囊、各自输入。与 UID私信页的「关注上限 / 私信上限」不冲突:
+ * 那边是两个**各自独立成立**的量,该分开;这里的 min/max 是**同一个量的下界与上界**,
+ * 拆两行反而像两个不相干的设置。
  */
 function buildIdFeatures() {
     var card = W.card(false);
     var base = "tabs.id.features.";
-    var rows = [];
+    var blocks = [];
+
+    /* ══════════════════ 组 1 · 只要这样的账号 ══════════════════ */
+    blocks.push(featureGroup("只要这样的账号"));
 
-    /* —— 过滤账户:设计稿里是两个勾选框并排 —— */
-    var privateRow = W.checkRow("\u8FC7\u6EE4\u79C1\u5BC6\u8D26\u6237",
-                                Store.getBool(base + "filterPrivate"),
+    /* 过滤私密 / 无头像:两个勾选框并排(设计稿 16:743) */
+    var privateRow = W.checkRow("过滤私密账户", Store.getBool(base + "filterPrivate"),
                                 function (on) { Store.set(base + "filterPrivate", on); });
-    var noAvatarRow = W.checkRow("\u8FC7\u6EE4\u65E0\u5934\u50CF\u8D26\u6237",
-                                 Store.getBool(base + "filterNoAvatar"),
+    var noAvatarRow = W.checkRow("过滤无头像账户", Store.getBool(base + "filterNoAvatar"),
                                  function (on) { Store.set(base + "filterNoAvatar", on); });
     var filterLine = U.row();
+    /* weight=1 在这里是安全的:两个控件同源、都只是"勾选框+短标签",
+     * 不会出现一边 MATCH_PARENT 把另一边挤成 0 宽的情况。 */
     U.params(privateRow.root, 0, -2, 1);
     U.params(noAvatarRow.root, 0, -2, 1);
     filterLine.addView(privateRow.root);
     filterLine.addView(noAvatarRow.root);
-    rows.push(filterLine);
-
-    /* —— 提取区域:国家简码 + 条款按钮在同一行(设计稿 16:743) —— */
-    var regionNote = W.chip("\u542B\u5BF9\u5E94\u533A\u57DFID\u6761\u6B3E", "accent");
-    regionNote.root.setOnClickListener(function () {
-        Dialog.alert("\u533A\u57DF ID \u6761\u6B3E",
-            "\u52FE\u9009\u540E\uFF0C\u53EA\u63D0\u53D6\u7B26\u5408\u4E0A\u65B9\u56FD\u5BB6\u7B80\u7801\u7684\u8D26\u53F7\u3002\n\n" +
-            "\u56FD\u5BB6\u7B80\u7801\uFF1A\u5982\u65E5\u672C\u586B JP\uFF1B\u4E2D\u56FD\u53F0\u6E7E\uFF1ATW\uFF1B" +
-            "\u591A\u56FD\u7528 # \u9694\u5F00\u3002\n\u7559\u7A7A\u5219\u5339\u914D\u6240\u6709\u56FD\u5BB6\u3002");
-    });
-
-    var regionRow = U.row();
-    regionRow.addView(U.text("\u63D0\u53D6\u533A\u57DF", Theme.m.labelSize, Theme.hex.textSecondary, false));
-    regionRow.addView(U.flexible());
-    regionRow.addView(W.textField(base + "regionCodes", "\u56FD\u5BB6\u7B80\u7801", 108).root);
-    regionRow.addView(U.hgap(6));
-    regionRow.addView(regionNote.root);
-    rows.push(regionRow);
-
-    rows.push(W.hint("\u56FD\u5BB6\u7B80\u7801\uFF1A\u5982\u65E5\u672C\u586B JP\uFF1B\u4E2D\u56FD\u53F0\u6E7E\uFF1ATW\uFF0C\u591A\u56FD\u5219\u7528 # \u9694\u5F00", false));
-    rows.push(W.hint("\u5982\uFF1AJP#TW#CN#US\uFF0C\u4E0D\u586B\u5219\u5339\u914D\u6240\u6709\u56FD\u5BB6", false));
+    blocks.push(filterLine);
 
-    /* —— 语言 / 昵称检测:两组「标签 + 下拉」并排。
-     * 弹性占位放在**两组之间**,而不是每组各放一个 ——
-     * 后者会把标签和它自己的下拉框拆到两端,读起来不知道哪个配哪个。 */
+    /* 语言区域 + 取样范围。
+     * 「取样范围」原来叫「昵称个签检测」,与「语言区域」并排却看不出两者的关系 ——
+     * 它其实是语言的**取样字段**(在昵称里查 / 在个签里查 / 都查),
+     * 而不是一个独立的过滤条件。改名之后这一行的读法就成立了。 */
     var langRow = U.row();
-    langRow.addView(U.text("\u8BED\u8A00\u533A\u57DF", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    langRow.addView(U.text("语言区域", Theme.m.labelSize, Theme.hex.textSecondary, false));
     langRow.addView(U.hgap(8));
     langRow.addView(W.dropdown("langRegion", base + "langRegion").root);
     langRow.addView(U.flexible());
-    langRow.addView(U.text("\u6635\u79F0\u4E2A\u7B7E\u68C0\u6D4B", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    langRow.addView(U.text("取样范围", Theme.m.labelSize, Theme.hex.textSecondary, false));
     langRow.addView(U.hgap(8));
     langRow.addView(W.dropdown("nicknameCheck", base + "nicknameCheck").root);
-    rows.push(langRow);
+    blocks.push(langRow);
+
+    /* —— 以下两项:执行侧还没有承载者 ——
+     * 不给可操作控件。原来「提取区域」是个输入框 + 一个 chip,说明里还写着"勾选后…",
+     * 但那一行根本没有勾选框 —— 文案与控件自相矛盾,是最容易被当成 bug 的一处。 */
+    blocks.push(W.mutedRow("提取区域", Store.getText(base + "regionCodes"), function () {
+        Dialog.alert("提取区域 · 暂不支持",
+            "TikTok 的用户页**不显示账号所属国家**,没有可靠的地方能读到它,"
+            + "所以这一项目前不会生效。\n\n"
+            + "你填的国家简码(" + Store.getText(base + "regionCodes") + ")已经保留,"
+            + "将来接入地区数据源后会直接生效,不需要重新填。\n\n"
+            + "当前版本的过滤只有:私密、无头像、语言,以及下面的四组数量门槛。");
+    }).root);
+
+    blocks.push(W.mutedRow("AI 画像识别", "头像 · 性别 · 年龄", function () {
+        Dialog.alert("AI 画像识别 · 暂不支持",
+            "AI 头像识别 / AI 性别 / AI 年龄需要人脸与属性模型(本地模型或云端接口),"
+            + "当前版本没有承载者。\n\n"
+            + "这三项原来的设置值都已保留,将来接入后直接生效。\n\n"
+            + "把关掉的道理和「提取区域」一样:与其给一个开了也不发生任何事的开关,"
+            + "不如明确告诉你它现在不参与。");
+    }).root);
+
+    /* ══════════════════ 组 2 · 数值门槛 ══════════════════ */
+    blocks.push(featureGroup("数值门槛"));
+
+    var metrics = [
+        { key: "follow", label: "关注量" },
+        { key: "fans",   label: "粉丝量" },
+        { key: "likes",  label: "获赞量" },
+        { key: "videos", label: "视频量" }
+    ];
+    for (var i = 0; i < metrics.length; i++) {
+        var m = metrics[i];
+        blocks.push(W.rangeRow(base + m.key + "Min", base + m.key + "Max", m.label, "~").root);
+    }
+
+    /* ══════════════════ 组 3 · 去重与输出 ══════════════════ */
+    blocks.push(featureGroup("去重与输出"));
+
+    /* 本地去重 / 跨轮去重。
+     * 这两个原来是三处控件(脚本一的「主播去重 + 去重模式」、脚本三的「UID 博主去重」),
+     * 分布在两张卡里,用户根本不知道它们是不是同一件事。现在收到一处、说人话。 */
+    var localBox = W.checkRow("本地去重", Store.getBool(base + "dedupeLocal"),
+                              function (on) { Store.set(base + "dedupeLocal", on); });
+    var historyBox = W.checkRow("跨轮去重", Store.getBool(base + "dedupeHistory"),
+                                function (on) {
+        Store.set(base + "dedupeHistory", on);
+        Pages.requestRerender();      /* 打开后会多一行说明 */
+    });
+    var dedupeRow = U.row();
+    U.params(localBox.root, 0, -2, 1);
+    U.params(historyBox.root, 0, -2, 1);
+    dedupeRow.addView(localBox.root);
+    dedupeRow.addView(historyBox.root);
+    blocks.push(dedupeRow);
+
+    if (Store.getBool(base + "dedupeHistory")) {
+        blocks.push(W.hint("跨轮 = 与历史已提取的 ID 比对,重复的跳过", false));
+    }
 
-    /* —— 云端去重:勾选后才出现表名与连通测试 —— */
-    var cloudBox = W.checkRow("\u4E91\u7AEF\u53BB\u91CD", Store.getBool(base + "cloudDedupe"),
+    /* 云端去重:勾选后才出现表名与连通测试 */
+    var cloudBox = W.checkRow("云端去重", Store.getBool(base + "cloudDedupe"),
                               function (on) {
         Store.set(base + "cloudDedupe", on);
         Pages.requestRerender();
@@ -398,63 +484,32 @@ function buildIdFeatures() {
         var cloudRow = U.row();
         cloudRow.addView(cloudBox.root);
         cloudRow.addView(U.flexible());
-        cloudRow.addView(W.textField(base + "cloudTable", "\u8868\u540D\uFF1A\u5B57\u7B26", 100).root);
+        cloudRow.addView(W.textField(base + "cloudTable", "表名:字符", 100).root);
         cloudRow.addView(U.hgap(6));
-        var testBtn = W.chip("\u8FDE\u901A\u6D4B\u8BD5", "accent");
+        var testBtn = W.chip("连通测试", "accent");
         testBtn.root.setOnClickListener(function () {
             var table = Store.getText(base + "cloudTable");
             /* 如实报告:没有服务端地址时就说没配,不假装测试通过 */
-            Dialog.alert("\u8FDE\u901A\u6D4B\u8BD5",
-                table === "" ? "\u8BF7\u5148\u586B\u5199\u8868\u540D\u3002"
-                             : "\u8868\u540D\uFF1A" + table + "\n\n\u5F53\u524D\u672A\u914D\u7F6E\u4E91\u7AEF\u53BB\u91CD\u670D\u52A1\u5730\u5740\uFF0C\u65E0\u6CD5\u771F\u5B9E\u6D4B\u8BD5\u3002");
+            Dialog.alert("连通测试",
+                table === "" ? "请先填写表名。"
+                             : "表名:" + table + "\n\n当前未配置云端去重服务地址,无法真实测试。");
         });
         cloudRow.addView(testBtn.root);
-        rows.push(cloudRow);
+        blocks.push(cloudRow);
     } else {
-        rows.push(cloudBox.root);
-    }
-
-    /* —— AI 头像识别:勾选后 AI 性别才可改 —— */
-    var aiRow = U.row();
-    var aiBox = W.checkRow("AI \u5934\u50CF\u8BC6\u522B", Store.getBool(base + "aiAvatar"),
-                           function (on) {
-        Store.set(base + "aiAvatar", on);
-        Pages.requestRerender();
-    });
-    aiRow.addView(aiBox.root);
-    aiRow.addView(U.flexible());
-    if (Store.getBool(base + "aiAvatar")) {
-        aiRow.addView(U.text("AI\u6027\u522B", Theme.m.labelSize, Theme.hex.textSecondary, false));
-        aiRow.addView(U.hgap(8));
-        aiRow.addView(W.dropdown("aiGender", base + "aiGender").root);
-    }
-    rows.push(aiRow);
-
-    rows.push(W.settingRow("AI \u5E74\u9F84 >",
-        W.numberField(base + "aiAgeMax", "AI \u5E74\u9F84").root, 6));
-
-    /* —— 四组数量门槛:一行一个区间,两个数各自独立可改(设计稿 16:781) —— */
-    var metrics = [
-        { key: "follow", label: "\u5173\u6CE8\u91CF" },
-        { key: "fans",   label: "\u7C89\u4E1D\u91CF" },
-        { key: "likes",  label: "\u83B7\u8D5E\u91CF" },
-        { key: "videos", label: "\u89C6\u9891\u91CF" }
-    ];
-    for (var i = 0; i < metrics.length; i++) {
-        var m = metrics[i];
-        rows.push(W.rangeRow(base + m.key + "Min", base + m.key + "Max", m.label, "~").root);
+        blocks.push(cloudBox.root);
     }
 
-    /* —— 提取数量上限 —— */
+    /* 提取上限。0 的语义写在文案里 —— 只说"0",用户会以为是"提取 0 个" */
     var stopRow = U.row();
-    stopRow.addView(U.text("\u63D0\u53D6\u7B26\u5408", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    stopRow.addView(U.text("提取满", Theme.m.labelSize, Theme.hex.textSecondary, false));
     stopRow.addView(U.flexible());
-    stopRow.addView(W.numberField(base + "extractLimit", "\u63D0\u53D6\u7B26\u5408").root);
+    stopRow.addView(W.numberField(base + "extractLimit", "提取符合").root);
     stopRow.addView(U.hgap(6));
-    stopRow.addView(U.text("\u4E2A ID \u540E\u505C\u6B62", Theme.m.labelSize, Theme.hex.textMuted, false));
-    rows.push(stopRow);
+    stopRow.addView(U.text("个 ID 后停止(0 = 不限)", Theme.m.labelSize, Theme.hex.textMuted, false));
+    blocks.push(stopRow);
 
-    W.stack(card, rows, 12);
+    W.stack(card, blocks, 12);
     return card;
 }
 

+ 83 - 19
tiktok-app/src/store.js

@@ -17,7 +17,12 @@
 
 var Store = {};
 
-Store.SCHEMA_VERSION = 6;
+/* v7(2026-10-17):ID提取页**清语义**(需求重整 A 档)。
+ * 删掉 4 个与卡片同义反复的开关、把 3 处去重收到「功能设置」只出现一次、
+ * 给两个"勾了却无所指"的开关补上它缺的输入、s3 的「进入页面」换成只含粉丝/关注两值的
+ * `expandList`(原来 4 个值里有 2 个在"展开哪个列表"这个语义下是非法值)。
+ * 迁移见 MIGRATIONS[6]。 */
+Store.SCHEMA_VERSION = 7;
 Store.STORAGE_NAME = "feizhou_app";
 Store.STORAGE_KEY = "config";
 
@@ -29,6 +34,13 @@ Store.TEXT_DIR = "/sdcard/Download/tiktok_app";
 Store.options = {
     dedupe:      ["ID 模式", "UID 模式"],
     enterPage:   ["粉丝页", "评论页", "主页", "关注页"],
+    /* 「从哪个页面取」与「展开哪个列表」是**两件事**,v7 之前共用一个 enterPage,
+     * 于是 s3(文本裂变)里能选到「评论页」—— 而在"展开文本里这些人的列表"这个语义下,
+     * 评论页根本不是合法值。分开之后 s3 只能选粉丝 / 关注。 */
+    expandList:  ["粉丝列表", "关注列表"],
+    /* 脚本二「视频从哪来」:原来只有一个"自定评论页面提 ID"开关,勾上却没说
+     * 从哪条视频开始 —— 补上这个来源维度,那个开关才有意义。 */
+    videoSource: ["当前屏幕", "关键词搜索"],
     aiGender:    ["男", "女"],
     toggle:      ["开启", "关闭"],
     dedupeMode:  ["关注+私信去重", "仅关注去重", "仅私信去重"],
@@ -51,6 +63,9 @@ Store.enum = {
      * 新增候选一律**追加在末尾**:位置即下拉里的顺序,插在中间会让老用户
      * 下次点开下拉时发现选项换了地方。 */
     enterPage:   { "fans": "粉丝页", "comment": "评论页", "home": "主页", "follow": "关注页" },
+    /* v7:只含"能列出一串人的页"。s3 用它,与 enterPage 语义分开。 */
+    expandList:  { "fans": "粉丝列表", "follow": "关注列表" },
+    videoSource: { "screen": "当前屏幕", "keyword": "关键词搜索" },
     aiGender:    { "male": "男", "female": "女" },
     toggle:      { "on": "开启", "off": "关闭" },
     dedupeMode:  { "follow_dm": "关注+私信去重", "follow": "仅关注去重", "dm": "仅私信去重" },
@@ -163,17 +178,18 @@ F.push(
  * 这一屏(并配了「请先选择要运行的脚本」的拦截弹窗),说明"还没选"是一个
  * 真实且合法的初始状态。四个选项里替用户随便挑一个,反而是越权。
  * 脚本一默认展开,因为它承载的设置行最多,收起会让人以为它没内容。 */
+/* ——— 脚本一 · 粉丝页提 ID ———
+ * v7 清理:
+ *   · 删「自定关注或粉丝页面提 ID」—— 它和下面的「从哪个页面取」说的是同一件事,
+ *     留着只会让用户以为"关掉它"和"取消勾选这张卡"有区别;
+ *   · 删「主播去重 + 去重模式」—— 去重收到「功能设置」,全页只出现一次;
+ *   · 「自定页面提 ID」补上它缺的关键词输入(原来勾上之后无所指);
+ *   · 「进入页面」改叫「从哪个页面取」(它取的是人,不是"进入"某页)。 */
 F.push.apply(F, cardFields("tabs.id.scripts.s1", false, true));
 F.push(
-    /* ★ 2026-10-13 规格书 §3 新增的两项(画布 C1 的脚本一展开态):
-     * 「自定关注或粉丝页面提 ID」默认✓、「自定页面提 ID」默认✗。
-     * 它们是**取材入口**的两个开关,与「进入页面」下拉不是一回事:
-     * 前者决定要不要走自定义入口,后者决定进哪一页。 */
-    { path: "tabs.id.scripts.s1.customFansPage", type: "bool", def: true },
-    { path: "tabs.id.scripts.s1.customPage",     type: "bool", def: false },
-    /* 去重是个开关,去重方式才是值 —— 早先只有后者,用户没法把去重整个关掉。 */
-    { path: "tabs.id.scripts.s1.dedupeEnabled", type: "bool", def: true },
-    { path: "tabs.id.scripts.s1.dedupe",    type: "enum", group: "dedupe",    def: "id" },
+    /* 搜索用户:原来是个纯开关,勾上却没说搜什么 —— 补关键词。留空 = 没配,optional */
+    { path: "tabs.id.scripts.s1.customPage",        type: "bool", def: false },
+    { path: "tabs.id.scripts.s1.customPageKeyword", type: "text", def: "", optional: true },
     { path: "tabs.id.scripts.s1.enterPage", type: "enum", group: "enterPage", def: "fans" },
     /* 主播 ID:可填多个,用 # 隔开(设计稿 16:479 的输入提示)。
      * 留空 = 不限定主播、从页面现场取 —— 这是真实且合理的状态,所以标 optional,
@@ -183,29 +199,33 @@ F.push(
     { path: "tabs.id.scripts.s1.textIdPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" }
 );
 
-/* ——— 脚本二 · 评论页提 ID(设计稿 16:483)——— */
+/* ——— 脚本二 · 评论页提 ID(设计稿 16:483)———
+ * v7:「自定评论页面提 ID」这个纯开关换成了**视频从哪来**。
+ * 原开关勾上只说"用自定义的评论页面",却没说那条视频是谁 —— 无从执行。 */
 F.push.apply(F, cardFields("tabs.id.scripts.s2", false, false));
 F.push(
-    { path: "tabs.id.scripts.s2.customCommentPage", type: "bool", def: true },
-    { path: "tabs.id.scripts.s2.commenterFans",     type: "bool", def: false }
+    { path: "tabs.id.scripts.s2.videoSource",   type: "enum", group: "videoSource", def: "screen" },
+    { path: "tabs.id.scripts.s2.videoKeyword",  type: "text", def: "fashion" },
+    { path: "tabs.id.scripts.s2.commenterFans", type: "bool", def: false }
 );
 
-/* ——— 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483)——— */
+/* ——— 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483)———
+ * v7:删「文本 UID 裂变提 ID」(与卡名一字不差,勾不勾都说不通)与「UID 博主去重」
+ * (与脚本一的去重是同一件事,已收到功能设置)。
+ * 「进入页面」换成 expandList —— 这里问的是"展开文本里这些人的哪个列表",
+ * 只有粉丝/关注两个合法答案。 */
 F.push.apply(F, cardFields("tabs.id.scripts.s3", false, false));
 F.push(
-    { path: "tabs.id.scripts.s3.uidFission",      type: "bool", def: true },
-    { path: "tabs.id.scripts.s3.uidDedupe",       type: "bool", def: true },
-    /* 脚本三默认从「关注页」进 —— 与脚本一的「粉丝页」不同,这是设计稿的取值 */
-    { path: "tabs.id.scripts.s3.enterPage",       type: "enum", group: "enterPage", def: "follow" },
+    { path: "tabs.id.scripts.s3.enterPage",       type: "enum", group: "expandList", def: "follow" },
     { path: "tabs.id.scripts.s3.fissionTextPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" }
 );
 
 /* ——— 脚本四 · 关键词视频提 ID(设计稿 16:589)———
+ * v7:删「关键词视频提 ID」(与卡名一字不差)。
  * 「关键词」给的是可直接跑的业务默认值而不是空串:空着的输入框既看不出该填什么,
  * 也让「未配置」和「配了空值」无法区分(与视频功能页的关键词同一套规则)。 */
 F.push.apply(F, cardFields("tabs.id.scripts.s4", false, false));
 F.push(
-    { path: "tabs.id.scripts.s4.keywordEnabled", type: "bool", def: true },
     { path: "tabs.id.scripts.s4.keyword",        type: "text", def: "fashion" },
     /* 三个「提取…」是额外的取材范围,默认都关:默认行为只剩「提视频作者的 ID」这一件事,
      * 最可预期。要更多来源自己勾 —— 反过来(默认全开)会让人不知道它到底抓了什么。 */
@@ -228,6 +248,11 @@ F.push(
      * 「还没填表名」是一个真实且合理的状态,不该被塞一个假默认值。
      * 取值审计靠这个标记把它和「漏了默认值」区分开。 */
     { path: "tabs.id.features.cloudTable",     type: "text", def: "", optional: true },
+    /* v7:本地去重从脚本卡收到这里,全页只出现一次。
+     * 「跨轮去重」是另一件事:与历史已提取的 ID 比对,所以单独一个开关 ——
+     * 原来用「ID 模式 / UID 模式」两个词表达它,用户根本看不出那是在说"要不要跟历史比"。 */
+    { path: "tabs.id.features.dedupeLocal",    type: "bool", def: true },
+    { path: "tabs.id.features.dedupeHistory",  type: "bool", def: false },
     { path: "tabs.id.features.aiAvatar",       type: "bool", def: false },
     { path: "tabs.id.features.aiGender",       type: "enum", group: "aiGender", def: "male" },
     { path: "tabs.id.features.aiAgeMax",       type: "int", min: 0, max: 100, def: 20 },
@@ -478,6 +503,45 @@ Store.MIGRATIONS = {
         } else if (oldTab === "mine") {
             assignPath(cfg, "activeTab", "mine");
         }
+    },
+
+    /**
+     * v6 → v7:ID提取页清语义(需求重整 A 档)。
+     *
+     * 两件必须做的,都是"类型系统看不出来的语义差异":
+     *
+     * ① **s3 的「进入页面」值域收窄**。原来是共用 `enterPage` 的四个值
+     *    (粉丝页/评论页/主页/关注页),但这个字段在脚本三里的意思是
+     *    "展开文本里这些人的哪个**列表**" —— 评论页和主页根本不是列表。
+     *    存量值是这两个的,一律落到「关注列表」(脚本三的设计稿默认值)。
+     *    不处理的话,`normalizeField` 会把非法枚举悄悄退回默认值,
+     *    用户只会看到"我选过的选项自己变了",而不知道发生了什么。
+     *
+     * ② **去重从卡片搬到功能设置**。旧的 `dedupeEnabled` 是用户对"要不要去重"
+     *    的真实表达,不能因为控件搬家就丢掉 —— 把它继承到 `dedupeLocal`。
+     *    旧 `dedupe`(ID 模式 / UID 模式)表达的其实是"跟不跟历史比",
+     *    映射到新增的 `dedupeHistory`("UID 模式"= 按输入文本比 = 留了个文本基准,
+     *    语义上更接近"跨轮")。这条映射**不完美**,所以写进 problems 里说清楚。
+     */
+    6: function (cfg, problems, src) {
+        var oldEnter = readPath(src, "tabs.id.scripts.s3.enterPage");
+        if (oldEnter === "comment" || oldEnter === "home") {
+            assignPath(cfg, "tabs.id.scripts.s3.enterPage", "follow");
+            problems.push("脚本三「进入页面」= " + oldEnter +
+                          " 在「展开哪个列表」的语义下不成立,已改为「关注列表」");
+        }
+
+        var oldDedupe = readPath(src, "tabs.id.scripts.s1.dedupeEnabled");
+        if (typeof oldDedupe === "boolean") {
+            assignPath(cfg, "tabs.id.features.dedupeLocal", oldDedupe);
+            problems.push("「主播去重」已并入功能设置 →「本地去重」,旧值 " + oldDedupe + " 已沿用");
+        }
+
+        var oldMode = readPath(src, "tabs.id.scripts.s1.dedupe");
+        if (oldMode === "text") {
+            assignPath(cfg, "tabs.id.features.dedupeHistory", true);
+            problems.push("旧去重模式「UID 模式」→「跨轮去重」已打开(按输入文本比对历史)");
+        }
     }
 };
 

+ 62 - 7
tiktok-app/src/widgets.js

@@ -268,9 +268,19 @@ W.settingRow = function (label, rightView, gapDp) {
  *
  * 命中区含整个标签:只让 16dp 的方框可点,真机上很难戳中。
  */
-W.checkRow = function (label, checked, onToggle) {
+/**
+ * 勾选行。
+ *
+ * @param disabled 置灰且不可点。**为「功能还没做,但字段要保留」的场景准备的**:
+ *                 给了界面一个"开得起来、却什么也不会发生"的开关,比不给更糟
+ *                 (用户会以为它生效了)。置灰至少是诚实的。
+ *                 勾选状态**照常显示**——它代表"用户上次的选择",不是"现在能改的选择"。
+ */
+W.checkRow = function (label, checked, onToggle, disabled) {
     var box = W.checkbox(checked, 16);
-    var labelView = U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false);
+    var off = !!disabled;
+    var labelView = U.text(label, Theme.m.labelSize,
+                           off ? Theme.hex.textMuted : Theme.hex.textSecondary, false);
     labelView.setSingleLine(true);
 
     var line = U.row(false);
@@ -279,15 +289,60 @@ W.checkRow = function (label, checked, onToggle) {
     line.addView(labelView);
     line.setPadding(0, U.dp(4), 0, U.dp(4));
 
-    line.setOnClickListener(function () {
-        var next = !box.isChecked();
-        box.set(next);
-        onToggle(next);
-    });
+    if (off) {
+        line.setAlpha(0.55);
+        /* 不可点:这里挂一个**空监听**而不是不挂 —— 不挂的话父容器(卡片)
+         * 的点击会继续冒泡上去,用户点这一行会触发别处的动作。
+         * 注意 setOnClickListener(function(){}) 与 setOnClickListener(null) 行为不同:
+         * 后者等于"没有监听器",照样冒泡(见 README 铁律)。 */
+        line.setOnClickListener(function () { });
+    } else {
+        line.setOnClickListener(function () {
+            var next = !box.isChecked();
+            box.set(next);
+            onToggle(next);
+        });
+    }
 
     return { root: line, isChecked: box.isChecked };
 };
 
+/**
+ * 灰行:**只读展示 + 「暂不支持」标记**。
+ *
+ * 专门给"设置项在界面上占着位置、但执行侧还没有能力实现"的项用
+ * (提取区域 / AI 头像识别 / AI 性别 / AI 年龄)。
+ *
+ * 为什么不是"灰掉的勾选框":勾选框本身就在说"这个可以改",
+ * 灰只是说"现在不能改",两者叠起来仍然在暗示它是个可选项。
+ * 而这里的事实是"它现在根本没有效果" —— 所以不给可操作控件,
+ * 只把用户上次填的值显示出来(保留值、将来接上就能用),点一行看原因。
+ */
+W.mutedRow = function (label, valueText, onTap) {
+    var line = U.row(false);
+    var labelView = U.text(label, Theme.m.labelSize, Theme.hex.textMuted, false);
+    labelView.setSingleLine(true);
+    line.addView(labelView);
+    line.addView(U.flexible());
+
+    if (valueText) {
+        var valueView = U.text(valueText, Theme.m.chipSize, Theme.hex.textFaint, false);
+        valueView.setSingleLine(true);
+        line.addView(valueView);
+        line.addView(U.hgap(6));
+    }
+
+    line.addView(W.chip("暂不支持", "mute").root);
+    line.setPadding(0, U.dp(4), 0, U.dp(4));
+
+    if (onTap) {
+        line.setOnClickListener(onTap);
+    } else {
+        line.setOnClickListener(function () { });
+    }
+    return { root: line };
+};
+
 /* ------------------------------------------------------------ 胶囊 / 标签 */
 
 /* 胶囊的四套色调集中在这里,W.chip 与 W.repaintChip 共用,

+ 115 - 40
tiktok-app/tools/audit-values.js

@@ -203,7 +203,9 @@ const dirty = {
     tabs: {
         id: {
             scripts: {
-                s1: { checked: 'yes', dedupe: '旧模式', enterPage: null, textIdPath: '' }
+                s1: { checked: 'yes', enterPage: null, textIdPath: '' },
+                /* v6 时代脚本三的「进入页面」可以是「评论页」,v7 值域收窄后不成立 */
+                s3: { enterPage: 'comment' }
             }
         },
         dm: {
@@ -228,7 +230,10 @@ console.log('归一化 + 迁移结果:');
 const EXPECT = [
     ['activeTab', 'tasks', '非法 tab 回退到「任务」'],
     ['tabs.id.scripts.s1.checked', true, '字符串真值转布尔'],
-    ['tabs.id.scripts.s1.dedupe', 'id', '非法枚举回退默认'],
+    /* v7 迁移:s3 的「进入页面」值域收窄成 expandList(只有粉丝/关注),
+     * 旧值「评论页」在"展开一个列表"的语义下不成立 → 落到关注列表。
+     * 这条同时承担了"非法枚举回退默认"的覆盖。 */
+    ['tabs.id.scripts.s3.enterPage', 'follow', 'v7:旧「评论页」→ 关注列表'],
     ['tabs.id.scripts.s1.enterPage', 'fans', 'null 回退默认'],
     ['tabs.id.scripts.s1.textIdPath', api.Store.TEXT_DIR + '/uid_list.txt', 'v1 空串经迁移补默认'],
     /* 旧上限是「非数字」→ 迁移读不出数就不动它,直接用默认区间 */
@@ -279,8 +284,9 @@ pageShows('video', 'fashion', '关键词有默认值');
 pageShows('video', 'US', '主播区域有默认值');
 pageShows('video', '停止个数', '停止个数是区间行');
 pageShows('video', '7', '旧 stopCount=7 迁移后在界面上显示为 7 ~ 7');
-pageShows('id', 'ID 模式', '去重方式有默认值');
-pageShows('id', '粉丝页', '进入页面有默认值');
+pageShows('id', '本地去重', '去重开关已收到功能设置(全页只出现一次)');
+pageShows('id', '从哪个页面取', '「从哪个页面取」取代了原来的「进入页面」');
+pageShows('id', '粉丝页', '默认从粉丝页取');
 pageShows('dm', '关注+私信去重', '去重方式有默认值');
 /* 关注与私信各自独立成项;数量是「一行一个区间」 */
 pageShows('dm', '进行关注', '「进行关注」独立一行');
@@ -298,7 +304,8 @@ pageShows('nurture', '10%', '评论概率有默认档位');
 pageShows('nurture', '15%', '关注概率有默认档位');
 pageShows('nurture', '单次操作', '单次操作是区间行');
 /* 新增的 ID提取功能设置 */
-pageShows('id', 'JP#TW#US', '提取区域有默认国家简码');
+pageShows('id', 'JP#TW#US', '提取区域填过的值仍然保留(即使当前不生效)');
+pageShows('id', '暂不支持', '没有承载者的项被明确标注,而不是给个能开的空开关');
 pageShows('id', '通杀', '语言区域有默认档位');
 pageShows('id', '500000', '数量门槛有默认上限');
 /* 四个数量门槛是「一行一个区间」,不是上下限各占一行 */
@@ -312,16 +319,18 @@ pageShows('id', '~', '数量门槛显示成「最小值 ~ 最大值」');
  * 每一项都对着设计稿的节点核过(16:533 / 16:589)。 */
 console.log('');
 console.log('ID提取 · 四张脚本卡的展开内容:');
+/* v7 之后每张卡的关键控件(文案已按清语义后的版本更新)。
+ * 注意 s4 的「关键词」不能拿来当断言词 —— 卡名「关键词视频提 ID」里就含它,
+ * 会变成"永远通过"的假断言。 */
 const ID_CARD_WORDS = [
     { key: 's1', label: '脚本一 · 粉丝页提 ID',
-      words: ['主播去重', '进入页面', '主播 ID', '使用文本 ID'] },
+      words: ['从哪个页面取', '主播账号', '搜索用户提 ID', '使用文本 ID'] },
     { key: 's2', label: '脚本二 · 评论页提 ID',
-      words: ['自定评论页面提 ID', '提取评论的人的粉丝 ID'] },
+      words: ['视频从哪来', '再取评论者的粉丝 ID'] },
     { key: 's3', label: '脚本三 · 文本 UID 裂变提 ID',
-      words: ['文本 UID 裂变提 ID', 'UID 博主去重', '进入页面', '裂变文本'] },
+      words: ['展开哪个列表', '裂变文本'] },
     { key: 's4', label: '脚本四 · 关键词视频提 ID',
-      words: ['关键词视频提 ID', '关键词主播', '关键词提取主播粉丝 ID',
-              '关键词提取评论区 ID', '关键词提取评论的人的粉丝 ID'] }
+      words: ['作者的粉丝 ID', '评论区里的 ID', '评论者的粉丝 ID'] }
 ];
 
 function expandOnlyId(key) {
@@ -348,33 +357,42 @@ check('同一时刻只渲染展开那张卡的设置区',
         const only2 = textsOf('id').join(' | ');
         expandOnlyId('s4');
         const only4 = textsOf('id').join(' | ');
-        return only2.indexOf('自定评论页面提 ID') >= 0 && only4.indexOf('自定评论页面提 ID') < 0;
+        return only2.indexOf('视频从哪来') >= 0 && only4.indexOf('视频从哪来') < 0;
     })());
 
 /* —— 开关与它右侧控件的联动:「关掉后右侧不该还留着」——
- * 写起来最容易漏的一处:只 addView、不处理关闭分支,用户会以为没关掉。 */
+ * 写起来最容易漏的一处:只 addView、不处理关闭分支,用户会以为没关掉。
+ * v7 之后脚本一里带右侧控件的是「搜索用户提 ID」(右侧是关键词输入框)。
+ *
+ * ⚠️ 判据用 `W.pendingFields`(本页注册过的输入控件)而不是界面文本:
+ * 行内输入框的 placeholder 是**画出来的**,不是 TextView 的 text,
+ * 按文本找永远找不到 —— 那样会写出"永远失败"或"永远通过"的假断言。 */
 expandOnlyId('s1');
-api.Store.set('tabs.id.scripts.s1.dedupeEnabled', true);
-check('开着「主播去重」时显示「去重方式」', textsOf('id').join(' | ').indexOf('ID 模式') >= 0);
-api.Store.set('tabs.id.scripts.s1.dedupeEnabled', false);
-check('关掉「主播去重」后不再显示「去重方式」',
-    textsOf('id').join(' | ').indexOf('ID 模式') < 0,
-    textsOf('id').join(' | ').slice(0, 200));
-api.Store.set('tabs.id.scripts.s1.dedupeEnabled', true);
-
-/* 脚本三的「进入页面」默认是「关注页」,与脚本一的「粉丝页」不同(设计稿的取值)。
- * 这类"同一控件在不同卡片上默认值不同"最容易写成共用一个默认值。 */
+api.W.beginPage();
+api.Store.set('tabs.id.scripts.s1.customPage', true);
+api.Pages.id(() => { });
+check('开着「搜索用户提 ID」时关键词输入框被创建',
+    api.W.pendingFields['tabs.id.scripts.s1.customPageKeyword'] !== undefined);
+
+api.W.beginPage();
+api.Store.set('tabs.id.scripts.s1.customPage', false);
+api.Pages.id(() => { });
+check('关掉后关键词输入框不再被创建',
+    api.W.pendingFields['tabs.id.scripts.s1.customPageKeyword'] === undefined);
+
+/* 脚本三的「展开哪个列表」默认是**关注列表**,与脚本一的「粉丝页」不同(设计稿的取值)。
+ * 这类"同一类控件在不同卡片上默认值不同"最容易写成共用一个默认值。 */
 expandOnlyId('s3');
-check('脚本三的「进入页面」默认「关注页」',
-    textsOf('id').join(' | ').indexOf('关注页') >= 0,
+check('脚本三的「展开哪个列表」默认「关注列表」',
+    textsOf('id').join(' | ').indexOf('关注列表') >= 0,
     textsOf('id').join(' | ').slice(0, 200));
 expandOnlyId('s1');
-check('脚本一的「进入页面」默认「粉丝页」',
+check('脚本一的「从哪个页面取」默认「粉丝页」',
     textsOf('id').join(' | ').indexOf('粉丝页') >= 0);
 
 /* 脚本四的关键词有可直接跑的业务默认值(不是空框) */
 expandOnlyId('s4');
-check('脚本四的「关键词主播」有默认值',
+check('脚本四的「关键词」有默认值',
     textsOf('id').join(' | ').indexOf(api.Store.getText('tabs.id.scripts.s4.keyword')) >= 0,
     api.Store.getText('tabs.id.scripts.s4.keyword'));
 
@@ -405,15 +423,18 @@ check('滚动指引位于脚本四之后、功能设置标题之前',
 console.log('');
 console.log('v6 改版(任务卡 / 运行记录 / 迁移):');
 
-/* C1 脚本一**新增**的两项(规格书 §3):默认值必须就是稿子给的那两个 */
+/* v7 清语义:脚本一原来的两项变成「删一个、改一个」——
+ *   · 「自定关注或粉丝页面提 ID」与「从哪个页面取」说的是同一件事 → 删掉;
+ *   · 「自定页面提 ID」勾上却没说搜什么 → 补关键词并改名「搜索用户提 ID」。 */
 expandOnlyId('s1');
 const s1Texts = textsOf('id').join(' | ');
-check('脚本一新增「自定关注或粉丝页面提 ID」', s1Texts.indexOf('自定关注或粉丝页面提 ID') >= 0);
-check('脚本一新增「自定页面提 ID」', s1Texts.indexOf('自定页面提 ID') >= 0);
-check('「自定关注或粉丝页面提 ID」默认开(稿子 ✓)',
-    api.Store.defaultOf('tabs.id.scripts.s1.customFansPage') === true);
-check('「自定页面提 ID」默认关(稿子 ✗)',
+check('v7 删掉了与「从哪个页面取」重复的「自定关注或粉丝页面提 ID」',
+    s1Texts.indexOf('自定关注或粉丝页面提 ID') < 0);
+check('「自定页面提 ID」改名为「搜索用户提 ID」', s1Texts.indexOf('搜索用户提 ID') >= 0);
+check('「搜索用户提 ID」默认关(要让用户主动开)',
     api.Store.defaultOf('tabs.id.scripts.s1.customPage') === false);
+check('它的关键词留空(没配就是没配,不塞一个假默认值)',
+    api.Store.defaultOf('tabs.id.scripts.s1.customPageKeyword') === '');
 expandOnlyId('s1');
 
 /* —— 标题区只能出现一次 ——
@@ -534,9 +555,37 @@ const v5bad = api.Store.validate({ version: 5, activeTab: '已删除的页签' }
 check('不认识的老页签回退到「任务」', v5bad.config.activeTab === 'tasks', `期望 'tasks',实际 ${v5bad.config.activeTab}`);
 check('不认识的老页签不带二级页', v5bad.config.activeDetail === '', `期望 '',实际 ${v5bad.config.activeDetail}`);
 
+/* —— v6 → v7:ID提取清语义的三条迁移 ——
+ * 单独造一份 v6 存档来测,因为场景 B 的脏数据里已经不再带 `dedupeEnabled`
+ * (那是被删掉的字段),只有这里能验证"旧值真的被继承,而不是丢掉、也不是静默改"。 */
+const v6id = api.Store.validate({
+    version: 6,
+    tabs: {
+        id: {
+            scripts: {
+                s1: { dedupeEnabled: false, dedupe: 'text' },
+                s3: { enterPage: 'comment' }
+            }
+        }
+    }
+});
+check('v7:「主播去重」旧值 false 已继承到「本地去重」',
+    v6id.config.tabs.id.features.dedupeLocal === false,
+    `实际 ${v6id.config.tabs.id.features.dedupeLocal}`);
+check('v7:旧去重模式「UID 模式」→「跨轮去重」打开',
+    v6id.config.tabs.id.features.dedupeHistory === true,
+    `实际 ${v6id.config.tabs.id.features.dedupeHistory}`);
+check('v7:脚本三旧「评论页」→「关注列表」',
+    v6id.config.tabs.id.scripts.s3.enterPage === 'follow',
+    `实际 ${v6id.config.tabs.id.scripts.s3.enterPage}`);
+check('v7 的每一条迁移都写进了 problems(不静默改用户配置)',
+    v6id.problems.filter((p) => p.indexOf('本地去重') >= 0).length === 1 &&
+    v6id.problems.filter((p) => p.indexOf('跨轮去重') >= 0).length === 1 &&
+    v6id.problems.filter((p) => p.indexOf('关注列表') >= 0).length === 1,
+    v6id.problems.join(' | '));
+
 /* 复位成默认展开态(只有脚本一展开) */
 expandOnlyId('s1');
-api.Store.set('tabs.id.scripts.s1.dedupeEnabled', true);
 
 /* ============================================================ 悬浮球 */
 /* 规格书 §10 / H1–H7。设备端能建窗,桩里建不了 —— 所以这一节验的是
@@ -618,7 +667,6 @@ check('记录字段缺失时渲染不出现 undefined',
 
 
 expandOnlyId('s1');
-api.Store.set('tabs.id.scripts.s1.dedupeEnabled', true);
 
 /* 区间行的结构断言:每个区间行必须是「标签 + 两个数字胶囊 + 一个 ~」 */
 console.log('');
@@ -960,16 +1008,20 @@ check('离开前整体提交,这次退格不会丢',
 /* —— 单选列表 —— */
 console.log('');
 console.log('单选列表:');
-const dropdown = api.W.dropdown('dedupe', 'tabs.id.scripts.s1.dedupe');
+/* 用 v7 新增的 expandList(只有粉丝/关注两值)验单选窗:
+ * 默认是「关注列表」,这里点「粉丝列表」写成 fans,能真实反映写入。 */
+const dropdown = api.W.dropdown('expandList', 'tabs.id.scripts.s3.enterPage');
 dropdown.root.performClick();
 sheet = api.Dialog.active;
-const allOptions = findAll(sheet.root, textIs('ID 模式'), [], 0);
-const targetOption = findAll(sheet.root, textIs('UID 模式'), [], 0);
+const allOptions = findAll(sheet.root, textIs('关注列表'), [], 0);
+const targetOption = findAll(sheet.root, textIs('粉丝列表'), [], 0);
 check('选择窗列出了全部候选', allOptions.length === 1 && targetOption.length === 1);
 if (targetOption.length === 1) { targetOption[0].performClick(); }
-check('选中项写回配置', api.Store.get('tabs.id.scripts.s1.dedupe') === 'text',
-    `实际 "${api.Store.get('tabs.id.scripts.s1.dedupe')}"`);
+check('选中项写回配置', api.Store.get('tabs.id.scripts.s3.enterPage') === 'fans',
+    `实际 "${api.Store.get('tabs.id.scripts.s3.enterPage')}"`);
 check('选择后弹窗关闭', api.Dialog.active === null);
+/* 复原,免得影响后面的用例 */
+api.Store.set('tabs.id.scripts.s3.enterPage', 'follow');
 
 /* —— 文本输入:同样直接在栏位里改 —— */
 console.log('');
@@ -1117,6 +1169,24 @@ const boolFields = api.Store.FIELDS.filter((f) => f.type === 'bool').map((f) =>
 const boolBackup = {};
 boolFields.forEach((p) => { boolBackup[p] = api.Store.get(p, false); api.Store.set(p, true); });
 sweepPages();
+
+/* ★ 还有一类条件渲染是**枚举驱动**的:脚本二选「关键词搜索」才出现关键词输入框。
+ *   布尔那一轮扫不到它(它不是一个开关),于是会被误判成「没有界面的字段」。
+ *   枚举每组只有 2~4 个取值,逐个取值各扫一遍,代价可控。
+ *
+ *   ⚠️ 必须放在**布尔全开还没恢复**的时候扫:卡片的展开本身是个 bool,
+ *   先恢复的话四张卡都折回去了,卡内的条件控件一个都扫不到(踩过)。 */
+const enumFields = api.Store.FIELDS.filter((f) => f.type === 'enum' && f.group);
+enumFields.forEach((f) => {
+    const backup = api.Store.get(f.path);
+    const keys = Object.keys(api.Store.enum[f.group] || {});
+    keys.forEach((k) => {
+        api.Store.set(f.path, k);
+        sweepPages();
+    });
+    api.Store.set(f.path, backup);
+});
+
 boolFields.forEach((p) => { api.Store.set(p, boolBackup[p]); });
 
 Object.keys(readPaths).forEach((p) => { boundPaths[p] = true; });
@@ -1475,7 +1545,12 @@ const PROGRAMMATIC = {
     activeTab: '底部页签栏的当前页(由点页签写入,本身不是一个「值控件」)',
     activeDetail: '当前打开的二级页(由进/退详情页写入,同样不是一个「值控件」)',
     'ball.side': '悬浮球吸附到哪一边(拖动松手时写入,没有对应的界面控件)',
-    'ball.yRatio': '悬浮球的垂直位置比例(拖动松手时写入,存比例而不是像素)'
+    'ball.yRatio': '悬浮球的垂直位置比例(拖动松手时写入,存比例而不是像素)',
+    /* v7:AI 三项归入「AI 画像识别 · 暂不支持」这一行灰字。
+     * 值仍保留在配置里(将来接上模型直接生效),但界面**故意不给控件** ——
+     * 给一个开了什么也不发生的开关,比不给更糟。 */
+    'tabs.id.features.aiGender': 'AI 性别:v7 起随「AI 画像识别」一起标为暂不支持,值保留、暂无控件',
+    'tabs.id.features.aiAgeMax': 'AI 年龄:同上'
 };
 const noUiFields = api.Store.FIELDS
     .filter((f) => !boundPaths[f.path])

+ 29 - 1
tiktok-app/tools/smoke.js

@@ -156,7 +156,35 @@ eq('默认配置版本号', api.Store.defaults().version, api.Store.SCHEMA_VERSI
 eq('默认 tab 是「任务」', api.Store.get('activeTab'), 'tasks');
 eq('脚本一默认未勾选(单选组不替用户做选择)', api.Store.get('tabs.id.scripts.s1.checked'), false);
 eq('脚本一默认展开', api.Store.get('tabs.id.scripts.s1.expanded'), true);
-eq('脚本一口径默认 ID 模式', api.Store.get('tabs.id.scripts.s1.dedupe'), 'id');
+
+/* —— v7 清语义:删掉的 7 个字段必须真的不在了 ——
+ * 光从页面里删不够:字段还留在 FIELDS 里的话,它会继续出现在导出配置与审计里,
+ * 变成一个"没有任何控件、却一直有值"的幽灵。 */
+const v7Deleted = [
+    'tabs.id.scripts.s1.customFansPage',
+    'tabs.id.scripts.s1.dedupeEnabled',
+    'tabs.id.scripts.s1.dedupe',
+    'tabs.id.scripts.s2.customCommentPage',
+    'tabs.id.scripts.s3.uidFission',
+    'tabs.id.scripts.s3.uidDedupe',
+    'tabs.id.scripts.s4.keywordEnabled'
+];
+const stillDeclared = v7Deleted.filter((p) => api.Store.FIELDS.some((f) => f.path === p));
+check('v7 删掉的 7 个字段已从字段表移除', stillDeclared.length === 0, stillDeclared.join(', '));
+
+/* —— v7 补上的字段:默认值就是"最省事的那种用法" —— */
+eq('本地去重默认开', api.Store.get('tabs.id.features.dedupeLocal'), true);
+eq('跨轮去重默认关(首次运行没有历史可比)', api.Store.get('tabs.id.features.dedupeHistory'), false);
+eq('脚本二「视频从哪来」默认当前屏幕', api.Store.get('tabs.id.scripts.s2.videoSource'), 'screen');
+eq('脚本三「展开哪个列表」默认关注列表', api.Store.get('tabs.id.scripts.s3.enterPage'), 'follow');
+
+/* 脚本三的 enterPage 只能取粉丝/关注 —— 这是 v7 把它从 enterPage 拆成 expandList 的全部意义:
+ * 旧值域里的「评论页」「主页」在"展开一个列表"的语义下不存在。 */
+const expandKeys = Object.keys(api.Store.enum.expandList);
+eq('「展开哪个列表」只有粉丝/关注两个合法值', expandKeys.join(','), 'fans,follow');
+eq('脚本三的 enterPage 归到 expandList 组',
+    (api.Store.FIELDS.filter((f) => f.path === 'tabs.id.scripts.s3.enterPage')[0] || {}).group,
+    'expandList');
 
 /* 养号参数:设计稿 16:2 —— 两个区间 + 三个档位 */
 eq('养号浏览时长默认 3 ~ 8',