Răsfoiți Sursa

ID提取执行引擎:取材 → 过滤 → 去重 → 落盘,界面点运行即可真抓

引擎侧(tiktok-auto)新增 6 个模块,界面侧接通配置分派。现在点「开始运行」会真的
去 TikTok 里按取材清单抓 ID,产物落在 /sdcard/Download/tiktok_app/uid_日期_时分.txt。

为什么先做真机探测再写代码
整套功能的地基是"能不能稳定读到用户名"。先在真机上逐页 dump 出锚点,结论改变了实现:
- 这一片的资源 id 是**语义化的**(tv_username / txt_desc / user_avatar),与信息流那批
  混淆 id(e4g/cn6)完全不同 → 优先按 id 定位,文案只作兜底
- **粉丝列表每行里就有 uid**(txt_desc),不必为了拿 uid 去开用户页 → 不开过滤时约 1 秒/个
- 评论区**只有昵称、没有 uid** → 评论者必须逐个点头像进主页,约 6~8 秒/个
- 用户名被 FID 隔离符(U+2068/U+2069)包裹,肉眼看不见,layout.sanitize 也不剥
- 头像无 text/无 desc/不可点 → 只能像素法(实测默认剪影高饱和占比 0.035,真头像 0.472)

六个新模块
- parse.js     纯函数:计数缩写(1.6M / 1.2万 / 2,491)、语言判定(CJK≥30% 判中文)、
               区间判定、用户名归一。这是"过滤真的会拦人"唯一能离线证明的部分
- uidset.js    去重集合 + 结果文件;每 20 个刷一次盘(点停/被杀时已抓的不能丢);只追加不删
- filters.js   「只要这样的账号」判定。**读不到资料 ≠ 不符合**:
               设了门槛读不到 → 拦;平台根本不提供(视频量)→ 放行 + 告知
- profile.js   用户主页的导航与读取(含头像像素判定、授权弹窗处理)
- idsource.js  五种起点 × 三种遍历形态(列表型 / 文本型 / 逐个点开型)+ 深度展开
- idflow.js    编排:取材 → 过滤 → 去重 → 落盘;三道物理护栏;停止时 finally 刷盘

深度展开为什么分两阶段
边遍历边展开会嵌套两层列表上下文,打开 B 列表时 A 的滚动位置就丢了。
所以先收完基础层、记下"这些人要展开",再逐个打开。展开进来的人同样过一遍过滤。

界面侧
- ID_SCRIPTS 加 engine 声明 → 不再被「引擎尚未接入」拦下
- Engine.buildConfig/prepare/unsupportedNotes 按 task 分派;「关键词为空」的拦截
  只对视频链路成立(ID提取可以只从粉丝列表或文本文件取材)
- buildIdConfig:取材清单几乎原样透传(Sources 当初就照着引擎契约设计的),
  21 个功能设置逐项映射;做不到的项进 notes
- "偏离默认值才报"而不是"有值就报":regionCodes/aiAgeMax/videosMax 默认就不是空的,
  按"有值"报会在每次运行时冒出一串噪音,把真正该注意的那条淹没
- exportRunConfig 带上 sources(它在独立 storage key 里,config 快照里没有)

【踩坑】六条,都是"看起来正常但结果是错的"
1. settle() 会提前返回:点开粉丝列表后骨架先出、行后加载,而 waitQuiet 判据是
   "文案连续两次不变"→ 加载中与打开前签名一样 → 872ms 就返回 → 误判"打不开列表"。
   凡是"点完要等新内容",判据必须是"新内容真的出现了"
2. 数字先渲染成占位的 0:同一账号两次运行分别读到粉丝 78 与 0。改成连续两次一致才采信
3. 点列表行不能用 node.click()(落在容器中心,常是空白,有时还是 Follow 按钮邻域,
   误触会把用户关注掉)→ 改按坐标点行左侧 30%
4. 需要资料但打不开用户页时,filters 原本返回"放行"→ 等于"打不开的人全都通过"。
   改成拦下并记日志
5. 文本源在 idsource 里自己判去重,绕过了编排层 → duplicates 统计永远是 0
6. bridge.finish 的 done 沿用最后一次 report 的值,比实际少一个 → 按 summary 校正

自检
- 引擎:PC 68 项(tools/pc-selftest-id.js)+ 设备 57 项,全绿
- 界面:413 项全绿(新增配置映射逐项断言、ID提取接入后真的起引擎、取材清单进配置)
- 审计:AUDIT_CLEAN

真机验证(Pixel 5a / TikTok 47.1.4)
- 界面端到端:点运行 → 引擎读 task=id + 完整取材清单 → 抓 3 个 → 产物 3 行 3 唯一
  → 记录页「完成:提取 3 个 ID → 文件路径」→ 43 秒
- 不过滤快路径:10 个 / 24 秒(含 ~15 秒启动与搜索)
- 带过滤:11 个处理 / 3 个收录 / 88 秒,逐个开用户页读粉丝数后判定
  数据三轮一致(78/30/64/95/819/1785/0/2019/0/673/1096)
- 文本源:注释行与空行跳过、行首 @ 剥掉、3 行 3 个
- 跨轮去重:第二次跑 duplicates=3、新增 0
- 停止:抓到 26 个时点停 → stopped + "已收 26 个" + 产物 26 行 26 唯一(一个不丢)

已知局限(都写进 README 与运行时告知)
- 无头像按像素色彩判定,纯黑白照片头像会被误判
- 视频量门槛不参与过滤(用户页只显示 关注/粉丝/获赞 三组数字)
- 提取区域、AI 三项、云端去重不做
- 逐个点开型(视频作者/评论者)约 6~8 秒一个,取材时要考虑时长

依据:PLAN_id_extract.md(执行方案)+ REQ_id_extract_v2.md(三段式需求)
梦境 1 săptămână în urmă
părinte
comite
0a12730ff7

+ 78 - 0
_probe/probe-dump.js

@@ -0,0 +1,78 @@
+/**
+ * probe-dump.js — 把当前屏幕的控件树完整 dump 到一个文本文件
+ *
+ * 为什么不用 `uiautomator dump`:TikTok 的页面一直在动(视频、动画),
+ * uiautomator 等不到 idle state 就直接失败(实测 "could not get idle state")。
+ * 而 AutoX 的无障碍读取不需要 idle,且**与引擎最终用的是同一套读取路径** ——
+ * 探针看到的和引擎将来看到的完全一致。
+ *
+ * 用法:adb 启动本脚本,几秒后读 /sdcard/tiktok_app/probe-dump.txt
+ */
+var OUT = "/sdcard/tiktok_app/probe-dump.txt";
+
+function clean(v) {
+    if (v === null || v === undefined) { return ""; }
+    return String(v).replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, "").replace(/\s+/g, " ").replace(/^\s+|\s+$/g, "");
+}
+
+function collect() {
+    var lines = [];
+    var start = auto.root;
+    if (!start) { return ["root=null"]; }
+
+    var stack = [{ node: start, depth: 0 }];
+    var count = 0;
+    while (stack.length > 0 && count < 2000) {
+        var item = stack.pop();
+        var node = item.node;
+        if (!node) { continue; }
+        count += 1;
+
+        var box = null;
+        var text = "", desc = "", cls = "", rid = "", clickable = false, scrollable = false, editable = false, selected = false;
+        try { box = node.bounds(); } catch (e) { box = null; }
+        try { text = clean(node.text()); } catch (e) { }
+        try { desc = clean(node.desc()); } catch (e) { }
+        try { cls = String(node.className()); } catch (e) { }
+        try { rid = String(node.id() || ""); } catch (e) { }
+        try { clickable = node.clickable() === true; } catch (e) { }
+        try { scrollable = node.scrollable() === true; } catch (e) { }
+        try { editable = node.className() === "android.widget.EditText"; } catch (e) { }
+        try { selected = node.selected() === true; } catch (e) { }
+
+        var visible = false;
+        try { visible = node.isVisibleToUser() === true; } catch (e) { visible = true; }
+
+        if (visible && (text !== "" || desc !== "" || clickable || scrollable)) {
+            var short = cls.split(".").pop();
+            var shortId = rid.indexOf("/") >= 0 ? rid.split("/").pop() : rid;
+            lines.push([
+                item.depth,
+                short,
+                shortId,
+                box ? ("[" + box.left + "," + box.top + "][" + box.right + "," + box.bottom + "]") : "[]",
+                clickable ? "CLK" : "-",
+                scrollable ? "SCR" : "-",
+                editable ? "EDT" : "-",
+                selected ? "SEL" : "-",
+                JSON.stringify(text),
+                JSON.stringify(desc)
+            ].join(" "));
+        }
+
+        var childCount = 0;
+        try { childCount = node.childCount(); } catch (e) { childCount = 0; }
+        for (var i = childCount - 1; i >= 0; i -= 1) {
+            try { stack.push({ node: node.child(i), depth: item.depth + 1 }); } catch (e) { }
+        }
+    }
+
+    lines.unshift("total=" + count + " screen=" + device.width + "x" + device.height +
+                  " pkg=" + (auto.root ? auto.root.packageName() : "?"));
+    return lines;
+}
+
+var lines = collect();
+files.ensureDir("/sdcard/tiktok_app/");
+files.write(OUT, lines.join("\n") + "\n");
+console.log("probe written: " + OUT + " lines=" + lines.length);

+ 34 - 0
_probe/probe-images.js

@@ -0,0 +1,34 @@
+/**
+ * probe-images.js — 只 dump 可见 ImageView 的位置与尺寸
+ *
+ * 用途:`userListRows`/`profile` 那套锚点只收"有文案或可点"的节点,
+ * 而**头像节点两样都不占**(无 text、无 desc、不可点),所以看不见它。
+ * 「无头像」这个过滤项要落地,就得先知道头像在哪 —— 这是给它用的探针。
+ */
+var OUT = "/sdcard/tiktok_app/probe-images.txt";
+var lines = [];
+
+var hits = className('android.widget.ImageView').find();
+for (var i = 0; i < hits.size(); i += 1) {
+    var node = hits.get(i);
+    var visible = false;
+    try { visible = node.isVisibleToUser() === true; } catch (e) { visible = true; }
+    if (!visible) { continue; }
+    var box = node.bounds();
+    var w = box.right - box.left;
+    var h = box.bottom - box.top;
+    if (w < 8 || h < 8) { continue; }
+    var id = "";
+    try { id = String(node.id() || ""); } catch (e) { }
+    lines.push([
+        "id=" + (id.indexOf("/") >= 0 ? id.split("/").pop() : id),
+        "desc=" + JSON.stringify(String(node.desc() || "")),
+        "b=[" + box.left + "," + box.top + "][" + box.right + "," + box.bottom + "]",
+        "size=" + w + "x" + h,
+        "clk=" + (node.clickable() === true)
+    ].join(" "));
+}
+
+files.ensureDir("/sdcard/tiktok_app/");
+files.write(OUT, "count=" + lines.length + "\n" + lines.join("\n") + "\n");
+console.log("images written: " + lines.length);

+ 9 - 3
tiktok-app/README.md

@@ -245,7 +245,11 @@ tiktok-app/
 > 一层)」的 4 个**不完整组合**:各缺一块、各有别人没有的一块,合起来才完整。于是
 > 「同一时间仅可运行一个脚本」这条约束本身就在跟用户较劲 —— 想同时抓"关键词视频的作者"
 > 和"文本里这批 uid 的粉丝"时,它直接不允许。定位过程与 38 个字段的逐条处置见
-> `REQ_id_extract_v2.md`。**执行引擎仍未接入**(点开始运行仍会被明确拦下)。
+> `REQ_id_extract_v2.md`。
+>
+> **v8 之后执行引擎已接入**(`task: "id"`):点「开始运行」会真的起因材与过滤去 TikTok 里抓 ID,
+> 产物落在 `/sdcard/Download/tiktok_app/uid_日期_时分.txt`。引擎侧的实现与实测锚点见
+> `tiktok-auto/README.md` 第 3.5 节。
 
 页面就是三段:
 
@@ -759,7 +763,8 @@ ID提取的四个数量门槛)。每敲一个数字都会跑一遍 `syncCrossR
 | **纯浏览模式** | `comment: {ok:true, skipped:true, reason:"本次未启用评论动作"}`——没勾评论就真的没发 |
 | **跑完自动切回前台** | 界面自己回到前台,顶部显示「上次运行:完成:处理 1 个视频 / 发出 0 条评论」 |
 | **两步停止** | 状态条「正在收尾」(琥珀色)→ 约 1 秒后「已停止(假引擎)」;引擎日志 `收到停止请求(runId 匹配)` |
-| 被拦下时不起引擎 | 在 ID提取页点「开始运行」→ 弹「…的执行引擎尚未接入」,引擎调用数为 0 |
+| 未接入的脚本被拦下 | 在 UID私信页点「开始运行」→ 弹「…的执行引擎尚未接入」,引擎调用数为 0(ID提取已接入,改用它验这条闸) |
+| **ID提取端到端** | 界面点运行 → 引擎读到 `task=id` + 完整取材清单 → 抓 3 个 uid → 产物文件 3 行 3 唯一 → 记录页「完成:提取 3 个 ID → 文件路径」 |
 
 **联调时自己绊自己两次**,都记下来免得重演:
 
@@ -974,7 +979,8 @@ D3 日志读取(坏行只丢自己)/ `Records` 文案纯函数 / **悬浮球
 界面构建    · 5 个页签 + 5 个弹窗全部可构建,且弹窗文案与设计稿逐字一致
 登录流程    · 空卡密不切屏;合法卡密 → 主界面 + 状态落盘 + 三态复位
 交互闭环    · 单选互斥 / 取消回到「未选」、切页签后按钮的有无、运行配置落盘
-运行态闭环  · 未选脚本拦截 → 缺权限拦截 → **引擎未接入拦截(且不起引擎)** →
+运行态闭环  · 未选脚本拦截 → 缺权限拦截 → **未接入引擎的脚本被拦下(且不起引擎)** →
+              **ID提取已接入:点运行真的起引擎且配置里带全取材清单** →
               开始运行 → 定位引擎产物 → **唤起引擎 + 参数与映射配置逐项核对** →
               状态条 + 计时推进 → 引擎上报进度就地在状态条刷新 →
               **点停止发请求 → 「正在收尾」→ 引擎确认后才复位** →

+ 184 - 11
tiktok-app/dist/tiktok-app.js

@@ -1,6 +1,6 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-17T10:20:53.221Z
+ *  built 2026-10-20T03:14:59.125Z
  *  modules: theme, store, records, sources, 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/ 后重新构建。
  */
@@ -2659,10 +2659,101 @@ function seconds(ms) {
 /**
  * 界面配置 → 引擎配置。
  *
+ * @param task 当前要跑的任务(`App.startRun` 传进来的那个对象,含 `task` 字段)
  * @returns { config, notes }
  *   notes 是「用户表达了意图、但引擎这次做不到」的条目,逐条是人话。
  */
-Engine.buildConfig = function () {
+Engine.buildConfig = function (task) {
+    /* 两条链路的配置形状完全不同(视频链路是"搜索→看→评",ID提取是"取材清单"),
+     * 所以在这里分派,而不是往一个函数里塞两套 if —— 那样两边都会变得难读且易改错。 */
+    if (task && task.task === "id") {
+        return Engine.buildIdConfig();
+    }
+    return Engine.buildVideoConfig();
+};
+
+/* ------------------------------------------------------------ ID 提取 */
+
+/** ID 提取的产物目录(与其它通道文件同根,方便一起清理/查看) */
+Engine.ID_DIR = "/sdcard/Download/tiktok_app";
+
+/**
+ * 界面 → 引擎(task = 'id')。
+ *
+ * ## 取材清单几乎是原样透传
+ * `Sources` 模块当初就是**照着引擎侧的契约**设计的(`kind` + 每种起点的参数 + `expand`),
+ * 所以这里不需要再翻译一遍字段名。少一层翻译就少一处"路径拼错=静默丢值"的机会。
+ *
+ * ## 做不到的项一律进 notes
+ * 这是项目一贯的规矩:用户勾了却什么都没发生,比明确告知"这次不生效"糟得多。
+ */
+Engine.buildIdConfig = function () {
+    var notes = [];
+    var base = "tabs.id.features.";
+
+    var sources = Sources.list();
+    var engineSources = [];
+    for (var i = 0; i < sources.length; i++) {
+        var one = sources[i];
+        /* 只带引擎认识的字段。`id` 是界面的稳定标识(删除/重排用),引擎不需要。 */
+        engineSources.push({
+            kind: one.kind,
+            anchors: one.anchors || "",
+            lists: one.lists || [],
+            searchType: one.searchType || "users",
+            keyword: one.keyword || "",
+            videoSource: one.videoSource || "screen",
+            videoKeyword: one.videoKeyword || "",
+            path: one.path || "",
+            expand: one.expand || ""
+        });
+    }
+
+    var config = {
+        task: "id",
+        id: {
+            sources: engineSources,
+            filter: {
+                private: Store.getBool(base + "filterPrivate"),
+                noAvatar: Store.getBool(base + "filterNoAvatar"),
+                langRegion: Store.getEnum(base + "langRegion", "langRegion"),
+                nicknameCheck: Store.getEnum(base + "nicknameCheck", "nicknameCheck"),
+                /* 四组区间:界面是"最小值 / 最大值"两格,0 表示该侧不设限 */
+                follow: { min: Store.getNumber(base + "followMin"), max: Store.getNumber(base + "followMax") },
+                fans: { min: Store.getNumber(base + "fansMin"), max: Store.getNumber(base + "fansMax") },
+                likes: { min: Store.getNumber(base + "likesMin"), max: Store.getNumber(base + "likesMax") },
+                videos: { min: Store.getNumber(base + "videosMin"), max: Store.getNumber(base + "videosMax") }
+            },
+            output: {
+                dir: Engine.ID_DIR,
+                historyPath: Engine.ID_DIR + "/uid_extracted.txt",
+                dedupeHistory: Store.getBool(base + "dedupeHistory"),
+                dryRun: false,
+                /* 0 = 不限。引擎另有 30 分钟时长护栏兜底(见 idflow.js) */
+                limit: Store.getNumber(base + "extractLimit")
+            }
+        },
+        runtime: {
+            configPath: Engine.CONFIG_PATH,
+            dryRun: false,
+            maxRuntimeMs: 0
+        }
+    };
+
+    notes = notes.concat(Engine.unsupportedNotes({ task: "id" }));
+
+    /* 「本地去重」关掉时要说清楚后果:它不只是"少一道过滤",
+     * 而是同一个 ID 会在产物文件里重复出现,下游私信会重复发。 */
+    if (!Store.getBool(base + "dedupeLocal")) {
+        notes.push("\u672C\u5730\u53BB\u91CD\uFF1A\u5DF2\u5173\u95ED\uFF0C\u540C\u4E00\u4E2A ID \u53EF\u80FD\u91CD\u590D\u5199\u5165");
+    }
+
+    return { config: config, notes: notes };
+};
+
+/* ------------------------------------------------------------ 视频链路 */
+
+Engine.buildVideoConfig = function () {
     var notes = [];
 
     var keyword = Store.getText("tabs.video.keyword");
@@ -2713,7 +2804,7 @@ Engine.buildConfig = function () {
         config.comments.pool = pool;
     }
 
-    notes = notes.concat(Engine.unsupportedNotes());
+    notes = notes.concat(Engine.unsupportedNotes({ task: "video" }));
     return { config: config, notes: notes };
 };
 
@@ -2723,8 +2814,11 @@ Engine.buildConfig = function () {
  * 单独抽出来是因为它只跟「已实现的动作集合」有关,
  * 将来引擎接入了点赞,**只改这一个函数**就够。
  */
-Engine.unsupportedNotes = function () {
+Engine.unsupportedNotes = function (task) {
     var notes = [];
+    if (task && task.task === "id") {
+        return Engine.unsupportedIdNotes();
+    }
     if (Store.getBool("tabs.video.actions.like")) {
         notes.push("\u70B9\u8D5E\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
     }
@@ -2737,6 +2831,60 @@ Engine.unsupportedNotes = function () {
     return notes;
 };
 
+/**
+ * ID 提取里"用户可能配了、但这次不生效"的项。
+ *
+ * 判据是**偏离默认值**,而不是"有值" —— 这一条很关键:
+ * 功能设置里有好几项的默认值本身就是非空的(`regionCodes = "JP#TW#US"`、
+ * `aiAgeMax = 20`、`videosMax = 500000`)。若按"有值就报",
+ * 每次运行都会冒出一串"未生效",把真正需要注意的那条淹没 ——
+ * 而 note 的本意是"你表达了意图,这次做不到",默认值并不代表任何意图。
+ */
+Engine.unsupportedIdNotes = function () {
+    var notes = [];
+    var base = "tabs.id.features.";
+
+    /** 这一项被用户改过吗(与默认值不同) */
+    function changed(path) {
+        return String(Store.get(path)) !== String(Store.defaultOf(path));
+    }
+
+    /* AI 三项:需要模型,本次不做。界面上是灰行,理论上改不动;
+     * 老配置里可能残留过值,所以仍然按"改过才报"来处理。 */
+    if (changed(base + "aiAvatar") && Store.getBool(base + "aiAvatar")) {
+        notes.push("AI \u5934\u50CF\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
+    }
+    if (changed(base + "aiGender") && Store.getBool(base + "aiGender")) {
+        notes.push("AI \u6027\u522B\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
+    }
+    if (changed(base + "aiAgeMax") && Store.getNumber(base + "aiAgeMax") > 0) {
+        notes.push("AI \u5E74\u9F84\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
+    }
+
+    /* 云端去重:契约已定但服务端没接。降级为本地去重并**说出来**,绝不静默。 */
+    if (Store.getBool(base + "cloudDedupe")) {
+        notes.push("\u4E91\u7AEF\u53BB\u91CD\uFF1A\u670D\u52A1\u672A\u63A5\u5165\uFF0C\u672C\u6B21\u53EA\u7528\u672C\u5730\u53BB\u91CD");
+    }
+
+    /* 视频量门槛:真机实测用户页**只显示 关注/粉丝/获赞 三组数字,没有视频数**。
+     * 引擎侧对这条的处理是"放行 + 告知"(见 tiktok-auto/src/filters.js 文件头)。
+     * 默认上限 500000 在语义上等于"不限",所以只有用户真的收窄了才报。 */
+    if (changed(base + "videosMin") || changed(base + "videosMax")) {
+        notes.push("\u89C6\u9891\u91CF\u95E8\u69DB\uFF1A\u7528\u6237\u9875\u4E0D\u663E\u793A\u8BE5\u6570\u5B57\uFF0C\u672C\u6B21\u4E0D\u53C2\u4E0E\u8FC7\u6EE4");
+    }
+
+    /* 无头像靠像素判定,纯黑白照片会被误判 —— 用户勾了就得先知道这个局限。 */
+    if (Store.getBool(base + "filterNoAvatar")) {
+        notes.push("\u65E0\u5934\u50CF\u68C0\u6D4B\uFF1A\u6309\u50CF\u7D20\u8272\u5F69\u5224\u5B9A\uFF0C\u7EAF\u9ED1\u767D\u7167\u7247\u4F1A\u88AB\u8BEF\u5224");
+    }
+
+    /* 「提取区域」刻意**不进 notes**:它在界面上是一行灰字 + 「暂不支持」胶囊,
+     * 用户已经看得到;每次运行再报一次只会变成噪音。它的值仍保留在配置里,
+     * 将来接上数据源时直接生效。 */
+
+    return notes;
+};
+
 /**
  * notes → 一句能放在 toast 里的话(没有就返回空串)。
  *
@@ -2757,10 +2905,11 @@ Engine.summarizeNotes = function (notes) {
 /**
  * 准备一次运行:定位产物 + 构建配置。
  *
+ * @param task 当前要跑的任务(含 `task` 字段);不传按视频链路处理(向后兼容)
  * @returns { ok, scriptPath, config, notes, error }
  *   error 是给用户看的(说清楚"缺什么、放哪"),不是技术异常串。
  */
-Engine.prepare = function () {
+Engine.prepare = function (task) {
     var path = Engine.locate();
     if (!path) {
         return {
@@ -2772,10 +2921,27 @@ Engine.prepare = function () {
         };
     }
 
-    var built = Engine.buildConfig();
+    var built = Engine.buildConfig(task);
+
+    /* 「关键词为空」是**视频链路**的硬前提(空关键词搜索必失败)。
+     * ID 提取不需要关键词 —— 它可以只从粉丝列表或文本文件取材,
+     * 所以这条拦截必须按任务分支,否则 ID 提取第一次点运行就会被无理由挡下。 */
+    if (task && task.task === "id") {
+        if (!built.config.id.sources || built.config.id.sources.length === 0) {
+            return {
+                ok: false,
+                scriptPath: path,
+                error: "\u8BF7\u5148\u5728\u300CID\u63D0\u53D6\u300D\u9875\u6DFB\u52A0\u81F3\u5C11\u4E00\u6761\u53D6\u6750\u3002"
+            };
+        }
+        return {
+            ok: true,
+            scriptPath: path,
+            config: built.config,
+            notes: built.notes
+        };
+    }
 
-    /* 关键词是引擎的硬前提:空关键词会导致搜索失败,与其让它跑一半报错,
-     * 不如在这里挡住并告诉用户填哪里。 */
     if (trimAll(built.config.target.keyword) === "") {
         return {
             ok: false,
@@ -6333,7 +6499,10 @@ Pages.registerScripts = function (tab, list) {
     Pages.SCRIPTS_OF[tab] = list;
 };
 
-Pages.ID_SCRIPTS = [{ key: "id", name: "ID提取" }];
+/* ★ 引擎声明:`engine` 一填,运行前置校验的第 4 步才会放行。
+ * 没有它时点「开始运行」会被 Dialog.engineMissing 拦下 —— 这是有意的设计:
+ * **绝不静默跑一个不相关的东西**。ID 提取的引擎侧(task = 'id')已接入。 */
+Pages.ID_SCRIPTS = [{ key: "id", name: "ID提取", engine: "tiktok-auto" }];
 Pages.registerScripts("id", Pages.ID_SCRIPTS);
 
 /* ============================================================ ① 取材 */
@@ -8850,7 +9019,11 @@ App.exportRunConfig = function (checked, plan) {
             config: plan ? plan.config : null,
             notes: plan ? plan.notes : []
         },
-        config: Store.current()
+        config: Store.current(),
+        /* ★ v8:取材清单**不在** `Store.FIELDS` 里(它是不定长列表,走独立 storage key),
+         *   所以 `config: Store.current()` 里没有它。但"这一轮到底从哪取的材"
+         *   恰恰是事后追溯最需要的一条,必须单独带上。 */
+        sources: Sources.list()
     };
     try {
         var dir = App.RUN_CONFIG_PATH.substring(0, App.RUN_CONFIG_PATH.lastIndexOf("/"));
@@ -8942,7 +9115,7 @@ App.toggleRun = function () {
  * 计时必须在唤起成功之后才开始(否则引擎没起来,界面却在计时,是最难解释的一种状态)。
  */
 App.startRun = function (task) {
-    var plan = Engine.prepare();
+    var plan = Engine.prepare(task);
     if (!plan.ok) {
         Dialog.engineUnavailable(plan.error);
         return;

+ 173 - 7
tiktok-app/src/engine.js

@@ -175,10 +175,101 @@ function seconds(ms) {
 /**
  * 界面配置 → 引擎配置。
  *
+ * @param task 当前要跑的任务(`App.startRun` 传进来的那个对象,含 `task` 字段)
  * @returns { config, notes }
  *   notes 是「用户表达了意图、但引擎这次做不到」的条目,逐条是人话。
  */
-Engine.buildConfig = function () {
+Engine.buildConfig = function (task) {
+    /* 两条链路的配置形状完全不同(视频链路是"搜索→看→评",ID提取是"取材清单"),
+     * 所以在这里分派,而不是往一个函数里塞两套 if —— 那样两边都会变得难读且易改错。 */
+    if (task && task.task === "id") {
+        return Engine.buildIdConfig();
+    }
+    return Engine.buildVideoConfig();
+};
+
+/* ------------------------------------------------------------ ID 提取 */
+
+/** ID 提取的产物目录(与其它通道文件同根,方便一起清理/查看) */
+Engine.ID_DIR = "/sdcard/Download/tiktok_app";
+
+/**
+ * 界面 → 引擎(task = 'id')。
+ *
+ * ## 取材清单几乎是原样透传
+ * `Sources` 模块当初就是**照着引擎侧的契约**设计的(`kind` + 每种起点的参数 + `expand`),
+ * 所以这里不需要再翻译一遍字段名。少一层翻译就少一处"路径拼错=静默丢值"的机会。
+ *
+ * ## 做不到的项一律进 notes
+ * 这是项目一贯的规矩:用户勾了却什么都没发生,比明确告知"这次不生效"糟得多。
+ */
+Engine.buildIdConfig = function () {
+    var notes = [];
+    var base = "tabs.id.features.";
+
+    var sources = Sources.list();
+    var engineSources = [];
+    for (var i = 0; i < sources.length; i++) {
+        var one = sources[i];
+        /* 只带引擎认识的字段。`id` 是界面的稳定标识(删除/重排用),引擎不需要。 */
+        engineSources.push({
+            kind: one.kind,
+            anchors: one.anchors || "",
+            lists: one.lists || [],
+            searchType: one.searchType || "users",
+            keyword: one.keyword || "",
+            videoSource: one.videoSource || "screen",
+            videoKeyword: one.videoKeyword || "",
+            path: one.path || "",
+            expand: one.expand || ""
+        });
+    }
+
+    var config = {
+        task: "id",
+        id: {
+            sources: engineSources,
+            filter: {
+                private: Store.getBool(base + "filterPrivate"),
+                noAvatar: Store.getBool(base + "filterNoAvatar"),
+                langRegion: Store.getEnum(base + "langRegion", "langRegion"),
+                nicknameCheck: Store.getEnum(base + "nicknameCheck", "nicknameCheck"),
+                /* 四组区间:界面是"最小值 / 最大值"两格,0 表示该侧不设限 */
+                follow: { min: Store.getNumber(base + "followMin"), max: Store.getNumber(base + "followMax") },
+                fans: { min: Store.getNumber(base + "fansMin"), max: Store.getNumber(base + "fansMax") },
+                likes: { min: Store.getNumber(base + "likesMin"), max: Store.getNumber(base + "likesMax") },
+                videos: { min: Store.getNumber(base + "videosMin"), max: Store.getNumber(base + "videosMax") }
+            },
+            output: {
+                dir: Engine.ID_DIR,
+                historyPath: Engine.ID_DIR + "/uid_extracted.txt",
+                dedupeHistory: Store.getBool(base + "dedupeHistory"),
+                dryRun: false,
+                /* 0 = 不限。引擎另有 30 分钟时长护栏兜底(见 idflow.js) */
+                limit: Store.getNumber(base + "extractLimit")
+            }
+        },
+        runtime: {
+            configPath: Engine.CONFIG_PATH,
+            dryRun: false,
+            maxRuntimeMs: 0
+        }
+    };
+
+    notes = notes.concat(Engine.unsupportedNotes({ task: "id" }));
+
+    /* 「本地去重」关掉时要说清楚后果:它不只是"少一道过滤",
+     * 而是同一个 ID 会在产物文件里重复出现,下游私信会重复发。 */
+    if (!Store.getBool(base + "dedupeLocal")) {
+        notes.push("\u672C\u5730\u53BB\u91CD\uFF1A\u5DF2\u5173\u95ED\uFF0C\u540C\u4E00\u4E2A ID \u53EF\u80FD\u91CD\u590D\u5199\u5165");
+    }
+
+    return { config: config, notes: notes };
+};
+
+/* ------------------------------------------------------------ 视频链路 */
+
+Engine.buildVideoConfig = function () {
     var notes = [];
 
     var keyword = Store.getText("tabs.video.keyword");
@@ -229,7 +320,7 @@ Engine.buildConfig = function () {
         config.comments.pool = pool;
     }
 
-    notes = notes.concat(Engine.unsupportedNotes());
+    notes = notes.concat(Engine.unsupportedNotes({ task: "video" }));
     return { config: config, notes: notes };
 };
 
@@ -239,8 +330,11 @@ Engine.buildConfig = function () {
  * 单独抽出来是因为它只跟「已实现的动作集合」有关,
  * 将来引擎接入了点赞,**只改这一个函数**就够。
  */
-Engine.unsupportedNotes = function () {
+Engine.unsupportedNotes = function (task) {
     var notes = [];
+    if (task && task.task === "id") {
+        return Engine.unsupportedIdNotes();
+    }
     if (Store.getBool("tabs.video.actions.like")) {
         notes.push("\u70B9\u8D5E\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
     }
@@ -253,6 +347,60 @@ Engine.unsupportedNotes = function () {
     return notes;
 };
 
+/**
+ * ID 提取里"用户可能配了、但这次不生效"的项。
+ *
+ * 判据是**偏离默认值**,而不是"有值" —— 这一条很关键:
+ * 功能设置里有好几项的默认值本身就是非空的(`regionCodes = "JP#TW#US"`、
+ * `aiAgeMax = 20`、`videosMax = 500000`)。若按"有值就报",
+ * 每次运行都会冒出一串"未生效",把真正需要注意的那条淹没 ——
+ * 而 note 的本意是"你表达了意图,这次做不到",默认值并不代表任何意图。
+ */
+Engine.unsupportedIdNotes = function () {
+    var notes = [];
+    var base = "tabs.id.features.";
+
+    /** 这一项被用户改过吗(与默认值不同) */
+    function changed(path) {
+        return String(Store.get(path)) !== String(Store.defaultOf(path));
+    }
+
+    /* AI 三项:需要模型,本次不做。界面上是灰行,理论上改不动;
+     * 老配置里可能残留过值,所以仍然按"改过才报"来处理。 */
+    if (changed(base + "aiAvatar") && Store.getBool(base + "aiAvatar")) {
+        notes.push("AI \u5934\u50CF\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
+    }
+    if (changed(base + "aiGender") && Store.getBool(base + "aiGender")) {
+        notes.push("AI \u6027\u522B\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
+    }
+    if (changed(base + "aiAgeMax") && Store.getNumber(base + "aiAgeMax") > 0) {
+        notes.push("AI \u5E74\u9F84\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
+    }
+
+    /* 云端去重:契约已定但服务端没接。降级为本地去重并**说出来**,绝不静默。 */
+    if (Store.getBool(base + "cloudDedupe")) {
+        notes.push("\u4E91\u7AEF\u53BB\u91CD\uFF1A\u670D\u52A1\u672A\u63A5\u5165\uFF0C\u672C\u6B21\u53EA\u7528\u672C\u5730\u53BB\u91CD");
+    }
+
+    /* 视频量门槛:真机实测用户页**只显示 关注/粉丝/获赞 三组数字,没有视频数**。
+     * 引擎侧对这条的处理是"放行 + 告知"(见 tiktok-auto/src/filters.js 文件头)。
+     * 默认上限 500000 在语义上等于"不限",所以只有用户真的收窄了才报。 */
+    if (changed(base + "videosMin") || changed(base + "videosMax")) {
+        notes.push("\u89C6\u9891\u91CF\u95E8\u69DB\uFF1A\u7528\u6237\u9875\u4E0D\u663E\u793A\u8BE5\u6570\u5B57\uFF0C\u672C\u6B21\u4E0D\u53C2\u4E0E\u8FC7\u6EE4");
+    }
+
+    /* 无头像靠像素判定,纯黑白照片会被误判 —— 用户勾了就得先知道这个局限。 */
+    if (Store.getBool(base + "filterNoAvatar")) {
+        notes.push("\u65E0\u5934\u50CF\u68C0\u6D4B\uFF1A\u6309\u50CF\u7D20\u8272\u5F69\u5224\u5B9A\uFF0C\u7EAF\u9ED1\u767D\u7167\u7247\u4F1A\u88AB\u8BEF\u5224");
+    }
+
+    /* 「提取区域」刻意**不进 notes**:它在界面上是一行灰字 + 「暂不支持」胶囊,
+     * 用户已经看得到;每次运行再报一次只会变成噪音。它的值仍保留在配置里,
+     * 将来接上数据源时直接生效。 */
+
+    return notes;
+};
+
 /**
  * notes → 一句能放在 toast 里的话(没有就返回空串)。
  *
@@ -273,10 +421,11 @@ Engine.summarizeNotes = function (notes) {
 /**
  * 准备一次运行:定位产物 + 构建配置。
  *
+ * @param task 当前要跑的任务(含 `task` 字段);不传按视频链路处理(向后兼容)
  * @returns { ok, scriptPath, config, notes, error }
  *   error 是给用户看的(说清楚"缺什么、放哪"),不是技术异常串。
  */
-Engine.prepare = function () {
+Engine.prepare = function (task) {
     var path = Engine.locate();
     if (!path) {
         return {
@@ -288,10 +437,27 @@ Engine.prepare = function () {
         };
     }
 
-    var built = Engine.buildConfig();
+    var built = Engine.buildConfig(task);
+
+    /* 「关键词为空」是**视频链路**的硬前提(空关键词搜索必失败)。
+     * ID 提取不需要关键词 —— 它可以只从粉丝列表或文本文件取材,
+     * 所以这条拦截必须按任务分支,否则 ID 提取第一次点运行就会被无理由挡下。 */
+    if (task && task.task === "id") {
+        if (!built.config.id.sources || built.config.id.sources.length === 0) {
+            return {
+                ok: false,
+                scriptPath: path,
+                error: "\u8BF7\u5148\u5728\u300CID\u63D0\u53D6\u300D\u9875\u6DFB\u52A0\u81F3\u5C11\u4E00\u6761\u53D6\u6750\u3002"
+            };
+        }
+        return {
+            ok: true,
+            scriptPath: path,
+            config: built.config,
+            notes: built.notes
+        };
+    }
 
-    /* 关键词是引擎的硬前提:空关键词会导致搜索失败,与其让它跑一半报错,
-     * 不如在这里挡住并告诉用户填哪里。 */
     if (trimAll(built.config.target.keyword) === "") {
         return {
             ok: false,

+ 6 - 2
tiktok-app/src/main.js

@@ -764,7 +764,11 @@ App.exportRunConfig = function (checked, plan) {
             config: plan ? plan.config : null,
             notes: plan ? plan.notes : []
         },
-        config: Store.current()
+        config: Store.current(),
+        /* ★ v8:取材清单**不在** `Store.FIELDS` 里(它是不定长列表,走独立 storage key),
+         *   所以 `config: Store.current()` 里没有它。但"这一轮到底从哪取的材"
+         *   恰恰是事后追溯最需要的一条,必须单独带上。 */
+        sources: Sources.list()
     };
     try {
         var dir = App.RUN_CONFIG_PATH.substring(0, App.RUN_CONFIG_PATH.lastIndexOf("/"));
@@ -856,7 +860,7 @@ App.toggleRun = function () {
  * 计时必须在唤起成功之后才开始(否则引擎没起来,界面却在计时,是最难解释的一种状态)。
  */
 App.startRun = function (task) {
-    var plan = Engine.prepare();
+    var plan = Engine.prepare(task);
     if (!plan.ok) {
         Dialog.engineUnavailable(plan.error);
         return;

+ 4 - 1
tiktok-app/src/page-id.js

@@ -35,7 +35,10 @@ Pages.registerScripts = function (tab, list) {
     Pages.SCRIPTS_OF[tab] = list;
 };
 
-Pages.ID_SCRIPTS = [{ key: "id", name: "ID提取" }];
+/* ★ 引擎声明:`engine` 一填,运行前置校验的第 4 步才会放行。
+ * 没有它时点「开始运行」会被 Dialog.engineMissing 拦下 —— 这是有意的设计:
+ * **绝不静默跑一个不相关的东西**。ID 提取的引擎侧(task = 'id')已接入。 */
+Pages.ID_SCRIPTS = [{ key: "id", name: "ID提取", engine: "tiktok-auto" }];
 Pages.registerScripts("id", Pages.ID_SCRIPTS);
 
 /* ============================================================ ① 取材 */

+ 119 - 3
tiktok-app/tools/smoke.js

@@ -172,6 +172,83 @@ check('空清单 = 未配置(再也找不到"哪个脚本被选中"这种概
 eq('ID提取功能设置仍是 21 个字段(v7 的 19 个 + 去重两开关)',
     api.Store.FIELDS.filter((f) => /^tabs\.id\.features\./.test(f.path)).length, 21);
 
+/* —— 界面 → 引擎的字段映射:一个都不许丢 ——
+ * 这个项目踩过"路径拼错 = 静默丢值"(字段在界面上有控件、能改、能存,
+ * 但引擎配置里永远是默认值,谁也看不出来)。所以逐项验证"改了就一定进引擎配置"。
+ * 做法:把每一项都改成**非默认值**,再看 buildIdConfig 的结果里是不是都有。 */
+const idBase = 'tabs.id.features.';
+api.Store.set(idBase + 'filterPrivate', true);
+api.Store.set(idBase + 'filterNoAvatar', true);
+api.Store.set(idBase + 'langRegion', 'zh');
+api.Store.set(idBase + 'nicknameCheck', 'sign');
+api.Store.set(idBase + 'followMin', 11);
+api.Store.set(idBase + 'followMax', 22);
+api.Store.set(idBase + 'fansMin', 33);
+api.Store.set(idBase + 'fansMax', 44);
+api.Store.set(idBase + 'likesMin', 55);
+api.Store.set(idBase + 'likesMax', 66);
+api.Store.set(idBase + 'videosMin', 77);
+api.Store.set(idBase + 'videosMax', 88);
+api.Store.set(idBase + 'extractLimit', 99);
+api.Store.set(idBase + 'dedupeHistory', true);
+api.Store.set(idBase + 'cloudDedupe', true);
+api.Store.set(idBase + 'regionCodes', 'JP#TW');
+api.Sources.clear();
+api.Sources.add('anchor');
+
+const mapped = api.Engine.buildIdConfig();
+const mf = mapped.config.id.filter;
+const mo = mapped.config.id.output;
+eq('映射:私密过滤', mf.private, true);
+eq('映射:无头像过滤', mf.noAvatar, true);
+eq('映射:语言区域', mf.langRegion, 'zh');
+eq('映射:取样范围', mf.nicknameCheck, 'sign');
+eq('映射:关注量下限', mf.follow.min, 11);
+eq('映射:关注量上限', mf.follow.max, 22);
+eq('映射:粉丝量下限', mf.fans.min, 33);
+eq('映射:粉丝量上限', mf.fans.max, 44);
+eq('映射:获赞量下限', mf.likes.min, 55);
+eq('映射:获赞量上限', mf.likes.max, 66);
+eq('映射:视频量下限', mf.videos.min, 77);
+eq('映射:视频量上限', mf.videos.max, 88);
+eq('映射:提取上限', mo.limit, 99);
+eq('映射:跨轮去重', mo.dedupeHistory, true);
+check('映射:结果目录与历史文件在同一处',
+    mo.dir && mo.historyPath.indexOf(mo.dir) === 0, mo.dir + ' / ' + mo.historyPath);
+
+/* 「做不到的项」必须逐条进 notes —— 用户勾了却什么都发生,比明确告知糟得多 */
+const noteText = mapped.notes.join(' | ');
+check('云端去重开了 → 如实告知只用本地', noteText.indexOf('云端去重') >= 0, noteText);
+check('视频量收窄了 → 如实告知用户页不显示该数字',
+    noteText.indexOf('视频量') >= 0, noteText);
+/* 「提取区域」刻意**不报**:它在界面上是灰行 + 「暂不支持」胶囊,用户已经看得到;
+ * 每次运行再报一次只会变成噪音。这条断言守住这个决定,防止以后又加回来。 */
+check('提取区域不重复报(界面上已标注暂不支持)',
+    noteText.indexOf('提取区域') < 0, noteText);
+
+/* 复原,别把非默认值留给后面的用例 */
+/* 复原:**逐项回到默认值**而不是硬编码 0 —— 这几项里 followMax/likesMax/videosMax
+ * 的默认值是 500000 而不是 0,硬写 0 会把后面的断言带歪(第一版就是这么错的)。
+ * 用 Store.defaultOf() 才能跟着默认值变。 */
+['filterPrivate', 'filterNoAvatar', 'langRegion', 'nicknameCheck',
+ 'followMin', 'followMax', 'fansMin', 'fansMax', 'likesMin', 'likesMax',
+ 'videosMin', 'videosMax', 'extractLimit', 'dedupeHistory', 'cloudDedupe',
+ 'regionCodes'].forEach((name) => {
+    api.Store.set(idBase + name, api.Store.defaultOf(idBase + name));
+});
+api.Sources.clear();
+
+/* 全默认值时不该冒出一堆"未生效"的噪音(会把真正需要注意的那条淹没) */
+api.Sources.add('anchor');
+const quiet = api.Engine.buildIdConfig();
+/* 默认配置下**只有一条**该报:无头像过滤默认是开的(规格书要求),
+ * 而它确实有黑白照片会被误判这个局限 —— 用户第一次跑就该知道。
+ * 其余各项(云端去重 / 视频量 / AI 三项 / 提取区域)都不该出声。 */
+check('默认配置下只报真正需要注意的那一条(无头像的局限)',
+    quiet.notes.length === 1 && quiet.notes[0].indexOf('无头像') >= 0,
+    quiet.notes.join(' | '));
+api.Sources.clear();
+
 /* —— v7 补的两个去重开关(v8 保留) —— */
 eq('本地去重默认开', api.Store.get('tabs.id.features.dedupeLocal'), true);
 eq('跨轮去重默认关(首次运行没有历史可比)', api.Store.get('tabs.id.features.dedupeHistory'), false);
@@ -792,17 +869,56 @@ check('拦下的是「请先添加一条取材」(ID提取没有脚本可选
     api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('请先添加一条取材') >= 0);
 api.Dialog.active && api.Dialog.active.close();
 
-/* ④ 引擎未接入 → 加了取材也要拦,且**不会**误起一个引擎 */
+/* ④ ID提取**已接入**执行引擎 —— 加了取材就该真的起引擎。
+ *    这一条是本轮(执行引擎开发)的落点:在此之前它一直被「引擎未接入」拦下。 */
 api.Sources.add('anchor');
+const spawnsBeforeId = env.engineState.spawned.length;
 api.App.toggleRun();
-eq('引擎未接入的脚本不进运行态', api.Session.running, false);
+eq('ID提取已接入引擎:点运行会真的起引擎', env.engineState.spawned.length, spawnsBeforeId + 1);
+eq('ID提取进入运行态', api.Session.running, true);
+check('没有弹任何拦截窗', api.Dialog.active === null);
+
+/* 起引擎时写下去的配置必须是 task=id,且**带上完整的取材清单** ——
+ * 这里最容易出的问题是"取景清单只写了第一条"或"字段名对不上引擎契约",
+ * 那种错在界面上完全看不出来,只有当引擎跑到一半才发现什么都没抓到。 */
+let idEngineConfig = null;
+try { idEngineConfig = JSON.parse(env.memoryFiles[api.Engine.CONFIG_PATH]); } catch (e) { idEngineConfig = null; }
+eq('引擎配置的 task 是 id', idEngineConfig && idEngineConfig.task, 'id');
+check('引擎配置带上了取材清单',
+    idEngineConfig && idEngineConfig.id && idEngineConfig.id.sources.length === 1,
+    idEngineConfig && idEngineConfig.id ? JSON.stringify(idEngineConfig.id.sources) : 'null');
+check('取材项带上了起点与深度(引擎靠这两个字段决定怎么走)',
+    idEngineConfig && idEngineConfig.id.sources[0].kind === 'anchor' &&
+    idEngineConfig.id.sources[0].expand === '',
+    idEngineConfig ? JSON.stringify(idEngineConfig.id.sources[0]) : 'null');
+check('引擎配置里带上了过滤与输出两段',
+    idEngineConfig && !!idEngineConfig.id.filter && !!idEngineConfig.id.output,
+    idEngineConfig ? Object.keys(idEngineConfig.id).join(',') : 'null');
+
+/* 收尾这次运行,让后面的用例回到"未运行"状态 */
+env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+    runId: api.Session.runId, status: 'stopped', phase: '', message: '已停止:本次已收 0 个 ID'
+});
+env.flushIntervals();
+eq('ID提取的终止态被接住', api.Session.running, false);
+
+/* ⑤ 仍有脚本没接入引擎(UID私信)→ 必须继续被**明确拦下**。
+ *    这条不能因为"ID提取接入了"就一并删掉:它是"绝不静默跑不相关的东西"那道闸。 */
+api.App.openDetail('dm');
+api.Store.set('tabs.dm.scripts.s1.checked', true);
+api.App.toggleRun();
+eq('未接入引擎的脚本不进运行态', api.Session.running, false);
 check('弹出的是「引擎未接入」拦截窗', !!api.Dialog.active);
 check('拦截窗说明了哪个脚本没接入',
     api.Dialog.active && textsOf(api.Dialog.active.root).join(' ').indexOf('执行引擎尚未接入') >= 0,
     api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
-eq('被拦下时没有起任何引擎', env.engineState.spawned.length, spawnsBefore);
 api.Dialog.active && api.Dialog.active.close();
 
+/* 回到 ID提取 详情页,继续后面的校验顺序用例 */
+api.App.openDetail('id');
+api.Sources.clear();
+api.Sources.add('anchor');
+
 /* ① 卡密优先于其它一切:失效时**直接回登录页**,连弹窗都不弹 */
 api.Store.set('license.status', 'none');
 api.App.toggleRun();

+ 154 - 4
tiktok-auto/README.md

@@ -64,18 +64,27 @@ tiktok-auto/
 │   ├── tiktok.js     # ★ TikTok 选择器与页面状态机
 │   ├── player.js     # ★ 播放存活探测 + 有界观看
 │   ├── comment.js    # ★ 评论发布与**分级证据校验**
-│   ├── flow.js       # 业务流程编排
-│   ├── main.js       # 入口与收尾(保证 __END__ / 关线程 / 输出 RESULT / 上报终态)
-│   └── __selftest.js # 设备端纯函数自检
+│   ├── flow.js       # 业务流程编排(视频链路)
+│   ├── main.js       # 入口与收尾(按 task 分派;保证 __END__ / 关线程 / RESULT / 上报终态)
+│   ├── __selftest.js # 设备端纯函数自检
+│   │
+│   │   —— ID 提取(task = "id",见第 3.5 节)——
+│   ├── parse.js      # 纯函数:计数缩写解析 / 语言判定 / 区间判定 / 用户名归一
+│   ├── uidset.js     # 去重集合 + 结果文件(只追加不删除)
+│   ├── filters.js    # 「只要这样的账号」判定(纯函数)
+│   ├── profile.js    # 用户主页的导航与读取(含头像像素判定)
+│   ├── idsource.js   # 五种取材源 + 深度展开
+│   └── idflow.js     # ID 提取的编排(取材 → 过滤 → 去重 → 落盘)
 ├── tools/
 │   ├── build.js      # 构建:src/ 多模块 → dist/ 单文件(含两道自检)
 │   ├── mcp.js        # MCP 桥最小客户端
 │   ├── run.js        # PC 侧执行器
+│   ├── pc-selftest-id.js # 在 PC 上验 ID 提取的纯函数(68 项,秒级)
 │   └── diag-*.js     # 排障探针(见第 7 节)
 └── dist/tiktok-auto.js              # 构建产物:可直接在 AutoX 里跑的单文件
 ```
 
