Forráskód Böngészése

ID提取改成三段式(B 档):四张单选脚本卡 → 可累加的取材清单

只改界面,执行引擎仍未接入(点开始运行仍被明确拦下,提示"ID提取的执行引擎尚未接入")。

为什么换结构
四张脚本卡是「起点(人从哪来)」×「深度(要不要再展开一层)」的 4 个**不完整组合**:
各缺一块、各有别人没有的一块,合起来才完整。于是"单选一个脚本"这条约束本身就在跟用户
较劲 —— 想同时抓"关键词视频的作者"和"文本里这批 uid 的粉丝"时,它直接不允许。

新结构(三段)
① 取材:可加多条的清单(最多 5 条),每条 = 起点 + 该起点的输入 + 深度
   · 五种起点:指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕
   · 深度:不展开 / 再取粉丝 / 再取关注,最多 2 层(第 3 层在真机上不现实)
   · 选了什么起点才出现什么输入 —— 不会再有"勾上却无所指"的开关
② 只要这样的账号:所有取材共用(账号特征 + 四组数值门槛,v7 的成果原样保留)
③ 输出:上限 + 本地/跨轮/云端去重 + 结果文件名

数据层
- 新增 src/sources.js:取材清单走**独立的 storage key**,不进 Store.FIELDS
  (不定长列表放进去等于让 validate() 校验数组,还会把"路径→值控件"的审计规则带歪),
  照 Records 的先例
- SCHEMA_VERSION 7 → 8:四张脚本卡的 23 个字段整棵删除
- Sources.migrateLegacy():把旧的四选一翻译成清单。三条原则:
  已有清单就不动(不覆盖用户自己加的)/ 能展开成多条就展开(s4 的三个开关 → 三条取材)/
  超上限要截断并说出来

控件
- W.chooser:不绑 Store 路径的通用单选胶囊(取材的起点/深度只能走回调)
- W.inlineField 新增 read/write:绑"不是 Store 字段"的值
- W.pendingExtra:这类输入框单独登记,不进 pendingFields
  (后者同时被取值审计当作"这个字段有控件"的证据,混进去会报"绑定了表外路径")

【踩坑】拼接式构建 = 所有模块共享顶层作用域
sources.js 里的 keysOf 与 store.js 同名,静默覆盖了后者,把 getEnum 打坏 ——
卡密状态读不出来,所有「开始运行」都被送回登录页,全程零报错。
已重命名(keysOf→sourceKeys 等),并把"新增模块先查重名"写进 README 与记忆。

自检
- smoke 387 项全绿(改:校验顺序那 4 条、F3 临时挂引擎、三段标题与"不再有单选约束")
- audit CLEAN(改:脏数据 EXPECT、四张卡断言段 → 取材清单断言段、
  任务卡三态改为基于清单、v6→v7 用例 → v6→v8 取材翻译用例、脚本卡点击 → 取材卡片删除与重启读回)
- 真机:v7 存档自动迁移成 1 条取材;起点联动(第 2 条切文本文件,第 1 条不受影响);
  过滤段与输出段渲染正常;删除按钮真的能删

依据:REQ_id_extract_v2.md §3(三段式)与 §6(迁移)
梦境 2 hete
szülő
commit
2295549b48

+ 54 - 48
tiktok-app/README.md

@@ -62,7 +62,8 @@ tiktok-app/
 │   ├── widgets.js      # 通用组件(卡片/设置行/单选框/勾选框/下拉/胶囊/页签/运行状态条…)
 │   ├── dialog.js       # 自绘弹窗体系(输入 / 选择 / 确认 / 权限 / 拦截)
 │   ├── floatball.js    # 全局悬浮球(H1–H7):跨 App 的控制入口与状态可视处
-│   ├── page-id.js      # ID提取页(单选脚本 + 功能设置)
+│   ├── sources.js      # ID提取的取材清单(不定长列表,独立 storage key)
+│   ├── page-id.js      # ID提取页(三段式:取材 / 过滤 / 输出)
 │   ├── page-dm.js      # UID私信页 + 养号页
 │   ├── page-video.js   # 视频功能页
 │   ├── page-tasks.js   # 任务列表(B1/B2,含四张任务卡与任务状态)
@@ -73,7 +74,7 @@ tiktok-app/
 ├── tools/
 │   ├── build.js        # src/ → dist/ 单文件(含三道检查)
 │   ├── android-stub.js # 最小 Android/AutoX 环境模拟
-│   ├── smoke.js        # 产物离线自检(386 项断言)
+│   ├── smoke.js        # 产物离线自检(387 项断言)
 │   └── audit-values.js # 界面取值审计(六个场景)
 └── dist/tiktok-app.js  # ⚠️ 构建产物,不要直接编辑
 ```
@@ -106,9 +107,9 @@ tiktok-app/
 | A1/A2/A3 | 卡密登录(含校验中 / 失败) | `16:257` / `16:1491` / `16:288` | `Pages.login` 三态 | ✅ 真机 |
 | A4 | 权限引导弹窗 | `30:738` | `Dialog.showPermissions` | ✅ 真机 |
 | **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` | 同上(v7 起分三组) | ✅ 真机 |
+| C1 | ID提取(取材清单) | `3:1` | `Pages.id` + `W.backHeader` | ✅ 真机 |
+| C2/C3 | ~~ID提取 · 脚本二三 / 脚本四~~ | `16:483` / `16:589` | **v8 已合并进 C1**(不再有脚本卡) | — |
+| C4/C5 | ID提取 · 过滤与输出两段 | `16:706` / `16:965` | 同上(v8 起独立成段) | ✅ 真机 |
 | 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,51 +239,56 @@ tiktok-app/
 | — 右侧滚动指示条(`x=367 w=3`) | **未实现**:它需要滚动监听,收益小、风险高(AutoX 里构造 `OnScrollChangeListener` 的兼容性没验过);底部渐隐已经把"还能滚"表达出来了 |
 | — §7 技术栈表 | 运行形态 = **AutoX.js `ui` 模块**(纯代码建 View);卡密/权限/存储/结果文件/更新检查全部沿用本项目既有实现(本地卡密、`storages`、`files`) |
 
-### ID提取页的四张脚本卡(v7 清语义)
+### ID提取页:三段式(v8)
 
-四张卡是**单选组**(设计稿写明「同一时间仅可运行一个脚本」),
-默认一个都不选 —— 稿子 16:23 专门画了「未选择脚本」那一屏,说明"还没选"是合法的初始状态。
+> **v8(2026-10-17)把四张脚本卡换掉了。** 那四张卡是「起点(人从哪来)」×「深度(要不要再展开
+> 一层)」的 4 个**不完整组合**:各缺一块、各有别人没有的一块,合起来才完整。于是
+> 「同一时间仅可运行一个脚本」这条约束本身就在跟用户较劲 —— 想同时抓"关键词视频的作者"
+> 和"文本里这批 uid 的粉丝"时,它直接不允许。定位过程与 38 个字段的逐条处置见
+> `REQ_id_extract_v2.md`。**执行引擎仍未接入**(点开始运行仍会被明确拦下)。
 
-> **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` / `store.js` 的注释里):
-
-- **`Pages.ID_DETAILS` 是 key → 构建函数的表**,不是一长串 `if`。
-  表让「哪张卡有什么」一眼可见。早先只有脚本一有内容,另外三张展开是**空的** ——
-  用户点开折叠箭头会以为坏了。
-- **`buildToggleRow` 抽出「开关 + 标签 +(勾上才出现的右侧控件)」**。
-  这种形态在卡里反复出现,而它有一个必须一起做对的细节:
-  右侧控件要在**关闭时移除**。各写各的很容易只 addView、不管关闭分支。
-  自检里的判据用 `W.pendingFields`(本页注册过的输入控件)而不是界面文本 ——
-  行内输入框的 placeholder 是**画出来的**,不是 TextView 的 text,按文本找永远找不到。
-- **v7 起两张卡不再共用 `enterPage`**:脚本三改用 `expandList`(只有粉丝/关注两值)。
-  旧值域里的「评论页」「主页」在"展开一个列表"这个语义下并不存在 ——
-  同一个下拉名在两处指两件事,是最容易读糊的一类问题。
-- **脚本四的三个「提取…」用缩进(16dp 左内边距)表达层级**:
-  它们是"在作者之外再取",与上面的关键词不是平级。加符号(`└`)会占字宽、标签一长就被挤。
-- **脚本三「展开哪个列表」默认关注、脚本一「从哪个页面取」默认粉丝页**(稿子的取值)。
-  这类"同类控件在不同卡片上默认值不同"最容易写成共用一个默认值,自检里各有断言。
-- **`anchorIds`(主播账号)标了 `optional: true`**:留空 = 不限定、从当前屏幕的列表直接抓,
-  这是真实且合理的状态。审计靠这个标记把「合理的空」和「漏了默认值」分开,
-  所以不能塞一个像 `账号1#账号2#账号3` 那样的假默认值(那是**占位提示**,填进去会被当真去搜)。
-- **v7 引入了版本迁移 `MIGRATIONS[6]`**,三件事都不许静默:① 脚本三值域收窄,
-  旧「评论页/主页」→ 关注列表;② 卡片里的「主播去重」继承到功能设置的「本地去重」;
-  ③ 旧去重模式「UID 模式」→「跨轮去重」打开。每一条都写进 `problems`,用户能看到改了什么。
+| ① **取材** | 可累加的清单(最多 5 条):每条 = 起点 + 该起点需要的输入 + 深度 |
+| ② **只要这样的账号** | 所有取材共用:账号特征(私密/无头像/语言)+ 四组数值门槛 |
+| ③ **输出** | 提取上限 + 本地/跨轮/云端去重 + 结果文件名 |
+
+**五个起点**(选了什么才出现什么,不会再有"勾上却无所指"的开关):
+
+| 起点 | 要填什么 | 取到谁 |
+|---|---|---|
+| 指定账号 | 账号(`#` 分隔)+ 取哪些列表(粉丝 / 关注 / 主页展示,**至少留一个**) | 这些列表里的人 |
+| 搜索结果 | 用户 / 视频作者 + 关键词 | 搜到的人 / 视频作者 |
+| 某个视频的评论区 | 视频从哪来(当前屏幕 / 关键词搜索) | 评论者 |
+| 文本文件 | 路径 + 选择文件 | 每行一个 UID |
+| 当前屏幕 | 无 | 此刻这个列表里的人 |
+
+**深度**:不展开 / 再取粉丝 / 再取关注,**最多 2 层**。
+100 个粉丝 × 每人 100 个粉丝 = 1 万次用户页打开,真机不现实、账号也会异常 —— 所以第 3 层
+干脆不给选项。「当前屏幕」没有深度:它已经是最底层。
+
+实现要点(都写在 `page-id.js` / `sources.js` 的注释里):
+
+- **取材存在 `Sources` 模块自己的 storage key 里**(`feizhou_app` 的 `sources`),
+  **不进 `Store.FIELDS`**:它是不定长列表,塞进那张标量表等于让 `validate()` 去校验数组,
+  还会把「路径 → 值控件」那套审计规则带歪。照 `Records` 的先例。
+- **`W.chooser`**:不绑 Store 路径的通用单选胶囊 —— 取材的"起点 / 深度"只能走回调。
+- **`W.inlineField` 新增 `read` / `write`**:用来绑"不是 Store 字段"的值(账号 / 关键词 / 路径)。
+  这类输入框注册到 `W.pendingExtra` 而**不是** `pendingFields` ——
+  后者同时被取值审计当作"这个字段有控件"的证据,混进去会报"绑定了表外路径"。
+- **`Sources.migrateLegacy()`** 把旧的"四张卡选一张"翻译成清单,三条原则:
+  ① **已有清单就不动**(不覆盖用户自己加的,重装/降级都安全);
+  ② **能展开成多条就展开**(脚本四的三个开关 → 三条独立取材,旧结构塞不下的能力);
+  ③ 超出上限要截断并**说出来**。
+- **【踩坑】模块级函数重名会静默互相覆盖**:`build.js` 是单文件拼接,所有模块共享
+  一个顶层作用域。v8 加 `sources.js` 时踩到了 —— 里面的 `keysOf` 与 `store.js` 同名,
+  把 `getEnum` 打坏了(卡密状态读不出 → 所有运行都被送回登录页,**没有任何报错**)。
+  新增模块前先查一遍:
+  ```bash
+  for f in src/*.js; do grep -o '^function [a-zA-Z_]*' "$f"; done | sort | uniq -d
+  ```
 
 ### 切屏与运行态
 
@@ -943,7 +949,7 @@ AutoX 的 `floaty.window` —— 而 `floaty` 恰恰做不到"两个窗口同时
    必须存在、第一行是 `"ui";`、且确实加载了界面产物。这类错误在设备上的表现是
    「以非 UI 模式执行 + 一堆不着边际的报错」,从日志里**看不出根因**,所以必须在 PC 侧拦住。
 
-`tools/smoke.js` 配合 `tools/android-stub.js` 在 PC 上把产物**真实跑一遍**(386 项断言)——
+`tools/smoke.js` 配合 `tools/android-stub.js` 在 PC 上把产物**真实跑一遍**(387 项断言)——
 v6 新增的部分:任务卡直跑 / F3 全局单跑确认(取消与「停止并开始」两条路径)/ 校验顺序
 (卡密 → 权限 → 已选脚本 → 引擎 → 是否在跑)/ 运行记录落盘与上限 / F5 报错态与红色运行条 /
 D3 日志读取(坏行只丢自己)/ `Records` 文案纯函数 / **悬浮球 80+ 项**
@@ -982,7 +988,7 @@ D3 日志读取(坏行只丢自己)/ `Records` 文案纯函数 / **悬浮球
 
 ```bash
 $ node tools/smoke.js
-通过 386 项
+通过 387 项
 SMOKE_ALL_PASS
 ```
 

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 854 - 381
tiktok-app/dist/tiktok-app.js


+ 20 - 1
tiktok-app/src/main.js

