Kaynağa Gözat

修:抓取用满时长护栏后,uid 解析 83 个一个都没换(并让"单次最长运行"可配)

现象(用户 limit=100 那次,30 分 1 秒)
  [idflow] 已达单次运行时长上限(30 分钟),准备收尾
  [idflow] 开始解析数字 uid:83 个
  [resolve] 到达时长上限,已换 0/83 个

根因:同一个坑的第二个变体
  上一轮修的是 shouldStop() 里的 limitReached(),这一轮是 timeUp()。
  抓取把 30 分钟用满(83 收录 + 142 跳过 = 225 个候选 x 约 8 秒),
  收尾一进来 timeUp() 就为真 → 83 个全丢。
  本质仍是"收尾继承了抓取的预算"。

修
- resolvePhase 从进入解析那一刻重新计时,用自己的预算
  id.guard.resolveMaxMs(默认 10 分钟 ≈ 240 个)
- 不再看抓取的 timeUp();用户主动停止仍有效(StopSignal 从 wait.tick 抛出)
- 解析被自身预算截断时加 warning:只换到 X/Y 个

验证(抓取预算故意设 60 秒,让它在抓取中途被掐)
  [idflow] 已达单次运行时长上限(1 分钟),准备收尾
  [resolve] user3670597276145 → 7294887337619964934(via html)
  [resolve] mubahir378 → 7673463166534124565(via html)
  [resolve] mukhawar.shop → 7682148805563646983(via html)
  uid 解析完成:成功 3 / 失败 0

顺带解决"想抓 100 个却只有 83 个"
  30 分钟是硬编码护栏,开过滤时约 8 秒/个 → 只够 200 来个候选。
  新增界面配置 tabs.id.features.maxRuntimeMin(分钟,默认 30,1~240):
  - store FIELDS(21 → 22 个字段)
  - page-id「输出」段加一行「单次最长运行 __ 分钟(到点收尾,已抓的不丢)」
  - engine.js 映射到 id.guard.maxRuntimeMs(分钟 → 毫秒)
  - smoke 补两条断言:字段数 22、maxRuntimeMin=90 → maxRuntimeMs=5400000
  端到端验证:界面改 3 → 引擎收到 guard.maxRuntimeMs = 180000 ✓

自检:界面 414 项全绿 / AUDIT_CLEAN

通则(第二次踩,该当铁律):**阶段性的"停止"不能共用**。
抓取阶段的"够数了/到点了"与收尾阶段的"必须做完"是相反的要求。
每个阶段要有自己的预算与判据,只共享"外部强制"(用户叫停)。
梦境 1 hafta önce
ebeveyn
işleme
a59aae1eda

+ 33 - 4
tiktok-app/dist/tiktok-app.js

@@ -1,6 +1,6 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-20T03:14:59.125Z
+ *  built 2026-10-20T11:32:21.394Z
  *  modules: theme, store, records, sources, device, service, engine, session, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -649,7 +649,18 @@ F.push(
     { path: "tabs.id.features.videosMin",      type: "int", min: 0, max: 100000000, def: 0 },
     { path: "tabs.id.features.videosMax",      type: "int", min: 0, max: 100000000, def: 500000 },
     /* 0 = 不限制。用「0 表示不限」而不是留空,是因为数字输入框留空无法表达。 */
-    { path: "tabs.id.features.extractLimit",   type: "int", min: 0, max: 100000, def: 0 }
+    { path: "tabs.id.features.extractLimit",   type: "int", min: 0, max: 100000, def: 0 },
+    /**
+     * 单次运行最长多少分钟(抓取阶段)。
+     *
+     * 为什么让用户可调:这个值实际决定了"一轮最多能抓多少个",而抓取速度完全取决于过滤设置
+     * —— 不开过滤约 1 秒/个;开了「排除私密 / 排除无头像 / 数量门槛」就要逐个开用户页,
+     * 约 8 秒/个。实测 225 个候选就吃满了 30 分钟。
+     * 所以"想抓 100 个"的人必须能把它调大,否则提取上限设多大都抓不到。
+     *
+     * 上限 240 分钟:再长还不如分几轮跑(每轮都刷盘,中断不丢)。
+     */
+    { path: "tabs.id.features.maxRuntimeMin",  type: "int", min: 1, max: 240, def: 30 }
 );
 
 /* ——— UID私信页 ——— */
