Jelajahi Sumber

断点续跑(步 2):引擎侧真的读写断点了

## 改了什么

`config.js` 加 `id.resume{enabled,path}`(默认关 —— 只有界面点「继续」才由
`--resume` 打开;手写配置默认别去读断点)。

`bridge.js`:`--resume`(裸开关)/ `--checkpoint <path>`;新增 `currentRunId()`
(断点里要记"这次是哪一轮写的",记录页靠它精确匹配)。

`idflow.js` 十处:
  ① 指纹 + planResume 决策,**全部在 launchApp 之前**做完(拒绝时零成本)
  ② 续跑复用上次的 runFile(不新开文件)
  ③ 续跑无条件把 runFile 载入去重集合;base 以**文件行数**为真相(不信 JSON 计数)
  ④ 断点建/续后立刻写一次(刚起步被杀也能续)
  ⑤ `limitReached` 改按累计(否则续跑会多收一轮)
  ⑥ 第一层取材:跳过已完成;未完成的把 `maxScreens` 按上次屏数放大
  ⑦ handleRow 同步入队展开项 + 每 20 个落盘
  ⑧ 深度展开按 `pendingExpand` 续做,做完一个落一个
  ⑨ resolvePhase 读**整份文件**(不是本轮新增)+ 复用上次的 mapFile
  ⑩ finally 落盘 state:`allDone` 或**达到上限** → done,否则 stopped

`bridge.finish` 的 `done` 与文案改用 `extractedTotal`(累计),续跑时加"(续跑)"前缀。

## 两个顺手修正

**取材失败不能标 done**。`idsource.run` 打不开列表时记 errors 后返回 0(不抛异常),
如果一律标 done,续跑就再也不会重试它 —— 用户会以为"这条取材就是没数据"。
现在:本轮新增了错误 → 保持 running 下次再试;否则 → done。

**达到提取上限也算"没有可续的了"**。不然会留下一个"点继续立刻又停"的断点
(limitReached 按累计,续跑一进来就已经到顶)。

## 真机验证(Pixel 5a / TikTok 47.1.4)

两条取材(fans + following)/ 32 秒预算:

```
A 不带 --resume:29 个,src-a done(3 屏),src-b pending,断点 state=stopped
B 带 --resume :跳过 fans → 做 following → 新增 14,累计 43
               同一个文件 uid_20261028_0749.txt 从 29 行 → 43 行
               文案「完成(续跑):提取 43 个 ID,本轮新增 14 个」
C 改过滤 + --resume:启动前就被拒(launch 日志 0 条,产物 43 行未动)
               原因精确到「断点失效:过滤条件已改动」
D 删断点 + --resume:日志「没有可用的断点(no-checkpoint),从零开始」
```

自检:PC 114 项 / 界面 smoke 422 项 / 两侧构建通过。

## 还没做

界面还没有入口(步 3/4)—— 现在只能用 `--resume` 手起引擎。
梦境 5 hari lalu
induk
melakukan
917bfe95fa

+ 345 - 34
tiktok-auto/dist/tiktok-auto.js

@@ -1,5 +1,5 @@
 /*! tiktok-auto v1.0.0
 /*! tiktok-auto v1.0.0
- *  built 2026-10-28T14:42:55.685Z
+ *  built 2026-10-28T14:48:00.291Z
  *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, main
  *  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/ 后重新构建。
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
  */
@@ -208,6 +208,12 @@ TikTokAuto.bridge = (function () {
             } else if (token === '--log') {
             } else if (token === '--log') {
                 options.logPath = argv[i + 1];
                 options.logPath = argv[i + 1];
                 i += 1;
                 i += 1;
+            } else if (token === '--resume') {
+                /* 裸开关,不取值:断点续跑由界面点「继续」时带上 */
+                options.resume = true;
+            } else if (token === '--checkpoint') {
+                options.checkpointPath = argv[i + 1];
+                i += 1;
             }
             }
         }
         }
         return options;
         return options;
@@ -230,9 +236,24 @@ TikTokAuto.bridge = (function () {
             injected.runtime = injected.runtime || {};
             injected.runtime = injected.runtime || {};
             injected.runtime.configPath = options.configPath;
             injected.runtime.configPath = options.configPath;
         }
         }
+        /* 续跑开关只改 `id.resume`,**不动 sources** ——
+         * `merge` 是深合并,所以不会把配置里的取材清单覆盖掉。 */
+        if (options.resume) {
+            injected.id = injected.id || {};
+            injected.id.resume = injected.id.resume || {};
+            injected.id.resume.enabled = true;
+            if (options.checkpointPath) {
+                injected.id.resume.path = options.checkpointPath;
+            }
+        }
         return injected;
         return injected;
     }
     }
 
 
+    /** 本次运行的 runId(断点里要记下"这是哪次运行写的",界面靠它把记录与断点对上) */
+    function currentRunId() {
+        return state.runId || '';
+    }
+
     /**
     /**
      * 日志落盘(界面侧「执行日志」的来源)。
      * 日志落盘(界面侧「执行日志」的来源)。
      *
      *
@@ -327,7 +348,10 @@ TikTokAuto.bridge = (function () {
         /* ID 提取的 `done` 是"已收个数",而最后一次 `report` 往往发生在收下一个人之前 ——
         /* ID 提取的 `done` 是"已收个数",而最后一次 `report` 往往发生在收下一个人之前 ——
          * 于是终态里 done 会比实际少一个(实测 11 个却报 10)。
          * 于是终态里 done 会比实际少一个(实测 11 个却报 10)。
          * 收尾时按 summary 校正一次,免得界面/记录上的数字自相矛盾。 */
          * 收尾时按 summary 校正一次,免得界面/记录上的数字自相矛盾。 */
-        if (summary && typeof summary.extracted === 'number') {
+        if (summary && typeof summary.extractedTotal === 'number') {
+            /* 续跑时 extracted 只是"本轮新增",累计数才和界面上的进度一致 */
+            patch.done = summary.extractedTotal;
+        } else if (summary && typeof summary.extracted === 'number') {
             patch.done = summary.extracted;
             patch.done = summary.extracted;
         }
         }
         report(patch);
         report(patch);
@@ -340,10 +364,19 @@ TikTokAuto.bridge = (function () {
          * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。
          * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。
          * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */
          * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */
         var isIdTask = summary && typeof summary.extracted === 'number';
         var isIdTask = summary && typeof summary.extracted === 'number';
+        /* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
+         * 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */
+        var total = summary && typeof summary.extractedTotal === 'number'
+            ? summary.extractedTotal
+            : (summary ? summary.extracted : 0);
+        var resumedTag = summary && summary.resumed ? '(续跑)' : '';
+        var thisRound = summary && summary.resumed && summary.extracted !== total
+            ? ',本轮新增 ' + summary.extracted + ' 个' : '';
 
 
         if (status === 'stopped') {
         if (status === 'stopped') {
             return isIdTask
             return isIdTask
-                ? '已停止:本次已收 ' + summary.extracted + ' 个 ID'
+                ? '已停止' + resumedTag + ':累计 ' + total + ' 个 ID' + thisRound +
+                  '(可点「继续」接着跑)'
                 : '已停止';
                 : '已停止';
         }
         }
         if (status === 'finished') {
         if (status === 'finished') {
@@ -351,7 +384,7 @@ TikTokAuto.bridge = (function () {
                 var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
                 var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
                 /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */
                 /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */
                 var mapped = summary.mapFile ? '(已生成 uid 映射)' : '';
                 var mapped = summary.mapFile ? '(已生成 uid 映射)' : '';
-                return '完成:提取 ' + summary.extracted + ' 个 ID' + tail + mapped;
+                return '完成' + resumedTag + ':提取 ' + total + ' 个 ID' + thisRound + tail + mapped;
             }
             }
             var watched = summary ? summary.attemptedVideos : 0;
             var watched = summary ? summary.attemptedVideos : 0;
             var posted = summary ? summary.postedComments : 0;
             var posted = summary ? summary.postedComments : 0;
@@ -426,6 +459,7 @@ TikTokAuto.bridge = (function () {
         fromExecArgv: fromExecArgv,
         fromExecArgv: fromExecArgv,
         isEnabled: isEnabled,
         isEnabled: isEnabled,
         checkStop: checkStop,
         checkStop: checkStop,
+        currentRunId: currentRunId,
         report: report,
         report: report,
         finish: finish,
         finish: finish,
         describe: describe,
         describe: describe,
@@ -517,6 +551,20 @@ TikTokAuto.config = (function () {
                  * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。
                  * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。
                  */
                  */
                 resolveMaxMs: 10 * 60 * 1000
                 resolveMaxMs: 10 * 60 * 1000
+            },
+            /**
+             * 断点续跑。默认**关**:只有界面明确点「继续」才会带 `--resume` 进来。
+             *
+             * 为什么不默认开:一份手写的 engine-config.json 如果默认就去读断点,
+             * 会出现"我只想从头跑一次,结果它接着上次跑"这种反直觉行为。
+             * 而且 `state:'none'`/`'done'` 时它本来也不会续 —— 但**默认关**更安全。
+             *
+             * path 留空则由 `output.dir` 派生(`checkpoint.pathOf`),
+             * 这样用户改产物目录时断点跟着走,不会出现"产物在新目录、断点在旧目录"。
+             */
+            resume: {
+                enabled: false,
+                path: ''
             }
             }
         },
         },
         watch: {
         watch: {
@@ -5138,6 +5186,22 @@ TikTokAuto.checkpoint = (function () {
         return '';
         return '';
     }
     }
 
 
+    /**
+     * 取一条取材的记录(找不到返回 null)。
+     *
+     * 续跑时要用它上次翻了多少屏来**放大** `maxScreens` 预算:
+     * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。
+     */
+    function sourceOf(cp, key) {
+        return findSource(cp, key);
+    }
+
+    /** 上次这条取材翻了多少屏(没记录就 0) */
+    function screensOf(cp, key) {
+        var one = findSource(cp, key);
+        return one ? (one.screens || 0) : 0;
+    }
+
     /* ------------------------------------------------------------ 深度展开 */
     /* ------------------------------------------------------------ 深度展开 */
 
 
     function expandKey(uid, which) {
     function expandKey(uid, which) {
@@ -5212,6 +5276,25 @@ TikTokAuto.checkpoint = (function () {
         return true;
         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 {
     return {
         DEFAULT_NAME: DEFAULT_NAME,
         DEFAULT_NAME: DEFAULT_NAME,
         VERSION: VERSION,
         VERSION: VERSION,
@@ -5226,11 +5309,14 @@ TikTokAuto.checkpoint = (function () {
         markSource: markSource,
         markSource: markSource,
         sourceProgress: sourceProgress,
         sourceProgress: sourceProgress,
         stoppedAt: stoppedAt,
         stoppedAt: stoppedAt,
+        sourceOf: sourceOf,
+        screensOf: screensOf,
         enqueueExpand: enqueueExpand,
         enqueueExpand: enqueueExpand,
         expandDone: expandDone,
         expandDone: expandDone,
         markExpandDone: markExpandDone,
         markExpandDone: markExpandDone,
         pendingExpand: pendingExpand,
         pendingExpand: pendingExpand,
-        allDone: allDone
+        allDone: allDone,
+        limitReached: limitReached
     };
     };
 }());
 }());
 
 
@@ -7420,7 +7506,17 @@ TikTokAuto.idflow = (function () {
             userMaxRuntimeMs: userMaxMs,
             userMaxRuntimeMs: userMaxMs,
             /** 实际生效的抓取预算(按 limit 自动放宽) */
             /** 实际生效的抓取预算(按 limit 自动放宽) */
             maxRuntimeMs: budgetFor(limit, userMaxMs),
             maxRuntimeMs: budgetFor(limit, userMaxMs),
-            resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000
+            resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000,
+            /**
+             * 断点续跑开关。默认关:只有界面点「继续」时才由 `--resume` 打开。
+             * path 留空时由 output.dir 派生 —— 用户改产物目录,断点跟着走。
+             */
+            resume: {
+                enabled: (id.resume && id.resume.enabled === true),
+                path: (id.resume && id.resume.path) ||
+                      ((output.dir || '/sdcard/Download/tiktok_app') + '/' +
+                       TikTokAuto.checkpoint.DEFAULT_NAME)
+            }
         };
         };
     }
     }
 
 
@@ -7438,13 +7534,21 @@ TikTokAuto.idflow = (function () {
         var summary = {
         var summary = {
             ok: false,
             ok: false,
             task: 'id',
             task: 'id',
-            extracted: 0,          /* 本次写进产物文件的个数 */
+            extracted: 0,          /* **本轮**写进产物文件的个数 */
             skipped: 0,            /* 因过滤被排除 */
             skipped: 0,            /* 因过滤被排除 */
             duplicates: 0,         /* 因去重被排除 */
             duplicates: 0,         /* 因去重被排除 */
             openedProfiles: 0,     /* 打开过多少次用户页(成本指标) */
             openedProfiles: 0,     /* 打开过多少次用户页(成本指标) */
             sources: [],
             sources: [],
             warnings: [],
             warnings: [],
             resultFile: '',
             resultFile: '',
+            /* —— 断点续跑相关 ——
+             * resumed:本次是不是接着上次跑
+             * extractedTotal:累计口径(上次已落盘 + 本轮新增),上限与文案都按它算
+             * resumeRejected:要求续跑但被拒时,把"为什么"结构化成一份给界面看
+             */
+            resumed: false,
+            extractedTotal: 0,
+            resumeRejected: null,
             errors: []
             errors: []
         };
         };
         lastSummary = summary;
         lastSummary = summary;
@@ -7453,10 +7557,21 @@ TikTokAuto.idflow = (function () {
         var expandQueue = [];
         var expandQueue = [];
         var softReported = {};
         var softReported = {};
         var writer = null;
         var writer = null;
+        var runFile = '';
+
+        /* 断点(cp)在"决定续跑"之后才会有值;base 是"上次已落盘的条数",
+         * 上限、文案、ok 判定都按 base + 本轮 来算。 */
+        var cp = null;
+        var cpPath = settings.resume.path;
+        var resumed = false;
+        var base = 0;
+        var sig = TikTokAuto.fingerprint.ofIdSection(config.id);
 
 
         /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */
         /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */
         function limitReached() {
         function limitReached() {
-            return settings.limit > 0 && summary.extracted >= settings.limit;
+            /* ★ 上限必须按**累计**算:不然设 100 个、跑了 60 个被停,
+             * 续跑后又会再收 100 个 = 一共 160 个。 */
+            return TikTokAuto.checkpoint.limitReached(cp, settings.limit, summary.extracted);
         }
         }
         function timeUp() {
         function timeUp() {
             return Date.now() - began >= settings.maxRuntimeMs;
             return Date.now() - began >= settings.maxRuntimeMs;
@@ -7536,12 +7651,19 @@ TikTokAuto.idflow = (function () {
          * 两份都留着,因为它们的用途真的不同,而且映射只有这一轮的数据。
          * 两份都留着,因为它们的用途真的不同,而且映射只有这一轮的数据。
          */
          */
         function resolvePhase(target, settings) {
         function resolvePhase(target, settings) {
-            var names = writer.list();
+            /* ★ 续跑时解析**整份结果文件**,不是只解析本轮新增。
+             * 映射文件是"结果文件的对照表",只含本轮新增就成了残表 ——
+             * 上次已收的 37 个永远拿不到数字 uid。
+             * 代价是这些名字要重新请求一遍(每个约 2.5 秒),先要正确性。 */
+            var names = resumed ? TikTokAuto.uidset.readUidFile(runFile) : writer.list();
             if (names.length === 0) {
             if (names.length === 0) {
                 return;
                 return;
             }
             }
-            var mapFile = settings.output.dir + '/' +
-                TikTokAuto.parse.timestampName('uid_map', new Date());
+            /* 续跑复用上次的映射文件路径(覆盖写整份):
+             * 否则一次任务会产出两个 uid_map,下游还得自己合并。 */
+            var mapFile = (resumed && cp && cp.mapFile)
+                ? cp.mapFile
+                : settings.output.dir + '/' + TikTokAuto.parse.timestampName('uid_map', new Date());
 
 
             log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
             log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
             var resolveStarted = Date.now();
             var resolveStarted = Date.now();
@@ -7579,6 +7701,10 @@ TikTokAuto.idflow = (function () {
             target.unresolved = result.failed.length;
             target.unresolved = result.failed.length;
             if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {
             if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {
                 target.mapFile = mapFile;
                 target.mapFile = mapFile;
+                if (cp) {
+                    cp.mapFile = mapFile;
+                    TikTokAuto.checkpoint.save(cpPath, cp);
+                }
             }
             }
             if (result.failed.length > 0) {
             if (result.failed.length > 0) {
                 target.warnings.push(result.failed.length +
                 target.warnings.push(result.failed.length +
@@ -7632,9 +7758,32 @@ TikTokAuto.idflow = (function () {
 
 
             if (row.expandTo) {
             if (row.expandTo) {
                 expandQueue.push({ uid: uid, which: row.expandTo });
                 expandQueue.push({ uid: uid, which: row.expandTo });
+                /* 同步记进断点:不然续跑时"这个人还要展开"这条信息就丢了 */
+                TikTokAuto.checkpoint.enqueueExpand(cp, uid, row.expandTo);
+            }
+
+            /* 节流落盘,与产物刷盘同拍(每 20 个)。
+             * 不追每一个:粒度是取材条目级,写那么频只换来"少重走 19 个人"。 */
+            if (cp && summary.extracted % TikTokAuto.uidset.FLUSH_EVERY === 0) {
+                saveCheckpoint();
             }
             }
         }
         }
 
 
+        /** 把当前进度写进断点文件(失败只记 warn,不影响主流程) */
+        function saveCheckpoint() {
+            if (!cp) {
+                return;
+            }
+            cp.counts = {
+                extracted: base + summary.extracted,
+                skipped: summary.skipped,
+                duplicates: summary.duplicates,
+                openedProfiles: summary.openedProfiles
+            };
+            cp.limitBase = base;
+            TikTokAuto.checkpoint.save(cpPath, cp);
+        }
+
         try {
         try {
             log.step('idflow', '=== TikTok ID 提取开始 ===');
             log.step('idflow', '=== TikTok ID 提取开始 ===');
             log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
             log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
@@ -7647,6 +7796,39 @@ TikTokAuto.idflow = (function () {
                       Math.round(settings.userMaxRuntimeMs / 60000) + ' 分钟)'
                       Math.round(settings.userMaxRuntimeMs / 60000) + ' 分钟)'
                     : '(未设提取上限,按你设的时长)'));
                     : '(未设提取上限,按你设的时长)'));
 
 
+            /* —— 续跑决策(★ 全部在启动 App 之前做完)——
+             * 拒绝续跑时零成本返回:不启动 TikTok、不动用户手机上的任何一个页面。
+             * 判定顺序见 checkpoint.planResume(文件不在 → 已跑完 → 演练 → 配置变 → 结果文件没了)。 */
+            if (settings.resume.enabled) {
+                var prev = TikTokAuto.checkpoint.read(cpPath);
+                var decision = TikTokAuto.checkpoint.planResume(
+                    prev, sig,
+                    !!(prev && prev.runFile && files.exists(prev.runFile)),
+                    { dryRun: settings.output.dryRun });
+
+                if (decision.mode === 'reject') {
+                    summary.errors.push(decision.message);
+                    summary.resumeRejected = {
+                        reason: decision.reason,
+                        changed: decision.changed || [],
+                        message: decision.message,
+                        runFile: (prev && prev.runFile) || ''
+                    };
+                    log.warn('idflow', '续跑被拒绝:' + decision.message);
+                    return summary;
+                }
+                if (decision.mode === 'resume') {
+                    cp = prev;
+                    resumed = true;
+                    summary.resumed = true;
+                    log.info('idflow', '续跑:上次停在「' +
+                        (TikTokAuto.checkpoint.stoppedAt(cp) || '未知') + '」,已收 ' +
+                        (cp.counts && cp.counts.extracted ? cp.counts.extracted : 0) + ' 个');
+                } else {
+                    log.info('idflow', '没有可用的断点(' + decision.reason + '),从零开始');
+                }
+            }
+
             TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
             TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
 
 
             if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) {
             if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) {
@@ -7654,9 +7836,16 @@ TikTokAuto.idflow = (function () {
                 return summary;
                 return summary;
             }
             }
             /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟,
             /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟,
-             * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。 */
-            var runFile = settings.output.dir + '/' +
-                TikTokAuto.parse.timestampName('uid', new Date());
+             * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。
+             *
+             * ★ 续跑时**复用上次的文件**(不新开):一次任务只该有一个 uid_*.txt,
+             *   否则用户拿到两个文件还得自己合并。 */
+            if (resumed && cp && cp.runFile) {
+                runFile = cp.runFile;
+            } else {
+                runFile = settings.output.dir + '/' +
+                    TikTokAuto.parse.timestampName('uid', new Date());
+            }
             writer = TikTokAuto.uidset.createWriter({
             writer = TikTokAuto.uidset.createWriter({
                 runPath: runFile,
                 runPath: runFile,
                 historyPath: settings.output.historyPath,
                 historyPath: settings.output.historyPath,
@@ -7668,6 +7857,32 @@ TikTokAuto.idflow = (function () {
             if (settings.output.dedupeHistory) {
             if (settings.output.dedupeHistory) {
                 TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath);
                 TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath);
             }
             }
+            /* ★ 续跑时**无条件**把上次的产物载入去重集合(不依赖 dedupeHistory):
+             * 否则会把已经收过的人再写一遍。base 以**文件行数**为准 —— 文件是真相,
+             * 不信断点里那个计数(它可能是被杀之前写的旧值)。 */
+            if (resumed) {
+                base = TikTokAuto.uidset.readUidFile(runFile).length;
+                TikTokAuto.uidset.loadHistory(seen, runFile);
+                summary.warnings.push('本次为续跑:结果继续追加到 ' + runFile);
+                log.info('idflow', '续跑:跳过已收 ' + base + ' 个');
+            }
+
+            /* 建/续断点,并**立刻写一次** —— 刚起步就被杀也能接着跑 */
+            if (!resumed) {
+                var entries = [];
+                for (var si = 0; si < settings.sources.length; si += 1) {
+                    entries.push({
+                        key: settings.sources[si].key ||
+                             ('i' + si + ':' + (settings.sources[si].kind || '')),
+                        kind: settings.sources[si].kind || '',
+                        label: TikTokAuto.idsource.describe(settings.sources[si])
+                    });
+                }
+                cp = TikTokAuto.checkpoint.empty(
+                    TikTokAuto.bridge.currentRunId ? TikTokAuto.bridge.currentRunId() : '',
+                    sig, runFile, entries);
+            }
+            saveCheckpoint();
 
 
             /* —— 启动与就位(复用视频链路的成熟实现) —— */
             /* —— 启动与就位(复用视频链路的成熟实现) —— */
             progress('启动应用', 0);
             progress('启动应用', 0);
@@ -7700,13 +7915,44 @@ TikTokAuto.idflow = (function () {
                     break;
                     break;
                 }
                 }
                 var source = settings.sources[i];
                 var source = settings.sources[i];
+                var sourceKey = source.key || ('i' + i + ':' + (source.kind || ''));
                 var before = summary.extracted;
                 var before = summary.extracted;
 
 
+                /* 上次已走完的取材直接跳过 —— 这就是"断点续跑"的全部含义:
+                 * 不是从第几屏接着滚,而是**不再重走已经走完的那几条**。 */
+                if (resumed && TikTokAuto.checkpoint.sourceDone(cp, sourceKey)) {
+                    summary.sources.push({
+                        label: TikTokAuto.idsource.describe(source),
+                        handled: 0,
+                        added: 0,
+                        reused: true
+                    });
+                    log.info('idflow', '续跑跳过(上次已完成):' + TikTokAuto.idsource.describe(source));
+                    continue;
+                }
+
+                TikTokAuto.checkpoint.markSource(cp, sourceKey, 'running');
+                saveCheckpoint();
+
                 progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted);
                 progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted);
+
+                /* 续跑时这条取材上次翻了多少屏 → 这次把预算放大相应倍数。
+                 * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。 */
+                var prevScreens = resumed ? TikTokAuto.checkpoint.screensOf(cp, sourceKey) : 0;
+                var maxScreens = 0;
+                if (prevScreens > 0) {
+                    maxScreens = Math.min(TikTokAuto.idsource.MAX_SCREENS * 2,
+                        TikTokAuto.idsource.MAX_SCREENS +
+                        Math.min(prevScreens, TikTokAuto.idsource.MAX_SCREENS));
+                }
+
+                var stat = {};
                 var ctx = {
                 var ctx = {
                     config: config,
                     config: config,
                     seen: seen,
                     seen: seen,
                     shouldStop: shouldStop,
                     shouldStop: shouldStop,
+                    maxScreens: maxScreens,
+                    stat: stat,
                     onSeenProfile: function (prof) {
                     onSeenProfile: function (prof) {
                         log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans +
                         log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans +
                             ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes);
                             ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes);
@@ -7721,6 +7967,7 @@ TikTokAuto.idflow = (function () {
                 };
                 };
 
 
                 var handled = 0;
                 var handled = 0;
+                var errorsBefore = summary.errors.length;
                 try {
                 try {
                     handled = TikTokAuto.idsource.run(source, ctx);
                     handled = TikTokAuto.idsource.run(source, ctx);
                 } catch (error) {
                 } catch (error) {
@@ -7737,24 +7984,51 @@ TikTokAuto.idflow = (function () {
                 });
                 });
                 log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) +
                 log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) +
                     ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个');
                     ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个');
+
+                /* ★ 走完一条就记一条 —— 下次续跑正是靠它跳过这一条。
+                 *
+                 * 但**失败的那条不能标 done**:`idsource.run` 打不开列表时会记 errors
+                 * 然后返回 0(不抛异常),如果一律标 done,续跑就再也不会重试它,
+                 * 用户会以为"这条取材就是这样、没数据"。所以:
+                 *   · 本轮新增了错误 → 保持 running,下次接着试
+                 *   · 否则(正常走完,哪怕列表本来就是空的)→ done
+                 *
+                 * 中途被停止时这里不会执行,那条取材保持 running,
+                 * 续跑就从它开头重走 —— 这就是"最多回退一条取材"。 */
+                var sourceFailed = summary.errors.length > errorsBefore;
+                TikTokAuto.checkpoint.markSource(cp, sourceKey,
+                    sourceFailed ? 'running' : 'done', {
+                        screens: stat.screens || 0,
+                        handled: handled,
+                        added: summary.extracted - before
+                    });
+                saveCheckpoint();
             }
             }
 
 
             /* —— 第二层:深度展开 ——
             /* —— 第二层:深度展开 ——
              * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
              * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
-             * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。 */
-            if (expandQueue.length > 0 && shouldStop() && limitReached()) {
+             * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。
+             *
+             * 续跑时先用断点过滤掉"上次已经展开过"的人:展开一个人要 5~10 秒,
+             * 重做一遍纯属浪费。 */
+            var todoExpand = TikTokAuto.checkpoint.pendingExpand(cp, expandQueue);
+            if (expandQueue.length > 0 && todoExpand.length === 0) {
+                log.info('idflow', '展开项上次已全部完成(' + expandQueue.length + ' 个),跳过');
+            }
+            if (todoExpand.length > 0 && shouldStop() && limitReached()) {
                 /* 上限在主层就满了 → 展开根本没跑。这必须说出来:
                 /* 上限在主层就满了 → 展开根本没跑。这必须说出来:
                  * 用户明明勾了「再取粉丝列表」,看到"提取满 3 个"很容易以为展开做了。 */
                  * 用户明明勾了「再取粉丝列表」,看到"提取满 3 个"很容易以为展开做了。 */
                 summary.warnings.push('提取上限已到,深度展开(再取粉丝列表)本次没有执行');
                 summary.warnings.push('提取上限已到,深度展开(再取粉丝列表)本次没有执行');
-                log.warn('idflow', '提取上限已到,' + expandQueue.length + ' 个待展开的账号本次没有展开');
+                log.warn('idflow', '提取上限已到,' + todoExpand.length + ' 个待展开的账号本次没有展开');
             }
             }
-            if (expandQueue.length > 0 && !shouldStop()) {
-                log.step('idflow', '开始深度展开:' + expandQueue.length + ' 个账号');
-                for (var e = 0; e < expandQueue.length; e += 1) {
+            if (todoExpand.length > 0 && !shouldStop()) {
+                log.step('idflow', '开始深度展开:' + todoExpand.length + ' 个账号' +
+                    (resumed ? '(已跳过上次做完的)' : ''));
+                for (var e = 0; e < todoExpand.length; e += 1) {
                     if (shouldStop()) {
                     if (shouldStop()) {
                         break;
                         break;
                     }
                     }
-                    var target = expandQueue[e];
+                    var target = todoExpand[e];
                     progress('展开 @' + target.uid + ' 的' +
                     progress('展开 @' + target.uid + ' 的' +
                         (target.which === 'following' ? '关注' : '粉丝'), summary.extracted);
                         (target.which === 'following' ? '关注' : '粉丝'), summary.extracted);
 
 
@@ -7784,10 +8058,15 @@ TikTokAuto.idflow = (function () {
                         }
                         }
                         log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2);
                         log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2);
                     }
                     }
+                    /* 做完一个就记一个:展开成本高(5~10 秒/人),重做代价大 */
+                    TikTokAuto.checkpoint.markExpandDone(cp, target.uid, target.which);
+                    saveCheckpoint();
                 }
                 }
             }
             }
 
 
-            summary.ok = summary.extracted > 0;
+            /* 成功判定按**累计**:续跑本轮新增 0 个、但上次已有 37 个,这是成功不是失败 */
+            var totalSoFar = base + summary.extracted;
+            summary.ok = totalSoFar > 0;
             if (!summary.ok && summary.errors.length === 0) {
             if (!summary.ok && summary.errors.length === 0) {
                 summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
                 summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
             }
             }
@@ -7799,7 +8078,15 @@ TikTokAuto.idflow = (function () {
              *   ② 失败**只记 warning**,绝不把已经抓到的 username 算成失败;
              *   ② 失败**只记 warning**,绝不把已经抓到的 username 算成失败;
              *   ③ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系。
              *   ③ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系。
              */
              */
-            if (settings.output.resolveUid && summary.extracted > 0 && !settings.output.dryRun) {
+            var needResolve = settings.output.resolveUid && !settings.output.dryRun && totalSoFar > 0;
+            /* 续跑但本轮一个都没新增、上次也已经生成过映射 → 直接沿用,不重算。
+             * 否则每次续跑都要把整份文件重新解析一遍(每个 2.5 秒)。 */
+            if (needResolve && resumed && summary.extracted === 0 && cp && cp.mapFile) {
+                log.info('idflow', '本轮没有新增,映射文件沿用上次的:' + cp.mapFile);
+                summary.mapFile = cp.mapFile;
+                needResolve = false;
+            }
+            if (needResolve) {
                 try {
                 try {
                     writer.close();
                     writer.close();
                     resolvePhase(summary, settings);
                     resolvePhase(summary, settings);
@@ -7824,9 +8111,28 @@ TikTokAuto.idflow = (function () {
                     summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)');
                     summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)');
                 }
                 }
             }
             }
+            /* 累计口径给上层(bridge 的 done、界面状态条、记录文案都用它) */
+            summary.extractedTotal = base + summary.extracted;
+
+            /* 断点收尾:无论正常结束还是被停止/出错解栈,都要把"走到哪"落盘。
+             * `allDone` 为真才算真跑完(界面上不再提供"继续");否则留成 stopped 等续跑。 */
+            if (cp) {
+                /* 三种情况都算"这一轮没有可续的了":
+                 *   ① 取材与展开都走完了;
+                 *   ② 达到了用户设的提取上限 —— 否则会留下一个"点了继续立刻又停"的断点,
+                 *      因为 limitReached 按累计算,续跑一进来就已经到顶了。
+                 */
+                cp.state = (TikTokAuto.checkpoint.allDone(cp) || limitReached()) ? 'done' : 'stopped';
+                saveCheckpoint();
+                log.info('idflow', '断点已保存(' + cp.state + '):取材 ' +
+                    TikTokAuto.checkpoint.sourceProgress(cp).done + '/' +
+                    TikTokAuto.checkpoint.sourceProgress(cp).total + ' 条已完成');
+            }
+
             /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append),
             /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append),
-             * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。 */
-            if (summary.extracted === 0) {
+             * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。
+             * 续跑时按累计判:上次已收 37 个,文件是存在的。 */
+            if (summary.extractedTotal === 0) {
                 summary.resultFile = '';
                 summary.resultFile = '';
             }
             }
             if (limitReached()) {
             if (limitReached()) {
@@ -7843,17 +8149,21 @@ TikTokAuto.idflow = (function () {
                  * 那么收满 L 个需要 L × T / E 秒。实测 1800 秒 / 83 个 → 100 个约 36 分钟。
                  * 那么收满 L 个需要 L × T / E 秒。实测 1800 秒 / 83 个 → 100 个约 36 分钟。
                  */
                  */
                 var elapsedMs = Date.now() - began;
                 var elapsedMs = Date.now() - began;
+                /* 文案与换算都按**累计**:续跑时本轮可能只新增 5 个,
+                 * 但用户真正关心的是"总共 42/100",说"只收了 5/100"会吓人。 */
                 if (summary.extracted > 0) {
                 if (summary.extracted > 0) {
                     var needMin = Math.ceil(
                     var needMin = Math.ceil(
-                        settings.limit * elapsedMs / summary.extracted / 60000
+                        (settings.limit - summary.extractedTotal + summary.extracted) *
+                        elapsedMs / summary.extracted / 60000
                     );
                     );
                     summary.warnings.push(
                     summary.warnings.push(
-                        '时间到,只收了 ' + summary.extracted + '/' + settings.limit + ' 个。' +
-                        '按本轮速度,收满 ' + settings.limit + ' 个约需 ' + needMin +
-                        ' 分钟 —— 建议把「单次最长运行」调到 ' + (needMin + 5) + ' 分钟以上'
+                        '时间到,共收了 ' + summary.extractedTotal + '/' + settings.limit +
+                        ' 个(本轮新增 ' + summary.extracted + ' 个)。' +
+                        '按本轮速度,收满 ' + settings.limit + ' 个还需约 ' + needMin +
+                        ' 分钟 —— 可以点「继续」接着跑'
                     );
                     );
-                    log.warn('idflow', '时间到,只收 ' + summary.extracted + ' 个;' +
-                        '按本轮速度收满 ' + settings.limit + ' 个约需 ' + needMin + ' 分钟');
+                    log.warn('idflow', '时间到,累计 ' + summary.extractedTotal + ' 个;' +
+                        '按本轮速度收满 ' + settings.limit + ' 个还需约 ' + needMin + ' 分钟');
                 } else {
                 } else {
                     /* 一个都没收到就超时:多半不是时间不够,而是过滤太严或取材取不到人。
                     /* 一个都没收到就超时:多半不是时间不够,而是过滤太严或取材取不到人。
                      * 这时给"调大时间"的建议是害人 —— 要说清该查哪里。 */
                      * 这时给"调大时间"的建议是害人 —— 要说清该查哪里。 */
@@ -7862,15 +8172,16 @@ TikTokAuto.idflow = (function () {
                         '过滤条件是否过严(见日志里的跳过原因)'
                         '过滤条件是否过严(见日志里的跳过原因)'
                     );
                     );
                 }
                 }
-            } else if (settings.limit > 0 && summary.extracted < settings.limit) {
+            } else if (settings.limit > 0 && summary.extractedTotal < settings.limit) {
                 /* 第三种"没到上限":取材本身跑完了(列表到底/文件读完),不是时间问题。
                 /* 第三种"没到上限":取材本身跑完了(列表到底/文件读完),不是时间问题。
                  * 这时调时间没用,得加取材量。 */
                  * 这时调时间没用,得加取材量。 */
                 summary.warnings.push(
                 summary.warnings.push(
-                    '取材范围已取尽,共收 ' + summary.extracted + '/' + settings.limit +
+                    '取材范围已取尽,共收 ' + summary.extractedTotal + '/' + settings.limit +
                     ' 个 —— 想收更多请增加取材(换关键词、加账号,或打开「再取粉丝列表」)'
                     ' 个 —— 想收更多请增加取材(换关键词、加账号,或打开「再取粉丝列表」)'
                 );
                 );
             }
             }
-            log.step('idflow', '=== 提取结束:新增 ' + summary.extracted +
+            log.step('idflow', '=== 提取结束' + (resumed ? '(续跑)' : '') +
+                ':新增 ' + summary.extracted + ' 个,累计 ' + summary.extractedTotal +
                 ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates +
                 ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates +
                 ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ===');
                 ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ===');
         }
         }

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

@@ -120,6 +120,12 @@ TikTokAuto.bridge = (function () {
             } else if (token === '--log') {
             } else if (token === '--log') {
                 options.logPath = argv[i + 1];
                 options.logPath = argv[i + 1];
                 i += 1;
                 i += 1;
+            } else if (token === '--resume') {
+                /* 裸开关,不取值:断点续跑由界面点「继续」时带上 */
+                options.resume = true;
+            } else if (token === '--checkpoint') {
+                options.checkpointPath = argv[i + 1];
+                i += 1;
             }
             }
         }
         }
         return options;
         return options;
@@ -142,9 +148,24 @@ TikTokAuto.bridge = (function () {
             injected.runtime = injected.runtime || {};
             injected.runtime = injected.runtime || {};
             injected.runtime.configPath = options.configPath;
             injected.runtime.configPath = options.configPath;
         }
         }
+        /* 续跑开关只改 `id.resume`,**不动 sources** ——
+         * `merge` 是深合并,所以不会把配置里的取材清单覆盖掉。 */
+        if (options.resume) {
+            injected.id = injected.id || {};
+            injected.id.resume = injected.id.resume || {};
+            injected.id.resume.enabled = true;
+            if (options.checkpointPath) {
+                injected.id.resume.path = options.checkpointPath;
+            }
+        }
         return injected;
         return injected;
     }
     }
 
 
+    /** 本次运行的 runId(断点里要记下"这是哪次运行写的",界面靠它把记录与断点对上) */
+    function currentRunId() {
+        return state.runId || '';
+    }
+
     /**
     /**
      * 日志落盘(界面侧「执行日志」的来源)。
      * 日志落盘(界面侧「执行日志」的来源)。
      *
      *
@@ -239,7 +260,10 @@ TikTokAuto.bridge = (function () {
         /* ID 提取的 `done` 是"已收个数",而最后一次 `report` 往往发生在收下一个人之前 ——
         /* ID 提取的 `done` 是"已收个数",而最后一次 `report` 往往发生在收下一个人之前 ——
          * 于是终态里 done 会比实际少一个(实测 11 个却报 10)。
          * 于是终态里 done 会比实际少一个(实测 11 个却报 10)。
          * 收尾时按 summary 校正一次,免得界面/记录上的数字自相矛盾。 */
          * 收尾时按 summary 校正一次,免得界面/记录上的数字自相矛盾。 */
-        if (summary && typeof summary.extracted === 'number') {
+        if (summary && typeof summary.extractedTotal === 'number') {
+            /* 续跑时 extracted 只是"本轮新增",累计数才和界面上的进度一致 */
+            patch.done = summary.extractedTotal;
+        } else if (summary && typeof summary.extracted === 'number') {
             patch.done = summary.extracted;
             patch.done = summary.extracted;
         }
         }
         report(patch);
         report(patch);
@@ -252,10 +276,19 @@ TikTokAuto.bridge = (function () {
          * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。
          * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。
          * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */
          * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */
         var isIdTask = summary && typeof summary.extracted === 'number';
         var isIdTask = summary && typeof summary.extracted === 'number';
+        /* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
+         * 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */
+        var total = summary && typeof summary.extractedTotal === 'number'
+            ? summary.extractedTotal
+            : (summary ? summary.extracted : 0);
+        var resumedTag = summary && summary.resumed ? '(续跑)' : '';
+        var thisRound = summary && summary.resumed && summary.extracted !== total
+            ? ',本轮新增 ' + summary.extracted + ' 个' : '';
 
 
         if (status === 'stopped') {
         if (status === 'stopped') {
             return isIdTask
             return isIdTask
-                ? '已停止:本次已收 ' + summary.extracted + ' 个 ID'
+                ? '已停止' + resumedTag + ':累计 ' + total + ' 个 ID' + thisRound +
+                  '(可点「继续」接着跑)'
                 : '已停止';
                 : '已停止';
         }
         }
         if (status === 'finished') {
         if (status === 'finished') {
@@ -263,7 +296,7 @@ TikTokAuto.bridge = (function () {
                 var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
                 var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
                 /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */
                 /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */
                 var mapped = summary.mapFile ? '(已生成 uid 映射)' : '';
                 var mapped = summary.mapFile ? '(已生成 uid 映射)' : '';
-                return '完成:提取 ' + summary.extracted + ' 个 ID' + tail + mapped;
+                return '完成' + resumedTag + ':提取 ' + total + ' 个 ID' + thisRound + tail + mapped;
             }
             }
             var watched = summary ? summary.attemptedVideos : 0;
             var watched = summary ? summary.attemptedVideos : 0;
             var posted = summary ? summary.postedComments : 0;
             var posted = summary ? summary.postedComments : 0;
@@ -338,6 +371,7 @@ TikTokAuto.bridge = (function () {
         fromExecArgv: fromExecArgv,
         fromExecArgv: fromExecArgv,
         isEnabled: isEnabled,
         isEnabled: isEnabled,
         checkStop: checkStop,
         checkStop: checkStop,
+        currentRunId: currentRunId,
         report: report,
         report: report,
         finish: finish,
         finish: finish,
         describe: describe,
         describe: describe,

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

@@ -272,6 +272,22 @@ TikTokAuto.checkpoint = (function () {
         return '';
         return '';
     }
     }
 
 
+    /**
+     * 取一条取材的记录(找不到返回 null)。
+     *
+     * 续跑时要用它上次翻了多少屏来**放大** `maxScreens` 预算:
+     * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。
+     */
+    function sourceOf(cp, key) {
+        return findSource(cp, key);
+    }
+
+    /** 上次这条取材翻了多少屏(没记录就 0) */
+    function screensOf(cp, key) {
+        var one = findSource(cp, key);
+        return one ? (one.screens || 0) : 0;
+    }
+
     /* ------------------------------------------------------------ 深度展开 */
     /* ------------------------------------------------------------ 深度展开 */
 
 
     function expandKey(uid, which) {
     function expandKey(uid, which) {
@@ -379,6 +395,8 @@ TikTokAuto.checkpoint = (function () {
         markSource: markSource,
         markSource: markSource,
         sourceProgress: sourceProgress,
         sourceProgress: sourceProgress,
         stoppedAt: stoppedAt,
         stoppedAt: stoppedAt,
+        sourceOf: sourceOf,
+        screensOf: screensOf,
         enqueueExpand: enqueueExpand,
         enqueueExpand: enqueueExpand,
         expandDone: expandDone,
         expandDone: expandDone,
         markExpandDone: markExpandDone,
         markExpandDone: markExpandDone,

+ 14 - 0
tiktok-auto/src/config.js

@@ -80,6 +80,20 @@ TikTokAuto.config = (function () {
                  * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。
                  * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。
                  */
                  */
                 resolveMaxMs: 10 * 60 * 1000
                 resolveMaxMs: 10 * 60 * 1000
+            },
+            /**
+             * 断点续跑。默认**关**:只有界面明确点「继续」才会带 `--resume` 进来。
+             *
+             * 为什么不默认开:一份手写的 engine-config.json 如果默认就去读断点,
+             * 会出现"我只想从头跑一次,结果它接着上次跑"这种反直觉行为。
+             * 而且 `state:'none'`/`'done'` 时它本来也不会续 —— 但**默认关**更安全。
+             *
+             * path 留空则由 `output.dir` 派生(`checkpoint.pathOf`),
+             * 这样用户改产物目录时断点跟着走,不会出现"产物在新目录、断点在旧目录"。
+             */
+            resume: {
+                enabled: false,
+                path: ''
             }
             }
         },
         },
         watch: {
         watch: {

+ 254 - 29
tiktok-auto/src/idflow.js

@@ -132,7 +132,17 @@ TikTokAuto.idflow = (function () {
             userMaxRuntimeMs: userMaxMs,
             userMaxRuntimeMs: userMaxMs,
             /** 实际生效的抓取预算(按 limit 自动放宽) */
             /** 实际生效的抓取预算(按 limit 自动放宽) */
             maxRuntimeMs: budgetFor(limit, userMaxMs),
             maxRuntimeMs: budgetFor(limit, userMaxMs),
-            resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000
+            resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000,
+            /**
+             * 断点续跑开关。默认关:只有界面点「继续」时才由 `--resume` 打开。
+             * path 留空时由 output.dir 派生 —— 用户改产物目录,断点跟着走。
+             */
+            resume: {
+                enabled: (id.resume && id.resume.enabled === true),
+                path: (id.resume && id.resume.path) ||
+                      ((output.dir || '/sdcard/Download/tiktok_app') + '/' +
+                       TikTokAuto.checkpoint.DEFAULT_NAME)
+            }
         };
         };
     }
     }
 
 
@@ -150,13 +160,21 @@ TikTokAuto.idflow = (function () {
         var summary = {
         var summary = {
             ok: false,
             ok: false,
             task: 'id',
             task: 'id',
-            extracted: 0,          /* 本次写进产物文件的个数 */
+            extracted: 0,          /* **本轮**写进产物文件的个数 */
             skipped: 0,            /* 因过滤被排除 */
             skipped: 0,            /* 因过滤被排除 */
             duplicates: 0,         /* 因去重被排除 */
             duplicates: 0,         /* 因去重被排除 */
             openedProfiles: 0,     /* 打开过多少次用户页(成本指标) */
             openedProfiles: 0,     /* 打开过多少次用户页(成本指标) */
             sources: [],
             sources: [],
             warnings: [],
             warnings: [],
             resultFile: '',
             resultFile: '',
+            /* —— 断点续跑相关 ——
+             * resumed:本次是不是接着上次跑
+             * extractedTotal:累计口径(上次已落盘 + 本轮新增),上限与文案都按它算
+             * resumeRejected:要求续跑但被拒时,把"为什么"结构化成一份给界面看
+             */
+            resumed: false,
+            extractedTotal: 0,
+            resumeRejected: null,
             errors: []
             errors: []
         };
         };
         lastSummary = summary;
         lastSummary = summary;
@@ -165,10 +183,21 @@ TikTokAuto.idflow = (function () {
         var expandQueue = [];
         var expandQueue = [];
         var softReported = {};
         var softReported = {};
         var writer = null;
         var writer = null;
+        var runFile = '';
+
+        /* 断点(cp)在"决定续跑"之后才会有值;base 是"上次已落盘的条数",
+         * 上限、文案、ok 判定都按 base + 本轮 来算。 */
+        var cp = null;
+        var cpPath = settings.resume.path;
+        var resumed = false;
+        var base = 0;
+        var sig = TikTokAuto.fingerprint.ofIdSection(config.id);
 
 
         /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */
         /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */
         function limitReached() {
         function limitReached() {
-            return settings.limit > 0 && summary.extracted >= settings.limit;
+            /* ★ 上限必须按**累计**算:不然设 100 个、跑了 60 个被停,
+             * 续跑后又会再收 100 个 = 一共 160 个。 */
+            return TikTokAuto.checkpoint.limitReached(cp, settings.limit, summary.extracted);
         }
         }
         function timeUp() {
         function timeUp() {
             return Date.now() - began >= settings.maxRuntimeMs;
             return Date.now() - began >= settings.maxRuntimeMs;
@@ -248,12 +277,19 @@ TikTokAuto.idflow = (function () {
          * 两份都留着,因为它们的用途真的不同,而且映射只有这一轮的数据。
          * 两份都留着,因为它们的用途真的不同,而且映射只有这一轮的数据。
          */
          */
         function resolvePhase(target, settings) {
         function resolvePhase(target, settings) {
-            var names = writer.list();
+            /* ★ 续跑时解析**整份结果文件**,不是只解析本轮新增。
+             * 映射文件是"结果文件的对照表",只含本轮新增就成了残表 ——
+             * 上次已收的 37 个永远拿不到数字 uid。
+             * 代价是这些名字要重新请求一遍(每个约 2.5 秒),先要正确性。 */
+            var names = resumed ? TikTokAuto.uidset.readUidFile(runFile) : writer.list();
             if (names.length === 0) {
             if (names.length === 0) {
                 return;
                 return;
             }
             }
-            var mapFile = settings.output.dir + '/' +
-                TikTokAuto.parse.timestampName('uid_map', new Date());
+            /* 续跑复用上次的映射文件路径(覆盖写整份):
+             * 否则一次任务会产出两个 uid_map,下游还得自己合并。 */
+            var mapFile = (resumed && cp && cp.mapFile)
+                ? cp.mapFile
+                : settings.output.dir + '/' + TikTokAuto.parse.timestampName('uid_map', new Date());
 
 
             log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
             log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
             var resolveStarted = Date.now();
             var resolveStarted = Date.now();
@@ -291,6 +327,10 @@ TikTokAuto.idflow = (function () {
             target.unresolved = result.failed.length;
             target.unresolved = result.failed.length;
             if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {
             if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {
                 target.mapFile = mapFile;
                 target.mapFile = mapFile;
+                if (cp) {
+                    cp.mapFile = mapFile;
+                    TikTokAuto.checkpoint.save(cpPath, cp);
+                }
             }
             }
             if (result.failed.length > 0) {
             if (result.failed.length > 0) {
                 target.warnings.push(result.failed.length +
                 target.warnings.push(result.failed.length +
@@ -344,9 +384,32 @@ TikTokAuto.idflow = (function () {
 
 
             if (row.expandTo) {
             if (row.expandTo) {
                 expandQueue.push({ uid: uid, which: row.expandTo });
                 expandQueue.push({ uid: uid, which: row.expandTo });
+                /* 同步记进断点:不然续跑时"这个人还要展开"这条信息就丢了 */
+                TikTokAuto.checkpoint.enqueueExpand(cp, uid, row.expandTo);
+            }
+
+            /* 节流落盘,与产物刷盘同拍(每 20 个)。
+             * 不追每一个:粒度是取材条目级,写那么频只换来"少重走 19 个人"。 */
+            if (cp && summary.extracted % TikTokAuto.uidset.FLUSH_EVERY === 0) {
+                saveCheckpoint();
             }
             }
         }
         }
 
 
+        /** 把当前进度写进断点文件(失败只记 warn,不影响主流程) */
+        function saveCheckpoint() {
+            if (!cp) {
+                return;
+            }
+            cp.counts = {
+                extracted: base + summary.extracted,
+                skipped: summary.skipped,
+                duplicates: summary.duplicates,
+                openedProfiles: summary.openedProfiles
+            };
+            cp.limitBase = base;
+            TikTokAuto.checkpoint.save(cpPath, cp);
+        }
+
         try {
         try {
             log.step('idflow', '=== TikTok ID 提取开始 ===');
             log.step('idflow', '=== TikTok ID 提取开始 ===');
             log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
             log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
@@ -359,6 +422,39 @@ TikTokAuto.idflow = (function () {
                       Math.round(settings.userMaxRuntimeMs / 60000) + ' 分钟)'
                       Math.round(settings.userMaxRuntimeMs / 60000) + ' 分钟)'
                     : '(未设提取上限,按你设的时长)'));
                     : '(未设提取上限,按你设的时长)'));
 
 
+            /* —— 续跑决策(★ 全部在启动 App 之前做完)——
+             * 拒绝续跑时零成本返回:不启动 TikTok、不动用户手机上的任何一个页面。
+             * 判定顺序见 checkpoint.planResume(文件不在 → 已跑完 → 演练 → 配置变 → 结果文件没了)。 */
+            if (settings.resume.enabled) {
+                var prev = TikTokAuto.checkpoint.read(cpPath);
+                var decision = TikTokAuto.checkpoint.planResume(
+                    prev, sig,
+                    !!(prev && prev.runFile && files.exists(prev.runFile)),
+                    { dryRun: settings.output.dryRun });
+
+                if (decision.mode === 'reject') {
+                    summary.errors.push(decision.message);
+                    summary.resumeRejected = {
+                        reason: decision.reason,
+                        changed: decision.changed || [],
+                        message: decision.message,
+                        runFile: (prev && prev.runFile) || ''
+                    };
+                    log.warn('idflow', '续跑被拒绝:' + decision.message);
+                    return summary;
+                }
+                if (decision.mode === 'resume') {
+                    cp = prev;
+                    resumed = true;
+                    summary.resumed = true;
+                    log.info('idflow', '续跑:上次停在「' +
+                        (TikTokAuto.checkpoint.stoppedAt(cp) || '未知') + '」,已收 ' +
+                        (cp.counts && cp.counts.extracted ? cp.counts.extracted : 0) + ' 个');
+                } else {
+                    log.info('idflow', '没有可用的断点(' + decision.reason + '),从零开始');
+                }
+            }
+
             TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
             TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
 
 
             if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) {
             if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) {
@@ -366,9 +462,16 @@ TikTokAuto.idflow = (function () {
                 return summary;
                 return summary;
             }
             }
             /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟,
             /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟,
-             * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。 */
-            var runFile = settings.output.dir + '/' +
-                TikTokAuto.parse.timestampName('uid', new Date());
+             * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。
+             *
+             * ★ 续跑时**复用上次的文件**(不新开):一次任务只该有一个 uid_*.txt,
+             *   否则用户拿到两个文件还得自己合并。 */
+            if (resumed && cp && cp.runFile) {
+                runFile = cp.runFile;
+            } else {
+                runFile = settings.output.dir + '/' +
+                    TikTokAuto.parse.timestampName('uid', new Date());
+            }
             writer = TikTokAuto.uidset.createWriter({
             writer = TikTokAuto.uidset.createWriter({
                 runPath: runFile,
                 runPath: runFile,
                 historyPath: settings.output.historyPath,
                 historyPath: settings.output.historyPath,
@@ -380,6 +483,32 @@ TikTokAuto.idflow = (function () {
             if (settings.output.dedupeHistory) {
             if (settings.output.dedupeHistory) {
                 TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath);
                 TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath);
             }
             }
+            /* ★ 续跑时**无条件**把上次的产物载入去重集合(不依赖 dedupeHistory):
+             * 否则会把已经收过的人再写一遍。base 以**文件行数**为准 —— 文件是真相,
+             * 不信断点里那个计数(它可能是被杀之前写的旧值)。 */
+            if (resumed) {
+                base = TikTokAuto.uidset.readUidFile(runFile).length;
+                TikTokAuto.uidset.loadHistory(seen, runFile);
+                summary.warnings.push('本次为续跑:结果继续追加到 ' + runFile);
+                log.info('idflow', '续跑:跳过已收 ' + base + ' 个');
+            }
+
+            /* 建/续断点,并**立刻写一次** —— 刚起步就被杀也能接着跑 */
+            if (!resumed) {
+                var entries = [];
+                for (var si = 0; si < settings.sources.length; si += 1) {
+                    entries.push({
+                        key: settings.sources[si].key ||
+                             ('i' + si + ':' + (settings.sources[si].kind || '')),
+                        kind: settings.sources[si].kind || '',
+                        label: TikTokAuto.idsource.describe(settings.sources[si])
+                    });
+                }
+                cp = TikTokAuto.checkpoint.empty(
+                    TikTokAuto.bridge.currentRunId ? TikTokAuto.bridge.currentRunId() : '',
+                    sig, runFile, entries);
+            }
+            saveCheckpoint();
 
 
             /* —— 启动与就位(复用视频链路的成熟实现) —— */
             /* —— 启动与就位(复用视频链路的成熟实现) —— */
             progress('启动应用', 0);
             progress('启动应用', 0);
@@ -412,13 +541,44 @@ TikTokAuto.idflow = (function () {
                     break;
                     break;
                 }
                 }
                 var source = settings.sources[i];
                 var source = settings.sources[i];
+                var sourceKey = source.key || ('i' + i + ':' + (source.kind || ''));
                 var before = summary.extracted;
                 var before = summary.extracted;
 
 
+                /* 上次已走完的取材直接跳过 —— 这就是"断点续跑"的全部含义:
+                 * 不是从第几屏接着滚,而是**不再重走已经走完的那几条**。 */
+                if (resumed && TikTokAuto.checkpoint.sourceDone(cp, sourceKey)) {
+                    summary.sources.push({
+                        label: TikTokAuto.idsource.describe(source),
+                        handled: 0,
+                        added: 0,
+                        reused: true
+                    });
+                    log.info('idflow', '续跑跳过(上次已完成):' + TikTokAuto.idsource.describe(source));
+                    continue;
+                }
+
+                TikTokAuto.checkpoint.markSource(cp, sourceKey, 'running');
+                saveCheckpoint();
+
                 progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted);
                 progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted);
+
+                /* 续跑时这条取材上次翻了多少屏 → 这次把预算放大相应倍数。
+                 * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。 */
+                var prevScreens = resumed ? TikTokAuto.checkpoint.screensOf(cp, sourceKey) : 0;
+                var maxScreens = 0;
+                if (prevScreens > 0) {
+                    maxScreens = Math.min(TikTokAuto.idsource.MAX_SCREENS * 2,
+                        TikTokAuto.idsource.MAX_SCREENS +
+                        Math.min(prevScreens, TikTokAuto.idsource.MAX_SCREENS));
+                }
+
+                var stat = {};
                 var ctx = {
                 var ctx = {
                     config: config,
                     config: config,
                     seen: seen,
                     seen: seen,
                     shouldStop: shouldStop,
                     shouldStop: shouldStop,
+                    maxScreens: maxScreens,
+                    stat: stat,
                     onSeenProfile: function (prof) {
                     onSeenProfile: function (prof) {
                         log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans +
                         log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans +
                             ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes);
                             ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes);
@@ -433,6 +593,7 @@ TikTokAuto.idflow = (function () {
                 };
                 };
 
 
                 var handled = 0;
                 var handled = 0;
+                var errorsBefore = summary.errors.length;
                 try {
                 try {
                     handled = TikTokAuto.idsource.run(source, ctx);
                     handled = TikTokAuto.idsource.run(source, ctx);
                 } catch (error) {
                 } catch (error) {
@@ -449,24 +610,51 @@ TikTokAuto.idflow = (function () {
                 });
                 });
                 log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) +
                 log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) +
                     ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个');
                     ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个');
+
+                /* ★ 走完一条就记一条 —— 下次续跑正是靠它跳过这一条。
+                 *
+                 * 但**失败的那条不能标 done**:`idsource.run` 打不开列表时会记 errors
+                 * 然后返回 0(不抛异常),如果一律标 done,续跑就再也不会重试它,
+                 * 用户会以为"这条取材就是这样、没数据"。所以:
+                 *   · 本轮新增了错误 → 保持 running,下次接着试
+                 *   · 否则(正常走完,哪怕列表本来就是空的)→ done
+                 *
+                 * 中途被停止时这里不会执行,那条取材保持 running,
+                 * 续跑就从它开头重走 —— 这就是"最多回退一条取材"。 */
+                var sourceFailed = summary.errors.length > errorsBefore;
+                TikTokAuto.checkpoint.markSource(cp, sourceKey,
+                    sourceFailed ? 'running' : 'done', {
+                        screens: stat.screens || 0,
+                        handled: handled,
+                        added: summary.extracted - before
+                    });
+                saveCheckpoint();
             }
             }
 
 
             /* —— 第二层:深度展开 ——
             /* —— 第二层:深度展开 ——
              * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
              * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
-             * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。 */
-            if (expandQueue.length > 0 && shouldStop() && limitReached()) {
+             * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。
+             *
+             * 续跑时先用断点过滤掉"上次已经展开过"的人:展开一个人要 5~10 秒,
+             * 重做一遍纯属浪费。 */
+            var todoExpand = TikTokAuto.checkpoint.pendingExpand(cp, expandQueue);
+            if (expandQueue.length > 0 && todoExpand.length === 0) {
+                log.info('idflow', '展开项上次已全部完成(' + expandQueue.length + ' 个),跳过');
+            }
+            if (todoExpand.length > 0 && shouldStop() && limitReached()) {
                 /* 上限在主层就满了 → 展开根本没跑。这必须说出来:
                 /* 上限在主层就满了 → 展开根本没跑。这必须说出来:
                  * 用户明明勾了「再取粉丝列表」,看到"提取满 3 个"很容易以为展开做了。 */
                  * 用户明明勾了「再取粉丝列表」,看到"提取满 3 个"很容易以为展开做了。 */
                 summary.warnings.push('提取上限已到,深度展开(再取粉丝列表)本次没有执行');
                 summary.warnings.push('提取上限已到,深度展开(再取粉丝列表)本次没有执行');
-                log.warn('idflow', '提取上限已到,' + expandQueue.length + ' 个待展开的账号本次没有展开');
+                log.warn('idflow', '提取上限已到,' + todoExpand.length + ' 个待展开的账号本次没有展开');
             }
             }
-            if (expandQueue.length > 0 && !shouldStop()) {
-                log.step('idflow', '开始深度展开:' + expandQueue.length + ' 个账号');
-                for (var e = 0; e < expandQueue.length; e += 1) {
+            if (todoExpand.length > 0 && !shouldStop()) {
+                log.step('idflow', '开始深度展开:' + todoExpand.length + ' 个账号' +
+                    (resumed ? '(已跳过上次做完的)' : ''));
+                for (var e = 0; e < todoExpand.length; e += 1) {
                     if (shouldStop()) {
                     if (shouldStop()) {
                         break;
                         break;
                     }
                     }
-                    var target = expandQueue[e];
+                    var target = todoExpand[e];
                     progress('展开 @' + target.uid + ' 的' +
                     progress('展开 @' + target.uid + ' 的' +
                         (target.which === 'following' ? '关注' : '粉丝'), summary.extracted);
                         (target.which === 'following' ? '关注' : '粉丝'), summary.extracted);
 
 
@@ -496,10 +684,15 @@ TikTokAuto.idflow = (function () {
                         }
                         }
                         log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2);
                         log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2);
                     }
                     }
+                    /* 做完一个就记一个:展开成本高(5~10 秒/人),重做代价大 */
+                    TikTokAuto.checkpoint.markExpandDone(cp, target.uid, target.which);
+                    saveCheckpoint();
                 }
                 }
             }
             }
 
 
-            summary.ok = summary.extracted > 0;
+            /* 成功判定按**累计**:续跑本轮新增 0 个、但上次已有 37 个,这是成功不是失败 */
+            var totalSoFar = base + summary.extracted;
+            summary.ok = totalSoFar > 0;
             if (!summary.ok && summary.errors.length === 0) {
             if (!summary.ok && summary.errors.length === 0) {
                 summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
                 summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
             }
             }
@@ -511,7 +704,15 @@ TikTokAuto.idflow = (function () {
              *   ② 失败**只记 warning**,绝不把已经抓到的 username 算成失败;
              *   ② 失败**只记 warning**,绝不把已经抓到的 username 算成失败;
              *   ③ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系。
              *   ③ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系。
              */
              */
-            if (settings.output.resolveUid && summary.extracted > 0 && !settings.output.dryRun) {
+            var needResolve = settings.output.resolveUid && !settings.output.dryRun && totalSoFar > 0;
+            /* 续跑但本轮一个都没新增、上次也已经生成过映射 → 直接沿用,不重算。
+             * 否则每次续跑都要把整份文件重新解析一遍(每个 2.5 秒)。 */
+            if (needResolve && resumed && summary.extracted === 0 && cp && cp.mapFile) {
+                log.info('idflow', '本轮没有新增,映射文件沿用上次的:' + cp.mapFile);
+                summary.mapFile = cp.mapFile;
+                needResolve = false;
+            }
+            if (needResolve) {
                 try {
                 try {
                     writer.close();
                     writer.close();
                     resolvePhase(summary, settings);
                     resolvePhase(summary, settings);
@@ -536,9 +737,28 @@ TikTokAuto.idflow = (function () {
                     summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)');
                     summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)');
                 }
                 }
             }
             }
+            /* 累计口径给上层(bridge 的 done、界面状态条、记录文案都用它) */
+            summary.extractedTotal = base + summary.extracted;
+
+            /* 断点收尾:无论正常结束还是被停止/出错解栈,都要把"走到哪"落盘。
+             * `allDone` 为真才算真跑完(界面上不再提供"继续");否则留成 stopped 等续跑。 */
+            if (cp) {
+                /* 三种情况都算"这一轮没有可续的了":
+                 *   ① 取材与展开都走完了;
+                 *   ② 达到了用户设的提取上限 —— 否则会留下一个"点了继续立刻又停"的断点,
+                 *      因为 limitReached 按累计算,续跑一进来就已经到顶了。
+                 */
+                cp.state = (TikTokAuto.checkpoint.allDone(cp) || limitReached()) ? 'done' : 'stopped';
+                saveCheckpoint();
+                log.info('idflow', '断点已保存(' + cp.state + '):取材 ' +
+                    TikTokAuto.checkpoint.sourceProgress(cp).done + '/' +
+                    TikTokAuto.checkpoint.sourceProgress(cp).total + ' 条已完成');
+            }
+
             /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append),
             /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append),
-             * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。 */
-            if (summary.extracted === 0) {
+             * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。
+             * 续跑时按累计判:上次已收 37 个,文件是存在的。 */
+            if (summary.extractedTotal === 0) {
                 summary.resultFile = '';
                 summary.resultFile = '';
             }
             }
             if (limitReached()) {
             if (limitReached()) {
@@ -555,17 +775,21 @@ TikTokAuto.idflow = (function () {
                  * 那么收满 L 个需要 L × T / E 秒。实测 1800 秒 / 83 个 → 100 个约 36 分钟。
                  * 那么收满 L 个需要 L × T / E 秒。实测 1800 秒 / 83 个 → 100 个约 36 分钟。
                  */
                  */
                 var elapsedMs = Date.now() - began;
                 var elapsedMs = Date.now() - began;
+                /* 文案与换算都按**累计**:续跑时本轮可能只新增 5 个,
+                 * 但用户真正关心的是"总共 42/100",说"只收了 5/100"会吓人。 */
                 if (summary.extracted > 0) {
                 if (summary.extracted > 0) {
                     var needMin = Math.ceil(
                     var needMin = Math.ceil(
-                        settings.limit * elapsedMs / summary.extracted / 60000
+                        (settings.limit - summary.extractedTotal + summary.extracted) *
+                        elapsedMs / summary.extracted / 60000
                     );
                     );
                     summary.warnings.push(
                     summary.warnings.push(
-                        '时间到,只收了 ' + summary.extracted + '/' + settings.limit + ' 个。' +
-                        '按本轮速度,收满 ' + settings.limit + ' 个约需 ' + needMin +
-                        ' 分钟 —— 建议把「单次最长运行」调到 ' + (needMin + 5) + ' 分钟以上'
+                        '时间到,共收了 ' + summary.extractedTotal + '/' + settings.limit +
+                        ' 个(本轮新增 ' + summary.extracted + ' 个)。' +
+                        '按本轮速度,收满 ' + settings.limit + ' 个还需约 ' + needMin +
+                        ' 分钟 —— 可以点「继续」接着跑'
                     );
                     );
-                    log.warn('idflow', '时间到,只收 ' + summary.extracted + ' 个;' +
-                        '按本轮速度收满 ' + settings.limit + ' 个约需 ' + needMin + ' 分钟');
+                    log.warn('idflow', '时间到,累计 ' + summary.extractedTotal + ' 个;' +
+                        '按本轮速度收满 ' + settings.limit + ' 个还需约 ' + needMin + ' 分钟');
                 } else {
                 } else {
                     /* 一个都没收到就超时:多半不是时间不够,而是过滤太严或取材取不到人。
                     /* 一个都没收到就超时:多半不是时间不够,而是过滤太严或取材取不到人。
                      * 这时给"调大时间"的建议是害人 —— 要说清该查哪里。 */
                      * 这时给"调大时间"的建议是害人 —— 要说清该查哪里。 */
@@ -574,15 +798,16 @@ TikTokAuto.idflow = (function () {
                         '过滤条件是否过严(见日志里的跳过原因)'
                         '过滤条件是否过严(见日志里的跳过原因)'
                     );
                     );
                 }
                 }
-            } else if (settings.limit > 0 && summary.extracted < settings.limit) {
+            } else if (settings.limit > 0 && summary.extractedTotal < settings.limit) {
                 /* 第三种"没到上限":取材本身跑完了(列表到底/文件读完),不是时间问题。
                 /* 第三种"没到上限":取材本身跑完了(列表到底/文件读完),不是时间问题。
                  * 这时调时间没用,得加取材量。 */
                  * 这时调时间没用,得加取材量。 */
                 summary.warnings.push(
                 summary.warnings.push(
-                    '取材范围已取尽,共收 ' + summary.extracted + '/' + settings.limit +
+                    '取材范围已取尽,共收 ' + summary.extractedTotal + '/' + settings.limit +
                     ' 个 —— 想收更多请增加取材(换关键词、加账号,或打开「再取粉丝列表」)'
                     ' 个 —— 想收更多请增加取材(换关键词、加账号,或打开「再取粉丝列表」)'
                 );
                 );
             }
             }
-            log.step('idflow', '=== 提取结束:新增 ' + summary.extracted +
+            log.step('idflow', '=== 提取结束' + (resumed ? '(续跑)' : '') +
+                ':新增 ' + summary.extracted + ' 个,累计 ' + summary.extractedTotal +
                 ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates +
                 ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates +
                 ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ===');
                 ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ===');
         }
         }