Bläddra i källkod

私信脚本二(自己粉丝私信):从自己的粉丝列表取材

## 做了什么

`task = "dm"` 现在支持两个脚本,差别只在**人从哪儿来**:

  s1 已提 UID 私信 → 读 UID 文本(原有)
  s2 自己粉丝私信 → 打开自己的主页 → 粉丝列表取材(本次)

两者共用后半段编排:前 followMax 个只关注、接着 sendMax 个只发私信(两批不重叠),
每个之后休 rest 秒。s2 要采 `followMax + sendMax` 个人,采够就停。

## 新增能力

- `tiktok.profileNav()`:底部导航的 Profile 项。只认 `desc === 'Profile'` + 中心在屏幕下缘,
  不限定 class(它是 FrameLayout,限定 Button 一个都找不到)
- `profile.openOwnProfile(config)`:先 `ensureFeed` 再点 Profile。**必须先回信息流** ——
  底部导航只在首页有,停在搜索页/播放页时直接找必然失败
- `dmflow.collectOwnFans(config, need, summary)`:开自己主页 → fans 列表 → `walkList` 收集,
  停止判据挂在 `ctx.shouldStop()`(够了就自然结束,不抛异常,已采到的不丢)

## 四个真机坑(三个是新踩的)

**① 点完 Profile 1 秒就"就绪",fans 入口却找不到**
username 比数字区早得多,而 fans 入口挂在数字上。`settle()` 挡不住(判据是"文案连续两次不变",
加载中与渲染完的签名可能一样)。改用 `profile.waitProfile()`:判据是 username + 关注/粉丝/获赞
三组都读到,读不到只警告不失败。另加"openList 失败 2 秒后重试一次"。

**② 发送键依赖键盘弹起,而 typeInto 的点击有时被输入法吞掉**
文字进去了、键盘没起 → 输入框不缩短 → 那块 72×72 的 ImageView 不存在 →
「输入后找不到发送键」,时好时坏。新增 `dm.ensureSendButton()`:先等 1.5 秒,
拿不到就按输入框中心再点一次强制聚焦,再等 4 秒。

**③ 私信页的脚本卡没有单选互斥**
`W.scriptHeader` 只负责画和报状态,互斥是页面的责任,而私信页漏了 ——
两个脚本能同时勾上,`buildDmConfig` 只能挑一个跑:用户以为跑「自己粉丝私信」,
实际跑「已提 UID 私信」,拿一份可能过期的 UID 文件发出去,**界面上看不出任何异常**。
新增 `Pages.toggleDmScript()`,并在 buildDmConfig 里"两个都勾着"时给一条 note。

**④ 休息时间迁移二次失效(v11 补执行)**
9/10 号迁移用 `restMin === 100` 判断,而设备上存的是字符串 `"100"` → 匹配不上 →
迁移"跑了但没改",版本却被抬到 10,**从此永久卡在 100~500**。
症状是「界面写 100~500、引擎按 10~40 跑」。SCHEMA 抬到 11,用 `Number()` 比较补执行一次
(只认那一对精确旧默认值,用户自己调过的不动)。

## 一个把之前的结论纠正过来的发现

我上次把"推了新版却没生效"归因为"读了 15 小时前的旧 engine-config"—— **那个结论是错的**。
真因是:**AutoX 会按入口脚本路径复用旧代码**。md5 完全一致、时间戳也是新的,
但界面还是旧行为(只渲染一张脚本卡、休息时间仍是 100~500);
把入口 `cp main.js main2.js` 再启动,立刻变成新版。

顺带:设备时区是 PDT,`ls -la` 的 mtime 与主机 `date` 差 15 小时 ——
拿主机时间比设备 mtime 必然得出"这是旧文件"的错误结论。AI_CONTEXT 的 D18 已改写。

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

```
直起引擎(script=s2 / send.max=1 / dryRun)
  → 生效配置(私信):{"script":"s2","source":"自己的粉丝列表",...}
  → 点 Profile FrameLayout(972,1871) → 三组数字 3.6s 读全
  → openList fans(1.0s)→ 取材 1/1 个(翻 1 屏)
  → 面板 1.8s 打开 → 写入 "hello" → 发送键 Rect(968,1248,1040,1320)
  → 「完成:关注 0 个 / 私信 0 条(演练 1 条,未真发)(取材自你自己的粉丝列表,1 人)」

界面点运行 → engine-config:task=dm / script=s2 / uidPath=''/ rest 10000-40000
  → 引擎按 s2 执行;因本机没有「随机私信文本」被 validate 拦下(一条都没发)
```

## 自检

引擎 25 模块 / pc-selftest 114 项;界面 smoke **490 项**(新增 4 条私信相关:
脚本二有引擎标记、script=s2 且 uidPath 为空、说明渲染、**单选互斥**);
v10/v9-字符串型存档的迁移断言 5 条;AUDIT_CLEAN;两侧 md5 与设备一致。

## 文档

- `tiktok-auto/README.md`:3.7 更新(脚本二已实现、迁移 v9/v11 的原委)+ 新增 3.75
- `docs/AI_CONTEXT.md`:D16 补"严格相等 + 版本已抬就再也不跑"、**D18 改写**、
  新增 D19(等三组数字)/ D20(单选互斥)/ D21(发送键依赖键盘)
梦境 4 dagar sedan
förälder
incheckning
7841847982

+ 42 - 7
docs/AI_CONTEXT.md

@@ -635,6 +635,11 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
   `MIGRATIONS[8]` → **静默跳过**,设备被标成 v9 而值还是旧的
 - 排查成本很高:迁移函数本身完全正确(PC 上单测通过),只是永远不会被调用
 - 补救:把 `SCHEMA_VERSION` 再抬一版,迁移键写 `9:`(v9→v10),让老设备补执行
+- ★ **还有第二层坑(v11 才真正修好)**:判断写成 `restMin === 100`,而设备上存的可能是
+  字符串 `"100"` → 匹配不上 → 迁移"跑了但没改",**版本却被抬上去**了,从此永久卡在旧值。
+  症状是「界面写 100~500、引擎按 10~40 跑」—— 两边不一致,比单纯显示旧默认更难查。
+  正解:条件用 `Number(...)` 比较,并且**抬版本时一律加一条补执行的迁移**
+  (只认那一对精确的旧默认值,用户自己调过的不动)
 
 **D17|`Store.load()` 迁移后不写回存储**
 
@@ -644,13 +649,43 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 - 修法:在 `Store.init()` 里 `load()` 之后补一次 `Store.save()`(此时 `state` 已设好,不会递归)
 - **`Store.load()` 里不能直接 save** —— 那时 `Store.state` 还没赋值,`save` 会回头调 `init` 死循环
 
-**D18|验证配置前必须看文件 mtime(★ 我在这上面浪费了三轮)**
-
-- 现象:反复"验证"迁移没生效,实际读的是 **15 小时前的旧 `engine-config.json`**
-- 根因:那几次「点运行」根本没触发(`input tap` 没落到按钮上),而我只 `rm` 了
-  `run-log.jsonl` / `run-state.json`,**没删 `engine-config.json`** → 读到上一轮的内容
-- 判据:先 `ls -la` 看 mtime 是否等于刚才,再看内容
-- 更稳的做法:**能在 PC 上验的逻辑(如配置映射)用 smoke 断言,不要靠真机点按钮**
+**D18|AutoX 会按「入口脚本路径」复用旧代码(★ 推了新版却没生效的真因)**
+
+- 现象:`adb push` 了新产物、**md5 与本地完全一致**,但界面行为/数值还是旧的
+  (实测:只渲染出一张脚本卡、私信休息时间仍显示 100~500)
+- 真因:入口还是 `/sdcard/Scripts/tiktok-demo/main.js` 这个**路径**,AutoX 复用了
+  上一次的脚本;把入口复制成 `main2.js` 再启动,**立刻**变成新行为
+- 判据/做法:确认"产物已更新但行为没变"时,**换一个入口文件名**启动一次
+  (`adb shell cp main.js main3.js` 再 `am start ... -d file:///.../main3.js`),
+  或者先在 AutoX 里把项目关掉重开
+- ⚠️ 顺带纠正一条早期结论:设备时区是 PDT,`ls -la` 的 mtime 与主机 `date` **差 15 小时**。
+  拿主机时间比设备 mtime 会得出"这是 15 小时前的旧文件"的错误结论(曾经就这么误判过)。
+  判断"文件是不是刚写的"要用 `adb shell date` 取设备时间,或直接比 `md5sum`
+
+**D19|脚本二取材:必须等主页三组数字读全,`settle()` 挡不住**
+
+- 现象:点底部 Profile 后 1 秒 `where() === 'profile'` 就通过,紧接着
+  `openList('fans')` 报「用户主页上找不到 fans 入口(等了 3 秒仍没有)」
+- 根因:username 比数字区早得多,而 fans 入口挂在数字上;`settle()` 的判据是
+  "文案连续两次不变",**加载中与渲染完的签名可能一样** → 提前返回
+- 正解:复用 `profile.waitProfile()`(判据是 username + 关注/粉丝/获赞**三组都读到**,
+  读不到只警告不失败),再 `openList`;`openList` 失败还给它一次 2 秒后的重试
+
+**D20|「脚本卡单选互斥」必须由页面做**
+
+- `W.scriptHeader` 只管画和报"点完该是什么状态",**互斥是页面的责任**(组件注释写明了)
+- 私信页漏了这一步:两个脚本可以同时勾上 → `buildDmConfig` 只能挑一个跑 →
+  用户以为跑的是「自己粉丝私信」,实际跑「已提 UID 私信」,拿一份可能过期的 UID 文件发出去,
+  **界面上看不出任何异常**
+- 正解:`toggleDmScript(key, next)` 勾一个就清另一个;并在 `buildDmConfig` 里
+  "两个都勾着"时给一条 note(不要静默挑一个)
+
+**D21|私信发送键依赖「键盘弹起」,拿不到就再点一次输入框**
+
+- 发送键是"输入框缩短后空出来的那块 72×72 ImageView",只在键盘弹起时存在
+- 而 `ui.typeInto` 的那次点击**有时被输入法吞掉**(文字进去了、键盘没起),
+  于是发送键不存在 → 报「输入后找不到发送键」,**时好时坏**
+- 正解:`ensureSendButton()` 先等 1.5 秒;拿不到就按输入框中心再点一次强制聚焦,再等 4 秒
 
 ### 6.E 数据与配置
 

+ 98 - 16
tiktok-app/dist/tiktok-app.js

@@ -1,6 +1,6 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-29T10:22:57.726Z
+ *  built 2026-10-29T10:43:51.193Z
  *  modules: theme, store, records, sources, device, service, fingerprint, engine, session, resume, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -450,8 +450,12 @@ var Store = {};
  *
  * v7(同日早些时候)是"清语义"那一档:删同义反复的开关、补缺输入的开关、
  * 功能设置分三组。那一档的成果(36 个字段、三组结构、置灰方式)v8 全部继承。
