Explorar el Código

修复搜索结果取材抓 0 个:两种用户列表页的行解析器没分派

现象(用户从界面跑「搜索结果 / 用户 / beautiful」,35 秒后 0 个):
日志里一对自相矛盾的行 —— userRows 就绪(6877ms)说读到行了,
紧接着 listRows 超时(8388ms)说没有行,最后"处理 0 行"。

根因
两种"用户列表页"的行结构不同,uid 的位置也不同:
- userListRows()  找 txt_desc   → 粉丝/关注列表
- usersResultRows() 找 tv_username → 搜索结果页
而 profile.listRows() 当初写死成前者,所以"取材=搜索结果"这条路径**必然**读不到行。
不是页面没加载(userRows 就绪已经证明行在),是用错了页面类型的解析器。

修
- profile.listRows() 按当前页面分派
- 新增 profile.isListPage(),同时认 user-list 与 users-results
- backToList() 的目标页改成 isListPage()
- idflow.obtainProfile() 里"从列表行进主页"的判据改成 isListPage()
  (否则搜索取材时每个候选都要重新搜一次,约 15 秒/个,慢十倍)
- idsource.walkList 首屏等待从硬编码 8000ms 改成 config.timeouts.pageReadyMs

顺带修一个"沉默的不生效"
取材设了「再取粉丝列表」但 limit 会在主层就抓满 → 展开永远不执行,
而用户看到"提取满 N 个"会以为展开做了。现在加一条 warning 明确告知。

真机验证(用用户那份 filter + 同样的取材)
收录 3 个,都有依据:
  @beautifulsoul78869 粉丝 7760 / 关注 5885 / 获赞 33800
  @ttbeauties6        粉丝 333 / 关注 42 / 获赞 2046
  @beautifulgirl12e   粉丝 155600 / 关注 0 / 获赞 967900
64 秒完成,产物 3 行 3 唯一。

顺带查清一个疑点(不是 bug)
用户截图里界面有 2 条取材但引擎日志说 1 条 —— 读设备存储确认第 2 条的 id 时间戳
与那次运行同一秒,是运行之后才加的。配置下发没有丢。

★ 通则(已写进 tiktok-auto/README.md 3.5.9 第 4 条)
同一个业务概念在不同页面上可能有两套结构。像 listRows() 这种"取一屏用户行"的抽象,
内部绑死某一种页面的选择器时,换个来源就会静默返回空 —— 上层看起来完全正常
(没有报错,只是"处理 0 行")。判据要按当前页面分派。
梦境 hace 1 semana
padre
commit
8b8b0030dc

+ 14 - 0
tiktok-auto/README.md

@@ -289,6 +289,20 @@ tiktok-auto/
    有时还是右侧 Follow 按钮的邻域(误触会把用户关注掉)。
    改成按坐标点行左侧 30%(用户名文字上),实测既进得去也不会误关注。
 
+4. **两种"用户列表页"的行结构不同,解析器必须分派**。
+   `userListRows()` 找 `txt_desc`(粉丝/关注列表),`usersResultRows()` 找 `tv_username`
+   (搜索结果页)。当初 `profile.listRows()` 写死成前者,于是"取材=搜索结果"时
+   必然出现这种日志组合:
+
+   ```
+   [waitFor] idsource:userRows 就绪(6877ms)   ← 搜索结果确实读到了行
+   [waitFor] listRows 超时                       ← 同一个列表却说没有行
+   [idsource] 当前屏没有读到用户行,结束列表      ← 结果"处理 0 行"
+   ```
+
+   看起来像"页面没加载",实际是**用错了页面类型的解析器**。
+   `listRows()` 现在按当前页面分派,`isListPage()` 同时认这两种页。
+
 ---
 
 ## 4. 需求 → 设计对照

+ 52 - 15
tiktok-auto/dist/tiktok-auto.js