-**为什么要有 `tools/build.js`**:人维护代码时希望"一个文件只做一件事"(`src/` 拆成 12 个模块),
+**为什么要有 `tools/build.js`**:人维护代码时希望"一个文件只做一件事"(`src/` 拆成 18 个模块,两条链路各占一半),
 但 AutoX 通过 MCP 下发的是**一整段脚本**,没法带一堆 `require`。
 中间的 concat 一步同时满足两边:开发按模块读,部署是一个自包含文件。
 
@@ -141,6 +150,147 @@ tiktok-auto/
 
 ---
 
+## 3.5 ID 提取(`task = "id"`)
+
+第二条链路,与视频链路并列:同一套启动/守护/停止/上报基础设施,只在
+`main.js` 里按 `config.task` 分派到 `idflow.runSession` 或 `flow.runSession`。
+
+### 3.5.1 五种取材源,三种遍历形态
+
+界面上的"取材清单"传到引擎就是 `id.sources` 数组(可多条,串行执行)。五种起点:
+
+| 起点 | 从哪儿拿 uid | 遍历形态 |
+|---|---|---|
+| `anchor` 指定账号 | 该账号的粉丝 / 关注 / 主页列表 | **列表型**(逐屏读行) |
+| `search` + `users` | 用户搜索结果 | **列表型** |
+| `screen` 当前屏幕 | 眼前这个列表 | **列表型** |
+| `text` 文本文件 | 文件里每一行 | **文本型**(一次性) |
+| `search` + `videos` | 搜索结果里的视频作者 | **逐个点开型** |
+| `video` 评论区 | 评论者 | **逐个点开型** |
+
+**列表型**最便宜:粉丝列表的每一行里就有 uid(实测 `TextView.txt_desc`),
+一次读一屏 11 行,翻页接着读 —— 不开用户页时约 **1 秒/个**。
+
+**逐个点开型**最贵:视频卡片上只有作者昵称、评论区只有昵称,**都没有 uid**,
+只能点进去看(播放页底部有 `@uid`,评论者要点头像进主页),约 **6~8 秒/个**。
+这就是为什么"评论者"和"评论者的粉丝"在界面上是两条独立取材,而不是默认全开。
+
+### 3.5.2 什么时候才打开用户页(成本的主要来源)
+
+`filters.needsProfile()` 一个判据决定:
+
+- 过滤设置**全都关着** → 直接从列表抄 uid,**一次用户页都不开**(快 6 倍);
+- 开了私密 / 无头像 / 语言 / 三组数量中任意一项 → 每个候选都要开一次用户页读资料。
+
+真机实测(同一个 594 粉丝的账号):
+
+| 模式 | 处理 11 个 | 单个成本 |
+|---|---|---|
+| 不过滤 | 24 秒(含 ~15 秒启动与搜索) | ~1 秒 |
+| 粉丝量 ≥ 1000 | 88 秒 | ~6.5 秒 |
+
+### 3.5.3 深度展开:两阶段,最多两层
+
+"再取这些人的粉丝"如果边遍历边展开,打开 B 列表时 A 的滚动位置就丢了。
+所以拆成两段:① 先收完基础层,记下"这些人要展开";② 再逐个打开他们的列表。
+两层都线性,没有嵌套。**展开进来的人同样过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。
+
+界面上只给两层是产品约束不是实现偷懒:100 粉丝 × 每人 100 粉丝 = 1 万次开页。
+
+### 3.5.4 过滤:三档,做不到的如实说
+
+| 档 | 项 | 怎么实现 |
+|---|---|---|
+| **能做** | 私密账户 | 用户页读到 "private" 提示且视频网格为空 |
+| | 关注 / 粉丝 / 获赞 区间 | `parse.count` 解析缩写(`1.6M` / `1.2万` / `2,491`)后比区间 |
+| | 语言区域(通杀/中/英) | 按昵称+个签的**字符构成**判定(CJK ≥30% 判中文,拉丁 ≥70% 判英文) |
+| | 取样范围(通杀/昵称/个签) | 决定语言判定读哪段文本 |
+| | 提取上限 | 计数到点即停;0 = 不限,但受时长护栏约束 |
+| **能做但有局限** | 无头像 | **像素法**:头像区高饱和像素占比 < 0.12 判为默认剪影。实测默认 0.035 / 真头像 0.472。**纯黑白照片头像会被误判**,运行时会告知 |
+| **不做,如实告知** | 提取区域 | TikTok 用户页**不显示国家**,没有可靠数据源 |
+| | AI 头像 / 性别 / 年龄 | 需要模型 |
+| | 视频量门槛 | 用户页**只显示 关注/粉丝/获赞 三组数字**,没有视频数 → 放行 + 告知"未参与过滤" |
+| | 云端去重 | 服务端未接入 → 降级本地去重 + 告知 |
+
+「数据缺失」与「不符合」严格分开:设了门槛却**读不到**数字 → 拦下;
+平台**根本不提供**该数字(视频量)→ 放行 + 告知。两者的责任不同。
+
+### 3.5.5 实测锚点(2026-10-20 / TikTok 47.1.4 / 1080×1920)
+
+**这一片的资源 id 是语义化的**,与信息流那批混淆 id(`e4g`/`cn6`)完全不同:
+
+| 页面 | 控件 | 内容 |
+|---|---|---|
+| 用户主页 | `Button`,文案 `@xxx` | **uid**(被 FID 隔离符 U+2068/U+2069 包裹,必须剥) |
+| | `TextView` ×2 组(`t_r` 数字 / `t_q` 标签) | Following / Followers / Likes |
+| | `Button`(长文本) | 个签 |
+| 粉丝/关注列表 | `TextView.txt_desc` | **uid** |
+| | `TextView.txt_user_name` | 昵称 |
+| | 顶部三页签 `Following N` / `Followers N` / `Suggested` | 可直接切换,不必退回主页 |
+| 搜索用户列表 | `TextView.tv_username` | **uid** |
+| | `TextView.tv_aweme_id` | 昵称(**id 名违反直觉**) |
+| | `TextView.tv_desc` | `594 followers · 2.5K likes`(**行内就有计数**) |
+| 播放页 | 底部 `Button`,文案 `@xxx` | 作者 uid |
+| 评论区 | `Button.title` | 只有昵称,**没有 uid** |
+| | `ImageView.blm` | 评论者头像(可点进主页) |
+| 用户主页头像 | `ImageView` 192×192 @ `[856,168][1048,360]` | 无 text / 无 desc / 不可点 → 只能像素法 |
+
+三条导航事实(都实测过,直接决定实现):
+
+1. **从列表进用户页再返回,列表位置保持**(首行与坐标都不变)→ 可以"读一屏 → 逐个进 → 退回来接着读"。
+2. **粉丝列表是从主页点进来的覆盖页**,顶部三页签可直接切 → "先收粉丝再收关注"不用来回退。
+3. **首次打开粉丝列表会弹「给 TikTok 访问你的 Facebook 好友列表」授权窗**(文案 `Don’t allow`,撇号是 U+2019),会盖住整个列表 → `openList` 里专门点掉它再等一次。
+
+### 3.5.6 产物与去重
+
+| 文件 | 内容 |
+|---|---|
+| `/sdcard/Download/tiktok_app/uid_YYYYMMDD_HHmm.txt` | 本次新收的 uid,一行一个 |
+| `/sdcard/Download/tiktok_app/uid_extracted.txt` | 累计(跨轮去重基准),**只追加不删除** |
+
+产物**不带 `@`、无表头、无注释**(下游 UID 私信直接当输入读)。
+文件名加时分是对规格书的有意偏离:规格书只有日期,同一天跑两次会互相覆盖。
+
+去重三源合一:本轮已收 + 历史已收 + 输入文本里已有的。
+**每 20 个刷一次盘**(不是结束时一次性写)—— 用户点停或脚本被杀时,已抓的部分不能全丢。
+
+### 3.5.7 物理护栏("不限"不等于跑到没电)
+
+界面里 `limit = 0` 的语义是"不限制",但真跑起来没有上限等于跑到没电。所以引擎另加:
+
+- **单次运行最大时长**:默认 30 分钟(`id.guard.maxRuntimeMs`);
+- **单列表最大翻屏数**:60 屏(`idsource.MAX_SCREENS`);
+- **连续 2 屏没有新 uid** → 认为到底(推荐位会重复出现同一个人)。
+
+到护栏时**如实报告**"因达到护栏而结束",绝不装作抓完了。
+
+### 3.5.8 停止:必须保住已抓的部分
+
+`StopSignal` 从唯一的等待原语 `wait.tick()` 抛出,覆盖"打开用户页 / 翻列表 / 读数字"等所有等待。
+`writer.close()` 放在 `finally` 里,**无论怎么结束都会刷盘**。
+
+实测:抓到 26 个时点停 → 状态 `stopped`、`message: "已停止:本次已收 26 个 ID"`、
+产物文件 **26 行 26 唯一**(一个不丢)。
+
+统计数字也随之校正:`done` 在 `bridge.finish` 里按 `summary.extracted` 覆盖一次,
+否则终态会比实际少一个(最后一次 `report` 发生在收下一个人之前)。
+
+### 3.5.9 三个踩过的坑(都很隐蔽)
+
+1. **`settle()` 会提前返回**。点开粉丝列表/搜索结果页签后,页面骨架先渲染、
+   内容是后加载的,而 `waitQuiet` 判据是"可见文案连续两次不变" ——
+   加载中的页面和打开前的**签名一样**,于是 872ms 就返回,被误判成"打不开列表"。
+   **凡是"点完要等新内容"的地方,判据必须是"新内容真的出现了"**,不能等"安静"。
+
+2. **数字会先渲染成占位的 0**。同一个账号两次运行分别读到粉丝 `78` 与 `0`。
+   修法:连续两次读到一样才采信(`readStatsStable`)。页面安静 ≠ 数字就位。
+
+3. **点列表行不能用 `node.click()`**。它落在容器**中心**,那里常常是空白,
+   有时还是右侧 Follow 按钮的邻域(误触会把用户关注掉)。
+   改成按坐标点行左侧 30%(用户名文字上),实测既进得去也不会误关注。
+
+---
+
 ## 4. 需求 → 设计对照
 
 ### 需求 1:网络延迟下也要稳定,不能只靠 sleep

Fișier diff suprimat deoarece este prea mare
+ 2903 - 42
tiktok-auto/dist/tiktok-auto.js


+ 130 - 0
tiktok-auto/src/__selftest.js

@@ -114,9 +114,139 @@ TikTokAuto.selftest = (function () {
         }
         check('随机观看时长落在 10s-35s 区间', inRange, '越界');
 
+        runIdExtractChecks();
+
         return results;
     }
 
+    /**
+     * ID 提取的纯函数自检。
+     *
+     * 这一段是整个 ID 提取里**唯一能离线证明"逻辑对了"**的部分 ——
+     * 界面上的 21 项设置最终都汇成 `parse` 与 `filters` 这两个模块的判断,
+     * 它们错了,过滤就形同虚设而用户以为它在生效。
+     * 所以这里逐条覆盖真机上实测到的书写形态(不是编的用例)。
+     */
+    function runIdExtractChecks() {
+        var parse = TikTokAuto.parse;
+        var filters = TikTokAuto.filters;
+        var uidset = TikTokAuto.uidset;
+
+        // ---- 计数字符串:真机实测的每一种写法 ----
+        var counts = [
+            ['594', 594],
+            ['2,491', 2491],
+            ['1.6M', 1600000],
+            ['253.9M', 253900000],
+            ['245K', 245000],
+            ['371.7K', 371700],
+            ['1.2万', 12000],
+            ['1.5亿', 150000000],
+            ['1.6M followers', 1600000],       // 整行带单位(搜索用户列表的形态)
+            ['594 followers · 2.5K likes', 594], // 取第一个数
+            ['0', 0]
+        ];
+        var countOk = true;
+        var countBad = '';
+        for (var c = 0; c < counts.length; c += 1) {
+            var got = parse.count(counts[c][0]);
+            if (got !== counts[c][1]) {
+                countOk = false;
+                countBad = counts[c][0] + ' → ' + got + '(期望 ' + counts[c][1] + ')';
+                break;
+            }
+        }
+        check('parseCount 认得所有实测书写形态', countOk, countBad);
+        check('parseCount 读不到时返回 null(不是 0)', parse.count('') === null && parse.count('abc') === null,
+            String(parse.count('abc')));
+
+        // ---- 语言:CJK 占比判定 ----
+        check('纯中文昵称判 zh', parse.language('小美爱穿搭') === 'zh', parse.language('小美爱穿搭'));
+        check('纯英文昵称判 en', parse.language('Fashion Girl') === 'en', parse.language('Fashion Girl'));
+        check('中文混英文仍判 zh(中文用户常混英文)',
+            parse.language('小美的Fashion') === 'zh', parse.language('小美的Fashion'));
+        check('英文混个别中文字仍判 en(英文用户极少混中文)',
+            parse.language('Fashion Girl 小') === 'en', parse.language('Fashion Girl 小'));
+        check('纯 emoji 昵称判 none', parse.language('🥷🏿💗✨') === 'none', parse.language('🥷🏿💗✨'));
+        check('仅中文不放过英文账号', parse.languageMatches('zh', 'en') === false);
+        check('仅英文不放过中文账号', parse.languageMatches('en', 'zh') === false);
+        check('通杀放过任何人', parse.languageMatches('any', 'en') === true);
+
+        // ---- 区间:三种"没有值"必须分开 ----
+        check('区间未设置时永远通过(连值都不用读)', parse.inRange(null, 0, 0) === true);
+        check('值在区间内通过', parse.inRange(500, 100, 1000) === true);
+        check('值低于下限被拦', parse.inRange(50, 100, 1000) === false);
+        check('上限为 0 表示该侧不设限', parse.inRange(999999, 100, 0) === true);
+        check('设了区间但读不到值 → 拦下(放行会让过滤形同虚设)',
+            parse.inRange(null, 100, 1000) === false);
+
+        // ---- 用户名归一化 ----
+        check('剥掉行首的 @', parse.username('@ajman_fashion_week') === 'ajman_fashion_week');
+        check('剥掉 FID 隔离符(真机上肉眼看不见,不剥就会污染比对键)',
+            parse.username('\u2068ajman_fashion_week\u2069') === 'ajman_fashion_week',
+            JSON.stringify(parse.username('\u2068ajman_fashion_week\u2069')));
+        check('识别合法用户名', parse.looksLikeUsername('user3670597276145') === true &&
+            parse.looksLikeUsername('mukhawar.shop') === true);
+        check('拒绝不像用户名的文本', parse.looksLikeUsername('This is a caption') === false);
+
+        // ---- 去重集合 ----
+        var set = uidset.create();
+        check('首次加入返回 true', set.add('a') === true);
+        check('重复加入返回 false 且不涨数', set.add('a') === false && set.size() === 1);
+        check('has 能查到已加入的', set.has('a') === true && set.has('b') === false);
+
+        // ---- 过滤判定:逐条覆盖 ----
+        var noneFilter = {};
+        check('没有任何过滤时放行', filters.judge({ uid: 'x' }, noneFilter).ok === true);
+
+        var privateOnly = { private: true };
+        check('私密账号被拦', filters.judge({ uid: 'x', isPrivate: true }, privateOnly).ok === false);
+        check('公开账号放行', filters.judge({ uid: 'x', isPrivate: false }, privateOnly).ok === true);
+
+        var zhOnly = { langRegion: 'zh', nicknameCheck: 'any' };
+        check('仅中文:中文昵称放行',
+            filters.judge({ uid: 'x', nickname: '小美', signature: '' }, zhOnly).ok === true);
+        check('仅中文:英文昵称被拦',
+            filters.judge({ uid: 'x', nickname: 'Fashion', signature: '' }, zhOnly).ok === false);
+        check('仅中文:纯 emoji 昵称被拦(没有语言线索)',
+            filters.judge({ uid: 'x', nickname: '🥷🏿', signature: '' }, zhOnly).ok === false);
+
+        /* 取样范围:昵称检测 vs 个签检测 —— 两者结果应当不同 */
+        var signOnlyZh = { langRegion: 'zh', nicknameCheck: 'sign' };
+        check('取样范围选「仅个签」时只看个签',
+            filters.judge({ uid: 'x', nickname: 'Fashion', signature: '小美爱穿搭' }, signOnlyZh).ok === true,
+            '昵称英文+个签中文应当放行');
+
+        var fansRange = { fans: { min: 1000, max: 0 } };
+        check('粉丝量达标放行', filters.judge({ uid: 'x', fans: 5940 }, fansRange).ok === true);
+        check('粉丝量不足被拦', filters.judge({ uid: 'x', fans: 594 }, fansRange).ok === false);
+        check('粉丝量读不到被拦', filters.judge({ uid: 'x', fans: null }, fansRange).ok === false);
+
+        /* 视频量:平台不提供 → 放行但必须留一条"没生效"的话 */
+        var videosRange = { videos: { min: 10, max: 0 } };
+        var videoVerdict = filters.judge({ uid: 'x', videos: null }, videosRange);
+        check('视频量门槛不拦人(平台不提供该数字)', videoVerdict.ok === true);
+        check('但必须如实告知"未参与过滤"', videoVerdict.soft.indexOf('视频量') >= 0, videoVerdict.soft);
+
+        // ---- 是否需要打开用户页(快路径判据) ----
+        check('没有任何过滤时不必打开用户页', filters.needsProfile({}) === false);
+        check('只设了视频量也不必打开(它读不到)',
+            filters.needsProfile({ videos: { min: 10, max: 0 } }) === false);
+        check('设了粉丝量就必须打开', filters.needsProfile({ fans: { min: 10, max: 0 } }) === true);
+        check('设了私密过滤就必须打开', filters.needsProfile({ private: true }) === true);
+
+        /* 「读不到资料」必须拦下:放行等于"打不开的人全都通过",
+         * 过滤看着在跑、实际放进一批没检查过的账号。快路径仍要放行。 */
+        check('需要资料但拿不到 → 拦下',
+            filters.judge(null, { fans: { min: 1000, max: 0 } }).ok === false);
+        check('不需要资料且没有资料 → 放行(快路径)', filters.judge(null, {}).ok === true);
+
+        // ---- 结果文件名 ----
+        var name = parse.timestampName('uid', new Date(2026, 9, 20, 18, 31, 0));
+        check('结果文件名带日期与时分(同一天跑两次不互相覆盖)',
+            name === 'uid_20261020_1831.txt', name);
+    }
+
     function report() {
         var list = run();
         var failed = 0;

+ 21 - 3
tiktok-auto/src/bridge.js

@@ -230,21 +230,39 @@ TikTokAuto.bridge = (function () {
         if (!state.enabled) {
             return;
         }
-        report({
+        var patch = {
             status: status,
             phase: '',
             summary: summary || null,
             message: describe(status, summary)
-        });
+        };
+        /* ID 提取的 `done` 是"已收个数",而最后一次 `report` 往往发生在收下一个人之前 ——
+         * 于是终态里 done 会比实际少一个(实测 11 个却报 10)。
+         * 收尾时按 summary 校正一次,免得界面/记录上的数字自相矛盾。 */
+        if (summary && typeof summary.extracted === 'number') {
+            patch.done = summary.extracted;
+        }
+        report(patch);
         log('info', '终态已上报:' + status);
     }
 
     /** 给界面看的一句人话,不要求界面自己去解析 summary 结构 */
     function describe(status, summary) {
+        /* ID 提取与视频链路的 summary 形状不同(提取个数 vs 视频个数),
+         * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。
+         * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */
+        var isIdTask = summary && typeof summary.extracted === 'number';
+
         if (status === 'stopped') {
-            return '已停止';
+            return isIdTask
+                ? '已停止:本次已收 ' + summary.extracted + ' 个 ID'
+                : '已停止';
         }
         if (status === 'finished') {
+            if (isIdTask) {
+                var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
+                return '完成:提取 ' + summary.extracted + ' 个 ID' + tail;
+            }
             var watched = summary ? summary.attemptedVideos : 0;
             var posted = summary ? summary.postedComments : 0;
             return '完成:处理 ' + watched + ' 个视频 / 发出 ' + posted + ' 条评论';

+ 76 - 9
tiktok-auto/src/config.js

@@ -19,6 +19,13 @@ TikTokAuto.config = (function () {
     var DEFAULT_DEVICE_PATH = '/sdcard/Download/tiktok-auto/config.json';
 
     var DEFAULTS = {
+        /**
+         * 任务类型。缺省 'video' 是**刻意的向后兼容**:
+         * 老配置(PC 侧下发、设备上残留的 config.json)里没有这个字段,
+         * 它们本来就是视频链路的,所以默认值必须还是 video,
+         * 否则一次升级会把所有老用法打成"未知任务"。
+         */
+        task: 'video',
         target: {
             packageName: 'com.zhiliaoapp.musically',
             keyword: 'fashion',
@@ -26,6 +33,38 @@ TikTokAuto.config = (function () {
             resultTab: 'Top',
             pickStrategy: 'random'
         },
+        /**
+         * ID 提取(task = 'id')。形状与界面 `sources.js` 对齐:
+         * `sources` 是**不定长清单**,每条自带起点与深度。
+         */
+        id: {
+            sources: [],
+            filter: {
+                private: false,
+                noAvatar: false,
+                langRegion: 'any',
+                nicknameCheck: 'any',
+                follow: { min: 0, max: 0 },
+                fans: { min: 0, max: 0 },
+                likes: { min: 0, max: 0 },
+                videos: { min: 0, max: 0 }
+            },
+            output: {
+                dir: '/sdcard/Download/tiktok_app',
+                historyPath: '/sdcard/Download/tiktok_app/uid_extracted.txt',
+                dedupeHistory: true,
+                dryRun: false,
+                limit: 0
+            },
+            /**
+             * 物理护栏。界面里 `limit = 0` 的语义是"不限",
+             * 但真跑起来没有上限等于跑到没电 —— 所以引擎自己有个时长上限。
+             * 到点时**如实报告**,不装作抓完了。
+             */
+            guard: {
+                maxRuntimeMs: 30 * 60 * 1000
+            }
+        },
         watch: {
             minMs: 10000,
             maxMs: 35000
@@ -170,6 +209,24 @@ TikTokAuto.config = (function () {
     }
 
     function validate(config) {
+        /* —— ID 提取任务:校验的是"取材清单",不是搜索关键词 ——
+         * 早先这个函数硬性要求 target.keyword 非空,那是视频链路的前提。
+         * ID 提取可以不搜索(从粉丝列表、文本文件、当前屏幕取材),
+         * 所以按 task 分支,而不是给关键词塞一个假默认值把校验糊过去。 */
+        if (config.task === 'id') {
+            if (!(config.id && config.id.sources instanceof Array)) {
+                throw new ConfigError('id.sources 必须是数组(取材清单)', config.id && config.id.sources);
+            }
+            if (config.id.sources.length === 0) {
+                throw new ConfigError('id.sources 不能为空,至少需要一条取材', 0);
+            }
+            var limit = Number(config.id.output && config.id.output.limit);
+            if (limit < 0) {
+                throw new ConfigError('id.output.limit 不能为负数', limit);
+            }
+            return config;
+        }
+
         var target = config.target;
         if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
             throw new ConfigError('target.keyword 不能为空', target.keyword);
@@ -205,15 +262,25 @@ TikTokAuto.config = (function () {
         var config = merge(merge(fromDefaults, fromDevice), injected || {});
 
         validate(config);
-        logger.info('config', '生效配置:' + JSON.stringify({
-            keyword: config.target.keyword,
-            maxVideos: config.target.maxVideos,
-            resultTab: config.target.resultTab,
-            watchMs: [config.watch.minMs, config.watch.maxMs],
-            commentEnabled: config.comments.enabled,
-            commentCount: config.comments.pool.length,
-            dryRun: config.runtime.dryRun
-        }));
+        if (config.task === 'id') {
+            logger.info('config', '生效配置(ID提取):' + JSON.stringify({
+                sources: config.id.sources.length,
+                filter: config.id.filter,
+                limit: config.id.output.limit,
+                dedupeHistory: config.id.output.dedupeHistory,
+                dryRun: config.id.output.dryRun
+            }));
+        } else {
+            logger.info('config', '生效配置:' + JSON.stringify({
+                keyword: config.target.keyword,
+                maxVideos: config.target.maxVideos,
+                resultTab: config.target.resultTab,
+                watchMs: [config.watch.minMs, config.watch.maxMs],
+                commentEnabled: config.comments.enabled,
+                commentCount: config.comments.pool.length,
+                dryRun: config.runtime.dryRun
+            }));
+        }
         return config;
     }
 

+ 196 - 0
tiktok-auto/src/filters.js

@@ -0,0 +1,196 @@
+/**
+ * filters.js — 「只要这样的账号」的判定
+ *
+ * 这一层是**纯函数**:给它一份采集到的资料 + 一份过滤设置,它只回答"要 / 不要 / 为什么"。
+ * 不点屏幕、不读写文件、不知道 TikTok 长什么样。所以它能被 100% 离线自检,
+ * 而"界面上的 21 项设置真的会拦人"这件事也就有了证据。
+ *
+ * ## 一条重要的区分:**数据缺失**不等于**不符合**
+ * 三种"没有值"必须分开处理,否则过滤要么形同虚设,要么乱杀:
+ *
+ *   ① 用户没设这个门槛        → 放行(设置里是 0,语义就是"不限")
+ *   ② 设了,但平台根本不提供  → **放行 + 记一条 warn**(例如"视频量":真机实测用户页
+ *                              顶部只有 关注/粉丝/获赞 三组数字,没有视频数)
+ *   ③ 设了,本应能读到却读不到 → **拦下**(读不到就无法证明符合,宁可少收)
+ *
+ * ②和③看起来像,但责任不同:②是我们做不到,不该惩罚用户抓到的人;③是数据异常,
+ * 放行会让过滤失去意义而用户以为它生效了。
+ *
+ * 依赖:TikTokAuto.parse、TikTokAuto.logger(调用时取)
+ */
+TikTokAuto.filters = (function () {
+    'use strict';
+
+    /**
+     * 这份过滤设置是否需要打开用户页才能判。
+     *
+     * 用途是**快路径**:所有过滤都关着时(最常见的第一种用法),
+     * 直接从列表页把 uid 抄下来就行 —— 不必为每个人开一次用户页。
+     * 实测一次用户页打开约 3~5 秒,100 个人就是 5~8 分钟,差一个数量级。
+     *
+     * 注意 `videos` 不参与这个判断:它读不到(见文件头 ②),
+     * 为此打开用户页也只是白花时间。
+     */
+    function needsProfile(filter) {
+        var f = filter || {};
+        if (f.private === true || f.noAvatar === true) {
+            return true;
+        }
+        if (f.langRegion && f.langRegion !== 'any') {
+            return true;
+        }
+        var groups = ['follow', 'fans', 'likes'];
+        for (var i = 0; i < groups.length; i += 1) {
+            var range = f[groups[i]] || {};
+            if (!TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 取该判定的取样文本:昵称、个签,或两者(昵称检测下拉的三个取值)。 */
+    function sampleText(profile, mode) {
+        var nick = profile.nickname || '';
+        var sign = profile.signature || '';
+        if (mode === 'nick') {
+            return nick;
+        }
+        if (mode === 'sign') {
+            return sign;
+        }
+        /* "通杀":两段合起来判 —— 分开判会让"昵称中文、个签全英文"的人两边都不算 */
+        return nick + ' ' + sign;
+    }
+
+    /**
+     * 单条判定的结果。
+     * `soft` 表示"我们做不到,放行但要告知"(对应文件头的 ②)。
+     */
+    function pass() {
+        return { ok: true, reason: '', soft: '' };
+    }
+
+    function reject(reason) {
+        return { ok: false, reason: reason, soft: '' };
+    }
+
+    function softPass(reason) {
+        return { ok: true, reason: '', soft: reason };
+    }
+
+    /**
+     * 判定一个账号要不要。
+     *
+     * @param profile 采集到的资料;`null` 表示"这次没有打开用户页"(快路径)
+     *                —— 此时只有不看资料就能判的项目生效,其余全部放行
+     * @param filter  过滤设置
+     * @returns { ok, reason, soft }
+     *   reason 是"为什么不要"(人话,会进日志)
+     *   soft 是"放行了但你应该知道"(进 summary 的 warnings,只报一次)
+     */
+    function judge(profile, filter) {
+        var f = filter || {};
+        var parse = TikTokAuto.parse;
+
+        if (!profile) {
+            /* 没有资料,两种情况必须分开:
+             *   · 这份设置**本来就不需要**资料(快路径)→ 放行;
+             *   · 需要资料却拿不到(用户页打不开、读不出)→ **拦下**。
+             * 第二种如果放行,等于"打不开的人全都通过" —— 过滤看着在跑,
+             * 实际放进来一批根本没检查过的账号,比不过滤更糟(用户以为筛过了)。 */
+            if (needsProfile(f)) {
+                return reject('打不开用户页,读不到任何资料');
+            }
+            return pass();
+        }
+
+        /* ① 私密账户。列表页读不到,只能打开用户页看提示文案。 */
+        if (f.private === true && profile.isPrivate === true) {
+            return reject('私密账号');
+        }
+
+        /* ② 无头像。真机实测头像节点不可读(无文案、不可点),
+         *    只能靠像素法 —— 见 profile.js 里的说明。读不到时放行并告知。 */
+        if (f.noAvatar === true) {
+            if (profile.hasAvatar === false) {
+                return reject('无头像');
+            }
+            if (profile.hasAvatar === null) {
+                return {
+                    ok: true,
+                    reason: '',
+                    soft: '无头像检测:头像读取不可用,本次未生效'
+                };
+            }
+        }
+
+        /* ③ 语言区域:按昵称/个签的字符构成判(见 parse.language 的规则说明) */
+        if (f.langRegion && f.langRegion !== 'any') {
+            var text = sampleText(profile, f.nicknameCheck || 'any');
+            var lang = parse.language(text);
+            if (lang === 'none') {
+                /* 纯 emoji / 纯数字昵称:没有语言线索。用户明确要筛语言,就不该混进来。 */
+                return reject('看不出语言(昵称与个签都没有文字)');
+            }
+            if (!parse.languageMatches(f.langRegion, lang)) {
+                return reject('语言不符:' + lang + ' ≠ ' + f.langRegion);
+            }
+        }
+
+        /* ④ 三组数量门槛。真机实测用户页顶部是 关注 / 粉丝 / 获赞,
+         *    数字形态是缩写("1.6M"),parse.count 负责解析。 */
+        var checks = [
+            { key: 'follow', label: '关注量' },
+            { key: 'fans', label: '粉丝量' },
+            { key: 'likes', label: '获赞量' }
+        ];
+        for (var i = 0; i < checks.length; i += 1) {
+            var one = checks[i];
+            var range = f[one.key] || {};
+            if (parse.isRangeOpen(range.min, range.max)) {
+                continue;
+            }
+            if (!parse.inRange(profile[one.key], range.min, range.max)) {
+                var actual = profile[one.key];
+                return reject(one.label + '不在区间内(' +
+                    (actual === null || actual === undefined ? '读不到' : actual) + ')');
+            }
+        }
+
+        /* ⑤ 视频量:平台不提供(实测),放行 + 告知。见文件头 ②。 */
+        var videoRange = f.videos || {};
+        if (!parse.isRangeOpen(videoRange.min, videoRange.max)) {
+            return softPass('视频量:用户页不显示该数字,本次未参与过滤');
+        }
+
+        return pass();
+    }
+
+    /** 给人看的一句话:这次到底在筛什么(进日志与界面状态条) */
+    function describe(filter) {
+        var f = filter || {};
+        var parts = [];
+        if (f.private === true) { parts.push('排除私密'); }
+        if (f.noAvatar === true) { parts.push('排除无头像'); }
+        if (f.langRegion && f.langRegion !== 'any') {
+            parts.push(f.langRegion === 'zh' ? '仅中文' : '仅英文');
+        }
+        var labels = { follow: '关注', fans: '粉丝', likes: '获赞' };
+        var keys = ['follow', 'fans', 'likes'];
+        for (var i = 0; i < keys.length; i += 1) {
+            var range = f[keys[i]] || {};
+            if (TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
+                continue;
+            }
+            parts.push(labels[keys[i]] + ' ' + (range.min || 0) + '~' + (range.max || '∞'));
+        }
+        return parts.length === 0 ? '不过滤(拿到就收)' : parts.join(',');
+    }
+
+    return {
+        needsProfile: needsProfile,
+        judge: judge,
+        describe: describe
+    };
+}());

+ 408 - 0
tiktok-auto/src/idflow.js

@@ -0,0 +1,408 @@
+/**
+ * idflow.js — ID 提取的编排
+ *
+ * 与 `flow.js`(视频链路)并列,共用同一套基础设施:应用启动、弹窗守护、
+ * 坐标点击、`wait.tick()` 的停止、`bridge.report()` 的状态上报。
+ *
+ * ## 一轮跑完是什么样
+ *
+ *   启动应用 → 进信息流 → 对每条取材:
+ *        取材(列表型 / 文本型 / 逐个点开型)
+ *        → 每个候选:去重判定 → 需要时打开用户页 → 过滤判定 → 写盘
+ *    → 深度展开(第二层,最多一层)
+ *    → 收尾:关文件、把 summary 上报给界面
+ *
+ * ## 三个必须处理好的"与视频链路不同"
+ *
+ * ① **总量未知**。粉丝列表可能几十万人,没有 `total` 可报。
+ *    所以状态条上的进度是「已收 N 个」而不是"N/M" —— 界面只读 `phase`,零改动。
+ *
+ * ② **"不限"是危险的**。界面里 `extractLimit = 0` 的语义是"不限制"。
+ *    但真跑起来没有上限就等于跑到没电。所以引擎**另加三道物理护栏**:
+ *    最大时长、单列表最大翻屏数(在 idsource 里)、连续无新内容即止。
+ *    到护栏时**如实报告**"因达到护栏而结束",绝不装作抓完了。
+ *
+ * ③ **停止时必须写文件**。用户在任意时刻点停,已经抓到的部分不能丢 ——
+ *    所以 `writer.close()` 放在 finally 里,无论怎么结束都会刷盘。
+ *
+ * 依赖:全部 ID 相关模块 + bridge / wait / ui / popups
+ */
+TikTokAuto.idflow = (function () {
+    'use strict';
+
+    /**
+     * 最近一次运行的 summary。
+     *
+     * 为什么挂模块级而不是只靠返回值:用户点停止时 StopSignal 会从
+     * `wait.tick()` 里抛出来,穿过整个流程到 main.js 的 catch ——
+     * 那时 `runSession` 的返回值根本用不上。main 从这里取已经统计好的数字,
+     * 否则界面会显示"提取 0 个"(实际抓到的都在文件里,只是没报上去)。
+     */
+    var lastSummary = null;
+
+    function logger() { return TikTokAuto.logger; }
+
+    /** 上报进度。只动 phase(界面状态条只读它),total 恒为 0 —— 这里没有总量。 */
+    function progress(phase, done) {
+        TikTokAuto.bridge.report({
+            status: 'running',
+            phase: phase + '(已收 ' + done + ' 个)',
+            done: done,
+            total: 0
+        });
+    }
+
+    /**
+     * 把界面配置里的 `id` 段收拢成引擎侧要用的形状。
+     *
+     * 界面已经做过一遍归一化(Sources.normalize),但**引擎不能假设界面一定对**:
+     * 手写的 engine-config.json、旧版本残留、被改坏的文件都会进来。
+     * 这里只做"缺了就补默认",不做语义校验 —— 语义校验在 filters 与 idsource 里。
+     */
+    function readSettings(config) {
+        var id = config.id || {};
+        var filter = id.filter || {};
+        var output = id.output || {};
+        var guard = id.guard || {};
+
+        return {
+            sources: (id.sources instanceof Array ? id.sources : []),
+            filter: {
+                private: filter.private === true,
+                noAvatar: filter.noAvatar === true,
+                langRegion: filter.langRegion || 'any',
+                nicknameCheck: filter.nicknameCheck || 'any',
+                follow: filter.follow || {},
+                fans: filter.fans || {},
+                likes: filter.likes || {},
+                videos: filter.videos || {}
+            },
+            output: {
+                dir: output.dir || '/sdcard/Download/tiktok_app',
+                historyPath: output.historyPath || (output.dir || '/sdcard/Download/tiktok_app') + '/uid_extracted.txt',
+                dedupeHistory: output.dedupeHistory !== false,
+                dryRun: output.dryRun === true
+            },
+            limit: Number(output.limit) || 0,
+            maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000
+        };
+    }
+
+    /**
+     * 跑一轮 ID 提取。
+     *
+     * @returns 结构化 summary(与视频链路同族,供 bridge/main 复用)
+     */
+    function runSession(config) {
+        var log = logger();
+        var profileApi = TikTokAuto.profile;
+        var settings = readSettings(config);
+        var began = Date.now();
+
+        var summary = {
+            ok: false,
+            task: 'id',
+            extracted: 0,          /* 本次写进产物文件的个数 */
+            skipped: 0,            /* 因过滤被排除 */
+            duplicates: 0,         /* 因去重被排除 */
+            openedProfiles: 0,     /* 打开过多少次用户页(成本指标) */
+            sources: [],
+            warnings: [],
+            resultFile: '',
+            errors: []
+        };
+        lastSummary = summary;
+
+        var seen = TikTokAuto.uidset.create();
+        var expandQueue = [];
+        var softReported = {};
+        var writer = null;
+
+        /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */
+        function limitReached() {
+            return settings.limit > 0 && summary.extracted >= settings.limit;
+        }
+        function timeUp() {
+            return Date.now() - began >= settings.maxRuntimeMs;
+        }
+        function shouldStop() {
+            if (limitReached()) {
+                return true;
+            }
+            if (timeUp()) {
+                if (!summary.warnings.length || summary.warnings[summary.warnings.length - 1] !== 'TIME_LIMIT') {
+                    log.warn('idflow', '已达单次运行时长上限(' +
+                        Math.round(settings.maxRuntimeMs / 60000) + ' 分钟),准备收尾');
+                    summary.warnings.push('TIME_LIMIT');
+                }
+                return true;
+            }
+            return false;
+        }
+
+        /**
+         * 给一个候选拿到资料。三条路径按代价从低到高:
+         *   ① 调用方已经读过(评论者那只,`_profile`);
+         *   ② 列表行自带计数(搜索用户列表有"粉丝 · 获赞");
+         *   ③ 打开用户页(最贵,约 3~5 秒)。
+         */
+        function obtainProfile(row) {
+            if (row._profile) {
+                return row._profile;
+            }
+            if (!TikTokAuto.filters.needsProfile(settings.filter)) {
+                /* 不需要任何用户页才能判的条件 → 绝不为此付一次开页代价 */
+                if (row.fans !== undefined || row.likes !== undefined) {
+                    return {
+                        uid: row.uid,
+                        nickname: row.nickname,
+                        signature: '',
+                        follow: null,
+                        fans: row.fans === undefined ? null : row.fans,
+                        likes: row.likes === undefined ? null : row.likes,
+                        isPrivate: false,
+                        hasAvatar: null
+                    };
+                }
+                return null;
+            }
+
+            /* 从列表行点进去最快(且实测返回后列表位置保持)。
+             * ★ 点开失败时**不再退回搜索**:在列表遍历中途跳去搜索,
+             *   会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。
+             *   这里直接返回 null,由 judge 按"读不到资料"拦下并记日志。 */
+            if (row.node && row.x !== undefined && profileApi.where() === 'user-list') {
+                if (profileApi.openRow(row)) {
+                    summary.openedProfiles += 1;
+                    var data = profileApi.readProfile();
+                    profileApi.backToList();
+                    return data;
+                }
+                log.warn('idflow', '点不开 @' + row.uid + ' 的主页,跳过(不回搜索,避免丢失列表位置)');
+                return null;
+            }
+            var profile = profileApi.openBySearch(row.uid, config);
+            if (profile) {
+                summary.openedProfiles += 1;
+            }
+            return profile;
+        }
+
+        /** 处理一个候选:去重 → 过滤 → 写盘 → 记下要不要展开 */
+        function handleRow(row) {
+            var parse = TikTokAuto.parse;
+            var uid = parse.username(row.uid);
+            if (uid === '' || !parse.looksLikeUsername(uid)) {
+                return;
+            }
+            if (seen.has(uid)) {
+                summary.duplicates += 1;
+                return;
+            }
+            if (shouldStop()) {
+                return;
+            }
+
+            progress('正在读取 @' + uid, summary.extracted);
+
+            var profile = obtainProfile(row);
+            var verdict = TikTokAuto.filters.judge(profile, settings.filter);
+            if (!verdict.ok) {
+                summary.skipped += 1;
+                log.info('idflow', '跳过 @' + uid + ':' + verdict.reason);
+                seen.add(uid);
+                return;
+            }
+            if (verdict.soft && !softReported[verdict.soft]) {
+                softReported[verdict.soft] = true;
+                summary.warnings.push(verdict.soft);
+                log.warn('idflow', verdict.soft);
+            }
+
+            seen.add(uid);
+            writer.write(uid);
+            summary.extracted += 1;
+
+            /* 收录时记下依据。这不只是"好看":用户事后想核对"这个人为啥被收进来"时,
+             * 日志是唯一的答案;没有它就只能重新跑一遍。 */
+            log.info('idflow', '收录 @' + uid + (profile
+                ? '(粉丝 ' + profile.fans + ' / 关注 ' + profile.follow +
+                  ' / 获赞 ' + profile.likes + ')'
+                : '(未打开用户页,按列表现有资料通过)'));
+
+            if (row.expandTo) {
+                expandQueue.push({ uid: uid, which: row.expandTo });
+            }
+        }
+
+        try {
+            log.step('idflow', '=== TikTok ID 提取开始 ===');
+            log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
+                TikTokAuto.filters.describe(settings.filter));
+
+            TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
+
+            if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) {
+                summary.errors.push('无法创建结果目录:' + settings.output.dir);
+                return summary;
+            }
+            /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟,
+             * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。 */
+            var runFile = settings.output.dir + '/' +
+                TikTokAuto.parse.timestampName('uid', new Date());
+            writer = TikTokAuto.uidset.createWriter({
+                runPath: runFile,
+                historyPath: settings.output.historyPath,
+                dryRun: settings.output.dryRun,
+                historyEnabled: settings.output.dedupeHistory
+            });
+            summary.resultFile = settings.output.dryRun ? '' : runFile;
+
+            if (settings.output.dedupeHistory) {
+                TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath);
+            }
+
+            /* —— 启动与就位(复用视频链路的成熟实现) —— */
+            progress('启动应用', 0);
+            if (!TikTokAuto.flow.launchApp(config)) {
+                summary.errors.push('启动应用失败');
+                return summary;
+            }
+            progress('进入信息流', 0);
+            if (!TikTokAuto.profile.ensureFeed(config)) {
+                summary.errors.push('未能进入信息流首页');
+                return summary;
+            }
+
+            if (settings.sources.length === 0) {
+                summary.errors.push('没有配置任何取材(界面里至少需要一条)');
+                return summary;
+            }
+
+            /* —— 第一层:逐条取材 —— */
+            for (var i = 0; i < settings.sources.length; i += 1) {
+                if (shouldStop()) {
+                    break;
+                }
+                var source = settings.sources[i];
+                var before = summary.extracted;
+
+                progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted);
+                var ctx = {
+                    config: config,
+                    seen: seen,
+                    shouldStop: shouldStop,
+                    onSeenProfile: function (prof) {
+                        log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans +
+                            ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes);
+                    },
+                    onRow: function (row) {
+                        /* 深度只在第一层记:第二层不再展开(上限两层) */
+                        if (source.expand && !row.expandTo) {
+                            row.expandTo = source.expand;
+                        }
+                        handleRow(row);
+                    }
+                };
+
+                var handled = 0;
+                try {
+                    handled = TikTokAuto.idsource.run(source, ctx);
+                } catch (error) {
+                    if (TikTokAuto.bridge.isStopSignal(error)) {
+                        throw error;
+                    }
+                    log.error('idflow', '取材失败(' + TikTokAuto.idsource.describe(source) + '):' + error);
+                    summary.errors.push('取材失败:' + TikTokAuto.idsource.describe(source));
+                }
+                summary.sources.push({
+                    label: TikTokAuto.idsource.describe(source),
+                    handled: handled,
+                    added: summary.extracted - before
+                });
+                log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) +
+                    ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个');
+            }
+
+            /* —— 第二层:深度展开 ——
+             * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
+             * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。 */
+            if (expandQueue.length > 0 && !shouldStop()) {
+                log.step('idflow', '开始深度展开:' + expandQueue.length + ' 个账号');
+                for (var e = 0; e < expandQueue.length; e += 1) {
+                    if (shouldStop()) {
+                        break;
+                    }
+                    var target = expandQueue[e];
+                    progress('展开 @' + target.uid + ' 的' +
+                        (target.which === 'following' ? '关注' : '粉丝'), summary.extracted);
+
+                    var opened = profileApi.openBySearch(target.uid, config);
+                    if (!opened) {
+                        log.warn('idflow', '展开时打不开 @' + target.uid);
+                        continue;
+                    }
+                    summary.openedProfiles += 1;
+                    if (!profileApi.openList(target.which, config)) {
+                        continue;
+                    }
+                    profileApi.switchListTab(target.which);
+
+                    var subCtx = {
+                        config: config,
+                        seen: seen,
+                        shouldStop: shouldStop,
+                        onSeenProfile: function () { },
+                        onRow: handleRow          /* 注意:这里**不再**设置 expandTo,所以不会递归 */
+                    };
+                    try {
+                        TikTokAuto.idsource.walkList(subCtx);
+                    } catch (error2) {
+                        if (TikTokAuto.bridge.isStopSignal(error2)) {
+                            throw error2;
+                        }
+                        log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2);
+                    }
+                }
+            }
+
+            summary.ok = summary.extracted > 0;
+            if (!summary.ok && summary.errors.length === 0) {
+                summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
+            }
+        } finally {
+            /* 无论是正常结束、出错、还是用户叫停,都必须把已经抓到的刷进文件 ——
+             * "点了停止但文件是空的"是最不能接受的一种失败。 */
+            if (writer) {
+                var written = writer.close();
+                if (written !== summary.extracted) {
+                    log.warn('idflow', '写盘数量与统计不符:文件 ' + written + ' / 统计 ' + summary.extracted);
+                }
+                if (writer.hadFailure()) {
+                    summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)');
+                }
+            }
+            /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append),
+             * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。 */
+            if (summary.extracted === 0) {
+                summary.resultFile = '';
+            }
+            if (limitReached()) {
+                summary.warnings.push('已提取满 ' + settings.limit + ' 个,按设置停止');
+            }
+            log.step('idflow', '=== 提取结束:新增 ' + summary.extracted +
+                ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates +
+                ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ===');
+        }
+
+        return summary;
+    }
+
+    return {
+        runSession: runSession,
+        readSettings: readSettings,
+        lastSummary: function () {
+            return lastSummary;
+        }
+    };
+}());

+ 552 - 0
tiktok-auto/src/idsource.js

@@ -0,0 +1,552 @@
+/**
+ * idsource.js — 五种「从哪儿找人」,以及深度展开
+ *
+ * ## 为什么是五种而不是四张脚本卡
+ * 旧的四个脚本卡是「起点 × 深度」的 4 个**不完整组合**。把它们拆开后发现,
+ * 真正不同的只有一件事:**候选 ID 从哪儿来**。于是收敛成五种起点
+ * (指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕),
+ * 深度(再取这些人的粉丝)是所有起点共用的一个选项。
+ *
+ * ## 三种遍历模式
+ * 五种起点在"怎么走"上只有三种形态,实现都归到这三条路径上:
+ *   · **列表型**(指定账号 / 搜索用户 / 当前屏幕)—— 逐屏读行,可翻页;
+ *   · **文本型**(文本文件)—— 一次性读出每一行;
+ *   · **逐个点开型**(搜索视频作者 / 评论区)—— 屏幕上的条目没有 uid,
+ *     只能点进去看(视频页底部有 `@uid`,评论者要靠头像进用户页)。
+ *
+ * ## 深度展开为什么分两阶段
+ * "取这批人的粉丝"如果边遍历边展开,会嵌套两层列表上下文 ——
+ * 打开 B 列表时 A 的滚动位置就丢了。所以做成:
+ *   ① 先把基础层收完,边收边记下"这些人要展开";
+ *   ② 再对这批人逐个打开其粉丝/关注列表。
+ * 两层都线性,没有嵌套,也就没有"回到上一层时不知道走到哪"的问题。
+ * **上限固定两层**:100 粉丝 × 每人 100 粉丝 = 1 万次开页,真机上不现实。
+ *
+ * 依赖:TikTokAuto.tiktok / profile / filters / parse / logger / wait
+ */
+TikTokAuto.idsource = (function () {
+    'use strict';
+
+    /** 翻一个列表最多翻多少屏。防止"永远有新内容"(推荐流)把一轮拖成无限长。 */
+    var MAX_SCREENS = 60;
+
+    /** 连续这么多屏都没有新 uid 就认为到底了(有的列表末尾会重复推荐位) */
+    var IDLE_SCREENS_TO_STOP = 2;
+
+    function logger() { return TikTokAuto.logger; }
+
+    /** 一行 uid 的临时载体(列表行带昵称与计数,用户页带更多资料,统一成这个形状) */
+    function rowOf(uid, nickname, extra) {
+        var one = { uid: uid, nickname: nickname || '' };
+        if (extra) {
+            for (var key in extra) {
+                if (extra.hasOwnProperty(key)) {
+                    one[key] = extra[key];
+                }
+            }
+        }
+        return one;
+    }
+
+    /* ============================================================ 列表型 */
+
+    /**
+     * 在"一个可滚动的用户列表"上逐行推进。
+     *
+     * 关键设计:**用 uid 集合判断进度,不用行下标**。
+     * 因为每次点进用户页再退回来,列表的节点树都重建过一遍(引用失效),
+     * 只有 uid 是稳的。
+     *
+     * @param ctx { seen, onRow(row), shouldStop() }
+     * @returns 处理了多少行
+     */
+    function walkList(ctx) {
+        var log = logger();
+        var handled = 0;
+        var idle = 0;
+        var screens = 0;
+
+        while (screens < MAX_SCREENS) {
+            if (ctx.shouldStop()) {
+                log.info('idsource', '达到停止条件,结束当前列表');
+                break;
+            }
+            screens += 1;
+
+            var rows = TikTokAuto.profile.listRows();
+            if (rows.length === 0 && screens === 1) {
+                /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
+                 * 从用户页点开粉丝列表后,页签先出来、行后出来(实测差 1~2 秒),
+                 * 这时 listRows() 返回空数组 —— 直接 break 就等于"一条都没抓到"。 */
+                TikTokAuto.wait.waitFor('listRows', function () {
+                    return TikTokAuto.profile.listRows().length > 0 ? true : null;
+                }, 8000, 300);
+                rows = TikTokAuto.profile.listRows();
+            }
+            if (rows.length === 0) {
+                log.info('idsource', '当前屏没有读到用户行,结束列表');
+                break;
+            }
+
+            var fresh = [];
+            for (var i = 0; i < rows.length; i += 1) {
+                if (!ctx.seen.has(rows[i].uid)) {
+                    fresh.push(rows[i]);
+                }
+            }
+
+            if (fresh.length === 0) {
+                idle += 1;
+                if (idle >= IDLE_SCREENS_TO_STOP) {
+                    log.info('idsource', '连续 ' + idle + ' 屏没有新 uid,认为列表到底');
+                    break;
+                }
+            } else {
+                idle = 0;
+            }
+
+            /* 一行一行处理:需要资料时会点进用户页再退回(实测位置保持) */
+            for (var j = 0; j < fresh.length; j += 1) {
+                if (ctx.shouldStop()) {
+                    return handled;
+                }
+                ctx.onRow(fresh[j]);
+                ctx.seen.add(fresh[j].uid);     /* 处理过就算见过,避免同一行被反复点开 */
+                handled += 1;
+            }
+
+            /* 翻一屏再看有没有新的 */
+            var before = signatureOfRows(rows);
+            TikTokAuto.profile.scrollList();
+            var after = signatureOfRows(TikTokAuto.profile.listRows());
+            if (after === before) {
+                idle += 1;
+                if (idle >= IDLE_SCREENS_TO_STOP) {
+                    log.info('idsource', '翻屏后内容不变,认为列表到底');
+                    break;
+                }
+            }
+        }
+        return handled;
+    }
+
+    /** 一屏 uid 的签名,用来判断"翻完到底有没有变" */
+    function signatureOfRows(rows) {
+        var parts = [];
+        for (var i = 0; i < rows.length; i += 1) {
+            parts.push(rows[i].uid);
+        }
+        return parts.join('|');
+    }
+
+    /* ============================================================ 文本型 */
+
+    /**
+     * 从本地文本文件读 uid。
+     *
+     * 两条容忍(都是为了让用户不用改自己的文件):
+     *   · 行首的 `@` 会被剥掉(有人习惯直接粘贴 "@name");
+     *   · `#` 开头的行当注释跳过。
+     */
+    function readTextSource(source) {
+        var list = TikTokAuto.uidset.readUidFile(source.path);
+        logger().info('idsource', '文本取材:' + source.path + ' 读到 ' + list.length + ' 行');
+        var rows = [];
+        for (var i = 0; i < list.length; i += 1) {
+            rows.push(rowOf(list[i], ''));
+        }
+        return rows;
+    }
+
+    /* ============================================================ 逐个点开型 */
+
+    /**
+     * 搜索结果里的视频 → 逐个点开,读播放页底部的作者 uid。
+     *
+     * 实测:搜索结果的视频卡片上只有**作者昵称**,没有 uid;
+     * 播放页底部有一个 `Button` 文案是 `@uid` —— 这是唯一能拿到 uid 的地方。
+     * 所以每个视频要"点开 → 读 → 退回",约 6 秒一个。
+     */
+    function walkSearchVideos(source, ctx) {
+        var log = logger();
+        var handled = 0;
+        var idle = 0;
+
+        for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
+            if (ctx.shouldStop()) {
+                break;
+            }
+            var cards = TikTokAuto.tiktok.videoCards();
+            if (cards.length === 0) {
+                log.info('idsource', '结果页没有卡片了');
+                break;
+            }
+
+            var opened = 0;
+            for (var i = 0; i < cards.length; i += 1) {
+                if (ctx.shouldStop()) {
+                    return handled;
+                }
+                var card = cards[i];
+                var marker = 'card:' + String(card.caption).slice(0, 40);
+                if (ctx.seen.has(marker)) {
+                    continue;
+                }
+                ctx.seen.add(marker);
+
+                if (!TikTokAuto.flow.openCard(ctx.config, card)) {
+                    log.warn('idsource', '打开视频失败:' + marker);
+                    TikTokAuto.flow.returnToResults(ctx.config);
+                    continue;
+                }
+                var uid = TikTokAuto.tiktok.playerAuthorUid();
+                if (uid) {
+                    ctx.onRow(rowOf(uid, String(card.caption).slice(0, 30)));
+                    handled += 1;
+                    opened += 1;
+                } else {
+                    log.warn('idsource', '播放页读不到作者 uid');
+                }
+                TikTokAuto.flow.returnToResults(ctx.config);
+            }
+
+            if (opened === 0) {
+                idle += 1;
+                if (idle >= IDLE_SCREENS_TO_STOP) {
+                    log.info('idsource', '连续几屏都读不到作者,结束');
+                    break;
+                }
+            } else {
+                idle = 0;
+            }
+            TikTokAuto.flow.scrollResults(ctx.config);
+        }
+        return handled;
+    }
+
+    /**
+     * 评论区 → 逐个点评论者的头像进用户页读 uid。
+     *
+     * 实测这条最贵:评论区只显示昵称,没有 uid(`Button title` 是昵称,
+     * 旁边那个 `@uid` 是固定节点、不是评论者的)。所以每个评论者都要
+     * "点头像 → 读 → 退回",约 8 秒一个。这也是为什么方案里
+     * 把"评论者"和"评论者的粉丝"分开成两条取材,而不是默认都开。
+     */
+    function walkCommenters(source, ctx) {
+        var log = logger();
+        var handled = 0;
+
+        for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
+            if (ctx.shouldStop()) {
+                break;
+            }
+            var rows = TikTokAuto.tiktok.commentRowers();
+            if (rows.length === 0) {
+                log.info('idsource', '评论区没有更多可见条目');
+                break;
+            }
+
+            var opened = 0;
+            var before = signatureOfNodes(rows);
+            for (var i = 0; i < rows.length; i += 1) {
+                if (ctx.shouldStop()) {
+                    return handled;
+                }
+                /* 头像节点每次都会重建,用"位置 + 序号"当标记 —— 换一屏位置就会变 */
+                var marker = 'cmt:' + rows[i].top + ':' + (screen * 100 + i);
+                if (ctx.seen.has(marker)) {
+                    continue;
+                }
+                ctx.seen.add(marker);
+
+                TikTokAuto.ui.tapPoint(rows[i].x, rows[i].y);
+                TikTokAuto.profile.settle(3000);
+                if (TikTokAuto.profile.where() !== 'profile') {
+                    log.warn('idsource', '点评论者头像没有进入用户主页');
+                    continue;
+                }
+                var profile = TikTokAuto.profile.readProfile();
+                if (profile.uid) {
+                    /* 资料已经在手上了,带上 `_profile` 让编排层不必再开一次用户页 */
+                    ctx.onRow(rowOf(profile.uid, profile.nickname, { _profile: profile }));
+                    handled += 1;
+                    opened += 1;
+                }
+                TikTokAuto.profile.backToCommentPanel();
+            }
+
+            if (opened === 0) {
+                break;
+            }
+            /* 评论区滚动 */
+            var layout = TikTokAuto.layout;
+            var screenSize = layout.screen();
+            TikTokAuto.ui.dragScreen(
+                Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.8),
+                Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.4),
+                420
+            );
+            TikTokAuto.profile.settle(2000);
+
+            if (signatureOfNodes(TikTokAuto.tiktok.commentRowers()) === before) {
+                log.info('idsource', '评论区滚动后内容不变,结束');
+                break;
+            }
+        }
+        return handled;
+    }
+
+    function signatureOfNodes(rows) {
+        var parts = [];
+        for (var i = 0; i < rows.length; i += 1) {
+            parts.push(rows[i].top);
+        }
+        return parts.join(',');
+    }
+
+    /* ============================================================ 入口 */
+
+    /**
+     * 把一个取材源跑完,逐行交给 `onRow`。
+     *
+     * @param source 取材项(与界面 sources.js 的形状一致)
+     * @param ctx    { config, seen, onRow(row), shouldStop() }
+     * @returns 处理的行数
+     */
+    function run(source, ctx) {
+        var log = logger();
+        log.step('idsource', '取材开始:' + describe(source));
+
+        if (source.kind === 'text') {
+            /* 文本源也走 `onRow`,**不在这一层自己判去重** ——
+             * 去重与过滤是编排层(idflow)的职责,在这里再做一遍会让
+             * `duplicates` 统计漏掉(实测:跨轮去重跳了 3 个,summary 里却是 0)。 */
+            var rows = readTextSource(source);
+            var handled = 0;
+            for (var i = 0; i < rows.length; i += 1) {
+                if (ctx.shouldStop()) {
+                    break;
+                }
+                ctx.onRow(rows[i]);
+                handled += 1;
+            }
+            return handled;
+        }
+
+        if (source.kind === 'screen') {
+            /* 当前屏幕:不做任何导航,直接把眼前这一屏当列表读 */
+            return walkList(ctx);
+        }
+
+        if (source.kind === 'anchor') {
+            return runAnchor(source, ctx);
+        }
+
+        if (source.kind === 'search') {
+            if (source.searchType === 'videos') {
+                if (!prepareSearch(source.keyword, ctx.config)) {
+                    return 0;
+                }
+                TikTokAuto.flow.selectResultTab({
+                    target: { resultTab: 'Top' },
+                    timeouts: ctx.config.timeouts,
+                    probe: ctx.config.probe
+                });
+                return walkSearchVideos(source, ctx);
+            }
+            if (!prepareSearch(source.keyword, ctx.config)) {
+                return 0;
+            }
+            return walkList(ctx);
+        }
+
+        if (source.kind === 'video') {
+            if (!prepareVideo(source, ctx.config)) {
+                return 0;
+            }
+            return walkCommenters(source, ctx);
+        }
+
+        log.warn('idsource', '未知的取材起点:' + source.kind);
+        return 0;
+    }
+
+    /**
+     * 指定账号:打开它的主页 → 按 `lists` 逐个开列表 → 每处走一遍 walkList。
+     *
+     * `anchors` 为空表示"不限定账号,从当前屏幕取" —— 这是界面允许的状态,
+     * 对应 v6 时代「主播 ID 留空」的语义。
+     */
+    function runAnchor(source, ctx) {
+        var log = logger();
+        var parse = TikTokAuto.parse;
+        var anchors = String(source.anchors || '').split('#');
+        var handled = 0;
+
+        var accounts = [];
+        for (var i = 0; i < anchors.length; i += 1) {
+            var name = parse.username(anchors[i]);
+            if (name !== '') {
+                accounts.push(name);
+            }
+        }
+
+        if (accounts.length === 0) {
+            log.info('idsource', '没有指定账号 → 直接把当前屏幕当列表');
+            return walkList(ctx);
+        }
+
+        for (var a = 0; a < accounts.length; a += 1) {
+            if (ctx.shouldStop()) {
+                break;
+            }
+            var profile = TikTokAuto.profile.openBySearch(accounts[a], ctx.config);
+            if (!profile) {
+                log.warn('idsource', '打不开账号 @' + accounts[a] + ',跳过');
+                continue;
+            }
+            ctx.onSeenProfile(profile);
+
+            var lists = source.lists && source.lists.length > 0 ? source.lists : ['fans'];
+            for (var l = 0; l < lists.length; l += 1) {
+                if (ctx.shouldStop()) {
+                    return handled;
+                }
+                var which = lists[l] === 'home' ? 'fans' : lists[l];
+                if (!TikTokAuto.profile.openList(which, ctx.config)) {
+                    log.warn('idsource', '打不开 ' + lists[l] + ' 列表');
+                    continue;
+                }
+                TikTokAuto.profile.switchListTab(which);
+                handled += walkList(ctx);
+                TikTokAuto.profile.backToProfile();
+            }
+        }
+        return handled;
+    }
+
+    /** 搜索关键词并停在结果页(供列表型与逐个点开型共用) */
+    function prepareSearch(keyword, config) {
+        var log = logger();
+        if (!TikTokAuto.profile.ensureFeed(config)) {
+            return false;
+        }
+        var tiktok = TikTokAuto.tiktok;
+        var entry = tiktok.searchEntry();
+        if (!entry) {
+            log.error('idsource', '首页找不到搜索入口');
+            return false;
+        }
+        TikTokAuto.ui.clickNode(entry, 'idsource:search');
+        if (TikTokAuto.wait.waitFor('idsource:search', function () {
+            var state = tiktok.idPageState();
+            return (state === 'search' || state === 'users-results') ? true : null;
+        }, config.timeouts.pageReadyMs, config.probe.pollTickMs) !== true) {
+            return false;
+        }
+
+        var input = tiktok.searchInput();
+        if (!input) {
+            return false;
+        }
+        TikTokAuto.ui.clickNode(input, 'idsource:input');
+        TikTokAuto.wait.tick(250);
+        if (!TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, 'idsource:type').ok) {
+            return false;
+        }
+        var submit = tiktok.searchSubmit();
+        if (!submit) {
+            return false;
+        }
+        TikTokAuto.ui.clickNode(submit, 'idsource:submit');
+
+        if (TikTokAuto.wait.waitFor('idsource:results', function () {
+            return tiktok.resultTabStripPresent() ? true : null;
+        }, config.timeouts.searchResultsMs, config.probe.pollTickMs) !== true) {
+            return false;
+        }
+
+        /* 用户搜索要切到 Users 页签;视频搜索留在 Top */
+        var usersTab = tiktok.resultTab('Users');
+        if (usersTab && !tiktok.resultTabSelected('Users')) {
+            TikTokAuto.ui.clickNode(usersTab, 'idsource:usersTab');
+            /* 等结果行真的出现(页签条先渲染、行后加载,只等"安静"会误判为空) */
+            TikTokAuto.wait.waitFor('idsource:userRows', function () {
+                return tiktok.usersResultRows().length > 0 ? true : null;
+            }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
+        }
+        return true;
+    }
+
+    /**
+     * 打开一个视频(播放页),供评论区取材用。
+     * `videoSource = "screen"` 表示用户自己已经把视频停在屏幕上,不动它。
+     */
+    function prepareVideo(source, config) {
+        var log = logger();
+        if (source.videoSource !== 'keyword') {
+            var state = TikTokAuto.tiktok.pageState();
+            if (state === 'player' || state === 'comment-panel') {
+                log.info('idsource', '已在播放页,直接用当前屏幕的视频');
+                return true;
+            }
+            log.warn('idsource', '取材设置为「当前屏幕」,但当前不在播放页(' + state + ')');
+            return false;
+        }
+
+        if (!prepareSearch(source.videoKeyword, config)) {
+            return false;
+        }
+        /* Top 页签是视频;取第一张卡点开 */
+        var cards = TikTokAuto.tiktok.videoCards();
+        if (cards.length === 0 && !TikTokAuto.flow.waitForCards(config)) {
+            return false;
+        }
+        cards = TikTokAuto.tiktok.videoCards();
+        if (cards.length === 0) {
+            return false;
+        }
+        if (!TikTokAuto.flow.openCard(config, cards[0])) {
+            return false;
+        }
+        /* 打开评论面板 */
+        var button = TikTokAuto.tiktok.commentButton();
+        if (!button) {
+            log.warn('idsource', '播放页找不到评论按钮');
+            return false;
+        }
+        TikTokAuto.ui.clickNode(button, 'idsource:comments');
+        return TikTokAuto.wait.waitFor('idsource:panel', function () {
+            return TikTokAuto.tiktok.commentPanelOpen() ? true : null;
+        }, config.timeouts.panelReadyMs, config.probe.pollTickMs) === true;
+    }
+
+    /** 一句人话摘要(日志与状态条用) */
+    function describe(source) {
+        var parse = TikTokAuto.parse;
+        if (source.kind === 'anchor') {
+            var list = source.anchors ? source.anchors.split('#')[0] : '当前屏幕';
+            return '账号 ' + list + ' 的 ' + (source.lists || []).join('+');
+        }
+        if (source.kind === 'search') {
+            return (source.searchType === 'videos' ? '视频作者' : '用户') + '「' + source.keyword + '」';
+        }
+        if (source.kind === 'video') {
+            return source.videoSource === 'keyword'
+                ? '评论区「' + source.videoKeyword + '」'
+                : '当前屏幕的评论区';
+        }
+        if (source.kind === 'text') {
+            return '文本 ' + String(source.path).split('/').pop();
+        }
+        return '当前屏幕';
+    }
+
+    return {
+        MAX_SCREENS: MAX_SCREENS,
+        run: run,
+        walkList: walkList,
+        readTextSource: readTextSource,
+        describe: describe
+    };
+}());

+ 23 - 3
tiktok-auto/src/main.js

@@ -40,6 +40,20 @@ TikTokAuto.main = (function () {
         return kept;
     }
 
+    /**
+     * 按 `config.task` 分派。
+     *
+     * 入口只有这一个分岔点:两条链路都走同一套"配置 → 守护线程 → 流程 → 终态上报",
+     * 差别只在中间调哪个 runSession。所以 `__END__`、RESULT、终态上报这些收尾逻辑
+     * **一份就够** —— 各写一份的话,两边的收尾行为迟早会不一致。
+     */
+    function runByTask(config) {
+        if (config.task === 'id') {
+            return TikTokAuto.idflow.runSession(config);
+        }
+        return TikTokAuto.flow.runSession(config);
+    }
+
     /**
      * @param injected PC 侧注入的配置覆盖项
      * @returns 结构化结果
@@ -65,7 +79,7 @@ TikTokAuto.main = (function () {
                 return summary;
             }
 
-            bridge.report({ phase: '启动应用', total: config.target.maxVideos });
+            bridge.report({ phase: '启动应用', total: config.task === 'id' ? 0 : config.target.maxVideos });
 
             guardState = TikTokAuto.popups.startGuard(config, log);
             var guardUp = TikTokAuto.wait.waitFor('guard', function () {
@@ -75,15 +89,21 @@ TikTokAuto.main = (function () {
                 log.warn('guard', '守护线程首次巡检未确认,继续执行(不阻塞主流程)');
             }
 
-            summary = TikTokAuto.flow.runSession(config);
+            summary = runByTask(config);
             log.step('main', '=== 流程结束,耗时 ' + log.elapsedMs() + 'ms ===');
         } catch (error) {
             if (bridge.isStopSignal(error)) {
                 /* 停止是**正常收尾**,不是崩溃:不记 ERROR、不把堆栈当异常信息 */
                 stoppedByUser = true;
                 log.warn('main', '收到停止请求,提前结束本次运行');
+                /* ID 提取的统计在 idflow 里已经算好了(含刚刷盘的那个数),
+                 * 这里取它 —— 否则界面会显示"提取 0 个",而文件里明明有内容。 */
+                var partial = TikTokAuto.idflow ? TikTokAuto.idflow.lastSummary() : null;
+                if (partial) {
+                    summary = partial;
+                }
                 summary.ok = false;
-                summary.errors = dropPlaceholder(summary.errors);
+                summary.errors = dropPlaceholder(summary.errors || []);
                 summary.errors.push('用户停止');
             } else {
                 var detail = error && error.stack ? String(error.stack) : String(error);

+ 266 - 0
tiktok-auto/src/parse.js

@@ -0,0 +1,266 @@
+/**
+ * parse.js — 文本 → 数值 / 语言 / 区间的**纯函数**解析
+ *
+ * ## 为什么单独一个模块
+ * 这三件事都是「界面上的设置真的会拦人」这一主张的证据链:
+ *   · 数量门槛要先把 "1.6M" 变成 1600000 才谈得上比较;
+ *   · 语言区域要靠昵称/个签里的字符构成来判;
+ *   · 区间判定要能表达"不限"和"读不到"这两种不同的"没有值"。
+ * 它们全部与界面无关,所以能 100% 离线自检 —— 这是整个 ID 提取里
+ * **唯一一处可以用断言证明"逻辑对了"** 的部分。
+ *
+ * ## 关于数字缩写(真机实测)
+ * TikTok 的计数一律是缩写形态,实测到的写法:
+ *   "594"、"2,491"、"1.6M"、"253.9M"、"245K"、"371.7K"
+ * 中文界面下会是 "1.2万" / "1.5亿"。所以后缀要同时认 K/M/B 与 万/亿。
+ *
+ * 依赖:无(纯函数,不取任何模块引用)
+ */
+TikTokAuto.parse = (function () {
+    'use strict';
+
+    /* ---------------------------------------------------------- 数字 */
+
+    /** 后缀倍数。键统一小写;`二`的两种写法(万/萬)都认。 */
+    var SUFFIX = {
+        '': 1,
+        'k': 1000,
+        'm': 1000000,
+        'b': 1000000000,
+        'w': 10000,          /* 万:中文界面 "1.2万" */
+        '万': 10000,
+        '萬': 10000,
+        '亿': 100000000,
+        '億': 100000000
+    };
+
+    /**
+     * 把一个计数字符串解析成整数。
+     *
+     * 返回 **null** 表示"读不到",与 **0** 严格区分:
+     * "0 followers" 是真实的值(一个粉丝都没有),"读不到"是另一回事 ——
+     * 数量门槛的判定要靠这个区别决定是"拦下"还是"放行"。
+     *
+     * @param raw 原始文本,如 "1.6M followers" / "2,491" / "1.2万"
+     * @returns {number|null}
+     */
+    function count(raw) {
+        if (raw === null || raw === undefined) {
+            return null;
+        }
+        /* 统一全角数字与全角逗号:某些语言/输入法会给出全角 */
+        var text = String(raw)
+            .replace(/[0-9]/g, function (ch) {
+                return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0);
+            })
+            .replace(/[,]/g, ',');
+
+        /* 抓「数字 + 可选小数 + 可选后缀」。前面的自由文本("1.6M followers")不影响。
+         * 后缀只认白名单里的字符,避免把 "594 followers" 的 f 当成后缀。 */
+        var matched = text.match(/(\d[\d,]*(?:\.\d+)?)\s*([kKmMbBwW万萬亿億]?)/);
+        if (!matched) {
+            return null;
+        }
+
+        var number = Number(String(matched[1]).replace(/,/g, ''));
+        if (isNaN(number)) {
+            return null;
+        }
+        var suffix = String(matched[2] || '').toLowerCase();
+        var factor = SUFFIX[suffix];
+        if (factor === undefined) {
+            /* 后缀不在白名单(例如 "594 followers" 的 f 已不会被捕获)→ 按无后缀处理 */
+            factor = 1;
+        }
+        return Math.round(number * factor);
+    }
+
+    /* ---------------------------------------------------------- 语言 */
+
+    /** CJK 汉字(含扩展 A 与兼容区)与假名 —— 判定"中文内容"用 */
+    var CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]/;
+    var LATIN = /[A-Za-z\u00c0-\u024f]/;   /* 含带音标的拉丁字母(西/法/德用户昵称很常见) */
+
+    /**
+     * 估算一段文本的语种。只返回三类,够用且不猜。
+     *
+     * 判定规则(不是拍脑袋,理由在下面):
+     *   · **只统计字母类字符**,忽略数字/标点/emoji —— 昵称里 emoji 占比极高,
+     *     算进分母会把"中文昵称 + 三个 emoji"判成"非中文"。
+     *   · 中文门槛 **30%**:中文用户常混英文单词("小美的Fashion"),
+     *     门槛设成 50% 会把这类人漏掉。
+     *   · 英文门槛 **70%**:反过来,英文用户极少混中文,
+     *     所以"仅英文"要更严格 —— 否则一个 "Fashion 小美" 会同时进两个桶。
+     *
+     * @returns {string} "zh" | "en" | "mixed" | "none"
+     */
+    function language(raw) {
+        var text = String(raw === null || raw === undefined ? '' : raw);
+        if (text === '') {
+            return 'none';
+        }
+
+        var cjk = 0;
+        var latin = 0;
+        for (var i = 0; i < text.length; i += 1) {
+            var ch = text.charAt(i);
+            if (CJK.test(ch)) {
+                cjk += 1;
+            } else if (LATIN.test(ch)) {
+                latin += 1;
+            }
+        }
+
+        var total = cjk + latin;
+        if (total === 0) {
+            return 'none';       /* 纯 emoji / 纯数字 / 阿拉伯文等 */
+        }
+        var zhRatio = cjk / total;
+        var enRatio = latin / total;
+
+        if (zhRatio >= 0.3) {
+            return 'zh';
+        }
+        if (enRatio >= 0.7) {
+            return 'en';
+        }
+        return 'mixed';
+    }
+
+    /**
+     * 语言是否匹配"仅中文 / 仅英文 / 通杀"。
+     *
+     * `mixed` 在两边都不算匹配 —— 说不清是哪一边的,就不该被任何一边选中。
+     * 但 `none`(纯 emoji 昵称)在"通杀"下通过:通杀本来就不筛。
+     */
+    function languageMatches(want, actual) {
+        if (!want || want === 'any') {
+            return true;
+        }
+        if (want === 'zh') {
+            return actual === 'zh';
+        }
+        if (want === 'en') {
+            return actual === 'en';
+        }
+        return true;
+    }
+
+    /* ---------------------------------------------------------- 区间 */
+
+    /**
+     * 区间判定。
+     *
+     * 三种状态必须分开表达,这是这个函数存在的理由:
+     *   · **不限**(min/max 都是 0)→ 永远通过,连值都不用读;
+     *   · **有值** → 落在 [min, max] 内(0 表示该侧不设限);
+     *   · **读不到**(value === null)→ **不通过**。
+     *
+     * 为什么读不到要拦:用户明确设了门槛,说明他在意这个条件;
+     * 读不到就无法证明符合,宁可少收并在日志里说清楚 ——
+     * 若这里放行,"过滤"就是形同虚设,而用户以为它生效了。
+     *
+     * @returns {boolean}
+     */
+    function inRange(value, min, max) {
+        var low = Number(min) || 0;
+        var high = Number(max) || 0;
+        if (low <= 0 && high <= 0) {
+            return true;
+        }
+        if (value === null || value === undefined) {
+            return false;
+        }
+        var number = Number(value);
+        if (isNaN(number)) {
+            return false;
+        }
+        if (low > 0 && number < low) {
+            return false;
+        }
+        if (high > 0 && number > high) {
+            return false;
+        }
+        return true;
+    }
+
+    /** 该区间是否"什么都没设"(用来判断要不要为此打开用户页) */
+    function isRangeOpen(min, max) {
+        return (Number(min) || 0) <= 0 && (Number(max) || 0) <= 0;
+    }
+
+    /* ---------------------------------------------------------- 用户名 */
+
+    /**
+     * 把 "@username" 归一成 "username"。
+     *
+     * 实测发现用户名文本被 **FID 隔离符** 包裹(U+2068 / U+2069):
+     * 显示为 `⁨ajman_fashion_week⁩`,肉眼完全看不出,但直接当文件名/比对键就会出错。
+     * `layout.sanitize` 处理了 U+200b-200f 等,**没有**处理 2068/2069(它们不是零宽字符,
+     * 是双向文本隔离符),所以这里单独剥一次。
+     *
+     * 同时剥掉全角 @ 与首尾空白。
+     */
+    function username(raw) {
+        if (raw === null || raw === undefined) {
+            return '';
+        }
+        return String(raw)
+            .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
+            .replace(/\s+/g, '')
+            .replace(/^[@@]+/, '');
+    }
+
+    /**
+     * 一个字符串是否像 TikTok 用户名(字母数字下划线点,长度 2~30)。
+     *
+     * 注意**不能**先走 username() —— 那个函数会去掉所有空白(清洗用途),
+     * 于是 "This is a caption" 会被压成 "Thisisacaption" 而"看起来像用户名"。
+     * 判定必须**保留原始空白**:真正的用户名里不会有空格。
+     */
+    function looksLikeUsername(raw) {
+        if (raw === null || raw === undefined) {
+            return false;
+        }
+        var name = String(raw)
+            .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
+            .replace(/^[\s@@]+|[\s]+$/g, '');
+        if (name.length < 2 || name.length > 30) {
+            return false;
+        }
+        if (/\s/.test(name)) {
+            return false;      /* 中间有空白 → 是文案,不是用户名 */
+        }
+        return /^[A-Za-z0-9_.]+$/.test(name);
+    }
+
+    /* ---------------------------------------------------------- 文件名 */
+
+    /** 两位补零 */
+    function pad2(value) {
+        return value < 10 ? '0' + value : String(value);
+    }
+
+    /**
+     * 结果文件名:`前缀_YYYYMMDD_HHmm.txt`
+     *
+     * 方案 §6.1 是对规格书的有意识偏离:规格书只有日期(`uid_20261013.txt`),
+     * 同一天跑两次会互相覆盖 —— 而"同一天调两次参数"恰恰是最常见的用法。
+     */
+    function timestampName(prefix, date) {
+        var d = date || new Date();
+        return prefix + '_' + d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) +
+            '_' + pad2(d.getHours()) + pad2(d.getMinutes()) + '.txt';
+    }
+
+    return {
+        count: count,
+        language: language,
+        languageMatches: languageMatches,
+        inRange: inRange,
+        isRangeOpen: isRangeOpen,
+        username: username,
+        looksLikeUsername: looksLikeUsername,
+        timestampName: timestampName
+    };
+}());

+ 625 - 0
tiktok-auto/src/profile.js

@@ -0,0 +1,625 @@
+/**
+ * profile.js — 在 TikTok 里"走到某个账号的主页并读出来"
+ *
+ * ## 这一层管什么
+ * 只管**导航**与**读取**:打开搜索、进用户主页、点开粉丝列表、翻一屏、退回去。
+ * 它不判断"要不要这个人"(那是 filters.js),也不管"从哪来"(idsource.js)。
+ *
+ * ## 真机实测出来的三条导航事实(2026-10-20 / TikTok 47.1.4)
+ * 1. **用户主页 → 粉丝列表是一张覆盖页**,顶部有三个页签(Following / Followers / Suggested),
+ *    可以直接在页签间切换 —— 所以"先收粉丝再收关注"不必退回主页再进。
+ * 2. **从列表进用户页再返回,列表位置会保持**(实测首行与坐标都不变)。
+ *    这条决定了翻页策略:可以"读一屏 → 逐个进 → 退回来接着读下一屏",
+ *    不必先把整屏 uid 存起来再统一处理。
+ * 3. **列表行里就有 uid**(`txt_desc`),不必为了拿 uid 去开用户页。
+ *    用户页只在**需要更多资料**(数量、语言、私密、头像)时才开 —— 差一个数量级的时间。
+ *
+ * ## 头像为什么要截屏
+ * 头像是 ImageView,**没有 text、没有 desc、不可点**,无障碍树里读不到任何线索。
+ * 实测对比:默认头像(灰色人形剪影)高饱和像素占比 0.035,真头像 0.472 ——
+ * 分离度足够,所以用像素法。**已知局限:纯黑白照片的头像会被误判成"无头像"**,
+ * 这一点会如实写进 README 与界面说明,不假装没有。
+ *
+ * 依赖:TikTokAuto.layout / tiktok / ui / wait / logger / parse
+ */
+TikTokAuto.profile = (function () {
+    'use strict';
+
+    /** 头像采样网格:5x5。25 个点足够区分剪影与照片,又不会让截图开销变成瓶颈。 */
+    var AVATAR_GRID = 5;
+
+    /** 高饱和像素占比超过它就认为"有头像"。实测默认头像 0.035 / 真头像 0.472。 */
+    var AVATAR_SATURATED_RATIO = 0.12;
+
+    function logger() { return TikTokAuto.logger; }
+
+    /** 等页面安静下来(签名连续几次不变),比固定 sleep 可靠 */
+    function settle(maxMs) {
+        TikTokAuto.wait.waitQuiet(function () {
+            return TikTokAuto.layout.signature();
+        }, { maxMs: maxMs || 2500, tickMs: 200, stableTimes: 2 });
+    }
+
+    // ------------------------------------------------------------ 读取主页
+
+    /**
+     * 把当前用户主页读成一份资料。
+     *
+     * 读不到的字段一律给 **null**(而不是 0 或空串):
+     * "读不到"与"就是 0"在过滤判定里含义完全不同(见 filters.js 文件头)。
+     *
+     * @returns { uid, nickname, signature, follow, fans, likes, isPrivate, hasAvatar, gridCount }
+     */
+    function readProfile() {
+        var tiktok = TikTokAuto.tiktok;
+        var parse = TikTokAuto.parse;
+
+        var userNode = tiktok.profileUsernameNode();
+        var uid = userNode ? parse.username(TikTokAuto.layout.text(userNode)) : '';
+
+        var nicknameNode = tiktok.profileNicknameNode();
+        var signatureNode = tiktok.profileSignatureNode();
+        var stats = readStatsStable();
+        var hint = tiktok.privateHint();
+        var grid = tiktok.videoGridCount();
+
+        return {
+            uid: uid,
+            nickname: nicknameNode ? TikTokAuto.layout.text(nicknameNode) : '',
+            signature: signatureNode ? TikTokAuto.layout.text(signatureNode) : '',
+            follow: stats.follow,
+            fans: stats.fans,
+            likes: stats.likes,
+            /* 私密:读到了提示文案才算私密;读不到就是"看起来是公开的"。
+             * 反过来判("没读到就是私密")会把所有正常账号都排除掉。 */
+            isPrivate: hint !== '' && grid < 3,
+            privateHint: hint,
+            /* 头像:null = 判定不可用(截屏没授权 / 找不到头像节点) */
+            hasAvatar: readAvatarState(),
+            gridCount: grid
+        };
+    }
+
+    /**
+     * 读三组数字,直到**连续两次一致**才采信。
+     *
+     * ★ 这是实测踩出来的:点开用户主页后,页面会先渲染骨架、再把数字填进去,
+     *   中间那一小段时间读到的数字是**占位的 0**。
+     *   同一个账号(@user3670597276145)两次运行分别读到 78 与 0,
+     *   而 78 才是真的 —— 如果拿 0 去比"粉丝量 ≥ 1000",它就被冤枉地跳过了。
+     *   页面"安静"不代表数字已经就位,只有"两次读到一样"才是可靠的信号。
+     */
+    function readStatsStable() {
+        var tiktok = TikTokAuto.tiktok;
+        TikTokAuto.wait.tick(350);
+        var previous = tiktok.statNumbers();
+
+        for (var i = 0; i < 3; i += 1) {
+            TikTokAuto.wait.tick(350);
+            var current = tiktok.statNumbers();
+            if (current.follow === previous.follow && current.fans === previous.fans &&
+                current.likes === previous.likes) {
+                return current;
+            }
+            previous = current;
+        }
+        logger().warn('profile', '三组数字连续三次都没稳定,采用最后一次的值(可能是中间态)');
+        return previous;
+    }
+
+    /**
+     * 头像是默认剪影吗?
+     *
+     * @returns true(有头像)/ false(默认剪影)/ null(判不了)
+     */
+    function readAvatarState() {
+        var node = avatarNode();
+        if (!node) {
+            return null;
+        }
+        var box = node.bounds();
+        var saturated = 0;
+        var total = 0;
+
+        var image = null;
+        try {
+            image = captureScreen();
+        } catch (error) {
+            /* Android 14 上截屏要 MediaProjection 授权。拿不到不是"没头像",
+             * 是"这次判不了" —— 必须返回 null,由上层如实告知。 */
+            logger().info('profile', '头像判定不可用(无法截屏):' + error);
+            return null;
+        }
+        if (!image) {
+            return null;
+        }
+
+        var stepX = (box.right - box.left) / (AVATAR_GRID + 1);
+        var stepY = (box.bottom - box.top) / (AVATAR_GRID + 1);
+        for (var row = 1; row <= AVATAR_GRID; row += 1) {
+            for (var col = 1; col <= AVATAR_GRID; col += 1) {
+                var x = Math.round(box.left + stepX * col);
+                var y = Math.round(box.top + stepY * row);
+                var pixel = 0;
+                try {
+                    pixel = image.pixel(x, y);
+                } catch (error2) {
+                    continue;
+                }
+                total += 1;
+                if (isSaturated(pixel)) {
+                    saturated += 1;
+                }
+            }
+        }
+
+        if (total === 0) {
+            return null;
+        }
+        var ratio = saturated / total;
+        logger().info('profile', '头像像素判定:高饱和 ' + saturated + '/' + total +
+            ' = ' + ratio.toFixed(3) + '(阈值 ' + AVATAR_SATURATED_RATIO + ')');
+        return ratio >= AVATAR_SATURATED_RATIO;
+    }
+
+    /** ARGB int → 饱和度是否够高。s = (max-min)/max,避开明亮的白色背景。 */
+    function isSaturated(pixel) {
+        var r = (pixel >> 16) & 0xff;
+        var g = (pixel >> 8) & 0xff;
+        var b = pixel & 0xff;
+        var max = Math.max(r, Math.max(g, b));
+        var min = Math.min(r, Math.min(g, b));
+        if (max < 40) {
+            return false;        /* 太暗:黑边/阴影,不算色彩 */
+        }
+        return (max - min) / max >= 0.25;
+    }
+
+    /**
+     * 用户主页的头像节点。
+     * 判据:可见 ImageView、尺寸 140~280px、位于页面上部 1/3。
+     * 实测新版布局在右上角([856,168][1048,360]),老布局可能在左上,所以两侧都认。
+     */
+    function avatarNode() {
+        var layout = TikTokAuto.layout;
+        var best = null;
+        var bestArea = 0;
+        var collection = className('android.widget.ImageView').find();
+        for (var i = 0; i < collection.size(); i += 1) {
+            var node = collection.get(i);
+            if (!layout.isVisible(node)) {
+                continue;
+            }
+            var box = node.bounds();
+            var width = box.right - box.left;
+            var height = box.bottom - box.top;
+            if (width < 140 || width > 280 || height < 140 || height > 280) {
+                continue;
+            }
+            if (box.top > device.height * 0.33) {
+                continue;
+            }
+            /* 取面积最大的那个:页面上部可能还有别的图标,头像一定是最大的 */
+            if (width * height > bestArea) {
+                bestArea = width * height;
+                best = node;
+            }
+        }
+        return best;
+    }
+
+    // ------------------------------------------------------------ 导航
+
+    /** 当前在哪个页面(ID 提取这条链路自己的判据) */
+    function where() {
+        return TikTokAuto.tiktok.idPageState();
+    }
+
+    /**
+     * 打开一个账号的主页(走搜索)。
+     *
+     * 为什么不直接拼 URL 打开:TikTok 的 deeplink 需要签名参数,
+     * 而且"用户在用的这个 TikTok 版本"才是唯一可靠的入口。
+     * 走搜索虽然慢(约 8 秒),但实测稳定。
+     *
+     * @returns 资料对象;失败返回 null
+     */
+    function openBySearch(uid, config) {
+        var step = 'openProfile';
+        var tiktok = TikTokAuto.tiktok;
+        var log = logger();
+        var parse = TikTokAuto.parse;
+        var wanted = parse.username(uid);
+
+        if (wanted === '') {
+            return null;
+        }
+
+        log.step(step, '搜索并打开 @' + wanted);
+        if (!ensureFeed(config)) {
+            return null;
+        }
+        if (!openSearch(config)) {
+            return null;
+        }
+        if (!submit(wanted, config)) {
+            return null;
+        }
+
+        /* 切到 Users 页签:不切的话看到的是视频,找不到人 */
+        var usersTab = tiktok.resultTab('Users');
+        if (usersTab) {
+            TikTokAuto.ui.clickNode(usersTab, step + ':usersTab');
+            /* ★ 等**结果行真的出现**,不能只等"页面安静":
+             * 页签条一渲染出来签名就稳定了,而结果列表还要联网加载 1~2 秒。
+             * 这里如果不等,就会稳定地误报"搜索结果里找不到该账号"(实测踩到)。 */
+            TikTokAuto.wait.waitFor(step + ':rows', function () {
+                return tiktok.usersResultRows().length > 0 ? true : null;
+            }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
+        }
+
+        var row = findRowByUid(wanted);
+        if (!row) {
+            log.warn(step, '搜索结果里找不到 @' + wanted +
+                '(当前可见 ' + tiktok.usersResultRows().length + ' 行)');
+            return null;
+        }
+        tapRow(row, step + ':row');
+        var arrived = waitProfile(step);
+
+        if (!arrived) {
+            /* 实测:`node.click()` 打在整行容器上会落在**中心**,
+             * 而搜索用户行的中心那块是空白(有时还是 Follow 按钮的邻域)——
+             * 点了返回 true 却没进主页。左侧 30% 处(用户名文字上)才稳定。
+             * 所以这里再补一次"按坐标点",只重试一次,不做无限重试。 */
+            log.info(step, '第一次点击没进主页,改用行内坐标重试');
+            tapRow(row, step + ':row-retry');
+            arrived = waitProfile(step + ':retry');
+        }
+        if (!arrived) {
+            log.warn(step, '点了搜索结果但没有进入用户主页(当前 ' + where() + ')');
+            return null;
+        }
+        /* 主页渲染完成后,数字区可能还差一帧。读之前再确认一次"三组数字里至少有一个读到了",
+         * 否则会把"还没渲染"当成"这个账号没有数据"。最多再等 3 秒。 */
+        TikTokAuto.wait.waitFor(step + ':stats', function () {
+            var stats = tiktok.statNumbers();
+            return (stats.follow !== null || stats.fans !== null || stats.likes !== null) ? true : null;
+        }, 3000, 200);
+        return readProfile();
+    }
+
+    /** 搜索页 → 输入 → 提交。复用 flow 里已经验证过的锚点,但关键词由这里给。 */
+    function openSearch(config) {
+        var step = 'openSearch';
+        var entry = TikTokAuto.tiktok.searchEntry();
+        if (!entry) {
+            logger().error(step, '首页找不到搜索入口');
+            return false;
+        }
+        TikTokAuto.ui.clickNode(entry, step);
+        return TikTokAuto.wait.waitFor(step, function () {
+            var state = TikTokAuto.tiktok.idPageState();
+            return (state === 'search' || state === 'users-results') ? true : null;
+        }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
+    }
+
+    function submit(keyword, config) {
+        var step = 'submitKeyword';
+        var tiktok = TikTokAuto.tiktok;
+
+        var input = tiktok.searchInput();
+        if (!input) {
+            logger().error(step, '找不到搜索输入框');
+            return false;
+        }
+        TikTokAuto.ui.clickNode(input, step);
+        TikTokAuto.wait.tick(250);
+
+        var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, step);
+        if (!typed.ok) {
+            return false;
+        }
+        var button = tiktok.searchSubmit();
+        if (!button) {
+            logger().error(step, '找不到搜索提交按钮');
+            return false;
+        }
+        TikTokAuto.ui.clickNode(button, step);
+
+        return TikTokAuto.wait.waitFor(step, function () {
+            var state = tiktok.idPageState();
+            return (state === 'users-results' || TikTokAuto.tiktok.resultTabStripPresent()) ? true : null;
+        }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
+    }
+
+    /** 在当前搜索结果里按 uid 找行(找不到返回 null,不猜第一行) */
+    function findRowByUid(uid) {
+        var rows = TikTokAuto.tiktok.usersResultRows();
+        for (var i = 0; i < rows.length; i += 1) {
+            if (rows[i].uid === uid) {
+                return rows[i];
+            }
+        }
+        return null;
+    }
+
+    /** 保证停在信息流首页(复用 flow 的成熟实现,避免两套退出逻辑) */
+    function ensureFeed(config) {
+        return TikTokAuto.flow.ensureFeed(config) === true;
+    }
+
+    /**
+     * 在用户主页上打开「粉丝/关注」列表。
+     *
+     * ★ 这里**不能**用 `settle()` 判就绪:点完之后列表要联网加载,
+     * 而 `waitQuiet` 的判据是"可见文案连续两次不变" —— 加载中的列表和
+     * 打开前的页面**签名是一样的**,于是它会立刻就返回(实测 872ms 就回来了),
+     * 然后被判成"打不开列表"。正确做法是等**列表真的有行**。
+     *
+     * @param which 'fans' | 'following'
+     */
+    function openList(which, config) {
+        var step = 'openList';
+        var tiktok = TikTokAuto.tiktok;
+        var entry = tiktok.statEntry(which === 'following' ? 'following' : 'fans');
+        if (!entry) {
+            logger().warn(step, '用户主页上找不到 ' + which + ' 入口');
+            return false;
+        }
+        logger().step(step, '打开列表:' + which);
+        TikTokAuto.ui.clickNode(entry, step);
+
+        var ready = TikTokAuto.wait.waitFor(step, function () {
+            return where() === 'user-list' ? true : null;
+        }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
+
+        if (ready === true) {
+            return true;
+        }
+
+        /* 与粉丝列表相关的一个真机实况:首次打开会弹「给 TikTok 访问你的 Facebook 好友列表」
+         * 的授权窗,盖住整个列表。它不是错误页,但会让"列表就绪"永远等不到。
+         * 这里点掉它再等一次 —— 只做一轮,不无限重试。 */
+        if (dismissFriendPrompt()) {
+            logger().info(step, '点掉了好友列表授权窗,再等一次');
+            ready = TikTokAuto.wait.waitFor(step + ':retry', function () {
+                return where() === 'user-list' ? true : null;
+            }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
+        }
+        return ready === true;
+    }
+
+    /**
+     * 点掉「Facebook 好友列表」授权窗的"不允许"。
+     *
+     * 实测这个弹窗的文案是英文 `Don’t allow`(撇号是 U+2019,不是 ASCII 单引号,
+     * 直接比对 'Don\'t allow' 是匹配不上的 —— 这里按**包含**比对并同时认几种写法)。
+     * 找不到就当没有弹窗,返回 false,由上层决定下一步。
+     */
+    function dismissFriendPrompt() {
+        var layout = TikTokAuto.layout;
+        var keywords = ['Facebook', '好友列表', 'friends list'];
+        var buttons = className('android.widget.Button').find();
+        var hint = false;
+
+        for (var i = 0; i < buttons.size(); i += 1) {
+            var text = layout.text(buttons.get(i));
+            if (text === '') {
+                continue;
+            }
+            if (text.indexOf('allow') >= 0 || text.indexOf('允许') >= 0 || text.indexOf('允許') >= 0) {
+                hint = true;
+                continue;
+            }
+            for (var k = 0; k < keywords.length; k += 1) {
+                if (text.indexOf(keywords[k]) >= 0) {
+                    hint = true;
+                    break;
+                }
+            }
+        }
+        if (!hint) {
+            return false;
+        }
+
+        /* 有授权窗:找那个"不允许 / Don't allow",它一定比"OK"靠左(实测 [260,1114][539,1209] vs [540,1114][819,1209]) */
+        var target = null;
+        for (var j = 0; j < buttons.size(); j += 1) {
+            var label = layout.text(buttons.get(j));
+            if (/don.t allow|不允许|不允許|稍后|稍後|not now|no thanks/i.test(label)) {
+                if (target === null || buttons.get(j).bounds().left < target.bounds().left) {
+                    target = buttons.get(j);
+                }
+            }
+        }
+        if (!target) {
+            return false;
+        }
+        logger().info('profile', '点掉授权窗:' + JSON.stringify(TikTokAuto.layout.text(target)));
+        TikTokAuto.ui.clickNode(target, 'dismissFriendPrompt');
+        settle(2000);
+        return true;
+    }
+
+    /**
+     * 在列表页切页签。已在目标页签上时直接返回 true(不重复点)。
+     * @param which 'fans' | 'following'
+     */
+    function switchListTab(which) {
+        var step = 'switchTab';
+        var tabs = TikTokAuto.tiktok.userListTabs();
+        var wanted = which === 'following' ? 'following' : 'fans';
+
+        for (var i = 0; i < tabs.length; i += 1) {
+            if (tabs[i].key !== wanted) {
+                continue;
+            }
+            if (TikTokAuto.tiktok.selectedListTab() === wanted) {
+                logger().info(step, '已在 ' + wanted + ' 页签上');
+                return true;
+            }
+            logger().info(step, '切到 ' + wanted + ' 页签(' + tabs[i].label + ')');
+            TikTokAuto.ui.clickNode(tabs[i].node, step);
+            settle(3000);
+            return true;
+        }
+        logger().warn(step, '找不到 ' + wanted + ' 页签');
+        return false;
+    }
+
+    /** 列表当前可见的每一行(已按屏幕从上到下排序) */
+    function listRows() {
+        return TikTokAuto.tiktok.userListRows();
+    }
+
+    /**
+     * 列表向下翻一屏。
+     *
+     * 起点刻意选在屏幕中部偏下、终点在中部偏上:太靠下会落到 Follow 按钮上
+     * (按钮吃掉手势,页面纹丝不动 —— 这个坑在界面上踩过一次,这里避开)。
+     *
+     * @returns 翻之前那一屏的 uid 签名,供调用方判断"翻完有没有新东西"
+     */
+    function scrollList() {
+        var layout = TikTokAuto.layout;
+        var screen = layout.screen();
+        TikTokAuto.ui.dragScreen(
+            Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.78),
+            Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.3),
+            420
+        );
+        settle(2500);
+    }
+
+    /** 点列表里的一行,进入该用户主页 */
+    function openRow(row) {
+        var step = 'openRow';
+        if (!row) {
+            return false;
+        }
+        logger().info(step, '点开 @' + row.uid);
+        tapRow(row, step);
+        return waitProfile(step);
+    }
+
+    /**
+     * 等用户主页**真的渲染出来**。
+     *
+     * ★ 不能只等 `settle()`(页面安静):点开主页后骨架先出现、`@username`
+     *   那几个字后出现,中间页面是"安静的" —— 于是 settle 会提前返回,
+     *   紧接着 readProfile() 读到一份**空资料**。
+     *   实测后果:一个粉丝数为 0 的账号被当成"读不到资料",或被误判过滤。
+     *   判据必须是"@username 节点已经存在"。
+     */
+    function waitProfile(step) {
+        return TikTokAuto.wait.waitFor(step + ':profile', function () {
+            return TikTokAuto.tiktok.profileUsernameNode() !== null ? true : null;
+        }, 10000, 300) === true;
+    }
+
+    /**
+     * 点一行用户条目。
+     *
+     * 用**坐标**而不是 `node.click()`:整行容器的 click() 在真机上常常落到
+     * "行内第一个可点元素"(也就是右侧那个 Follow 按钮),会把用户关注掉 ——
+     * 这是不能接受的副作用。所以点在行左侧 30% 处(用户名文字上),远离按钮。
+     * 实测这一处既能进主页,也不会误触关注。
+     */
+    function tapRow(row, step) {
+        if (row.x === undefined || row.y === undefined) {
+            TikTokAuto.ui.clickNode(row.node, step || 'tapRow');
+            return;
+        }
+        TikTokAuto.ui.tapPoint(row.x, row.y);
+    }
+
+    /** 从用户主页退回列表(列表位置会保持,实测) */
+    function backToList() {
+        var step = 'backToList';
+        var deadline = Date.now() + 8000;
+        while (Date.now() < deadline) {
+            var state = where();
+            if (state === 'user-list') {
+                return true;
+            }
+            if (state === 'profile' || state === 'comment-panel' || state === 'users-results') {
+                TikTokAuto.ui.goBack(step);
+                settle(2000);
+                continue;
+            }
+            /* 其它页面(搜索/结果/信息流):已经退过头了 */
+            return false;
+        }
+        return where() === 'user-list';
+    }
+
+    /** 从列表退回用户主页 */
+    function backToProfile() {
+        var step = 'backToProfile';
+        var deadline = Date.now() + 8000;
+        while (Date.now() < deadline) {
+            var state = where();
+            if (state === 'profile') {
+                return true;
+            }
+            TikTokAuto.ui.goBack(step);
+            settle(2000);
+        }
+        return where() === 'profile';
+    }
+
+    /**
+     * 从用户主页退回评论区。
+     *
+     * 与 backToProfile/backToList 是一组:评论区取材时每个评论者都要
+     * "点头像 → 读 → 退回来",退错一层(退到播放页而不是评论区)就会
+     * 让下一轮找不到评论者。所以这里按状态确认,不靠数次数。
+     */
+    function backToCommentPanel() {
+        var step = 'backToComment';
+        var deadline = Date.now() + 10000;
+        while (Date.now() < deadline) {
+            var state = where();
+            if (state === 'comment-panel') {
+                return true;
+            }
+            if (state === 'player') {
+                /* 退到了播放页:把评论面板重新点开,而不是继续退 */
+                var button = TikTokAuto.tiktok.commentButton();
+                if (button) {
+                    TikTokAuto.ui.clickNode(button, step + ':reopen');
+                    settle(2500);
+                    if (where() === 'comment-panel') {
+                        return true;
+                    }
+                }
+                return false;
+            }
+            TikTokAuto.ui.goBack(step);
+            settle(2000);
+        }
+        return where() === 'comment-panel';
+    }
+
+    return {
+        readProfile: readProfile,
+        readAvatarState: readAvatarState,
+        avatarNode: avatarNode,
+        where: where,
+        ensureFeed: ensureFeed,
+        openBySearch: openBySearch,
+        findRowByUid: findRowByUid,
+        openList: openList,
+        switchListTab: switchListTab,
+        listRows: listRows,
+        scrollList: scrollList,
+        openRow: openRow,
+        backToList: backToList,
+        backToProfile: backToProfile,
+        backToCommentPanel: backToCommentPanel,
+        settle: settle,
+        dismissFriendPrompt: dismissFriendPrompt,
+        AVATAR_SATURATED_RATIO: AVATAR_SATURATED_RATIO
+    };
+}());

+ 536 - 1
tiktok-auto/src/tiktok.js

@@ -416,6 +416,524 @@ TikTokAuto.tiktok = (function () {
         return texts;
     }
 
+    // ============================================================ ID 提取
+    /*
+     * 下面这一整段是 2026-10-20 在真机上逐页 dump 出来的锚点
+     * (Pixel 5a / 1080x1920 / TikTok 47.1.4 / 英文界面)。**每条都是实测值**,
+     * 不是猜的 —— 猜出来的选择器在这个项目里从来没有一次是对的。
+     *
+     * ★ 一个重要发现:这一片的**资源 id 是语义化的**(`tv_username` / `txt_desc` /
+     *   `user_avatar`),与信息流那批混淆 id(`e4g`/`cn6`)完全不同。
+     *   原因是这些页面来自 TikTok 的搜索/社交业务模块,用的是另一套布局。
+     *   所以这里优先按 id 定位,文案只作为兜底(改版时 id 比文案更稳)。
+     *
+     * 实测到的三处文本陷阱:
+     *   ① 用户名被 **FID 隔离符** 包裹:显示 `⁨ajman_fashion_week⁩`(U+2068/U+2069),
+     *      肉眼看不见,`layout.sanitize` 也不剥(那不是零宽字符)→ 统一走 `parse.username`;
+     *   ② 搜索用户列表里 **`tv_aweme_id` 装的是昵称**(id 名违反直觉),`tv_username` 才是 uid;
+     *   ③ 粉丝列表与搜索列表的同义控件**id 不同**:
+     *      搜索列表 `tv_username`/`tv_desc`,粉丝列表 `txt_desc`(uid)/`txt_user_name`(昵称)。
+     */
+
+    /** 是否在用户主页:有一个文案形如 "@xxx" 的可点 Button,且下面跟着三组数字 */
+    function profileUsernameNode() {
+        var hits = findByLabel(className('android.widget.Button'), function (node) {
+            var value = layout.text(node);
+            return value.charAt(0) === '@' && value.length > 1 &&
+                value.length < 40 && topOf(node) < screenHeight() * 0.3;
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /** 用户主页的昵称(在 @xxx 上面那一行) */
+    function profileNicknameNode() {
+        var userNode = profileUsernameNode();
+        if (!userNode) {
+            return null;
+        }
+        var userTop = topOf(userNode);
+        var hits = findByLabel(className('android.widget.Button'), function (node) {
+            var box = node.bounds();
+            return box.top < userTop && box.bottom > userTop - screenHeight() * 0.12 &&
+                layout.text(node) !== '' && layout.text(node).charAt(0) !== '@';
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /** 用户页顶部三组数字的标签文案。英文界面实测为这三个词。 */
+    var STAT_LABELS = {
+        'Following': 'follow',
+        'Followers': 'fans',
+        'Likes': 'likes',
+        '关注': 'follow',
+        '粉丝': 'fans',
+        '获赞': 'likes',
+        'Abonnements': 'follow',
+        'Abonnés': 'fans',
+        "J'aime": 'likes'
+    };
+
+    /**
+     * 用户页顶部三组数字:`{ follow: 343, fans: 594, likes: 2491 }`。
+     *
+     * 结构(实测):每个数字是一个 LinearLayout,里面两个 TextView ——
+     * 数字在上、标签在下。所以按"标签找容器、再取容器里的数字"读。
+     * 注意**没有视频数**:这个页面只显示 关注/粉丝/获赞 三组,这是平台行为。
+     */
+    function statNumbers() {
+        var out = { follow: null, fans: null, likes: null };
+        var parse = TikTokAuto.parse;
+        var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
+
+        for (var i = 0; i < labels.length; i += 1) {
+            var labelText = layout.text(labels[i]);
+            var key = STAT_LABELS[labelText];
+            if (!key) {
+                continue;
+            }
+            /* 标签必须在页面上部(三组数字区),否则可能是别处长文本里恰好含这个词 */
+            if (topOf(labels[i]) > screenHeight() * 0.25) {
+                continue;
+            }
+            var container = parentOf(labels[i]);
+            var value = container ? numberInside(container) : null;
+            if (value === null) {
+                value = parse.count(labelText);
+            }
+            if (out[key] === null) {
+                out[key] = value;
+            }
+        }
+        return out;
+    }
+
+    function parentOf(node) {
+        try {
+            return node.parent();
+        } catch (ignored) {
+            return null;
+        }
+    }
+
+    /** 在一个容器里找那个"纯数字"的 TextView,解析成整数 */
+    function numberInside(container) {
+        var parse = TikTokAuto.parse;
+        var found = null;
+        var walk = [container];
+        var guard = 0;
+        while (walk.length > 0 && guard < 30) {
+            guard += 1;
+            var node = walk.shift();
+            if (!node) {
+                continue;
+            }
+            var value = layout.text(node);
+            if (value !== '' && /^[\d,.\s]+[kKmMbBwW万萬亿億]?$/.test(value)) {
+                var parsed = parse.count(value);
+                if (parsed !== null && (found === null || parsed > found)) {
+                    found = parsed;
+                }
+            }
+            var count = 0;
+            try { count = node.childCount(); } catch (ignored) { count = 0; }
+            for (var i = 0; i < count; i += 1) {
+                try { walk.push(node.child(i)); } catch (ignoredToo) { /* 遍历中失效就跳过 */ }
+            }
+        }
+        return found;
+    }
+
+    /** 用户主页的个签:页面中上部一段较长的可点文本(实测是个 Button,不是 TextView) */
+    function profileSignatureNode() {
+        var hits = findByLabel(className('android.widget.Button'), function (node) {
+            var value = layout.text(node);
+            var box = node.bounds();
+            return value.length >= 4 && value.charAt(0) !== '@' &&
+                box.top > screenHeight() * 0.22 && box.top < screenHeight() * 0.34 &&
+                (box.right - box.left) > screenWidth() * 0.4;
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /**
+     * 私密账号提示。
+     * 实测:私密账号的主页会在视频区位置显示一行提示("This account is private" 等)。
+     * 这里只在**视频网格明显不存在**时才认这条,避免把正常账号的某条视频文案误判成提示。
+     */
+    function privateHint() {
+        var keys = ['private', "isn't available", 'no videos', 'No videos'];
+        var texts = visibleTexts(0);
+        for (var i = 0; i < texts.length; i += 1) {
+            for (var j = 0; j < keys.length; j += 1) {
+                if (String(texts[i]).toLowerCase().indexOf(String(keys[j]).toLowerCase()) >= 0) {
+                    return texts[i];
+                }
+            }
+        }
+        return '';
+    }
+
+    /** 用户主页的视频网格格子数(判断"有没有内容"用,不用于视频量门槛) */
+    function videoGridCount() {
+        var hits = findByLabel(className('android.widget.FrameLayout'), function (node) {
+            var point = layout.center(node);
+            return point !== null && point.y > screenHeight() * 0.33;
+        });
+        return hits.length;
+    }
+
+    /** 用户主页上的「关注/粉丝」入口(点它打开列表页) */
+    function statEntry(which) {
+        var wanted = which === 'following' ? 'follow' : 'fans';
+        var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
+        for (var i = 0; i < labels.length; i += 1) {
+            if (STAT_LABELS[layout.text(labels[i])] !== wanted) {
+                continue;
+            }
+            if (topOf(labels[i]) > screenHeight() * 0.25) {
+                continue;
+            }
+            /* 可点的是外层容器,标签自己不可点 */
+            return layout.clickableAncestor(labels[i]) || parentOf(labels[i]);
+        }
+        return null;
+    }
+
+    /**
+     * 粉丝/关注列表页的页签("Following 343" / "Followers 594" / "Suggested")。
+     *
+     * 实测这个列表是**从用户页点进来的一张覆盖页**,顶部有三个页签,
+     * 可以直接在两个页签之间切换 —— 所以"先收粉丝再收关注"不必来回退。
+     */
+    function userListTabs() {
+        var out = [];
+        var hits = findByLabel(className('android.widget.TextView'), function (node) {
+            var value = layout.text(node);
+            return /^(Following|Followers|Suggested|关注|粉丝|推荐)\b/.test(value) &&
+                topOf(node) < screenHeight() * 0.16;
+        });
+        for (var i = 0; i < hits.length; i += 1) {
+            var value = layout.text(hits[i]);
+            var key = value.indexOf('Follower') === 0 || value.indexOf('粉丝') === 0 ? 'fans'
+                : (value.indexOf('Suggested') === 0 || value.indexOf('推荐') === 0 ? 'suggested' : 'following');
+            out.push({
+                key: key,
+                label: value,
+                node: layout.clickableAncestor(hits[i]) || hits[i]
+            });
+        }
+        return out;
+    }
+
+    /** 当前列表页选中的是哪个页签(用 selected 属性判,实测可用) */
+    function selectedListTab() {
+        var tabs = userListTabs();
+        for (var i = 0; i < tabs.length; i += 1) {
+            var selected = false;
+            try {
+                selected = tabs[i].node.selected() === true || tabs[i].node.isSelected() === true;
+            } catch (ignored) {
+                selected = false;
+            }
+            if (selected) {
+                return tabs[i].key;
+            }
+        }
+        /* selected 读不到时退回文案判断:选中态的页签文案与未选中态无差异,
+         * 所以这里只能返回 unknown,由调用方用"点完再看内容变没变"确认。 */
+        return 'unknown';
+    }
+
+    /**
+     * 粉丝/关注列表里的每一行。
+     *
+     * 实测结构:`LinearLayout`(整行可点)> `TextView txt_user_name`(昵称)
+     * + `TextView txt_desc`(**uid**)+ `Button ubp`(Follow)。
+     * 注意与搜索用户列表**id 不同**(那边是 tv_username/tv_desc)。
+     *
+     * 返回的 `node` 是行容器,点它进用户页;`x` 是"安全点击的横向位置"——
+     * 靠左,避开右侧的 Follow 按钮(实测按钮左边界 x≈872)。
+     */
+    function userListRows() {
+        var parse = TikTokAuto.parse;
+        var rows = [];
+        var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
+            return resourceIdOf(node).indexOf('txt_desc') >= 0 && layout.text(node) !== '';
+        });
+
+        /* 有的版本 uid 放在 txt_user_name、昵称放在 txt_desc,所以按"哪个像 uid"再兜一层 */
+        if (uidNodes.length === 0) {
+            uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
+                return resourceIdOf(node).indexOf('txt_user_name') >= 0 &&
+                    parse.looksLikeUsername(layout.text(node));
+            });
+        }
+
+        for (var i = 0; i < uidNodes.length; i += 1) {
+            var uidNode = uidNodes[i];
+            var uid = parse.username(layout.text(uidNode));
+            if (!parse.looksLikeUsername(uid)) {
+                continue;
+            }
+            var row = findRowContainer(uidNode);
+            var box = row ? row.bounds() : uidNode.bounds();
+            rows.push({
+                uid: uid,
+                nickname: nicknameOf(uidNode, row),
+                node: row || uidNode,
+                top: box.top,
+                x: Math.round(box.left + (box.right - box.left) * 0.3),
+                y: Math.round((box.top + box.bottom) / 2)
+            });
+        }
+        rows.sort(function (a, b) { return a.top - b.top; });
+        return rows;
+    }
+
+    function resourceIdOf(node) {
+        return layout.resourceId(node);
+    }
+
+    /** 从 uid 文本往上找"整行"容器(高度落在一行的高度区间内) */
+    function findRowContainer(uidNode) {
+        var cursor = uidNode;
+        var depth = 0;
+        while (cursor && depth < 5) {
+            var box = cursor.bounds();
+            var rowHeight = box.bottom - box.top;
+            if (layout.isClickable(cursor) && rowHeight >= screenHeight() * 0.05 &&
+                rowHeight <= screenHeight() * 0.12) {
+                return cursor;
+            }
+            cursor = parentOf(cursor);
+            depth += 1;
+        }
+        return layout.clickableAncestor(uidNode) || parentOf(uidNode);
+    }
+
+    /** 行里的昵称:找同一容器内的另一个 TextView(不是 uid 那个) */
+    function nicknameOf(uidNode, row) {
+        if (!row) {
+            return '';
+        }
+        var found = '';
+        var walk = [row];
+        var guard = 0;
+        while (walk.length > 0 && guard < 40) {
+            guard += 1;
+            var node = walk.shift();
+            if (!node) {
+                continue;
+            }
+            var id = resourceIdOf(node);
+            var value = layout.text(node);
+            if (value !== '' && (id.indexOf('txt_user_name') >= 0 || id.indexOf('tv_aweme_id') >= 0)) {
+                found = value;
+                break;
+            }
+            var count = 0;
+            try { count = node.childCount(); } catch (ignored) { count = 0; }
+            for (var i = 0; i < count; i += 1) {
+                try { walk.push(node.child(i)); } catch (ignoredToo) { /* 跳过 */ }
+            }
+        }
+        return found;
+    }
+
+    /**
+     * 搜索结果的「用户」列表行。
+     *
+     * 实测结构:`Button`(整行可点)> `TextView tv_username`(uid)
+     * + `TextView tv_aweme_id`(**昵称**,id 名违反直觉)+ `TextView tv_desc`("594 followers · 2.5K likes")。
+     *
+     * 额外好处:`tv_desc` 里就有粉丝数与获赞数 —— 所以按数量筛选时
+     * **这一条来源不必逐个打开用户页**(其余来源没有这个信息)。
+     */
+    function usersResultRows() {
+        var parse = TikTokAuto.parse;
+        var rows = [];
+        var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
+            return resourceIdOf(node).indexOf('tv_username') >= 0 && layout.text(node) !== '';
+        });
+
+        for (var i = 0; i < uidNodes.length; i += 1) {
+            var uid = parse.username(layout.text(uidNodes[i]));
+            if (!parse.looksLikeUsername(uid)) {
+                continue;
+            }
+            var box = uidNodes[i].bounds();
+            var desc = '';
+            var nickname = '';
+            var rowNode = null;
+
+            /* 向上两层找行容器,再在容器里读昵称与计数 */
+            var cursor = uidNodes[i];
+            for (var up = 0; up < 4 && cursor; up += 1) {
+                cursor = parentOf(cursor);
+                if (cursor && layout.isClickable(cursor)) {
+                    rowNode = cursor;
+                    break;
+                }
+            }
+            if (rowNode) {
+                var children = findByLabelInside(rowNode, className('android.widget.TextView'));
+                for (var j = 0; j < children.length; j += 1) {
+                    var id = resourceIdOf(children[j]);
+                    var value = layout.text(children[j]);
+                    if (id.indexOf('tv_aweme_id') >= 0) {
+                        nickname = value;
+                    } else if (id.indexOf('tv_desc') >= 0) {
+                        desc = value;
+                    }
+                }
+                var rowBox = rowNode.bounds();
+                box = { left: rowBox.left, top: rowBox.top, right: rowBox.right, bottom: rowBox.bottom };
+            }
+
+            rows.push({
+                uid: uid,
+                nickname: nickname,
+                node: rowNode || uidNodes[i],
+                top: box.top,
+                fans: parseFollowerLine(desc, 'followers'),
+                likes: parseFollowerLine(desc, 'likes'),
+                x: Math.round(box.left + (box.right - box.left) * 0.3),
+                y: Math.round((box.top + box.bottom) / 2)
+            });
+        }
+        rows.sort(function (a, b) { return a.top - b.top; });
+        return rows;
+    }
+
+    /** 在一个节点内部找符合条件的子孙(不含自身之外的范围) */
+    function findByLabelInside(rootNode, selector) {
+        var out = [];
+        var collection = selector.find();
+        for (var i = 0; i < collection.size(); i += 1) {
+            var node = collection.get(i);
+            var cursor = node;
+            var depth = 0;
+            var inside = false;
+            while (cursor && depth < 12) {
+                if (cursor === rootNode) {
+                    inside = true;
+                    break;
+                }
+                cursor = parentOf(cursor);
+                depth += 1;
+            }
+            if (inside) {
+                out.push(node);
+            }
+        }
+        return out;
+    }
+
+    /**
+     * 从 "594 followers · 2.5K likes" 里分别抠出两个数。
+     * 分隔符实测是 " · "(中点),但不同语言/版本可能不同,所以按**关键词**切而不是按分隔符。
+     */
+    function parseFollowerLine(text, which) {
+        var parse = TikTokAuto.parse;
+        if (!text) {
+            return null;
+        }
+        var parts = String(text).split(/[·•|]/);
+        for (var i = 0; i < parts.length; i += 1) {
+            var part = parts[i];
+            var lower = part.toLowerCase();
+            if (which === 'followers' && (lower.indexOf('follower') >= 0 || lower.indexOf('粉丝') >= 0)) {
+                return parse.count(part);
+            }
+            if (which === 'likes' && (lower.indexOf('like') >= 0 || lower.indexOf('获赞') >= 0)) {
+                return parse.count(part);
+            }
+        }
+        return null;
+    }
+
+    /** 播放页底部的作者 uid(实测:`Button l2m` 的 text = "@2131823375") */
+    function playerAuthorUid() {
+        var parse = TikTokAuto.parse;
+        var hits = findByLabel(className('android.widget.Button'), function (node) {
+            var value = layout.text(node);
+            return value.charAt(0) === '@' && parse.looksLikeUsername(value);
+        });
+        return hits.length > 0 ? parse.username(layout.text(hits[0])) : '';
+    }
+
+    /**
+     * 评论区里的每一条:**只有昵称,没有 uid**(实测 2026-10-20)。
+     *
+     * 所以"从评论区取评论者"必须逐个点头像/昵称进用户页 —— 成本比列表高一个数量级。
+     * 这里返回每个人可点的节点(头像 ImageView `blm`)与昵称,供上层决定怎么进。
+     */
+    function commentRowers() {
+        var out = [];
+        var avatars = findByLabel(className('android.widget.ImageView'), function (node) {
+            return resourceIdOf(node).indexOf('blm') >= 0;
+        });
+        for (var i = 0; i < avatars.length; i += 1) {
+            var box = avatars[i].bounds();
+            /* 头像必须在评论列表区域内(屏幕中下部),不是视频右上角的作者头像 */
+            if (box.top < screenHeight() * 0.38) {
+                continue;
+            }
+            out.push({
+                node: avatars[i],
+                top: box.top,
+                x: Math.round((box.left + box.right) / 2),
+                y: Math.round((box.top + box.bottom) / 2)
+            });
+        }
+        out.sort(function (a, b) { return a.top - b.top; });
+        return out;
+    }
+
+    /** 结果页的某个页签当前是否选中(Users 页签用) */
+    function resultTabSelected(name) {
+        var node = resultTab(name);
+        if (!node) {
+            return false;
+        }
+        try {
+            return node.selected() === true || node.isSelected() === true;
+        } catch (ignored) {
+            return false;
+        }
+    }
+
+    /**
+     * ID 提取这条链路自己在用的页面判定。
+     *
+     * **刻意与 pageState() 分开**:pageState 是视频链路的判据,
+     * 往里加分支就有可能把"播放页 vs 结果页"的老判定带歪(方案 §12 明确要求回归)。
+     * 新开一个函数,视频链路一行都不用动。
+     *
+     * @returns 'profile' | 'user-list' | 'users-results' | 'comment-panel' | 'search' | 'feed' | 'other'
+     */
+    function idPageState() {
+        if (commentPanelOpen()) {
+            return 'comment-panel';
+        }
+        if (profileUsernameNode() !== null) {
+            return 'profile';
+        }
+        if (userListTabs().length >= 2 && userListRows().length > 0) {
+            return 'user-list';
+        }
+        if (usersResultRows().length > 0) {
+            return 'users-results';
+        }
+        if (searchInput() !== null) {
+            return 'search';
+        }
+        if (bottomNavPresent()) {
+            return 'feed';
+        }
+        return 'other';
+    }
+
     // ---------------------------------------------------------------- 页面状态
 
     /**
@@ -486,6 +1004,23 @@ TikTokAuto.tiktok = (function () {
         commentPanelOpen: commentPanelOpen,
         commentListContains: commentListContains,
         visibleTexts: visibleTexts,
-        describeScreen: describeScreen
+        describeScreen: describeScreen,
+
+        /* —— ID 提取(2026-10-20 真机实测锚点,见上面的长注释) —— */
+        profileUsernameNode: profileUsernameNode,
+        profileNicknameNode: profileNicknameNode,
+        profileSignatureNode: profileSignatureNode,
+        statNumbers: statNumbers,
+        statEntry: statEntry,
+        privateHint: privateHint,
+        videoGridCount: videoGridCount,
+        userListTabs: userListTabs,
+        selectedListTab: selectedListTab,
+        userListRows: userListRows,
+        usersResultRows: usersResultRows,
+        playerAuthorUid: playerAuthorUid,
+        commentRowers: commentRowers,
+        resultTabSelected: resultTabSelected,
+        idPageState: idPageState
     };
 }());

+ 176 - 0
tiktok-auto/src/uidset.js

@@ -0,0 +1,176 @@
+/**
+ * uidset.js — 去重集合与结果文件
+ *
+ * ## 三种"已经见过"
+ * 1. **本轮已收**:这次运行里已经写进产物文件的;
+ * 2. **历史已收**:之前各轮跑过的(`uid_extracted.txt`,只追加不删除);
+ * 3. **输入里已有**:文本取材那份文件本身就列着的("裂变"时,"输入里的人"不该再算新收获)。
+ * 三者合成一个集合,`add` 之后就不再重复。区别只在**装载来源**,判定逻辑是同一个。
+ *
+ * ## 为什么落盘是"追加"而不是"最后一次性写"
+ * 用户点了停止、或脚本被系统杀掉时,已经抓到的部分不能全丢。
+ * 所以每收 N 个就 append 一次 —— 代价是几条多余的系统调用,收益是"停在哪都留着"。
+ *
+ * ## 为什么不用 files 的删除接口
+ * 引擎产物要过 MCP 的危险调用黑名单(`build.js` 的 `DANGEROUS_RULES` 里有一条按
+ * 词边界匹配 `remove` 加左括号),命中后每次下发都要求人工确认。
+ * 所以这里**只追加不删除** —— 需要"重来"时由用户自己在文件管理器里删。
+ *
+ * 依赖:TikTokAuto.logger(调用时取)
+ */
+TikTokAuto.uidset = (function () {
+    'use strict';
+
+    /** 每收这么多个就 append 一次。20 是"丢掉的最多几个"与"写盘频率"的折中。 */
+    var FLUSH_EVERY = 20;
+
+    /**
+     * 去重集合。
+     *
+     * 内部用普通对象而不是 Set:Rhino 对 ES6 Set 的支持在各版本不一致,
+     * 而对象字面量的键查找在所有版本上都是稳的。uid 只会是字母数字下划线点,不会撞 `__proto__`。
+     */
+    function create() {
+        var seen = {};
+        var count = 0;
+
+        return {
+            /** 见过没有 */
+            has: function (uid) {
+                return seen[uid] === true;
+            },
+            /** 记一笔;返回 true 表示这是新加的(之前没见过) */
+            add: function (uid) {
+                if (seen[uid] === true) {
+                    return false;
+                }
+                seen[uid] = true;
+                count += 1;
+                return true;
+            },
+            /** 已记多少(含历史装载的) */
+            size: function () {
+                return count;
+            }
+        };
+    }
+
+    /**
+     * 读一批文本行,返回去重后的 uid 数组。
+     * 跳过空行、`#` 注释行、以及带 @ 的行(有些用户会直接粘贴 "@name",我们容忍这种输入)。
+     */
+    function readUidFile(path) {
+        var out = [];
+        try {
+            if (!path || !files.exists(path)) {
+                return out;
+            }
+            var lines = String(files.read(path)).split(/\r?\n/);
+            for (var i = 0; i < lines.length; i += 1) {
+                var uid = TikTokAuto.parse.username(lines[i]);
+                if (uid !== '' && uid.charAt(0) !== '#') {
+                    out.push(uid);
+                }
+            }
+        } catch (error) {
+            TikTokAuto.logger.warn('uidset', '读取 ' + path + ' 失败:' + error);
+        }
+        return out;
+    }
+
+    /**
+     * 从历史文件装载去重集合。
+     * 文件不存在 = 第一次跑,不是错误。
+     */
+    function loadHistory(set, path) {
+        var list = readUidFile(path);
+        for (var i = 0; i < list.length; i += 1) {
+            set.add(list[i]);
+        }
+        TikTokAuto.logger.info('uidset', '历史去重装载 ' + list.length + ' 条(' + path + ')');
+        return list.length;
+    }
+
+    /**
+     * 结果写入器。
+     *
+     * 两个文件同时写:
+     *   · `本次`:这一轮新收的(新建,给人看/给下游吃);
+     *   · `累计`:所有轮次的(追加,跨轮去重的基准)。
+     *
+     * @param options { runPath, historyPath, dryRun, historyEnabled }
+     */
+    function createWriter(options) {
+        var pending = [];
+        var written = 0;
+        var flushFailed = false;
+
+        function flush() {
+            if (pending.length === 0) {
+                return;
+            }
+            var block = pending.join('\n') + '\n';
+            pending = [];
+            if (options.dryRun) {
+                return;         /* 演练模式:不碰任何文件 */
+            }
+            try {
+                files.append(options.runPath, block);
+                if (options.historyEnabled) {
+                    files.append(options.historyPath, block);
+                }
+            } catch (error) {
+                /* 写盘失败要留痕但不能中断 —— 用户更需要"继续抓"而不是"因为一个文件写不进去全停"。
+                 * 只报一次,避免几百条一模一样的警告把日志刷满。 */
+                if (!flushFailed) {
+                    flushFailed = true;
+                    TikTokAuto.logger.error('uidset', '写结果文件失败:' + error);
+                }
+            }
+        }
+
+        return {
+            /** 收一个 uid。调用方保证已经过去重。 */
+            write: function (uid) {
+                pending.push(uid);
+                written += 1;
+                if (pending.length >= FLUSH_EVERY) {
+                    flush();
+                }
+            },
+            /** 已写多少个 */
+            count: function () {
+                return written;
+            },
+            /** 收尾:把剩下的刷出去。**收到停止信号时也必须调它**,否则"停了但文件是空的"。 */
+            close: function () {
+                flush();
+                return written;
+            },
+            /** 是否发生过写盘失败(给 summary 用) */
+            hadFailure: function () {
+                return flushFailed;
+            }
+        };
+    }
+
+    /** 确保目录存在(结果文件与累计文件同目录) */
+    function ensureDir(dir) {
+        try {
+            files.ensureDir(dir + '/');
+            return true;
+        } catch (error) {
+            TikTokAuto.logger.error('uidset', '创建目录失败 ' + dir + ':' + error);
+            return false;
+        }
+    }
+
+    return {
+        FLUSH_EVERY: FLUSH_EVERY,
+        create: create,
+        readUidFile: readUidFile,
+        loadHistory: loadHistory,
+        createWriter: createWriter,
+        ensureDir: ensureDir
+    };
+}());

+ 12 - 1
tiktok-auto/tools/build.js

@@ -47,7 +47,18 @@ const MODULE_ORDER = [
     'tiktok',
     'player',
     'comment',
-    'flow'
+    'flow',
+    /* —— ID 提取(task = 'id')——
+     * 顺序按依赖走:parse 是纯函数地基;uidset/filters 只依赖 parse;
+     * profile 调 tiktok 与 flow;idsource 调 profile;idflow 调全部。
+     * 这些模块**都不在加载时取别的模块的引用**(一律在函数体内按名字取),
+     * 所以这里的顺序主要是给人读的,不满足也不会炸 —— 但仍然按依赖排。 */
+    'parse',
+    'uidset',
+    'filters',
+    'profile',
+    'idsource',
+    'idflow'
 ];
 
 const ENTRY_MODULE = 'main';

+ 161 - 0
tiktok-auto/tools/pc-selftest-id.js

@@ -0,0 +1,161 @@
+/**
+ * pc-selftest-id.js — 在 PC 上验证 ID 提取的纯函数(不碰设备)
+ *
+ * 为什么单独一份:设备的自检要过 MCP 下发,一轮几十秒;而 parse/filters
+ * 是**零依赖纯函数**,拼起来就能在 PC 上跑。先用它把逻辑验对,
+ * 再上设备验"真机环境里也没问题",两步分开,排查时能立刻区分是逻辑错还是环境错。
+ *
+ * 用法:node tools/pc-selftest-id.js
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const SRC = path.join(__dirname, '..', 'src');
+
+/* 只拼纯函数模块 —— 它们不引用 files/device/sleep 这些 AutoX 全局 */
+const MODULES = ['parse', 'filters', 'uidset'];
+
+const sandbox = {
+    console: console,
+    Date: Date, Math: Math, JSON: JSON,
+    String: String, Number: Number, Object: Object,
+    Array: Array, RegExp: RegExp, isNaN: isNaN, parseInt: parseInt,
+    TikTokAuto: {}
+};
+/* uidset 与 filters 在函数体内取 logger / files,这里给最小桩 */
+sandbox.TikTokAuto.logger = {
+    info: () => { }, warn: () => { }, error: () => { }, step: () => { }
+};
+sandbox.files = {
+    exists: () => false,
+    read: () => '',
+    append: () => { },
+    ensureDir: () => { }
+};
+
+vm.createContext(sandbox);
+
+let source = 'var TikTokAuto = this.TikTokAuto;\n';
+for (const name of MODULES) {
+    source += fs.readFileSync(path.join(SRC, `${name}.js`), 'utf8') + '\n';
+}
+vm.runInContext(source, sandbox);
+
+const parse = sandbox.TikTokAuto.parse;
+const filters = sandbox.TikTokAuto.filters;
+const uidset = sandbox.TikTokAuto.uidset;
+
+let pass = 0;
+let fail = 0;
+
+function check(name, condition, detail) {
+    if (condition === true) {
+        pass += 1;
+        return;
+    }
+    fail += 1;
+    console.log('FAIL ' + name + (detail === undefined ? '' : '  → ' + detail));
+}
+
+/* ---------------------------------------------------------- 计数解析 */
+const CASES = [
+    ['594', 594], ['2,491', 2491], ['1.6M', 1600000], ['253.9M', 253900000],
+    ['245K', 245000], ['371.7K', 371700], ['1.2万', 12000], ['1.5亿', 150000000],
+    ['1.6M followers', 1600000], ['594 followers · 2.5K likes', 594],
+    ['0', 0], ['12.3K', 12300], ['1,234', 1234], ['100万', 1000000]
+];
+for (const [input, want] of CASES) {
+    const got = parse.count(input);
+    check(`parseCount("${input}") = ${want}`, got === want, `实际 ${got}`);
+}
+check('parseCount 空串 → null', parse.count('') === null);
+check('parseCount 非数字 → null', parse.count('abc') === null);
+check('parseCount 全角数字 "1.2万"', parse.count('1.2万') === 12000, String(parse.count('1.2万')));
+
+/* ---------------------------------------------------------- 语言 */
+check('中文 → zh', parse.language('小美爱穿搭') === 'zh');
+check('英文 → en', parse.language('Fashion Girl') === 'en');
+check('中文混英文 → zh', parse.language('小美的Fashion') === 'zh', parse.language('小美的Fashion'));
+check('英文混一个字 → en', parse.language('Fashion Girl 小') === 'en', parse.language('Fashion Girl 小'));
+check('纯 emoji → none', parse.language('🥷🏿💗✨') === 'none');
+check('阿拉伯文 → none(没有中英线索)', parse.language('عايشه حسين') === 'none', parse.language('عايشه حسين'));
+check('zh 不放行 en', parse.languageMatches('zh', 'en') === false);
+check('en 不放行 zh', parse.languageMatches('en', 'zh') === false);
+check('any 全放行', parse.languageMatches('any', 'en') === true && parse.languageMatches('any', 'none') === true);
+
+/* ---------------------------------------------------------- 区间 */
+check('未设区间 → 无脑通过', parse.inRange(null, 0, 0) === true);
+check('值在内', parse.inRange(500, 100, 1000) === true);
+check('值在下限外', parse.inRange(50, 100, 1000) === false);
+check('上限 0 = 不设限', parse.inRange(999999, 100, 0) === true);
+check('只设上限', parse.inRange(500, 0, 1000) === true && parse.inRange(5000, 0, 1000) === false);
+check('设了区间但值为 null → 拦', parse.inRange(null, 100, 1000) === false);
+check('isRangeOpen 双向为 0 才算空', parse.isRangeOpen(0, 0) === true && parse.isRangeOpen(1, 0) === false);
+
+/* ---------------------------------------------------------- 用户名 */
+check('剥 @', parse.username('@abc') === 'abc');
+check('剥 FID 隔离符', parse.username('\u2068ajman_fashion_week\u2069') === 'ajman_fashion_week',
+    JSON.stringify(parse.username('\u2068ajman_fashion_week\u2069')));
+check('合法用户名', parse.looksLikeUsername('user3670597276145') === true);
+check('带点的用户名', parse.looksLikeUsername('mukhawar.shop') === true);
+check('文案不是用户名', parse.looksLikeUsername('This is a caption') === false);
+check('过长不是用户名', parse.looksLikeUsername('a'.repeat(31)) === false);
+
+/* ---------------------------------------------------------- 去重集合 */
+const set = uidset.create();
+check('首次 add → true', set.add('a') === true);
+check('重复 add → false', set.add('a') === false);
+check('size 只算唯一值', set.size() === 1);
+check('has 正确', set.has('a') === true && set.has('b') === false);
+
+/* ---------------------------------------------------------- 过滤 */
+check('无过滤全放行', filters.judge({ uid: 'x' }, {}).ok === true);
+check('私密被拦', filters.judge({ uid: 'x', isPrivate: true }, { private: true }).ok === false);
+check('公开放行', filters.judge({ uid: 'x', isPrivate: false }, { private: true }).ok === true);
+
+check('仅中文 + 中文昵称', filters.judge({ uid: 'x', nickname: '小美' }, { langRegion: 'zh' }).ok === true);
+check('仅中文 + 英文昵称被拦', filters.judge({ uid: 'x', nickname: 'Fashion' }, { langRegion: 'zh' }).ok === false);
+check('仅中文 + 纯 emoji 被拦', filters.judge({ uid: 'x', nickname: '🥷🏿' }, { langRegion: 'zh' }).ok === false);
+check('取样=个签:昵称英文但个签中文 → 放行',
+    filters.judge({ uid: 'x', nickname: 'Fashion', signature: '小美' },
+        { langRegion: 'zh', nicknameCheck: 'sign' }).ok === true);
+check('取样=昵称:同上应当被拦',
+    filters.judge({ uid: 'x', nickname: 'Fashion', signature: '小美' },
+        { langRegion: 'zh', nicknameCheck: 'nick' }).ok === false);
+
+check('粉丝达标', filters.judge({ uid: 'x', fans: 5940 }, { fans: { min: 1000, max: 0 } }).ok === true);
+check('粉丝不足', filters.judge({ uid: 'x', fans: 594 }, { fans: { min: 1000, max: 0 } }).ok === false);
+check('粉丝读不到 → 拦', filters.judge({ uid: 'x', fans: null }, { fans: { min: 1000, max: 0 } }).ok === false);
+check('粉丝上界', filters.judge({ uid: 'x', fans: 99999 }, { fans: { min: 0, max: 10000 } }).ok === false);
+
+const videoVerdict = filters.judge({ uid: 'x' }, { videos: { min: 10, max: 0 } });
+check('视频量不拦人', videoVerdict.ok === true);
+check('视频量要如实告知未生效', videoVerdict.soft.indexOf('视频量') >= 0, videoVerdict.soft);
+
+check('无头像:默认剪影被拦', filters.judge({ uid: 'x', hasAvatar: false }, { noAvatar: true }).ok === false);
+check('无头像:有头像放行', filters.judge({ uid: 'x', hasAvatar: true }, { noAvatar: true }).ok === true);
+const noAvatarUnknown = filters.judge({ uid: 'x', hasAvatar: null }, { noAvatar: true });
+check('无头像:判不了时放行', noAvatarUnknown.ok === true);
+check('无头像:判不了要告知', noAvatarUnknown.soft.indexOf('无头像') >= 0, noAvatarUnknown.soft);
+
+check('无过滤 → 不必开用户页', filters.needsProfile({}) === false);
+check('只有视频量 → 不必开用户页', filters.needsProfile({ videos: { min: 10, max: 0 } }) === false);
+check('有粉丝量 → 必须开', filters.needsProfile({ fans: { min: 10, max: 0 } }) === true);
+check('有语言 → 必须开', filters.needsProfile({ langRegion: 'zh' }) === true);
+
+/* 「读不到资料」必须拦下 —— 放行等于"打不开的人全都通过",
+ * 过滤看着在跑、实际放进一批没检查过的账号,比不过滤更糟。
+ * 但快路径(本来不需要资料)仍然要放行。 */
+check('需要资料但拿不到 → 拦下', filters.judge(null, { fans: { min: 1000, max: 0 } }).ok === false);
+check('不需要资料且没有资料 → 放行(快路径)', filters.judge(null, {}).ok === true);
+
+/* ---------------------------------------------------------- 文件名 */
+const name = parse.timestampName('uid', new Date(2026, 9, 20, 18, 31, 0));
+check('文件名带时分', name === 'uid_20261020_1831.txt', name);
+
+console.log('');
+console.log(`通过 ${pass} 项,失败 ${fail} 项`);
+process.exit(fail === 0 ? 0 : 1);

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff