Explorar o código

工程债四项:搜索结果取片修复 / uid 映射可取 / resolveUid 开关 / 文档与清理

## ① 搜索结果取片(真 bug:cat 关键词一个视频都拿不到)

现象:关键词 cat 报「结果页已无更多卡片」,而页面上明明有卡片;fashion 正常。

真机排查后发现是**四个问题叠在一起**,其中第一个是主因:

1. **卡片"看得见"判错了对象**。结果卡片的文案 Button 在**卡片底部**,卡片本身很高
   (实测一张 [8,1242]→[536,1920+])。结果页上多一条通栏广告或一行筛选 chips,网格被推下去,
   文案就落到屏幕外(实测 y=2099 > 1920)。旧判据 `bottomOf(node) <= H-12` 于是把
   **看得见、点得着**的卡片整排丢掉。
   → 改成只看**容器**:与屏幕有实质交集(≥8% 屏高)即可;点击点由 layout.center 用
   visibleRect 夹取,永远在屏内。
2. **cardContainer 的高度上限 80% 太紧**。卡片贴边时 bounds() 被裁切得又大又怪,卡在 80%
   会把整排卡片判成"找不到容器" → 上层误判"卡片用尽"。放宽到 105%。
3. **点"已选中"的页签会重刷列表**。默认页签是 Top,配置的 resultTab 也是 Top,
   等于每次运行都点一次当前页签 → TikTok 重刷网格 → 卡片先消失;而 waitQuiet 判不出
   "正在重刷"(签名和点之前一样)。→ resultTabSelected() 为真就跳过点击;
   真要切页签时判据改成"卡片重新出现了"。
4. **浮层按钮冒充卡片**。授权弹窗的 Customize Settings / Confirm My Choices 又宽又有文案,
   被当成卡片点开白等 20 秒。→ 加两条硬判据:在 GridView/RecyclerView 里;能找到卡片尺寸的
   可点击容器。

顺带把三类"长得像视频卡"的东西分清:
  · 广告视频   → 正常播放页,正常看
  · 照片轮播帖 → 照片查看器,没有播放页。信息行是 `Photo`(视频帖是 `Video`)
                 → waitPlayerReady 先用 4 秒判类型,认出是照片帖立刻返回(2 秒 vs 白等 20 秒)
  · 商品/带货卡 → 商品详情页(Add to cart / Buy Now)→ 打不开就当非视频跳过

**打不开 ≠ 出错**:跳过只记 warning,不进 errors。卡片用尽同理 —— 一个都没收到才报错,
收到过就记 warning 并建议"换关键词或调小目标"(以前"3 个里有 1 张照片帖"看起来像故障)。

还修了一个循环级的坑:**去重只按文案会死循环**。同一张卡的文案会读到不同截断
(`swagger #fashion …` 与 `… #fashion #fyp`),按 caption 认不出是同一张;而 chooseCard
还有"全都试过就退回全量再挑"的兜底 —— 那就是死循环入口(实测连点 11 次)。
→ caption + 容器矩形双重去重(容差 40px),全试过时返回 null。

另外 `returnToResults` 在 pageState 为 unknown 时**只等不按返回**,15 秒预算被 500ms 的等待
耗完,一次返回都没按过就报「未能回到结果页」。照片帖/商品页正好都是 unknown。
→ 连续 3 次 unknown 后照样按返回。

## ② uid 映射文件在界面上取不到

引擎侧是对的(idflow 写 target.mapFile,bridge 的 message 也会带「(已生成 uid 映射)」),
但界面组装记录时只存了 resultFile,mapFile 被丢掉 —— 用户看得到那句话,却看不到路径、
点不开也分享不了,只能自己去 /sdcard 里翻。

→ record 增加 mapFile;记录详情把两份产物各渲染一行(说明 + 路径 + 打开/分享)。
文档里写的「已生成 uid 映射」从此是**可用**的,不是一句空话。

## ③ 「同时解析数字 uid」加界面开关

engine.js 原来写死 resolveUid: true,想关只能改代码。现在「输出」卡片有开关(默认开),
关掉可省每个约 2.5 秒(要下整页 SSR)。store 字段 22 → 23。

## ④ 文档与清理

- PLAN_id_extract.md / REQ_id_extract_v2.md 头部「待评审·未实施」→ **已实施**(v8 已落地)。
  这个不改,人和 AI 都会以为还要做一遍。
- docs/AI_CONTEXT.md 第 6 节补 C14/C15/C16 + D7/D8(卡片可见性、三类假视频卡、
  浮层冒充卡片、页签重刷、文案去重死循环)
- tiktok-auto/README.md 新增 3.45 节
- 删除已废弃的 tiktok_extract_cookie.js / _pc.js(681 行,登录态路线已废,git 可回溯)

## 验证

真机(Pixel 5a / TikTok 47.1.4):
  cat    2 个视频 / 2 目标 / errors 空 / ok=true   ← 原来是 0 个
  fashion 3 个视频 + 2 张跳过(照片帖/商品卡)/ errors 空
自检:引擎 pc-selftest 68 项;界面 smoke 422 项(新增 5 条:映射文件 3 条 + resolveUid 2 条);
AUDIT_CLEAN;两侧 md5 与设备一致。

未做:断点续跑(1~2 天级,按之前定的顺序排在后面)。
梦境 hai 5 días
pai
achega
ddfb17cad8

+ 4 - 1
PLAN_id_extract.md

@@ -1,4 +1,7 @@
-# PLAN · ID提取 执行功能方案(**待评审 · 未实施**)
+# PLAN · ID提取 执行功能方案(**已实施** · 2026-10-20 起可跑)
+
+> ✅ **状态:已实施并在真机验证过**(`task = "id"`,六模块 + 三段式界面)。
+> 本文件保留作为设计依据;实现取舍与实测锚点见 `tiktok-auto/README.md` 第 3.5 节。
 
 > 范围:`tiktok-app`(界面)↔ `tiktok-auto`(执行引擎)两端。
 > 目标:让「任务 › ID提取」从**只有页面**变成**点了运行真的会去 TikTok 里抓 ID**。

+ 4 - 1
REQ_id_extract_v2.md

@@ -1,4 +1,7 @@
-# REQ · ID提取 需求重整 v2(**待评审 · 未实施**)
+# REQ · ID提取 需求重整 v2(**已实施** · v8 三段式落地)
+
+> ✅ **状态:已实施**。本文就是 v8 那次改造的依据(删 5 / 合并 7 / 改名 6 / 置灰 3 / 保留 17
+> 与三段式结构都已落地)。保留作需求原始记录。
 
 > 对象:`tiktok-app` 界面「任务 › ID提取」的四张脚本卡 + 功能设置。
 > 目标:不再往上加功能,而是**把已经存在但互相矛盾的需求理清**,让每个控件都有唯一、可执行的语义。

+ 47 - 2
docs/AI_CONTEXT.md

@@ -137,7 +137,7 @@ tiktok-scripts/
 | 模块 | 职责 |
 |---|---|
 | `theme.js` | 色值令牌、尺寸、字号(**令牌缺键 = 静默视觉删除**) |
-| `store.js` | ★ 配置默认值的**唯一来源** `Store.FIELDS`(68 条),含版本迁移 |
+| `store.js` | ★ 配置默认值的**唯一来源** `Store.FIELDS`(69 条),含版本迁移 |
 | `sources.js` | ID 提取的取材清单(**独立 storage key,不进 FIELDS**) |
 | `records.js` | 运行记录(同上,独立 key) |
 | `device.js` | 设备能力探测(包名、权限、无障碍) |
@@ -468,6 +468,36 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
   头像在 + 按钮不在 = 已关注;两个都不在 = 页面还没好
 - 另外:首页顶部那个 `Following` 页签是 **FrameLayout**,限定 `className('android.widget.Button')` 就能排除
 
+**C14|卡片"看得见"要看容器,不能看文案(★ 2026-10-28,这条让 `cat` 从 0 个变成 2 个)**
+
+- 现象:关键词 `cat` 报「结果页已无更多卡片」,但页面上明明有卡片;`fashion` 正常
+- 根因:结果卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 `[8,1242]→[536,1920+]`)。
+  结果页上只要多一条通栏广告或一行筛选 chips,网格被推下去,文案就落到**屏幕外**(实测 y=2099 > 1920)。
+  旧判据 `bottomOf(node) <= H - 12` 于是把这些**看得见、点得着**的卡片全丢掉
+- 正解:文案照读(无障碍树里有,只是不在可视区),**能不能点由容器决定**:
+  容器与屏幕要有实质交集(露出 ≥8% 屏高);点击点由 `layout.center` 用 `visibleRect` 夹取,永远在屏内
+- 配套:`cardContainer` 的高度上限从 80% 放宽到 **105%** —— 卡片贴边时 `bounds()` 会被裁切得又大又怪,
+  卡在 80% 上会把整排卡片判成"没有容器"
+
+**C15|结果页混着三种"长得像视频卡"的东西**
+
+| 类型 | 点开之后 | 处理 |
+|---|---|---|
+| 广告**视频** | 正常播放页(带 `Ad` + `Shop now`) | 正常看,能点赞/关注 |
+| **照片轮播帖** | 照片查看器,**没有播放页** | 用信息行的 `Photo` 标记提前判掉(约 2 秒,别等满 20 秒) |
+| **商品/带货卡** | 商品详情页(`Add to cart` / `Buy Now`) | 打不开就当"非视频"跳过 |
+
+- 判据:照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView,视频帖是「`Video` · …」;
+  限定**文案完全等于** `Photo` 且在下半屏,避免误伤正文里出现 Photo 的视频
+- **打不开 ≠ 出错**:这类跳过只记 warning,不进 `errors`(否则"4 个视频里有 1 张照片帖"看起来像故障)
+- 卡片用尽同理:**一个都没收到才报错**,收到过就记 warning 并给出"换关键词/调小目标"的建议
+
+**C16|浮层按钮会冒充卡片**
+
+- 现象:授权弹窗的 `Customize Settings` / `Confirm My Choices` 被当成卡片点开,白等 20 秒
+- 正解:两条硬判据 —— ① 节点必须在 `GridView`/`RecyclerView` 里(`insideResultGrid()`)
+  ② 必须能找到"卡片尺寸"的可点容器(找不到时 `cardContainer` 会退回节点本身,那就不是卡片)
+
 ### 6.D 流程与状态机
 
 **D1|阶段性的"停止"不能共用(★ 踩了两次)**
@@ -507,6 +537,21 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 - 20 秒是"每个**收录**"的折算(含被跳过的),实测收录率约 37%
 - 解析阶段另有独立预算:`count × 3秒 + 1分钟`
 
+**D7|点"已选中"的页签会让列表重刷(★)**
+
+- 现象:卡片读到了、下一句又变 0,最后报「已无更多卡片」
+- 根因:「Top」是结果页默认页签,而默认配置的 `resultTab` 也是 Top —— 等于每次都点一次当前页签。
+  TikTok 会因此**重刷整个网格**,而 `waitQuiet` 判不出"正在重刷"(签名和点之前一样)
+- 正解:`resultTabSelected()` 已经是目标页签就**跳过点击**;真要切页签时,判据必须是"卡片重新出现了"
+- 顺带:主循环读到 0 张卡时**先等 5 秒再滚**(真的到底时不该白等满 20 秒)
+
+**D8|去重只按文案会死循环**
+
+- 现象:同一张照片帖被反复点开、反复跳过,直到 guard 用完
+- 根因:同一张卡的文案在不同时刻读到**不同的截断**(`swagger #fashion …` vs `… #fashion #fyp`),
+  按 caption 去重认不出是同一张;而 `chooseCard` 还有"全都试过就退回全量再挑"的兜底 —— 那就是死循环入口
+- 正解:caption + **容器矩形**双重去重(容差 40px);全试过时返回 null,由调用方决定滚动还是收工
+
 ### 6.E 数据与配置
 
 | 编号 | 现象 | 正解 |
@@ -556,7 +601,7 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 2. `page-*.js` 加控件(输入框只用 `W.inlineField` / `W.numberField`)
 3. `engine.js` 的 `buildConfig`/`buildIdConfig` 加映射
 4. `tools/smoke.js` 补断言:
-   - 字段总数(`ID提取功能设置是 22 个字段`)
+   - 字段总数(`ID提取功能设置是 23 个字段`)
    - **映射正确**(改值 → 引擎配置里出现对应值)
 5. `$NODE tools/build.js && $NODE tools/smoke.js`
 6. 推设备 + 真机确认落盘、重启后读回

+ 59 - 19
tiktok-app/dist/tiktok-app.js

@@ -1,6 +1,6 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-28T09:52:53.962Z
+ *  built 2026-10-28T10:49:21.035Z
  *  modules: theme, store, records, sources, device, service, engine, session, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -650,6 +650,14 @@ F.push(
     { 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 },
+    /**
+     * 抓完之后是否顺手把 username 换成 TikTok 内部的数字 uid,另存一份映射文件。
+     *
+     * 默认开:数字 uid 在 App 界面上任何地方都读不到,不在这里换就得事后另跑一次。
+     * 但每个要多下约 400KB 的主页 SSR(约 2.5 秒),只要 handle 的场景可以关掉。
+     * 2026-10-28 之前这个开关只存在于引擎配置里(界面写死 true),想关得改代码。
+     */
+    { path: "tabs.id.features.resolveUid",     type: "bool", def: true },
     /**
      * 运行时长的**下限**(分钟)。
      *
@@ -1372,6 +1380,9 @@ Records.add = function (record) {
     one.endedAt = one.endedAt || Date.now();
     one.resultText = one.resultText || "";
     one.resultFile = one.resultFile || "";
+    /* uid 映射文件的路径(ID提取且勾了「同时解析数字 uid」时才有)。
+     * 老记录里没有这个字段 → 归一化成空串,渲染层据此决定要不要多一行。 */
+    one.mapFile = one.mapFile || "";
     one.logs = one.logs instanceof Array ? one.logs : [];
 
     list.unshift(one);
@@ -2741,9 +2752,9 @@ Engine.buildIdConfig = function () {
                 dryRun: false,
                 /* 0 = 不限。引擎另有「单次最长运行」与「解析 uid 独立预算」两道时长护栏 */
                 limit: Store.getNumber(base + "extractLimit"),
-                /* 抓完之后换数字 uid(默认开)。界面上暂未给开关——它没有副作用,
-                 * 只是多花时间(约 2.5 秒/个),而数字 uid 在界面上读不到,不换就得事后另跑。 */
-                resolveUid: true
+                /* 抓完之后换数字 uid。默认开(数字 uid 在界面上读不到,不换就得事后另跑),
+                 * 每个约多花 2.5 秒 —— 「输出」卡片上给了开关,用户可关。 */
+                resolveUid: Store.getBool(base + "resolveUid")
             },
             guard: {
                 /* 抓取阶段最长多久(分钟 → 毫秒)。到点收尾,已抓的不丢。 */
@@ -6923,6 +6934,15 @@ function buildOutput() {
         Theme.m.labelSize, Theme.hex.textMuted, false));
     blocks.push(runtimeRow);
 
+    /* 数字 uid:默认开。它要额外下一遍用户主页(约 2.5 秒/个),
+     * 只要 handle 的场景可以关掉省时间 —— 以前没有开关,想关只能改配置。 */
+    var resolveBox = W.checkRow("同时解析数字 uid", Store.getBool(base + "resolveUid"),
+                                function (on) { Store.set(base + "resolveUid", on); });
+    blocks.push(resolveBox.root);
+    if (Store.getBool(base + "resolveUid")) {
+        blocks.push(W.hint("抓完再逐个换成 TikTok 内部的数字 uid,另存一份 uid_map 文件;每个约多花 2~3 秒", false));
+    }
+
     blocks.push(W.hint("结果文件自动命名 uid_日期_时分.txt,存放在 /sdcard/Download/tiktok_app", false));
 
     W.stack(card, blocks, 12);
@@ -7723,27 +7743,43 @@ function buildSummaryCard(record) {
     }
 
     if (record.resultFile) {
-        var fileRow = U.row();
-        var path = U.text(record.resultFile, Theme.m.chipSize, Theme.hex.accentText, false);
-        path.setSingleLine(true);
-        path.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE);
-        U.params(path, 0, -2, 1);
-        fileRow.addView(path);
-        fileRow.addView(U.hgap(8));
-        fileRow.addView(W.smallButton("\u6253\u5F00", "accent", function () {
-            App.openResultFile(record.resultFile);
-        }).root);
-        fileRow.addView(U.hgap(6));
-        fileRow.addView(W.smallButton("\u5206\u4EAB", "default", function () {
-            App.shareResultFile(record.resultFile);
-        }).root);
-        rows.push(fileRow);
+        pushFileRows(rows, "\u7ED3\u679C\u6587\u4EF6", record.resultFile);
+    }
+    if (record.mapFile) {
+        pushFileRows(rows, "uid \u6620\u5C04\uFF08username\u2192\u6570\u5B57 uid\uFF09", record.mapFile);
     }
 
     W.stack(card, rows, 10);
     return card;
 }
 
+/**
+ * 一份产物文件:说明行 + 路径行(点路径打开)+ 打开/分享两个按钮。
+ *
+ * 抽成函数是因为现在有两份产物(结果文件 + uid 映射文件),
+ * 各自都要能打开和分享 —— 光在界面上写一句「已生成 uid 映射」而取不到文件,
+ * 等于让用户自己去翻 /sdcard。
+ */
+function pushFileRows(rows, caption, path) {
+    rows.push(U.text(caption, Theme.m.chipSize, Theme.hex.textFaint, false));
+
+    var fileRow = U.row();
+    var pathText = U.text(path, Theme.m.chipSize, Theme.hex.accentText, false);
+    pathText.setSingleLine(true);
+    pathText.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE);
+    U.params(pathText, 0, -2, 1);
+    fileRow.addView(pathText);
+    fileRow.addView(U.hgap(8));
+    fileRow.addView(W.smallButton("\u6253\u5F00", "accent", function () {
+        App.openResultFile(path);
+    }).root);
+    fileRow.addView(U.hgap(6));
+    fileRow.addView(W.smallButton("\u5206\u4EAB", "default", function () {
+        App.shareResultFile(path);
+    }).root);
+    rows.push(fileRow);
+}
+
 /** 日志卡:标题行(执行日志 + 复制)+ 日志行。 */
 function buildLogCard(record) {
     var card = W.card(false);
@@ -9020,6 +9056,10 @@ App.writeRecord = function (result) {
         durationMs: Math.max(0, endedAt - Session.startedAt),
         resultText: result.message || "",
         resultFile: result.summary && result.summary.resultFile ? result.summary.resultFile : "",
+        /* ID提取勾了「同时解析数字 uid」时,引擎还会另写一份 username→uid 的映射文件。
+         * 以前这里只传 resultFile,映射文件虽然在手机上,界面上却看不到路径、
+         * 点不开也分享不了 —— 等于白生成。 */
+        mapFile: result.summary && result.summary.mapFile ? result.summary.mapFile : "",
         logs: Engine.readLog()
     });
     console.log("[records] 已记录本次运行:" + record.id + " / " + record.result);

+ 3 - 3
tiktok-app/src/engine.js

@@ -247,9 +247,9 @@ Engine.buildIdConfig = function () {
                 dryRun: false,
                 /* 0 = 不限。引擎另有「单次最长运行」与「解析 uid 独立预算」两道时长护栏 */
                 limit: Store.getNumber(base + "extractLimit"),
-                /* 抓完之后换数字 uid(默认开)。界面上暂未给开关——它没有副作用,
-                 * 只是多花时间(约 2.5 秒/个),而数字 uid 在界面上读不到,不换就得事后另跑。 */
-                resolveUid: true
+                /* 抓完之后换数字 uid。默认开(数字 uid 在界面上读不到,不换就得事后另跑),
+                 * 每个约多花 2.5 秒 —— 「输出」卡片上给了开关,用户可关。 */
+                resolveUid: Store.getBool(base + "resolveUid")
             },
             guard: {
                 /* 抓取阶段最长多久(分钟 → 毫秒)。到点收尾,已抓的不丢。 */

+ 4 - 0
tiktok-app/src/main.js

@@ -716,6 +716,10 @@ App.writeRecord = function (result) {
         durationMs: Math.max(0, endedAt - Session.startedAt),
         resultText: result.message || "",
         resultFile: result.summary && result.summary.resultFile ? result.summary.resultFile : "",
+        /* ID提取勾了「同时解析数字 uid」时,引擎还会另写一份 username→uid 的映射文件。
+         * 以前这里只传 resultFile,映射文件虽然在手机上,界面上却看不到路径、
+         * 点不开也分享不了 —— 等于白生成。 */
+        mapFile: result.summary && result.summary.mapFile ? result.summary.mapFile : "",
         logs: Engine.readLog()
     });
     console.log("[records] 已记录本次运行:" + record.id + " / " + record.result);

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

@@ -438,6 +438,15 @@ function buildOutput() {
         Theme.m.labelSize, Theme.hex.textMuted, false));
     blocks.push(runtimeRow);
 
+    /* 数字 uid:默认开。它要额外下一遍用户主页(约 2.5 秒/个),
+     * 只要 handle 的场景可以关掉省时间 —— 以前没有开关,想关只能改配置。 */
+    var resolveBox = W.checkRow("同时解析数字 uid", Store.getBool(base + "resolveUid"),
+                                function (on) { Store.set(base + "resolveUid", on); });
+    blocks.push(resolveBox.root);
+    if (Store.getBool(base + "resolveUid")) {
+        blocks.push(W.hint("抓完再逐个换成 TikTok 内部的数字 uid,另存一份 uid_map 文件;每个约多花 2~3 秒", false));
+    }
+
     blocks.push(W.hint("结果文件自动命名 uid_日期_时分.txt,存放在 /sdcard/Download/tiktok_app", false));
 
     W.stack(card, blocks, 12);

+ 31 - 15
tiktok-app/src/page-records.js

@@ -160,27 +160,43 @@ function buildSummaryCard(record) {
     }
 
     if (record.resultFile) {
-        var fileRow = U.row();
-        var path = U.text(record.resultFile, Theme.m.chipSize, Theme.hex.accentText, false);
-        path.setSingleLine(true);
-        path.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE);
-        U.params(path, 0, -2, 1);
-        fileRow.addView(path);
-        fileRow.addView(U.hgap(8));
-        fileRow.addView(W.smallButton("\u6253\u5F00", "accent", function () {
-            App.openResultFile(record.resultFile);
-        }).root);
-        fileRow.addView(U.hgap(6));
-        fileRow.addView(W.smallButton("\u5206\u4EAB", "default", function () {
-            App.shareResultFile(record.resultFile);
-        }).root);
-        rows.push(fileRow);
+        pushFileRows(rows, "\u7ED3\u679C\u6587\u4EF6", record.resultFile);
+    }
+    if (record.mapFile) {
+        pushFileRows(rows, "uid \u6620\u5C04\uFF08username\u2192\u6570\u5B57 uid\uFF09", record.mapFile);
     }
 
     W.stack(card, rows, 10);
     return card;
 }
 
+/**
+ * 一份产物文件:说明行 + 路径行(点路径打开)+ 打开/分享两个按钮。
+ *
+ * 抽成函数是因为现在有两份产物(结果文件 + uid 映射文件),
+ * 各自都要能打开和分享 —— 光在界面上写一句「已生成 uid 映射」而取不到文件,
+ * 等于让用户自己去翻 /sdcard。
+ */
+function pushFileRows(rows, caption, path) {
+    rows.push(U.text(caption, Theme.m.chipSize, Theme.hex.textFaint, false));
+
+    var fileRow = U.row();
+    var pathText = U.text(path, Theme.m.chipSize, Theme.hex.accentText, false);
+    pathText.setSingleLine(true);
+    pathText.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE);
+    U.params(pathText, 0, -2, 1);
+    fileRow.addView(pathText);
+    fileRow.addView(U.hgap(8));
+    fileRow.addView(W.smallButton("\u6253\u5F00", "accent", function () {
+        App.openResultFile(path);
+    }).root);
+    fileRow.addView(U.hgap(6));
+    fileRow.addView(W.smallButton("\u5206\u4EAB", "default", function () {
+        App.shareResultFile(path);
+    }).root);
+    rows.push(fileRow);
+}
+
 /** 日志卡:标题行(执行日志 + 复制)+ 日志行。 */
 function buildLogCard(record) {
     var card = W.card(false);

+ 3 - 0
tiktok-app/src/records.js

@@ -110,6 +110,9 @@ Records.add = function (record) {
     one.endedAt = one.endedAt || Date.now();
     one.resultText = one.resultText || "";
     one.resultFile = one.resultFile || "";
+    /* uid 映射文件的路径(ID提取且勾了「同时解析数字 uid」时才有)。
+     * 老记录里没有这个字段 → 归一化成空串,渲染层据此决定要不要多一行。 */
+    one.mapFile = one.mapFile || "";
     one.logs = one.logs instanceof Array ? one.logs : [];
 
     list.unshift(one);

+ 8 - 0
tiktok-app/src/store.js

@@ -226,6 +226,14 @@ F.push(
     { 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 },
+    /**
+     * 抓完之后是否顺手把 username 换成 TikTok 内部的数字 uid,另存一份映射文件。
+     *
+     * 默认开:数字 uid 在 App 界面上任何地方都读不到,不在这里换就得事后另跑一次。
+     * 但每个要多下约 400KB 的主页 SSR(约 2.5 秒),只要 handle 的场景可以关掉。
+     * 2026-10-28 之前这个开关只存在于引擎配置里(界面写死 true),想关得改代码。
+     */
+    { path: "tabs.id.features.resolveUid",     type: "bool", def: true },
     /**
      * 运行时长的**下限**(分钟)。
      *

+ 44 - 3
tiktok-app/tools/smoke.js

@@ -173,9 +173,9 @@ check('空清单 = 未配置(再也找不到"哪个脚本被选中"这种概
     api.Pages.checkedScript('id') === null);
 
 /* v7 那次清语义的成果一个都没丢:功能设置 19 个字段还在 */
-/* 22 = v7 的 19 个 + 去重两开关 + 单次最长运行(它决定"一轮最多能抓多少个") */
-eq('ID提取功能设置是 22 个字段',
-    api.Store.FIELDS.filter((f) => /^tabs\.id\.features\./.test(f.path)).length, 22);
+/* 23 = v7 的 19 个 + 去重两开关 + 单次最长运行 + 同时解析数字 uid */
+eq('ID提取功能设置是 23 个字段',
+    api.Store.FIELDS.filter((f) => /^tabs\.id\.features\./.test(f.path)).length, 23);
 
 /* —— 界面 → 引擎的字段映射:一个都不许丢 ——
  * 这个项目踩过"路径拼错 = 静默丢值"(字段在界面上有控件、能改、能存,
@@ -229,6 +229,14 @@ eq('映射:单次最长运行(分钟 → 毫秒)',
     mappedRuntime.config.id.guard.maxRuntimeMs, 90 * 60 * 1000);
 api.Store.set(idBase + 'maxRuntimeMin', api.Store.defaultOf(idBase + 'maxRuntimeMin'));
 
+/* 同时解析数字 uid:默认开,关了必须真的传到引擎。
+ * 这个开关决定"抓完要不要再多花 2.5 秒/个去换 uid" —— 映射丢了的话
+ * 用户关掉它仍然会白等,而界面上完全看不出来。 */
+eq('映射:同时解析数字 uid 默认开', api.Engine.buildIdConfig().config.id.output.resolveUid, true);
+api.Store.set(idBase + 'resolveUid', false);
+eq('映射:关掉后引擎不再解析 uid', api.Engine.buildIdConfig().config.id.output.resolveUid, false);
+api.Store.set(idBase + 'resolveUid', api.Store.defaultOf(idBase + 'resolveUid'));
+
 /* 「做不到的项」必须逐条进 notes —— 用户勾了却什么都发生,比明确告知糟得多 */
 const noteText = mapped.notes.join(' | ');
 check('云端去重开了 → 如实告知只用本地', noteText.indexOf('云端去重') >= 0, noteText);
@@ -1097,6 +1105,39 @@ check('记录条目显示「任务 · 脚本」',
     textsOf(api.App.contentHost).join(' | '));
 check('记录条目显示结果标签', textsOf(api.App.contentHost).indexOf('已完成') >= 0);
 
+/* —— uid 映射文件:引擎写了,界面就必须拿得到(2026-10-28 修)——
+ * 引擎把 mapFile 放在 summary 里(idflow.js),而界面组装记录时曾经只存 resultFile,
+ * 于是记录页只有一句「(已生成 uid 映射)」—— 路径、打开、分享全都没有,
+ * 用户只能自己去 /sdcard 里翻。这条断言盯的就是"引擎给了、界面丢了"。 */
+api.App.openDetail('id');
+if (api.Sources.count() === 0) { api.Sources.add('anchor'); }
+/* 先拍一张记录快照:这条场景只是为了验"映射文件不丢",验完必须还原,
+ * 否则后面「共 1 条」「返回后回到记录列表」这些断言全会被多出来的记录带偏。 */
+const recordsBeforeMap = api.Records.list();
+api.App.startRun(api.App.currentTask());
+env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+    runId: api.Session.runId, status: 'finished', phase: '',
+    summary: {
+        ok: true,
+        resultFile: '/sdcard/Download/tiktok_app/uid_20261028_1200.txt',
+        mapFile: '/sdcard/Download/tiktok_app/uid_map_20261028_1200.txt'
+    }
+});
+env.flushIntervals();
+const idRecord = api.Records.list()[0];
+eq('ID 记录带着 uid 映射文件路径', idRecord.mapFile,
+   '/sdcard/Download/tiktok_app/uid_map_20261028_1200.txt');
+api.App.openRecord(idRecord.id);
+const idTexts = textsOf(api.App.contentHost).join(' | ');
+check('记录详情能显示映射文件路径', idTexts.indexOf('uid_map_20261028_1200') >= 0, idTexts);
+check('结果文件与映射文件各自占一行',
+    idTexts.indexOf('uid_20261028_1200.txt') >= 0 && idTexts.indexOf('uid 映射') >= 0, idTexts);
+
+/* 还原现场:关掉详情、把记录列表恢复成这条场景之前的模样。
+ * 不还原的话,后面「共 1 条」「返回后回到记录列表」都会被这条假记录带偏。 */
+api.App.closeRecord();
+api.Records.save(recordsBeforeMap);
+
 /* —— D3 运行详情:点条目进去 —— */
 api.App.openRecord(afterFinish[0].id);
 check('运行详情顶部是返回头', textsOf(api.App.contentHost).indexOf('运行详情') >= 0);

+ 39 - 0
tiktok-auto/README.md

@@ -175,6 +175,45 @@ summary `likedVideos=1 / followedAuthors=1`。
 
 ---
 
+## 3.45 搜索结果取片(`videoCards` 的四条硬判据)
+
+结果页的卡片解析看着简单,实际上踩过 4 个坑(2026-10-28 一并修掉):
+
+| 判据 | 为什么 |
+|---|---|
+| 文案控件宽 ≥ 35% 屏宽 | 排掉右侧竖排的点赞/评论/分享按钮 |
+| 必须在 `GridView`/`RecyclerView` 里 | 排掉浮层按钮(授权弹窗的 `Customize Settings`) |
+| 必须能找到"卡片尺寸"的可点容器 | 孤立按钮不是卡片;`cardContainer` 退回节点本身即判否 |
+| **容器**与屏幕有实质交集(≥8% 屏高) | 见下 |
+
+★ **不能拿"文案在屏幕内"当条件**:卡片的文案 Button 在**卡片底部**,卡片本身很高。
+结果页上多一条通栏广告或一行筛选 chips,网格被推下去,文案就落到屏幕外(实测 y=2099 > 1920)。
+按文案位置判断会把**看得见、点得着**的卡片整排丢掉 —— 表现成"这个关键词没有视频"。
+
+另外 `cardContainer` 的高度上限是 **105%** 屏高(不是 80%)。卡片贴边时 `bounds()` 被裁切得又大又怪,
+卡在 80% 会把整排卡片判成"找不到容器"。
+
+**结果页上混着三种"长得像视频卡"的东西**:
+
+| 类型 | 点开之后 | 处理 |
+|---|---|---|
+| 广告**视频** | 正常播放页(`Ad` + `Shop now`) | 正常看 |
+| **照片轮播帖** | 照片查看器,没有播放页 | 信息行的 `Photo` 标记提前判掉(约 2 秒 vs 白等 20 秒) |
+| **商品/带货卡** | 商品详情页 | 打不开就当非视频,跳过 |
+
+跳过只记 warning、不进 `errors`;**一个都没收到**才当错误。
+
+**两个循环级的坑**(都会表现成"零报错地失败"):
+
+1. **点"已选中"的页签会重刷列表** —— 默认页签是 Top,配置的 `resultTab` 也是 Top,
+   于是每次运行都点一次当前页签 → TikTok 重刷网格 → 卡片先消失。
+   现在 `resultTabSelected()` 为真就跳过点击。
+2. **去重只按文案会死循环** —— 同一张卡的文案会读到不同截断(`swagger #fashion …` 与
+   `… #fashion #fyp`)。现在 caption + **容器矩形**双重去重(容差 40px),
+   且去掉了"全都试过就退回全量再挑"的兜底。
+
+---
+
 ## 3.5 ID 提取(`task = "id"`)
 
 第二条链路,与视频链路并列:同一套启动/守护/停止/上报基础设施,只在

+ 324 - 33
tiktok-auto/dist/tiktok-auto.js

@@ -1,5 +1,5 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-28T09:52:51.438Z
+ *  built 2026-10-28T10:49:20.750Z
  *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, parse, uidset, filters, profile, idsource, resolve, idflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -557,7 +557,14 @@ TikTokAuto.config = (function () {
         timeouts: {
             appLaunchMs: 25000,
             pageReadyMs: 15000,
-            searchResultsMs: 20000,
+            /**
+             * 搜索结果首屏 / 切页签重刷的等待上限。
+             *
+             * 2026-10-28 实测:关键词 `cat` 从点搜索到网格出现约 **18 秒**
+             * (8 秒时截图还是空的,再等 10 秒才有卡)。原来 20 秒只剩 2 秒余量,
+             * 网络稍慢就会判成"没有可用卡片"。放到 30 秒。
+             */
+            searchResultsMs: 30000,
             playerReadyMs: 20000,
             playbackStartMs: 12000,
             panelReadyMs: 8000,
@@ -1730,6 +1737,15 @@ TikTokAuto.tiktok = (function () {
     var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
     var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
 
+    /**
+     * 结果页卡片搜索区的上边界(占屏高比例)。
+     *
+     * 实测布局:搜索框 → 页签栏 y 152~232 → GridView 从 y=232 开始(屏高 1920)。
+     * 取 0.13(=250)刚好卡在网格起始线下方,把搜索框和页签排掉。
+     * 详见 videoCards() 的注释(用 0.3 会漏掉滚动后的上排卡片)。
+     */
+    var ROWS_TOP_RATIO = 0.13;
+
     var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证
     var logger = TikTokAuto.logger;
 
@@ -1862,7 +1878,11 @@ TikTokAuto.tiktok = (function () {
         var depth = 0;
         while (cursor && depth < 6) {
             var cardHeight = bottomOf(cursor) - topOf(cursor);
-            if (cardHeight >= height * 0.18 && cardHeight <= height * 0.8) {
+            /* 上限从 0.8 放宽到 1.05(2026-10-28):卡片滚动到贴着屏幕上下边缘时,
+             * 容器会有一部分在屏幕外,`bounds()` 报的高度于是**被裁切得又大又怪**,
+             * 实测超过 80% 后被这里挡掉 → 整排卡片变成"找不到容器" → 上层误判
+             * "卡片用尽"。点击点由 `layout.center` 用 visibleRect 夹取,仍然安全。 */
+            if (cardHeight >= height * 0.18 && cardHeight <= height * 1.05) {
                 return cursor;
             }
             cursor = layout.clickableAncestor(cursor);
@@ -1872,23 +1892,84 @@ TikTokAuto.tiktok = (function () {
     }
 
     /**
-     * 结果页的视频卡片列表。
+     * 这个节点是不是在"结果网格"里(GridView / RecyclerView 的子孙)。
      *
-     * 判据(都来自实测踩坑):
+     * 为什么需要:结果页上会浮出各种**不是视频卡但同样又宽又有文案**的东西 ——
+     * 授权弹窗的 `Customize Settings` / `Confirm My Choices`、商品卡的 `Shop now`、
+     * 「Users」分区的表头。它们能通过"宽 + 有文案"的判据,被当成卡片点开白等 20 秒。
+     * 而真正的结果卡片一定在滚动容器里,弹窗不出现在其中。
+     */
+    function insideResultGrid(node) {
+        var cursor = node;
+        for (var depth = 0; depth < 8; depth += 1) {
+            try {
+                cursor = cursor.parent();
+            } catch (ignored) {
+                return false;
+            }
+            if (!cursor) {
+                return false;
+            }
+            var name = '';
+            try {
+                name = String(cursor.className() || '');
+            } catch (ignoredToo) {
+                name = '';
+            }
+            if (name.indexOf('GridView') >= 0 || name.indexOf('RecyclerView') >= 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 当前页面是"照片轮播帖"吗(不是视频)?
+     *
+     * 真机实测(2026-10-28):照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView,
+     * 视频帖是「`Video` · …」。照片帖**没有播放页**,`onPlayerPage()` 也认不出来,
+     * 于是 openCard 会白等满 20 秒(playerReadyMs)再失败。
+     *
+     * 判据限定文案**完全等于** `Photo` 且位于屏幕下半部分(信息行),
+     * 避免误伤说明文字里出现 Photo 的普通视频。
+     */
+    function photoPost() {
+        var height = screenHeight();
+        var hits = findByLabel(className('android.widget.TextView'), function (node) {
+            return (layout.text(node) || '') === 'Photo' && topOf(node) > height * 0.5;
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /**
+     * 结果网格里当前可见的卡片(按屏幕从上到下)。
+     *
+     * 判据(2026-10-28 用 AutoX 无障碍树真机核对过):
      *   - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉;
-     *   - top > 30%H,据此排掉顶部搜索框;
-     *   - 底部被导航栏切掉半截的先不收,避免点到一半的位置。
+     *   - **必须在 GridView/RecyclerView 里**(排掉浮层/弹窗按钮,如 `Customize Settings`);
+     *   - **必须能找到一个"卡片尺寸"的可点容器**(`cardContainer` 找不到时会退回节点
+     *     本身,那种情况说明它只是一个孤立按钮,不是卡片);
+     *   - 可见性看**容器**,不看文案。
+     *
+     * ★ 为什么不再要求"文案在屏幕内"(这次最关键的修正):
+     * 卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 8,1242 → 536,1920+)。
+     * 结果页上只要多一条通栏广告或一行筛选 chips,网格就被推下去,文案跟着落到
+     * **屏幕外**(实测 y=2099 > 1920)。旧的 `bottomOf(node) <= height - 12` 于是把这些
+     * **明明看得见、点得着**的卡片全部丢掉 —— 表现成"只处理了 1 个就说卡片用尽"。
+     *
+     * 正确做法:文案照读(无障碍树里它在,只是不在可视区),能不能点由**容器**决定;
+     * 点击点由 `layout.center` 用 `visibleRect` 夹取,永远落在屏幕内。
      */
     function videoCards() {
         var width = screenWidth();
         var height = screenHeight();
+        var gridTop = height * ROWS_TOP_RATIO; /* 页签栏底部,网格从这里开始 */
+        var minVisible = height * 0.08;         /* 露出来的太少就不点(点下去容易点到邻居) */
         var cards = [];
         var seen = {};
 
         var hits = findByLabel(className('android.widget.Button'), function (node) {
-            return widthOf(node) >= width * 0.35 &&
-                topOf(node) > height * 0.3 &&
-                bottomOf(node) <= height - 12;
+            return widthOf(node) >= width * 0.35 && insideResultGrid(node);
         });
 
         for (var i = 0; i < hits.length; i += 1) {
@@ -1896,16 +1977,35 @@ TikTokAuto.tiktok = (function () {
             if (!caption) {
                 continue;
             }
-            var key = caption + '@' + topOf(hits[i]);
+            var container = cardContainer(hits[i]);
+            if (!container || container === hits[i]) {
+                /* 找不到卡片尺寸的可点容器 → 这是个孤立按钮(弹窗按钮、分区表头等) */
+                continue;
+            }
+            var boxTop = topOf(container);
+            var boxBottom = bottomOf(container);
+            if (boxBottom <= gridTop) {
+                /* 整个容器都在页签栏之上 —— 不是结果卡片 */
+                continue;
+            }
+            if (boxTop >= height) {
+                /* 完全在屏幕下方,看不见也点不着 */
+                continue;
+            }
+            if (Math.min(boxBottom, height) - Math.max(boxTop, gridTop) < minVisible) {
+                /* 只露出一条缝,点下去很容易落到相邻卡片上 */
+                continue;
+            }
+            var key = caption + '@' + boxTop;
             if (seen[key]) {
                 continue;
             }
             seen[key] = true;
             cards.push({
                 anchor: hits[i],
-                container: cardContainer(hits[i]),
+                container: container,
                 caption: caption,
-                top: topOf(hits[i])
+                top: boxTop
             });
         }
 
@@ -2792,6 +2892,7 @@ TikTokAuto.tiktok = (function () {
         authorAvatar: authorAvatar,
         followState: followState,
         onPlayerPage: onPlayerPage,
+        photoPost: photoPost,
         backButton: backButton,
         commentCount: commentCount,
         inlineInput: inlineInput,
@@ -3084,6 +3185,30 @@ TikTokAuto.player = (function () {
     function waitPlayerReady(config, step) {
         var logger = TikTokAuto.logger;
         var tiktok = TikTokAuto.tiktok;
+
+        /* 先花最多 4 秒分辨"打开的是不是视频"。
+         *
+         * 搜索结果里混着照片轮播帖,它的信息行是 `Photo · 1w ago`,**永远不会有播放页**,
+         * 而 `onPlayerPage()` 也认不出来 —— 不提前判掉就要白等满 playerReadyMs(20 秒)。
+         * 真机实测:一轮 4 个目标里撞到 1 个照片帖,就白白浪费掉 20 秒。
+         *
+         * 认出是视频(或两种都不是)之后仍走原来的等待,不改变正常路径的行为。
+         */
+        var kind = TikTokAuto.wait.waitFor(step + ':kind', function () {
+            if (tiktok.onPlayerPage()) {
+                return 'player';
+            }
+            if (tiktok.photoPost()) {
+                return 'photo';
+            }
+            return null;
+        }, 4000, config.probe.pollTickMs);
+
+        if (kind === 'photo') {
+            logger.info(step, '打开的是照片帖(没有播放页),跳过');
+            return false;
+        }
+
         var ready = TikTokAuto.wait.waitFor(step, function () {
             return tiktok.onPlayerPage() ? true : null;
         }, config.timeouts.playerReadyMs, config.probe.pollTickMs);
@@ -3650,13 +3775,19 @@ TikTokAuto.flow = (function () {
         }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
     }
 
-    /** 等结果网格真的渲染出卡片(而不是页面框架出来了就算完) */
-    function waitForCards(config) {
+    /**
+     * 等结果网格真的渲染出卡片(而不是页面框架出来了就算完)。
+     *
+     * @param timeoutMs 可选。不传用 `searchResultsMs`(20 秒,够首屏/切页签重刷)。
+     *                  主循环里"滚动之前"的那次只值几秒 —— 到底时不该白等 20 秒。
+     * @returns 卡片数组(成功)或 null(超时)
+     */
+    function waitForCards(config, timeoutMs) {
         var step = 'waitForCards';
         return TikTokAuto.wait.waitFor(step, function () {
             var cards = TikTokAuto.tiktok.videoCards();
             return cards.length > 0 ? cards : null;
-        }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
+        }, timeoutMs || config.timeouts.searchResultsMs, config.probe.pollTickMs);
     }
 
     function selectResultTab(config) {
@@ -3665,16 +3796,36 @@ TikTokAuto.flow = (function () {
         if (!name) {
             return false;
         }
-        var tab = TikTokAuto.tiktok.resultTab(name);
+        var tiktok = TikTokAuto.tiktok;
+
+        /* ★ 已经是目标页签就**不要点**(2026-10-28 实测)。
+         *
+         * 「Top」是结果页的默认页签,而我们默认配置的 resultTab 也是 Top ——
+         * 等于每次运行都会点一次当前页签。点已选中的页签会让 TikTok 重刷整个网格:
+         * 卡片先消失、再重新渲染,中间那段时间 videoCards() 返回 0。
+         * 后面 `waitQuiet` 又判断不出"列表正在重刷"(签名和点之前一样),
+         * 于是直接进了主循环 → 读到 0 张卡 → 滚一下 → 还是 0 → 报「已无更多卡片」。
+         * 整个流程零报错地失败,看着像"这个关键词没有视频"。
+         */
+        if (tiktok.resultTabSelected(name)) {
+            logger().info(step, '页签已经是 ' + name + ',跳过点击(点了会重刷列表)');
+            return true;
+        }
+
+        var tab = tiktok.resultTab(name);
         if (!tab) {
             logger().warn(step, '找不到页签 ' + name + ',沿用当前页签');
             return false;
         }
         logger().info(step, '切换到页签 ' + name);
         TikTokAuto.ui.clickNode(tab, step);
-        TikTokAuto.wait.waitQuiet(function () {
-            return TikTokAuto.layout.signature();
-        }, { maxMs: 4000, tickMs: 250, stableTimes: 3 });
+
+        /* 切页签一定会重刷网格 → 判据必须是"卡片真的重新出现了",
+         * 不能再用 waitQuiet(它会把"还在加载"当成"已经安静")。 */
+        if (!waitForCards(config)) {
+            logger().warn(step, '切到 ' + name + ' 后没等到卡片');
+            return false;
+        }
         return true;
     }
 
@@ -3695,18 +3846,76 @@ TikTokAuto.flow = (function () {
 
     // ------------------------------------------------------------ 选片与观看
 
-    function chooseCard(config, cards, visited) {
+    /**
+     * 从当前屏的卡片里挑一张没试过的。
+     *
+     * @param visited 已处理过的 caption 列表(用于跨屏去重)
+     * @param tried   已尝试过的**容器矩形**列表
+     *
+     * ★ 为什么还要按矩形去重(2026-10-28 修):
+     * 同一张卡片的文案在不同时刻会读到**不同的截断**:一次是 `swagger #fashion …`,
+     * 另一次是 `… #fashion #outfitinspo #fyp`(前面被省略号截掉)。按 caption 去重认不出
+     * 是同一张,于是"点开→不是视频→跳过→又点到它"循环 11 次才被 guard 拦住。
+     * 容器矩形在没滚动时是稳定的,用它兜住这一层。
+     *
+     * 也去掉了"全都试过就退回全量再挑一遍"的兜底 —— 那个兜底正是死循环的入口。
+     * 全试过时返回 null,由调用方决定是滚动还是收工。
+     */
+    function chooseCard(config, cards, visited, tried) {
         var fresh = [];
         for (var i = 0; i < cards.length; i += 1) {
-            if (visited.indexOf(cards[i].caption) === -1) {
-                fresh.push(cards[i]);
+            if (visited.indexOf(cards[i].caption) !== -1) {
+                continue;
+            }
+            if (cardAlreadyTried(cards[i], tried)) {
+                continue;
             }
+            fresh.push(cards[i]);
+        }
+        if (fresh.length === 0) {
+            return null;
         }
-        var pool = fresh.length > 0 ? fresh : cards;
         if (config.target.pickStrategy === 'first') {
-            return pool[0];
+            return fresh[0];
+        }
+        return fresh[Math.floor(Math.random() * fresh.length)];
+    }
+
+    /** 容器矩形是否已在"试过"名单里(容差 40px,容忍布局微抖) */
+    function cardAlreadyTried(card, tried) {
+        if (!tried || tried.length === 0) {
+            return false;
+        }
+        var box = null;
+        try {
+            box = card.container.bounds();
+        } catch (ignored) {
+            return false;
+        }
+        if (!box) {
+            return false;
+        }
+        for (var i = 0; i < tried.length; i += 1) {
+            var one = tried[i];
+            if (Math.abs(one.left - box.left) <= 40 &&
+                Math.abs(one.top - box.top) <= 40 &&
+                Math.abs(one.right - box.right) <= 40) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 把一张卡的容器矩形记进"试过"名单 */
+    function rememberCard(card, tried) {
+        try {
+            var box = card.container.bounds();
+            if (box) {
+                tried.push({ left: box.left, top: box.top, right: box.right });
+            }
+        } catch (ignored) {
+            /* 记不上就记不上,caption 那一层去重仍在 */
         }
-        return pool[Math.floor(Math.random() * pool.length)];
     }
 
     function openCard(config, card) {
@@ -3729,15 +3938,31 @@ TikTokAuto.flow = (function () {
         var tiktokLayout = TikTokAuto.layout;
         var deadline = Date.now() + config.timeouts.pageReadyMs;
         var backs = 0;
+        var unknownStreak = 0;
 
-        while (Date.now() < deadline && backs < 5) {
+        while (Date.now() < deadline && backs < 6) {
             var state = tiktok.pageState();
             if (state === 'results') {
                 return true;
             }
             if (state === 'unknown') {
-                TikTokAuto.wait.tick(500);
-                continue;
+                /* ★ 连续 unknown 也要按返回(2026-10-28 修)。
+                 *
+                 * 原来是"unknown 就只等 500ms、绝不按返回",于是卡在
+                 * **照片轮播帖 / 商品页**这类 pageState 认不出的页面时,
+                 * 15 秒预算会被 500ms 的等待耗完,一次返回都没按过,
+                 * 最后报「未能回到结果页,跳过后续取片」——整个循环就此中断。
+                 *
+                 * 页面刚切换的头 1.5 秒确实是 unknown(还在渲染),所以先让两次;
+                 * 之后就必须动。
+                 */
+                unknownStreak += 1;
+                if (unknownStreak < 3) {
+                    TikTokAuto.wait.tick(500);
+                    continue;
+                }
+            } else {
+                unknownStreak = 0;
             }
             backs += 1;
             log.info(step, '当前在 ' + state + ',返回上一页');
@@ -3786,6 +4011,10 @@ TikTokAuto.flow = (function () {
             likesRequested: config.likes && config.likes.enabled === true,
             followsRequested: config.follows && config.follows.enabled === true,
             details: [],
+            /* 不算失败但值得说的事(例:搜索结果里混着商品卡,点开不是视频就跳过)。
+             * 必须在这里初始化 —— 后面直接 push,不初始化就是运行期 TypeError,
+             * 而 AutoX 上这种异常不弹窗、不报错,只表现成"流程莫名停住"。 */
+            warnings: [],
             errors: []
         };
         var total = config.target.maxVideos;
@@ -3814,6 +4043,7 @@ TikTokAuto.flow = (function () {
         selectResultTab(config);
 
         var visited = [];
+        var tried = [];
         var usedComments = [];
         var guard = 0;
 
@@ -3824,20 +4054,76 @@ TikTokAuto.flow = (function () {
                      summary.attemptedVideos, total);
 
             var cards = TikTokAuto.tiktok.videoCards();
+            if (cards.length === 0) {
+                /* ★ 先"等"再"滚"(2026-10-28 修)。
+                 *
+                 * 读到 0 张卡有两种可能:① 真的没有更多了 ② 列表正在重排/重刷
+                 * (切页签、刚翻过一页、图片加载中)。直接滚会把 ② 当成 ① 而收工。
+                 * 这里只给 5 秒 —— 真的到底时不该白等满 20 秒。
+                 */
+                cards = waitForCards(config, 5000) || [];
+            }
             if (cards.length === 0) {
                 scrollResults(config);
-                cards = TikTokAuto.tiktok.videoCards();
+                cards = waitForCards(config) || [];
                 if (cards.length === 0) {
-                    summary.errors.push('结果页已无更多卡片');
+                    /* ★ 卡片用尽不是"错误"(2026-10-28 改)。
+                     *
+                     * 搜索结果少的时候(例:关键词 cat 只有 3~4 张视频卡,
+                     * 中间还夹着 1 张商品卡)会正常用完。以前这里 push 一条 errors,
+                     * 于是"处理了 3 个视频、目标 4 个"看起来像出了故障。
+                     *
+                     * 真正该报错的是**一个都没收到** —— 那说明选词/解析真有问题。
+                     */
+                    if (summary.attemptedVideos === 0) {
+                        summary.errors.push('结果页没有可用的视频卡片');
+                    } else {
+                        summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
+                            summary.attemptedVideos + ' 个(目标 ' + total +
+                            ' 个),换个关键词或调小目标数量');
+                    }
+                    break;
+                }
+            }
+
+            var card = chooseCard(config, cards, visited, tried);
+
+            /* 当前屏的卡片全试过了(含"试过但打不开"的)→ 滚一屏再看。
+             * 绝不能像旧版那样"全都试过就退回全量再挑" —— 那是死循环的入口:
+             * 同一张照片帖会被反复点开、反复跳过,直到 guard 用完。 */
+            if (!card) {
+                scrollResults(config);
+                cards = waitForCards(config) || [];
+                card = chooseCard(config, cards, visited, tried);
+                if (!card) {
+                    if (summary.attemptedVideos === 0) {
+                        summary.errors.push('结果页没有可用的视频卡片');
+                    } else {
+                        summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
+                            summary.attemptedVideos + ' 个(目标 ' + total +
+                            ' 个),换个关键词或调小目标数量');
+                    }
                     break;
                 }
             }
 
-            var card = chooseCard(config, cards, visited);
             visited.push(card.caption);
+            rememberCard(card, tried);
 
             if (!openCard(config, card)) {
-                summary.errors.push('打开视频失败:' + String(card.caption).slice(0, 30));
+                /* ★ 打不开 ≠ 出错(2026-10-28 实测)。
+                 *
+                 * 搜索结果里混着两种"长得像视频卡"的东西:
+                 *   · 广告**视频** —— 点进去是正常播放页(有 Ad 标记 + Shop now),能正常看
+                 *   · 照片轮播帖 / 商品卡 —— 根本没有播放页
+                 *     (照片帖约 2 秒就能判出来,见 player.waitPlayerReady 的 :kind 预判)
+                 *
+                 * 以前这里 push 一条 errors,于是"4 个视频里有 1 张照片帖"会让整轮
+                 * 记录看起来像出了故障。现在改成:记一条 warning、跳过、继续下一张。
+                 */
+                summary.warnings.push('跳过非视频卡片:' + String(card.caption).slice(0, 30));
+                logger().info('flow', '点开后没有播放页,当作非视频卡片跳过:' +
+                    String(card.caption).slice(0, 30));
                 returnToResults(config);
                 continue;
             }
@@ -5542,6 +5828,11 @@ TikTokAuto.idsource = (function () {
                 break;
             }
             var cards = TikTokAuto.tiktok.videoCards();
+            if (cards.length === 0) {
+                /* 0 张卡可能是"到底了",也可能是"列表正在重排/图片在加载"。
+                 * 先给 5 秒,等不到再判定到底 —— 直接 break 会把慢当成空。 */
+                cards = TikTokAuto.flow.waitForCards(ctx.config, 5000) || [];
+            }
             if (cards.length === 0) {
                 log.info('idsource', '结果页没有卡片了');
                 break;

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

@@ -120,7 +120,14 @@ TikTokAuto.config = (function () {
         timeouts: {
             appLaunchMs: 25000,
             pageReadyMs: 15000,
-            searchResultsMs: 20000,
+            /**
+             * 搜索结果首屏 / 切页签重刷的等待上限。
+             *
+             * 2026-10-28 实测:关键词 `cat` 从点搜索到网格出现约 **18 秒**
+             * (8 秒时截图还是空的,再等 10 秒才有卡)。原来 20 秒只剩 2 秒余量,
+             * 网络稍慢就会判成"没有可用卡片"。放到 30 秒。
+             */
+            searchResultsMs: 30000,
             playerReadyMs: 20000,
             playbackStartMs: 12000,
             panelReadyMs: 8000,

+ 181 - 20
tiktok-auto/src/flow.js

@@ -158,13 +158,19 @@ TikTokAuto.flow = (function () {
         }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
     }
 
-    /** 等结果网格真的渲染出卡片(而不是页面框架出来了就算完) */
-    function waitForCards(config) {
+    /**
+     * 等结果网格真的渲染出卡片(而不是页面框架出来了就算完)。
+     *
+     * @param timeoutMs 可选。不传用 `searchResultsMs`(20 秒,够首屏/切页签重刷)。
+     *                  主循环里"滚动之前"的那次只值几秒 —— 到底时不该白等 20 秒。
+     * @returns 卡片数组(成功)或 null(超时)
+     */
+    function waitForCards(config, timeoutMs) {
         var step = 'waitForCards';
         return TikTokAuto.wait.waitFor(step, function () {
             var cards = TikTokAuto.tiktok.videoCards();
             return cards.length > 0 ? cards : null;
-        }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
+        }, timeoutMs || config.timeouts.searchResultsMs, config.probe.pollTickMs);
     }
 
     function selectResultTab(config) {
@@ -173,16 +179,36 @@ TikTokAuto.flow = (function () {
         if (!name) {
             return false;
         }
-        var tab = TikTokAuto.tiktok.resultTab(name);
+        var tiktok = TikTokAuto.tiktok;
+
+        /* ★ 已经是目标页签就**不要点**(2026-10-28 实测)。
+         *
+         * 「Top」是结果页的默认页签,而我们默认配置的 resultTab 也是 Top ——
+         * 等于每次运行都会点一次当前页签。点已选中的页签会让 TikTok 重刷整个网格:
+         * 卡片先消失、再重新渲染,中间那段时间 videoCards() 返回 0。
+         * 后面 `waitQuiet` 又判断不出"列表正在重刷"(签名和点之前一样),
+         * 于是直接进了主循环 → 读到 0 张卡 → 滚一下 → 还是 0 → 报「已无更多卡片」。
+         * 整个流程零报错地失败,看着像"这个关键词没有视频"。
+         */
+        if (tiktok.resultTabSelected(name)) {
+            logger().info(step, '页签已经是 ' + name + ',跳过点击(点了会重刷列表)');
+            return true;
+        }
+
+        var tab = tiktok.resultTab(name);
         if (!tab) {
             logger().warn(step, '找不到页签 ' + name + ',沿用当前页签');
             return false;
         }
         logger().info(step, '切换到页签 ' + name);
         TikTokAuto.ui.clickNode(tab, step);
-        TikTokAuto.wait.waitQuiet(function () {
-            return TikTokAuto.layout.signature();
-        }, { maxMs: 4000, tickMs: 250, stableTimes: 3 });
+
+        /* 切页签一定会重刷网格 → 判据必须是"卡片真的重新出现了",
+         * 不能再用 waitQuiet(它会把"还在加载"当成"已经安静")。 */
+        if (!waitForCards(config)) {
+            logger().warn(step, '切到 ' + name + ' 后没等到卡片');
+            return false;
+        }
         return true;
     }
 
@@ -203,18 +229,76 @@ TikTokAuto.flow = (function () {
 
     // ------------------------------------------------------------ 选片与观看
 
-    function chooseCard(config, cards, visited) {
+    /**
+     * 从当前屏的卡片里挑一张没试过的。
+     *
+     * @param visited 已处理过的 caption 列表(用于跨屏去重)
+     * @param tried   已尝试过的**容器矩形**列表
+     *
+     * ★ 为什么还要按矩形去重(2026-10-28 修):
+     * 同一张卡片的文案在不同时刻会读到**不同的截断**:一次是 `swagger #fashion …`,
+     * 另一次是 `… #fashion #outfitinspo #fyp`(前面被省略号截掉)。按 caption 去重认不出
+     * 是同一张,于是"点开→不是视频→跳过→又点到它"循环 11 次才被 guard 拦住。
+     * 容器矩形在没滚动时是稳定的,用它兜住这一层。
+     *
+     * 也去掉了"全都试过就退回全量再挑一遍"的兜底 —— 那个兜底正是死循环的入口。
+     * 全试过时返回 null,由调用方决定是滚动还是收工。
+     */
+    function chooseCard(config, cards, visited, tried) {
         var fresh = [];
         for (var i = 0; i < cards.length; i += 1) {
-            if (visited.indexOf(cards[i].caption) === -1) {
-                fresh.push(cards[i]);
+            if (visited.indexOf(cards[i].caption) !== -1) {
+                continue;
+            }
+            if (cardAlreadyTried(cards[i], tried)) {
+                continue;
             }
+            fresh.push(cards[i]);
+        }
+        if (fresh.length === 0) {
+            return null;
         }
-        var pool = fresh.length > 0 ? fresh : cards;
         if (config.target.pickStrategy === 'first') {
-            return pool[0];
+            return fresh[0];
+        }
+        return fresh[Math.floor(Math.random() * fresh.length)];
+    }
+
+    /** 容器矩形是否已在"试过"名单里(容差 40px,容忍布局微抖) */
+    function cardAlreadyTried(card, tried) {
+        if (!tried || tried.length === 0) {
+            return false;
+        }
+        var box = null;
+        try {
+            box = card.container.bounds();
+        } catch (ignored) {
+            return false;
+        }
+        if (!box) {
+            return false;
+        }
+        for (var i = 0; i < tried.length; i += 1) {
+            var one = tried[i];
+            if (Math.abs(one.left - box.left) <= 40 &&
+                Math.abs(one.top - box.top) <= 40 &&
+                Math.abs(one.right - box.right) <= 40) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 把一张卡的容器矩形记进"试过"名单 */
+    function rememberCard(card, tried) {
+        try {
+            var box = card.container.bounds();
+            if (box) {
+                tried.push({ left: box.left, top: box.top, right: box.right });
+            }
+        } catch (ignored) {
+            /* 记不上就记不上,caption 那一层去重仍在 */
         }
-        return pool[Math.floor(Math.random() * pool.length)];
     }
 
     function openCard(config, card) {
@@ -237,15 +321,31 @@ TikTokAuto.flow = (function () {
         var tiktokLayout = TikTokAuto.layout;
         var deadline = Date.now() + config.timeouts.pageReadyMs;
         var backs = 0;
+        var unknownStreak = 0;
 
-        while (Date.now() < deadline && backs < 5) {
+        while (Date.now() < deadline && backs < 6) {
             var state = tiktok.pageState();
             if (state === 'results') {
                 return true;
             }
             if (state === 'unknown') {
-                TikTokAuto.wait.tick(500);
-                continue;
+                /* ★ 连续 unknown 也要按返回(2026-10-28 修)。
+                 *
+                 * 原来是"unknown 就只等 500ms、绝不按返回",于是卡在
+                 * **照片轮播帖 / 商品页**这类 pageState 认不出的页面时,
+                 * 15 秒预算会被 500ms 的等待耗完,一次返回都没按过,
+                 * 最后报「未能回到结果页,跳过后续取片」——整个循环就此中断。
+                 *
+                 * 页面刚切换的头 1.5 秒确实是 unknown(还在渲染),所以先让两次;
+                 * 之后就必须动。
+                 */
+                unknownStreak += 1;
+                if (unknownStreak < 3) {
+                    TikTokAuto.wait.tick(500);
+                    continue;
+                }
+            } else {
+                unknownStreak = 0;
             }
             backs += 1;
             log.info(step, '当前在 ' + state + ',返回上一页');
@@ -294,6 +394,10 @@ TikTokAuto.flow = (function () {
             likesRequested: config.likes && config.likes.enabled === true,
             followsRequested: config.follows && config.follows.enabled === true,
             details: [],
+            /* 不算失败但值得说的事(例:搜索结果里混着商品卡,点开不是视频就跳过)。
+             * 必须在这里初始化 —— 后面直接 push,不初始化就是运行期 TypeError,
+             * 而 AutoX 上这种异常不弹窗、不报错,只表现成"流程莫名停住"。 */
+            warnings: [],
             errors: []
         };
         var total = config.target.maxVideos;
@@ -322,6 +426,7 @@ TikTokAuto.flow = (function () {
         selectResultTab(config);
 
         var visited = [];
+        var tried = [];
         var usedComments = [];
         var guard = 0;
 
@@ -332,20 +437,76 @@ TikTokAuto.flow = (function () {
                      summary.attemptedVideos, total);
 
             var cards = TikTokAuto.tiktok.videoCards();
+            if (cards.length === 0) {
+                /* ★ 先"等"再"滚"(2026-10-28 修)。
+                 *
+                 * 读到 0 张卡有两种可能:① 真的没有更多了 ② 列表正在重排/重刷
+                 * (切页签、刚翻过一页、图片加载中)。直接滚会把 ② 当成 ① 而收工。
+                 * 这里只给 5 秒 —— 真的到底时不该白等满 20 秒。
+                 */
+                cards = waitForCards(config, 5000) || [];
+            }
             if (cards.length === 0) {
                 scrollResults(config);
-                cards = TikTokAuto.tiktok.videoCards();
+                cards = waitForCards(config) || [];
                 if (cards.length === 0) {
-                    summary.errors.push('结果页已无更多卡片');
+                    /* ★ 卡片用尽不是"错误"(2026-10-28 改)。
+                     *
+                     * 搜索结果少的时候(例:关键词 cat 只有 3~4 张视频卡,
+                     * 中间还夹着 1 张商品卡)会正常用完。以前这里 push 一条 errors,
+                     * 于是"处理了 3 个视频、目标 4 个"看起来像出了故障。
+                     *
+                     * 真正该报错的是**一个都没收到** —— 那说明选词/解析真有问题。
+                     */
+                    if (summary.attemptedVideos === 0) {
+                        summary.errors.push('结果页没有可用的视频卡片');
+                    } else {
+                        summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
+                            summary.attemptedVideos + ' 个(目标 ' + total +
+                            ' 个),换个关键词或调小目标数量');
+                    }
+                    break;
+                }
+            }
+
+            var card = chooseCard(config, cards, visited, tried);
+
+            /* 当前屏的卡片全试过了(含"试过但打不开"的)→ 滚一屏再看。
+             * 绝不能像旧版那样"全都试过就退回全量再挑" —— 那是死循环的入口:
+             * 同一张照片帖会被反复点开、反复跳过,直到 guard 用完。 */
+            if (!card) {
+                scrollResults(config);
+                cards = waitForCards(config) || [];
+                card = chooseCard(config, cards, visited, tried);
+                if (!card) {
+                    if (summary.attemptedVideos === 0) {
+                        summary.errors.push('结果页没有可用的视频卡片');
+                    } else {
+                        summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
+                            summary.attemptedVideos + ' 个(目标 ' + total +
+                            ' 个),换个关键词或调小目标数量');
+                    }
                     break;
                 }
             }
 
-            var card = chooseCard(config, cards, visited);
             visited.push(card.caption);
+            rememberCard(card, tried);
 
             if (!openCard(config, card)) {
-                summary.errors.push('打开视频失败:' + String(card.caption).slice(0, 30));
+                /* ★ 打不开 ≠ 出错(2026-10-28 实测)。
+                 *
+                 * 搜索结果里混着两种"长得像视频卡"的东西:
+                 *   · 广告**视频** —— 点进去是正常播放页(有 Ad 标记 + Shop now),能正常看
+                 *   · 照片轮播帖 / 商品卡 —— 根本没有播放页
+                 *     (照片帖约 2 秒就能判出来,见 player.waitPlayerReady 的 :kind 预判)
+                 *
+                 * 以前这里 push 一条 errors,于是"4 个视频里有 1 张照片帖"会让整轮
+                 * 记录看起来像出了故障。现在改成:记一条 warning、跳过、继续下一张。
+                 */
+                summary.warnings.push('跳过非视频卡片:' + String(card.caption).slice(0, 30));
+                logger().info('flow', '点开后没有播放页,当作非视频卡片跳过:' +
+                    String(card.caption).slice(0, 30));
                 returnToResults(config);
                 continue;
             }

+ 5 - 0
tiktok-auto/src/idsource.js

@@ -178,6 +178,11 @@ TikTokAuto.idsource = (function () {
                 break;
             }
             var cards = TikTokAuto.tiktok.videoCards();
+            if (cards.length === 0) {
+                /* 0 张卡可能是"到底了",也可能是"列表正在重排/图片在加载"。
+                 * 先给 5 秒,等不到再判定到底 —— 直接 break 会把慢当成空。 */
+                cards = TikTokAuto.flow.waitForCards(ctx.config, 5000) || [];
+            }
             if (cards.length === 0) {
                 log.info('idsource', '结果页没有卡片了');
                 break;

+ 24 - 0
tiktok-auto/src/player.js

@@ -257,6 +257,30 @@ TikTokAuto.player = (function () {
     function waitPlayerReady(config, step) {
         var logger = TikTokAuto.logger;
         var tiktok = TikTokAuto.tiktok;
+
+        /* 先花最多 4 秒分辨"打开的是不是视频"。
+         *
+         * 搜索结果里混着照片轮播帖,它的信息行是 `Photo · 1w ago`,**永远不会有播放页**,
+         * 而 `onPlayerPage()` 也认不出来 —— 不提前判掉就要白等满 playerReadyMs(20 秒)。
+         * 真机实测:一轮 4 个目标里撞到 1 个照片帖,就白白浪费掉 20 秒。
+         *
+         * 认出是视频(或两种都不是)之后仍走原来的等待,不改变正常路径的行为。
+         */
+        var kind = TikTokAuto.wait.waitFor(step + ':kind', function () {
+            if (tiktok.onPlayerPage()) {
+                return 'player';
+            }
+            if (tiktok.photoPost()) {
+                return 'photo';
+            }
+            return null;
+        }, 4000, config.probe.pollTickMs);
+
+        if (kind === 'photo') {
+            logger.info(step, '打开的是照片帖(没有播放页),跳过');
+            return false;
+        }
+
         var ready = TikTokAuto.wait.waitFor(step, function () {
             return tiktok.onPlayerPage() ? true : null;
         }, config.timeouts.playerReadyMs, config.probe.pollTickMs);

+ 105 - 11
tiktok-auto/src/tiktok.js

@@ -38,6 +38,15 @@ TikTokAuto.tiktok = (function () {
     var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
     var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
 
+    /**
+     * 结果页卡片搜索区的上边界(占屏高比例)。
+     *
+     * 实测布局:搜索框 → 页签栏 y 152~232 → GridView 从 y=232 开始(屏高 1920)。
+     * 取 0.13(=250)刚好卡在网格起始线下方,把搜索框和页签排掉。
+     * 详见 videoCards() 的注释(用 0.3 会漏掉滚动后的上排卡片)。
+     */
+    var ROWS_TOP_RATIO = 0.13;
+
     var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证
     var logger = TikTokAuto.logger;
 
@@ -170,7 +179,11 @@ TikTokAuto.tiktok = (function () {
         var depth = 0;
         while (cursor && depth < 6) {
             var cardHeight = bottomOf(cursor) - topOf(cursor);
-            if (cardHeight >= height * 0.18 && cardHeight <= height * 0.8) {
+            /* 上限从 0.8 放宽到 1.05(2026-10-28):卡片滚动到贴着屏幕上下边缘时,
+             * 容器会有一部分在屏幕外,`bounds()` 报的高度于是**被裁切得又大又怪**,
+             * 实测超过 80% 后被这里挡掉 → 整排卡片变成"找不到容器" → 上层误判
+             * "卡片用尽"。点击点由 `layout.center` 用 visibleRect 夹取,仍然安全。 */
+            if (cardHeight >= height * 0.18 && cardHeight <= height * 1.05) {
                 return cursor;
             }
             cursor = layout.clickableAncestor(cursor);
@@ -180,23 +193,84 @@ TikTokAuto.tiktok = (function () {
     }
 
     /**
-     * 结果页的视频卡片列表。
+     * 这个节点是不是在"结果网格"里(GridView / RecyclerView 的子孙)。
+     *
+     * 为什么需要:结果页上会浮出各种**不是视频卡但同样又宽又有文案**的东西 ——
+     * 授权弹窗的 `Customize Settings` / `Confirm My Choices`、商品卡的 `Shop now`、
+     * 「Users」分区的表头。它们能通过"宽 + 有文案"的判据,被当成卡片点开白等 20 秒。
+     * 而真正的结果卡片一定在滚动容器里,弹窗不出现在其中。
+     */
+    function insideResultGrid(node) {
+        var cursor = node;
+        for (var depth = 0; depth < 8; depth += 1) {
+            try {
+                cursor = cursor.parent();
+            } catch (ignored) {
+                return false;
+            }
+            if (!cursor) {
+                return false;
+            }
+            var name = '';
+            try {
+                name = String(cursor.className() || '');
+            } catch (ignoredToo) {
+                name = '';
+            }
+            if (name.indexOf('GridView') >= 0 || name.indexOf('RecyclerView') >= 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 当前页面是"照片轮播帖"吗(不是视频)?
      *
-     * 判据(都来自实测踩坑):
+     * 真机实测(2026-10-28):照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView,
+     * 视频帖是「`Video` · …」。照片帖**没有播放页**,`onPlayerPage()` 也认不出来,
+     * 于是 openCard 会白等满 20 秒(playerReadyMs)再失败。
+     *
+     * 判据限定文案**完全等于** `Photo` 且位于屏幕下半部分(信息行),
+     * 避免误伤说明文字里出现 Photo 的普通视频。
+     */
+    function photoPost() {
+        var height = screenHeight();
+        var hits = findByLabel(className('android.widget.TextView'), function (node) {
+            return (layout.text(node) || '') === 'Photo' && topOf(node) > height * 0.5;
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /**
+     * 结果网格里当前可见的卡片(按屏幕从上到下)。
+     *
+     * 判据(2026-10-28 用 AutoX 无障碍树真机核对过):
      *   - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉;
-     *   - top > 30%H,据此排掉顶部搜索框;
-     *   - 底部被导航栏切掉半截的先不收,避免点到一半的位置。
+     *   - **必须在 GridView/RecyclerView 里**(排掉浮层/弹窗按钮,如 `Customize Settings`);
+     *   - **必须能找到一个"卡片尺寸"的可点容器**(`cardContainer` 找不到时会退回节点
+     *     本身,那种情况说明它只是一个孤立按钮,不是卡片);
+     *   - 可见性看**容器**,不看文案。
+     *
+     * ★ 为什么不再要求"文案在屏幕内"(这次最关键的修正):
+     * 卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 8,1242 → 536,1920+)。
+     * 结果页上只要多一条通栏广告或一行筛选 chips,网格就被推下去,文案跟着落到
+     * **屏幕外**(实测 y=2099 > 1920)。旧的 `bottomOf(node) <= height - 12` 于是把这些
+     * **明明看得见、点得着**的卡片全部丢掉 —— 表现成"只处理了 1 个就说卡片用尽"。
+     *
+     * 正确做法:文案照读(无障碍树里它在,只是不在可视区),能不能点由**容器**决定;
+     * 点击点由 `layout.center` 用 `visibleRect` 夹取,永远落在屏幕内。
      */
     function videoCards() {
         var width = screenWidth();
         var height = screenHeight();
+        var gridTop = height * ROWS_TOP_RATIO; /* 页签栏底部,网格从这里开始 */
+        var minVisible = height * 0.08;         /* 露出来的太少就不点(点下去容易点到邻居) */
         var cards = [];
         var seen = {};
 
         var hits = findByLabel(className('android.widget.Button'), function (node) {
-            return widthOf(node) >= width * 0.35 &&
-                topOf(node) > height * 0.3 &&
-                bottomOf(node) <= height - 12;
+            return widthOf(node) >= width * 0.35 && insideResultGrid(node);
         });
 
         for (var i = 0; i < hits.length; i += 1) {
@@ -204,16 +278,35 @@ TikTokAuto.tiktok = (function () {
             if (!caption) {
                 continue;
             }
-            var key = caption + '@' + topOf(hits[i]);
+            var container = cardContainer(hits[i]);
+            if (!container || container === hits[i]) {
+                /* 找不到卡片尺寸的可点容器 → 这是个孤立按钮(弹窗按钮、分区表头等) */
+                continue;
+            }
+            var boxTop = topOf(container);
+            var boxBottom = bottomOf(container);
+            if (boxBottom <= gridTop) {
+                /* 整个容器都在页签栏之上 —— 不是结果卡片 */
+                continue;
+            }
+            if (boxTop >= height) {
+                /* 完全在屏幕下方,看不见也点不着 */
+                continue;
+            }
+            if (Math.min(boxBottom, height) - Math.max(boxTop, gridTop) < minVisible) {
+                /* 只露出一条缝,点下去很容易落到相邻卡片上 */
+                continue;
+            }
+            var key = caption + '@' + boxTop;
             if (seen[key]) {
                 continue;
             }
             seen[key] = true;
             cards.push({
                 anchor: hits[i],
-                container: cardContainer(hits[i]),
+                container: container,
                 caption: caption,
-                top: topOf(hits[i])
+                top: boxTop
             });
         }
 
@@ -1100,6 +1193,7 @@ TikTokAuto.tiktok = (function () {
         authorAvatar: authorAvatar,
         followState: followState,
         onPlayerPage: onPlayerPage,
+        photoPost: photoPost,
         backButton: backButton,
         commentCount: commentCount,
         inlineInput: inlineInput,

+ 0 - 438
tiktok_extract_cookie.js

@@ -1,438 +0,0 @@
-/**
- * ============================================================================
- *  TikTok 登录态(Cookie)提取器 —— shell + root,无需无障碍
- *
- *  产出:Cookie 串写入 /sdcard/Download/tiktok_data/tiktok_cookie.txt
- *        tiktok_stats.js 会通过 CONFIG.cookieFile 自动读取。
- *
- *  ⚠ Rhino 引擎不允许脚本顶层使用 return,否则启动即报 "invalid return"
- *    并 0 秒退出 —— 所有逻辑已收进函数内。
- *
- * ---------------------------------------------------------------------------
- *  【云手机上的两个大坑 —— 已解决,勿回退】
- *
- *  坑 1:mount namespace 隔离(为什么 test -d /data/data/xxx 返回 NO)
- *    云机会把 /data/data 用 tmpfs 重新挂载成一个**空目录**,
- *    只把 Auto.js 自己和 GMS 的目录 bind 进去:
- *        tmpfs on /data/data type tmpfs (mode=751)
- *        /dev/loop2 on /data/data/org.autojs.autojspro
- *        /dev/loop2 on /data/data/com.google.android.gms
- *    所以 Auto.js 里 `ls /data/data/` 只能看到这两个包,
- *    TikTok 的目录**根本不存在**(不是权限问题,是挂载视图问题)。
- *    → 用 /proc/1/root 穿透到 init 的挂载视图:
- *        /proc/1/root/data/user/0/com.zhiliaoapp.musically/…
- *      /proc/<pid>/root 是 magic symlink,会跟随目标进程的 mount ns,
- *      **不需要 CAP_SYS_ADMIN**(nsenter 需要,所以优先用这个)。
- *
- *  坑 2:Cookie 存储不是明文(为什么 grep 命中 0 项)
- *    · shared_prefs/ttnetCookieStore.xml 的值是 **Java 序列化的十六进制**
- *        <string name="http://tiktok.com/|d_ticket">ACED0005…</string>
- *      根本没有明文 "sessionid=",grep 永远找不到。
- *      字节流结构:[TC_STRING 名称][TC_STRING 值][TC_STRING 域名][TC_STRING 路径]
- *      → 取第 2 个 TC_STRING(0x74) 即 cookie 值。
- *    · app_webview/Default/Cookies 是 SQLite,name 与 value **分列存储**,
- *      grep "sessionid=xxx" 同样匹配不到 → 必须用 SQL 查。
- *
- *  另外:files.* / java.io.* 在 Auto.js **进程 uid** 下运行(非 root),
- *  读不到 /proc/1/root 下的文件 → 必须先用 root shell `cp` 到 /sdcard。
- * ============================================================================
- */
-
-// ============================== CONFIG ======================================
-var CONFIG = {
-    pkg: "com.zhiliaoapp.musically",
-    outFile: "/sdcard/Download/tiktok_data/tiktok_cookie.txt",
-    workDir: "/sdcard/Download/tiktok_data/_extract",
-    verify: true,
-    testAwemeId: "7552202593888603410"
-};
-
-// 输出顺序(优先级从高到低);不在表内的 Cookie 会按字母序追加在后面
-var PRIORITY_KEYS = [
-    "sessionid", "sessionid_ss", "sid_tt", "uid_tt", "uid_tt_ss", "sid_guard",
-    "msToken", "odin_tt", "passport_csrf_token", "passport_csrf_token_default",
-    "multi_sids", "tt-target-idc", "store-idc", "ttwid", "s_v_web_id",
-    "cmpl_token", "d_ticket", "install_id", "ttreq"
-];
-
-// ============================== root shell ==================================
-function sh(cmd) {
-    try {
-        var r = shell(cmd, true);
-        var out = "";
-        if (r && typeof r.result === "string") out = r.result;
-        else if (typeof r === "string") out = r;
-        return out || "";
-    } catch (e) {
-        console.log("[sh] 执行失败: " + e);
-        return "";
-    }
-}
-
-function shTrim(cmd) { return sh(cmd).replace(/^\s+|\s+$/g, ""); }
-
-// ============================== 数据目录定位 =================================
-// 注意:pm/dumpsys 返回的是**宿主视图**路径(/data/user/0/<pkg>),
-// 要读它必须加 /proc/1/root 前缀。
-function pmDataDir(PKG) {
-    var out = sh("pm dump " + PKG + " 2>&1 | grep -i -m1 datadir");
-    var m = out.match(/dataDir=([^\s,]+)/);
-    if (m) return m[1];
-    var out2 = sh("dumpsys package " + PKG + " 2>&1 | grep -i -m1 datadir");
-    var m2 = out2.match(/dataDir=([^\s,]+)/);
-    return m2 ? m2[1] : "";
-}
-
-/**
- * 命名空间穿透策略(按顺序探测):
- *   prefix  : 直接加 /proc/1/root 前缀 —— magic symlink,**不需要 CAP_SYS_ADMIN**,首选
- *   nsenter : 用 nsenter 切到 init 的 mount ns —— 需要 CAP_SYS_ADMIN,作兜底
- *   direct  : 不加前缀(普通真机,本来就能直接读)
- */
-var NS_STRATEGIES = [
-    { kind: "prefix", root: "/proc/1/root", label: "/proc/1/root 前缀" },
-    { kind: "nsenter", mount: "/proc/1/ns/mnt", label: "nsenter 切换 mount ns" },
-    { kind: "direct", label: "直接路径" }
-];
-
-var NS = null;
-
-// 把宿主路径包装成当前策略下 shell 可读的路径片段(含引号)
-function nsPath(p) {
-    if (NS && NS.kind === "prefix") return "'" + NS.root + p + "'";
-    return "'" + p + "'";
-}
-
-// 把命令包装成当前策略下可执行的命令
-function nsCmd(cmd) {
-    if (NS && NS.kind === "nsenter") {
-        return "nsenter --mount=" + NS.mount + " -- sh -c '" + cmd.replace(/'/g, "'\\''") + "'";
-    }
-    return cmd;
-}
-
-// 在当前策略下测试路径是否存在
-function nsTest(p) {
-    return shTrim(nsCmd("test -d " + nsPath(p) + " && echo YES || echo NO")).indexOf("YES") !== -1;
-}
-
-// 依次探测可用策略
-function probeNs(dataDir) {
-    for (var i = 0; i < NS_STRATEGIES.length; i++) {
-        NS = NS_STRATEGIES[i];
-        var ok = nsTest(dataDir + "/shared_prefs") || nsTest(dataDir + "/app_webview");
-        console.log("[ns] " + NS.label + " -> " + (ok ? "✓ 可用" : "✗ 不可用"));
-        if (ok) return NS;
-    }
-    NS = null;
-    return null;
-}
-
-// ============================== 字节流解析 ==================================
-function hexToBytes(hex) {
-    var n = Math.floor(hex.length / 2);
-    var arr = new Array(n);
-    for (var i = 0; i < n; i++) arr[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
-    return arr;
-}
-
-function utf8Decode(bytes, start, len) {
-    var s = "", i = start, end = start + len;
-    while (i < end) {
-        var c = bytes[i++];
-        if (c < 0x80) s += String.fromCharCode(c);
-        else if ((c & 0xE0) === 0xC0 && i < end) {
-            s += String.fromCharCode(((c & 0x1F) << 6) | (bytes[i++] & 0x3F));
-        } else if ((c & 0xF0) === 0xE0 && i + 1 < end) {
-            s += String.fromCharCode(((c & 0x0F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F));
-        } else if ((c & 0xF8) === 0xF0 && i + 2 < end) {
-            var cp = ((c & 0x07) << 18) | ((bytes[i++] & 0x3F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F);
-            cp -= 0x10000;
-            s += String.fromCharCode(0xD800 + (cp >> 10), 0xDC00 + (cp & 0x3FF));
-        } else s += "?";
-    }
-    return s;
-}
-
-// 顺序扫描 Java 序列化字节流,抽取全部 TC_STRING(0x74)
-function scanTcStrings(bytes) {
-    var out = [];
-    var i = 0;
-    while (i < bytes.length) {
-        var m = bytes[i];
-        if (m === 0x74) {                       // TC_STRING
-            if (i + 2 >= bytes.length) break;
-            var len = (bytes[i + 1] << 8) | bytes[i + 2];
-            if (i + 3 + len > bytes.length) { i++; continue; }
-            out.push(utf8Decode(bytes, i + 3, len));
-            i += 3 + len;
-        } else if (m === 0x77) {                // TC_BLOCKDATA + 1B len
-            i += 2 + bytes[i + 1];
-        } else if (m === 0x7a) {                // TC_BLOCKDATALONG + 4B len
-            i += 5 + ((bytes[i + 1] << 24) | (bytes[i + 2] << 16) | (bytes[i + 3] << 8) | bytes[i + 4]);
-        } else {                                // TC_NULL / TC_ENDBLOCKDATA / 其它
-            i++;
-        }
-    }
-    return out;
-}
-
-/**
- * 解析 ttnetCookieStore.xml
- * 形如: <string name="http://tiktok.com/|d_ticket">ACED0005…</string>
- * 值解码后前两个 TC_STRING 就是 [cookie名, cookie值]。
- */
-function parseTtnetXml(xmlText) {
-    var map = {};
-    var re = /<string name="([^"]+)">([0-9A-Fa-f]+)<\/string>/g;
-    var m, n = 0;
-    while ((m = re.exec(xmlText)) !== null) {
-        var key = m[1], hex = m[2];
-        var hostKey = key.split("|")[0];
-        var strs;
-        try { strs = scanTcStrings(hexToBytes(hex)); } catch (e) { continue; }
-        if (strs.length < 2) continue;
-        var name = strs[0], value = strs[1];
-        if (!name || !value || value.length < 1) continue;
-        // 同名取更长的一个
-        if (!map[name] || value.length > map[name].value.length) {
-            map[name] = { value: value, domain: hostKey };
-        }
-        n++;
-    }
-    console.log("[ttnet] 解析条目 " + n + " 个,去重后 Cookie 名 " + countKeys(map) + " 个");
-    return map;
-}
-
-function countKeys(o) { var c = 0; for (var k in o) if (o.hasOwnProperty(k)) c++; return c; }
-
-/**
- * 解析 WebView 的 Cookies SQLite(name / value 分列)
- * 用 Android 内置 SQLiteDatabase 直接查(Auto.js 是 Java 环境,可直接调用)
- */
-function parseWebviewCookies(dbPath) {
-    var map = {};
-    var db = null, cur = null;
-    try {
-        var SQLiteDatabase = android.database.sqlite.SQLiteDatabase;
-        db = SQLiteDatabase.openDatabase(dbPath, null, 0); // 0 = OPEN_READWRITE
-        cur = db.rawQuery("SELECT name, value, host_key, length(value) FROM cookies", null);
-        var n = 0, total = 0;
-        while (cur.moveToNext()) {
-            total++;
-            var name = cur.getString(0);
-            var value = cur.getString(1);
-            if (!name || value === null || value === "") continue;   // 跳过无值行
-            var host = cur.getString(2) || "";
-            if (!map[name] || value.length > map[name].value.length) {
-                map[name] = { value: value, domain: host };
-            }
-            n++;
-        }
-        console.log("[sqlite] 读到 " + total + " 行,有效 Cookie 名 " + countKeys(map) + " 个");
-    } catch (e) {
-        console.log("[sqlite] 解析失败(不影响主流程): " + e);
-    } finally {
-        try { if (cur) cur.close(); } catch (e2) {}
-        try { if (db) db.close(); } catch (e3) {}
-    }
-    return map;
-}
-
-// ============================== 合并 & 输出 =================================
-function mergeInto(dst, src) {
-    for (var k in src) {
-        if (!src.hasOwnProperty(k)) continue;
-        if (!dst[k] || src[k].value.length > dst[k].value.length) dst[k] = src[k];
-    }
-    return dst;
-}
-
-function buildCookieString(map) {
-    var parts = [], used = {};
-    var i, k;
-    for (i = 0; i < PRIORITY_KEYS.length; i++) {
-        k = PRIORITY_KEYS[i];
-        if (map[k]) { parts.push(k + "=" + map[k].value); used[k] = true; }
-    }
-    // 其余按字母序追加
-    var rest = [];
-    for (k in map) if (map.hasOwnProperty(k) && !used[k]) rest.push(k);
-    rest.sort();
-    for (i = 0; i < rest.length; i++) parts.push(rest[i] + "=" + map[rest[i]].value);
-    return parts.join("; ");
-}
-
-function mask(v) {
-    if (!v) return "";
-    if (v.length <= 12) return v;
-    return v.substring(0, 8) + "..." + v.substring(v.length - 4) + " (len=" + v.length + ")";
-}
-
-// ============================== 登录态有效性实测 =============================
-function readBody(conn) {
-    try {
-        var code = conn.getResponseCode();
-        var stream = (code >= 400) ? conn.getErrorStream() : conn.getInputStream();
-        if (!stream) { conn.disconnect(); return ""; }
-        var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, "UTF-8"));
-        var sb = new java.lang.StringBuilder();
-        var line;
-        while ((line = reader.readLine()) !== null) sb.append(line);
-        try { reader.close(); } catch (e1) {}
-        conn.disconnect();
-        return sb.toString();
-    } catch (e) {
-        try { conn.disconnect(); } catch (e2) {}
-        return "";
-    }
-}
-
-function verifyCookie(cookieStr) {
-    if (!CONFIG.verify || !cookieStr) return;
-    console.log("[verify] 用登录态实测官方评论接口 ...");
-    try {
-        var url = "https://www.tiktok.com/api/comment/list/?aid=1988&app_name=tiktok_web" +
-                  "&device_platform=web_pc&aweme_id=" + CONFIG.testAwemeId +
-                  "&cursor=0&count=20&item_type=0&from_page=video";
-        var conn = new java.net.URL(url).openConnection();
-        conn.setConnectTimeout(10000);
-        conn.setReadTimeout(10000);
-        conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
-        conn.setRequestProperty("Accept", "application/json, text/plain, */*");
-        conn.setRequestProperty("Referer", "https://www.tiktok.com/");
-        conn.setRequestProperty("Cookie", cookieStr);
-        var code = conn.getResponseCode();
-        var body = readBody(conn);
-        if (!body || body.replace(/\s/g, "") === "") {
-            console.log("[verify] HTTP " + code + " 响应为空 —— 官方接口需 A-Bogus 签名,与登录态无关(预期内)");
-            console.log("[verify] 评论请走 tiktok_stats.js 的镜像接口(已实测可用)");
-            return;
-        }
-        var obj = null;
-        try { obj = JSON.parse(body); } catch (e) {}
-        if (obj && obj.comments && obj.comments.length > 0) {
-            console.log("[verify] ✓ 拿到 " + obj.comments.length + " 条评论,Cookie 有效");
-        } else if (obj) {
-            console.log("[verify] 返回 status_code=" + obj.status_code + "(官方接口被签名风控拦截,属预期)");
-        }
-    } catch (e) {
-        console.log("[verify] 请求异常: " + e);
-    }
-}
-
-// ============================== 主流程 ======================================
-function main() {
-    try { console.show(); } catch (e) {}
-    console.log("==== TikTok 登录态提取(root)开始 ====");
-
-    // --- 1. root 校验 ---
-    var idOut = shTrim("id");
-    if (idOut.indexOf("uid=0") === -1) {
-        console.log("✗ 未获取 root(id 输出: " + idOut + ")");
-        console.log("  请在 Auto.js 中授予 root 授权后重试。");
-        return;
-    }
-    console.log("[root] ✓ " + idOut);
-
-    // --- 2. 定位数据目录(宿主视图路径)---
-    var PKG = CONFIG.pkg;
-    var dataDir = pmDataDir(PKG);
-    if (!dataDir) {
-        var cands = ["/data/user/0/" + PKG, "/data/data/" + PKG, "/data/user_de/0/" + PKG];
-        for (var c = 0; c < cands.length; c++) {
-            if (shTrim("test -e " + cands[c] + " && echo YES || echo NO").indexOf("YES") !== -1 ||
-                shTrim("test -e /proc/1/root" + cands[c] + " && echo YES || echo NO").indexOf("YES") !== -1) {
-                dataDir = cands[c];
-                break;
-            }
-        }
-    }
-    if (!dataDir) { console.log("✗ 未能定位 TikTok 数据目录(App 是否已安装?)"); return; }
-    console.log("[dir] 宿主路径 = " + dataDir);
-
-    // --- 3. 探测命名空间穿透策略(云机必须穿透 mount ns 才能看到 TikTok 目录)---
-    var ns = probeNs(dataDir);
-    if (!ns) {
-        console.log("✗ 所有命名空间策略都读不到该目录。");
-        console.log("  诊断: ls -ld " + dataDir + " -> " + shTrim("ls -ld " + dataDir + " 2>&1"));
-        console.log("        ls /data/data -> " + sh("ls /data/data 2>&1").replace(/\n/g, " | ").replace(/^\s+|\s+$/g, ""));
-        console.log("  若 ls /data/data 只列出很少的包,说明本机有 mount namespace 隔离。");
-        console.log("  请把以上输出发我进一步定位。");
-        return;
-    }
-    console.log("[ns] 采用: " + ns.label);
-
-    // --- 4. 用 root shell 把 Cookie 存储复制到 /sdcard ---
-    //     (files.* / java.io.* 在 App 进程 uid 下运行,读不了 /proc/1/root,必须先 cp 出来)
-    sh("mkdir -p " + CONFIG.workDir);
-    sh("mkdir -p " + CONFIG.outFile.substring(0, CONFIG.outFile.lastIndexOf("/")));
-
-    function stage(relPath, dstName) {
-        var src = dataDir + "/" + relPath;
-        var dst = CONFIG.workDir + "/" + dstName;
-        sh(nsCmd("cp " + nsPath(src) + " '" + dst + "' 2>/dev/null; chmod 666 '" + dst + "' 2>/dev/null"));
-        var ok = shTrim("test -s '" + dst + "' && echo YES || echo NO").indexOf("YES") !== -1;
-        console.log("[stage] " + relPath + " -> " + (ok ? "✓ " + dst : "✗ 不存在/复制失败"));
-        return ok ? dst : "";
-    }
-
-    var xmlPath = stage("shared_prefs/ttnetCookieStore.xml", "ttnetCookieStore.xml");
-    var dbPath = stage("app_webview/Default/Cookies", "Cookies");
-    // WAL/SHM 一并带上,避免读到过期快照
-    stage("app_webview/Default/Cookies-wal", "Cookies-wal");
-    stage("app_webview/Default/Cookies-shm", "Cookies-shm");
-
-    if (!xmlPath && !dbPath) {
-        console.log("✗ 两个 Cookie 存储都拿不到。请确认 TikTok 已登录并至少打开过一次。");
-        return;
-    }
-
-    // --- 5. 解析 ---
-    var map = {};
-    if (xmlPath) {
-        try {
-            mergeInto(map, parseTtnetXml(String(files.read(xmlPath))));
-        } catch (e) {
-            console.log("[ttnet] 读取/解析失败: " + e);
-        }
-    }
-    if (dbPath) mergeInto(map, parseWebviewCookies(dbPath));
-
-    // --- 6. 输出 ---
-    var cookieStr = buildCookieString(map);
-    console.log("==== 提取结果 ====");
-    var keys = [];
-    for (var k in map) if (map.hasOwnProperty(k)) keys.push(k);
-    keys.sort();
-    for (var i = 0; i < keys.length; i++) {
-        console.log("  " + keys[i] + " = " + mask(map[keys[i]].value));
-    }
-
-    var essential = ["sessionid", "sid_tt", "uid_tt", "msToken"];
-    var miss = [];
-    for (var e = 0; e < essential.length; e++) if (!map[essential[e]]) miss.push(essential[e]);
-
-    if (keys.length === 0) {
-        console.log("✗ 未提取到任何 Cookie。请确认 TikTok 已登录。");
-        return;
-    }
-    if (miss.length > 0) {
-        console.log("⚠ 缺少关键项: " + miss.join(", ") + " —— 可能未登录或登录态已过期");
-    } else {
-        console.log("✓ 关键登录态齐全 (sessionid / sid_tt / uid_tt / msToken)");
-    }
-
-    try {
-        files.write(CONFIG.outFile, cookieStr);
-        console.log("[save] ✓ 已写入: " + CONFIG.outFile + "  (" + cookieStr.length + " 字符)");
-        console.log("[save] tiktok_stats.js 会通过 CONFIG.cookieFile 自动读取");
-    } catch (e3) {
-        console.log("[save] 写入失败: " + e3);
-    }
-
-    verifyCookie(cookieStr);
-    console.log("==== 完成 ====");
-}
-
-main();

+ 0 - 243
tiktok_extract_cookie_pc.js

@@ -1,243 +0,0 @@
-/**
- * ============================================================================
- *  TikTok 登录态提取(PC 侧 / Node.js + adb)
- *
- *  用途:跟 Auto.js 版(tiktok_extract_cookie.js)等价的 PC 备用通道。
- *        在设备有 mount namespace 隔离、且 Auto.js 侧不便运行时,从 PC 侧
- *        `adb shell su 0` 提取(adb shell 是宿主完整 root 视图)。
- *
- *  Cookie 来源(双源互相校验):
- *    1) <dataDir>/shared_prefs/ttnetCookieStore.xml
- *       值 = Java 序列化的 hex(ACED0005…),不是明文!
- *       字节流结构:[TC_STRING 名称][TC_STRING 值][TC_STRING 域名][TC_STRING 路径]
- *       → 取第 2 个 TC_STRING(0x74) 即 cookie 值。
- *    2) <dataDir>/app_webview/Default/Cookies (SQLite,name/value 分列)
- *       → 用 node:sqlite 读(Node 22 内置,可能需要 --experimental-sqlite)
- *
- *  【运行】
- *    node tiktok_extract_cookie_pc.js
- *    前置:adb 已 connect 设备、设备已 root、TikTok 已登录。
- *    产出:/sdcard/Download/tiktok_data/tiktok_cookie.txt(tiktok_stats.js 自动读取)
- * ============================================================================
- */
-
-const { spawnSync } = require("child_process");
-const fs = require("fs");
-const path = require("path");
-
-// ============================== 配置 ========================================
-const CFG = {
-    pkg: "com.zhiliaoapp.musically",
-    adbSerial: "",                        // 多设备时填,如 "106.119.179.124:23098";单设备留空
-    remoteDir: "/sdcard/Download/tiktok_data",
-    tmpDir: path.join(__dirname, "_extract_tmp"),
-    remoteTmp: "/sdcard/Download/_ttnet_dump",
-    cookieFileRemote: "/sdcard/Download/tiktok_data/tiktok_cookie.txt",
-    // 最终 Cookie 串的 key 顺序(优先级从高到低)
-    priorityKeys: [
-        "sessionid", "sessionid_ss", "sid_tt", "uid_tt", "uid_tt_ss", "sid_guard",
-        "msToken", "odin_tt", "passport_csrf_token", "passport_csrf_token_default",
-        "multi_sids", "tt-target-idc", "store-idc", "ttwid", "s_v_web_id",
-        "cmpl_token", "d_ticket", "install_id", "ttreq"
-    ]
-};
-
-// ============================== adb 封装 ====================================
-function adb(parts) {
-    const s = CFG.adbSerial ? ["-s", CFG.adbSerial] : [];
-    const argv = ["adb"].concat(s, parts);
-    const r = spawnSync(argv[0], argv.slice(1), {
-        encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024
-    });
-    const out = (r.stdout || "") + (r.stderr || "");
-    if (r.error && !out) throw r.error;
-    return out;
-}
-function adbShell(cmdStr) { return adb(["shell", cmdStr]); }
-// 用 su 0 执行(本设备 su 不支持 -c,必须 su 0 sh -c '...')
-function su(cmdStr) { return adbShell("su 0 sh -c '" + cmdStr.replace(/'/g, "'\\''") + "'"); }
-
-function mask(v) {
-    if (!v) return "";
-    return v.length <= 14 ? v : v.slice(0, 10) + "..." + v.slice(-4) + " (len=" + v.length + ")";
-}
-
-// ============================== Java 序列化 hex 解析 =========================
-// 顺序扫描字节流,抽取全部 TC_STRING(0x74)
-function scanTcStrings(buf) {
-    const out = [];
-    let i = 0;
-    while (i < buf.length) {
-        const m = buf[i];
-        if (m === 0x74) {                                   // TC_STRING
-            if (i + 2 >= buf.length) break;
-            const len = buf.readUInt16BE(i + 1);
-            if (i + 3 + len > buf.length) { i++; continue; }
-            out.push(buf.slice(i + 3, i + 3 + len).toString("utf8"));
-            i += 3 + len;
-        } else if (m === 0x77) {                            // TC_BLOCKDATA + 1B len
-            i += 2 + buf[i + 1];
-        } else if (m === 0x7a) {                            // TC_BLOCKDATALONG + 4B len
-            i += 5 + buf.readUInt32BE(i + 1);
-        } else i++;                                         // TC_NULL/ENDBLOCKDATA/…
-    }
-    return out;
-}
-
-function parseTtnetXml(xml) {
-    const map = {};
-    const re = /<string name="([^"]+)">([0-9A-Fa-f]+)<\/string>/g;
-    let m, n = 0;
-    while ((m = re.exec(xml)) !== null) {
-        const key = m[1], hex = m[2];
-        const host = key.split("|")[0];
-        let strs;
-        try { strs = scanTcStrings(Buffer.from(hex, "hex")); } catch (e) { continue; }
-        if (strs.length < 2) continue;
-        const name = strs[0], value = strs[1];
-        if (!name || !value) continue;
-        if (!map[name] || value.length > map[name].value.length) map[name] = { value, domain: host };
-        n++;
-    }
-    console.log("[xml] 解析条目 " + n + " 个,去重后 Cookie 名 " + Object.keys(map).length + " 个");
-    return map;
-}
-
-// 读 WebView 的 Cookies SQLite(可选,失败不影响)
-function parseWebviewCookies(dbPath) {
-    const map = {};
-    let sqlite;
-    try { sqlite = require("node:sqlite"); }
-    catch (e) {
-        console.log("[sqlite] node:sqlite 不可用(Node 22 需加 --experimental-sqlite),跳过");
-        return map;
-    }
-    try {
-        const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
-        const rows = db.prepare("SELECT name, value, host_key FROM cookies").all();
-        let n = 0;
-        for (const r of rows) {
-            if (!r.name || !r.value) continue;
-            if (!map[r.name] || r.value.length > map[r.name].value.length) {
-                map[r.name] = { value: r.value, domain: r.host_key || "" };
-            }
-            n++;
-        }
-        db.close();
-        console.log("[sqlite] 读到 " + rows.length + " 行,有效 Cookie 名 " + Object.keys(map).length + " 个");
-    } catch (e) {
-        console.log("[sqlite] 解析失败(不影响主流程): " + e.message);
-    }
-    return map;
-}
-
-function mergeInto(dst, src) {
-    for (const k of Object.keys(src)) {
-        if (!dst[k] || src[k].value.length > dst[k].value.length) dst[k] = src[k];
-    }
-    return dst;
-}
-
-function buildCookieString(map) {
-    const parts = [], used = new Set();
-    for (const k of CFG.priorityKeys) {
-        if (map[k]) { parts.push(k + "=" + map[k].value); used.add(k); }
-    }
-    for (const k of Object.keys(map).sort()) {
-        if (!used.has(k)) parts.push(k + "=" + map[k].value);
-    }
-    return parts.join("; ");
-}
-
-// ============================== 主流程 ======================================
-function main() {
-    console.log("==== TikTok 登录态提取(PC/adb) ====\n");
-
-    // 0) 设备检查
-    const devs = adb(["devices"]).trim().split("\n").slice(1)
-        .filter(l => l.trim() && !l.startsWith("*")).map(l => l.split("\t")[0]);
-    console.log("[adb] 在线设备: " + (devs.join(", ") || "(无)"));
-    if (devs.length === 0) { console.log("✗ 无在线设备,请先 adb connect"); process.exit(1); }
-    if (CFG.adbSerial && !devs.includes(CFG.adbSerial)) {
-        console.log("✗ 指定的设备 " + CFG.adbSerial + " 不在线"); process.exit(1);
-    }
-
-    // 1) 定位数据目录(pm dump 最权威),并确认可读
-    let dataDir = "";
-    const dump = su("pm dump " + CFG.pkg + " 2>&1 | grep -i -m1 datadir");
-    const dm = dump.match(/dataDir=([^\s,]+)/);
-    if (dm) dataDir = dm[1];
-    if (!dataDir) {
-        for (const c of ["/data/user/0/" + CFG.pkg, "/data/data/" + CFG.pkg]) {
-            if (su("test -d " + c + " && echo YES || echo NO").includes("YES")) { dataDir = c; break; }
-        }
-    }
-    if (!dataDir) { console.log("✗ 未能定位数据目录(未 root / TikTok 未安装?)"); process.exit(1); }
-    console.log("[dir] " + dataDir + " -> " +
-        (su("test -d " + dataDir + "/shared_prefs && echo YES || echo NO").includes("YES") ? "可读 ✓" : "不可读 ✗"));
-
-    fs.mkdirSync(CFG.tmpDir, { recursive: true });
-    adbShell("mkdir -p " + CFG.remoteDir);
-
-    // 2) 把两个 Cookie 存储拷到 /sdcard 再 pull
-    const stage = [
-        ["shared_prefs/ttnetCookieStore.xml", "ttnetCookieStore.xml"],
-        ["app_webview/Default/Cookies", "Cookies"],
-        ["app_webview/Default/Cookies-wal", "Cookies-wal"],
-        ["app_webview/Default/Cookies-shm", "Cookies-shm"]
-    ];
-    const staged = {};
-    for (const [rel, name] of stage) {
-        const remote = CFG.remoteTmp + "_" + name;
-        su("cp '" + dataDir + "/" + rel + "' '" + remote + "' 2>/dev/null; chmod 644 '" + remote + "' 2>/dev/null");
-        const local = path.join(CFG.tmpDir, name);
-        adb(["pull", remote, local]);
-        const ok = fs.existsSync(local) && fs.statSync(local).size > 0;
-        console.log("[stage] " + rel + " -> " + (ok ? "✓ " + fs.statSync(local).size + " 字节" : "✗ 缺失"));
-        if (ok) staged[name] = local;
-    }
-    if (!staged["ttnetCookieStore.xml"] && !staged["Cookies"]) {
-        console.log("✗ 两个 Cookie 存储都拿不到"); process.exit(1);
-    }
-
-    // 3) 解析(双源合并)
-    let map = {};
-    if (staged["ttnetCookieStore.xml"]) {
-        map = mergeInto(map, parseTtnetXml(fs.readFileSync(staged["ttnetCookieStore.xml"], "utf8")));
-    }
-    if (staged["Cookies"]) {
-        map = mergeInto(map, parseWebviewCookies(staged["Cookies"]));
-    }
-
-    const keys = Object.keys(map).sort();
-    if (keys.length === 0) { console.log("✗ 未解析到任何 Cookie,TikTok 是否已登录?"); process.exit(1); }
-
-    const essential = ["sessionid", "sid_tt", "uid_tt", "msToken"];
-    const missing = essential.filter(k => !map[k]);
-    console.log("\n---- Cookie(脱敏)----");
-    for (const k of keys) {
-        const tag = CFG.priorityKeys.includes(k) ? "★" : " ";
-        console.log("  " + tag + " " + k.padEnd(34) + "= " + mask(map[k].value));
-    }
-    console.log("\n" +
-        (missing.length ? "⚠ 缺少关键项: " + missing.join(", ")
-                        : "✓ 关键登录态齐全 (sessionid / sid_tt / uid_tt / msToken)"));
-
-    // 4) 写本地 + push 回设备
-    const cookieStr = buildCookieString(map);
-    const localCookie = path.join(CFG.tmpDir, "tiktok_cookie.txt");
-    fs.writeFileSync(localCookie, cookieStr);
-    console.log("\n[cookie] 串长度 " + cookieStr.length + ",段数 " + cookieStr.split("; ").length);
-
-    const pushOut = adb(["push", localCookie, CFG.cookieFileRemote]);
-    console.log("[push] " + pushOut.trim().split("\n")[0]);
-    const back = adb(["shell", "cat " + CFG.cookieFileRemote]).trim();
-    console.log("[verify] 设备端长度 " + back.length + (back === cookieStr ? " ✓ 与本地一致" : " ✗ 不一致"));
-
-    console.log("\n==== 完成 ====");
-    console.log("下一步:运行 tiktok_stats.js(会自动读取 " + CFG.cookieFileRemote + ")");
-    console.log("注:官方评论接口已被 A-Bogus 签名拦截,登录态对评论无帮助;");
-    console.log("    评论数据由 tiktok_stats.js 走镜像接口获取。");
-}
-
-main();