@@ -1,5 +1,5 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-20T03:13:58.647Z
+ *  built 2026-10-20T08:08:36.158Z
  *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, parse, uidset, filters, profile, idsource, idflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -4709,8 +4709,33 @@ TikTokAuto.profile = (function () {
         return false;
     }
 
-    /** 列表当前可见的每一行(已按屏幕从上到下排序) */
+    /**
+     * 当前在不在"一个可以逐行取 uid 的列表页"上。
+     *
+     * 有两种,长得不一样、资源 id 也不同,但对上层来说都是"一屏一屏的用户行":
+     *   · `user-list`    粉丝/关注列表(`txt_desc` 装 uid)
+     *   · `users-results` 搜索结果的用户页签(`tv_username` 装 uid)
+     * 上层不该关心是哪种 —— 这正是这个判据存在的意义。
+     */
+    function isListPage(state) {
+        var now = state || where();
+        return now === 'user-list' || now === 'users-results';
+    }
+
+    /**
+     * 列表当前可见的每一行(已按屏幕从上到下排序)。
+     *
+     * ★ **按当前页面选解析器**,这是实测踩出来的一个"看起来能跑但永远 0 行"的 bug:
+     *   当初这里写死成 `userListRows()`(粉丝列表的解析器,找 `txt_desc`),
+     *   而搜索结果页的用户行是 `tv_username` —— 于是"取材=搜索结果"时,
+     *   上一步 `usersResultRows()` 明明读到了行(日志里 `userRows` 就绪),
+     *   下一步 `listRows()` 却返回空,最后表现为"处理 0 行、一个都没抓到"。
+     *   两种页面的行结构不同,但**对调用方是同一件事**,所以分派必须留在这里。
+     */
     function listRows() {
+        if (where() === 'users-results') {
+            return TikTokAuto.tiktok.usersResultRows();
+        }
         return TikTokAuto.tiktok.userListRows();
     }
 
@@ -4775,24 +4800,25 @@ TikTokAuto.profile = (function () {
         TikTokAuto.ui.tapPoint(row.x, row.y);
     }
 
-    /** 从用户主页退回列表(列表位置会保持,实测) */
+    /** 从用户主页退回列表(列表位置会保持,实测);两种列表页都认 */
     function backToList() {
         var step = 'backToList';
-        var deadline = Date.now() + 8000;
+        var deadline = Date.now() + 10000;
         while (Date.now() < deadline) {
             var state = where();
-            if (state === 'user-list') {
+            if (isListPage(state)) {
                 return true;
             }
-            if (state === 'profile' || state === 'comment-panel' || state === 'users-results') {
+            if (state === 'profile' || state === 'comment-panel') {
                 TikTokAuto.ui.goBack(step);
                 settle(2000);
                 continue;
             }
-            /* 其它页面(搜索/结果/信息流):已经退过头了 */
+            /* 已经退到搜索页/结果页/信息流:说明退过头了(列表已经不在栈上)。
+             * 这种情况不该再往回退 —— 再退就退到首页去了。 */
             return false;
         }
-        return where() === 'user-list';
+        return isListPage();
     }
 
     /** 从列表退回用户主页 */
@@ -4854,6 +4880,7 @@ TikTokAuto.profile = (function () {
         openList: openList,
         switchListTab: switchListTab,
         listRows: listRows,
+        isListPage: isListPage,
         scrollList: scrollList,
         openRow: openRow,
         backToList: backToList,
@@ -4945,11 +4972,12 @@ TikTokAuto.idsource = (function () {
             var rows = TikTokAuto.profile.listRows();
             if (rows.length === 0 && screens === 1) {
                 /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
-                 * 从用户页点开粉丝列表后,页签先出来、行后出来(实测差 1~2 秒),
-                 * 这时 listRows() 返回空数组 —— 直接 break 就等于"一条都没抓到"。 */
+                 * 从用户页点开粉丝列表、或从结果页切到用户页签后,
+                 * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 ——
+                 * 直接 break 就等于"一条都没抓到"。 */
                 TikTokAuto.wait.waitFor('listRows', function () {
                     return TikTokAuto.profile.listRows().length > 0 ? true : null;
-                }, 8000, 300);
+                }, ctx.config ? ctx.config.timeouts.pageReadyMs : 8000, 300);
                 rows = TikTokAuto.profile.listRows();
             }
             if (rows.length === 0) {
@@ -5592,10 +5620,13 @@ TikTokAuto.idflow = (function () {
             }
 
             /* 从列表行点进去最快(且实测返回后列表位置保持)。
-             * ★ 点开失败时**不再退回搜索**:在列表遍历中途跳去搜索,
-             *   会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。
-             *   这里直接返回 null,由 judge 按"读不到资料"拦下并记日志。 */
-            if (row.node && row.x !== undefined && profileApi.where() === 'user-list') {
+             * ★ 判据是"当前在**任一**种列表页上",不是只有粉丝列表:
+             *   搜索结果的用户列表也一样能直接点行进去。
+             *   早先写死成 `where() === 'user-list'`,于是搜索取材时
+             *   每个候选都要**重新搜一次**(约 15 秒/个)—— 慢十倍且没有必要。
+             * ★ 点开失败时不再退回搜索:在列表遍历中途跳去搜索,
+             *   会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。 */
+            if (row.node && row.x !== undefined && profileApi.isListPage()) {
                 if (profileApi.openRow(row)) {
                     summary.openedProfiles += 1;
                     var data = profileApi.readProfile();
@@ -5751,6 +5782,12 @@ TikTokAuto.idflow = (function () {
             /* —— 第二层:深度展开 ——
              * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
              * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。 */
+            if (expandQueue.length > 0 && shouldStop() && limitReached()) {
+                /* 上限在主层就满了 → 展开根本没跑。这必须说出来:
+                 * 用户明明勾了「再取粉丝列表」,看到"提取满 3 个"很容易以为展开做了。 */
+                summary.warnings.push('提取上限已到,深度展开(再取粉丝列表)本次没有执行');
+                log.warn('idflow', '提取上限已到,' + expandQueue.length + ' 个待展开的账号本次没有展开');
+            }
             if (expandQueue.length > 0 && !shouldStop()) {
                 log.step('idflow', '开始深度展开:' + expandQueue.length + ' 个账号');
                 for (var e = 0; e < expandQueue.length; e += 1) {

+ 13 - 4
tiktok-auto/src/idflow.js

@@ -168,10 +168,13 @@ TikTokAuto.idflow = (function () {
             }
 
             /* 从列表行点进去最快(且实测返回后列表位置保持)。
-             * ★ 点开失败时**不再退回搜索**:在列表遍历中途跳去搜索,
-             *   会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。
-             *   这里直接返回 null,由 judge 按"读不到资料"拦下并记日志。 */
-            if (row.node && row.x !== undefined && profileApi.where() === 'user-list') {
+             * ★ 判据是"当前在**任一**种列表页上",不是只有粉丝列表:
+             *   搜索结果的用户列表也一样能直接点行进去。
+             *   早先写死成 `where() === 'user-list'`,于是搜索取材时
+             *   每个候选都要**重新搜一次**(约 15 秒/个)—— 慢十倍且没有必要。
+             * ★ 点开失败时不再退回搜索:在列表遍历中途跳去搜索,
+             *   会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。 */
+            if (row.node && row.x !== undefined && profileApi.isListPage()) {
                 if (profileApi.openRow(row)) {
                     summary.openedProfiles += 1;
                     var data = profileApi.readProfile();
@@ -327,6 +330,12 @@ TikTokAuto.idflow = (function () {
             /* —— 第二层:深度展开 ——
              * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
              * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。 */
+            if (expandQueue.length > 0 && shouldStop() && limitReached()) {
+                /* 上限在主层就满了 → 展开根本没跑。这必须说出来:
+                 * 用户明明勾了「再取粉丝列表」,看到"提取满 3 个"很容易以为展开做了。 */
+                summary.warnings.push('提取上限已到,深度展开(再取粉丝列表)本次没有执行');
+                log.warn('idflow', '提取上限已到,' + expandQueue.length + ' 个待展开的账号本次没有展开');
+            }
             if (expandQueue.length > 0 && !shouldStop()) {
                 log.step('idflow', '开始深度展开:' + expandQueue.length + ' 个账号');
                 for (var e = 0; e < expandQueue.length; e += 1) {

+ 4 - 3
tiktok-auto/src/idsource.js

@@ -76,11 +76,12 @@ TikTokAuto.idsource = (function () {
             var rows = TikTokAuto.profile.listRows();
             if (rows.length === 0 && screens === 1) {
                 /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
-                 * 从用户页点开粉丝列表后,页签先出来、行后出来(实测差 1~2 秒),
-                 * 这时 listRows() 返回空数组 —— 直接 break 就等于"一条都没抓到"。 */
+                 * 从用户页点开粉丝列表、或从结果页切到用户页签后,
+                 * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 ——
+                 * 直接 break 就等于"一条都没抓到"。 */
                 TikTokAuto.wait.waitFor('listRows', function () {
                     return TikTokAuto.profile.listRows().length > 0 ? true : null;
-                }, 8000, 300);
+                }, ctx.config ? ctx.config.timeouts.pageReadyMs : 8000, 300);
                 rows = TikTokAuto.profile.listRows();
             }
             if (rows.length === 0) {

+ 34 - 7
tiktok-auto/src/profile.js

@@ -468,8 +468,33 @@ TikTokAuto.profile = (function () {
         return false;
     }
 
-    /** 列表当前可见的每一行(已按屏幕从上到下排序) */
+    /**
+     * 当前在不在"一个可以逐行取 uid 的列表页"上。
+     *
+     * 有两种,长得不一样、资源 id 也不同,但对上层来说都是"一屏一屏的用户行":
+     *   · `user-list`    粉丝/关注列表(`txt_desc` 装 uid)
+     *   · `users-results` 搜索结果的用户页签(`tv_username` 装 uid)
+     * 上层不该关心是哪种 —— 这正是这个判据存在的意义。
+     */
+    function isListPage(state) {
+        var now = state || where();
+        return now === 'user-list' || now === 'users-results';
+    }
+
+    /**
+     * 列表当前可见的每一行(已按屏幕从上到下排序)。
+     *
+     * ★ **按当前页面选解析器**,这是实测踩出来的一个"看起来能跑但永远 0 行"的 bug:
+     *   当初这里写死成 `userListRows()`(粉丝列表的解析器,找 `txt_desc`),
+     *   而搜索结果页的用户行是 `tv_username` —— 于是"取材=搜索结果"时,
+     *   上一步 `usersResultRows()` 明明读到了行(日志里 `userRows` 就绪),
+     *   下一步 `listRows()` 却返回空,最后表现为"处理 0 行、一个都没抓到"。
+     *   两种页面的行结构不同,但**对调用方是同一件事**,所以分派必须留在这里。
+     */
     function listRows() {
+        if (where() === 'users-results') {
+            return TikTokAuto.tiktok.usersResultRows();
+        }
         return TikTokAuto.tiktok.userListRows();
     }
 
@@ -534,24 +559,25 @@ TikTokAuto.profile = (function () {
         TikTokAuto.ui.tapPoint(row.x, row.y);
     }
 
-    /** 从用户主页退回列表(列表位置会保持,实测) */
+    /** 从用户主页退回列表(列表位置会保持,实测);两种列表页都认 */
     function backToList() {
         var step = 'backToList';
-        var deadline = Date.now() + 8000;
+        var deadline = Date.now() + 10000;
         while (Date.now() < deadline) {
             var state = where();
-            if (state === 'user-list') {
+            if (isListPage(state)) {
                 return true;
             }
-            if (state === 'profile' || state === 'comment-panel' || state === 'users-results') {
+            if (state === 'profile' || state === 'comment-panel') {
                 TikTokAuto.ui.goBack(step);
                 settle(2000);
                 continue;
             }
-            /* 其它页面(搜索/结果/信息流):已经退过头了 */
+            /* 已经退到搜索页/结果页/信息流:说明退过头了(列表已经不在栈上)。
+             * 这种情况不该再往回退 —— 再退就退到首页去了。 */
             return false;
         }
-        return where() === 'user-list';
+        return isListPage();
     }
 
     /** 从列表退回用户主页 */
@@ -613,6 +639,7 @@ TikTokAuto.profile = (function () {
         openList: openList,
         switchListTab: switchListTab,
         listRows: listRows,
+        isListPage: isListPage,
         scrollList: scrollList,
         openRow: openRow,
         backToList: backToList,