+ *
+ * v11(2026-10-29):补执行「私信休息时间 100~500 → 10~40」。
+ * 9 号迁移用严格相等判断,碰上字符串型的旧值会静默放过、版本却被抬高,
+ * 从此永久卡在旧默认。这次用 Number() 比较再补一次。
  */
-Store.SCHEMA_VERSION = 10;
+Store.SCHEMA_VERSION = 11;
 Store.STORAGE_NAME = "feizhou_app";
 Store.STORAGE_KEY = "config";
 
@@ -956,14 +960,36 @@ Store.MIGRATIONS = {
          *
          * 只改**还停在旧默认值**的配置;用户手动调过的保持原样。
          * 全新安装(src 为空)读到的是 undefined,条件不成立,保留新默认。 */
-        var restMin = readPath(src, "tabs.dm.restMin");
-        var restMax = readPath(src, "tabs.dm.restMax");
+        var restMin = Number(readPath(src, "tabs.dm.restMin"));
+        var restMax = Number(readPath(src, "tabs.dm.restMax"));
         if (restMin === 100 && restMax === 500) {
             assignPath(cfg, "tabs.dm.restMin", 10);
             assignPath(cfg, "tabs.dm.restMax", 40);
             problems.push("私信「休息时间」默认值由 100~500 秒改为 10~40 秒" +
                           "(原值会让一轮跑 5~24 小时,已跟随新默认)");
         }
+    },
+
+    10: function (cfg, problems, src) {
+        /* v10 → v11:**同一条修复的补执行**(上面的 9 号迁移在真机上漏了)。
+         *
+         * 为什么还要再来一次:那条迁移当时用 `restMin === 100` 做判断,
+         * 而设备上存的可能是字符串 `"100"`(不同写入路径留下的),严格相等匹配不上 →
+         * 迁移"跑了但什么也没改",版本却被抬到 10,于是**永久卡在 100~500**:
+         * 之后每次启动 fromVersion 都等于 SCHEMA,这条迁移再也不会执行。
+         * 现象是"界面上写着 100~500 秒,引擎实际按 10~40 跑"(引擎侧有一道读取兜底),
+         * 两边不一致 —— 这比单纯显示旧默认更难查。
+         *
+         * 这次用 Number() 比较,并且**只认这一对精确的旧默认值**:
+         * 用户自己调过的任何一组数都不会被覆盖。 */
+        var lo = Number(readPath(src, "tabs.dm.restMin"));
+        var hi = Number(readPath(src, "tabs.dm.restMax"));
+        if (lo === 100 && hi === 500) {
+            assignPath(cfg, "tabs.dm.restMin", 10);
+            assignPath(cfg, "tabs.dm.restMax", 40);
+            problems.push("私信「休息时间」仍是最初的 100~500 秒,已按 10~40 秒修正" +
+                          "(原值一轮要跑 5~24 小时)");
+        }
     }
 };
 
@@ -2960,11 +2986,15 @@ Engine.buildConfig = function (task, opts) {
 Engine.buildDmConfig = function () {
     var notes = [];
 
-    /* 脚本二(自己粉丝私信)还没实现。勾了它要如实说,
-     * 而不是静默按脚本一跑 —— 那会拿"自己的粉丝"当"已提 UID"处理,结果完全不对。 */
-    var useS2 = Store.getBool("tabs.dm.scripts.s2.checked");
-    if (useS2) {
-        notes.push("脚本二「自己粉丝私信」:引擎尚未实现,本轮按脚本一(已提 UID 文本)执行");
+    /* 两个脚本是单选(页面侧 `toggleDmScript` 保证互斥)。万一都勾着(手改过配置、
+     * 或将来某次改动破坏了互斥),按脚本一处理 —— 那是"有明确输入"的那条,
+     * 比静默换源安全;但**必须说出来**,否则用户以为跑的是脚本二。 */
+    var s1On = Store.getBool("tabs.dm.scripts.s1.checked");
+    var s2On = Store.getBool("tabs.dm.scripts.s2.checked");
+    var useS2 = s2On && !s1On;
+    var script = useS2 ? "s2" : "s1";
+    if (s1On && s2On) {
+        notes.push("两个私信脚本都勾着,本轮按脚本一执行(这一页是单选)");
     }
 
     var followOn = Store.getBool("tabs.dm.doFollow");
@@ -2990,11 +3020,18 @@ Engine.buildDmConfig = function () {
         notes.push("私信文案文件是空的,本轮发不出私信(请检查「随机私信文本」)");
     }
 
+    if (useS2) {
+        notes.push("脚本二「自己粉丝私信」:从你自己的粉丝列表取材(先回关 " +
+                   "「关注数量」个,再给「私信数量」个发私信)");
+    }
+
     var config = {
         task: "dm",
         dm: {
-            script: "s1",
-            uidPath: Store.getText("tabs.dm.scripts.s1.uidTextPath"),
+            script: script,
+            /* 脚本二不用这个路径(人从粉丝列表采);传空串而不是留着旧路径 ——
+             * 留着会让日志里出现一个"看起来在用的文件",排障时误导。 */
+            uidPath: useS2 ? "" : Store.getText("tabs.dm.scripts.s1.uidTextPath"),
             /* 每次运行在区间里随机取一个数 */
             follow: { enabled: followOn, max: followOn ? randomInt(followLo, followHi) : 0 },
             send: { enabled: sendOn, max: sendOn ? randomInt(sendLo, sendHi) : 0 },
@@ -7782,14 +7819,31 @@ var Pages = Pages || {};
 
 Pages.DM_SCRIPTS = [
     { key: "s1", name: "脚本一 \u00B7 已提 UID 私信", engine: "tiktok-auto" },
-    /* ★ 脚本二**刻意不加 engine**:引擎还没实现"从自己粉丝列表取材"这条链路。
-     * 没有这个标记,点运行会被 `Dialog.engineMissing` 明确拦下 ——
-     * 这比让它静默按脚本一的逻辑跑(拿自己的粉丝当"已提 UID")好得多。 */
-    { key: "s2", name: "脚本二 \u00B7 自己粉丝私信" }
+    { key: "s2", name: "脚本二 \u00B7 自己粉丝私信", engine: "tiktok-auto" }
 ];
 
 Pages.registerScripts("dm", Pages.DM_SCRIPTS);
 
+/**
+ * 脚本二展开后的说明。
+ *
+ * 它**没有自己的配置项**:取材就发生在「自己的粉丝列表」里,
+ * 要多少人由共用参数决定(关注数量 + 私信数量)。所以这里只解释规则,
+ * 不放任何控件 —— 放一个"取材上限"之类的假选项,反而会让人以为能调。
+ */
+function buildDmScript2Detail() {
+    var detail = U.column();
+    var rows = [
+        W.hint("\u4ECE\u4F60\u81EA\u5DF1\u7684\u7C89\u4E1D\u5217\u8868\u53D6\u4EBA\uFF1A" +
+               "\u5148\u53D6\u300C\u5173\u6CE8\u6570\u91CF\u300D\u4E2A\u56DE\u5173\uFF0C" +
+               "\u518D\u53D6\u300C\u79C1\u4FE1\u6570\u91CF\u300D\u4E2A\u53D1\u79C1\u4FE1\u3002", false),
+        W.hint("\u4E24\u6279\u4E0D\u91CD\u53E0\uFF0C\u6240\u4EE5\u7C89\u4E1D\u6570\u8981\u4E0D\u5C11\u4E8E\u4E24\u8005\u4E4B\u548C\uFF1B" +
+               "\u4E0D\u591F\u65F6\u6309\u5B9E\u9645\u6570\u91CF\u6267\u884C\uFF0C\u5E76\u5728\u7ED3\u679C\u91CC\u8BF4\u660E\u3002", false)
+    ];
+    W.stack(detail, rows, 8);
+    return detail;
+}
+
 /** 脚本一展开后的四行:按什么条件筛、筛完做什么、从哪儿读 UID。 */
 function buildDmScript1Detail(rerender) {
     var base = "tabs.dm.scripts.s1.";
@@ -7823,6 +7877,31 @@ function buildDmScript1Detail(rerender) {
     return detail;
 }
 
+/**
+ * 脚本勾选:**单选**(同一时间只能跑一个)。
+ *
+ * `W.scriptHeader` 只负责画和报"点完该是什么状态",互斥必须由页面做
+ * (组件注释里写明了这一点)。漏了这一步的后果很具体:
+ * 两个脚本都勾着时,`buildDmConfig` 只能挑一个跑 ——
+ * 用户以为跑的是「自己粉丝私信」,实际跑的是「已提 UID 私信」,
+ * 拿一份可能过期的 UID 文件发出去,而且界面上看不出任何异常。
+ */
+function toggleDmScript(scriptKey, next) {
+    for (var i = 0; i < Pages.DM_SCRIPTS.length; i += 1) {
+        var key = Pages.DM_SCRIPTS[i].key;
+        var path = "tabs.dm.scripts." + key + ".checked";
+        if (key === scriptKey) {
+            Store.set(path, next);
+        } else if (next) {
+            /* 只在新勾上时才清别人:取消自己不该顺手把别人也取消掉 */
+            Store.set(path, false);
+        }
+    }
+}
+
+/* 暴露给自检:互斥是"数据层的不变量",不该只能靠点 UI 才能验 */
+Pages.toggleDmScript = toggleDmScript;
+
 function buildDmScriptCard(script, rerender) {
     var base = "tabs.dm.scripts." + script.key + ".";
     var checked = Store.getBool(base + "checked");
@@ -7836,7 +7915,7 @@ function buildDmScriptCard(script, rerender) {
         summary: null,
         disabled: Session.running,
         onToggleCheck: function (next) {
-            Store.set(base + "checked", next);
+            toggleDmScript(script.key, next);
             rerender();
         },
         onToggleExpand: function () {
@@ -7848,6 +7927,9 @@ function buildDmScriptCard(script, rerender) {
     if (expanded && script.key === "s1") {
         card.addView(U.spacer(12));
         card.addView(buildDmScript1Detail(rerender));
+    } else if (expanded && script.key === "s2") {
+        card.addView(U.spacer(12));
+        card.addView(buildDmScript2Detail());
     }
     return card;
 }

+ 18 - 7
tiktok-app/src/engine.js

@@ -225,11 +225,15 @@ Engine.buildConfig = function (task, opts) {
 Engine.buildDmConfig = function () {
     var notes = [];
 
-    /* 脚本二(自己粉丝私信)还没实现。勾了它要如实说,
-     * 而不是静默按脚本一跑 —— 那会拿"自己的粉丝"当"已提 UID"处理,结果完全不对。 */
-    var useS2 = Store.getBool("tabs.dm.scripts.s2.checked");
-    if (useS2) {
-        notes.push("脚本二「自己粉丝私信」:引擎尚未实现,本轮按脚本一(已提 UID 文本)执行");
+    /* 两个脚本是单选(页面侧 `toggleDmScript` 保证互斥)。万一都勾着(手改过配置、
+     * 或将来某次改动破坏了互斥),按脚本一处理 —— 那是"有明确输入"的那条,
+     * 比静默换源安全;但**必须说出来**,否则用户以为跑的是脚本二。 */
+    var s1On = Store.getBool("tabs.dm.scripts.s1.checked");
+    var s2On = Store.getBool("tabs.dm.scripts.s2.checked");
+    var useS2 = s2On && !s1On;
+    var script = useS2 ? "s2" : "s1";
+    if (s1On && s2On) {
+        notes.push("两个私信脚本都勾着,本轮按脚本一执行(这一页是单选)");
     }
 
     var followOn = Store.getBool("tabs.dm.doFollow");
@@ -255,11 +259,18 @@ Engine.buildDmConfig = function () {
         notes.push("私信文案文件是空的,本轮发不出私信(请检查「随机私信文本」)");
     }
 
+    if (useS2) {
+        notes.push("脚本二「自己粉丝私信」:从你自己的粉丝列表取材(先回关 " +
+                   "「关注数量」个,再给「私信数量」个发私信)");
+    }
+
     var config = {
         task: "dm",
         dm: {
-            script: "s1",
-            uidPath: Store.getText("tabs.dm.scripts.s1.uidTextPath"),
+            script: script,
+            /* 脚本二不用这个路径(人从粉丝列表采);传空串而不是留着旧路径 ——
+             * 留着会让日志里出现一个"看起来在用的文件",排障时误导。 */
+            uidPath: useS2 ? "" : Store.getText("tabs.dm.scripts.s1.uidTextPath"),
             /* 每次运行在区间里随机取一个数 */
             follow: { enabled: followOn, max: followOn ? randomInt(followLo, followHi) : 0 },
             send: { enabled: sendOn, max: sendOn ? randomInt(sendLo, sendHi) : 0 },

+ 50 - 5
tiktok-app/src/page-dm.js

@@ -14,14 +14,31 @@ var Pages = Pages || {};
 
 Pages.DM_SCRIPTS = [
     { key: "s1", name: "脚本一 \u00B7 已提 UID 私信", engine: "tiktok-auto" },
-    /* ★ 脚本二**刻意不加 engine**:引擎还没实现"从自己粉丝列表取材"这条链路。
-     * 没有这个标记,点运行会被 `Dialog.engineMissing` 明确拦下 ——
-     * 这比让它静默按脚本一的逻辑跑(拿自己的粉丝当"已提 UID")好得多。 */
-    { key: "s2", name: "脚本二 \u00B7 自己粉丝私信" }
+    { key: "s2", name: "脚本二 \u00B7 自己粉丝私信", engine: "tiktok-auto" }
 ];
 
 Pages.registerScripts("dm", Pages.DM_SCRIPTS);
 
+/**
+ * 脚本二展开后的说明。
+ *
+ * 它**没有自己的配置项**:取材就发生在「自己的粉丝列表」里,
+ * 要多少人由共用参数决定(关注数量 + 私信数量)。所以这里只解释规则,
+ * 不放任何控件 —— 放一个"取材上限"之类的假选项,反而会让人以为能调。
+ */
+function buildDmScript2Detail() {
+    var detail = U.column();
+    var rows = [
+        W.hint("\u4ECE\u4F60\u81EA\u5DF1\u7684\u7C89\u4E1D\u5217\u8868\u53D6\u4EBA\uFF1A" +
+               "\u5148\u53D6\u300C\u5173\u6CE8\u6570\u91CF\u300D\u4E2A\u56DE\u5173\uFF0C" +
+               "\u518D\u53D6\u300C\u79C1\u4FE1\u6570\u91CF\u300D\u4E2A\u53D1\u79C1\u4FE1\u3002", false),
+        W.hint("\u4E24\u6279\u4E0D\u91CD\u53E0\uFF0C\u6240\u4EE5\u7C89\u4E1D\u6570\u8981\u4E0D\u5C11\u4E8E\u4E24\u8005\u4E4B\u548C\uFF1B" +
+               "\u4E0D\u591F\u65F6\u6309\u5B9E\u9645\u6570\u91CF\u6267\u884C\uFF0C\u5E76\u5728\u7ED3\u679C\u91CC\u8BF4\u660E\u3002", false)
+    ];
+    W.stack(detail, rows, 8);
+    return detail;
+}
+
 /** 脚本一展开后的四行:按什么条件筛、筛完做什么、从哪儿读 UID。 */
 function buildDmScript1Detail(rerender) {
     var base = "tabs.dm.scripts.s1.";
@@ -55,6 +72,31 @@ function buildDmScript1Detail(rerender) {
     return detail;
 }
 
+/**
+ * 脚本勾选:**单选**(同一时间只能跑一个)。
+ *
+ * `W.scriptHeader` 只负责画和报"点完该是什么状态",互斥必须由页面做
+ * (组件注释里写明了这一点)。漏了这一步的后果很具体:
+ * 两个脚本都勾着时,`buildDmConfig` 只能挑一个跑 ——
+ * 用户以为跑的是「自己粉丝私信」,实际跑的是「已提 UID 私信」,
+ * 拿一份可能过期的 UID 文件发出去,而且界面上看不出任何异常。
+ */
+function toggleDmScript(scriptKey, next) {
+    for (var i = 0; i < Pages.DM_SCRIPTS.length; i += 1) {
+        var key = Pages.DM_SCRIPTS[i].key;
+        var path = "tabs.dm.scripts." + key + ".checked";
+        if (key === scriptKey) {
+            Store.set(path, next);
+        } else if (next) {
+            /* 只在新勾上时才清别人:取消自己不该顺手把别人也取消掉 */
+            Store.set(path, false);
+        }
+    }
+}
+
+/* 暴露给自检:互斥是"数据层的不变量",不该只能靠点 UI 才能验 */
+Pages.toggleDmScript = toggleDmScript;
+
 function buildDmScriptCard(script, rerender) {
     var base = "tabs.dm.scripts." + script.key + ".";
     var checked = Store.getBool(base + "checked");
@@ -68,7 +110,7 @@ function buildDmScriptCard(script, rerender) {
         summary: null,
         disabled: Session.running,
         onToggleCheck: function (next) {
-            Store.set(base + "checked", next);
+            toggleDmScript(script.key, next);
             rerender();
         },
         onToggleExpand: function () {
@@ -80,6 +122,9 @@ function buildDmScriptCard(script, rerender) {
     if (expanded && script.key === "s1") {
         card.addView(U.spacer(12));
         card.addView(buildDmScript1Detail(rerender));
+    } else if (expanded && script.key === "s2") {
+        card.addView(U.spacer(12));
+        card.addView(buildDmScript2Detail());
     }
     return card;
 }

+ 29 - 3
tiktok-app/src/store.js

@@ -26,8 +26,12 @@ var Store = {};
  *
  * v7(同日早些时候)是"清语义"那一档:删同义反复的开关、补缺输入的开关、
  * 功能设置分三组。那一档的成果(36 个字段、三组结构、置灰方式)v8 全部继承。
+ *
+ * v11(2026-10-29):补执行「私信休息时间 100~500 → 10~40」。
+ * 9 号迁移用严格相等判断,碰上字符串型的旧值会静默放过、版本却被抬高,
+ * 从此永久卡在旧默认。这次用 Number() 比较再补一次。
  */
-Store.SCHEMA_VERSION = 10;
+Store.SCHEMA_VERSION = 11;
 Store.STORAGE_NAME = "feizhou_app";
 Store.STORAGE_KEY = "config";
 
@@ -532,14 +536,36 @@ Store.MIGRATIONS = {
          *
          * 只改**还停在旧默认值**的配置;用户手动调过的保持原样。
          * 全新安装(src 为空)读到的是 undefined,条件不成立,保留新默认。 */
-        var restMin = readPath(src, "tabs.dm.restMin");
-        var restMax = readPath(src, "tabs.dm.restMax");
+        var restMin = Number(readPath(src, "tabs.dm.restMin"));
+        var restMax = Number(readPath(src, "tabs.dm.restMax"));
         if (restMin === 100 && restMax === 500) {
             assignPath(cfg, "tabs.dm.restMin", 10);
             assignPath(cfg, "tabs.dm.restMax", 40);
             problems.push("私信「休息时间」默认值由 100~500 秒改为 10~40 秒" +
                           "(原值会让一轮跑 5~24 小时,已跟随新默认)");
         }
+    },
+
+    10: function (cfg, problems, src) {
+        /* v10 → v11:**同一条修复的补执行**(上面的 9 号迁移在真机上漏了)。
+         *
+         * 为什么还要再来一次:那条迁移当时用 `restMin === 100` 做判断,
+         * 而设备上存的可能是字符串 `"100"`(不同写入路径留下的),严格相等匹配不上 →
+         * 迁移"跑了但什么也没改",版本却被抬到 10,于是**永久卡在 100~500**:
+         * 之后每次启动 fromVersion 都等于 SCHEMA,这条迁移再也不会执行。
+         * 现象是"界面上写着 100~500 秒,引擎实际按 10~40 跑"(引擎侧有一道读取兜底),
+         * 两边不一致 —— 这比单纯显示旧默认更难查。
+         *
+         * 这次用 Number() 比较,并且**只认这一对精确的旧默认值**:
+         * 用户自己调过的任何一组数都不会被覆盖。 */
+        var lo = Number(readPath(src, "tabs.dm.restMin"));
+        var hi = Number(readPath(src, "tabs.dm.restMax"));
+        if (lo === 100 && hi === 500) {
+            assignPath(cfg, "tabs.dm.restMin", 10);
+            assignPath(cfg, "tabs.dm.restMax", 40);
+            problems.push("私信「休息时间」仍是最初的 100~500 秒,已按 10~40 秒修正" +
+                          "(原值一轮要跑 5~24 小时)");
+        }
     }
 };
 

+ 73 - 12
tiktok-app/tools/smoke.js

@@ -1016,11 +1016,21 @@ api.Store.set('activeDetail', '');
 /* ------------------------------------- 11.6 UID私信(配置映射 / 两个脚本的区别) */
 console.log('[smoke] UID私信 · 配置映射 · 脚本二的拦截');
 
-/* 脚本一接入了引擎,脚本二**刻意没有** —— 这条区别必须守住:
- * 脚本二(自己粉丝私信)的取材链路还没实现,加上 engine 就会让它按脚本一的逻辑
- * 静默跑(拿自己的粉丝当"已提 UID"),结果完全不对。 */
+/* 两个脚本都接入了引擎,逐个校验 —— 漏一个标记就会被 `Dialog.engineMissing`
+ * 拦在运行前(那道闸是"绝不静默跑不相关的东西")。 */
 eq('私信脚本一有引擎标记', api.Pages.DM_SCRIPTS[0].engine, 'tiktok-auto');
-eq('私信脚本二没有引擎标记(取材链路未实现)', api.Pages.DM_SCRIPTS[1].engine, undefined);
+eq('私信脚本二有引擎标记(自己粉丝取材已实现)', api.Pages.DM_SCRIPTS[1].engine, 'tiktok-auto');
+
+/* ★ 单选互斥(这条是真机上发现的 bug):两个都勾着时 `buildDmConfig` 只能挑一个跑,
+ * 用户以为跑「自己粉丝私信」、实际跑「已提 UID 私信」,界面上完全看不出异常。 */
+api.Pages.toggleDmScript('s1', true);
+eq('私信脚本单选:勾脚本一 → 脚本二被取消', api.Store.getBool('tabs.dm.scripts.s2.checked'), false);
+api.Pages.toggleDmScript('s2', true);
+eq('私信脚本单选:勾脚本二 → 脚本一被取消', api.Store.getBool('tabs.dm.scripts.s1.checked'), false);
+eq('私信脚本单选:脚本二现在是勾上的', api.Store.getBool('tabs.dm.scripts.s2.checked'), true);
+api.Pages.toggleDmScript('s2', false);
+eq('私信脚本单选:再点一次可取消(回到未选脚本)', api.Store.getBool('tabs.dm.scripts.s2.checked'), false);
+api.Pages.toggleDmScript('s1', true);
 
 api.Store.set('tabs.dm.scripts.s1.checked', true);
 api.Store.set('tabs.dm.scripts.s2.checked', false);
@@ -1065,6 +1075,36 @@ check('私信:旧默认休息会给出说明',
       dmLegacyRest.notes.some((n) => String(n).indexOf('休息时间') >= 0),
       JSON.stringify(dmLegacyRest.notes));
 
+/* ★ 迁移补执行(v11)。这条守的是一个**真发生过**的坑:
+ * 9 号迁移用 `restMin === 100` 判断,设备上存的可能是字符串 "100" →
+ * 匹配不上 → 迁移"跑了但没改",版本却被抬到 10 → 从此永久卡在 100~500。
+ * 症状是「界面写 100~500、引擎按 10~40 跑」两边不一致。 */
+const legacyV10 = JSON.parse(JSON.stringify(api.Store.current()));
+legacyV10.version = 10;
+legacyV10.tabs.dm.restMin = 100;
+legacyV10.tabs.dm.restMax = 500;
+eq('v10 存档的旧休息时间被补执行迁移(下限)',
+   api.Store.validate(legacyV10).config.tabs.dm.restMin, 10);
+eq('v10 存档的旧休息时间被补执行迁移(上限)',
+   api.Store.validate(legacyV10).config.tabs.dm.restMax, 40);
+
+const legacyStrRest = JSON.parse(JSON.stringify(api.Store.current()));
+legacyStrRest.version = 9;
+legacyStrRest.tabs.dm.restMin = '100';
+legacyStrRest.tabs.dm.restMax = '500';
+eq('字符串型旧休息时间也能迁移(严格相等曾静默漏掉)',
+   api.Store.validate(legacyStrRest).config.tabs.dm.restMax, 40);
+
+/* 反过来:用户自己调过的值**绝不能**被迁移覆盖 */
+const customRest = JSON.parse(JSON.stringify(api.Store.current()));
+customRest.version = 10;
+customRest.tabs.dm.restMin = 60;
+customRest.tabs.dm.restMax = 90;
+eq('用户自定义的休息时间不被迁移覆盖(下限)',
+   api.Store.validate(customRest).config.tabs.dm.restMin, 60);
+eq('用户自定义的休息时间不被迁移覆盖(上限)',
+   api.Store.validate(customRest).config.tabs.dm.restMax, 90);
+
 /* prepare 也要按任务分支早返回:私信配置里同样没有 `target`,
  * 不早返回就会崩在 `built.config.target.keyword`(真机上是脚本退出、界面回桌面、零提示) */
 let dmPlan = null;
@@ -1076,16 +1116,32 @@ try {
 check('私信 prepare 不抛异常(配置里没有 target)',
       dmPlan && dmPlan.ok === true, JSON.stringify(dmPlan && dmPlan.error));
 
-/* 勾了脚本二时要如实说明"本轮按脚本一执行" */
+/* 勾了脚本二:取材改成"自己的粉丝列表",所以 uidPath 必须留空 ——
+ * 留旧路径会让日志里出现一个"看起来在用的文件",排障时误导。 */
 api.Store.set('tabs.dm.scripts.s1.checked', false);
 api.Store.set('tabs.dm.scripts.s2.checked', true);
 const dmS2 = api.Engine.buildConfig({ task: 'dm' });
-check('私信:勾了脚本二 → notes 里说明按脚本一执行',
-      dmS2.notes.length > 0 && dmS2.notes[0].indexOf('脚本二') >= 0,
+eq('私信:勾了脚本二 → 配置里 script = s2', dmS2.config.dm.script, 's2');
+eq('私信:勾了脚本二 → 不带 UID 文本路径', dmS2.config.dm.uidPath, '');
+check('私信:勾了脚本二 → notes 说清从自己粉丝列表取材',
+      dmS2.notes.some((n) => String(n).indexOf('粉丝列表') >= 0),
       JSON.stringify(dmS2.notes));
 api.Store.set('tabs.dm.scripts.s2.checked', false);
 api.Store.set('tabs.dm.scripts.s1.checked', true);
 
+/* 脚本二**没有自己的控件**(取材规则由共用参数决定),所以展开后那段说明是
+ * 用户唯一能知道"人从哪儿来、要多少才够"的地方 —— 它必须真的渲染出来。 */
+api.App.openDetail('dm');
+api.Store.set('tabs.dm.scripts.s2.expanded', true);
+api.App.renderScreen();
+const s2ExpandTexts = textsOf(api.App.contentHost).join(' | ');
+check('脚本二展开后有取材说明(自己的粉丝列表)',
+      s2ExpandTexts.indexOf('粉丝列表') >= 0, s2ExpandTexts);
+check('脚本二说明里点出「关注数量」「私信数量」两个额度',
+      s2ExpandTexts.indexOf('关注数量') >= 0 && s2ExpandTexts.indexOf('私信数量') >= 0,
+      s2ExpandTexts);
+api.Store.set('tabs.dm.scripts.s2.expanded', false);
+
 /* 还原默认值 */
 api.Store.set('tabs.dm.followMin', api.Store.defaultOf('tabs.dm.followMin'));
 api.Store.set('tabs.dm.followMax', api.Store.defaultOf('tabs.dm.followMax'));
@@ -1223,11 +1279,15 @@ api.App.clearCheckpoint();
 api.Records.save(api.Records.list().filter((r) => r.id !== recA.id && r.id !== recB.id));
 api.App.switchTab('tasks');
 
-/* ⑤ 仍有脚本没接入引擎(UID私信·**脚本二**「自己粉丝私信」)→ 必须继续被**明确拦下**。
- *    这条不能因为"脚本一接入了"就删掉:它是"绝不静默跑不相关的东西"那道闸。
- *    注意必须把 s1 取消勾选 —— `checkedScript('dm')` 返回的是勾着的那个,
- *    s1 还勾着就会拿到有引擎的那个,测不到拦截。 */
+/* ⑤ 「引擎未接入」那道闸必须继续有效。
+ *
+ * ★ 四个任务现在**全都接了引擎**,已经没有"天然缺引擎"的样本可用了。
+ * 所以这条用例改成**临时摘掉**脚本二的 engine 标记来验闸门本身,验完立刻装回去 ——
+ * 直接删掉这条就等于放弃守"绝不静默跑不相关的东西"(以后新增任务漏填 engine 时,
+ * 会静默按别的链路的逻辑跑,症状是"配置对、结果完全不对")。 */
 api.App.openDetail('dm');
+const savedS2Engine = api.Pages.DM_SCRIPTS[1].engine;
+delete api.Pages.DM_SCRIPTS[1].engine;          /* 注册表存的是同一个数组引用,改对象即可 */
 api.Store.set('tabs.dm.scripts.s1.checked', false);
 api.Store.set('tabs.dm.scripts.s2.checked', true);
 api.App.toggleRun();
@@ -1238,7 +1298,8 @@ check('拦截窗说明了哪个脚本没接入',
     api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
 api.Dialog.active && api.Dialog.active.close();
 
-/* 还原勾选,别让后面的用例拿到"脚本二被选中"的状态 */
+/* 装回引擎标记并还原勾选 —— 后面所有用例都依赖"脚本二有引擎" */
+api.Pages.DM_SCRIPTS[1].engine = savedS2Engine;
 api.Store.set('tabs.dm.scripts.s2.checked', false);
 api.Store.set('tabs.dm.scripts.s1.checked', true);
 

+ 41 - 4
tiktok-auto/README.md

@@ -566,16 +566,53 @@ TikTok 列表内容会变、滚动位置无法复原,记行号只会漏人或
 → 点它 → 404ms 后输入框清空 → sent=1,「完成:关注 0 个 / 私信 1 条」
 ```
 
-### 脚本二「自己粉丝私信」未实现
+### 脚本二「自己粉丝私信」已实现
+
+取材改成"打开自己的主页 → 粉丝列表",细节见 3.75。`DM_SCRIPTS.s2` 已带 `engine` 标记。
+
+### 休息时间默认值改过(v9 / v11)
 
-它的取材链路(从**自己**的粉丝列表抓人)还没做,所以 `DM_SCRIPTS.s2` **刻意没有 `engine` 标记** ——
-点运行会被 `Dialog.engineMissing` 明确拦下,而不是按脚本一的逻辑静默跑(那会拿自己的粉丝当"已提 UID")。
 
-### 休息时间默认值改过(v9/v10)
 
 `100~500 秒` → `10~40 秒`。它是**每处理完一个 UID 之后**的休息,而两批合计 120~230 人 ——
 按旧默认一轮要跑 5~24 小时,实际跑不完。只改"还停在旧默认值"的配置。
 
+★ 这条迁移执行过两次(v10 漏了、v11 补上):v10 那次用 `=== 100` 判断,
+碰上字符串型旧值匹配不上,版本却被抬上去 → **永久卡在旧默认**。
+所以抬版本时要"再加一条补执行的迁移",条件用 `Number()` 比较。
+
+---
+
+## 3.75 私信的两个脚本:s1(UID 文本)与 s2(自己粉丝)
+
+同一条编排(`dmflow`),差别只在**人从哪儿来**:
+
+| | 脚本一 `s1` | 脚本二 `s2` |
+|---|---|---|
+| 取材 | 读 UID 文本(`dm.uidPath`) | 打开**自己的主页** → 粉丝列表 |
+| 何时启动 App | 读完文件之后(文件不对就立刻返回) | **先启动**,取材必须在 App 里做 |
+| 数量依据 | 文件行数 | 采到 `关注数量 + 私信数量` 为止 |
+
+两者共用后半段:前 N 个只关注、接着 M 个只发私信(两批不重叠),每个之后休 `rest` 秒。
+
+### 脚本二的取材(`dmflow.collectOwnFans`)
+
+1. `flow.ensureFeed()` → 点底部导航 `Profile`(`tiktok.profileNav()`)
+2. **等主页三组数字读全**(`profile.waitProfile()`)—— `settle()` 挡不住,
+   实测 username 出现 1 秒就够了,但 fans 入口挂在数字上,这时 `openList` 必然失败
+3. `profile.openList('fans')` + `switchListTab('fans')`(切错页签会取到"你关注的人")
+4. `idsource.walkList()` 逐屏收集,**够了就停**(判据挂在 `ctx.shouldStop()` 上,
+   不是抛异常,所以已采到的不会丢)
+
+### 三个真机坑
+
+- **发送键依赖键盘弹起**:它只在输入框缩短后才存在。`typeInto` 的点击有时被输入法吞掉
+  (文字进去了、键盘没起)→ 找不到发送键。`dm.ensureSendButton()` 会再点一次输入框强制聚焦
+- **脚本卡是单选**:`W.scriptHeader` 不做互斥,页面必须自己做(`Pages.toggleDmScript`)。
+  漏了的话两个都能勾上,引擎只能挑一个跑,而界面上完全看不出异常
+- **取材可能在列表顶部就"到底"**:`walkList` 只认"翻不翻得动",不认"本屏有没有新人" ——
+  详见 3.5.10 那条坑
+
 ---
 
 ## 4. 需求 → 设计对照

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

@@ -1,5 +1,5 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-29T10:22:57.399Z
+ *  built 2026-10-29T10:43:50.864Z
  *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, nurture, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, dm, dmflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -384,6 +384,11 @@ TikTokAuto.bridge = (function () {
             if (summary.skipped > 0) { tail += ',跳过 ' + summary.skipped + ' 个'; }
             if (summary.failed > 0) { tail += ',失败 ' + summary.failed + ' 个'; }
             if (summary.dryRun) { tail += '(演练 ' + (summary.dryRunCount || 0) + ' 条,未真发)'; }
+            /* 人是哪儿来的要说清:脚本二跑完只说"关注 3 个/私信 2 条",
+             * 用户没法确认它到底是不是从自己的粉丝里取的人。 */
+            if (summary.sourceKind === 'ownFans') {
+                tail += '(取材自你自己的粉丝列表,' + (summary.totalUids || 0) + ' 人)';
+            }
             return tail;
         };
         /* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
@@ -677,9 +682,12 @@ TikTokAuto.config = (function () {
          * 「休息时间」是**每处理完一个 UID 之后**的间隔,也在这里传确定的区间。
          */
         dm: {
-            /** 's1' = 已提 UID 私信(读文本);'s2' = 自己粉丝私信(暂未实现) */
+            /**
+             * 's1' = 已提 UID 私信(读文本文件)
+             * 's2' = 自己粉丝私信(打开自己的主页 → 粉丝列表取材,不用 uidPath)
+             */
             script: 's1',
-            /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释 */
+            /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释(s2 忽略) */
             uidPath: '/sdcard/Download/tiktok_app/uid_list.txt',
             follow: { enabled: true, max: 0 },
             send: { enabled: true, max: 0 },
@@ -884,6 +892,14 @@ TikTokAuto.config = (function () {
             var sendMax = Number(dm.send && dm.send.max);
             var followOn = dm.follow && dm.follow.enabled === true;
             var sendOn = dm.send && dm.send.enabled === true;
+            if (dm.script !== 's1' && dm.script !== 's2') {
+                throw new ConfigError('dm.script 只能是 "s1" 或 "s2"', dm.script);
+            }
+            /* 脚本一靠外部文本取材,路径空着必然一个都读不到 —— 提前报,
+             * 而不是先启动 App、跑到一半才发现"没有可用账号"。 */
+            if (dm.script === 's1' && !dm.uidPath) {
+                throw new ConfigError('脚本一必须指定 UID 文本(dm.uidPath 为空)', dm.uidPath);
+            }
             if (!followOn && !sendOn) {
                 throw new ConfigError('「进行关注」与「进行私信」都关着,没有可执行的动作',
                                       { follow: followOn, send: sendOn });
@@ -963,6 +979,22 @@ TikTokAuto.config = (function () {
                 commentCount: config.comments.pool.length,
                 dryRun: config.runtime.dryRun
             }));
+        } else if (config.task === 'dm') {
+            /* 私信同样没有关键词,落到下面的 video 分支会打印
+             * `{"keyword":"fashion",...}` —— 排障时完全误导(曾经真被这条日志带偏过,
+             * 以为跑的是视频链路)。这里报"从哪儿取人、发多少、歇多久"。 */
+            var dm = config.dm;
+            logger.info('config', '生效配置(私信):' + JSON.stringify({
+                script: dm.script,
+                source: dm.script === 's2' ? '自己的粉丝列表'
+                                                : ('UID 文本 ' + dm.uidPath),
+                follow: dm.follow,
+                send: dm.send,
+                restSec: [Math.round(dm.rest.minMs / 1000), Math.round(dm.rest.maxMs / 1000)],
+                dedupeMode: dm.dedupeMode,
+                textCount: dm.pool.length,
+                dryRun: config.runtime.dryRun
+            }));
         } else {
             logger.info('config', '生效配置:' + JSON.stringify({
                 keyword: config.target.keyword,
@@ -2072,6 +2104,30 @@ TikTokAuto.tiktok = (function () {
         return layout.findOnce(desc('Home')) !== null && layout.findOnce(desc('Profile')) !== null;
     }
 
+    /**
+     * 底部导航的「Profile」项 —— 用来打开**自己的**主页(脚本二要自己的粉丝列表)。
+     *
+     * 判据刻意很松:`desc === 'Profile'` 精确匹配 + 中心落在屏幕下缘。
+     * 不限定 class(它是 FrameLayout,和历史记忆里"首页顶部 Following 页签是
+     * FrameLayout"同一族,限定 Button 会一个都找不到),也不认文案 ——
+     * 底部导航这一项没有 text。
+     *
+     * 为什么还要判"在下缘":右侧竖排作者头像的 desc 是 `xxx profile`(小写、
+     * 带用户名前缀),精确匹配 `Profile` 本来就排除掉了;但万一新版把它改成
+     * 纯 `Profile`,这一条能挡住"点到了头像"这种最坏情况。
+     */
+    function profileNav() {
+        var node = layout.findOnce(desc('Profile'));
+        if (!node) {
+            return null;
+        }
+        var point = layout.center(node);
+        if (!point || point.y < screenHeight() * 0.88) {
+            return null;
+        }
+        return node;
+    }
+
     // ---------------------------------------------------------------- 搜索页
 
     /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */
@@ -3365,6 +3421,7 @@ TikTokAuto.tiktok = (function () {
         searchSubmit: searchSubmit,
         searchHistoryPresent: searchHistoryPresent,
         bottomNavPresent: bottomNavPresent,
+        profileNav: profileNav,
         resultTab: resultTab,
         resultTabStripPresent: resultTabStripPresent,
         videoCards: videoCards,
@@ -6809,9 +6866,56 @@ TikTokAuto.profile = (function () {
         }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
     }
 
+    /**
+     * 打开**自己**的主页(底部导航的 Profile)。
+     *
+     * 为什么必须先回信息流:底部导航只在首页有。当前若停在搜索页 / 播放页 /
+     * 别人的主页,屏幕上根本没有那个入口 —— 直接找必然失败,而且报的是
+     * "找不到 Profile 入口"这种看不出原因的话。
+     *
+     * 为什么不用 openBySearch(自己的用户名):引擎里没有"我是谁"这个信息
+     * (不带登录态、也不应该去猜),而底部导航这一步是零成本且永远正确的。
+     */
+    function openOwnProfile(config) {
+        var step = 'openOwnProfile';
+        var log = logger();
+
+        if (!ensureFeed(config)) {
+            log.warn(step, '回不到信息流,打不开自己的主页');
+            return false;
+        }
+
+        var nav = TikTokAuto.tiktok.profileNav();
+        if (!nav) {
+            log.warn(step, '底部导航上找不到 Profile 入口');
+            return false;
+        }
+        TikTokAuto.ui.clickNode(nav, step);
+
+        var ready = TikTokAuto.wait.waitFor(step, function () {
+            return where() === 'profile' ? true : null;
+        }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
+
+        if (ready !== true) {
+            log.warn(step, '点了 Profile 但没进入用户主页');
+            return false;
+        }
+        /* ★ 必须等**三组数字**读全,不能用 settle(3000)(2026-10-29 真机踩到)。
+         *
+         * 「username 出现」比「数字渲染完」早得多:实测点完 Profile 1 秒就
+         * `where() === 'profile'` 通过,而 fans 入口挂在数字上 —— 这时 `statEntry('fans')`
+         * 找不到,上层看到的是"用户主页上找不到 fans 入口(等了 3 秒仍没有)",
+         * 一次运行就此失败。`settle()` 在动态页面上会提前返回(它的判据是
+         * "文案连续两次不变",而加载中与渲染完的签名可能一样),挡不住这件事。
+         *
+         * `waitProfile` 的判据正好是「username + 关注/粉丝/获赞三组都读到」,
+         * 而且它读不到时**只警告不失败**(有的账号确实不显示某一组)。 */
+        waitProfile(step + ':stats');
+        return true;
+    }
+
     /** 在当前搜索结果里按 uid 找行(找不到返回 null,不猜第一行) */
-    function findRowByUid(uid) {
-        var rows = TikTokAuto.tiktok.usersResultRows();
+    function findRowByUid(uid) {        var rows = TikTokAuto.tiktok.usersResultRows();
         for (var i = 0; i < rows.length; i += 1) {
             if (rows[i].uid === uid) {
                 return rows[i];
@@ -7164,6 +7268,7 @@ TikTokAuto.profile = (function () {
         where: where,
         ensureFeed: ensureFeed,
         openBySearch: openBySearch,
+        openOwnProfile: openOwnProfile,
         findRowByUid: findRowByUid,
         openList: openList,
         switchListTab: switchListTab,
@@ -9235,6 +9340,42 @@ TikTokAuto.dm = (function () {
         return best;
     }
 
+    /**
+     * 拿到发送键,拿不到就**再点一次输入框**强制聚焦后重试一次。
+     *
+     * ★ 为什么需要重试(2026-10-29 真机踩到):发送键只在**键盘弹起**时才存在
+     * (输入框缩短,右侧空出那块 72×72 的 ImageView)。而 `typeInto` 的那次点击
+     * 有时被输入法吞掉 —— 文字确实写进去了,键盘却没起来,于是发送键不存在。
+     * 表现是"演练成功、实发失败:输入后找不到发送键",且时好时坏。
+     *
+     * 重试的动作很小(点一下输入框),但它把"时好时坏"变成"基本必成"。
+     */
+    function ensureSendButton(config, step) {
+        var log = logger();
+        var button = TikTokAuto.wait.waitFor(step, function () {
+            return sendButton() || null;
+        }, 1500, 250);
+        if (button) {
+            return button;
+        }
+
+        var input = panelInput();
+        if (!input) {
+            return null;
+        }
+        var point = TikTokAuto.layout.center(input);
+        if (!point) {
+            return null;
+        }
+        log.info(step, '发送键还没出现,再点一次输入框强制聚焦');
+        TikTokAuto.ui.tapPoint(point.x, point.y);
+        TikTokAuto.wait.tick(400);
+
+        return TikTokAuto.wait.waitFor(step + ':retry', function () {
+            return sendButton() || null;
+        }, 4000, 250);
+    }
+
     /**
      * 打开私信面板(前提:当前已经在目标用户的**主页**上)。
      *
@@ -9336,9 +9477,7 @@ TikTokAuto.dm = (function () {
             /* dryRun 也要**先把发送键找一遍**(只记录、不点)。
              * 否则"演练模式"永远验证不到最不确定的那一环 —— 发送键是输入后才出现的、
              * 无 id 无 desc 的 ImageView,找不到就等于发不出去,而演练却会报"成功"。 */
-            var preview = TikTokAuto.wait.waitFor(story + ':btn-preview', function () {
-                return sendButton() || null;
-            }, 4000, 250);
+            var preview = ensureSendButton(config, story + ':btn-preview');
             log.warn(story, 'dryRun:已输入但不发送。发送键' +
                 (preview ? '已定位 ' + JSON.stringify(preview.bounds()) : '**没找到**') +
                 ',内容=' + JSON.stringify(content));
@@ -9353,10 +9492,8 @@ TikTokAuto.dm = (function () {
                      sendButtonFound: preview !== null };
         }
 
-        /* 发送键是输入之后才出现的,写完之后等一下再找 */
-        var button = TikTokAuto.wait.waitFor(story + ':btn', function () {
-            return sendButton() || null;
-        }, 4000, 250);
+        /* 发送键是输入之后才出现的(而且依赖键盘弹起),拿不到会再聚焦重试一次 */
+        var button = ensureSendButton(config, story + ':btn');
         if (!button) {
             log.warn(story, '输入后仍找不到发送键');
             closePanel(story);
@@ -9523,6 +9660,75 @@ TikTokAuto.dmflow = (function () {
         return out;
     }
 
+    /**
+     * 脚本二:从**自己的粉丝列表**取材。
+     *
+     * 采够 `need` 个就停 —— 多采没意义,而且每多翻一屏就多等一次渲染。
+     * 停止条件挂在 `ctx.shouldStop()` 上:`walkList` 每处理一行前都会问,
+     * 够了就自然结束(不是抛异常,所以不会把已采到的丢掉)。
+     *
+     * ★ 不要在这里判"列表到底":那是 walkList 的事(它只看"翻不翻得动")。
+     * 早期版本用"本屏没有新人"当到底判据,结果预载历史时在列表顶部就收工。
+     *
+     * @returns 采到的 uid 数组(可能少于 need,比如粉丝本来就没那么多)
+     */
+    function collectOwnFans(config, need, summary) {
+        var log = logger();
+        var profileApi = TikTokAuto.profile;
+        var uids = [];
+
+        if (!profileApi.openOwnProfile(config)) {
+            summary.errors.push('打不开自己的主页(底部导航 Profile),无法取材');
+            return uids;
+        }
+        if (!profileApi.openList('fans', config)) {
+            /* 主页数字区的入口第一次常常还没渲染出来(openList 内部已等 3 秒)。
+             * 再等 2 秒重试一次 —— 失败一次就整轮退出太亏了。 */
+            log.warn('dmflow', '第一次没打开粉丝列表,2 秒后重试');
+            TikTokAuto.wait.tick(2000);
+            if (!profileApi.openList('fans', config)) {
+                summary.errors.push('打不开自己的粉丝列表(可能主页上不显示粉丝数)');
+                return uids;
+            }
+        }
+        /* 列表页默认停在「粉丝」还是「关注」取决于上次的操作,必须显式切一次。
+         * 切错页签的后果很难发现:一样能翻、一样有人,只是全是**你关注的人**。 */
+        if (!profileApi.switchListTab('fans')) {
+            summary.warnings.push('没找到「粉丝」页签,按当前页签取材(可能取到的是关注列表)');
+        }
+        log.step('dmflow', '开始从自己的粉丝列表取材,目标 ' + need + ' 个');
+
+        var ctx = {
+            config: config,
+            seen: TikTokAuto.uidset.create(),
+            stat: {},
+            shouldStop: function () {
+                return uids.length >= need;
+            },
+            onSeenProfile: function () { },
+            onRow: function (row) {
+                if (uids.length < need) {
+                    uids.push(row.uid);
+                }
+            }
+        };
+
+        try {
+            TikTokAuto.idsource.walkList(ctx);
+        } catch (error) {
+            if (TikTokAuto.bridge.isStopSignal(error)) {
+                throw error;
+            }
+            log.warn('dmflow', '遍历粉丝列表时出错:' + error);
+            summary.warnings.push('遍历粉丝列表时出错,已采到 ' + uids.length + ' 个');
+        }
+
+        profileApi.backToProfile();
+        log.step('dmflow', '自己粉丝取材完成:' + uids.length + '/' + need +
+            ' 个(翻了 ' + (ctx.stat.screens || 0) + ' 屏)');
+        return uids;
+    }
+
     /** 休息指定区间,**分段等**:每秒醒一次,停止信号能立刻生效 */
     function rest(config, step) {
         var lo = config.dm.rest.minMs;
@@ -9591,16 +9797,17 @@ TikTokAuto.dmflow = (function () {
     function runSession(config) {
         var log = logger();
         var settings = config.dm;
-        var parse = TikTokAuto.parse;
 
         var summary = {
             ok: false,
             task: 'dm',
             script: settings.script,
+            /** 人是哪儿来的:'uids' = 脚本一读文本;'ownFans' = 脚本二自己粉丝列表 */
+            sourceKind: settings.script === 's2' ? 'ownFans' : 'uids',
             /** 演练模式:输入但不发送。要单独计数,否则 dryRun 会被误报成"什么都没做成" */
             dryRun: !!(config.runtime && config.runtime.dryRun),
             dryRunCount: 0,
-            /** UID 文件里读到多少个(去重后) */
+            /** 取材结果里有多少个人(s1 = 文件行数,s2 = 粉丝列表采到的) */
             totalUids: 0,
             followPlanned: 0,
             dmPlanned: 0,
@@ -9613,15 +9820,7 @@ TikTokAuto.dmflow = (function () {
             errors: []
         };
 
-        /* ① 读 UID —— 这一步不碰手机,先做完再启动 App */
-        var uids = readUids(settings.uidPath);
-        summary.totalUids = uids.length;
-        if (uids.length === 0) {
-            summary.errors.push('UID 文本里没有可用的账号(' + (settings.uidPath || '(未设置路径)') + ')');
-            return summary;
-        }
-
-        /* ② 分两批:前 N 个关注,接着 M 个私信 —— 两批人不重叠 */
+        /* ① 先算两批各要多少人 —— 脚本二靠这个数才知道要采多少人 */
         var followMax = settings.follow.enabled ? settings.follow.max : 0;
         var sendMax = settings.send.enabled ? settings.send.max : 0;
         if (followMax === 0 && sendMax === 0) {
@@ -9629,30 +9828,75 @@ TikTokAuto.dmflow = (function () {
             return summary;
         }
 
+        var need = followMax + sendMax;
+        var uids = [];
+        var appReady = false;
+
+        if (settings.script === 's2') {
+            /* 脚本二:人就在**自己**主页的粉丝列表里,必须先把 App 拉起来才能取材。
+             * 顺序与脚本一相反(脚本一是"文件不对就立刻返回,不折腾设备")。 */
+            progress('打开自己的粉丝列表', 0, need);
+            if (!TikTokAuto.flow.launchApp(config)) {
+                summary.errors.push('启动应用失败');
+                return summary;
+            }
+            if (!TikTokAuto.flow.ensureFeed(config)) {
+                summary.errors.push('未能进入信息流首页');
+                return summary;
+            }
+            appReady = true;
+            uids = collectOwnFans(config, need, summary);
+            if (uids.length === 0) {
+                if (summary.errors.length === 0) {
+                    summary.errors.push('自己的粉丝列表里没取到人(可能还没有粉丝)');
+                }
+                return summary;
+            }
+        } else {
+            /* 脚本一:读文本。**这一步不碰手机**,文件不对就立刻返回。 */
+            uids = readUids(settings.uidPath);
+            if (uids.length === 0) {
+                summary.errors.push('UID 文本里没有可用的账号(' +
+                    (settings.uidPath || '(未设置路径)') + ')');
+                return summary;
+            }
+        }
+        summary.totalUids = uids.length;
+
+        /* ② 分两批:前 N 个关注,接着 M 个私信 —— 两批人不重叠 */
+
         var followBatch = uids.slice(0, followMax);
         var dmBatch = uids.slice(followMax, followMax + sendMax);
         summary.followPlanned = followBatch.length;
         summary.dmPlanned = dmBatch.length;
 
         if (followBatch.length < followMax || dmBatch.length < sendMax) {
-            summary.warnings.push('UID 只够 ' + uids.length + ' 个,少于计划(关注 ' +
+            summary.warnings.push((settings.script === 's2' ? '粉丝' : 'UID') +
+                '只够 ' + uids.length + ' 个,少于计划(关注 ' +
                 followMax + ' + 私信 ' + sendMax + '),本轮按实际数量执行');
         }
 
         var total = followBatch.length + dmBatch.length;
-        log.step('dmflow', '私信任务开始:UID ' + uids.length + ' 个;关注 ' +
+        log.step('dmflow', '私信任务开始(' +
+            (settings.script === 's2' ? '自己粉丝取材' : 'UID 文本取材') + '):' +
+            (settings.script === 's2' ? '粉丝' : 'UID') + ' ' + uids.length + ' 个;关注 ' +
             followBatch.length + ' 个 + 私信 ' + dmBatch.length + ' 个;' +
             '每个之后休息 ' + Math.round(settings.rest.minMs / 1000) + '~' +
             Math.round(settings.rest.maxMs / 1000) + ' 秒');
 
-        progress('启动应用', 0, total);
-        if (!TikTokAuto.flow.launchApp(config)) {
-            summary.errors.push('启动应用失败');
-            return summary;
-        }
-        if (!TikTokAuto.flow.ensureFeed(config)) {
-            summary.errors.push('未能进入信息流首页');
-            return summary;
+        /* 脚本二在取材时已经把 App 拉起来并停在「自己的主页」上了,别重复启动:
+         * launchApp 会重新发一次 startActivity(已在前台也一样),把页面状态打乱。
+         * 后面的 handleOne → openBySearch 第一步就是 ensureFeed,会自己回信息流。 */
+        if (!appReady) {
+            progress('启动应用', 0, total);
+            if (!TikTokAuto.flow.launchApp(config)) {
+                summary.errors.push('启动应用失败');
+                return summary;
+            }
+            if (!TikTokAuto.flow.ensureFeed(config)) {
+                summary.errors.push('未能进入信息流首页');
+                return summary;
+            }
         }
 
         var usedTexts = [];
@@ -9761,6 +10005,7 @@ TikTokAuto.dmflow = (function () {
     return {
         runSession: runSession,
         readUids: readUids,
+        collectOwnFans: collectOwnFans,
         pickText: pickText,
         CONSECUTIVE_FAIL_LIMIT: CONSECUTIVE_FAIL_LIMIT
     };

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

@@ -296,6 +296,11 @@ TikTokAuto.bridge = (function () {
             if (summary.skipped > 0) { tail += ',跳过 ' + summary.skipped + ' 个'; }
             if (summary.failed > 0) { tail += ',失败 ' + summary.failed + ' 个'; }
             if (summary.dryRun) { tail += '(演练 ' + (summary.dryRunCount || 0) + ' 条,未真发)'; }
+            /* 人是哪儿来的要说清:脚本二跑完只说"关注 3 个/私信 2 条",
+             * 用户没法确认它到底是不是从自己的粉丝里取的人。 */
+            if (summary.sourceKind === 'ownFans') {
+                tail += '(取材自你自己的粉丝列表,' + (summary.totalUids || 0) + ' 人)';
+            }
             return tail;
         };
         /* 续跑时"数字"要给累计口径,并明确说这是续跑 ——

+ 29 - 2
tiktok-auto/src/config.js

@@ -167,9 +167,12 @@ TikTokAuto.config = (function () {
          * 「休息时间」是**每处理完一个 UID 之后**的间隔,也在这里传确定的区间。
          */
         dm: {
-            /** 's1' = 已提 UID 私信(读文本);'s2' = 自己粉丝私信(暂未实现) */
+            /**
+             * 's1' = 已提 UID 私信(读文本文件)
+             * 's2' = 自己粉丝私信(打开自己的主页 → 粉丝列表取材,不用 uidPath)
+             */
             script: 's1',
-            /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释 */
+            /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释(s2 忽略) */
             uidPath: '/sdcard/Download/tiktok_app/uid_list.txt',
             follow: { enabled: true, max: 0 },
             send: { enabled: true, max: 0 },
@@ -374,6 +377,14 @@ TikTokAuto.config = (function () {
             var sendMax = Number(dm.send && dm.send.max);
             var followOn = dm.follow && dm.follow.enabled === true;
             var sendOn = dm.send && dm.send.enabled === true;
+            if (dm.script !== 's1' && dm.script !== 's2') {
+                throw new ConfigError('dm.script 只能是 "s1" 或 "s2"', dm.script);
+            }
+            /* 脚本一靠外部文本取材,路径空着必然一个都读不到 —— 提前报,
+             * 而不是先启动 App、跑到一半才发现"没有可用账号"。 */
+            if (dm.script === 's1' && !dm.uidPath) {
+                throw new ConfigError('脚本一必须指定 UID 文本(dm.uidPath 为空)', dm.uidPath);
+            }
             if (!followOn && !sendOn) {
                 throw new ConfigError('「进行关注」与「进行私信」都关着,没有可执行的动作',
                                       { follow: followOn, send: sendOn });
@@ -453,6 +464,22 @@ TikTokAuto.config = (function () {
                 commentCount: config.comments.pool.length,
                 dryRun: config.runtime.dryRun
             }));
+        } else if (config.task === 'dm') {
+            /* 私信同样没有关键词,落到下面的 video 分支会打印
+             * `{"keyword":"fashion",...}` —— 排障时完全误导(曾经真被这条日志带偏过,
+             * 以为跑的是视频链路)。这里报"从哪儿取人、发多少、歇多久"。 */
+            var dm = config.dm;
+            logger.info('config', '生效配置(私信):' + JSON.stringify({
+                script: dm.script,
+                source: dm.script === 's2' ? '自己的粉丝列表'
+                                                : ('UID 文本 ' + dm.uidPath),
+                follow: dm.follow,
+                send: dm.send,
+                restSec: [Math.round(dm.rest.minMs / 1000), Math.round(dm.rest.maxMs / 1000)],
+                dedupeMode: dm.dedupeMode,
+                textCount: dm.pool.length,
+                dryRun: config.runtime.dryRun
+            }));
         } else {
             logger.info('config', '生效配置:' + JSON.stringify({
                 keyword: config.target.keyword,

+ 39 - 7
tiktok-auto/src/dm.js

@@ -139,6 +139,42 @@ TikTokAuto.dm = (function () {
         return best;
     }
 
+    /**
+     * 拿到发送键,拿不到就**再点一次输入框**强制聚焦后重试一次。
+     *
+     * ★ 为什么需要重试(2026-10-29 真机踩到):发送键只在**键盘弹起**时才存在
+     * (输入框缩短,右侧空出那块 72×72 的 ImageView)。而 `typeInto` 的那次点击
+     * 有时被输入法吞掉 —— 文字确实写进去了,键盘却没起来,于是发送键不存在。
+     * 表现是"演练成功、实发失败:输入后找不到发送键",且时好时坏。
+     *
+     * 重试的动作很小(点一下输入框),但它把"时好时坏"变成"基本必成"。
+     */
+    function ensureSendButton(config, step) {
+        var log = logger();
+        var button = TikTokAuto.wait.waitFor(step, function () {
+            return sendButton() || null;
+        }, 1500, 250);
+        if (button) {
+            return button;
+        }
+
+        var input = panelInput();
+        if (!input) {
+            return null;
+        }
+        var point = TikTokAuto.layout.center(input);
+        if (!point) {
+            return null;
+        }
+        log.info(step, '发送键还没出现,再点一次输入框强制聚焦');
+        TikTokAuto.ui.tapPoint(point.x, point.y);
+        TikTokAuto.wait.tick(400);
+
+        return TikTokAuto.wait.waitFor(step + ':retry', function () {
+            return sendButton() || null;
+        }, 4000, 250);
+    }
+
     /**
      * 打开私信面板(前提:当前已经在目标用户的**主页**上)。
      *
@@ -240,9 +276,7 @@ TikTokAuto.dm = (function () {
             /* dryRun 也要**先把发送键找一遍**(只记录、不点)。
              * 否则"演练模式"永远验证不到最不确定的那一环 —— 发送键是输入后才出现的、
              * 无 id 无 desc 的 ImageView,找不到就等于发不出去,而演练却会报"成功"。 */
-            var preview = TikTokAuto.wait.waitFor(story + ':btn-preview', function () {
-                return sendButton() || null;
-            }, 4000, 250);
+            var preview = ensureSendButton(config, story + ':btn-preview');
             log.warn(story, 'dryRun:已输入但不发送。发送键' +
                 (preview ? '已定位 ' + JSON.stringify(preview.bounds()) : '**没找到**') +
                 ',内容=' + JSON.stringify(content));
@@ -257,10 +291,8 @@ TikTokAuto.dm = (function () {
                      sendButtonFound: preview !== null };
         }
 
-        /* 发送键是输入之后才出现的,写完之后等一下再找 */
-        var button = TikTokAuto.wait.waitFor(story + ':btn', function () {
-            return sendButton() || null;
-        }, 4000, 250);
+        /* 发送键是输入之后才出现的(而且依赖键盘弹起),拿不到会再聚焦重试一次 */
+        var button = ensureSendButton(config, story + ':btn');
         if (!button) {
             log.warn(story, '输入后仍找不到发送键');
             closePanel(story);

+ 129 - 21
tiktok-auto/src/dmflow.js

@@ -75,6 +75,75 @@ TikTokAuto.dmflow = (function () {
         return out;
     }
 
+    /**
+     * 脚本二:从**自己的粉丝列表**取材。
+     *
+     * 采够 `need` 个就停 —— 多采没意义,而且每多翻一屏就多等一次渲染。
+     * 停止条件挂在 `ctx.shouldStop()` 上:`walkList` 每处理一行前都会问,
+     * 够了就自然结束(不是抛异常,所以不会把已采到的丢掉)。
+     *
+     * ★ 不要在这里判"列表到底":那是 walkList 的事(它只看"翻不翻得动")。
+     * 早期版本用"本屏没有新人"当到底判据,结果预载历史时在列表顶部就收工。
+     *
+     * @returns 采到的 uid 数组(可能少于 need,比如粉丝本来就没那么多)
+     */
+    function collectOwnFans(config, need, summary) {
+        var log = logger();
+        var profileApi = TikTokAuto.profile;
+        var uids = [];
+
+        if (!profileApi.openOwnProfile(config)) {
+            summary.errors.push('打不开自己的主页(底部导航 Profile),无法取材');
+            return uids;
+        }
+        if (!profileApi.openList('fans', config)) {
+            /* 主页数字区的入口第一次常常还没渲染出来(openList 内部已等 3 秒)。
+             * 再等 2 秒重试一次 —— 失败一次就整轮退出太亏了。 */
+            log.warn('dmflow', '第一次没打开粉丝列表,2 秒后重试');
+            TikTokAuto.wait.tick(2000);
+            if (!profileApi.openList('fans', config)) {
+                summary.errors.push('打不开自己的粉丝列表(可能主页上不显示粉丝数)');
+                return uids;
+            }
+        }
+        /* 列表页默认停在「粉丝」还是「关注」取决于上次的操作,必须显式切一次。
+         * 切错页签的后果很难发现:一样能翻、一样有人,只是全是**你关注的人**。 */
+        if (!profileApi.switchListTab('fans')) {
+            summary.warnings.push('没找到「粉丝」页签,按当前页签取材(可能取到的是关注列表)');
+        }
+        log.step('dmflow', '开始从自己的粉丝列表取材,目标 ' + need + ' 个');
+
+        var ctx = {
+            config: config,
+            seen: TikTokAuto.uidset.create(),
+            stat: {},
+            shouldStop: function () {
+                return uids.length >= need;
+            },
+            onSeenProfile: function () { },
+            onRow: function (row) {
+                if (uids.length < need) {
+                    uids.push(row.uid);
+                }
+            }
+        };
+
+        try {
+            TikTokAuto.idsource.walkList(ctx);
+        } catch (error) {
+            if (TikTokAuto.bridge.isStopSignal(error)) {
+                throw error;
+            }
+            log.warn('dmflow', '遍历粉丝列表时出错:' + error);
+            summary.warnings.push('遍历粉丝列表时出错,已采到 ' + uids.length + ' 个');
+        }
+
+        profileApi.backToProfile();
+        log.step('dmflow', '自己粉丝取材完成:' + uids.length + '/' + need +
+            ' 个(翻了 ' + (ctx.stat.screens || 0) + ' 屏)');
+        return uids;
+    }
+
     /** 休息指定区间,**分段等**:每秒醒一次,停止信号能立刻生效 */
     function rest(config, step) {
         var lo = config.dm.rest.minMs;
@@ -143,16 +212,17 @@ TikTokAuto.dmflow = (function () {
     function runSession(config) {
         var log = logger();
         var settings = config.dm;
-        var parse = TikTokAuto.parse;
 
         var summary = {
             ok: false,
             task: 'dm',
             script: settings.script,
+            /** 人是哪儿来的:'uids' = 脚本一读文本;'ownFans' = 脚本二自己粉丝列表 */
+            sourceKind: settings.script === 's2' ? 'ownFans' : 'uids',
             /** 演练模式:输入但不发送。要单独计数,否则 dryRun 会被误报成"什么都没做成" */
             dryRun: !!(config.runtime && config.runtime.dryRun),
             dryRunCount: 0,
-            /** UID 文件里读到多少个(去重后) */
+            /** 取材结果里有多少个人(s1 = 文件行数,s2 = 粉丝列表采到的) */
             totalUids: 0,
             followPlanned: 0,
             dmPlanned: 0,
@@ -165,15 +235,7 @@ TikTokAuto.dmflow = (function () {
             errors: []
         };
 
-        /* ① 读 UID —— 这一步不碰手机,先做完再启动 App */
-        var uids = readUids(settings.uidPath);
-        summary.totalUids = uids.length;
-        if (uids.length === 0) {
-            summary.errors.push('UID 文本里没有可用的账号(' + (settings.uidPath || '(未设置路径)') + ')');
-            return summary;
-        }
-
-        /* ② 分两批:前 N 个关注,接着 M 个私信 —— 两批人不重叠 */
+        /* ① 先算两批各要多少人 —— 脚本二靠这个数才知道要采多少人 */
         var followMax = settings.follow.enabled ? settings.follow.max : 0;
         var sendMax = settings.send.enabled ? settings.send.max : 0;
         if (followMax === 0 && sendMax === 0) {
@@ -181,30 +243,75 @@ TikTokAuto.dmflow = (function () {
             return summary;
         }
 
+        var need = followMax + sendMax;
+        var uids = [];
+        var appReady = false;
+
+        if (settings.script === 's2') {
+            /* 脚本二:人就在**自己**主页的粉丝列表里,必须先把 App 拉起来才能取材。
+             * 顺序与脚本一相反(脚本一是"文件不对就立刻返回,不折腾设备")。 */
+            progress('打开自己的粉丝列表', 0, need);
+            if (!TikTokAuto.flow.launchApp(config)) {
+                summary.errors.push('启动应用失败');
+                return summary;
+            }
+            if (!TikTokAuto.flow.ensureFeed(config)) {
+                summary.errors.push('未能进入信息流首页');
+                return summary;
+            }
+            appReady = true;
+            uids = collectOwnFans(config, need, summary);
+            if (uids.length === 0) {
+                if (summary.errors.length === 0) {
+                    summary.errors.push('自己的粉丝列表里没取到人(可能还没有粉丝)');
+                }
+                return summary;
+            }
+        } else {
+            /* 脚本一:读文本。**这一步不碰手机**,文件不对就立刻返回。 */
+            uids = readUids(settings.uidPath);
+            if (uids.length === 0) {
+                summary.errors.push('UID 文本里没有可用的账号(' +
+                    (settings.uidPath || '(未设置路径)') + ')');
+                return summary;
+            }
+        }
+        summary.totalUids = uids.length;
+
+        /* ② 分两批:前 N 个关注,接着 M 个私信 —— 两批人不重叠 */
+
         var followBatch = uids.slice(0, followMax);
         var dmBatch = uids.slice(followMax, followMax + sendMax);
         summary.followPlanned = followBatch.length;
         summary.dmPlanned = dmBatch.length;
 
         if (followBatch.length < followMax || dmBatch.length < sendMax) {
-            summary.warnings.push('UID 只够 ' + uids.length + ' 个,少于计划(关注 ' +
+            summary.warnings.push((settings.script === 's2' ? '粉丝' : 'UID') +
+                '只够 ' + uids.length + ' 个,少于计划(关注 ' +
                 followMax + ' + 私信 ' + sendMax + '),本轮按实际数量执行');
         }
 
         var total = followBatch.length + dmBatch.length;
-        log.step('dmflow', '私信任务开始:UID ' + uids.length + ' 个;关注 ' +
+        log.step('dmflow', '私信任务开始(' +
+            (settings.script === 's2' ? '自己粉丝取材' : 'UID 文本取材') + '):' +
+            (settings.script === 's2' ? '粉丝' : 'UID') + ' ' + uids.length + ' 个;关注 ' +
             followBatch.length + ' 个 + 私信 ' + dmBatch.length + ' 个;' +
             '每个之后休息 ' + Math.round(settings.rest.minMs / 1000) + '~' +
             Math.round(settings.rest.maxMs / 1000) + ' 秒');
 
-        progress('启动应用', 0, total);
-        if (!TikTokAuto.flow.launchApp(config)) {
-            summary.errors.push('启动应用失败');
-            return summary;
-        }
-        if (!TikTokAuto.flow.ensureFeed(config)) {
-            summary.errors.push('未能进入信息流首页');
-            return summary;
+        /* 脚本二在取材时已经把 App 拉起来并停在「自己的主页」上了,别重复启动:
+         * launchApp 会重新发一次 startActivity(已在前台也一样),把页面状态打乱。
+         * 后面的 handleOne → openBySearch 第一步就是 ensureFeed,会自己回信息流。 */
+        if (!appReady) {
+            progress('启动应用', 0, total);
+            if (!TikTokAuto.flow.launchApp(config)) {
+                summary.errors.push('启动应用失败');
+                return summary;
+            }
+            if (!TikTokAuto.flow.ensureFeed(config)) {
+                summary.errors.push('未能进入信息流首页');
+                return summary;
+            }
         }
 
         var usedTexts = [];
@@ -313,6 +420,7 @@ TikTokAuto.dmflow = (function () {
     return {
         runSession: runSession,
         readUids: readUids,
+        collectOwnFans: collectOwnFans,
         pickText: pickText,
         CONSECUTIVE_FAIL_LIMIT: CONSECUTIVE_FAIL_LIMIT
     };

+ 50 - 2
tiktok-auto/src/profile.js

@@ -419,9 +419,56 @@ TikTokAuto.profile = (function () {
         }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
     }
 
+    /**
+     * 打开**自己**的主页(底部导航的 Profile)。
+     *
+     * 为什么必须先回信息流:底部导航只在首页有。当前若停在搜索页 / 播放页 /
+     * 别人的主页,屏幕上根本没有那个入口 —— 直接找必然失败,而且报的是
+     * "找不到 Profile 入口"这种看不出原因的话。
+     *
+     * 为什么不用 openBySearch(自己的用户名):引擎里没有"我是谁"这个信息
+     * (不带登录态、也不应该去猜),而底部导航这一步是零成本且永远正确的。
+     */
+    function openOwnProfile(config) {
+        var step = 'openOwnProfile';
+        var log = logger();
+
+        if (!ensureFeed(config)) {
+            log.warn(step, '回不到信息流,打不开自己的主页');
+            return false;
+        }
+
+        var nav = TikTokAuto.tiktok.profileNav();
+        if (!nav) {
+            log.warn(step, '底部导航上找不到 Profile 入口');
+            return false;
+        }
+        TikTokAuto.ui.clickNode(nav, step);
+
+        var ready = TikTokAuto.wait.waitFor(step, function () {
+            return where() === 'profile' ? true : null;
+        }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
+
+        if (ready !== true) {
+            log.warn(step, '点了 Profile 但没进入用户主页');
+            return false;
+        }
+        /* ★ 必须等**三组数字**读全,不能用 settle(3000)(2026-10-29 真机踩到)。
+         *
+         * 「username 出现」比「数字渲染完」早得多:实测点完 Profile 1 秒就
+         * `where() === 'profile'` 通过,而 fans 入口挂在数字上 —— 这时 `statEntry('fans')`
+         * 找不到,上层看到的是"用户主页上找不到 fans 入口(等了 3 秒仍没有)",
+         * 一次运行就此失败。`settle()` 在动态页面上会提前返回(它的判据是
+         * "文案连续两次不变",而加载中与渲染完的签名可能一样),挡不住这件事。
+         *
+         * `waitProfile` 的判据正好是「username + 关注/粉丝/获赞三组都读到」,
+         * 而且它读不到时**只警告不失败**(有的账号确实不显示某一组)。 */
+        waitProfile(step + ':stats');
+        return true;
+    }
+
     /** 在当前搜索结果里按 uid 找行(找不到返回 null,不猜第一行) */
-    function findRowByUid(uid) {
-        var rows = TikTokAuto.tiktok.usersResultRows();
+    function findRowByUid(uid) {        var rows = TikTokAuto.tiktok.usersResultRows();
         for (var i = 0; i < rows.length; i += 1) {
             if (rows[i].uid === uid) {
                 return rows[i];
@@ -774,6 +821,7 @@ TikTokAuto.profile = (function () {
         where: where,
         ensureFeed: ensureFeed,
         openBySearch: openBySearch,
+        openOwnProfile: openOwnProfile,
         findRowByUid: findRowByUid,
         openList: openList,
         switchListTab: switchListTab,

+ 25 - 0
tiktok-auto/src/tiktok.js

@@ -125,6 +125,30 @@ TikTokAuto.tiktok = (function () {
         return layout.findOnce(desc('Home')) !== null && layout.findOnce(desc('Profile')) !== null;
     }
 
+    /**
+     * 底部导航的「Profile」项 —— 用来打开**自己的**主页(脚本二要自己的粉丝列表)。
+     *
+     * 判据刻意很松:`desc === 'Profile'` 精确匹配 + 中心落在屏幕下缘。
+     * 不限定 class(它是 FrameLayout,和历史记忆里"首页顶部 Following 页签是
+     * FrameLayout"同一族,限定 Button 会一个都找不到),也不认文案 ——
+     * 底部导航这一项没有 text。
+     *
+     * 为什么还要判"在下缘":右侧竖排作者头像的 desc 是 `xxx profile`(小写、
+     * 带用户名前缀),精确匹配 `Profile` 本来就排除掉了;但万一新版把它改成
+     * 纯 `Profile`,这一条能挡住"点到了头像"这种最坏情况。
+     */
+    function profileNav() {
+        var node = layout.findOnce(desc('Profile'));
+        if (!node) {
+            return null;
+        }
+        var point = layout.center(node);
+        if (!point || point.y < screenHeight() * 0.88) {
+            return null;
+        }
+        return node;
+    }
+
     // ---------------------------------------------------------------- 搜索页
 
     /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */
@@ -1418,6 +1442,7 @@ TikTokAuto.tiktok = (function () {
         searchSubmit: searchSubmit,
         searchHistoryPresent: searchHistoryPresent,
         bottomNavPresent: bottomNavPresent,
+        profileNav: profileNav,
         resultTab: resultTab,
         resultTabStripPresent: resultTabStripPresent,
         videoCards: videoCards,