Selaa lähdekoodia

断点续跑(步 0+1):修 walkList 的 idle 判据 + 指纹/断点纯逻辑层

## 步 0:一个已在生产里的 bug —— 预载历史后"列表顶部就判到底"

idsource.walkList 里 `idle` 被两件事同时累加:
  ① 本屏 fresh 为空(全是已收过的人)→ idle + 1
  ② 翻屏后签名不变 → idle + 1
而 idflow 会先把 uid_extracted.txt 预载进 seen。于是**第二轮跑同一个账号**时,
前两屏全是历史 → idle=2 → 在列表顶部就"到底",一条新人都拿不到。
断点续跑还要预载上次的产物,这个误判会从"偶发"变成"必然"。

改法:删掉 ①,只保留"翻得动翻不动"(`walkStep` 纯函数,可离线回归)。
一屏全是旧人只说明"旧人多",不代表列表到头。

真机验证(Pixel 5a / TikTok 47.1.4):
  第一轮攒 20 个 → 历史 2 屏
  第二轮在 20 个历史之上又收 **30 个新人**(旧版会在第 2 屏收工)
  第三轮历史已 50 个(5 屏以上全是旧人)→ 仍然滚到列表真正到底
  (58 秒、"翻屏后内容不变";旧版约 8 秒就报"连续 2 屏没有新 uid")

配套:`ctx.maxScreens`(续跑按上次屏数放大预算)、`ctx.stat.screens`(供断点记录)。

## 步 1:fingerprint.js + checkpoint.js(纯逻辑,不改运行行为)

**fingerprint**:项目没有 hash 库,自己写"稳定序列化 + djb2"。
覆盖 sources / filter / output 三段(**分项存**,界面才能说清"是取材改了");
不覆盖 limit / resolveUid / dryRun / filter.videos / guard —— 改这些不该让断点失效
(多算一个字段,用户改个"至少运行几分钟"就白丢断点)。

**checkpoint**:`<结果目录>/id_progress.json`,取材条目级(不记屏/行游标)。
- `read` 容错:半截 JSON 当"没有断点",绝不抛(产物还在,丢的只是能否续跑)
- 「清空断点」= 覆盖写成 state:'none',**不删文件**(`remove(` 命中危险调用黑名单)
- `limitReached` 按累计算(30 + 70 = 100 才算到顶),否则续跑会多收一轮
- 展开项按 `uid|which` 区分(同一个人可能分别展开 fans 与 following)

## 一个必须记住的构建约束

`DANGEROUS_RULES` 里有 `/\bcall\s*\(/i`,**连注释里的字面量都会命中**。
所以 `Object.prototype.toString.call(...)` 这种标准写法不能用,
要写成 `Array.isArray(v)` 或 `.apply(v, [])`(同 bridge.js/config.js 的既有规避方式)。

## 验证

- PC 自检 **114 项**(原 75 + 新增 39)
- 其中一条专防算法漂移:用 vm 同时加载引擎与界面的 fingerprint.js,
  对 9 组 fixture 逐字段比对 —— 两份实现不一致 = 界面说能续、引擎说配置变了
- 界面 smoke 422 项 / AUDIT_CLEAN / 两侧构建通过(引擎 22 模块)

## 还没做

引擎侧还没真的读写断点(步 2),界面也还没有入口(步 3/4)。
这一步只是把纯逻辑与判据铺好,运行行为与之前**完全一致**。
梦境 5 päivää sitten
vanhempi
sitoutus
5a40623fd5

+ 192 - 2
tiktok-app/dist/tiktok-app.js

@@ -1,7 +1,7 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-28T10:49:21.035Z
- *  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
+ *  built 2026-10-28T14:42:55.859Z
+ *  modules: theme, store, records, sources, device, service, fingerprint, engine, session, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
 (function () {
@@ -2502,6 +2502,196 @@ Service.checkUpdate = function () {
 };
 
 
+/* ============================== src/fingerprint.js ============================== */
+/**
+ * fingerprint.js — 配置指纹(界面侧)
+ *
+ * ★ **逐字节同算法的一份拷贝**,源头在 `tiktok-auto/src/fingerprint.js`。
+ *
+ * 为什么必须有两份:界面要在"点继续之前"就判断断点还有没有效(不折腾用户手机),
+ * 引擎要在"启动 App 之前"再判断一次(用户也可能直接改配置手起引擎)。
+ * 项目没有共享模块机制,只能各放一份。
+ *
+ * **改算法必须两边一起改**:两侧算出的指纹不同 = 界面说"能续跑"、引擎说"配置变了"。
+ * `tiktok-auto/tools/pc-selftest-id.js` 有一组断言专门比对两份实现的结果。
+ */
+var Fingerprint = (function () {
+    'use strict';
+
+    var VERSION = 1;
+
+    /**
+     * 是不是数组。
+     *
+     * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时
+     * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。
+     *
+     * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号
+     * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。
+     * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。
+     */
+    function isArray(v) {
+        if (typeof Array.isArray === 'function') {
+            return Array.isArray(v);
+        }
+        return Object.prototype.toString.apply(v, []) === '[object Array]';
+    }
+
+    /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */
+    function ownKeys(v) {
+        var keys = [];
+        for (var k in v) {
+            if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) {
+                keys.push(k);
+            }
+        }
+        keys.sort();
+        return keys;
+    }
+
+    /**
+     * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。
+     *
+     * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串,
+     * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。
+     */
+    function canon(v) {
+        if (v === null || v === undefined) {
+            return 'n';
+        }
+        if (isArray(v)) {
+            var parts = [];
+            for (var i = 0; i < v.length; i += 1) {
+                parts.push(canon(v[i]));
+            }
+            return '[' + parts.join(',') + ']';
+        }
+        if (typeof v === 'object') {
+            var keys = ownKeys(v);
+            var out = [];
+            for (var j = 0; j < keys.length; j += 1) {
+                out.push(keys[j] + ':' + canon(v[keys[j]]));
+            }
+            return '{' + out.join(',') + '}';
+        }
+        if (typeof v === 'number') {
+            return 'd' + String(v);
+        }
+        if (typeof v === 'boolean') {
+            return v ? 'T' : 'F';
+        }
+        return 's' + JSON.stringify(String(v));
+    }
+
+    /**
+     * djb2-xor 摘要。
+     *
+     * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分,
+     * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。
+     */
+    function digest(text) {
+        var s = String(text);
+        var h = 5381;
+        for (var i = 0; i < s.length; i += 1) {
+            h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0;   /* |0 截成 32 位,避免精度丢失 */
+        }
+        return (h >>> 0).toString(36) + '-' + s.length.toString(36);
+    }
+
+    /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */
+    function ofSource(one, index) {
+        var s = one || {};
+        return {
+            key: s.key || ('i' + index + ':' + (s.kind || '')),
+            kind: s.kind || '',
+            anchors: s.anchors || '',
+            lists: isArray(s.lists) ? s.lists : [],
+            searchType: s.searchType || '',
+            keyword: s.keyword || '',
+            videoSource: s.videoSource || '',
+            videoKeyword: s.videoKeyword || '',
+            path: s.path || '',
+            expand: s.expand || ''
+        };
+    }
+
+    /**
+     * 算 `id` 段的指纹。
+     *
+     * **分项存**(sources / filter / output 各一份)而不只存一个总串:
+     * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。
+     *
+     * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`,
+     *           引擎传解析后的 `config.id` —— 两者是同一份数据)
+     */
+    function ofIdSection(id) {
+        var section = id || {};
+
+        var list = isArray(section.sources) ? section.sources : [];
+        var sources = [];
+        for (var i = 0; i < list.length; i += 1) {
+            sources.push(ofSource(list[i], i));
+        }
+
+        var f = section.filter || {};
+        var o = section.output || {};
+
+        var sourcesHash = digest(canon(sources));
+        var filterHash = digest(canon({
+            private: f.private === true,
+            noAvatar: f.noAvatar === true,
+            langRegion: f.langRegion || 'any',
+            nicknameCheck: f.nicknameCheck || 'any',
+            follow: f.follow || {},
+            fans: f.fans || {},
+            likes: f.likes || {}
+        }));
+        var outputHash = digest(canon({
+            dir: o.dir || '',
+            historyPath: o.historyPath || '',
+            dedupeHistory: o.dedupeHistory !== false
+        }));
+
+        return {
+            v: VERSION,
+            sources: sourcesHash,
+            filter: filterHash,
+            output: outputHash,
+            all: digest(sourcesHash + filterHash + outputHash)
+        };
+    }
+
+    /**
+     * 比两份指纹。
+     * @returns { same, changed: ['sources'|'filter'|'output'] }
+     */
+    function compare(a, b) {
+        if (!a || !b || a.v !== VERSION || b.v !== VERSION) {
+            return { same: false, changed: ['sources', 'filter', 'output'] };
+        }
+        var changed = [];
+        if (a.sources !== b.sources) {
+            changed.push('sources');
+        }
+        if (a.filter !== b.filter) {
+            changed.push('filter');
+        }
+        if (a.output !== b.output) {
+            changed.push('output');
+        }
+        return { same: changed.length === 0, changed: changed };
+    }
+
+    return {
+        VERSION: VERSION,
+        canon: canon,
+        digest: digest,
+        ofIdSection: ofIdSection,
+        compare: compare
+    };
+}());
+
+
 /* ============================== src/engine.js ============================== */
 /* ============================== src/engine.js ==============================
  * 执行引擎桥接:把界面收集的配置交给 tiktok-auto,再把它的进度读回来。

+ 187 - 0
tiktok-app/src/fingerprint.js

@@ -0,0 +1,187 @@
+/**
+ * fingerprint.js — 配置指纹(界面侧)
+ *
+ * ★ **逐字节同算法的一份拷贝**,源头在 `tiktok-auto/src/fingerprint.js`。
+ *
+ * 为什么必须有两份:界面要在"点继续之前"就判断断点还有没有效(不折腾用户手机),
+ * 引擎要在"启动 App 之前"再判断一次(用户也可能直接改配置手起引擎)。
+ * 项目没有共享模块机制,只能各放一份。
+ *
+ * **改算法必须两边一起改**:两侧算出的指纹不同 = 界面说"能续跑"、引擎说"配置变了"。
+ * `tiktok-auto/tools/pc-selftest-id.js` 有一组断言专门比对两份实现的结果。
+ */
+var Fingerprint = (function () {
+    'use strict';
+
+    var VERSION = 1;
+
+    /**
+     * 是不是数组。
+     *
+     * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时
+     * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。
+     *
+     * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号
+     * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。
+     * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。
+     */
+    function isArray(v) {
+        if (typeof Array.isArray === 'function') {
+            return Array.isArray(v);
+        }
+        return Object.prototype.toString.apply(v, []) === '[object Array]';
+    }
+
+    /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */
+    function ownKeys(v) {
+        var keys = [];
+        for (var k in v) {
+            if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) {
+                keys.push(k);
+            }
+        }
+        keys.sort();
+        return keys;
+    }
+
+    /**
+     * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。
+     *
+     * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串,
+     * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。
+     */
+    function canon(v) {
+        if (v === null || v === undefined) {
+            return 'n';
+        }
+        if (isArray(v)) {
+            var parts = [];
+            for (var i = 0; i < v.length; i += 1) {
+                parts.push(canon(v[i]));
+            }
+            return '[' + parts.join(',') + ']';
+        }
+        if (typeof v === 'object') {
+            var keys = ownKeys(v);
+            var out = [];
+            for (var j = 0; j < keys.length; j += 1) {
+                out.push(keys[j] + ':' + canon(v[keys[j]]));
+            }
+            return '{' + out.join(',') + '}';
+        }
+        if (typeof v === 'number') {
+            return 'd' + String(v);
+        }
+        if (typeof v === 'boolean') {
+            return v ? 'T' : 'F';
+        }
+        return 's' + JSON.stringify(String(v));
+    }
+
+    /**
+     * djb2-xor 摘要。
+     *
+     * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分,
+     * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。
+     */
+    function digest(text) {
+        var s = String(text);
+        var h = 5381;
+        for (var i = 0; i < s.length; i += 1) {
+            h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0;   /* |0 截成 32 位,避免精度丢失 */
+        }
+        return (h >>> 0).toString(36) + '-' + s.length.toString(36);
+    }
+
+    /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */
+    function ofSource(one, index) {
+        var s = one || {};
+        return {
+            key: s.key || ('i' + index + ':' + (s.kind || '')),
+            kind: s.kind || '',
+            anchors: s.anchors || '',
+            lists: isArray(s.lists) ? s.lists : [],
+            searchType: s.searchType || '',
+            keyword: s.keyword || '',
+            videoSource: s.videoSource || '',
+            videoKeyword: s.videoKeyword || '',
+            path: s.path || '',
+            expand: s.expand || ''
+        };
+    }
+
+    /**
+     * 算 `id` 段的指纹。
+     *
+     * **分项存**(sources / filter / output 各一份)而不只存一个总串:
+     * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。
+     *
+     * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`,
+     *           引擎传解析后的 `config.id` —— 两者是同一份数据)
+     */
+    function ofIdSection(id) {
+        var section = id || {};
+
+        var list = isArray(section.sources) ? section.sources : [];
+        var sources = [];
+        for (var i = 0; i < list.length; i += 1) {
+            sources.push(ofSource(list[i], i));
+        }
+
+        var f = section.filter || {};
+        var o = section.output || {};
+
+        var sourcesHash = digest(canon(sources));
+        var filterHash = digest(canon({
+            private: f.private === true,
+            noAvatar: f.noAvatar === true,
+            langRegion: f.langRegion || 'any',
+            nicknameCheck: f.nicknameCheck || 'any',
+            follow: f.follow || {},
+            fans: f.fans || {},
+            likes: f.likes || {}
+        }));
+        var outputHash = digest(canon({
+            dir: o.dir || '',
+            historyPath: o.historyPath || '',
+            dedupeHistory: o.dedupeHistory !== false
+        }));
+
+        return {
+            v: VERSION,
+            sources: sourcesHash,
+            filter: filterHash,
+            output: outputHash,
+            all: digest(sourcesHash + filterHash + outputHash)
+        };
+    }
+
+    /**
+     * 比两份指纹。
+     * @returns { same, changed: ['sources'|'filter'|'output'] }
+     */
+    function compare(a, b) {
+        if (!a || !b || a.v !== VERSION || b.v !== VERSION) {
+            return { same: false, changed: ['sources', 'filter', 'output'] };
+        }
+        var changed = [];
+        if (a.sources !== b.sources) {
+            changed.push('sources');
+        }
+        if (a.filter !== b.filter) {
+            changed.push('filter');
+        }
+        if (a.output !== b.output) {
+            changed.push('output');
+        }
+        return { same: changed.length === 0, changed: changed };
+    }
+
+    return {
+        VERSION: VERSION,
+        canon: canon,
+        digest: digest,
+        ofIdSection: ofIdSection,
+        compare: compare
+    };
+}());

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

@@ -44,6 +44,7 @@ const MODULE_ORDER = [
     'sources',      // ID提取的取材清单:不定长列表,与 records 同样走独立 storage key
     'device',
     'service',
+    'fingerprint',  // 配置指纹:与 tiktok-auto/src/fingerprint.js 同算法(断点续跑用)
     'engine',       // 执行引擎桥接:界面与 tiktok-auto 之间的文件通道
     'session',
     'widgets',

+ 690 - 39
tiktok-auto/dist/tiktok-auto.js

@@ -1,6 +1,6 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-28T10:49:20.750Z
- *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, parse, uidset, filters, profile, idsource, resolve, idflow, main
+ *  built 2026-10-28T14:42:55.685Z
+ *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
 (function () {
@@ -4652,6 +4652,589 @@ TikTokAuto.uidset = (function () {
 }());
 
 
+/* ============================== src/fingerprint.js ============================== */
+/**
+ * fingerprint.js — 配置指纹(判断"断点还有效吗")
+ *
+ * ## 为什么要它
+ * 断点续跑的前提是"配置没变过"。取材换了关键词、过滤条件调了门槛,
+ * 上次"已完成"的判定就不再成立 —— 接着跑会得到口径不一致的数据,而用户看不出来。
+ *
+ * ## 为什么不用 hash 库
+ * 项目里没有任何现成的 hash / 稳定序列化工具,而 AutoX 环境里能用的
+ * 也就 Rhino 那点东西(没有 `Math.imul`、没有 ES6 Map/Set 的稳定行为)。
+ * 所以自己写两个小函数:**稳定序列化 + djb2**。
+ *
+ * ## 覆盖哪些字段(这是设计决策,不是随手挑的)
+ *
+ * | 覆盖 | 为什么 |
+ * |---|---|
+ * | `sources[]` 的全部取值字段(**顺序敏感**) | 换账号/关键词/深度/删条目都改变"取哪些人" |
+ * | `filter` 的 private/noAvatar/langRegion/nicknameCheck/follow/fans/likes | 直接决定谁被拦掉 |
+ * | `output` 的 dir/historyPath/dedupeHistory | dir 决定产物位置;后两个决定 `seen` 的初始内容 |
+ *
+ * | 不覆盖 | 为什么 |
+ * |---|---|
+ * | `output.limit` | 约束"跑多少个"而不是"取哪些人";续跑仍按累计数生效 |
+ * | `output.resolveUid` | 只影响收尾是否多产一份映射文件 |
+ * | `output.dryRun` | 演练不写产物,**调用方直接禁续跑**,不必进指纹 |
+ * | `filter.videos` | `filters.judge` 只对它出 soft 提示,不参与判定 |
+ * | `guard.*` / `timeouts` / `probe` / `runtime` | 与"取哪些人"无关 |
+ *
+ * ## ★ 这个文件在界面侧有一份逐字节相同的实现
+ * `tiktok-app/src/fingerprint.js`。**改这里必须同步改那边**,
+ * 否则两侧算出的指纹不同 → 界面说"可以续跑"、引擎说"配置变了"。
+ * `tools/pc-selftest-id.js` 里有一组断言专门比对两份实现的结果。
+ */
+TikTokAuto.fingerprint = (function () {
+    'use strict';
+
+    var VERSION = 1;
+
+    /**
+     * 是不是数组。
+     *
+     * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时
+     * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。
+     *
+     * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号
+     * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。
+     * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。
+     */
+    function isArray(v) {
+        if (typeof Array.isArray === 'function') {
+            return Array.isArray(v);
+        }
+        return Object.prototype.toString.apply(v, []) === '[object Array]';
+    }
+
+    /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */
+    function ownKeys(v) {
+        var keys = [];
+        for (var k in v) {
+            if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) {
+                keys.push(k);
+            }
+        }
+        keys.sort();
+        return keys;
+    }
+
+    /**
+     * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。
+     *
+     * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串,
+     * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。
+     */
+    function canon(v) {
+        if (v === null || v === undefined) {
+            return 'n';
+        }
+        if (isArray(v)) {
+            var parts = [];
+            for (var i = 0; i < v.length; i += 1) {
+                parts.push(canon(v[i]));
+            }
+            return '[' + parts.join(',') + ']';
+        }
+        if (typeof v === 'object') {
+            var keys = ownKeys(v);
+            var out = [];
+            for (var j = 0; j < keys.length; j += 1) {
+                out.push(keys[j] + ':' + canon(v[keys[j]]));
+            }
+            return '{' + out.join(',') + '}';
+        }
+        if (typeof v === 'number') {
+            return 'd' + String(v);
+        }
+        if (typeof v === 'boolean') {
+            return v ? 'T' : 'F';
+        }
+        return 's' + JSON.stringify(String(v));
+    }
+
+    /**
+     * djb2-xor 摘要。
+     *
+     * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分,
+     * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。
+     */
+    function digest(text) {
+        var s = String(text);
+        var h = 5381;
+        for (var i = 0; i < s.length; i += 1) {
+            h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0;   /* |0 截成 32 位,避免精度丢失 */
+        }
+        return (h >>> 0).toString(36) + '-' + s.length.toString(36);
+    }
+
+    /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */
+    function ofSource(one, index) {
+        var s = one || {};
+        return {
+            key: s.key || ('i' + index + ':' + (s.kind || '')),
+            kind: s.kind || '',
+            anchors: s.anchors || '',
+            lists: isArray(s.lists) ? s.lists : [],
+            searchType: s.searchType || '',
+            keyword: s.keyword || '',
+            videoSource: s.videoSource || '',
+            videoKeyword: s.videoKeyword || '',
+            path: s.path || '',
+            expand: s.expand || ''
+        };
+    }
+
+    /**
+     * 算 `id` 段的指纹。
+     *
+     * **分项存**(sources / filter / output 各一份)而不只存一个总串:
+     * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。
+     *
+     * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`,
+     *           引擎传解析后的 `config.id` —— 两者是同一份数据)
+     */
+    function ofIdSection(id) {
+        var section = id || {};
+
+        var list = isArray(section.sources) ? section.sources : [];
+        var sources = [];
+        for (var i = 0; i < list.length; i += 1) {
+            sources.push(ofSource(list[i], i));
+        }
+
+        var f = section.filter || {};
+        var o = section.output || {};
+
+        var sourcesHash = digest(canon(sources));
+        var filterHash = digest(canon({
+            private: f.private === true,
+            noAvatar: f.noAvatar === true,
+            langRegion: f.langRegion || 'any',
+            nicknameCheck: f.nicknameCheck || 'any',
+            follow: f.follow || {},
+            fans: f.fans || {},
+            likes: f.likes || {}
+        }));
+        var outputHash = digest(canon({
+            dir: o.dir || '',
+            historyPath: o.historyPath || '',
+            dedupeHistory: o.dedupeHistory !== false
+        }));
+
+        return {
+            v: VERSION,
+            sources: sourcesHash,
+            filter: filterHash,
+            output: outputHash,
+            all: digest(sourcesHash + filterHash + outputHash)
+        };
+    }
+
+    /**
+     * 比两份指纹。
+     * @returns { same, changed: ['sources'|'filter'|'output'] }
+     */
+    function compare(a, b) {
+        if (!a || !b || a.v !== VERSION || b.v !== VERSION) {
+            return { same: false, changed: ['sources', 'filter', 'output'] };
+        }
+        var changed = [];
+        if (a.sources !== b.sources) {
+            changed.push('sources');
+        }
+        if (a.filter !== b.filter) {
+            changed.push('filter');
+        }
+        if (a.output !== b.output) {
+            changed.push('output');
+        }
+        return { same: changed.length === 0, changed: changed };
+    }
+
+    return {
+        VERSION: VERSION,
+        canon: canon,
+        digest: digest,
+        ofIdSection: ofIdSection,
+        compare: compare
+    };
+}());
+
+
+/* ============================== src/checkpoint.js ============================== */
+/**
+ * checkpoint.js — ID 提取的断点("跑到哪了"的唯一真相)
+ *
+ * ## 粒度:取材条目级,不是屏/行级
+ *
+ * TikTok 的列表内容会变、滚动位置无法复原。记"第几屏第几行"只会造成
+ * 漏掉别人或重复劳动;而「哪条取材已经走完」是稳定事实。
+ * 所以:**跳过已完成的取材;未完成的那条从头重走,靠已收集合跳人**。
+ *
+ * ## 文件
+ * `<id.output.dir>/id_progress.json`,与产物同目录(用户清产物时看得见)。
+ * 全局只保留一份(全局单跑,多份会让"哪份有效"变成判断题)。
+ *
+ * ## 几条硬约束(都踩过或差点踩)
+ *
+ * 1. **只能覆盖写,不能删文件** —— `files.remove` 命中 MCP 危险调用黑名单
+ *    (见 `tools/build.js` 的 `DANGEROUS_RULES` 与 `uidset.js` 文件头)。
+ *    「清空断点」= 覆盖写成 `state:"none"`。
+ * 2. **read 必须容错** —— 写到一半被杀会留下半截 JSON。读不懂就当"没有断点",
+ *    绝不抛异常:产物还在,丢的只是"能不能接着跑"。
+ * 3. **不存已收 uid 列表** —— 结果文件本身就是那份清单,重复存只会出现两份不一致。
+ * 4. **写盘不追每一条 uid** —— 粒度是取材条目级,每收 1 个就写只换来"少重走 19 个人"。
+ *    节奏是:取材边界 + 每 20 个(与产物刷盘同拍)+ 收尾。
+ *
+ * 依赖:TikTokAuto.logger、TikTokAuto.fingerprint(仅 compare 用)
+ */
+TikTokAuto.checkpoint = (function () {
+    'use strict';
+
+    /** 文件名固定:不放用户配置里,避免"换个名字就找不到断点" */
+    var DEFAULT_NAME = 'id_progress.json';
+    var VERSION = 1;
+
+    function logger() { return TikTokAuto.logger; }
+
+    /**
+     * 是不是数组。优先 `Array.isArray`;退回用 `.apply` 而不是 `Function.prototype.call`
+     * —— 后者那个关键字加左括号会命中 MCP 危险调用黑名单(见 `tools/build.js`)。
+     */
+    function isArray(v) {
+        if (typeof Array.isArray === 'function') {
+            return Array.isArray(v);
+        }
+        return Object.prototype.toString.apply(v, []) === '[object Array]';
+    }
+
+    function dirOf(path) {
+        var s = String(path || '');
+        var i = s.lastIndexOf('/');
+        return i > 0 ? s.substring(0, i + 1) : '';
+    }
+
+    /** 断点文件路径(由结果目录派生) */
+    function pathOf(output) {
+        var dir = (output && output.dir) || '/sdcard/Download/tiktok_app';
+        return dir + '/' + DEFAULT_NAME;
+    }
+
+    /**
+     * 新建一份断点。
+     *
+     * @param runId  当前这次运行(界面靠它把记录与断点精确对上)
+     * @param sig    指纹(`fingerprint.ofIdSection(config.id)`)
+     * @param runFile 结果文件绝对路径(续跑时复用它,不新开文件)
+     * @param sources `[{key, kind, label}]`,顺序与 config.id.sources 一致
+     */
+    function empty(runId, sig, runFile, sources) {
+        var list = [];
+        for (var i = 0; i < (sources || []).length; i += 1) {
+            var s = sources[i] || {};
+            list.push({
+                key: s.key || ('i' + i + ':' + (s.kind || '')),
+                kind: s.kind || '',
+                label: s.label || '',
+                status: 'pending',       /* pending | running | done */
+                screens: 0,
+                handled: 0,
+                added: 0
+            });
+        }
+        var now = Date.now();
+        return {
+            v: VERSION,
+            state: 'running',            /* running | stopped | done | none */
+            runId: runId || '',
+            beganAt: now,
+            updatedAt: now,
+            sig: sig || null,
+            runFile: runFile || '',
+            mapFile: '',
+            limitBase: 0,
+            counts: { extracted: 0, skipped: 0, duplicates: 0, openedProfiles: 0 },
+            sources: list,
+            expand: { queue: [], done: [] }
+        };
+    }
+
+    /** 读断点。**任何解析问题都返回 null**(当作没有断点),绝不抛 */
+    function read(path) {
+        try {
+            if (!path || !files.exists(path)) {
+                return null;
+            }
+            var text = String(files.read(path) || '');
+            if (text.replace(/\s/g, '') === '') {
+                return null;
+            }
+            var cp = JSON.parse(text);
+            if (!cp || cp.v !== VERSION) {
+                return null;
+            }
+            /* 半截文件可能缺字段:补齐,别让调用方到处判空 */
+            if (!isArray(cp.sources)) {
+                cp.sources = [];
+            }
+            if (!cp.expand || typeof cp.expand !== 'object') {
+                cp.expand = { queue: [], done: [] };
+            }
+            if (!isArray(cp.expand.queue)) {
+                cp.expand.queue = [];
+            }
+            if (!isArray(cp.expand.done)) {
+                cp.expand.done = [];
+            }
+            return cp;
+        } catch (error) {
+            logger().warn('checkpoint', '断点文件读不懂(当作没有断点):' + error);
+            return null;
+        }
+    }
+
+    /** 覆盖写整份断点。返回是否写成功(失败只记 warn,不影响主流程) */
+    function save(path, cp) {
+        if (!cp) {
+            return false;
+        }
+        try {
+            var dir = dirOf(path);
+            if (dir) {
+                files.ensureDir(dir);
+            }
+            cp.updatedAt = Date.now();
+            files.write(path, JSON.stringify(cp));
+            return true;
+        } catch (error) {
+            logger().warn('checkpoint', '断点写盘失败(不影响已抓到的结果):' + error);
+            return false;
+        }
+    }
+
+    /** 「清空断点」= 覆盖成空壳。**不删文件**(`files.remove` 是危险调用) */
+    function clearStub() {
+        return { v: VERSION, state: 'none', updatedAt: Date.now() };
+    }
+
+    /** 指纹不符时给用户一句人话,说清是哪一段变了 */
+    function nameOf(key) {
+        if (key === 'sources') { return '取材'; }
+        if (key === 'filter') { return '过滤条件'; }
+        if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; }
+        return key;
+    }
+
+    /**
+     * 能不能续跑?—— 顺序很重要,每一步的失败都要给可操作的话。
+     *
+     * 全部判定都在**启动 App 之前**做完:拒绝时零成本返回,不折腾用户手机。
+     *
+     * @param prev          上一次读到的断点(或 null)
+     * @param sig           当前配置指纹
+     * @param runFileExists 上次的结果文件还在不在
+     * @param opts          { dryRun }
+     * @returns { mode:'resume'|'fresh'|'reject', reason, message, changed }
+     */
+    function planResume(prev, sig, runFileExists, opts) {
+        var options = opts || {};
+
+        if (!prev || prev.v !== VERSION || prev.state === 'none') {
+            return { mode: 'fresh', reason: 'no-checkpoint', message: '', changed: [] };
+        }
+        if (prev.state === 'done') {
+            return { mode: 'fresh', reason: 'last-complete', message: '', changed: [] };
+        }
+        if (options.dryRun) {
+            return {
+                mode: 'reject', reason: 'dry-run', changed: [],
+                message: '断点失效:演练模式不写产物,无法续跑'
+            };
+        }
+
+        var cmp = TikTokAuto.fingerprint.compare(prev.sig, sig);
+        if (!cmp.same) {
+            var names = [];
+            for (var i = 0; i < cmp.changed.length; i += 1) {
+                names.push(nameOf(cmp.changed[i]));
+            }
+            return {
+                mode: 'reject', reason: 'config-changed', changed: cmp.changed,
+                message: '断点失效:' + names.join('、') + '已改动,只能重新开始'
+            };
+        }
+
+        if (!runFileExists) {
+            return {
+                mode: 'reject', reason: 'run-file-missing', changed: [],
+                message: '断点失效:上次的结果文件已不存在(' + (prev.runFile || '') + ')'
+            };
+        }
+
+        return { mode: 'resume', reason: 'ok', message: '', changed: [] };
+    }
+
+    /* ------------------------------------------------------------ 取材条目 */
+
+    function findSource(cp, key) {
+        if (!cp || !isArray(cp.sources)) {
+            return null;
+        }
+        for (var i = 0; i < cp.sources.length; i += 1) {
+            if (cp.sources[i].key === key) {
+                return cp.sources[i];
+            }
+        }
+        return null;
+    }
+
+    function sourceDone(cp, key) {
+        var one = findSource(cp, key);
+        return !!(one && one.status === 'done');
+    }
+
+    /**
+     * 更新一条取材的状态。
+     * @param extra 可选 { screens, handled, added }
+     */
+    function markSource(cp, key, status, extra) {
+        var one = findSource(cp, key);
+        if (!one) {
+            return null;
+        }
+        one.status = status;
+        if (extra) {
+            if (extra.screens !== undefined) { one.screens = extra.screens; }
+            if (extra.handled !== undefined) { one.handled = extra.handled; }
+            if (extra.added !== undefined) { one.added = extra.added; }
+        }
+        return one;
+    }
+
+    /** 已完成 / 共几条(给界面写提示条用) */
+    function sourceProgress(cp) {
+        var done = 0;
+        var total = cp && isArray(cp.sources) ? cp.sources.length : 0;
+        for (var i = 0; i < total; i += 1) {
+            if (cp.sources[i].status === 'done') {
+                done += 1;
+            }
+        }
+        return { done: done, total: total };
+    }
+
+    /** 上次停在哪儿(提示条上那句"停在「xxx」") */
+    function stoppedAt(cp) {
+        if (!cp || !isArray(cp.sources)) {
+            return '';
+        }
+        for (var i = 0; i < cp.sources.length; i += 1) {
+            if (cp.sources[i].status !== 'done') {
+                return cp.sources[i].label || '';
+            }
+        }
+        return '';
+    }
+
+    /* ------------------------------------------------------------ 深度展开 */
+
+    function expandKey(uid, which) {
+        return String(uid) + '|' + String(which);
+    }
+
+    function enqueueExpand(cp, uid, which) {
+        if (!cp || !uid) {
+            return false;
+        }
+        var key = expandKey(uid, which);
+        if (cp.expand.done.indexOf(key) >= 0) {
+            return false;                     /* 上次已经展开过,别重复排 */
+        }
+        for (var i = 0; i < cp.expand.queue.length; i += 1) {
+            if (expandKey(cp.expand.queue[i].uid, cp.expand.queue[i].which) === key) {
+                return false;
+            }
+        }
+        cp.expand.queue.push({ uid: String(uid), which: String(which) });
+        return true;
+    }
+
+    function expandDone(cp, uid, which) {
+        return !!cp && cp.expand && cp.expand.done.indexOf(expandKey(uid, which)) >= 0;
+    }
+
+    function markExpandDone(cp, uid, which) {
+        if (!cp || !cp.expand) {
+            return;
+        }
+        var key = expandKey(uid, which);
+        if (cp.expand.done.indexOf(key) < 0) {
+            cp.expand.done.push(key);
+        }
+    }
+
+    /**
+     * 这次还要展开谁。
+     *
+     * @param queue 本轮内存里攒的待展开项(`[{uid, which}]`,由 handleRow 边收边入队)
+     * @returns 过滤掉"上次已完成"之后的那部分
+     */
+    function pendingExpand(cp, queue) {
+        var out = [];
+        for (var i = 0; i < (queue || []).length; i += 1) {
+            var one = queue[i];
+            if (!expandDone(cp, one.uid, one.which)) {
+                out.push(one);
+            }
+        }
+        return out;
+    }
+
+    /* ------------------------------------------------------------ 收尾判定 */
+
+    /** 所有取材都 done,且展开队列里没有未完成的 → 这一轮真的做完了 */
+    function allDone(cp) {
+        if (!cp || !isArray(cp.sources) || cp.sources.length === 0) {
+            return false;
+        }
+        for (var i = 0; i < cp.sources.length; i += 1) {
+            if (cp.sources[i].status !== 'done') {
+                return false;
+            }
+        }
+        for (var j = 0; j < cp.expand.queue.length; j += 1) {
+            if (!expandDone(cp, cp.expand.queue[j].uid, cp.expand.queue[j].which)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    return {
+        DEFAULT_NAME: DEFAULT_NAME,
+        VERSION: VERSION,
+        pathOf: pathOf,
+        empty: empty,
+        read: read,
+        save: save,
+        clearStub: clearStub,
+        planResume: planResume,
+        nameOf: nameOf,
+        sourceDone: sourceDone,
+        markSource: markSource,
+        sourceProgress: sourceProgress,
+        stoppedAt: stoppedAt,
+        enqueueExpand: enqueueExpand,
+        expandDone: expandDone,
+        markExpandDone: markExpandDone,
+        pendingExpand: pendingExpand,
+        allDone: allDone
+    };
+}());
+
+
 /* ============================== src/filters.js ============================== */
 /**
  * filters.js — 「只要这样的账号」的判定
@@ -5680,11 +6263,69 @@ TikTokAuto.idsource = (function () {
     /** 翻一个列表最多翻多少屏。防止"永远有新内容"(推荐流)把一轮拖成无限长。 */
     var MAX_SCREENS = 60;
 
-    /** 连续这么多屏都没有新 uid 就认为到底了(有的列表末尾会重复推荐位) */
+    /**
+     * 连续这么多次"翻屏后内容不变"就认为到头了(有的列表末尾会重复推荐位)。
+     *
+     * ★ 注意判据是**翻不动**,不是"没有新 uid"(2026-10-28 修,见 walkStep 的注释)。
+     */
     var IDLE_SCREENS_TO_STOP = 2;
 
     function logger() { return TikTokAuto.logger; }
 
+    /**
+     * 一屏处理完之后决定下一步。纯函数,便于在 PC 上回归(`tools/pc-selftest-id.js`)。
+     *
+     * ★ 这里修的是一个**真 bug**(2026-10-28)。
+     *
+     * 旧实现有两个地方同时给 `idle` 加一:
+     *   ① 本屏 `fresh` 为空(全是已收过的人)→ idle + 1
+     *   ② 翻屏后签名不变 → idle + 1
+     * 而 `idflow` 会先把 `uid_extracted.txt` 预载进 `seen`。于是**第二轮跑同一个账号**时,
+     * 前两屏全是历史 → idle=2 → **在列表顶部就判"到底"**,一条新人都拿不到。
+     * 断点续跑要把上次的产物也预载进 `seen`,这个误判会从"偶发"变成"必然"。
+     *
+     * 正解:**只看"翻得动翻不动"**。一屏全是旧人说明"旧人多",不代表列表到头;
+     * 只有"滚动之后签名没变"才是真的到头。
+     *
+     * @param state { screens, stuck }
+     * @param view  { sigBefore, sigAfter, rowsAfter, maxScreens }
+     * @returns { action: 'continue'|'break', state, reason }
+     */
+    function walkStep(state, view) {
+        var stuck = state.stuck;
+        var screens = state.screens;
+
+        if (view.rowsAfter === 0) {
+            /* 翻完这一屏读不到行了:可能还在加载,也可能真的到头。
+             * 不在这里下结论 —— 交给下一轮循环开头的"空行即结束"处理
+             * (这样"加载慢"和"列表结束"两个判断只在一处)。 */
+            return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
+        }
+
+        if (view.sigAfter === view.sigBefore) {
+            stuck += 1;
+            if (stuck >= IDLE_SCREENS_TO_STOP) {
+                return {
+                    action: 'break',
+                    reason: '翻屏后内容不变,认为列表到底',
+                    state: { screens: screens, stuck: stuck }
+                };
+            }
+        } else {
+            /* 内容变了 → 列表还在往下走。**不管这一屏里有没有新人**都归零。 */
+            stuck = 0;
+        }
+
+        if (screens >= view.maxScreens) {
+            return {
+                action: 'break',
+                reason: '达到单列表翻屏上限 ' + view.maxScreens + ' 屏',
+                state: { screens: screens, stuck: stuck }
+            };
+        }
+        return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
+    }
+
     /** 一行 uid 的临时载体(列表行带昵称与计数,用户页带更多资料,统一成这个形状) */
     function rowOf(uid, nickname, extra) {
         var one = { uid: uid, nickname: nickname || '' };
@@ -5703,28 +6344,32 @@ TikTokAuto.idsource = (function () {
     /**
      * 在"一个可滚动的用户列表"上逐行推进。
      *
-     * 关键设计:**用 uid 集合判断进度,不用行下标**。
+     * 关键设计:**用 uid 集合决定"哪一行还要处理",不用行下标**。
      * 因为每次点进用户页再退回来,列表的节点树都重建过一遍(引用失效),
      * 只有 uid 是稳的。
      *
-     * @param ctx { seen, onRow(row), shouldStop() }
-     * @returns 处理了多少行
+     * 但**停止判据不能用 uid 集合** —— 那会把"全是旧人"误判成"列表到底"(见 walkStep)。
+     *
+     * @param ctx { seen, onRow(row), shouldStop(), maxScreens?, stat?, config? }
+     *   maxScreens:本次最多翻多少屏(续跑时按上次已翻的屏数放大,见 idflow)
+     *   stat:传入则回填 { screens, handled },供断点记录
+     * @returns 处理了多少行(本屏新收的)
      */
     function walkList(ctx) {
         var log = logger();
         var handled = 0;
-        var idle = 0;
-        var screens = 0;
+        var maxScreens = ctx.maxScreens || MAX_SCREENS;
+        var state = { screens: 0, stuck: 0 };
 
-        while (screens < MAX_SCREENS) {
+        while (state.screens < maxScreens) {
             if (ctx.shouldStop()) {
                 log.info('idsource', '达到停止条件,结束当前列表');
                 break;
             }
-            screens += 1;
+            state.screens += 1;
 
             var rows = TikTokAuto.profile.listRows();
-            if (rows.length === 0 && screens === 1) {
+            if (rows.length === 0 && state.screens === 1) {
                 /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
                  * 从用户页点开粉丝列表、或从结果页切到用户页签后,
                  * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 ——
@@ -5739,45 +6384,47 @@ TikTokAuto.idsource = (function () {
                 break;
             }
 
-            var fresh = [];
+            /* 一行一行处理:只处理没见过的人(见过的直接跳过,但**不因此判定列表到底**)。
+             * 需要资料时会点进用户页再退回(实测位置保持)。 */
+            var stopped = false;
             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,认为列表到底');
+                if (ctx.shouldStop()) {
+                    stopped = true;
                     break;
                 }
-            } else {
-                idle = 0;
-            }
-
-            /* 一行一行处理:需要资料时会点进用户页再退回(实测位置保持) */
-            for (var j = 0; j < fresh.length; j += 1) {
-                if (ctx.shouldStop()) {
-                    return handled;
+                if (ctx.seen.has(rows[i].uid)) {
+                    continue;
                 }
-                ctx.onRow(fresh[j]);
-                ctx.seen.add(fresh[j].uid);     /* 处理过就算见过,避免同一行被反复点开 */
+                ctx.onRow(rows[i]);
+                ctx.seen.add(rows[i].uid);     /* 处理过就算见过,避免同一行被反复点开 */
                 handled += 1;
             }
+            if (stopped) {
+                break;
+            }
 
-            /* 翻一屏再看有没有新的 */
+            /* 翻一屏,用"内容有没有变"判断是否到头 */
             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;
-                }
+            var afterRows = TikTokAuto.profile.listRows();
+
+            var step = walkStep(state, {
+                sigBefore: before,
+                sigAfter: signatureOfRows(afterRows),
+                rowsAfter: afterRows.length,
+                maxScreens: maxScreens
+            });
+            state = step.state;
+            if (step.action === 'break') {
+                log.info('idsource', step.reason);
+                break;
             }
         }
+
+        if (ctx.stat) {
+            ctx.stat.screens = state.screens;
+            ctx.stat.handled = handled;
+        }
         return handled;
     }
 
@@ -6220,8 +6867,12 @@ TikTokAuto.idsource = (function () {
 
     return {
         MAX_SCREENS: MAX_SCREENS,
+        IDLE_SCREENS_TO_STOP: IDLE_SCREENS_TO_STOP,
         run: run,
         walkList: walkList,
+        /* 纯函数,供 PC 回归(`tools/pc-selftest-id.js`)——
+         * 停止判据是"翻不动了"而不是"没有新人",这条必须能被离线断言。 */
+        walkStep: walkStep,
         readTextSource: readTextSource,
         describe: describe
     };

+ 389 - 0
tiktok-auto/src/checkpoint.js

@@ -0,0 +1,389 @@
+/**
+ * checkpoint.js — ID 提取的断点("跑到哪了"的唯一真相)
+ *
+ * ## 粒度:取材条目级,不是屏/行级
+ *
+ * TikTok 的列表内容会变、滚动位置无法复原。记"第几屏第几行"只会造成
+ * 漏掉别人或重复劳动;而「哪条取材已经走完」是稳定事实。
+ * 所以:**跳过已完成的取材;未完成的那条从头重走,靠已收集合跳人**。
+ *
+ * ## 文件
+ * `<id.output.dir>/id_progress.json`,与产物同目录(用户清产物时看得见)。
+ * 全局只保留一份(全局单跑,多份会让"哪份有效"变成判断题)。
+ *
+ * ## 几条硬约束(都踩过或差点踩)
+ *
+ * 1. **只能覆盖写,不能删文件** —— `files.remove` 命中 MCP 危险调用黑名单
+ *    (见 `tools/build.js` 的 `DANGEROUS_RULES` 与 `uidset.js` 文件头)。
+ *    「清空断点」= 覆盖写成 `state:"none"`。
+ * 2. **read 必须容错** —— 写到一半被杀会留下半截 JSON。读不懂就当"没有断点",
+ *    绝不抛异常:产物还在,丢的只是"能不能接着跑"。
+ * 3. **不存已收 uid 列表** —— 结果文件本身就是那份清单,重复存只会出现两份不一致。
+ * 4. **写盘不追每一条 uid** —— 粒度是取材条目级,每收 1 个就写只换来"少重走 19 个人"。
+ *    节奏是:取材边界 + 每 20 个(与产物刷盘同拍)+ 收尾。
+ *
+ * 依赖:TikTokAuto.logger、TikTokAuto.fingerprint(仅 compare 用)
+ */
+TikTokAuto.checkpoint = (function () {
+    'use strict';
+
+    /** 文件名固定:不放用户配置里,避免"换个名字就找不到断点" */
+    var DEFAULT_NAME = 'id_progress.json';
+    var VERSION = 1;
+
+    function logger() { return TikTokAuto.logger; }
+
+    /**
+     * 是不是数组。优先 `Array.isArray`;退回用 `.apply` 而不是 `Function.prototype.call`
+     * —— 后者那个关键字加左括号会命中 MCP 危险调用黑名单(见 `tools/build.js`)。
+     */
+    function isArray(v) {
+        if (typeof Array.isArray === 'function') {
+            return Array.isArray(v);
+        }
+        return Object.prototype.toString.apply(v, []) === '[object Array]';
+    }
+
+    function dirOf(path) {
+        var s = String(path || '');
+        var i = s.lastIndexOf('/');
+        return i > 0 ? s.substring(0, i + 1) : '';
+    }
+
+    /** 断点文件路径(由结果目录派生) */
+    function pathOf(output) {
+        var dir = (output && output.dir) || '/sdcard/Download/tiktok_app';
+        return dir + '/' + DEFAULT_NAME;
+    }
+
+    /**
+     * 新建一份断点。
+     *
+     * @param runId  当前这次运行(界面靠它把记录与断点精确对上)
+     * @param sig    指纹(`fingerprint.ofIdSection(config.id)`)
+     * @param runFile 结果文件绝对路径(续跑时复用它,不新开文件)
+     * @param sources `[{key, kind, label}]`,顺序与 config.id.sources 一致
+     */
+    function empty(runId, sig, runFile, sources) {
+        var list = [];
+        for (var i = 0; i < (sources || []).length; i += 1) {
+            var s = sources[i] || {};
+            list.push({
+                key: s.key || ('i' + i + ':' + (s.kind || '')),
+                kind: s.kind || '',
+                label: s.label || '',
+                status: 'pending',       /* pending | running | done */
+                screens: 0,
+                handled: 0,
+                added: 0
+            });
+        }
+        var now = Date.now();
+        return {
+            v: VERSION,
+            state: 'running',            /* running | stopped | done | none */
+            runId: runId || '',
+            beganAt: now,
+            updatedAt: now,
+            sig: sig || null,
+            runFile: runFile || '',
+            mapFile: '',
+            limitBase: 0,
+            counts: { extracted: 0, skipped: 0, duplicates: 0, openedProfiles: 0 },
+            sources: list,
+            expand: { queue: [], done: [] }
+        };
+    }
+
+    /** 读断点。**任何解析问题都返回 null**(当作没有断点),绝不抛 */
+    function read(path) {
+        try {
+            if (!path || !files.exists(path)) {
+                return null;
+            }
+            var text = String(files.read(path) || '');
+            if (text.replace(/\s/g, '') === '') {
+                return null;
+            }
+            var cp = JSON.parse(text);
+            if (!cp || cp.v !== VERSION) {
+                return null;
+            }
+            /* 半截文件可能缺字段:补齐,别让调用方到处判空 */
+            if (!isArray(cp.sources)) {
+                cp.sources = [];
+            }
+            if (!cp.expand || typeof cp.expand !== 'object') {
+                cp.expand = { queue: [], done: [] };
+            }
+            if (!isArray(cp.expand.queue)) {
+                cp.expand.queue = [];
+            }
+            if (!isArray(cp.expand.done)) {
+                cp.expand.done = [];
+            }
+            return cp;
+        } catch (error) {
+            logger().warn('checkpoint', '断点文件读不懂(当作没有断点):' + error);
+            return null;
+        }
+    }
+
+    /** 覆盖写整份断点。返回是否写成功(失败只记 warn,不影响主流程) */
+    function save(path, cp) {
+        if (!cp) {
+            return false;
+        }
+        try {
+            var dir = dirOf(path);
+            if (dir) {
+                files.ensureDir(dir);
+            }
+            cp.updatedAt = Date.now();
+            files.write(path, JSON.stringify(cp));
+            return true;
+        } catch (error) {
+            logger().warn('checkpoint', '断点写盘失败(不影响已抓到的结果):' + error);
+            return false;
+        }
+    }
+
+    /** 「清空断点」= 覆盖成空壳。**不删文件**(`files.remove` 是危险调用) */
+    function clearStub() {
+        return { v: VERSION, state: 'none', updatedAt: Date.now() };
+    }
+
+    /** 指纹不符时给用户一句人话,说清是哪一段变了 */
+    function nameOf(key) {
+        if (key === 'sources') { return '取材'; }
+        if (key === 'filter') { return '过滤条件'; }
+        if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; }
+        return key;
+    }
+
+    /**
+     * 能不能续跑?—— 顺序很重要,每一步的失败都要给可操作的话。
+     *
+     * 全部判定都在**启动 App 之前**做完:拒绝时零成本返回,不折腾用户手机。
+     *
+     * @param prev          上一次读到的断点(或 null)
+     * @param sig           当前配置指纹
+     * @param runFileExists 上次的结果文件还在不在
+     * @param opts          { dryRun }
+     * @returns { mode:'resume'|'fresh'|'reject', reason, message, changed }
+     */
+    function planResume(prev, sig, runFileExists, opts) {
+        var options = opts || {};
+
+        if (!prev || prev.v !== VERSION || prev.state === 'none') {
+            return { mode: 'fresh', reason: 'no-checkpoint', message: '', changed: [] };
+        }
+        if (prev.state === 'done') {
+            return { mode: 'fresh', reason: 'last-complete', message: '', changed: [] };
+        }
+        if (options.dryRun) {
+            return {
+                mode: 'reject', reason: 'dry-run', changed: [],
+                message: '断点失效:演练模式不写产物,无法续跑'
+            };
+        }
+
+        var cmp = TikTokAuto.fingerprint.compare(prev.sig, sig);
+        if (!cmp.same) {
+            var names = [];
+            for (var i = 0; i < cmp.changed.length; i += 1) {
+                names.push(nameOf(cmp.changed[i]));
+            }
+            return {
+                mode: 'reject', reason: 'config-changed', changed: cmp.changed,
+                message: '断点失效:' + names.join('、') + '已改动,只能重新开始'
+            };
+        }
+
+        if (!runFileExists) {
+            return {
+                mode: 'reject', reason: 'run-file-missing', changed: [],
+                message: '断点失效:上次的结果文件已不存在(' + (prev.runFile || '') + ')'
+            };
+        }
+
+        return { mode: 'resume', reason: 'ok', message: '', changed: [] };
+    }
+
+    /* ------------------------------------------------------------ 取材条目 */
+
+    function findSource(cp, key) {
+        if (!cp || !isArray(cp.sources)) {
+            return null;
+        }
+        for (var i = 0; i < cp.sources.length; i += 1) {
+            if (cp.sources[i].key === key) {
+                return cp.sources[i];
+            }
+        }
+        return null;
+    }
+
+    function sourceDone(cp, key) {
+        var one = findSource(cp, key);
+        return !!(one && one.status === 'done');
+    }
+
+    /**
+     * 更新一条取材的状态。
+     * @param extra 可选 { screens, handled, added }
+     */
+    function markSource(cp, key, status, extra) {
+        var one = findSource(cp, key);
+        if (!one) {
+            return null;
+        }
+        one.status = status;
+        if (extra) {
+            if (extra.screens !== undefined) { one.screens = extra.screens; }
+            if (extra.handled !== undefined) { one.handled = extra.handled; }
+            if (extra.added !== undefined) { one.added = extra.added; }
+        }
+        return one;
+    }
+
+    /** 已完成 / 共几条(给界面写提示条用) */
+    function sourceProgress(cp) {
+        var done = 0;
+        var total = cp && isArray(cp.sources) ? cp.sources.length : 0;
+        for (var i = 0; i < total; i += 1) {
+            if (cp.sources[i].status === 'done') {
+                done += 1;
+            }
+        }
+        return { done: done, total: total };
+    }
+
+    /** 上次停在哪儿(提示条上那句"停在「xxx」") */
+    function stoppedAt(cp) {
+        if (!cp || !isArray(cp.sources)) {
+            return '';
+        }
+        for (var i = 0; i < cp.sources.length; i += 1) {
+            if (cp.sources[i].status !== 'done') {
+                return cp.sources[i].label || '';
+            }
+        }
+        return '';
+    }
+
+    /* ------------------------------------------------------------ 深度展开 */
+
+    function expandKey(uid, which) {
+        return String(uid) + '|' + String(which);
+    }
+
+    function enqueueExpand(cp, uid, which) {
+        if (!cp || !uid) {
+            return false;
+        }
+        var key = expandKey(uid, which);
+        if (cp.expand.done.indexOf(key) >= 0) {
+            return false;                     /* 上次已经展开过,别重复排 */
+        }
+        for (var i = 0; i < cp.expand.queue.length; i += 1) {
+            if (expandKey(cp.expand.queue[i].uid, cp.expand.queue[i].which) === key) {
+                return false;
+            }
+        }
+        cp.expand.queue.push({ uid: String(uid), which: String(which) });
+        return true;
+    }
+
+    function expandDone(cp, uid, which) {
+        return !!cp && cp.expand && cp.expand.done.indexOf(expandKey(uid, which)) >= 0;
+    }
+
+    function markExpandDone(cp, uid, which) {
+        if (!cp || !cp.expand) {
+            return;
+        }
+        var key = expandKey(uid, which);
+        if (cp.expand.done.indexOf(key) < 0) {
+            cp.expand.done.push(key);
+        }
+    }
+
+    /**
+     * 这次还要展开谁。
+     *
+     * @param queue 本轮内存里攒的待展开项(`[{uid, which}]`,由 handleRow 边收边入队)
+     * @returns 过滤掉"上次已完成"之后的那部分
+     */
+    function pendingExpand(cp, queue) {
+        var out = [];
+        for (var i = 0; i < (queue || []).length; i += 1) {
+            var one = queue[i];
+            if (!expandDone(cp, one.uid, one.which)) {
+                out.push(one);
+            }
+        }
+        return out;
+    }
+
+    /* ------------------------------------------------------------ 收尾判定 */
+
+    /** 所有取材都 done,且展开队列里没有未完成的 → 这一轮真的做完了 */
+    function allDone(cp) {
+        if (!cp || !isArray(cp.sources) || cp.sources.length === 0) {
+            return false;
+        }
+        for (var i = 0; i < cp.sources.length; i += 1) {
+            if (cp.sources[i].status !== 'done') {
+                return false;
+            }
+        }
+        for (var j = 0; j < cp.expand.queue.length; j += 1) {
+            if (!expandDone(cp, cp.expand.queue[j].uid, cp.expand.queue[j].which)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 「提取满 N 个就停」—— 续跑时必须**按累计数**算。
+     *
+     * 续跑的人均语义是"这一轮凑够 N 个",那就变成设了 100 个、跑了 60 个被停、
+     * 接着再收 100 个 = 一共 160 个。所以基数是"上次已落盘的条数",
+     * 而那个数以**结果文件的行数**为准(文件是真相,不信 JSON 里的计数)。
+     *
+     * @param cp               断点(读 `limitBase`)
+     * @param limit            用户设的上限(0 = 不限)
+     * @param currentExtracted 本轮新增
+     */
+    function limitReached(cp, limit, currentExtracted) {
+        if (!(limit > 0)) {
+            return false;
+        }
+        var base = (cp && cp.limitBase) || 0;
+        return (base + (currentExtracted || 0)) >= limit;
+    }
+
+    return {
+        DEFAULT_NAME: DEFAULT_NAME,
+        VERSION: VERSION,
+        pathOf: pathOf,
+        empty: empty,
+        read: read,
+        save: save,
+        clearStub: clearStub,
+        planResume: planResume,
+        nameOf: nameOf,
+        sourceDone: sourceDone,
+        markSource: markSource,
+        sourceProgress: sourceProgress,
+        stoppedAt: stoppedAt,
+        enqueueExpand: enqueueExpand,
+        expandDone: expandDone,
+        markExpandDone: markExpandDone,
+        pendingExpand: pendingExpand,
+        allDone: allDone,
+        limitReached: limitReached
+    };
+}());

+ 208 - 0
tiktok-auto/src/fingerprint.js

@@ -0,0 +1,208 @@
+/**
+ * fingerprint.js — 配置指纹(判断"断点还有效吗")
+ *
+ * ## 为什么要它
+ * 断点续跑的前提是"配置没变过"。取材换了关键词、过滤条件调了门槛,
+ * 上次"已完成"的判定就不再成立 —— 接着跑会得到口径不一致的数据,而用户看不出来。
+ *
+ * ## 为什么不用 hash 库
+ * 项目里没有任何现成的 hash / 稳定序列化工具,而 AutoX 环境里能用的
+ * 也就 Rhino 那点东西(没有 `Math.imul`、没有 ES6 Map/Set 的稳定行为)。
+ * 所以自己写两个小函数:**稳定序列化 + djb2**。
+ *
+ * ## 覆盖哪些字段(这是设计决策,不是随手挑的)
+ *
+ * | 覆盖 | 为什么 |
+ * |---|---|
+ * | `sources[]` 的全部取值字段(**顺序敏感**) | 换账号/关键词/深度/删条目都改变"取哪些人" |
+ * | `filter` 的 private/noAvatar/langRegion/nicknameCheck/follow/fans/likes | 直接决定谁被拦掉 |
+ * | `output` 的 dir/historyPath/dedupeHistory | dir 决定产物位置;后两个决定 `seen` 的初始内容 |
+ *
+ * | 不覆盖 | 为什么 |
+ * |---|---|
+ * | `output.limit` | 约束"跑多少个"而不是"取哪些人";续跑仍按累计数生效 |
+ * | `output.resolveUid` | 只影响收尾是否多产一份映射文件 |
+ * | `output.dryRun` | 演练不写产物,**调用方直接禁续跑**,不必进指纹 |
+ * | `filter.videos` | `filters.judge` 只对它出 soft 提示,不参与判定 |
+ * | `guard.*` / `timeouts` / `probe` / `runtime` | 与"取哪些人"无关 |
+ *
+ * ## ★ 这个文件在界面侧有一份逐字节相同的实现
+ * `tiktok-app/src/fingerprint.js`。**改这里必须同步改那边**,
+ * 否则两侧算出的指纹不同 → 界面说"可以续跑"、引擎说"配置变了"。
+ * `tools/pc-selftest-id.js` 里有一组断言专门比对两份实现的结果。
+ */
+TikTokAuto.fingerprint = (function () {
+    'use strict';
+
+    var VERSION = 1;
+
+    /**
+     * 是不是数组。
+     *
+     * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时
+     * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。
+     *
+     * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号
+     * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。
+     * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。
+     */
+    function isArray(v) {
+        if (typeof Array.isArray === 'function') {
+            return Array.isArray(v);
+        }
+        return Object.prototype.toString.apply(v, []) === '[object Array]';
+    }
+
+    /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */
+    function ownKeys(v) {
+        var keys = [];
+        for (var k in v) {
+            if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) {
+                keys.push(k);
+            }
+        }
+        keys.sort();
+        return keys;
+    }
+
+    /**
+     * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。
+     *
+     * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串,
+     * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。
+     */
+    function canon(v) {
+        if (v === null || v === undefined) {
+            return 'n';
+        }
+        if (isArray(v)) {
+            var parts = [];
+            for (var i = 0; i < v.length; i += 1) {
+                parts.push(canon(v[i]));
+            }
+            return '[' + parts.join(',') + ']';
+        }
+        if (typeof v === 'object') {
+            var keys = ownKeys(v);
+            var out = [];
+            for (var j = 0; j < keys.length; j += 1) {
+                out.push(keys[j] + ':' + canon(v[keys[j]]));
+            }
+            return '{' + out.join(',') + '}';
+        }
+        if (typeof v === 'number') {
+            return 'd' + String(v);
+        }
+        if (typeof v === 'boolean') {
+            return v ? 'T' : 'F';
+        }
+        return 's' + JSON.stringify(String(v));
+    }
+
+    /**
+     * djb2-xor 摘要。
+     *
+     * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分,
+     * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。
+     */
+    function digest(text) {
+        var s = String(text);
+        var h = 5381;
+        for (var i = 0; i < s.length; i += 1) {
+            h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0;   /* |0 截成 32 位,避免精度丢失 */
+        }
+        return (h >>> 0).toString(36) + '-' + s.length.toString(36);
+    }
+
+    /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */
+    function ofSource(one, index) {
+        var s = one || {};
+        return {
+            key: s.key || ('i' + index + ':' + (s.kind || '')),
+            kind: s.kind || '',
+            anchors: s.anchors || '',
+            lists: isArray(s.lists) ? s.lists : [],
+            searchType: s.searchType || '',
+            keyword: s.keyword || '',
+            videoSource: s.videoSource || '',
+            videoKeyword: s.videoKeyword || '',
+            path: s.path || '',
+            expand: s.expand || ''
+        };
+    }
+
+    /**
+     * 算 `id` 段的指纹。
+     *
+     * **分项存**(sources / filter / output 各一份)而不只存一个总串:
+     * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。
+     *
+     * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`,
+     *           引擎传解析后的 `config.id` —— 两者是同一份数据)
+     */
+    function ofIdSection(id) {
+        var section = id || {};
+
+        var list = isArray(section.sources) ? section.sources : [];
+        var sources = [];
+        for (var i = 0; i < list.length; i += 1) {
+            sources.push(ofSource(list[i], i));
+        }
+
+        var f = section.filter || {};
+        var o = section.output || {};
+
+        var sourcesHash = digest(canon(sources));
+        var filterHash = digest(canon({
+            private: f.private === true,
+            noAvatar: f.noAvatar === true,
+            langRegion: f.langRegion || 'any',
+            nicknameCheck: f.nicknameCheck || 'any',
+            follow: f.follow || {},
+            fans: f.fans || {},
+            likes: f.likes || {}
+        }));
+        var outputHash = digest(canon({
+            dir: o.dir || '',
+            historyPath: o.historyPath || '',
+            dedupeHistory: o.dedupeHistory !== false
+        }));
+
+        return {
+            v: VERSION,
+            sources: sourcesHash,
+            filter: filterHash,
+            output: outputHash,
+            all: digest(sourcesHash + filterHash + outputHash)
+        };
+    }
+
+    /**
+     * 比两份指纹。
+     * @returns { same, changed: ['sources'|'filter'|'output'] }
+     */
+    function compare(a, b) {
+        if (!a || !b || a.v !== VERSION || b.v !== VERSION) {
+            return { same: false, changed: ['sources', 'filter', 'output'] };
+        }
+        var changed = [];
+        if (a.sources !== b.sources) {
+            changed.push('sources');
+        }
+        if (a.filter !== b.filter) {
+            changed.push('filter');
+        }
+        if (a.output !== b.output) {
+            changed.push('output');
+        }
+        return { same: changed.length === 0, changed: changed };
+    }
+
+    return {
+        VERSION: VERSION,
+        canon: canon,
+        digest: digest,
+        ofIdSection: ofIdSection,
+        compare: compare
+    };
+}());

+ 105 - 37
tiktok-auto/src/idsource.js

@@ -30,11 +30,69 @@ TikTokAuto.idsource = (function () {
     /** 翻一个列表最多翻多少屏。防止"永远有新内容"(推荐流)把一轮拖成无限长。 */
     var MAX_SCREENS = 60;
 
-    /** 连续这么多屏都没有新 uid 就认为到底了(有的列表末尾会重复推荐位) */
+    /**
+     * 连续这么多次"翻屏后内容不变"就认为到头了(有的列表末尾会重复推荐位)。
+     *
+     * ★ 注意判据是**翻不动**,不是"没有新 uid"(2026-10-28 修,见 walkStep 的注释)。
+     */
     var IDLE_SCREENS_TO_STOP = 2;
 
     function logger() { return TikTokAuto.logger; }
 
+    /**
+     * 一屏处理完之后决定下一步。纯函数,便于在 PC 上回归(`tools/pc-selftest-id.js`)。
+     *
+     * ★ 这里修的是一个**真 bug**(2026-10-28)。
+     *
+     * 旧实现有两个地方同时给 `idle` 加一:
+     *   ① 本屏 `fresh` 为空(全是已收过的人)→ idle + 1
+     *   ② 翻屏后签名不变 → idle + 1
+     * 而 `idflow` 会先把 `uid_extracted.txt` 预载进 `seen`。于是**第二轮跑同一个账号**时,
+     * 前两屏全是历史 → idle=2 → **在列表顶部就判"到底"**,一条新人都拿不到。
+     * 断点续跑要把上次的产物也预载进 `seen`,这个误判会从"偶发"变成"必然"。
+     *
+     * 正解:**只看"翻得动翻不动"**。一屏全是旧人说明"旧人多",不代表列表到头;
+     * 只有"滚动之后签名没变"才是真的到头。
+     *
+     * @param state { screens, stuck }
+     * @param view  { sigBefore, sigAfter, rowsAfter, maxScreens }
+     * @returns { action: 'continue'|'break', state, reason }
+     */
+    function walkStep(state, view) {
+        var stuck = state.stuck;
+        var screens = state.screens;
+
+        if (view.rowsAfter === 0) {
+            /* 翻完这一屏读不到行了:可能还在加载,也可能真的到头。
+             * 不在这里下结论 —— 交给下一轮循环开头的"空行即结束"处理
+             * (这样"加载慢"和"列表结束"两个判断只在一处)。 */
+            return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
+        }
+
+        if (view.sigAfter === view.sigBefore) {
+            stuck += 1;
+            if (stuck >= IDLE_SCREENS_TO_STOP) {
+                return {
+                    action: 'break',
+                    reason: '翻屏后内容不变,认为列表到底',
+                    state: { screens: screens, stuck: stuck }
+                };
+            }
+        } else {
+            /* 内容变了 → 列表还在往下走。**不管这一屏里有没有新人**都归零。 */
+            stuck = 0;
+        }
+
+        if (screens >= view.maxScreens) {
+            return {
+                action: 'break',
+                reason: '达到单列表翻屏上限 ' + view.maxScreens + ' 屏',
+                state: { screens: screens, stuck: stuck }
+            };
+        }
+        return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
+    }
+
     /** 一行 uid 的临时载体(列表行带昵称与计数,用户页带更多资料,统一成这个形状) */
     function rowOf(uid, nickname, extra) {
         var one = { uid: uid, nickname: nickname || '' };
@@ -53,28 +111,32 @@ TikTokAuto.idsource = (function () {
     /**
      * 在"一个可滚动的用户列表"上逐行推进。
      *
-     * 关键设计:**用 uid 集合判断进度,不用行下标**。
+     * 关键设计:**用 uid 集合决定"哪一行还要处理",不用行下标**。
      * 因为每次点进用户页再退回来,列表的节点树都重建过一遍(引用失效),
      * 只有 uid 是稳的。
      *
-     * @param ctx { seen, onRow(row), shouldStop() }
-     * @returns 处理了多少行
+     * 但**停止判据不能用 uid 集合** —— 那会把"全是旧人"误判成"列表到底"(见 walkStep)。
+     *
+     * @param ctx { seen, onRow(row), shouldStop(), maxScreens?, stat?, config? }
+     *   maxScreens:本次最多翻多少屏(续跑时按上次已翻的屏数放大,见 idflow)
+     *   stat:传入则回填 { screens, handled },供断点记录
+     * @returns 处理了多少行(本屏新收的)
      */
     function walkList(ctx) {
         var log = logger();
         var handled = 0;
-        var idle = 0;
-        var screens = 0;
+        var maxScreens = ctx.maxScreens || MAX_SCREENS;
+        var state = { screens: 0, stuck: 0 };
 
-        while (screens < MAX_SCREENS) {
+        while (state.screens < maxScreens) {
             if (ctx.shouldStop()) {
                 log.info('idsource', '达到停止条件,结束当前列表');
                 break;
             }
-            screens += 1;
+            state.screens += 1;
 
             var rows = TikTokAuto.profile.listRows();
-            if (rows.length === 0 && screens === 1) {
+            if (rows.length === 0 && state.screens === 1) {
                 /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
                  * 从用户页点开粉丝列表、或从结果页切到用户页签后,
                  * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 ——
@@ -89,45 +151,47 @@ TikTokAuto.idsource = (function () {
                 break;
             }
 
-            var fresh = [];
+            /* 一行一行处理:只处理没见过的人(见过的直接跳过,但**不因此判定列表到底**)。
+             * 需要资料时会点进用户页再退回(实测位置保持)。 */
+            var stopped = false;
             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,认为列表到底');
+                if (ctx.shouldStop()) {
+                    stopped = true;
                     break;
                 }
-            } else {
-                idle = 0;
-            }
-
-            /* 一行一行处理:需要资料时会点进用户页再退回(实测位置保持) */
-            for (var j = 0; j < fresh.length; j += 1) {
-                if (ctx.shouldStop()) {
-                    return handled;
+                if (ctx.seen.has(rows[i].uid)) {
+                    continue;
                 }
-                ctx.onRow(fresh[j]);
-                ctx.seen.add(fresh[j].uid);     /* 处理过就算见过,避免同一行被反复点开 */
+                ctx.onRow(rows[i]);
+                ctx.seen.add(rows[i].uid);     /* 处理过就算见过,避免同一行被反复点开 */
                 handled += 1;
             }
+            if (stopped) {
+                break;
+            }
 
-            /* 翻一屏再看有没有新的 */
+            /* 翻一屏,用"内容有没有变"判断是否到头 */
             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;
-                }
+            var afterRows = TikTokAuto.profile.listRows();
+
+            var step = walkStep(state, {
+                sigBefore: before,
+                sigAfter: signatureOfRows(afterRows),
+                rowsAfter: afterRows.length,
+                maxScreens: maxScreens
+            });
+            state = step.state;
+            if (step.action === 'break') {
+                log.info('idsource', step.reason);
+                break;
             }
         }
+
+        if (ctx.stat) {
+            ctx.stat.screens = state.screens;
+            ctx.stat.handled = handled;
+        }
         return handled;
     }
 
@@ -570,8 +634,12 @@ TikTokAuto.idsource = (function () {
 
     return {
         MAX_SCREENS: MAX_SCREENS,
+        IDLE_SCREENS_TO_STOP: IDLE_SCREENS_TO_STOP,
         run: run,
         walkList: walkList,
+        /* 纯函数,供 PC 回归(`tools/pc-selftest-id.js`)——
+         * 停止判据是"翻不动了"而不是"没有新人",这条必须能被离线断言。 */
+        walkStep: walkStep,
         readTextSource: readTextSource,
         describe: describe
     };

+ 3 - 0
tiktok-auto/tools/build.js

@@ -56,6 +56,9 @@ const MODULE_ORDER = [
      * 所以这里的顺序主要是给人读的,不满足也不会炸 —— 但仍然按依赖排。 */
     'parse',
     'uidset',
+    /* 断点续跑的地基:fingerprint 算配置指纹(纯函数)、checkpoint 管进度文件 */
+    'fingerprint',
+    'checkpoint',
     'filters',
     'profile',
     'idsource',

+ 243 - 6
tiktok-auto/tools/pc-selftest-id.js

@@ -16,7 +16,7 @@ const vm = require('vm');
 const SRC = path.join(__dirname, '..', 'src');
 
 /* 只拼纯函数模块 —— 它们不引用 files/device/sleep 这些 AutoX 全局 */
-const MODULES = ['parse', 'filters', 'uidset'];
+const MODULES = ['parse', 'filters', 'uidset', 'fingerprint', 'checkpoint', 'idsource'];
 
 const sandbox = {
     console: console,
@@ -25,15 +25,18 @@ const sandbox = {
     Array: Array, RegExp: RegExp, isNaN: isNaN, parseInt: parseInt,
     TikTokAuto: {}
 };
-/* uidset 与 filters 在函数体内取 logger / files,这里给最小桩 */
+/* uidset 与 filters 在函数体内取 logger / files,这里给最小桩。
+ * files 用内存实现:断点读写的往返测试要靠它。 */
 sandbox.TikTokAuto.logger = {
     info: () => { }, warn: () => { }, error: () => { }, step: () => { }
 };
+const memFiles = {};
 sandbox.files = {
-    exists: () => false,
-    read: () => '',
-    append: () => { },
-    ensureDir: () => { }
+    exists: (p) => Object.prototype.hasOwnProperty.call(memFiles, p),
+    read: (p) => (memFiles[p] === undefined ? '' : memFiles[p]),
+    write: (p, content) => { memFiles[p] = String(content); },
+    append: (p, content) => { memFiles[p] = (memFiles[p] || '') + String(content); },
+    ensureDir: () => true
 };
 
 vm.createContext(sandbox);
@@ -47,6 +50,9 @@ vm.runInContext(source, sandbox);
 const parse = sandbox.TikTokAuto.parse;
 const filters = sandbox.TikTokAuto.filters;
 const uidset = sandbox.TikTokAuto.uidset;
+const fingerprint = sandbox.TikTokAuto.fingerprint;
+const checkpoint = sandbox.TikTokAuto.checkpoint;
+const idsource = sandbox.TikTokAuto.idsource;
 
 let pass = 0;
 let fail = 0;
@@ -152,6 +158,237 @@ check('有语言 → 必须开', filters.needsProfile({ langRegion: 'zh' }) ===
 check('需要资料但拿不到 → 拦下', filters.judge(null, { fans: { min: 1000, max: 0 } }).ok === false);
 check('不需要资料且没有资料 → 放行(快路径)', filters.judge(null, {}).ok === true);
 
+/* ---------------------------------------------------------- 列表遍历的停止判据 */
+/*
+ * 这里验的是 2026-10-28 修的那个真 bug:
+ * 旧实现把"本屏没有新 uid"和"翻屏后内容不变"两件事都当成"到底了",
+ * 于是预载历史(跨轮去重)或预载上次产物(断点续跑)之后,
+ * 前两屏全是旧人 → 在列表顶部就判定"列表到底",一条新人都拿不到。
+ *
+ * 用一个可控的假列表驱动真正的 walkList,这样测的是生产代码而不是复述逻辑。
+ */
+function fakeProfile(pages) {
+    let cursor = 0;
+    sandbox.TikTokAuto.profile = {
+        listRows: () => pages[Math.min(cursor, pages.length - 1)],
+        scrollList: () => { cursor += 1; }
+    };
+}
+
+/* ① 全部是旧人,但列表还在往下走 → 必须继续翻,不能判到底 */
+const seenAll = uidset.create();
+['a1', 'a2', 'b1', 'b2', 'c1', 'c2'].forEach((uid) => seenAll.add(uid));
+fakeProfile([[{ uid: 'a1' }, { uid: 'a2' }],
+             [{ uid: 'b1' }, { uid: 'b2' }],
+             [{ uid: 'c1' }, { uid: 'c2' }],
+             []]);
+let stat = {};
+let got = idsource.walkList({
+    seen: seenAll, shouldStop: () => false, stat: stat,
+    onRow: () => { throw new Error('全是旧人,不该处理任何一行'); }
+});
+check('全屏都是旧人 → 仍然继续往下翻(不判列表到底)',
+      got === 0 && stat.screens >= 3, 'screens=' + stat.screens);
+check('全屏都是旧人时不重复处理', got === 0, 'handled=' + got);
+
+/* ② 翻不动了(每次读到的都是同一屏)→ 连续两次就停 */
+fakeProfile([[{ uid: 'a1' }, { uid: 'a2' }]]);
+stat = {};
+idsource.walkList({
+    seen: uidset.create(), shouldStop: () => false, stat: stat,
+    onRow: () => { }
+});
+check('翻屏后内容不变 → 连续两次就判到底', stat.screens === 2, 'screens=' + stat.screens);
+
+/* ③ 遇到新人要正常处理(回归:改动不能把"该收的人"也一起跳过) */
+fakeProfile([[{ uid: 'n1' }, { uid: 'n2' }], []]);
+const collected = [];
+idsource.walkList({
+    seen: uidset.create(), shouldStop: () => false,
+    onRow: (row) => { collected.push(row.uid); }
+});
+check('新人照常处理', collected.join(',') === 'n1,n2', collected.join(','));
+
+/* ④ maxScreens 是硬顶(续跑会按上次屏数放大它) */
+fakeProfile([[{ uid: 'a1' }], [{ uid: 'a2' }], [{ uid: 'a3' }], [{ uid: 'a4' }], []]);
+stat = {};
+idsource.walkList({
+    seen: uidset.create(), shouldStop: () => false, stat: stat, maxScreens: 2,
+    onRow: () => { }
+});
+check('maxScreens 到顶就停', stat.screens === 2, 'screens=' + stat.screens);
+
+/* ⑤ walkStep 纯函数:变 → 不变 → 变,stuck 必须归零 */
+let step = idsource.walkStep({ screens: 1, stuck: 1 },
+    { sigBefore: 'a', sigAfter: 'b', rowsAfter: 3, maxScreens: 60 });
+check('内容变了就把 stuck 归零', step.action === 'continue' && step.state.stuck === 0, JSON.stringify(step));
+step = idsource.walkStep({ screens: 1, stuck: 0 },
+    { sigBefore: 'a', sigAfter: '', rowsAfter: 0, maxScreens: 60 });
+check('翻完读不到行时不下结论(交给下一轮空行判定)', step.action === 'continue', JSON.stringify(step));
+
+/* ---------------------------------------------------------- 配置指纹 */
+/*
+ * 指纹决定"断点还算不算数"。这里盯两件事:
+ *   ① 该变的必须变(改了取材/过滤/输出目录 → 断点失效)
+ *   ② 不该变的必须不变(改上限、时长、resolveUid → 不该让断点失效)
+ * 第 ② 类最容易写错:多算一个字段,用户改个"至少运行几分钟"就白丢断点。
+ */
+const baseId = () => ({
+    sources: [
+        { key: 'src-a', kind: 'anchor', anchors: 'ajman#other', lists: ['fans'],
+          expand: 'fans', searchType: '', keyword: '', videoSource: '', videoKeyword: '', path: '' },
+        { key: 'src-b', kind: 'search', searchType: 'users', keyword: '小猫',
+          anchors: '', lists: [], expand: '', videoSource: '', videoKeyword: '', path: '' }
+    ],
+    filter: { private: true, noAvatar: false, langRegion: 'any', nicknameCheck: 'any',
+              follow: {}, fans: { min: 1000, max: 500000 }, likes: {}, videos: { min: 0, max: 500000 } },
+    output: { dir: '/sdcard/Download/tiktok_app', historyPath: '/sdcard/Download/tiktok_app/uid_extracted.txt',
+              dedupeHistory: true, dryRun: false, limit: 100, resolveUid: true },
+    guard: { maxRuntimeMs: 1800000 }
+});
+
+const fpBase = fingerprint.ofIdSection(baseId());
+check('指纹是四项(分项 + 总)',
+      fpBase.v === 1 && !!fpBase.sources && !!fpBase.filter && !!fpBase.output && !!fpBase.all);
+
+/* ① 不该变的:键序 */
+const reordered = baseId();
+const flipped = {};
+Object.keys(reordered).reverse().forEach((k) => { flipped[k] = reordered[k]; });
+check('换对象键序 → 指纹不变', fingerprint.compare(fpBase, fingerprint.ofIdSection(flipped)).same === true);
+
+/* ② 不该变的:只改"跑多少/跑多久/是否解析 uid" */
+[['limit', 999], ['resolveUid', false], ['dryRun', true]].forEach(([k, v]) => {
+    const one = baseId();
+    one.output[k] = v;
+    check(`改 output.${k} → 断点仍然有效`, fingerprint.compare(fpBase, fingerprint.ofIdSection(one)).same === true);
+});
+check('改 guard.maxRuntimeMs → 断点仍然有效', fingerprint.compare(fpBase, fingerprint.ofIdSection(
+    Object.assign(baseId(), { guard: { maxRuntimeMs: 60000 } }))).same === true);
+
+/* ③ 不该变的:视频量门槛(filters.judge 只对它出 soft 提示,不参与判定) */
+const videosChanged = baseId();
+videosChanged.filter.videos = { min: 50, max: 999 };
+check('改视频量门槛 → 断点仍然有效(它不参与过滤)',
+      fingerprint.compare(fpBase, fingerprint.ofIdSection(videosChanged)).same === true);
+
+/* ④ 该变的 */
+const fansChanged = baseId();
+fansChanged.filter.fans = { min: 500, max: 500000 };
+check('改粉丝门槛 → 只有 filter 变', JSON.stringify(fingerprint.compare(fpBase, fingerprint.ofIdSection(fansChanged)).changed) === '["filter"]');
+
+const dedupeChanged = baseId();
+dedupeChanged.output.dedupeHistory = false;
+check('关掉跨轮去重 → 只有 output 变',
+      JSON.stringify(fingerprint.compare(fpBase, fingerprint.ofIdSection(dedupeChanged)).changed) === '["output"]');
+
+const sourcesReordered = baseId();
+sourcesReordered.sources = [baseId().sources[1], baseId().sources[0]];
+check('取材顺序调换 → sources 变(顺序影响"先取到谁")',
+      fingerprint.compare(fpBase, fingerprint.ofIdSection(sourcesReordered)).changed.join(',') === 'sources');
+
+const keywordChanged = baseId();
+keywordChanged.sources[1].keyword = '小狗';
+check('换关键词 → 断点失效', fingerprint.compare(fpBase, fingerprint.ofIdSection(keywordChanged)).same === false);
+
+/* ⑤ 缺字段 / 空配置不能炸 */
+check('空配置也能算出指纹', !!fingerprint.ofIdSection({}).all);
+check('没 sources 时指纹稳定', fingerprint.ofIdSection({}).sources === fingerprint.ofIdSection({}).sources);
+
+/* ⑥ ★ 两份实现必须一致(界面 / 引擎各一份,算法漂移 = 界面说能续、引擎说不能) */
+const uiFpSandbox = { JSON: JSON, String: String, Number: Number, Object: Object, Array: Array, isNaN: isNaN };
+vm.createContext(uiFpSandbox);
+vm.runInContext(fs.readFileSync(path.join(SRC, '..', '..', 'tiktok-app', 'src', 'fingerprint.js'), 'utf8') +
+                '\nthis.__F = Fingerprint;', uiFpSandbox);
+const UI_FP = uiFpSandbox.__F;
+check('界面侧指纹模块能加载', !!UI_FP && typeof UI_FP.ofIdSection === 'function');
+
+let mismatch = '';
+[baseId(), flipped, fansChanged, dedupeChanged, sourcesReordered, keywordChanged,
+ videosChanged, {}, { sources: [] }].forEach((fixture, i) => {
+    const a = fingerprint.ofIdSection(fixture);
+    const b = UI_FP.ofIdSection(fixture);
+    if (JSON.stringify(a) !== JSON.stringify(b)) {
+        mismatch = 'fixture#' + i + ' 引擎=' + JSON.stringify(a) + ' 界面=' + JSON.stringify(b);
+    }
+});
+check('引擎与界面算出的指纹逐字段相同(防算法漂移)', mismatch === '', mismatch);
+
+/* ---------------------------------------------------------- 断点模型 */
+const CP_PATH = '/sdcard/Download/tiktok_app/id_progress.json';
+const SIG = fingerprint.ofIdSection(baseId());
+const cp0 = checkpoint.empty('run-1', SIG, '/sdcard/Download/tiktok_app/uid_20261028_1200.txt',
+                            [{ key: 'src-a', kind: 'anchor', label: '账号 A' },
+                             { key: 'src-b', kind: 'search', label: '用户「小猫」' }]);
+check('断点初始状态是 running 且两条取材都 pending',
+      cp0.state === 'running' && cp0.sources.length === 2 && cp0.sources.every((s) => s.status === 'pending'));
+check('断点记下了 runId 与结果文件', cp0.runId === 'run-1' && cp0.runFile.indexOf('uid_') > 0);
+
+checkpoint.markSource(cp0, 'src-a', 'running');
+check('标 running 后不算完成', checkpoint.sourceDone(cp0, 'src-a') === false);
+checkpoint.markSource(cp0, 'src-a', 'done', { screens: 12, handled: 214, added: 37 });
+check('标 done 后算完成且带上屏数/处理数',
+      checkpoint.sourceDone(cp0, 'src-a') === true &&
+      cp0.sources[0].screens === 12 && cp0.sources[0].handled === 214);
+check('还没做完时 allDone 为假', checkpoint.allDone(cp0) === false);
+
+/* 展开:同一个人的 fans 与 following 要分开记 */
+checkpoint.enqueueExpand(cp0, 'u1', 'fans');
+checkpoint.enqueueExpand(cp0, 'u2', 'fans');
+checkpoint.enqueueExpand(cp0, 'u1', 'following');
+check('展开项按 uid + 列表去重', cp0.expand.queue.length === 3, String(cp0.expand.queue.length));
+checkpoint.enqueueExpand(cp0, 'u1', 'fans');
+check('重复入队不再增加', cp0.expand.queue.length === 3, String(cp0.expand.queue.length));
+checkpoint.markExpandDone(cp0, 'u1', 'fans');
+check('u1 的 fans 完成后,following 仍然待做',
+      checkpoint.expandDone(cp0, 'u1', 'fans') === true &&
+      checkpoint.expandDone(cp0, 'u1', 'following') === false);
+check('pendingExpand 只返回没做过的',
+      checkpoint.pendingExpand(cp0, cp0.expand.queue).length === 2,
+      String(checkpoint.pendingExpand(cp0, cp0.expand.queue).length));
+
+checkpoint.markSource(cp0, 'src-b', 'done');
+check('取材全 done 但展开有剩 → allDone 仍为假', checkpoint.allDone(cp0) === false);
+checkpoint.markExpandDone(cp0, 'u2', 'fans');
+checkpoint.markExpandDone(cp0, 'u1', 'following');
+check('取材与展开都完成后 allDone 为真', checkpoint.allDone(cp0) === true);
+
+/* 存盘 → 读回 */
+checkpoint.save(CP_PATH, cp0);
+const cpBack = checkpoint.read(CP_PATH);
+check('断点能存能读', !!cpBack && cpBack.runId === 'run-1' && cpBack.sources.length === 2);
+memFiles[CP_PATH] = '{ 半截 JSON';
+check('半截文件当作没有断点(不抛异常)', checkpoint.read(CP_PATH) === null);
+memFiles[CP_PATH] = '';
+check('空文件当作没有断点', checkpoint.read(CP_PATH) === null);
+check('清空断点给的是覆盖用的空壳', checkpoint.clearStub().state === 'none');
+
+/* ---------------------------------------------------------- 续跑决策 */
+const runExists = true;
+check('没有断点 → 从零开始', checkpoint.planResume(null, SIG, runExists, {}).mode === 'fresh');
+check('上次已跑完 → 不提供继续',
+      checkpoint.planResume(Object.assign({}, cp0, { state: 'done' }), SIG, runExists, {}).mode === 'fresh');
+check('演练模式 → 拒绝续跑',
+      checkpoint.planResume(Object.assign({}, cp0, { state: 'stopped' }), SIG, runExists, { dryRun: true }).mode === 'reject');
+check('配置变了 → 拒绝,并说清是哪一段',
+      (() => {
+          const r = checkpoint.planResume(Object.assign({}, cp0, { state: 'stopped' }),
+                                          fingerprint.ofIdSection(keywordChanged), runExists, {});
+          return r.mode === 'reject' && r.changed.join(',') === 'sources' && r.message.indexOf('取材') >= 0;
+      })());
+check('结果文件没了 → 拒绝',
+      (() => {
+          const r = checkpoint.planResume(Object.assign({}, cp0, { state: 'stopped' }), SIG, false, {});
+          return r.mode === 'reject' && r.reason === 'run-file-missing';
+      })());
+check('配置一致 + 文件在 → 可以续跑',
+      checkpoint.planResume(Object.assign({}, cp0, { state: 'stopped' }), SIG, runExists, {}).mode === 'resume');
+
+/* ---------------------------------------------------------- 上限按累计算 */
+check('上限按累计:30 + 70 = 100 → 到顶', checkpoint.limitReached({ limitBase: 30 }, 100, 70) === true);
+check('上限按累计:30 + 69 = 99 → 没到', checkpoint.limitReached({ limitBase: 30 }, 100, 69) === false);
+check('不限(0)永远不到顶', checkpoint.limitReached({ limitBase: 0 }, 0, 999) === false);
+
 /* ---------------------------------------------------------- 文件名 */
 const name = parse.timestampName('uid', new Date(2026, 9, 20, 18, 31, 0));
 check('文件名带时分', name === 'uid_20261020_1831.txt', name);