@@ -2729,8 +2740,15 @@ Engine.buildIdConfig = function () {
                 historyPath: Engine.ID_DIR + "/uid_extracted.txt",
                 dedupeHistory: Store.getBool(base + "dedupeHistory"),
                 dryRun: false,
-                /* 0 = 不限。引擎另有 30 分钟时长护栏兜底(见 idflow.js) */
-                limit: Store.getNumber(base + "extractLimit")
+                /* 0 = 不限。引擎另有「单次最长运行」与「解析 uid 独立预算」两道时长护栏 */
+                limit: Store.getNumber(base + "extractLimit"),
+                /* 抓完之后换数字 uid(默认开)。界面上暂未给开关——它没有副作用,
+                 * 只是多花时间(约 2.5 秒/个),而数字 uid 在界面上读不到,不换就得事后另跑。 */
+                resolveUid: true
+            },
+            guard: {
+                /* 抓取阶段最长多久(分钟 → 毫秒)。到点收尾,已抓的不丢。 */
+                maxRuntimeMs: Math.max(1, Store.getNumber(base + "maxRuntimeMin")) * 60 * 1000
             }
         },
         runtime: {
@@ -6890,6 +6908,17 @@ function buildOutput() {
     stopRow.addView(U.text("个 ID 后停止(0 = 不限)", Theme.m.labelSize, Theme.hex.textMuted, false));
     blocks.push(stopRow);
 
+    /* 单次最长运行时间。它决定"一轮最多能抓多少个"——
+     * 开了过滤时约 8 秒/个,30 分钟只够 200 来个候选。
+     * 提取上限设得比这还大是没意义的,所以要让它可见可调。 */
+    var runtimeRow = U.row();
+    runtimeRow.addView(U.text("单次最长运行", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    runtimeRow.addView(U.flexible());
+    runtimeRow.addView(W.numberField(base + "maxRuntimeMin", "30").root);
+    runtimeRow.addView(U.hgap(6));
+    runtimeRow.addView(U.text("分钟(到点收尾,已抓的不丢)", Theme.m.labelSize, Theme.hex.textMuted, false));
+    blocks.push(runtimeRow);
+
     blocks.push(W.hint("结果文件自动命名 uid_日期_时分.txt,存放在 /sdcard/Download/tiktok_app", false));
 
     W.stack(card, blocks, 12);

+ 9 - 2
tiktok-app/src/engine.js

@@ -245,8 +245,15 @@ Engine.buildIdConfig = function () {
                 historyPath: Engine.ID_DIR + "/uid_extracted.txt",
                 dedupeHistory: Store.getBool(base + "dedupeHistory"),
                 dryRun: false,
-                /* 0 = 不限。引擎另有 30 分钟时长护栏兜底(见 idflow.js) */
-                limit: Store.getNumber(base + "extractLimit")
+                /* 0 = 不限。引擎另有「单次最长运行」与「解析 uid 独立预算」两道时长护栏 */
+                limit: Store.getNumber(base + "extractLimit"),
+                /* 抓完之后换数字 uid(默认开)。界面上暂未给开关——它没有副作用,
+                 * 只是多花时间(约 2.5 秒/个),而数字 uid 在界面上读不到,不换就得事后另跑。 */
+                resolveUid: true
+            },
+            guard: {
+                /* 抓取阶段最长多久(分钟 → 毫秒)。到点收尾,已抓的不丢。 */
+                maxRuntimeMs: Math.max(1, Store.getNumber(base + "maxRuntimeMin")) * 60 * 1000
             }
         },
         runtime: {

+ 11 - 0
tiktok-app/src/page-id.js

@@ -426,6 +426,17 @@ function buildOutput() {
     stopRow.addView(U.text("个 ID 后停止(0 = 不限)", Theme.m.labelSize, Theme.hex.textMuted, false));
     blocks.push(stopRow);
 
+    /* 单次最长运行时间。它决定"一轮最多能抓多少个"——
+     * 开了过滤时约 8 秒/个,30 分钟只够 200 来个候选。
+     * 提取上限设得比这还大是没意义的,所以要让它可见可调。 */
+    var runtimeRow = U.row();
+    runtimeRow.addView(U.text("单次最长运行", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    runtimeRow.addView(U.flexible());
+    runtimeRow.addView(W.numberField(base + "maxRuntimeMin", "30").root);
+    runtimeRow.addView(U.hgap(6));
+    runtimeRow.addView(U.text("分钟(到点收尾,已抓的不丢)", Theme.m.labelSize, Theme.hex.textMuted, false));
+    blocks.push(runtimeRow);
+
     blocks.push(W.hint("结果文件自动命名 uid_日期_时分.txt,存放在 /sdcard/Download/tiktok_app", false));
 
     W.stack(card, blocks, 12);

+ 12 - 1
tiktok-app/src/store.js

@@ -225,7 +225,18 @@ F.push(
     { path: "tabs.id.features.videosMin",      type: "int", min: 0, max: 100000000, def: 0 },
     { path: "tabs.id.features.videosMax",      type: "int", min: 0, max: 100000000, def: 500000 },
     /* 0 = 不限制。用「0 表示不限」而不是留空,是因为数字输入框留空无法表达。 */
-    { path: "tabs.id.features.extractLimit",   type: "int", min: 0, max: 100000, def: 0 }
+    { path: "tabs.id.features.extractLimit",   type: "int", min: 0, max: 100000, def: 0 },
+    /**
+     * 单次运行最长多少分钟(抓取阶段)。
+     *
+     * 为什么让用户可调:这个值实际决定了"一轮最多能抓多少个",而抓取速度完全取决于过滤设置
+     * —— 不开过滤约 1 秒/个;开了「排除私密 / 排除无头像 / 数量门槛」就要逐个开用户页,
+     * 约 8 秒/个。实测 225 个候选就吃满了 30 分钟。
+     * 所以"想抓 100 个"的人必须能把它调大,否则提取上限设多大都抓不到。
+     *
+     * 上限 240 分钟:再长还不如分几轮跑(每轮都刷盘,中断不丢)。
+     */
+    { path: "tabs.id.features.maxRuntimeMin",  type: "int", min: 1, max: 240, def: 30 }
 );
 
 /* ——— UID私信页 ——— */

+ 11 - 2
tiktok-app/tools/smoke.js

@@ -169,8 +169,9 @@ check('空清单 = 未配置(再也找不到"哪个脚本被选中"这种概
     api.Pages.checkedScript('id') === null);
 
 /* v7 那次清语义的成果一个都没丢:功能设置 19 个字段还在 */
-eq('ID提取功能设置仍是 21 个字段(v7 的 19 个 + 去重两开关)',
-    api.Store.FIELDS.filter((f) => /^tabs\.id\.features\./.test(f.path)).length, 21);
+/* 22 = v7 的 19 个 + 去重两开关 + 单次最长运行(它决定"一轮最多能抓多少个") */
+eq('ID提取功能设置是 22 个字段',
+    api.Store.FIELDS.filter((f) => /^tabs\.id\.features\./.test(f.path)).length, 22);
 
 /* —— 界面 → 引擎的字段映射:一个都不许丢 ——
  * 这个项目踩过"路径拼错 = 静默丢值"(字段在界面上有控件、能改、能存,
@@ -216,6 +217,14 @@ eq('映射:跨轮去重', mo.dedupeHistory, true);
 check('映射:结果目录与历史文件在同一处',
     mo.dir && mo.historyPath.indexOf(mo.dir) === 0, mo.dir + ' / ' + mo.historyPath);
 
+/* 单次最长运行(分钟 → 毫秒)。这个值实际决定「一轮最多能抓多少个」:
+ * 映射丢了的话用户改时间完全不起作用,而且界面上看不出来(改完还在按 30 分钟收尾)。 */
+api.Store.set(idBase + 'maxRuntimeMin', 90);
+const mappedRuntime = api.Engine.buildIdConfig();
+eq('映射:单次最长运行(分钟 → 毫秒)',
+    mappedRuntime.config.id.guard.maxRuntimeMs, 90 * 60 * 1000);
+api.Store.set(idBase + 'maxRuntimeMin', api.Store.defaultOf(idBase + 'maxRuntimeMin'));
+
 /* 「做不到的项」必须逐条进 notes —— 用户勾了却什么都发生,比明确告知糟得多 */
 const noteText = mapped.notes.join(' | ');
 check('云端去重开了 → 如实告知只用本地', noteText.indexOf('云端去重') >= 0, noteText);

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

@@ -1,5 +1,5 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-20T10:29:07.437Z
+ *  built 2026-10-20T11:29:19.585Z
  *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, parse, uidset, filters, profile, idsource, resolve, idflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -494,7 +494,17 @@ TikTokAuto.config = (function () {
              * 到点时**如实报告**,不装作抓完了。
              */
             guard: {
-                maxRuntimeMs: 30 * 60 * 1000
+                /** 抓取阶段最长多久(不含收尾) */
+                maxRuntimeMs: 30 * 60 * 1000,
+                /**
+                 * 解析数字 uid 阶段**自己的**预算。
+                 *
+                 * 为什么必须独立:抓取很容易把 `maxRuntimeMs` 用满
+                 * (实测 83 个候选 × 每人约 8 秒 = 30 分钟),
+                 * 而解析 uid 是**收尾工作**,恰恰在这之后才做。
+                 * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。
+                 */
+                resolveMaxMs: 10 * 60 * 1000
             }
         },
         watch: {
@@ -6036,7 +6046,10 @@ TikTokAuto.idflow = (function () {
                 resolveUid: output.resolveUid !== false
             },
             limit: Number(output.limit) || 0,
-            maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000
+            maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000,
+            /* 解析 uid 的独立预算:默认 10 分钟 ≈ 240 个(每个约 2.5 秒)。
+             * 与抓取预算分开,因为它们是先后发生的两件事(见下)。 */
+            resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000
         };
     }
 
@@ -6160,27 +6173,34 @@ TikTokAuto.idflow = (function () {
                 TikTokAuto.parse.timestampName('uid_map', new Date());
 
             log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
+            var resolveStarted = Date.now();
             var result = TikTokAuto.resolve.resolveAll(names, {
                 /**
-                 * ★ 这里**故意不传 shouldStop()**。
+                 * ★ 这里**只用自己的预算**,不继承抓取阶段的任何停止条件。
                  *
-                 * `shouldStop()` 里含 `limitReached()` —— 那是**抓取阶段**的停止条件:
-                 * "已经收够这么多个了,别再往外抓"。而解析 uid 是**收尾工作**,
-                 * 恰恰是在抓满之后才需要做。
+                 * 两次踩同一个坑,两次都是"收尾继承了抓取的条件":
+                 *   ① 先是 `shouldStop()` 里的 `limitReached()` —— 抓满 N 个后解析 0 个;
+                 *   ② 再是 `shouldStop()` 里的 `timeUp()` —— 抓取把 30 分钟用满后解析 0 个
+                 *      (实测:83 个候选 × 每人约 8 秒 = 30 分钟,收尾一点时间都不剩)。
                  *
-                 * 早先直接传 shouldStop(),于是设了「提取满 3 个」的用户:
-                 * 抓满 3 个 → limitReached() 一直为真 → 解析阶段第一句就被判"收到停止"
-                 * → 一个 uid 都换不到(实测 3 个全丢,日志只有一行"收到停止,已换 0/3")。
-                 *
-                 * 只保留时长护栏:用户主动停止靠 StopSignal 从 `wait.tick` 抛出,
-                 * 不需要在这里轮询。
+                 * 抓取阶段的"够了 / 到点了"和收尾阶段的"必须做完"是相反的要求。
+                 * 所以这里从**进入解析的那一刻**重新计时,用一个独立预算(默认 10 分钟)。
+                 * 用户主动停止仍然有效 —— 那是 StopSignal 从 `wait.tick` 抛出来的。
                  */
-                shouldStop: timeUp,
+                shouldStop: function () {
+                    return Date.now() - resolveStarted >= settings.resolveMaxMs;
+                },
                 onProgress: function (done, total) {
                     progress('解析 uid ' + done + '/' + total, target.extracted);
                 }
             });
 
+            if (result.stopped) {
+                target.warnings.push('解析 uid 到达自身时长上限(' +
+                    Math.round(settings.resolveMaxMs / 60000) + ' 分钟),只换到 ' +
+                    result.map.length + '/' + names.length + ' 个');
+            }
+
             target.resolved = result.map.length;
             target.unresolved = result.failed.length;
             if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {

+ 11 - 1
tiktok-auto/src/config.js

@@ -69,7 +69,17 @@ TikTokAuto.config = (function () {
              * 到点时**如实报告**,不装作抓完了。
              */
             guard: {
-                maxRuntimeMs: 30 * 60 * 1000
+                /** 抓取阶段最长多久(不含收尾) */
+                maxRuntimeMs: 30 * 60 * 1000,
+                /**
+                 * 解析数字 uid 阶段**自己的**预算。
+                 *
+                 * 为什么必须独立:抓取很容易把 `maxRuntimeMs` 用满
+                 * (实测 83 个候选 × 每人约 8 秒 = 30 分钟),
+                 * 而解析 uid 是**收尾工作**,恰恰在这之后才做。
+                 * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。
+                 */
+                resolveMaxMs: 10 * 60 * 1000
             }
         },
         watch: {

+ 22 - 12
tiktok-auto/src/idflow.js

@@ -86,7 +86,10 @@ TikTokAuto.idflow = (function () {
                 resolveUid: output.resolveUid !== false
             },
             limit: Number(output.limit) || 0,
-            maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000
+            maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000,
+            /* 解析 uid 的独立预算:默认 10 分钟 ≈ 240 个(每个约 2.5 秒)。
+             * 与抓取预算分开,因为它们是先后发生的两件事(见下)。 */
+            resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000
         };
     }
 
@@ -210,27 +213,34 @@ TikTokAuto.idflow = (function () {
                 TikTokAuto.parse.timestampName('uid_map', new Date());
 
             log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
+            var resolveStarted = Date.now();
             var result = TikTokAuto.resolve.resolveAll(names, {
                 /**
-                 * ★ 这里**故意不传 shouldStop()**。
+                 * ★ 这里**只用自己的预算**,不继承抓取阶段的任何停止条件。
                  *
-                 * `shouldStop()` 里含 `limitReached()` —— 那是**抓取阶段**的停止条件:
-                 * "已经收够这么多个了,别再往外抓"。而解析 uid 是**收尾工作**,
-                 * 恰恰是在抓满之后才需要做。
+                 * 两次踩同一个坑,两次都是"收尾继承了抓取的条件":
+                 *   ① 先是 `shouldStop()` 里的 `limitReached()` —— 抓满 N 个后解析 0 个;
+                 *   ② 再是 `shouldStop()` 里的 `timeUp()` —— 抓取把 30 分钟用满后解析 0 个
+                 *      (实测:83 个候选 × 每人约 8 秒 = 30 分钟,收尾一点时间都不剩)。
                  *
-                 * 早先直接传 shouldStop(),于是设了「提取满 3 个」的用户:
-                 * 抓满 3 个 → limitReached() 一直为真 → 解析阶段第一句就被判"收到停止"
-                 * → 一个 uid 都换不到(实测 3 个全丢,日志只有一行"收到停止,已换 0/3")。
-                 *
-                 * 只保留时长护栏:用户主动停止靠 StopSignal 从 `wait.tick` 抛出,
-                 * 不需要在这里轮询。
+                 * 抓取阶段的"够了 / 到点了"和收尾阶段的"必须做完"是相反的要求。
+                 * 所以这里从**进入解析的那一刻**重新计时,用一个独立预算(默认 10 分钟)。
+                 * 用户主动停止仍然有效 —— 那是 StopSignal 从 `wait.tick` 抛出来的。
                  */
-                shouldStop: timeUp,
+                shouldStop: function () {
+                    return Date.now() - resolveStarted >= settings.resolveMaxMs;
+                },
                 onProgress: function (done, total) {
                     progress('解析 uid ' + done + '/' + total, target.extracted);
                 }
             });
 
+            if (result.stopped) {
+                target.warnings.push('解析 uid 到达自身时长上限(' +
+                    Math.round(settings.resolveMaxMs / 60000) + ' 分钟),只换到 ' +
+                    result.map.length + '/' + names.length + ' 个');
+            }
+
             target.resolved = result.map.length;
             target.unresolved = result.failed.length;
             if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {