Sfoglia il codice sorgente

时间预算按提取上限动态放宽 + 进主页等待补齐(用户两条反馈)

① 时间上限与提取上限脱节
  原来固定 30 分钟,而用户目标是"提取满 N 个":
  设 100 个要 38 分钟、设 1000 个要 5 个多小时,30 分钟永远不够。

  新公式(idflow.budgetFor):
    perResultMs     = 20000   // 折算到每个"收录"(含被跳过的)
    fixedOverheadMs = 5 分钟  // 启动 app / 搜索 / 开列表
    limit <= 0 → 用用户设的时长
    limit >  0 → clamp(max(用户设的, limit×20s + 5min), 4h)

  语义变了:界面「单次最长运行」改成「至少运行 __ 分钟(按提取上限自动放宽,最多 4 小时)」
  —— 用户设的值现在是**下限**。

  实测 limit=100 → 38 分钟(实测 100 个约需 36 分钟,吻合)
       limit=1000 → 240 分钟(4 小时封顶)
  日志开头说明:抓取预算 38 分钟(按提取上限 100 个估算;你设的下限是 30 分钟)
  解析预算同样按实际个数算:count×3s + 1min,1~60 分钟

② 进主页等待太短 —— 正是之前"找不到 fans 入口"的根因
  waitProfile 原来只等 @username 出现(约 585ms),数字区还在渲染就判"到了",
  导致 statEntry 找不到入口、数字读成占位的 0(实测把 2019 粉丝读成 0)。

  三层修:
  - waitProfile 分两段:等 @username(10s) + 等三组数字全非 null(10s,超时也继续但留痕)
  - openList 找不到入口时再等 3 秒重试一次(原来立刻报失败)
  - readStatsStable 对"三组全 0"这个可疑值多等 1.5 秒复核

  判据从"两组"改成"三组都读到"很关键:粉丝0/关注0/获赞null 这种半渲染
  在"两组"判据下会通过。

  真机对比(@.1523615):修前 0/0/null → 修后 2020/2108/5936 ✓
  代价:每账号慢约 25%(8 秒 → 10 秒),这是读准必须付的钱。

自检:引擎 68 项 / 界面 414 项全绿。
梦境 1 settimana fa
parent
commit
b07b8c75dd

+ 12 - 12
tiktok-app/dist/tiktok-app.js

@@ -1,6 +1,6 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-20T11:32:21.394Z
+ *  built 2026-10-21T15:00:55.550Z
  *  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/ 后重新构建。
  */
@@ -651,14 +651,13 @@ F.push(
     /* 0 = 不限制。用「0 表示不限」而不是留空,是因为数字输入框留空无法表达。 */
     { path: "tabs.id.features.extractLimit",   type: "int", min: 0, max: 100000, def: 0 },
     /**
-     * 单次运行最长多少分钟(抓取阶段)。
+     * 运行时长的**下限**(分钟)。
      *
-     * 为什么让用户可调:这个值实际决定了"一轮最多能抓多少个",而抓取速度完全取决于过滤设置
-     * —— 不开过滤约 1 秒/个;开了「排除私密 / 排除无头像 / 数量门槛」就要逐个开用户页,
-     * 约 8 秒/个。实测 225 个候选就吃满了 30 分钟。
-     * 所以"想抓 100 个"的人必须能把它调大,否则提取上限设多大都抓不到。
+     * 它不是"最长多久",而是"至少跑这么久"—— 引擎会按提取上限自动放大:
+     * 每个收录约 20 秒(开过滤时要逐个开用户页,含被跳过的折算),
+     * 所以 limit=100 会自动给到约 38 分钟,limit=1000 给到 4 小时(封顶)。
      *
-     * 上限 240 分钟:再长还不如分几轮跑(每轮都刷盘,中断不丢)。
+     * 界面上写「至少运行 __ 分钟(按提取上限自动放宽,最多 4 小时)」。
      */
     { path: "tabs.id.features.maxRuntimeMin",  type: "int", min: 1, max: 240, def: 30 }
 );
@@ -6908,15 +6907,16 @@ function buildOutput() {
     stopRow.addView(U.text("个 ID 后停止(0 = 不限)", Theme.m.labelSize, Theme.hex.textMuted, false));
     blocks.push(stopRow);
 
-    /* 单次最长运行时间。它决定"一轮最多能抓多少个"——
-     * 开了过滤时约 8 秒/个,30 分钟只够 200 来个候选。
-     * 提取上限设得比这还大是没意义的,所以要让它可见可调。 */
+    /* 运行时长:它现在是**下限**而不是上限 —— 引擎会按提取上限自动放宽
+     * (每个收录约 20 秒,见 tiktok-auto/src/idflow.js 的 budgetFor)。
+     * 设 100 个却只给 30 分钟,会永远抓不满;所以文案必须说清"会自动放宽"。 */
     var runtimeRow = U.row();
-    runtimeRow.addView(U.text("单次最长运行", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    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));
+    runtimeRow.addView(U.text("分钟(按提取上限自动放宽,最多 4 小时)",
+        Theme.m.labelSize, Theme.hex.textMuted, false));
     blocks.push(runtimeRow);
 
     blocks.push(W.hint("结果文件自动命名 uid_日期_时分.txt,存放在 /sdcard/Download/tiktok_app", false));

+ 6 - 5
tiktok-app/src/page-id.js

@@ -426,15 +426,16 @@ function buildOutput() {
     stopRow.addView(U.text("个 ID 后停止(0 = 不限)", Theme.m.labelSize, Theme.hex.textMuted, false));
     blocks.push(stopRow);
 
-    /* 单次最长运行时间。它决定"一轮最多能抓多少个"——
-     * 开了过滤时约 8 秒/个,30 分钟只够 200 来个候选。
-     * 提取上限设得比这还大是没意义的,所以要让它可见可调。 */
+    /* 运行时长:它现在是**下限**而不是上限 —— 引擎会按提取上限自动放宽
+     * (每个收录约 20 秒,见 tiktok-auto/src/idflow.js 的 budgetFor)。
+     * 设 100 个却只给 30 分钟,会永远抓不满;所以文案必须说清"会自动放宽"。 */
     var runtimeRow = U.row();
-    runtimeRow.addView(U.text("单次最长运行", Theme.m.labelSize, Theme.hex.textSecondary, false));
+    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));
+    runtimeRow.addView(U.text("分钟(按提取上限自动放宽,最多 4 小时)",
+        Theme.m.labelSize, Theme.hex.textMuted, false));
     blocks.push(runtimeRow);
 
     blocks.push(W.hint("结果文件自动命名 uid_日期_时分.txt,存放在 /sdcard/Download/tiktok_app", false));

+ 5 - 6
tiktok-app/src/store.js

@@ -227,14 +227,13 @@ F.push(
     /* 0 = 不限制。用「0 表示不限」而不是留空,是因为数字输入框留空无法表达。 */
     { path: "tabs.id.features.extractLimit",   type: "int", min: 0, max: 100000, def: 0 },
     /**
-     * 单次运行最长多少分钟(抓取阶段)。
+     * 运行时长的**下限**(分钟)。
      *
-     * 为什么让用户可调:这个值实际决定了"一轮最多能抓多少个",而抓取速度完全取决于过滤设置
-     * —— 不开过滤约 1 秒/个;开了「排除私密 / 排除无头像 / 数量门槛」就要逐个开用户页,
-     * 约 8 秒/个。实测 225 个候选就吃满了 30 分钟。
-     * 所以"想抓 100 个"的人必须能把它调大,否则提取上限设多大都抓不到。
+     * 它不是"最长多久",而是"至少跑这么久"—— 引擎会按提取上限自动放大:
+     * 每个收录约 20 秒(开过滤时要逐个开用户页,含被跳过的折算),
+     * 所以 limit=100 会自动给到约 38 分钟,limit=1000 给到 4 小时(封顶)。
      *
-     * 上限 240 分钟:再长还不如分几轮跑(每轮都刷盘,中断不丢)。
+     * 界面上写「至少运行 __ 分钟(按提取上限自动放宽,最多 4 小时)」。
      */
     { path: "tabs.id.features.maxRuntimeMin",  type: "int", min: 1, max: 240, def: 30 }
 );

+ 131 - 13
tiktok-auto/dist/tiktok-auto.js

@@ -1,5 +1,5 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-21T14:19:52.518Z
+ *  built 2026-10-21T15:03:41.961Z
  *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, parse, uidset, filters, profile, idsource, resolve, idflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -4368,6 +4368,18 @@ TikTokAuto.profile = (function () {
             var current = tiktok.statNumbers();
             if (current.follow === previous.follow && current.fans === previous.fans &&
                 current.likes === previous.likes) {
+                /* 三组全是 0 是**可疑值**:既可能是真新号,也可能是占位还没换掉。
+                 * 多等 1.5 秒复核一次 —— 真新号复核后还是 0,不影响结果;
+                 * 占位的话这一刻通常已经填上真值了(实测把 2019 粉丝读成 0 过)。 */
+                if (current.follow === 0 && current.fans === 0 && current.likes === 0) {
+                    TikTokAuto.wait.tick(1500);
+                    var recheck = tiktok.statNumbers();
+                    if (recheck.follow !== 0 || recheck.fans !== 0 || recheck.likes !== 0) {
+                        logger().info('profile', '三组全 0 复核后拿到真值:' +
+                            recheck.follow + '/' + recheck.fans + '/' + recheck.likes);
+                        return recheck;
+                    }
+                }
                 return current;
             }
             previous = current;
@@ -4705,12 +4717,26 @@ TikTokAuto.profile = (function () {
     function openList(which, config) {
         var step = 'openList';
         var tiktok = TikTokAuto.tiktok;
-        var entry = tiktok.statEntry(which === 'following' ? 'following' : 'fans');
+        var wanted = which === 'following' ? 'following' : 'fans';
+
+        var entry = tiktok.statEntry(wanted);
         if (!entry) {
-            logger().warn(step, '用户主页上找不到 ' + which + ' 入口');
+            /* ★ 找不到入口时**再等一次**,不要立刻报失败。
+             * 实测(用户 2026-10-21 反馈):从搜索结果进主页后,标题已经出来了,
+             * 但数字区还在渲染,这时 `statEntry()` 返回 null,直接判"找不到 fans 入口",
+             * 用户看到的现象就是"还没加载出来就已经返回了"。重跑往往就正常 —— 典型的时序问题。
+             * 这里最多等 3 秒,够渲染完;真没有(比如私密号不显示列表)就如实失败。 */
+            logger().info(step, '暂时找不到 ' + wanted + ' 入口,等页面渲染完再试');
+            TikTokAuto.wait.waitFor(step + ':entry', function () {
+                entry = tiktok.statEntry(wanted);
+                return entry !== null ? true : null;
+            }, 3000, 300);
+        }
+        if (!entry) {
+            logger().warn(step, '用户主页上找不到 ' + wanted + ' 入口(等了 3 秒仍没有)');
             return false;
         }
-        logger().step(step, '打开列表:' + which);
+        logger().step(step, '打开列表:' + wanted);
         TikTokAuto.ui.clickNode(entry, step);
 
         var ready = TikTokAuto.wait.waitFor(step, function () {
@@ -4880,10 +4906,49 @@ TikTokAuto.profile = (function () {
      *   实测后果:一个粉丝数为 0 的账号被当成"读不到资料",或被误判过滤。
      *   判据必须是"@username 节点已经存在"。
      */
+    /**
+     * 等用户主页**真的渲染完**,而不是"标题出来了就算到"。
+     *
+     * ★ 两段等待,缺一不可(实测踩出来的):
+     *
+     *   ① 等 `@username` 出现 —— 这一步很快(约 585ms),因为标题先渲染;
+     *   ② 再等顶部三组数字(关注/粉丝/获赞)**至少读到两个**。
+     *
+     * 只做 ① 会出两类问题,而且都很难查:
+     *   · 数字区还是占位的 0 → 把"粉丝 78"读成"粉丝 0"→ 按数量门槛被错误跳过;
+     *   · 数字区根本没渲染 → `statEntry()` 找不到"粉丝/关注"那个可点入口
+     *     → 报"用户主页上找不到 fans 入口"(用户看到的就是"还没加载出来就返回了")。
+     *
+     * 第 ② 段是**尽力等**:有些账号(私密号、无数据的新号)可能只有一组数字,
+     * 所以超时也照常返回 true —— 不能因为等不到三组就把正常账号判成失败。
+     */
     function waitProfile(step) {
-        return TikTokAuto.wait.waitFor(step + ':profile', function () {
-            return TikTokAuto.tiktok.profileUsernameNode() !== null ? true : null;
-        }, 10000, 300) === true;
+        var tiktok = TikTokAuto.tiktok;
+        var arrived = TikTokAuto.wait.waitFor(step + ':profile', function () {
+            return tiktok.profileUsernameNode() !== null ? true : null;
+        }, 10000, 300);
+        if (arrived !== true) {
+            return false;
+        }
+        /* 第二段:数字区。**三组都要读到**,10 秒上限。
+         *
+         * 为什么要求三组而不是两组:TikTok 会先把数字渲染成占位的 0,
+         * 再逐组换成真值。要求"两组"时,`粉丝 0 / 关注 0 / 获赞 null` 这种
+         * 半渲染状态就算通过了 —— 实测把 @.1523615 的 2019 粉丝读成了 0,
+         * 而这类错误会直接影响数量门槛的判定(读成 0 就会被"粉丝≥1000"筛掉)。 */
+        var statsReady = TikTokAuto.wait.waitFor(step + ':stats', function () {
+            var stats = tiktok.statNumbers();
+            return (stats.follow !== null && stats.fans !== null && stats.likes !== null)
+                ? true : null;
+        }, 10000, 300);
+        if (statsReady !== true) {
+            /* 不是错误:确实有账号不显示某一组(新号可能没有获赞)。
+             * 但要在日志里留痕,因为后面的"读不到数量"多半是这里没等到。 */
+            var last = tiktok.statNumbers();
+            logger().info('profile', '三组数字 10 秒内没读全(关注 ' + last.follow +
+                ' / 粉丝 ' + last.fans + ' / 获赞 ' + last.likes + '),继续');
+        }
+        return true;
     }
 
     /**
@@ -6012,6 +6077,46 @@ TikTokAuto.idflow = (function () {
         });
     }
 
+    /**
+     * 估算"够用的抓取预算"。
+     *
+     * ## 为什么要自动算
+     * 预算是**按时间**给的,而用户的目标是**按个数**("提取满 100 个")。
+     * 两者之间要经过"每个的平均耗时",而这个耗时取决于过滤设置(差 6 倍):
+     *   · 不过滤 → 直接从列表抄 uid,约 1.5 秒/个;
+     *   · 开了私密/无头像/数量门槛 → 每个都要打开用户页读资料,约 8 秒/个;
+     *   · 再加上被过滤跳过的(实测收录率约 37%)→ 折算到"每个**收录**"约 20 秒。
+     *
+     * 所以固定给 30 分钟是错的:设 100 个要 38 分钟、设 1000 个要 5 个多小时。
+     * 用户设的数只当**下限**("至少跑这么久"),实际按提取上限放宽。
+     *
+     * ## 为什么还要封顶
+     * 4 小时是设备安全的边界(长时间亮屏运行会发热)。真要更多,分几轮跑更好 ——
+     * 每轮都刷盘,中断不丢,还能跨轮去重。
+     */
+    var PER_RESULT_MS = 20000;                 /* 折算到每个"收录"的耗时 */
+    var FIXED_OVERHEAD_MS = 5 * 60 * 1000;     /* 启动 app / 搜索 / 开列表的固定开销 */
+    var HARD_CAP_MS = 4 * 60 * 60 * 1000;      /* 4 小时硬顶 */
+
+    /** 解析 uid 的预算:按实际待解析个数算(每个约 3 秒 + 1 分钟缓冲),1~60 分钟 */
+    var PER_RESOLVE_MS = 3000;
+    var RESOLVE_CAP_MS = 60 * 60 * 1000;
+
+    function budgetFor(limit, userMinMs) {
+        var userMs = Math.max(1, userMinMs);
+        if (limit <= 0) {
+            /* 不限个数 → 没有依据可算,按用户设的走 */
+            return Math.min(userMs, HARD_CAP_MS);
+        }
+        var autoMs = limit * PER_RESULT_MS + FIXED_OVERHEAD_MS;
+        return Math.min(Math.max(userMs, autoMs), HARD_CAP_MS);
+    }
+
+    function resolveBudgetFor(count) {
+        var need = count * PER_RESOLVE_MS + 60000;
+        return Math.min(Math.max(10 * 60 * 1000, need), RESOLVE_CAP_MS);
+    }
+
     /**
      * 把界面配置里的 `id` 段收拢成引擎侧要用的形状。
      *
@@ -6024,6 +6129,8 @@ TikTokAuto.idflow = (function () {
         var filter = id.filter || {};
         var output = id.output || {};
         var guard = id.guard || {};
+        var limit = Number(output.limit) || 0;
+        var userMaxMs = Number(guard.maxRuntimeMs) || 30 * 60 * 1000;
 
         return {
             sources: (id.sources instanceof Array ? id.sources : []),
@@ -6045,10 +6152,11 @@ TikTokAuto.idflow = (function () {
                 /* 缺省开:数字 uid 在界面上读不到,不在这里换就得事后另跑一次 */
                 resolveUid: output.resolveUid !== false
             },
-            limit: Number(output.limit) || 0,
-            maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000,
-            /* 解析 uid 的独立预算:默认 10 分钟 ≈ 240 个(每个约 2.5 秒)。
-             * 与抓取预算分开,因为它们是先后发生的两件事(见下)。 */
+            limit: limit,
+            /** 用户设的(下限,界面「单次最长运行」) */
+            userMaxRuntimeMs: userMaxMs,
+            /** 实际生效的抓取预算(按 limit 自动放宽) */
+            maxRuntimeMs: budgetFor(limit, userMaxMs),
             resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000
         };
     }
@@ -6174,6 +6282,9 @@ TikTokAuto.idflow = (function () {
 
             log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
             var resolveStarted = Date.now();
+            /* 解析预算也按实际个数算:10 分钟只够 240 个,抓了 1000 个就不够用了 */
+            var resolveBudgetMs = resolveBudgetFor(names.length);
+            log.info('idflow', '解析预算 ' + Math.round(resolveBudgetMs / 60000) + ' 分钟');
             var result = TikTokAuto.resolve.resolveAll(names, {
                 /**
                  * ★ 这里**只用自己的预算**,不继承抓取阶段的任何停止条件。
@@ -6188,7 +6299,7 @@ TikTokAuto.idflow = (function () {
                  * 用户主动停止仍然有效 —— 那是 StopSignal 从 `wait.tick` 抛出来的。
                  */
                 shouldStop: function () {
-                    return Date.now() - resolveStarted >= settings.resolveMaxMs;
+                    return Date.now() - resolveStarted >= resolveBudgetMs;
                 },
                 onProgress: function (done, total) {
                     progress('解析 uid ' + done + '/' + total, target.extracted);
@@ -6197,7 +6308,7 @@ TikTokAuto.idflow = (function () {
 
             if (result.stopped) {
                 target.warnings.push('解析 uid 到达自身时长上限(' +
-                    Math.round(settings.resolveMaxMs / 60000) + ' 分钟),只换到 ' +
+                    Math.round(resolveBudgetMs / 60000) + ' 分钟),只换到 ' +
                     result.map.length + '/' + names.length + ' 个');
             }
 
@@ -6265,6 +6376,13 @@ TikTokAuto.idflow = (function () {
             log.step('idflow', '=== TikTok ID 提取开始 ===');
             log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
                 TikTokAuto.filters.describe(settings.filter));
+            /* 把预算说清楚:用户设的是下限,实际按提取上限放宽过。
+             * 不说的话,"怎么跑了 38 分钟"会变成第二个疑问。 */
+            log.info('idflow', '抓取预算 ' + Math.round(settings.maxRuntimeMs / 60000) + ' 分钟' +
+                (settings.limit > 0
+                    ? '(按提取上限 ' + settings.limit + ' 个估算;你设的下限是 ' +
+                      Math.round(settings.userMaxRuntimeMs / 60000) + ' 分钟)'
+                    : '(未设提取上限,按你设的时长)'));
 
             TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
 

+ 59 - 6
tiktok-auto/src/idflow.js

@@ -52,6 +52,46 @@ TikTokAuto.idflow = (function () {
         });
     }
 
+    /**
+     * 估算"够用的抓取预算"。
+     *
+     * ## 为什么要自动算
+     * 预算是**按时间**给的,而用户的目标是**按个数**("提取满 100 个")。
+     * 两者之间要经过"每个的平均耗时",而这个耗时取决于过滤设置(差 6 倍):
+     *   · 不过滤 → 直接从列表抄 uid,约 1.5 秒/个;
+     *   · 开了私密/无头像/数量门槛 → 每个都要打开用户页读资料,约 8 秒/个;
+     *   · 再加上被过滤跳过的(实测收录率约 37%)→ 折算到"每个**收录**"约 20 秒。
+     *
+     * 所以固定给 30 分钟是错的:设 100 个要 38 分钟、设 1000 个要 5 个多小时。
+     * 用户设的数只当**下限**("至少跑这么久"),实际按提取上限放宽。
+     *
+     * ## 为什么还要封顶
+     * 4 小时是设备安全的边界(长时间亮屏运行会发热)。真要更多,分几轮跑更好 ——
+     * 每轮都刷盘,中断不丢,还能跨轮去重。
+     */
+    var PER_RESULT_MS = 20000;                 /* 折算到每个"收录"的耗时 */
+    var FIXED_OVERHEAD_MS = 5 * 60 * 1000;     /* 启动 app / 搜索 / 开列表的固定开销 */
+    var HARD_CAP_MS = 4 * 60 * 60 * 1000;      /* 4 小时硬顶 */
+
+    /** 解析 uid 的预算:按实际待解析个数算(每个约 3 秒 + 1 分钟缓冲),1~60 分钟 */
+    var PER_RESOLVE_MS = 3000;
+    var RESOLVE_CAP_MS = 60 * 60 * 1000;
+
+    function budgetFor(limit, userMinMs) {
+        var userMs = Math.max(1, userMinMs);
+        if (limit <= 0) {
+            /* 不限个数 → 没有依据可算,按用户设的走 */
+            return Math.min(userMs, HARD_CAP_MS);
+        }
+        var autoMs = limit * PER_RESULT_MS + FIXED_OVERHEAD_MS;
+        return Math.min(Math.max(userMs, autoMs), HARD_CAP_MS);
+    }
+
+    function resolveBudgetFor(count) {
+        var need = count * PER_RESOLVE_MS + 60000;
+        return Math.min(Math.max(10 * 60 * 1000, need), RESOLVE_CAP_MS);
+    }
+
     /**
      * 把界面配置里的 `id` 段收拢成引擎侧要用的形状。
      *
@@ -64,6 +104,8 @@ TikTokAuto.idflow = (function () {
         var filter = id.filter || {};
         var output = id.output || {};
         var guard = id.guard || {};
+        var limit = Number(output.limit) || 0;
+        var userMaxMs = Number(guard.maxRuntimeMs) || 30 * 60 * 1000;
 
         return {
             sources: (id.sources instanceof Array ? id.sources : []),
@@ -85,10 +127,11 @@ TikTokAuto.idflow = (function () {
                 /* 缺省开:数字 uid 在界面上读不到,不在这里换就得事后另跑一次 */
                 resolveUid: output.resolveUid !== false
             },
-            limit: Number(output.limit) || 0,
-            maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000,
-            /* 解析 uid 的独立预算:默认 10 分钟 ≈ 240 个(每个约 2.5 秒)。
-             * 与抓取预算分开,因为它们是先后发生的两件事(见下)。 */
+            limit: limit,
+            /** 用户设的(下限,界面「单次最长运行」) */
+            userMaxRuntimeMs: userMaxMs,
+            /** 实际生效的抓取预算(按 limit 自动放宽) */
+            maxRuntimeMs: budgetFor(limit, userMaxMs),
             resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000
         };
     }
@@ -214,6 +257,9 @@ TikTokAuto.idflow = (function () {
 
             log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
             var resolveStarted = Date.now();
+            /* 解析预算也按实际个数算:10 分钟只够 240 个,抓了 1000 个就不够用了 */
+            var resolveBudgetMs = resolveBudgetFor(names.length);
+            log.info('idflow', '解析预算 ' + Math.round(resolveBudgetMs / 60000) + ' 分钟');
             var result = TikTokAuto.resolve.resolveAll(names, {
                 /**
                  * ★ 这里**只用自己的预算**,不继承抓取阶段的任何停止条件。
@@ -228,7 +274,7 @@ TikTokAuto.idflow = (function () {
                  * 用户主动停止仍然有效 —— 那是 StopSignal 从 `wait.tick` 抛出来的。
                  */
                 shouldStop: function () {
-                    return Date.now() - resolveStarted >= settings.resolveMaxMs;
+                    return Date.now() - resolveStarted >= resolveBudgetMs;
                 },
                 onProgress: function (done, total) {
                     progress('解析 uid ' + done + '/' + total, target.extracted);
@@ -237,7 +283,7 @@ TikTokAuto.idflow = (function () {
 
             if (result.stopped) {
                 target.warnings.push('解析 uid 到达自身时长上限(' +
-                    Math.round(settings.resolveMaxMs / 60000) + ' 分钟),只换到 ' +
+                    Math.round(resolveBudgetMs / 60000) + ' 分钟),只换到 ' +
                     result.map.length + '/' + names.length + ' 个');
             }
 
@@ -305,6 +351,13 @@ TikTokAuto.idflow = (function () {
             log.step('idflow', '=== TikTok ID 提取开始 ===');
             log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
                 TikTokAuto.filters.describe(settings.filter));
+            /* 把预算说清楚:用户设的是下限,实际按提取上限放宽过。
+             * 不说的话,"怎么跑了 38 分钟"会变成第二个疑问。 */
+            log.info('idflow', '抓取预算 ' + Math.round(settings.maxRuntimeMs / 60000) + ' 分钟' +
+                (settings.limit > 0
+                    ? '(按提取上限 ' + settings.limit + ' 个估算;你设的下限是 ' +
+                      Math.round(settings.userMaxRuntimeMs / 60000) + ' 分钟)'
+                    : '(未设提取上限,按你设的时长)'));
 
             TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
 

+ 71 - 6
tiktok-auto/src/profile.js

@@ -99,6 +99,18 @@ TikTokAuto.profile = (function () {
             var current = tiktok.statNumbers();
             if (current.follow === previous.follow && current.fans === previous.fans &&
                 current.likes === previous.likes) {
+                /* 三组全是 0 是**可疑值**:既可能是真新号,也可能是占位还没换掉。
+                 * 多等 1.5 秒复核一次 —— 真新号复核后还是 0,不影响结果;
+                 * 占位的话这一刻通常已经填上真值了(实测把 2019 粉丝读成 0 过)。 */
+                if (current.follow === 0 && current.fans === 0 && current.likes === 0) {
+                    TikTokAuto.wait.tick(1500);
+                    var recheck = tiktok.statNumbers();
+                    if (recheck.follow !== 0 || recheck.fans !== 0 || recheck.likes !== 0) {
+                        logger().info('profile', '三组全 0 复核后拿到真值:' +
+                            recheck.follow + '/' + recheck.fans + '/' + recheck.likes);
+                        return recheck;
+                    }
+                }
                 return current;
             }
             previous = current;
@@ -436,12 +448,26 @@ TikTokAuto.profile = (function () {
     function openList(which, config) {
         var step = 'openList';
         var tiktok = TikTokAuto.tiktok;
-        var entry = tiktok.statEntry(which === 'following' ? 'following' : 'fans');
+        var wanted = which === 'following' ? 'following' : 'fans';
+
+        var entry = tiktok.statEntry(wanted);
         if (!entry) {
-            logger().warn(step, '用户主页上找不到 ' + which + ' 入口');
+            /* ★ 找不到入口时**再等一次**,不要立刻报失败。
+             * 实测(用户 2026-10-21 反馈):从搜索结果进主页后,标题已经出来了,
+             * 但数字区还在渲染,这时 `statEntry()` 返回 null,直接判"找不到 fans 入口",
+             * 用户看到的现象就是"还没加载出来就已经返回了"。重跑往往就正常 —— 典型的时序问题。
+             * 这里最多等 3 秒,够渲染完;真没有(比如私密号不显示列表)就如实失败。 */
+            logger().info(step, '暂时找不到 ' + wanted + ' 入口,等页面渲染完再试');
+            TikTokAuto.wait.waitFor(step + ':entry', function () {
+                entry = tiktok.statEntry(wanted);
+                return entry !== null ? true : null;
+            }, 3000, 300);
+        }
+        if (!entry) {
+            logger().warn(step, '用户主页上找不到 ' + wanted + ' 入口(等了 3 秒仍没有)');
             return false;
         }
-        logger().step(step, '打开列表:' + which);
+        logger().step(step, '打开列表:' + wanted);
         TikTokAuto.ui.clickNode(entry, step);
 
         var ready = TikTokAuto.wait.waitFor(step, function () {
@@ -611,10 +637,49 @@ TikTokAuto.profile = (function () {
      *   实测后果:一个粉丝数为 0 的账号被当成"读不到资料",或被误判过滤。
      *   判据必须是"@username 节点已经存在"。
      */
+    /**
+     * 等用户主页**真的渲染完**,而不是"标题出来了就算到"。
+     *
+     * ★ 两段等待,缺一不可(实测踩出来的):
+     *
+     *   ① 等 `@username` 出现 —— 这一步很快(约 585ms),因为标题先渲染;
+     *   ② 再等顶部三组数字(关注/粉丝/获赞)**至少读到两个**。
+     *
+     * 只做 ① 会出两类问题,而且都很难查:
+     *   · 数字区还是占位的 0 → 把"粉丝 78"读成"粉丝 0"→ 按数量门槛被错误跳过;
+     *   · 数字区根本没渲染 → `statEntry()` 找不到"粉丝/关注"那个可点入口
+     *     → 报"用户主页上找不到 fans 入口"(用户看到的就是"还没加载出来就返回了")。
+     *
+     * 第 ② 段是**尽力等**:有些账号(私密号、无数据的新号)可能只有一组数字,
+     * 所以超时也照常返回 true —— 不能因为等不到三组就把正常账号判成失败。
+     */
     function waitProfile(step) {
-        return TikTokAuto.wait.waitFor(step + ':profile', function () {
-            return TikTokAuto.tiktok.profileUsernameNode() !== null ? true : null;
-        }, 10000, 300) === true;
+        var tiktok = TikTokAuto.tiktok;
+        var arrived = TikTokAuto.wait.waitFor(step + ':profile', function () {
+            return tiktok.profileUsernameNode() !== null ? true : null;
+        }, 10000, 300);
+        if (arrived !== true) {
+            return false;
+        }
+        /* 第二段:数字区。**三组都要读到**,10 秒上限。
+         *
+         * 为什么要求三组而不是两组:TikTok 会先把数字渲染成占位的 0,
+         * 再逐组换成真值。要求"两组"时,`粉丝 0 / 关注 0 / 获赞 null` 这种
+         * 半渲染状态就算通过了 —— 实测把 @.1523615 的 2019 粉丝读成了 0,
+         * 而这类错误会直接影响数量门槛的判定(读成 0 就会被"粉丝≥1000"筛掉)。 */
+        var statsReady = TikTokAuto.wait.waitFor(step + ':stats', function () {
+            var stats = tiktok.statNumbers();
+            return (stats.follow !== null && stats.fans !== null && stats.likes !== null)
+                ? true : null;
+        }, 10000, 300);
+        if (statsReady !== true) {
+            /* 不是错误:确实有账号不显示某一组(新号可能没有获赞)。
+             * 但要在日志里留痕,因为后面的"读不到数量"多半是这里没等到。 */
+            var last = tiktok.statNumbers();
+            logger().info('profile', '三组数字 10 秒内没读全(关注 ' + last.follow +
+                ' / 粉丝 ' + last.fans + ' / 获赞 ' + last.likes + '),继续');
+        }
+        return true;
     }
 
     /**