@@ -810,7 +810,11 @@ App.requestRun = function (taskKey) {
     /* ③ 该任务当前选中的脚本 */
     var script = Pages.checkedScript(taskKey);
     if (!script) {
-        Dialog.runBlocked("\u8BF7\u5148\u9009\u62E9\u8981\u8FD0\u884C\u7684\u811A\u672C");
+        /* v8:ID提取没有"脚本"了,它的空态是"还没添加取材" ——
+         * 对用户说"请先选择要运行的脚本"会让他到处找那不存在的脚本。 */
+        Dialog.runBlocked(taskKey === "id"
+            ? "\u8BF7\u5148\u6DFB\u52A0\u4E00\u6761\u53D6\u6750"
+            : "\u8BF7\u5148\u9009\u62E9\u8981\u8FD0\u884C\u7684\u811A\u672C");
         return;
     }
 
@@ -1123,6 +1127,20 @@ App.mount = function (root) {
 App.main = function () {
     Store.init();
 
+    /* v8:把旧的四张卡配置翻译成「取材清单」(一次性,已有清单就不动)。
+     *
+     * 为什么放在这里而不是 Store.load 里:Store 不该知道 Sources 存在
+     * (清单是另一个储物柜),而且这里能保证**所有模块都已加载完**再动它。
+     * 迁移失败不许拦住界面 —— 最坏情况是用户看到空清单,自己加一条即可。 */
+    try {
+        var migrated = Sources.migrateLegacy();
+        if (migrated && migrated.length > 0) {
+            console.log("[app] 旧的四张卡配置已翻译成 " + migrated.length + " 条取材");
+        }
+    } catch (eMig) {
+        console.log("[app] 取材清单迁移失败(不影响使用,清单为空时可手动添加): " + eMig);
+    }
+
     /* getEnum 保证拿回合法 tab key:旧配置里存了已删除的页签也不会白屏。
      * 老页签(id/dm/nurture/video)由 Store.MIGRATIONS[5] 映射成 tasks(见 store.js)。 */
     var configuredTab = Store.getEnum("activeTab", "tab");
@@ -1208,6 +1226,7 @@ if (typeof globalThis !== "undefined" && globalThis.__FEIZHOU_TEST__) {
         Theme: Theme,
         Store: Store,
         Records: Records,
+        Sources: Sources,
         Device: Device,
         Service: Service,
         Engine: Engine,

+ 285 - 373
tiktok-app/src/page-id.js

@@ -1,361 +1,292 @@
 /* ============================== src/page-id.js ==============================
- * ID提取页(设计稿 3:1 / 16:23 / 16:319 / 16:706)。
+ * ID提取页 —— **三段式**(v8 / 2026-10-17)。
  *
- * 结构:当前脚本提示 → 「脚本选择」(**单选**)→ 单选说明 → 4 张脚本卡
- *       → 滚动指引(指向下方功能设置)→ 「功能设置」→ 运行约束提示(仅运行时)。
+ *   ① 取材  从哪儿找人 —— 可累加的取材清单(每条自带起点与深度)
+ *   ② 只要这样的账号 —— 过滤条件(所有取材共用)
+ *   ③ 输出  上限 / 去重 / 文件
  *
- * ⚠️ 本页的脚本卡是**单选**,不是多选。
- *    设计稿写得很明确:「单选:同一时间仅可运行一个脚本」。
- *    互斥逻辑放在页面里(Pages.toggleIdScript),因为它是这一页的业务规则,
- *    放组件里会让别的页面被迫接受同一条规则。
+ * ## 为什么不再是"四张脚本卡选一个"
+ * v7 及以前是四张卡单选。那四张卡其实是「起点(人从哪来)」×「深度(要不要再展开
+ * 一层)」的 4 个**不完整组合**:各缺一块、各有别人没有的一块,合起来才完整。
+ * 于是「同一时间仅可运行一个脚本」这条约束本身就在跟用户较劲 ——
+ * 想同时抓"关键词视频的作者"和"文本里这批 uid 的粉丝"时,它直接不允许。
+ *
+ * 现在的模型:
+ *   · **五个起点**(指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕)
+ *   · **一个深度选项**(不展开 / 再取粉丝 / 再取关注),最多 2 层
+ *   · 取材可加多条,**按顺序串行执行**(真机只有一个前台,没有并发可言),
+ *     共用同一份去重集合与同一个输出文件
+ *
+ * ## 每个控件都有唯一含义
+ * 选了什么起点才出现什么输入 —— 不会再有"勾上却无所指"的开关。
+ * 这是 v7 那次"清语义"留下来的规矩,v8 继续守。
  */
 
 var Pages = Pages || {};
 
 /**
- * 各页脚本清单的集中注册表。
- * main.js 需要把「tab + scriptKey」翻译成界面上显示的中文名
- * (运行状态条、拦截提示、导出配置的日志都要用它)。
- * 让每个页面文件自己注册,比在 main.js 里再维护一份映射表更难漏。
+ * 各页脚本清单的集中注册表(main.js 用 key → 中文名翻译)。
+ * ID提取已经没有"脚本"这个概念,但仍然注册一个占位条目:
+ * 导出配置、运行日志、拦截提示里都会把 key 翻成中文名,
+ * 让那套通用机制不必为 id 开特例。
  */
 Pages.SCRIPTS_OF = {};
-
 Pages.registerScripts = function (tab, list) {
     Pages.SCRIPTS_OF[tab] = list;
 };
 
-/* 四张脚本卡的文案与摘要。摘要只在折叠态显示,展开后由具体设置行替代。
- * v7 顺手把 s2 的摘要从「评论裂变」改成「评论者」——
- * "裂变"这个词在脚本二里其实只表示"再取评论者的粉丝",
- * 而它在脚本三里又是另一种意思,同一个词两种含义最容易读糊。 */
-Pages.ID_SCRIPTS = [
-    { key: "s1", name: "脚本一 · 粉丝页提 ID" },
-    { key: "s2", name: "脚本二 · 评论页提 ID", summary: "评论者" },
-    { key: "s3", name: "脚本三 · 文本 UID 裂变提 ID", summary: "文本 UID" },
-    { key: "s4", name: "脚本四 · 关键词视频提 ID", summary: "关键词视频" }
-];
-
+Pages.ID_SCRIPTS = [{ key: "id", name: "ID提取" }];
 Pages.registerScripts("id", Pages.ID_SCRIPTS);
 
-/** 取当前选中的脚本 key(未选返回空串)。 */
-Pages.checkedIdScript = function () {
-    for (var i = 0; i < Pages.ID_SCRIPTS.length; i++) {
-        var key = Pages.ID_SCRIPTS[i].key;
-        if (Store.getBool("tabs.id.scripts." + key + ".checked")) { return key; }
-    }
-    return "";
-};
+/* ============================================================ ① 取材 */
 
-/** 取某个脚本的显示名,用于「当前脚本」提示与运行状态条。 */
-Pages.scriptName = function (tab, key) {
-    var list = Pages.SCRIPTS_OF[tab] || [];
-    for (var i = 0; i < list.length; i++) {
-        if (list[i].key === key) { return list[i].name; }
-    }
-    /* 找不到就回退成 key 本身:显示 "s2" 也比显示 "undefined" 强 */
-    return key;
-};
+/** 候选文案数组(下拉要的是纯文案,顺序与 Sources 里的表一致)。 */
+function labelsOf(table) {
+    var out = [];
+    for (var i = 0; i < table.length; i++) { out.push(table[i].label); }
+    return out;
+}
 
-/**
- * 单选:选中 key 的同时把同组其它脚本清掉;on=false 则整组清空
- * (回到设计稿 16:23 的「未选择脚本」状态)。
- *
- * 为什么要在页面里做而不是让组件做:单选是**本页的业务规则**(设计稿的
- * 「同一时间仅可运行一个脚本」),而 UID私信 / 养号 / 视频功能三页是可以多选的。
- * 把规则放进组件,别的页面就被迫一起变成单选。
- */
-Pages.toggleIdScript = function (key, on) {
-    for (var i = 0; i < Pages.ID_SCRIPTS.length; i++) {
-        var k = Pages.ID_SCRIPTS[i].key;
-        Store.set("tabs.id.scripts." + k + ".checked", on ? k === key : false);
+/** 取某个表里 key 的下标(选了之后要把文案换掉,回调给的是下标)。 */
+function indexOfKey(table, key) {
+    for (var i = 0; i < table.length; i++) {
+        if (table[i].key === key) { return i; }
     }
-};
-
-/* ------------------------------------------------------------ 脚本卡 */
+    return 0;
+}
 
 /**
- * 「开关 + 标签 +(勾上才出现的一个控件)」。
+ * 切换"取哪些列表"里的某一项。
  *
- * 四个脚本里反复出现这种形态(主播去重 / 使用文本 ID / AI 头像识别…),
- * 抽出来是因为它有一个**必须一起做对**的细节:右侧控件只在开关打开时才 addView。
- * 各写各的很容易漏掉「关掉后右侧还留着」,那会让用户以为没关掉。
- *
- * @param opts.path     开关绑定的配置路径
- * @param opts.label    标签
- * @param opts.rightOf  (on) => View|null,返回开关打开时要显示在右侧的控件
- * @param opts.rerender true → 切换后整页重建(右侧控件的出现/消失需要它)
+ * **最后一个不许取消**:三个都取消,这条取材就没有任何来源可抓 ——
+ * 它会变成一条永远跑不出结果的取材,而用户看不出为什么。
  */
-function buildToggleRow(opts) {
-    var on = Store.getBool(opts.path);
-    var line = U.row();
+function toggleListOf(itemId, key, next) {
+    var cur = Sources.find(itemId);
+    if (!cur) { return; }
+
+    var list = cur.lists.slice();
+    var at = list.indexOf(key);
+
+    if (next && at < 0) {
+        list.push(key);
+    } else if (!next && at >= 0) {
+        if (list.length === 1) {
+            Dialog.alert("至少要留一个列表",
+                "这条取材要取哪个列表?\n\n三个都取消的话,它就没有任何来源可抓了。");
+            Pages.requestRerender();
+            return;
+        }
+        list.splice(at, 1);
+    } else {
+        return;      /* 状态没变,不必写盘也不必重建 */
+    }
 
-    var box = W.checkRow(opts.label, on, function (next) {
-        Store.set(opts.path, next);
-        if (opts.rerender) { Pages.requestRerender(); }
-    });
-    line.addView(box.root);
+    Sources.update(itemId, { lists: list });
+    Pages.requestRerender();
+}
 
-    var right = on && opts.rightOf ? opts.rightOf(on) : null;
-    if (right) {
-        line.addView(U.flexible());
-        line.addView(U.hgap(8));
-        line.addView(right);
+/** 「取哪些列表」多选行。 */
+function buildListPicker(item) {
+    var row = U.row();
+    for (var i = 0; i < Sources.LISTS.length; i++) {
+        (function (entry, at) {
+            var on = item.lists.indexOf(entry.key) >= 0;
+            var box = W.checkRow(entry.label, on, function (next) {
+                toggleListOf(item.id, entry.key, next);
+            });
+            if (at > 0) { row.addView(U.hgap(10)); }
+            row.addView(box.root);
+        })(Sources.LISTS[i], i);
     }
-    return line;
+    return row;
 }
 
-/**
- * 脚本一 · 粉丝页提 ID(设计稿 3:244;v7 清语义)。
- *
- * v7 改了三处,都是"让每个控件只有一种含义":
- *   · 删「自定关注或粉丝页面提 ID」—— 它与下面的「从哪个页面取」指的是同一件事,
- *     两个控件表达一个意思,用户只能猜"关掉它"和"取消勾选这张卡"有什么区别;
- *   · 删「主播去重 + 去重模式」—— 去重收到「功能设置」,全页只出现一次;
- *   · 「自定页面提 ID」补上关键词输入 —— 原来勾上之后没说搜什么,无从执行。
- *
- * 顺序改成「主路径 → 额外来源」:先把"从哪个页面取 + 主播账号"放一起,
- * 这两项合起来才构成一次完整取材;搜索用户与文本是两个并列的额外入口。
- */
-function buildIdScript1Detail() {
-    var base = "tabs.id.scripts.s1.";
-    var rows = [];
-
-    /* —— 主路径:主播账号 → 它的哪个列表 —— */
-    rows.push(W.settingRow("从哪个页面取",
-        W.dropdown("enterPage", base + "enterPage").root, 8));
-
-    /* 主播账号:可填多个,用 # 隔开;留空表示不限定、从当前屏幕的列表直接抓 */
-    rows.push(W.settingRow("主播账号",
-        W.inlineField({
-            path: base + "anchorIds",
-            placeholder: "账号1#账号2#账号3",
-            widthDp: 148
-        }).root, 8));
-
-    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();
+/** 起点下拉(选完要重排输入行,所以必须重建整页)。 */
+function buildKindChooser(item) {
+    return W.chooser({
+        label: Sources.kindLabel(item.kind),
+        options: labelsOf(Sources.KINDS),
+        index: function () {
+            var cur = Sources.find(item.id);
+            return indexOfKey(Sources.KINDS, cur ? cur.kind : "anchor");
+        },
+        onPick: function (i) {
+            /* 换起点 → normalize 会把旧起点特有的参数丢掉(见 Sources.normalize 的第 ① 条) */
+            Sources.update(item.id, { kind: Sources.KINDS[i].key });
+            Pages.requestRerender();
+        },
+        minWidthDp: 110
     });
+}
 
-    /* 路径本身就可在栏位里直接改(可以手输,不一定非走「选择文件」) */
-    var pathField = W.inlineField({
-        path: base + "textIdPath",
-        placeholder: "文本路径",
-        widthDp: 110,
-        align: "right"
+/** 深度下拉。`screen` 起点已经是最底层,不给这个选项。 */
+function buildExpandChooser(item) {
+    if (!Sources.canExpand(item.kind)) { return null; }
+    return W.chooser({
+        label: Sources.expandLabel(item.expand),
+        options: labelsOf(Sources.EXPANDS),
+        index: function () {
+            var cur = Sources.find(item.id);
+            return indexOfKey(Sources.EXPANDS, cur ? cur.expand : "");
+        },
+        onPick: function (i) {
+            Sources.update(item.id, { expand: Sources.EXPANDS[i].key });
+            Pages.requestRerender();
+        },
+        minWidthDp: 104
     });
+}
 
-    var useRow = U.row();
-    useRow.addView(useBox.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;
+/** 一个绑到取材字段的输入框(不是 Store 字段,所以用 read/write 而不是 path)。 */
+function sourceField(item, key, placeholder, widthDp) {
+    return W.inlineField({
+        read: function () {
+            var cur = Sources.find(item.id);
+            return cur ? cur[key] : "";
+        },
+        write: function (value) {
+            var patch = {};
+            patch[key] = value;
+            Sources.update(item.id, patch);
+        },
+        placeholder: placeholder,
+        widthDp: widthDp
+    });
 }
 
-/**
- * 脚本二 · 评论页提 ID(设计稿 16:483;v7 换掉一个纯开关)。
- *
- * v7:「自定评论页面提 ID」勾上只说"用自定义的评论页面",却没说**是哪条视频的**
- * —— 执行时无从下手。换成一个真正决定取材起点的维度:视频从哪来。
- * ("当前屏幕"= 用户已经自己停在某个评论区,这是最常见也最省事的用法。)
- */
-function buildIdScript2Detail() {
-    var base = "tabs.id.scripts.s2.";
+/** 一条取材的输入区 —— **按起点分派**,选了什么才出现什么。 */
+function buildSourceInputs(item) {
     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));
+    /* —— 指定账号 —— */
+    if (item.kind === "anchor") {
+        rows.push(W.settingRow("账号",
+            sourceField(item, "anchors", "账号1#账号2#账号3", 148).root, 8));
+        rows.push(W.settingRow("取哪些列表", null, 0));
+        rows.push(buildListPicker(item));
+        rows.push(W.hint("账号留空 = 从当前屏幕上的列表直接抓", false));
     }
 
-    rows.push(buildToggleRow({
-        path: base + "commenterFans",
-        label: "再取评论者的粉丝 ID",
-        rerender: false
-    }));
+    /* —— 搜索结果 —— */
+    if (item.kind === "search") {
+        rows.push(W.settingRow("搜什么", W.chooser({
+            label: item.searchType === "videos" ? "视频作者" : "用户",
+            options: labelsOf(Sources.SEARCH_TYPES),
+            index: function () {
+                var cur = Sources.find(item.id);
+                return indexOfKey(Sources.SEARCH_TYPES, cur ? cur.searchType : "users");
+            },
+            onPick: function (i) {
+                Sources.update(item.id, { searchType: Sources.SEARCH_TYPES[i].key });
+                Pages.requestRerender();
+            },
+            minWidthDp: 96
+        }).root, 8));
+        rows.push(W.settingRow("关键词",
+            sourceField(item, "keyword", "关键词", 148).root, 8));
+    }
 
-    var detail = U.column();
-    W.stack(detail, rows, 12);
-    return detail;
-}
+    /* —— 某个视频的评论区 —— */
+    if (item.kind === "video") {
+        rows.push(W.settingRow("视频从哪来", W.chooser({
+            label: item.videoSource === "keyword" ? "关键词搜索" : "当前屏幕",
+            options: labelsOf(Sources.VIDEO_SOURCES),
+            index: function () {
+                var cur = Sources.find(item.id);
+                return indexOfKey(Sources.VIDEO_SOURCES, cur ? cur.videoSource : "screen");
+            },
+            onPick: function (i) {
+                Sources.update(item.id, { videoSource: Sources.VIDEO_SOURCES[i].key });
+                Pages.requestRerender();
+            },
+            minWidthDp: 104
+        }).root, 8));
 
-/**
- * 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483;v7 删两个开关)。
- *
- * v7:
- *   · 删「文本 UID 裂变提 ID」—— 与卡片名一字不差,勾不勾都说不通;
- *   · 删「UID 博主去重」—— 与脚本一的去重是同一件事,已收到「功能设置」;
- *   · 「进入页面」→「展开哪个列表」,并换成只含粉丝/关注两个值的 `expandList`
- *     (原来那套 4 个值里有「评论页」「主页」,在"展开一个列表"的语义下根本不存在)。
- */
-function buildIdScript3Detail() {
-    var base = "tabs.id.scripts.s3.";
-    var rows = [];
+        if (item.videoSource === "keyword") {
+            rows.push(W.settingRow("搜索关键词",
+                sourceField(item, "videoKeyword", "关键词", 148).root, 8));
+        } else {
+            rows.push(W.hint("当前屏幕 = 用你此刻停在的那条视频的评论区", false));
+        }
+    }
 
-    rows.push(W.settingRow("展开哪个列表",
-        W.dropdown("expandList", base + "enterPage").root, 8));
+    /* —— 文本文件 —— */
+    if (item.kind === "text") {
+        var field = sourceField(item, "path", "文本路径", 108);
+        var pathRow = U.row();
+        pathRow.addView(U.text("文件", Theme.m.labelSize, Theme.hex.textSecondary, false));
+        pathRow.addView(U.flexible());
+        pathRow.addView(field.root);
+        pathRow.addView(U.hgap(6));
+        /* storePath 传 null:只让选择器回调路径,不写 Store(取材字段不在字段表里) */
+        pathRow.addView(W.filePickerButton(null, function (picked) {
+            Sources.update(item.id, { path: picked });
+            field.paint();
+        }).root);
+        rows.push(pathRow);
+        rows.push(W.hint("一行一个 UID;空行与 # 开头的行会被跳过", false));
+    }
 
-    rows.push(W.filePicker("裂变文本", base + "fissionTextPath", "选择").root);
+    /* —— 当前屏幕 —— */
+    if (item.kind === "screen") {
+        rows.push(W.hint("用你此刻停在的那个列表(粉丝页 / 关注页 / 评论区都行)", false));
+    }
 
-    rows.push(W.hint("对文本里的每个 UID,取它上面选的那个列表", false));
+    /* —— 深度(所有起点共用,screen 除外) —— */
+    var expand = buildExpandChooser(item);
+    if (expand) {
+        rows.push(W.settingRow("深度", expand.root, 8));
+    }
 
-    var detail = U.column();
-    W.stack(detail, rows, 12);
-    return detail;
+    return rows;
 }
 
-/**
- * 脚本四 · 关键词视频提 ID(设计稿 16:589;v7 删一个开关 + 补层级)。
- *
- * v7:
- *   · 删「关键词视频提 ID」(与卡名一字不差);
- *   · 三个「提取…」原来是四项平级排列,读不出"第 1 项是根、后 3 项是扩展",
- *     更读不出"评论区与作者粉丝是同一层、评论者的粉丝更深一层" ——
- *     现在加缩进 + 一句说明,让层级在视觉上成立。
- */
-function buildIdScript4Detail() {
-    var base = "tabs.id.scripts.s4.";
-    var rows = [];
+/** 一条取材卡片。 */
+function buildSourceCard(item, index) {
+    var card = W.card(false);
 
-    rows.push(W.settingRow("关键词",
-        W.inlineField({
-            path: base + "keyword",
-            placeholder: "关键词",
-            widthDp: 148
-        }).root, 8));
+    var head = U.row();
+    head.addView(U.text("第 " + (index + 1) + " 条", Theme.m.labelSize, Theme.hex.textPrimary, true));
+    head.addView(U.flexible());
+    var remove = W.smallButton("删除", "danger", function () {
+        Sources.removeAt(index);
+        Pages.requestRerender();
+    });
+    head.addView(remove.root);
 
-    rows.push(W.hint("默认只提取视频作者的 ID;下面三项是额外的取材范围", false));
+    var rows = [head];
+    rows.push(U.spacer(4));
+    rows.push(W.settingRow("起点", buildKindChooser(item).root, 8));
 
-    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 inputs = buildSourceInputs(item);
+    for (var i = 0; i < inputs.length; i++) { rows.push(inputs[i]); }
 
-    var detail = U.column();
-    W.stack(detail, rows, 12);
-    return detail;
+    W.stack(card, rows, 10);
+    return card;
 }
 
-/**
- * key → 展开后的设置区。
- *
- * 四个脚本做的事各不相同(粉丝页 / 评论页 / 文本 UID / 关键词视频),
- * 设置项也就各不相同 —— 早先只有脚本一有内容,另外三张展开是空的,
- * 用户点开折叠箭头会以为坏了。
- */
-Pages.ID_DETAILS = {
-    s1: buildIdScript1Detail,
-    s2: buildIdScript2Detail,
-    s3: buildIdScript3Detail,
-    s4: buildIdScript4Detail
-};
-
-/** 单张脚本卡。折叠态只出头部一行,展开态追加设置区。 */
-function buildIdScriptCard(script) {
-    var base = "tabs.id.scripts." + script.key + ".";
-    var checked = Store.getBool(base + "checked");
-    var expanded = Store.getBool(base + "expanded");
-    var running = Session.running;
-
-    var card = W.card(checked);
-    card.addView(W.scriptHeader({
-        checked: checked,
-        expanded: expanded,
-        name: script.name,
-        mode: "radio",
-        /* 运行中不允许换脚本:设计稿 09 的底部提示就是这么写的,
-         * 而且真换了脚本,状态条上显示的名字就与正在跑的不是同一个了。 */
-        disabled: running,
-        /* 展开后摘要位置让给设置行,避免一行里挤三样东西 */
-        summary: expanded ? null : script.summary,
-        onToggleCheck: function (next) {
-            Pages.toggleIdScript(script.key, next);
-            Pages.requestRerender();
-        },
-        onToggleExpand: function () {
-            Store.set(base + "expanded", !expanded);
-            Pages.requestRerender();
-        }
-    }).root);
-
-    if (expanded) {
-        var build = Pages.ID_DETAILS[script.key];
-        if (build) {
-            card.addView(U.spacer(12));
-            card.addView(build());
-        }
-    }
-
-    return card;
+/** 一条取材都没有时的占位 —— 不用 `W.emptyState`(它靠 weight 撑满整屏,在滚动页里不成立)。 */
+function buildSourcesEmpty() {
+    var box = U.column();
+    box.setBackground(U.strokeBg(0.03, 0.10, "#FFFFFF", Theme.m.cardRadius));
+    box.setPadding(U.dp(16), U.dp(22), U.dp(16), U.dp(22));
+
+    var head = U.text("还没有取材", 13, Theme.hex.textSecondary, true);
+    head.setGravity(android.view.Gravity.CENTER);
+    box.addView(head);
+    box.addView(U.spacer(6));
+
+    var sub = U.text("点下面的「添加一条取材」,选从哪儿找人", Theme.m.hintSize,
+                     Theme.hex.textFaint, false);
+    sub.setGravity(android.view.Gravity.CENTER);
+    box.addView(sub);
+    return box;
 }
 
-/* ------------------------------------------------------------ 功能设置 */
+/* ============================================== ② 只要这样的账号 */
 
-/** 卡内分组标题。
- * 比 W.sectionTitle 轻一档:功能设置是**一张卡里的三个块**,不是三个页面段,
- * 用页面级标题会把三组切成三个互不相干的区域。 */
+/** 卡内分组标题(与 v7 一致:比 sectionTitle 轻一档)。 */
 function featureGroup(title) {
     var view = U.text(title, 11, Theme.hex.textMuted, true);
     view.setPadding(0, U.dp(8), 0, 0);
@@ -363,48 +294,31 @@ function featureGroup(title) {
 }
 
 /**
- * 功能设置卡(设计稿 16:733;v7 重排为三组)。
+ * ② 过滤:所有取材共用。
  *
- * v7 做了三件事:
- *   ① **分三组**:原来 19 个字段平铺,混了"过滤条件 / 检测方式 / 待支持项"三种性质,
- *      用户读不出哪些会真的丢人。现在按「只要这样的账号 / 数值门槛 / 去重与输出」分组。
- *   ② **置灰两项**:提取区域、AI 画像(头像·性别·年龄)当前没有承载者 ——
- *      不再给"能开却无效"的控件,改成灰行 + 「暂不支持」标记,点一行看原因。
- *      值仍保留在配置里,将来接上直接生效。
- *   ③ **去重收口**:脚本卡里的「主播去重 + 去重模式」「UID 博主去重」全部搬到这里,
- *      用「本地去重 / 跨轮去重」两个说人话的开关表达(全页只出现一次)。
- *
- * 关注量/粉丝量/获赞量/视频量仍用 `W.rangeRow` —— 「最小值 ~ 最大值」一个区间占一行,
- * 但两个数各自一个胶囊、各自输入。与 UID私信页的「关注上限 / 私信上限」不冲突:
- * 那边是两个**各自独立成立**的量,该分开;这里的 min/max 是**同一个量的下界与上界**,
- * 拆两行反而像两个不相干的设置。
+ * 这里**一项都不删**(v7 的成果):私密 / 无头像 / 语言 / 取样范围 / 四组数量门槛。
+ * 只有「提取区域」「AI 画像识别」两项当前没有承载者 → 灰行 + 暂不支持
+ * (不给一个能开却什么也不会发生的开关)。
  */
-function buildIdFeatures() {
+function buildAccountFilters() {
     var card = W.card(false);
     var base = "tabs.id.features.";
     var blocks = [];
 
-    /* ══════════════════ 组 1 · 只要这样的账号 ══════════════════ */
-    blocks.push(featureGroup("只要这样的账号"));
+    blocks.push(featureGroup("账号特征"));
 
-    /* 过滤私密 / 无头像:两个勾选框并排(设计稿 16:743) */
     var privateRow = W.checkRow("过滤私密账户", Store.getBool(base + "filterPrivate"),
                                 function (on) { Store.set(base + "filterPrivate", on); });
     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);
     blocks.push(filterLine);
 
-    /* 语言区域 + 取样范围。
-     * 「取样范围」原来叫「昵称个签检测」,与「语言区域」并排却看不出两者的关系 ——
-     * 它其实是语言的**取样字段**(在昵称里查 / 在个签里查 / 都查),
-     * 而不是一个独立的过滤条件。改名之后这一行的读法就成立了。 */
+    /* 语言区域 + 取样范围:后者是前者的**取样字段**,不是独立的过滤条件 */
     var langRow = U.row();
     langRow.addView(U.text("语言区域", Theme.m.labelSize, Theme.hex.textSecondary, false));
     langRow.addView(U.hgap(8));
@@ -415,9 +329,6 @@ function buildIdFeatures() {
     langRow.addView(W.dropdown("nicknameCheck", base + "nicknameCheck").root);
     blocks.push(langRow);
 
-    /* —— 以下两项:执行侧还没有承载者 ——
-     * 不给可操作控件。原来「提取区域」是个输入框 + 一个 chip,说明里还写着"勾选后…",
-     * 但那一行根本没有勾选框 —— 文案与控件自相矛盾,是最容易被当成 bug 的一处。 */
     blocks.push(W.mutedRow("提取区域", Store.getText(base + "regionCodes"), function () {
         Dialog.alert("提取区域 · 暂不支持",
             "TikTok 的用户页**不显示账号所属国家**,没有可靠的地方能读到它,"
@@ -436,7 +347,6 @@ function buildIdFeatures() {
             + "不如明确告诉你它现在不参与。");
     }).root);
 
-    /* ══════════════════ 组 2 · 数值门槛 ══════════════════ */
     blocks.push(featureGroup("数值门槛"));
 
     var metrics = [
@@ -450,12 +360,17 @@ function buildIdFeatures() {
         blocks.push(W.rangeRow(base + m.key + "Min", base + m.key + "Max", m.label, "~").root);
     }
 
-    /* ══════════════════ 组 3 · 去重与输出 ══════════════════ */
-    blocks.push(featureGroup("去重与输出"));
+    W.stack(card, blocks, 12);
+    return card;
+}
+
+/* ============================================== ③ 输出 */
+
+function buildOutput() {
+    var card = W.card(false);
+    var base = "tabs.id.features.";
+    var blocks = [];
 
-    /* 本地去重 / 跨轮去重。
-     * 这两个原来是三处控件(脚本一的「主播去重 + 去重模式」、脚本三的「UID 博主去重」),
-     * 分布在两张卡里,用户根本不知道它们是不是同一件事。现在收到一处、说人话。 */
     var localBox = W.checkRow("本地去重", Store.getBool(base + "dedupeLocal"),
                               function (on) { Store.set(base + "dedupeLocal", on); });
     var historyBox = W.checkRow("跨轮去重", Store.getBool(base + "dedupeHistory"),
@@ -489,7 +404,6 @@ function buildIdFeatures() {
         var testBtn = W.chip("连通测试", "accent");
         testBtn.root.setOnClickListener(function () {
             var table = Store.getText(base + "cloudTable");
-            /* 如实报告:没有服务端地址时就说没配,不假装测试通过 */
             Dialog.alert("连通测试",
                 table === "" ? "请先填写表名。"
                              : "表名:" + table + "\n\n当前未配置云端去重服务地址,无法真实测试。");
@@ -500,7 +414,7 @@ function buildIdFeatures() {
         blocks.push(cloudBox.root);
     }
 
-    /* 提取上限。0 的语义写在文案里 —— 只说"0",用户会以为是"提取 0 个" */
+    /* 提取上限:0 的语义写在文案里 —— 只说"0",用户会以为是"提取 0 个" */
     var stopRow = U.row();
     stopRow.addView(U.text("提取满", Theme.m.labelSize, Theme.hex.textSecondary, false));
     stopRow.addView(U.flexible());
@@ -509,58 +423,56 @@ function buildIdFeatures() {
     stopRow.addView(U.text("个 ID 后停止(0 = 不限)", Theme.m.labelSize, Theme.hex.textMuted, false));
     blocks.push(stopRow);
 
+    blocks.push(W.hint("结果文件自动命名 uid_日期_时分.txt,存放在 /sdcard/Download/tiktok_app", false));
+
     W.stack(card, blocks, 12);
     return card;
 }
 
-/* ------------------------------------------------------------ 页面 */
+/* ============================================== 页面 */
 
-/** 页面主体。rerender 由 main.js 注入,用于交互后重建当前页。 */
 Pages.id = function (rerender) {
     var inner = U.column();
     var blocks = [];
 
-    /* 「当前脚本」提示(设计稿 16:587):只在选了脚本之后出现。
-     * 放在最上面是因为它会随滚动被推走,不会长期占地方。 */
-    var current = Pages.checkedIdScript();
-    if (current) {
-        var tip = U.column();
-        tip.setBackground(U.solidBg(U.alphaOf("#FFFFFF", Theme.alpha.chipFill), 10));
-        tip.setPadding(U.dp(8), U.dp(8), U.dp(8), U.dp(8));
-        var tipText = U.text("\u5F53\u524D\u811A\u672C\uFF1A" + Pages.scriptName("id", current),
-                             11, Theme.hex.textMuted, false);
-        tipText.setGravity(android.view.Gravity.CENTER);
-        tip.addView(tipText);
-        blocks.push(tip);
-    }
-
-    blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9"));
+    /* ——— ① 取材 ——— */
+    blocks.push(W.sectionTitle("取材"));
+    blocks.push(W.hint("从哪儿找人。可添加多条,按顺序执行;共用同一份去重与输出", false));
 
-    /* 单选说明。放在标题下面而不是塞进卡片:它约束的是「整组」,
-     * 写进某一张卡会让人以为只对那张卡生效。 */
-    var rule = U.text("\u5355\u9009\uFF1A\u540C\u4E00\u65F6\u95F4\u4EC5\u53EF\u8FD0\u884C\u4E00\u4E2A\u811A\u672C",
-                      11, Theme.hex.textFaint, false);
-    blocks.push(rule);
+    var list = Sources.list();
+    if (list.length === 0) {
+        blocks.push(buildSourcesEmpty());
+    } else {
+        for (var i = 0; i < list.length; i++) {
+            blocks.push(buildSourceCard(list[i], i));
+        }
+    }
 
-    for (var i = 0; i < Pages.ID_SCRIPTS.length; i++) {
-        blocks.push(buildIdScriptCard(Pages.ID_SCRIPTS[i]));
+    if (list.length < Sources.MAX) {
+        var add = W.ghostButton("+ 添加一条取材", 44, function () {
+            if (Sources.count() >= Sources.MAX) { return; }
+            Sources.add("anchor");
+            Pages.requestRerender();
+        });
+        blocks.push(add.root);
+    } else {
+        blocks.push(W.hint("已达上限 " + Sources.MAX + " 条 —— 想加新的先删一条", false));
     }
 
-    /* ★ 滚动指引放在**脚本四之后、功能设置之前**(2026-10-13 用户指定位置)。
-     * 它指向的就是紧接在下面的那一屏内容,贴着被指向的东西才读得懂;
-     * 原先放在页尾时它是自相矛盾的:滚到功能设置的人已经不需要它,
-     * 而真正需要它的(还没往下滚的人)恰恰也看不到它 —— 它自己也被折在屏幕外。 */
-    blocks.push(W.hint("\u2193 \u4E0B\u65B9\u8FD8\u6709\u300C\u529F\u80FD\u8BBE\u7F6E\u300D\uFF0C\u5411\u4E0B\u6EDA\u52A8\u67E5\u770B", true));
+    /* ——— ② 只要这样的账号 ——— */
+    blocks.push(U.spacer(4));
+    blocks.push(W.sectionTitle("只要这样的账号"));
+    blocks.push(buildAccountFilters());
 
+    /* ——— ③ 输出 ——— */
     blocks.push(U.spacer(4));
-    blocks.push(W.sectionTitle("\u529F\u80FD\u8BBE\u7F6E"));
-    blocks.push(buildIdFeatures());
+    blocks.push(W.sectionTitle("输出"));
+    blocks.push(buildOutput());
 
-    blocks.push(U.spacer(8));
-    /* 运行中的约束提示留在页尾:它约束的是**整页**(不只是脚本卡那一组),
-     * 而且它只在运行中出现 —— 与上面的滚动指引不会同时抢同一个位置。 */
+    /* 运行中的约束提示留在页尾:它约束的是整页,而它只在运行中出现 */
     if (Session.running) {
-        blocks.push(W.hint("\u8FD0\u884C\u4E2D\u4E0D\u53EF\u5207\u6362\u811A\u672C \u00B7 \u8BF7\u4FDD\u6301\u65E0\u969C\u788D\u6743\u9650\u5F00\u542F", true));
+        blocks.push(U.spacer(4));
+        blocks.push(W.hint("运行中不可修改取材 · 请保持无障碍权限开启", true));
     }
 
     W.stack(inner, blocks, Theme.m.gapPage);

+ 27 - 2
tiktok-app/src/page-tasks.js

@@ -58,6 +58,26 @@ Pages.taskName = function (key) {
  * 但接受任意任务 key,而不是只认当前 tab。
  */
 Pages.checkedScript = function (taskKey) {
+    /* ★ v8:ID提取没有"脚本"这个概念了 —— 它的配置是**取材清单**。
+     * 所以"已配置" = 清单非空,而不是"某张卡的 checked 为真"(那些字段已删除)。
+     * 两个名字分开给:
+     *   · `name` 给拦截弹窗用(「ID提取的执行引擎尚未接入」比塞一整段摘要更像人话);
+     *   · `detail` 给任务卡的「当前:…」用(那里正需要"从哪儿取材"这个信息)。 */
+    if (taskKey === "id") {
+        if (Sources.isEmpty()) { return null; }
+        /* 引擎声明仍然从 SCRIPTS_OF 读:将来 ID提取 接入执行引擎时,
+         * 在 `Pages.ID_SCRIPTS` 上加一个 `engine` 字段就够了,这里不用改。 */
+        var idEntry = (Pages.SCRIPTS_OF["id"] || [])[0] || {};
+        return {
+            key: "id",
+            name: Pages.taskName("id"),
+            detail: Sources.summary(),
+            engine: idEntry.engine || "",
+            task: "id",
+            taskName: Pages.taskName("id")
+        };
+    }
+
     var list = Pages.SCRIPTS_OF[taskKey] || [];
     for (var i = 0; i < list.length; i++) {
         var entry = Store.getBool("tabs." + taskKey + ".scripts." + list[i].key + ".checked");
@@ -65,6 +85,7 @@ Pages.checkedScript = function (taskKey) {
             return {
                 key: list[i].key,
                 name: list[i].name,
+                detail: list[i].name,
                 engine: list[i].engine || "",
                 task: taskKey,
                 taskName: Pages.taskName(taskKey)
@@ -99,8 +120,12 @@ Pages.taskState = function (taskKey) {
 /** 任务卡的「当前:xxx」一行。 */
 Pages.taskScriptLine = function (taskKey) {
     var script = Pages.checkedScript(taskKey);
-    var name = script ? script.name : "\u672A\u9009\u62E9\u811A\u672C";
-    return "\u5F53\u524D\uFF1A" + name;
+    if (!script) {
+        /* v8:ID提取没有"脚本"了,它的空态是"还没添加取材" */
+        return "\u5F53\u524D\uFF1A" + (taskKey === "id" ? "\u672A\u6DFB\u52A0\u53D6\u6750" : "\u672A\u9009\u62E9\u811A\u672C");
+    }
+    /* detail 才是"从哪儿取材"(ID提取用清单摘要,其它任务与 name 相同) */
+    return "\u5F53\u524D\uFF1A" + (script.detail || script.name);
 };
 
 /* ------------------------------------------------------------ 任务卡 */

+ 514 - 0
tiktok-app/src/sources.js

@@ -0,0 +1,514 @@
+/* ============================== src/sources.js ==============================
+ * ID提取的「取材清单」—— 从哪儿找人。
+ *
+ * ## 为什么单独一个模块,而不是塞进 Store.FIELDS
+ * Store 管的是「**固定字段**的配置」:一张声明表,一个路径一个标量值。
+ * 取材是**不定长列表**(可以 1 条,也可以 5 条),塞进字段表就等于
+ * 让 validate() 去校验一个数组,还会把「路径 → 值控件」那套审计规则带歪。
+ * 所以照 `Records` 的先例:**同一份 storages,另一个 key**,自己管读写。
+ *
+ * ## 为什么是「清单」而不是「四个脚本选一个」
+ * 旧结构里四张卡是「起点 × 深度」的 4 个**不完整组合**:各缺一块、各有别人没有的一块,
+ * 合起来才完整。于是用户想同时抓两种来源时,单选约束直接不允许。
+ * 取材是**可累加**的,多条按顺序串行执行(同一台手机只有一个前台,没有并发可言),
+ * 共用同一份去重集合与同一个输出文件 —— 这才对得上用户的真实用法。
+ *
+ * ## 一条取材长什么样
+ * `{ id, kind, ...按 kind 决定用哪几个参数..., expand }`
+ * 只有 5 种起点,每种需要的参数由 `FIELDS_OF` 声明;界面据此决定显示哪些输入行。
+ * **选了什么起点才出现什么输入**,所以不会再有"勾上却无所指"的开关。
+ */
+
+var Sources = {};
+
+/** 与 Store / Records 共用一份 storages,只是换一个 key(见文件头注释)。 */
+Sources.STORAGE_NAME = "feizhou_app";
+Sources.STORAGE_KEY = "sources";
+
+/**
+ * 最多几条。
+ *
+ * 5 条刚好覆盖全部五种起点各一条;再多页面会长到滚不完,
+ * 而串行跑一轮的真实耗时也不允许(每条都要挨个打开用户页)。
+ */
+Sources.MAX = 5;
+
+/**
+ * 五种起点。顺序 = 下拉里的顺序 = 从常见到少见。
+ *
+ * `take` 是"这个起点能取到什么人",只用于界面说明 ——
+ * 执行引擎还没接,所以这里不写任何选择器。
+ */
+Sources.KINDS = [
+    { key: "anchor", label: "指定账号", take: "它的粉丝 / 关注 / 主页展示的账号" },
+    { key: "search", label: "搜索结果", take: "用户搜索结果里的人 / 视频的作者" },
+    { key: "video",  label: "某个视频的评论区", take: "评论者" },
+    { key: "text",   label: "文本文件", take: "文件里每一行的 UID" },
+    { key: "screen", label: "当前屏幕", take: "此刻屏幕上这个列表里的人" }
+];
+
+/**
+ * 深度:**最多 2 层**(列表里的人 → 这些人的粉丝)。
+ *
+ * 第 3 层不给选项是有原因的:100 个粉丝 × 每人 100 个粉丝 = 1 万次用户页打开,
+ * 在真机上不现实,也会让账号看起来异常。这是产品约束,不是实现偷懒。
+ */
+Sources.EXPANDS = [
+    { key: "",          label: "不展开" },
+    { key: "fans",      label: "再取粉丝列表" },
+    { key: "following", label: "再取关注列表" }
+];
+
+/** `anchor` 起点可以取哪几个列表(多选)。 */
+Sources.LISTS = [
+    { key: "fans",      label: "粉丝列表" },
+    { key: "following", label: "关注列表" },
+    { key: "home",      label: "主页展示" }
+];
+
+/** `search` 起点的两种类型。 */
+Sources.SEARCH_TYPES = [
+    { key: "users",  label: "用户" },
+    { key: "videos", label: "视频作者" }
+];
+
+/** `video` 起点的两种来源。 */
+Sources.VIDEO_SOURCES = [
+    { key: "screen",  label: "当前屏幕" },
+    { key: "keyword", label: "关键词搜索" }
+];
+
+/**
+ * 每种起点**需要哪些参数**。界面与归一化都读这一张表。
+ *
+ * `lists` 是唯一的多值参数(数组),其余都是字符串。
+ */
+Sources.FIELDS_OF = {
+    anchor: ["anchors", "lists"],
+    search: ["searchType", "keyword"],
+    video:  ["videoSource", "videoKeyword"],
+    text:   ["path"],
+    screen: []
+};
+
+/** 该起点是否能再展开一层("当前屏幕"已经是最底了,没得展开)。 */
+Sources.canExpand = function (kind) {
+    return kind !== "screen";
+};
+
+/* ------------------------------------------------------------ 基础工具 */
+
+function sourceTrim(text) {
+    return String(text === undefined || text === null ? "" : text).replace(/^\s+|\s+$/g, "");
+}
+
+/** 留空就用业务默认值:空输入框既看不出该填什么,也让"没配"和"配了空"分不开。 */
+function withDefault(value, fallback) {
+    var text = sourceTrim(value);
+    return text === "" ? fallback : text;
+}
+
+function sourceKeys(list) {
+    var out = [];
+    for (var i = 0; i < list.length; i++) { out.push(list[i].key); }
+    return out;
+}
+
+/** 生成一个稳定 id。删除与自检都靠它,所以不能只用下标(删除后下标会滑)。 */
+Sources.newId = function () {
+    return "src-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
+};
+
+/* ------------------------------------------------------------ 归一化 */
+
+/**
+ * 把一条取材收拢成合法形状。
+ *
+ * 两条原则:
+ *   ① **只保留与该 kind 有关的参数** —— 用户从「指定账号」切到「文本文件」时,
+ *      旧的 anchors 不该继续躺在数据里(导出配置时会让人以为它还有用);
+ *   ② 非法值一律回退到该参数的默认值,**并且不抛异常** ——
+ *      一条坏数据不该让整页渲染不出来(与 Store.normalizeField 同一套态度)。
+ *
+ * @param raw    原始对象(可能缺字段、类型错、来自旧版本)
+ * @param forceKind  强制用这个 kind(新建时用),不传则读 raw.kind
+ */
+Sources.normalize = function (raw, forceKind) {
+    var one = (raw && typeof raw === "object") ? raw : {};
+    var kinds = sourceKeys(Sources.KINDS);
+    var kind = sourceTrim(forceKind || one.kind);
+    if (kinds.indexOf(kind) < 0) { kind = "anchor"; }
+
+    var out = {
+        id: sourceTrim(one.id) || Sources.newId(),
+        kind: kind
+    };
+
+    var fields = Sources.FIELDS_OF[kind];
+
+    for (var i = 0; i < fields.length; i++) {
+        var name = fields[i];
+        if (name === "lists") {
+            var allowed = sourceKeys(Sources.LISTS);
+            var picked = [];
+            var rawLists = one.lists;
+            if (rawLists instanceof Array) {
+                for (var j = 0; j < rawLists.length; j++) {
+                    if (allowed.indexOf(rawLists[j]) >= 0 && picked.indexOf(rawLists[j]) < 0) {
+                        picked.push(rawLists[j]);
+                    }
+                }
+            }
+            /* 一个都不选 = 这条取材取不到任何人。回退成"粉丝列表"而不是留空:
+             * 留空会让界面出现一条**永远跑不出结果**的取材,而用户看不出为什么。 */
+            if (picked.length === 0) { picked.push("fans"); }
+            out.lists = picked;
+        } else if (name === "searchType") {
+            out.searchType = sourceKeys(Sources.SEARCH_TYPES).indexOf(one.searchType) >= 0
+                ? one.searchType : "users";
+        } else if (name === "videoSource") {
+            out.videoSource = sourceKeys(Sources.VIDEO_SOURCES).indexOf(one.videoSource) >= 0
+                ? one.videoSource : "screen";
+        } else if (name === "keyword" || name === "videoKeyword") {
+            out[name] = withDefault(one[name], "fashion");
+        } else if (name === "path") {
+            out.path = withDefault(one.path, Store.TEXT_DIR + "/uid_list.txt");
+        } else if (name === "anchors") {
+            /* 账号可以留空:留空 = 不限定、从当前屏幕的列表直接抓。
+             * 这是真实且合理的状态,所以不塞假默认值(与 Store 里 anchorIds 同一套规则)。 */
+            out.anchors = sourceTrim(one.anchors);
+        }
+    }
+
+    /* 深度:screen 起点没有展开这一说;其余非法值一律回退"不展开" */
+    out.expand = "";
+    if (Sources.canExpand(kind)) {
+        var expands = sourceKeys(Sources.EXPANDS);
+        if (expands.indexOf(one.expand) >= 0) { out.expand = one.expand; }
+    }
+
+    return out;
+};
+
+/* ------------------------------------------------------------ 读写 */
+
+function sourceStorage() {
+    return storages.create(Sources.STORAGE_NAME);
+}
+
+/**
+ * 读全部取材(按顺序)。
+ *
+ * 读不出来 → 空数组(一条坏数据不该让整个界面起不来);
+ * 超过 MAX → 截断(旧版本可能存过更多条)。
+ */
+Sources.list = function () {
+    var raw = null;
+    try {
+        raw = sourceStorage().get(Sources.STORAGE_KEY, null);
+    } catch (e) {
+        console.log("[sources] 读取失败: " + e);
+    }
+    if (typeof raw === "string" && raw.length > 0) {
+        try {
+            raw = JSON.parse(raw);
+        } catch (e2) {
+            console.log("[sources] JSON 解析失败,按空处理");
+            raw = null;
+        }
+    }
+    if (!(raw instanceof Array)) { return []; }
+
+    var out = [];
+    for (var i = 0; i < raw.length && out.length < Sources.MAX; i++) {
+        if (raw[i] && typeof raw[i] === "object") {
+            out.push(Sources.normalize(raw[i]));
+        }
+    }
+    return out;
+};
+
+Sources.save = function (list) {
+    var safe = (list instanceof Array) ? list.slice(0, Sources.MAX) : [];
+    try {
+        sourceStorage().put(Sources.STORAGE_KEY, JSON.stringify(safe));
+        return true;
+    } catch (e) {
+        console.log("[sources] 保存失败: " + e);
+        return false;
+    }
+};
+
+/* ------------------------------------------------------------ 增删改 */
+
+/** 按 id 取一条(界面每次操作都重新读,避免拿渲染时的旧快照覆盖新值)。 */
+Sources.find = function (id) {
+    var list = Sources.list();
+    for (var i = 0; i < list.length; i++) {
+        if (list[i].id === id) { return list[i]; }
+    }
+    return null;
+};
+
+
+Sources.isEmpty = function () {
+    return Sources.list().length === 0;
+};
+
+Sources.count = function () {
+    return Sources.list().length;
+};
+
+/** 追加一条(同时告诉调用方成不成 —— 满了就不该静默失败)。 */
+Sources.add = function (kind) {
+    var list = Sources.list();
+    if (list.length >= Sources.MAX) { return null; }
+    var one = Sources.normalize({ kind: kind || "anchor" });
+    list.push(one);
+    Sources.save(list);
+    return one;
+};
+
+/**
+ * 改一条。`patch` 只合入传进来的键,其余保持。
+ *
+ * 注意:**改 kind 要重新归一化**(新起点不该继承旧起点的参数)——
+ * 所以这里走 normalize(patch 合并后, 新 kind),而不是简单 assign。
+ */
+Sources.update = function (id, patch) {
+    var list = Sources.list();
+    for (var i = 0; i < list.length; i++) {
+        if (list[i].id !== id) { continue; }
+        var merged = {};
+        var k;
+        for (k in list[i]) {
+            if (list[i].hasOwnProperty(k)) { merged[k] = list[i][k]; }
+        }
+        for (k in patch) {
+            if (patch.hasOwnProperty(k)) { merged[k] = patch[k]; }
+        }
+        merged.id = id;
+        list[i] = Sources.normalize(merged, merged.kind);
+        Sources.save(list);
+        return list[i];
+    }
+    return null;
+};
+
+/**
+ * 删第 index 条。
+ *
+ * 用**下标**而不是 id:界面是按顺序渲染的,用户点的就是"第几条"。
+ * 删除后其余条目自动前移,不需要重排 id。
+ */
+Sources.removeAt = function (index) {
+    var list = Sources.list();
+    if (index < 0 || index >= list.length) { return false; }
+    list.splice(index, 1);
+    Sources.save(list);
+    return true;
+};
+
+Sources.clear = function () {
+    try {
+        sourceStorage().remove(Sources.STORAGE_KEY);
+    } catch (e) {
+        console.log("[sources] 清空失败: " + e);
+    }
+};
+
+/* ------------------------------------------------------------ 文案 */
+
+/** 按 key 取中文名(找不到回退 key 本身 —— 显示 "zzz" 也比 "undefined" 强)。 */
+function labelOf(table, key) {
+    for (var i = 0; i < table.length; i++) {
+        if (table[i].key === key) { return table[i].label; }
+    }
+    return key;
+}
+
+Sources.kindLabel = function (kind) { return labelOf(Sources.KINDS, kind); };
+Sources.expandLabel = function (key) { return labelOf(Sources.EXPANDS, key); };
+
+/**
+ * 一条取材 → 一句话摘要。
+ *
+ * 用在两个地方:取材卡片的标题行(收起时也要看得出这条是干什么的)、
+ * 以及运行记录里的「当前脚本」行(那里放不下一整段配置)。
+ */
+Sources.describe = function (one) {
+    if (!one) { return ""; }
+    var head = "";
+
+    if (one.kind === "anchor") {
+        var lists = [];
+        for (var i = 0; i < one.lists.length; i++) {
+            lists.push(labelOf(Sources.LISTS, one.lists[i]).replace("列表", ""));
+        }
+        var who = sourceTrim(one.anchors) === "" ? "当前屏幕的账号" : one.anchors.split("#")[0];
+        head = who + " · " + lists.join("+");
+    } else if (one.kind === "search") {
+        head = (one.searchType === "videos" ? "视频作者" : "用户") + "「" + one.keyword + "」";
+    } else if (one.kind === "video") {
+        head = one.videoSource === "keyword"
+            ? "评论区「" + one.videoKeyword + "」"
+            : "当前屏幕的评论区";
+    } else if (one.kind === "text") {
+        var name = String(one.path).split("/").pop();
+        head = "文本 " + name;
+    } else {
+        head = "当前屏幕";
+    }
+
+    if (one.expand) { head += " → " + labelOf(Sources.EXPANDS, one.expand).replace("再取", ""); }
+    return head;
+};
+
+/**
+ * 整份清单 → 一句话(任务卡与运行记录用)。
+ * 空清单返回空串,由调用方决定显示「未配置」还是别的东西。
+ */
+Sources.summary = function () {
+    var list = Sources.list();
+    if (list.length === 0) { return ""; }
+    if (list.length === 1) { return Sources.describe(list[0]); }
+    return Sources.describe(list[0]) + " 等 " + list.length + " 条";
+};
+
+/* ------------------------------------------------------------ 旧配置迁移 */
+
+/** 读 Store 那份 sourceStorage 的**原始** JSON(含 v8 之前的四张卡字段)。 */
+function readLegacyConfig() {
+    try {
+        var raw = storages.create(Store.STORAGE_NAME).get(Store.STORAGE_KEY, null);
+        if (typeof raw === "string" && raw.length > 0) { return JSON.parse(raw); }
+        if (raw && typeof raw === "object") { return raw; }
+    } catch (e) {
+        console.log("[sources] 读取旧配置失败: " + e);
+    }
+    return null;
+}
+
+function readPathSafe(obj, path) {
+    var segs = String(path).split(".");
+    var node = obj;
+    for (var i = 0; i < segs.length; i++) {
+        if (node === null || typeof node !== "object") { return undefined; }
+        node = node[segs[i]];
+    }
+    return node;
+}
+
+/**
+ * 旧「进入页面」→ 新的「取哪些列表」。
+ *
+ * `comment` 没有对应:在"从一个账号取它哪个列表"这个语义下,账号页没有"评论页"。
+ * 这是 v6 遗留的一处语义模糊,迁移时回退成粉丝列表并记一条日志 ——
+ * 用户的意图在 v8 里可以用「某个视频的评论区」这条取材重新表达。
+ */
+function listsFromEnterPage(value) {
+    if (value === "follow") { return ["following"]; }
+    if (value === "home") { return ["home"]; }
+    if (value === "comment") {
+        console.log("[sources] 旧「进入页面=评论页」在取材清单里没有对应,已按「粉丝列表」迁移");
+        return ["fans"];
+    }
+    return ["fans"];
+}
+
+/**
+ * v7 → v8:把"四张卡里选了一张"翻译成取材清单。**一次性**。
+ *
+ * 三条原则:
+ *   ① **已有清单就不动** —— 用户自己加的取材永远不被覆盖(重装/降级都安全);
+ *   ② **能展开成多条就展开** —— 脚本四的三个开关本来就是三种取材范围,
+ *      旧结构只能勾在同一个脚本里,新结构下它们天然是三条独立取材;
+ *   ③ 超出 `MAX` 要截断并**说出来**,不静默丢。
+ *
+ * @returns 迁移后的清单;没得迁移(没旧配置 / 已有清单 / 旧配置一条都没选)返回 null
+ */
+Sources.migrateLegacy = function () {
+    if (Sources.list().length > 0) { return null; }
+
+    var raw = readLegacyConfig();
+    if (!raw) { return null; }
+
+    var scripts = readPathSafe(raw, "tabs.id.scripts") || {};
+    var items = [];
+
+    /* —— 脚本一:主播账号 + 可选的"搜索用户"和"文本" —— */
+    var s1 = scripts.s1 || {};
+    if (s1.checked) {
+        items.push({
+            kind: "anchor",
+            anchors: s1.anchorIds || "",
+            lists: listsFromEnterPage(s1.enterPage),
+            expand: ""
+        });
+        if (s1.useTextId) {
+            items.push({ kind: "text", path: s1.textIdPath, expand: "" });
+        }
+        if (s1.customPage) {
+            items.push({
+                kind: "search",
+                searchType: "users",
+                keyword: s1.customPageKeyword || "",
+                expand: ""
+            });
+        }
+    }
+
+    /* —— 脚本二:评论区(+ 可选展开评论者的粉丝) —— */
+    var s2 = scripts.s2 || {};
+    if (s2.checked) {
+        items.push({
+            kind: "video",
+            videoSource: s2.videoSource || "screen",
+            videoKeyword: s2.videoKeyword || "",
+            expand: s2.commenterFans ? "fans" : ""
+        });
+    }
+
+    /* —— 脚本三:文本裂变(旧"进入页面"就是它的展开方向) —— */
+    var s3 = scripts.s3 || {};
+    if (s3.checked) {
+        items.push({
+            kind: "text",
+            path: s3.fissionTextPath,
+            expand: (s3.enterPage === "follow") ? "following" : "fans"
+        });
+    }
+
+    /* —— 脚本四:三个开关展开成三条取材(旧结构塞不下,新结构天然支持) —— */
+    var s4 = scripts.s4 || {};
+    if (s4.checked) {
+        var kw = s4.keyword || "";
+        items.push({
+            kind: "search",
+            searchType: "videos",
+            keyword: kw,
+            expand: s4.anchorFans ? "fans" : ""
+        });
+        if (s4.commentArea) {
+            items.push({ kind: "video", videoSource: "keyword", videoKeyword: kw, expand: "" });
+        }
+        if (s4.commenterFans) {
+            items.push({ kind: "video", videoSource: "keyword", videoKeyword: kw, expand: "fans" });
+        }
+    }
+
+    if (items.length === 0) { return null; }
+
+    if (items.length > Sources.MAX) {
+        console.log("[sources] 旧配置翻译出 " + items.length + " 条取材,超过上限 " +
+                    Sources.MAX + " 条,已截断(多出来的取材可在界面重新添加)");
+        items = items.slice(0, Sources.MAX);
+    }
+
+    var list = [];
+    for (var i = 0; i < items.length; i++) {
+        list.push(Sources.normalize(items[i]));
+    }
+    Sources.save(list);
+    return list;
+};

+ 29 - 73
tiktok-app/src/store.js

@@ -17,12 +17,17 @@
 
 var Store = {};
 
-/* v7(2026-10-17):ID提取页**清语义**(需求重整 A 档)。
- * 删掉 4 个与卡片同义反复的开关、把 3 处去重收到「功能设置」只出现一次、
- * 给两个"勾了却无所指"的开关补上它缺的输入、s3 的「进入页面」换成只含粉丝/关注两值的
- * `expandList`(原来 4 个值里有 2 个在"展开哪个列表"这个语义下是非法值)。
- * 迁移见 MIGRATIONS[6]。 */
-Store.SCHEMA_VERSION = 7;
+/* v8(2026-10-17):ID提取页从「四张单选脚本卡」改成**三段式**
+ * (① 取材清单 ② 只要这样的账号 ③ 输出)。
+ *
+ * 四张卡的 23 个字段全部删除,清单改由 `Sources` 模块单独持久化
+ * (不定长列表进不了这张标量表)。旧配置由 `Sources.migrateLegacy()`
+ * 翻译成清单,用户的选项一个不丢。
+ *
+ * v7(同日早些时候)是"清语义"那一档:删同义反复的开关、补缺输入的开关、
+ * 功能设置分三组。那一档的成果(36 个字段、三组结构、置灰方式)v8 全部继承。
+ */
+Store.SCHEMA_VERSION = 8;
 Store.STORAGE_NAME = "feizhou_app";
 Store.STORAGE_KEY = "config";
 
@@ -174,65 +179,20 @@ F.push(
 );
 
 /* ——— ID提取页 ———
- * 四张卡是**单选组**,默认一个都不选:设计稿 16:23 专门画了「未选择脚本」
- * 这一屏(并配了「请先选择要运行的脚本」的拦截弹窗),说明"还没选"是一个
- * 真实且合法的初始状态。四个选项里替用户随便挑一个,反而是越权。
- * 脚本一默认展开,因为它承载的设置行最多,收起会让人以为它没内容。 */
-/* ——— 脚本一 · 粉丝页提 ID ———
- * v7 清理:
- *   · 删「自定关注或粉丝页面提 ID」—— 它和下面的「从哪个页面取」说的是同一件事,
- *     留着只会让用户以为"关掉它"和"取消勾选这张卡"有区别;
- *   · 删「主播去重 + 去重模式」—— 去重收到「功能设置」,全页只出现一次;
- *   · 「自定页面提 ID」补上它缺的关键词输入(原来勾上之后无所指);
- *   · 「进入页面」改叫「从哪个页面取」(它取的是人,不是"进入"某页)。 */
-F.push.apply(F, cardFields("tabs.id.scripts.s1", false, true));
-F.push(
-    /* 搜索用户:原来是个纯开关,勾上却没说搜什么 —— 补关键词。留空 = 没配,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,
-     * 而不是塞一个假默认值(审计靠这个标记把「合理的空」和「漏了默认值」分开)。 */
-    { path: "tabs.id.scripts.s1.anchorIds", type: "text", def: "", optional: true },
-    { path: "tabs.id.scripts.s1.useTextId", type: "bool", def: false },
-    { path: "tabs.id.scripts.s1.textIdPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" }
-);
-
-/* ——— 脚本二 · 评论页提 ID(设计稿 16:483)———
- * v7:「自定评论页面提 ID」这个纯开关换成了**视频从哪来**。
- * 原开关勾上只说"用自定义的评论页面",却没说那条视频是谁 —— 无从执行。 */
-F.push.apply(F, cardFields("tabs.id.scripts.s2", false, false));
-F.push(
-    { 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)———
- * v7:删「文本 UID 裂变提 ID」(与卡名一字不差,勾不勾都说不通)与「UID 博主去重」
- * (与脚本一的去重是同一件事,已收到功能设置)。
- * 「进入页面」换成 expandList —— 这里问的是"展开文本里这些人的哪个列表",
- * 只有粉丝/关注两个合法答案。 */
-F.push.apply(F, cardFields("tabs.id.scripts.s3", false, false));
-F.push(
-    { 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.keyword",        type: "text", def: "fashion" },
-    /* 三个「提取…」是额外的取材范围,默认都关:默认行为只剩「提视频作者的 ID」这一件事,
-     * 最可预期。要更多来源自己勾 —— 反过来(默认全开)会让人不知道它到底抓了什么。 */
-    { path: "tabs.id.scripts.s4.anchorFans",     type: "bool", def: false },
-    { path: "tabs.id.scripts.s4.commentArea",    type: "bool", def: false },
-    { path: "tabs.id.scripts.s4.commenterFans",  type: "bool", def: false }
-);
+ * ★ v8(2026-10-17):四张脚本卡的 **23 个字段全部删除**。
+ *
+ * 为什么删而不是继续改:四张卡是「起点 × 深度」的 4 个**不完整组合**
+ * (各缺一块、各有别人没有的一块,合起来才完整)。这个结构本身就是"乱"的根 ——
+ * 于是用户想同时抓两种来源时,单选约束直接不允许。
+ * v8 把它们换成**取材清单**:可累加、每条自带起点与深度、按顺序串行执行。
+ *
+ * 清单**不在这张表里**:它是不定长列表,放进来就等于让 validate() 去校验数组,
+ * 还会把「路径 → 值控件」那套审计规则带歪。它走 `Sources` 模块自己的 storage key
+ * (照 `Records` 先例,理由见 `src/sources.js` 文件头)。
+ *
+ * 旧配置由 `Sources.migrateLegacy()` 翻译成清单(`Store.load` 末尾调用):
+ * 用户原来的选择一个都不丢 —— s4 的三个开关甚至会展开成**三条**取材。
+ */
 
 /* ID提取页的「功能设置」(设计稿 16:733)。
  * 门槛类字段(关注量/粉丝量/获赞量/视频量)按「一个控件一个数」拆成 min/max 两项,
@@ -412,8 +372,6 @@ Store.MIGRATIONS = {
         var emptyInV1 = [
             "tabs.video.keyword",
             "tabs.video.region",
-            "tabs.id.scripts.s1.textIdPath",
-            "tabs.dm.scripts.s1.uidTextPath",
             "tabs.dm.dmTextPath",
             "tabs.dm.commentTextPath",
             "tabs.video.dmTextPath",
@@ -524,12 +482,10 @@ Store.MIGRATIONS = {
      *    语义上更接近"跨轮")。这条映射**不完美**,所以写进 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 +
-                          " 在「展开哪个列表」的语义下不成立,已改为「关注列表」");
-        }
+        /* ⚠️ v8 起 s3 的「进入页面」字段已经随四张卡一起删除,
+         * 这里不再对它做归一化 —— 那段值现在由 `Sources.migrateLegacy()`
+         * 从原始 src 直接读,翻译成取材的「展开哪个列表」。
+         * 原来那条 assignPath 会写到一个**已不存在的路径**上,纯属幽灵写入。 */
 
         var oldDedupe = readPath(src, "tabs.id.scripts.s1.dedupeEnabled");
         if (typeof oldDedupe === "boolean") {

+ 75 - 6
tiktok-app/src/widgets.js

@@ -537,7 +537,12 @@ W.toggleRow = function (items, onChange) {
  *   onSubmit      软键盘回车
  */
 W.inlineField = function (opts) {
-    var bound = typeof opts.path === "string" && opts.path !== "";
+    /* 绑定方式有两种:
+     *   · `path`  —— 绑 Store 的字段(绝大多数控件)
+     *   · `read`/`write` —— 绑"不是 Store 字段"的值(取材清单里的账号/关键词/路径)。
+     *     后者必须自己提供读写,因为 Sources 是另一个储物柜。 */
+    var hasWriter = typeof opts.write === "function";
+    var bound = hasWriter || (typeof opts.path === "string" && opts.path !== "");
     var numeric = !!opts.numeric;
 
     var edit = new android.widget.EditText(context);
@@ -573,7 +578,11 @@ W.inlineField = function (opts) {
     var lastText = "";
 
     function readStore() {
-        if (bound) {
+        if (typeof opts.read === "function") {
+            var fromCallback = opts.read();
+            return fromCallback === undefined || fromCallback === null ? "" : String(fromCallback);
+        }
+        if (bound && typeof opts.path === "string" && opts.path !== "") {
             return numeric ? String(Store.getNumber(opts.path)) : Store.getText(opts.path);
         }
         return opts.value === undefined || opts.value === null ? "" : String(opts.value);
@@ -603,6 +612,15 @@ W.inlineField = function (opts) {
     function commit() {
         if (!bound) { return false; }
         var raw = String(edit.getText()).trim();
+
+        /* 写到哪里:`opts.write` 优先(取材清单这类不在字段表里的值),
+         * 否则写 Store(绝大多数控件走这条)。 */
+        if (hasWriter) {
+            opts.write(raw);
+            if (opts.onCommitted) { opts.onCommitted(raw); }
+            return true;
+        }
+
         if (numeric) {
             if (raw === "") { return false; }
             var n = Number(raw);
@@ -657,8 +675,16 @@ W.inlineField = function (opts) {
     };
 
     /* 登记进「本页待提交输入框」:切页签 / 按开始运行这类**不会先失焦**的动作
-     * 之前要整体提交一次,否则「只退格、没失焦就走」的那次编辑会丢。 */
-    if (bound) { W.pendingFields[opts.path] = handle; }
+     * 之前要整体提交一次,否则「只退格、没失焦就走」的那次编辑会丢。
+     * 绑 Store 字段的进 pendingFields(审计会据此判断"这个字段有控件"),
+     * 绑 Sources 之类非字段值的进 pendingExtra(不进审计口径)。 */
+    if (bound) {
+        if (typeof opts.path === "string" && opts.path !== "") {
+            W.pendingFields[opts.path] = handle;
+        } else {
+            W.pendingExtra.push(handle);
+        }
+    }
 
     return handle;
 };
@@ -671,8 +697,15 @@ W.pendingFields = {};
 /** 开始渲染一个新页面:清空待提交表。调用方要先 `commitPendingInputs()` 再调这个。 */
 W.beginPage = function () {
     W.pendingFields = {};
+    W.pendingExtra = [];
 };
 
+/* 不绑定配置路径的输入框(例如 ID提取「取材清单」里的账号/关键词/路径)。
+ * 与 pendingFields 分开是**必须的**:pendingFields 同时被取值审计当作
+ * 「这个字段有界面控件」的证据,而取材字段根本不在字段表里 ——
+ * 混在一起会让审计报「绑定了表外路径」。 */
+W.pendingExtra = [];
+
 /**
  * 提交当前页面上所有绑定过配置的输入框。
  *
@@ -686,6 +719,40 @@ W.commitPendingInputs = function () {
             W.pendingFields[path].commit();
         }
     }
+    for (var i = 0; i < W.pendingExtra.length; i++) {
+        W.pendingExtra[i].commit();
+    }
+};
+
+/**
+ * 通用单选胶囊:**不绑 Store 路径**,选完只回调一个下标。
+ *
+ * 为什么需要它:`W.dropdown` 绑的是 `Store` 的一个枚举路径,
+ * 而取材清单存在 `Sources` 里(不是字段),它的"起点 / 深度"这类选择只能走回调。
+ *
+ * @param opts.label     当前显示的文案
+ * @param opts.options   候选文案数组
+ * @param opts.index     当前下标(**函数**,点击时现算 —— 渲染之后状态可能已经变了)
+ * @param opts.onPick    选中回调 (index)
+ */
+W.chooser = function (opts) {
+    var chip = W.chip(opts.label, opts.tone || "default", opts.minWidthDp || 76);
+    chip.root.setOnClickListener(function () {
+        var current = typeof opts.index === "function" ? opts.index() : opts.index;
+        if (current === undefined || current === null || current < 0) { current = 0; }
+        Dialog.choose({
+            options: opts.options,
+            currentIndex: current,
+            onConfirm: function (index) {
+                chip.setText(opts.options[index]);
+                opts.onPick(index);
+            }
+        });
+    });
+    return {
+        root: chip.root,
+        set: function (text) { chip.setText(text); }
+    };
 };
 
 /**
@@ -809,8 +876,10 @@ function pickTextFileInto(storePath, onPicked) {
         currentIndex: 0,
         onConfirm: function (index) {
             var picked = list[index];
-            Store.set(storePath, picked);
-            if (onPicked) { onPicked(); }
+            /* storePath 可以为空:取材清单这类"不在字段表里的路径"只走回调,
+             * 不写 Store(否则会往配置里塞一个表外字段)。 */
+            if (storePath) { Store.set(storePath, picked); }
+            if (onPicked) { onPicked(picked); }
             toast("已选择:" + picked);
         }
     });

+ 180 - 204
tiktok-app/tools/audit-values.js

@@ -229,13 +229,9 @@ const loaded = api.Store.init();
 console.log('归一化 + 迁移结果:');
 const EXPECT = [
     ['activeTab', 'tasks', '非法 tab 回退到「任务」'],
-    ['tabs.id.scripts.s1.checked', true, '字符串真值转布尔'],
-    /* 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 空串经迁移补默认'],
+    /* v8:四张卡的字段全部删了,脏数据里那些旧值**不该留下来**(见下面那条断言)。
+     * 这里保留的是功能设置的两个去重开关(v7 起就在,v8 继续)。 */
+    ['tabs.id.features.dedupeLocal', true, '归一化后仍有合法默认值'],
     /* 旧上限是「非数字」→ 迁移读不出数就不动它,直接用默认区间 */
     ['tabs.dm.followMin', 100, '旧关注上限非数字 → 不迁移,用默认区间下限'],
     ['tabs.dm.followMax', 200, '旧关注上限非数字 → 不迁移,用默认区间上限'],
@@ -268,7 +264,7 @@ EXPECT.forEach(([p, want, why]) => {
 });
 
 /* 表外字段必须被丢弃,否则脏数据会一路传到界面 */
-if (api.Store.get('已废弃的字段') === undefined && loaded.tabs.id.scripts.s1.旧字段 === undefined) {
+if (api.Store.get('已废弃的字段') === undefined && loaded.tabs.id.scripts === undefined) {
     pass('表外字段已被丢弃,不会穿透到界面');
 } else {
     fail('表外字段没有被丢弃');
@@ -285,8 +281,8 @@ pageShows('video', 'US', '主播区域有默认值');
 pageShows('video', '停止个数', '停止个数是区间行');
 pageShows('video', '7', '旧 stopCount=7 迁移后在界面上显示为 7 ~ 7');
 pageShows('id', '本地去重', '去重开关已收到功能设置(全页只出现一次)');
-pageShows('id', '从哪个页面取', '「从哪个页面取」取代了原来的「进入页面」');
-pageShows('id', '粉丝页', '默认从粉丝页取');
+pageShows('id', '只要这样的账号', '过滤条件独立成段(原来混在功能设置卡里)');
+pageShows('id', '还没有取材', '空清单有明确占位,不是一片空白');
 pageShows('dm', '关注+私信去重', '去重方式有默认值');
 /* 关注与私信各自独立成项;数量是「一行一个区间」 */
 pageShows('dm', '进行关注', '「进行关注」独立一行');
@@ -312,110 +308,70 @@ pageShows('id', '500000', '数量门槛有默认上限');
 pageShows('id', '关注量', '关注量有独立一行');
 pageShows('id', '~', '数量门槛显示成「最小值 ~ 最大值」');
 
-/* —— ID提取 · 四张脚本卡的展开内容 ——
+/* —— v8:ID提取的「取材清单」——
  *
- * 早先只有脚本一有展开内容,脚本二/三/四点开是**空的** —— 用户会以为折叠箭头坏了。
- * 逐张**单独**展开来扫,避免"打开了 A 的卡却把 B 的内容算成它的"这种自欺。
- * 每一项都对着设计稿的节点核过(16:533 / 16:589)。 */
+ * 这一段守的是 v8 的核心结构:清单为空 → 占位;加一条 → 按起点出现对应输入;
+ * 换起点 → **旧起点的输入必须消失**(不留残影);加满 → 添加入口变成提示。
+ * 每一条都是"结构真的换了"的直接证据,而不是"文案改过了"。
+ */
 console.log('');
-console.log('ID提取 · 四张脚本卡的展开内容:');
-/* v7 之后每张卡的关键控件(文案已按清语义后的版本更新)。
- * 注意 s4 的「关键词」不能拿来当断言词 —— 卡名「关键词视频提 ID」里就含它,
- * 会变成"永远通过"的假断言。 */
-const ID_CARD_WORDS = [
-    { key: 's1', label: '脚本一 · 粉丝页提 ID',
-      words: ['从哪个页面取', '主播账号', '搜索用户提 ID', '使用文本 ID'] },
-    { key: 's2', label: '脚本二 · 评论页提 ID',
-      words: ['视频从哪来', '再取评论者的粉丝 ID'] },
-    { key: 's3', label: '脚本三 · 文本 UID 裂变提 ID',
-      words: ['展开哪个列表', '裂变文本'] },
-    { key: 's4', label: '脚本四 · 关键词视频提 ID',
-      words: ['作者的粉丝 ID', '评论区里的 ID', '评论者的粉丝 ID'] }
-];
-
-function expandOnlyId(key) {
-    ['s1', 's2', 's3', 's4'].forEach((k) => {
-        api.Store.set('tabs.id.scripts.' + k + '.expanded', k === key);
-    });
-}
-
-ID_CARD_WORDS.forEach((card) => {
-    expandOnlyId(card.key);
-    const joined = textsOf('id').join(' | ');
-    const missing = card.words.filter((w) => joined.indexOf(w) < 0);
-    if (missing.length === 0) {
-        pass(`${card.label} 展开后 ${card.words.length} 项设置齐全`);
-    } else {
-        fail(`${card.label} 展开后缺少:${missing.join('、')}`);
-    }
-});
-
-/* 每张卡的门槛:展开的那张有内容,其余三张不该把内容露出来 */
-check('同一时刻只渲染展开那张卡的设置区',
-    (() => {
-        expandOnlyId('s2');
-        const only2 = textsOf('id').join(' | ');
-        expandOnlyId('s4');
-        const only4 = textsOf('id').join(' | ');
-        return only2.indexOf('视频从哪来') >= 0 && only4.indexOf('视频从哪来') < 0;
-    })());
-
-/* —— 开关与它右侧控件的联动:「关掉后右侧不该还留着」——
- * 写起来最容易漏的一处:只 addView、不处理关闭分支,用户会以为没关掉。
- * v7 之后脚本一里带右侧控件的是「搜索用户提 ID」(右侧是关键词输入框)。
- *
- * ⚠️ 判据用 `W.pendingFields`(本页注册过的输入控件)而不是界面文本:
- * 行内输入框的 placeholder 是**画出来的**,不是 TextView 的 text,
- * 按文本找永远找不到 —— 那样会写出"永远失败"或"永远通过"的假断言。 */
-expandOnlyId('s1');
-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,
-    textsOf('id').join(' | ').slice(0, 200));
-expandOnlyId('s1');
-check('脚本一的「从哪个页面取」默认「粉丝页」',
-    textsOf('id').join(' | ').indexOf('粉丝页') >= 0);
-
-/* 脚本四的关键词有可直接跑的业务默认值(不是空框) */
-expandOnlyId('s4');
-check('脚本四的「关键词」有默认值',
-    textsOf('id').join(' | ').indexOf(api.Store.getText('tabs.id.scripts.s4.keyword')) >= 0,
-    api.Store.getText('tabs.id.scripts.s4.keyword'));
-
-/* —— 滚动指引的位置(2026-10-13 用户指定)——
- *
- * 它必须落在「脚本四」之后、「功能设置」标题之前:它指向的就是紧接着下面
- * 那一屏内容,两条断言缺一不可 —— 只断言"在功能设置之前"的话,
- * 把它挪到最顶上也能通过,但那已经不是"脚本四后面"了。
- *
- * 比对用的是**渲染顺序下标**(DFS 顺序 = 屏幕上从上到下),
- * 且「功能设置」取的是**标题元素本身**而不是 indexOf 子串 ——
- * 指引文案里也含「功能设置」四个字,用子串会指到指引自己身上。 */
-check('滚动指引位于脚本四之后、功能设置标题之前',
-    (() => {
-        const items = [];
-        collect(api.Pages.id(() => { }), items, 'id', 0);
-        const texts = items.map((x) => x.text);
-        const hintAt = texts.indexOf('↓ 下方还有「功能设置」,向下滚动查看');
-        const card4At = texts.indexOf('脚本四 · 关键词视频提 ID');
-        const titleAt = texts.indexOf('功能设置');
-        return hintAt > card4At && hintAt < titleAt;
-    })(),
-    '顺序应为:脚本四 → 滚动指引 → 功能设置标题');
+console.log('ID提取 · 取材清单(v8):');
+
+api.Sources.clear();
+let idTexts = textsOf('id').join(' | ');
+check('空清单时显示「还没有取材」占位',
+    idTexts.indexOf('还没有取材') >= 0, idTexts.slice(0, 170));
+check('空清单时三段标题都在(取材 / 只要这样的账号 / 输出)',
+    idTexts.indexOf('取材') >= 0
+    && idTexts.indexOf('只要这样的账号') >= 0
+    && idTexts.indexOf('输出') >= 0, idTexts.slice(0, 170));
+
+/* 指定账号:账号输入 + 三个列表 + 深度 */
+api.Sources.add('anchor');
+idTexts = textsOf('id').join(' | ');
+check('指定账号:渲染出「第 1 条」与账号输入',
+    idTexts.indexOf('第 1 条') >= 0 && idTexts.indexOf('账号') >= 0, idTexts.slice(0, 170));
+check('指定账号:三个列表都可选',
+    ['粉丝列表', '关注列表', '主页展示'].every((w) => idTexts.indexOf(w) >= 0));
+check('指定账号:有深度选项', idTexts.indexOf('深度') >= 0);
+check('指定账号:不出现别的起点的输入',
+    idTexts.indexOf('视频从哪来') < 0 && idTexts.indexOf('搜索关键词') < 0);
+
+/* 换起点:旧起点的输入必须消失 —— "选了才出现"的另一半 */
+const oneSource = api.Sources.list()[0];
+api.Sources.update(oneSource.id, { kind: 'text' });
+idTexts = textsOf('id').join(' | ');
+check('换成文本文件后出现「文件」行', idTexts.indexOf('文件') >= 0, idTexts.slice(0, 170));
+check('换成文本文件后「取哪些列表」消失', idTexts.indexOf('取哪些列表') < 0, idTexts.slice(0, 170));
+check('换起点后数据里也不留旧参数', api.Sources.find(oneSource.id).anchors === undefined,
+    String(api.Sources.find(oneSource.id).anchors));
+
+/* 视频起点:默认当前屏幕,选关键词才出输入框 */
+api.Sources.update(oneSource.id, { kind: 'video' });
+idTexts = textsOf('id').join(' | ');
+check('视频起点默认「当前屏幕」并有说明',
+    idTexts.indexOf('当前屏幕') >= 0 && idTexts.indexOf('评论区') >= 0, idTexts.slice(0, 170));
+api.Sources.update(oneSource.id, { videoSource: 'keyword' });
+idTexts = textsOf('id').join(' | ');
+check('选「关键词搜索」后才出现搜索关键词输入', idTexts.indexOf('搜索关键词') >= 0);
+
+/* 当前屏幕:没有深度(它已经是最底层,没得展开) */
+api.Sources.update(oneSource.id, { kind: 'screen' });
+idTexts = textsOf('id').join(' | ');
+check('当前屏幕起点不提供「深度」', idTexts.indexOf('深度') < 0, idTexts.slice(0, 170));
+
+/* 上限:5 条封顶,添加入口变成提示 */
+api.Sources.clear();
+for (let si = 0; si < api.Sources.MAX; si++) { api.Sources.add('anchor'); }
+check('清单可以加到上限', api.Sources.count() === api.Sources.MAX, 
+    api.Sources.count() + ' / ' + api.Sources.MAX);
+idTexts = textsOf('id').join(' | ');
+check('满额时不再显示「添加一条取材」', idTexts.indexOf('添加一条取材') < 0, idTexts.slice(-170));
+check('满额时说明已达上限', idTexts.indexOf('已达上限') >= 0);
+
+/* 回到一条,供后面的用例使用 */
+api.Sources.clear();
+api.Sources.add('anchor');
 
 /* ============================================================ v6 改版 */
 /* 2026-10-13 规格书:底栏 5 页签 → 3 tab,四个任务降级为任务卡 + 二级页。
@@ -423,19 +379,7 @@ check('滚动指引位于脚本四之后、功能设置标题之前',
 console.log('');
 console.log('v6 改版(任务卡 / 运行记录 / 迁移):');
 
-/* v7 清语义:脚本一原来的两项变成「删一个、改一个」——
- *   · 「自定关注或粉丝页面提 ID」与「从哪个页面取」说的是同一件事 → 删掉;
- *   · 「自定页面提 ID」勾上却没说搜什么 → 补关键词并改名「搜索用户提 ID」。 */
-expandOnlyId('s1');
-const s1Texts = textsOf('id').join(' | ');
-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');
+/* (v7 的"清语义"断言已随 v8 改结构整体作废:四张卡与它们的字段都不在了。) */
 
 /* —— 标题区只能出现一次 ——
  * 真机踩过:页面函数里画了一次、路由层又加了一次,界面上就是两个「飞舟脚本」。
@@ -466,27 +410,32 @@ check('任务卡的图标都是几何块字符(不是 emoji 码位)',
     taskCards.every((t) => t.icon.charCodeAt(0) >= 0x2500 && t.icon.charCodeAt(0) <= 0x25FF),
     taskCards.map((t) => t.icon).join(','));
 
-/* 状态徽标三态:未配置(没选脚本)/ 空闲(选了没跑)/ 运行中 */
-api.Store.set('tabs.id.scripts.s1.checked', false);
-check('没选脚本时任务卡是「未配置」', api.Pages.taskState('id').badge === '未配置', `期望 '未配置',实际 ${api.Pages.taskState('id').badge}`);
-api.Store.set('tabs.id.scripts.s1.checked', true);
-check('选了脚本没跑时是「空闲」', api.Pages.taskState('id').badge === '空闲', `期望 '空闲',实际 ${api.Pages.taskState('id').badge}`);
+/* 状态徽标三态:未配置(清单为空)/ 空闲(有取材没跑)/ 运行中 */
+api.Sources.clear();
+check('清单为空时任务卡是「未配置」', api.Pages.taskState('id').badge === '未配置',
+    `期望 '未配置',实际 ${api.Pages.taskState('id').badge}`);
+api.Sources.add('anchor');
+check('有取材没跑时是「空闲」', api.Pages.taskState('id').badge === '空闲',
+    `期望 '空闲',实际 ${api.Pages.taskState('id').badge}`);
 
 const wasRunning = api.Session.running;
-api.Session.start('id', 's1', '脚本一 · 粉丝页提 ID', 'run-audit');
-check('正在跑的那个任务是「运行中」', api.Pages.taskState('id').badge === '运行中', `期望 '运行中',实际 ${api.Pages.taskState('id').badge}`);
-check('别的任务不受影响(仍然空闲)', api.Pages.taskState('dm').badge === '空闲', `期望 '空闲',实际 ${api.Pages.taskState('dm').badge}`);
+api.Session.start('id', 'id', 'ID提取', 'run-audit');
+check('正在跑的那个任务是「运行中」', api.Pages.taskState('id').badge === '运行中',
+    `期望 '运行中',实际 ${api.Pages.taskState('id').badge}`);
+check('别的任务不受影响(仍然空闲)', api.Pages.taskState('dm').badge === '空闲',
+    `期望 '空闲',实际 ${api.Pages.taskState('dm').badge}`);
 api.Session.stop();
-if (wasRunning) { api.Session.start('id', 's1', '脚本一 · 粉丝页提 ID', 'run-audit'); }
-
-/* 任务卡的「当前:xxx」跟着选中脚本走 —— 这条守着「两张表拼起来」那段逻辑 */
-check('任务卡的当前脚本行来自选中脚本',
-    api.Pages.taskScriptLine('id') === '当前:脚本一 · 粉丝页提 ID',
-    api.Pages.taskScriptLine('id'));
-api.Store.set('tabs.id.scripts.s1.checked', false);
-check('一个都没选时如实说「未选择脚本」', api.Pages.taskScriptLine('id') === '当前:未选择脚本', `期望 '当前:未选择脚本',实际 ${api.Pages.taskScriptLine('id')}`);
-api.Store.set('tabs.id.scripts.s1.checked', true);
-
+if (wasRunning) { api.Session.start('id', 'id', 'ID提取', 'run-audit'); }
+
+/* 任务卡的「当前:xxx」跟着**取材摘要**走(v8 起没有"当前脚本"了) */
+const idLine = api.Pages.taskScriptLine('id');
+check('任务卡的当前行显示的是取材摘要,不是"脚本一"',
+    idLine.indexOf('当前:') === 0 && idLine.indexOf('脚本') < 0, idLine);
+api.Sources.clear();
+check('清单为空时如实说「未添加取材」',
+    api.Pages.taskScriptLine('id') === '当前:未添加取材',
+    `实际 ${api.Pages.taskScriptLine('id')}`);
+api.Sources.add('anchor');
 /* —— 任务列表页渲染出来的是四张卡,且没有 undefined —— */
 const tasksTexts = textsOf('tasks').join(' | ');
 check('任务列表渲染出四张任务卡',
@@ -555,37 +504,60 @@ 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' }
+/* —— v6/v7 → v8:旧四张卡配置 → 取材清单 ——
+ * 单独造一份旧存档来测:场景 B 的脏数据里 `tabs.id.scripts` 已被整棵丢弃,
+ * 只有这里能验证"用户原来的选择真的一条不丢"。
+ * 最值得看的是 **s4 的三个开关展开成三条取材** —— 那是旧结构塞不下的能力。 */
+api.Sources.clear();
+env.memoryStorage[api.Store.STORAGE_NAME] = {
+    config: JSON.stringify({
+        version: 6,
+        tabs: {
+            id: {
+                scripts: {
+                    s1: { checked: true, anchorIds: 'aaa#bbb', enterPage: 'fans' },
+                    s4: {
+                        checked: true, keyword: 'fashion',
+                        anchorFans: true, commentArea: true, commenterFans: true
+                    }
+                }
             }
         }
-    }
-});
-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');
+    })
+};
+const migrated = api.Sources.migrateLegacy();
+check('旧配置能翻译成取材清单', !!migrated && migrated.length > 0,
+    migrated ? String(migrated.length) + ' 条' : 'null');
+check('s1 的主播账号与粉丝列表被带过来',
+    migrated && migrated[0].kind === 'anchor' && migrated[0].anchors === 'aaa#bbb'
+    && migrated[0].lists.join(',') === 'fans',
+    migrated ? JSON.stringify(migrated[0]) : 'null');
+check('s4 的三个开关展开成三条(加上 s1 共 4 条)',
+    migrated && migrated.length === 4, '共 ' + (migrated ? migrated.length : 0) + ' 条');
+check('作者粉丝 → 视频作者 + 深度粉丝',
+    migrated && migrated[1].kind === 'search' && migrated[1].searchType === 'videos'
+    && migrated[1].expand === 'fans',
+    migrated ? JSON.stringify(migrated[1]) : 'null');
+check('评论区 → 独立的评论区取材',
+    migrated && migrated[2].kind === 'video' && migrated[2].expand === '',
+    migrated ? JSON.stringify(migrated[2]) : 'null');
+check('评论者的粉丝 → 评论区 + 深度粉丝',
+    migrated && migrated[3].kind === 'video' && migrated[3].expand === 'fans',
+    migrated ? JSON.stringify(migrated[3]) : 'null');
+
+/* 已经用过新版的用户:清单非空时**不许**被旧配置覆盖 */
+api.Sources.clear();
+api.Sources.add('text');
+const again = api.Sources.migrateLegacy();
+check('清单非空时不再迁移(用户自己加的取材不被覆盖)',
+    again === null && api.Sources.count() === 1,
+    again === null ? 'null' : JSON.stringify(again));
+
+/* 复位:恢复默认配置与一条取材,供后面的用例使用 */
+env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(api.Store.defaults()) };
+api.Store.init();
+api.Sources.clear();
+api.Sources.add('anchor');
 
 /* ============================================================ 悬浮球 */
 /* 规格书 §10 / H1–H7。设备端能建窗,桩里建不了 —— 所以这一节验的是
@@ -666,7 +638,6 @@ check('记录字段缺失时渲染不出现 undefined',
     bareTexts.indexOf('undefined') < 0 && bareTexts.indexOf('null') < 0, bareTexts.slice(0, 200));
 
 
-expandOnlyId('s1');
 
 /* 区间行的结构断言:每个区间行必须是「标签 + 两个数字胶囊 + 一个 ~」 */
 console.log('');
@@ -1391,46 +1362,51 @@ check('其余三个动作开关没被误改',
 api.Store.set('tabs.video.actions.like', likeBefore);
 
 console.log('');
-console.log('脚本卡(ID提取 · 勾选与展开):');
+console.log('');
+console.log('取材卡片(删除 / 落盘):');
+
+/** 清单条数的断言(写成小函数,失败的提示里带实际条数)。 */
+function eqSources(want, label) {
+    check(label, api.Sources.count() === want, '实际 ' + api.Sources.count() + ' 条');
+}
 /* 页面点任何东西都会调 `Pages.requestRerender()`,真机上由 `App.renderContent` 注入。
  * 这里直接渲染页面,所以要自己补上 —— 这正是「界面靠 App 装配」的那部分契约。 */
 api.Pages.requestRerender = () => { };
 api.App.activeTab = 'id';
-page = renderPage('id');
-/* ★ 脚本卡的「勾选」命中的是勾选框本身,名字文本没有监听 ——
- * 勾选与展开是两个独立命中区(合并成一个的话,想展开却把脚本关掉是必然事件)。 */
-const script2Box = boxLeftOf(page, '脚本二 · 评论页提 ID');
-check('找得到脚本二的勾选框', !!script2Box);
-if (script2Box) {
-    script2Box.performClick();
-    check('点勾选框 → 组内单选生效',
-        api.Store.getBool('tabs.id.scripts.s2.checked') === true &&
-        api.Store.getBool('tabs.id.scripts.s1.checked') === false,
-        `s1=${api.Store.getBool('tabs.id.scripts.s1.checked')} s2=${api.Store.getBool('tabs.id.scripts.s2.checked')}`);
-    restartApp();
-    check('模拟重启后选中的仍是脚本二',
-        api.Store.getBool('tabs.id.scripts.s2.checked') === true &&
-        api.Store.getBool('tabs.id.scripts.s1.checked') === false);
-}
+
+api.Sources.clear();
+api.Sources.add('anchor');
+api.Sources.add('text');
+eqSources(2, '准备两条取材');
 
 page = renderPage('id');
-const arrow = controlRightOf(page, '脚本二 · 评论页提 ID');
-const expandedBefore = api.Store.getBool('tabs.id.scripts.s2.expanded');
-check('找得到脚本二的展开箭头', !!arrow);
-if (arrow) {
-    arrow.performClick();
-    check('展开态已落盘',
-        api.Store.getBool('tabs.id.scripts.s2.expanded') === !expandedBefore,
-        `实际 ${api.Store.getBool('tabs.id.scripts.s2.expanded')}`);
+const delButtons = findAll(page, textIs('删除'), [], 0);
+check('每条取材卡片都有「删除」按钮', delButtons.length === 2, '找到 ' + delButtons.length + ' 个');
+
+if (delButtons.length === 2) {
+    delButtons[0].performClick();
+    eqSources(1, '点删除后清单真的少了一条');
+    check('删掉的是第 1 条(剩下的那条是文本文件)',
+        api.Sources.list()[0].kind === 'text', api.Sources.list()[0].kind);
+
+    /* 落盘 + 模拟重启:取材是**另一个储物柜**,这条专门验它真的存进了 storages,
+     * 而不是只活在内存里(那会让用户加完取材、杀进程再打开就全没了)。 */
     restartApp();
-    check('重启后展开态被保留(不是每次启动都回到默认)',
-        api.Store.getBool('tabs.id.scripts.s2.expanded') === !expandedBefore);
-    /* 收尾:恢复成默认,免得影响后面的断言 */
-    api.Store.set('tabs.id.scripts.s2.expanded', expandedBefore);
-    api.Store.set('tabs.id.scripts.s2.checked', false);
-    api.Store.set('tabs.id.scripts.s1.checked', true);
+    eqSources(1, '模拟重启后取材还在');
 }
 
+/* 起点切换要能把改动写进清单(下拉是页面里唯一"改数据但不走 Store"的控件) */
+api.Sources.clear();
+const oneForEdit = api.Sources.add('anchor');
+api.Sources.update(oneForEdit.id, { anchors: 'zzz', lists: ['following'] });
+check('改过的字段能读回', api.Sources.find(oneForEdit.id).anchors === 'zzz'
+    && api.Sources.find(oneForEdit.id).lists.join(',') === 'following',
+    JSON.stringify(api.Sources.find(oneForEdit.id)));
+restartApp();
+check('模拟重启后取材字段还在',
+    api.Sources.find(oneForEdit.id).anchors === 'zzz',
+    JSON.stringify(api.Sources.find(oneForEdit.id)));
+
 console.log('');
 console.log('文件路径(UID私信 · 随机私信文本):');
 env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(api.Store.defaults()) };

+ 1 - 0
tiktok-app/tools/build.js

@@ -41,6 +41,7 @@ const MODULE_ORDER = [
     'theme',
     'store',
     'records',      // 运行记录:任务卡/记录页/结果文件都读它
+    'sources',      // ID提取的取材清单:不定长列表,与 records 同样走独立 storage key
     'device',
     'service',
     'engine',       // 执行引擎桥接:界面与 tiktok-auto 之间的文件通道

+ 66 - 54
tiktok-app/tools/smoke.js

@@ -154,37 +154,27 @@ check('界面用到的颜色键都存在', missingHex.length === 0, missingHex.j
 
 eq('默认配置版本号', api.Store.defaults().version, api.Store.SCHEMA_VERSION);
 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);
 
-/* —— v7 清语义:删掉的 7 个字段必须真的不在了 ——
+/* —— v8:ID提取不再有"脚本",四张卡的 23 个字段全部从字段表移除 ——
  * 光从页面里删不够:字段还留在 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(', '));
+const legacyIdFields = api.Store.FIELDS.filter((f) => /^tabs\.id\.scripts\./.test(f.path));
+check('四张脚本卡的字段已全部从字段表移除', legacyIdFields.length === 0,
+    legacyIdFields.map((f) => f.path).join(', '));
+
+/* 取材清单是**另一个储物柜**(不定长列表进不了那张标量表)。空清单是合法初始状态,
+ * 等价于 v7 的"一个脚本都没选":能进页面,但点运行会被拦。 */
+eq('取材清单默认是空的', api.Sources.count(), 0);
+check('空清单 = 未配置(再也找不到"哪个脚本被选中"这种概念)',
+    api.Pages.checkedScript('id') === null);
+
+/* v7 那次清语义的成果一个都没丢:功能设置 19 个字段还在 */
+eq('ID提取功能设置仍是 21 个字段(v7 的 19 个 + 去重两开关)',
+    api.Store.FIELDS.filter((f) => /^tabs\.id\.features\./.test(f.path)).length, 21);
 
-/* —— v7 补上的字段:默认值就是"最省事的那种用法" —— */
+/* —— v7 补的两个去重开关(v8 保留) —— */
 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',
@@ -469,37 +459,47 @@ globalThis.floaty.checkPermission = () => true;
 /* 运行目标 = **当前打开的那个任务详情页**里选中的脚本(v6 改版后的规则)。
  * 一级页(任务列表)没有"当前任务"这回事:四张卡各有各的「运行」按钮。 */
 api.App.openDetail('id');
-check('ID提取页默认未选脚本(设计稿 16:23 的初始态)', api.App.currentTask() === null);
-eq('脚本一默认展开(它承载的设置行最多)', api.Store.get('tabs.id.scripts.s1.expanded'), true);
+check('ID提取页默认没有可运行目标(取材清单是空的)', api.App.currentTask() === null);
+eq('取材清单默认为空', api.Sources.count(), 0);
 
 api.App.openDetail('dm');
 eq('进 UID私信详情后运行目标是该页脚本一', api.App.currentTask().key, 's1');
 eq('运行目标带上了界面显示名', api.App.currentTask().name, '脚本一 · 已提 UID 私信');
 eq('运行目标带上了它属于哪个任务', api.App.currentTask().task, 'dm');
 
-/* ID提取页是单选:选中脚本二应当自动取消脚本一 */
+/* —— v8:ID提取的"已配置" = 取材清单非空,不再有"单选一个脚本"这回事 ——
+ * 而且运行目标的 key 固定是 "id":它不再用脚本 key 区分,
+ * 因为清单里可以有任意多条取材,没有"当前是哪一条"这种概念。 */
 api.App.openDetail('id');
-api.Pages.toggleIdScript('s2', true);
-eq('单选后脚本一被取消', api.Store.get('tabs.id.scripts.s1.checked'), false);
-eq('单选后脚本二被选中', api.Store.get('tabs.id.scripts.s2.checked'), true);
-eq('单选后运行目标变脚本二', api.App.currentTask().key, 's2');
-api.Pages.toggleIdScript('s1', true);
-eq('再选回脚本一,脚本二自动取消', api.Store.get('tabs.id.scripts.s2.checked'), false);
-
-/* 取消选中 → 回到「未选择脚本」状态,此时按钮应当拦下来 */
-api.Pages.toggleIdScript('s1', false);
-check('取消后 ID提取页没有选中脚本', api.App.currentTask() === null);
+api.Sources.add('anchor');
+check('加了一条取材后就有了运行目标', api.App.currentTask() !== null);
+eq('运行目标的 key 是 id(不再是 s1/s2)', api.App.currentTask().key, 'id');
+check('运行目标的显示名不再出现"脚本"字样',
+    api.App.currentTask().name.indexOf('脚本') < 0, api.App.currentTask().name);
+
+/* 清单可以有多条,运行目标仍是同一个(摘要取第一条) */
+api.Sources.add('text');
+eq('两条取材后清单长度是 2', api.Sources.count(), 2);
+eq('运行目标不随条数变化', api.App.currentTask().key, 'id');
+check('任务卡的「当前:」显示取材摘要而不是"未选择脚本"',
+    api.Pages.taskScriptLine('id').indexOf('未选择') < 0, api.Pages.taskScriptLine('id'));
+
+/* 空清单 → 点运行被拦(与 v7 的"未选脚本"同一关,但文案不同) */
+api.Sources.clear();
+check('清空取材后没有运行目标', api.App.currentTask() === null);
 
 api.App.openDetail('id');
 api.App.toggleRun();
-eq('未选脚本时不进入运行态', api.Session.running, false);
-check('未选脚本时弹出拦截弹窗', !!api.Dialog.active);
+eq('空清单时不进入运行态', api.Session.running, false);
+check('空清单时弹出拦截弹窗', !!api.Dialog.active);
 check('拦截弹窗标题是「无法开始运行」',
     api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('无法开始运行') >= 0);
-check('拦截弹窗说明是「请先选择要运行的脚本」',
-    api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('请先选择要运行的脚本') >= 0);
+check('拦截文案说的是「添加取材」,不是让人去找不存在的脚本',
+    api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('请先添加一条取材') >= 0);
 api.Dialog.active && api.Dialog.active.close();
-api.Pages.toggleIdScript('s1', true);
+
+/* 加回一条,供后面的用例使用 */
+api.Sources.add('anchor');
 
 /* tab 切换:切到「我的」时不应该有开始运行按钮 */
 api.App.switchTab('mine');
@@ -555,8 +555,19 @@ check('二级页顶部是返回头(标题 + 返回箭头)',
 /* 内容区应该有「返回头 + 间距 + 滚动页」 */
 check('内容区构建出返回头与页面', api.App.contentHost.getChildCount() >= 3,
     `实际 ${api.App.contentHost.getChildCount()}`);
-check('ID提取页显示单选说明',
-    textsOf(api.App.contentHost).indexOf('单选:同一时间仅可运行一个脚本') >= 0);
+/* v8:三段的标题都要在 —— 这是"结构换了"最直接的可读证据。
+ * (原来的「单选:同一时间仅可运行一个脚本」说明随单选一起消失了。) */
+const idPageTexts = textsOf(api.App.contentHost);
+check('ID提取页有「取材」「只要这样的账号」「输出」三段',
+    idPageTexts.indexOf('取材') >= 0
+    && idPageTexts.indexOf('只要这样的账号') >= 0
+    && idPageTexts.indexOf('输出') >= 0,
+    idPageTexts.slice(0, 20).join(' | '));
+check('不再有「单选:同一时间仅可运行一个脚本」这条约束',
+    idPageTexts.indexOf('单选:同一时间仅可运行一个脚本') < 0);
+check('有取材时渲染出取材卡片与「添加一条」',
+    idPageTexts.indexOf('第 1 条') >= 0 && idPageTexts.indexOf('+ 添加一条取材') >= 0,
+    idPageTexts.slice(0, 20).join(' | '));
 
 /* 运行配置落盘 */
 const exported = api.App.exportRunConfig([{ tab: 'id', script: 's1' }]);
@@ -771,18 +782,18 @@ env.engineState.children = [{ id: 'child-1' }];
  * 所以这里逐条把"前一关"卡住,验证它确实**先**被拦下来。 */
 api.App.lastRunResult = null;
 api.App.openDetail('id');
-api.Pages.toggleIdScript('s1', false);            /* 先保证"未选脚本" */
+api.Sources.clear();                              /* 先保证"取材清单为空" */
 const spawnsBefore = env.engineState.spawned.length;
 
 /* ③ 已选脚本?否 → F1 */
 api.App.toggleRun();
 eq('未选脚本时不进运行态', api.Session.running, false);
-check('拦下的是「请先选择要运行的脚本」',
-    api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('请先选择要运行的脚本') >= 0);
+check('拦下的是「请先添加一条取材」(ID提取没有脚本可选了)',
+    api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('请先添加一条取材') >= 0);
 api.Dialog.active && api.Dialog.active.close();
 
-/* ④ 引擎未接入 → 选了脚本也要拦,且**不会**误起一个引擎 */
-api.Pages.toggleIdScript('s1', true);
+/* ④ 引擎未接入 → 加了取材也要拦,且**不会**误起一个引擎 */
+api.Sources.add('anchor');
 api.App.toggleRun();
 eq('引擎未接入的脚本不进运行态', api.Session.running, false);
 check('弹出的是「引擎未接入」拦截窗', !!api.Dialog.active);
@@ -800,8 +811,8 @@ eq('回登录页时不弹任何弹窗', api.Dialog.active, null);
 api.Store.set('license.status', 'active');
 api.App.screen = 'app';
 
-/* ② 权限先于「已选脚本」:故意不选脚本 + 缺权限,拦截的必须是权限窗 */
-api.Pages.toggleIdScript('s1', false);
+/* ② 权限先于「有取材」:故意清空清单 + 缺权限,拦截的必须是权限窗 */
+api.Sources.clear();
 globalThis.floaty.checkPermission = () => false;   /* 桩:悬浮窗未授权 */
 api.App.openDetail('id');
 api.App.toggleRun();
@@ -810,7 +821,7 @@ check('缺权限时弹的是权限拦截窗(先于"未选脚本")',
     api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
 api.Dialog.active && api.Dialog.active.close();
 globalThis.floaty.checkPermission = () => true;    /* 恢复:后续用例需要权限齐全 */
-api.Pages.toggleIdScript('s1', true);
+api.Sources.add('anchor');
 
 /* ⑧ 运行中卡密失效 → 停跑 + 弹到期窗 + 回登录页(设计稿 16:1360) */
 api.Store.set('license.status', 'active');
@@ -868,7 +879,8 @@ const idScripts = api.Pages.SCRIPTS_OF['id'];
 const originalIdEngine = idScripts[0].engine;
 idScripts[0].engine = 'tiktok-auto';
 
-api.Store.set('tabs.id.scripts.s1.checked', true);
+/* v8:ID提取的"已配置"是取材清单非空,不再有脚本勾选 */
+api.Sources.add('anchor');
 const beforeSwitch = env.engineState.spawned.length;
 const runningRunId = api.Session.runId;
 api.App.requestRun('id');

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott