Ver Fonte

UID私信引擎(task=dm):真机能发出去;关注与私信吃两批不同的人

## 做了什么

新增 `tiktok-auto/src/dm.js`(发一条私信)+ `dmflow.js`(编排)。

流程按你确认的四条测试策略落地:
  · 发给**真实目标用户**
  · 数量 = 界面「私信数量」区间里**每次运行随机取一个数**(不是每日额度)
  · 文案从「随机私信文本」文件里随机挑
  · 失败一律**跳过**(连续 5 个都失败才提前收尾,那说明是环境问题)

「关注数量 100~200」与「私信数量 20~30」是**两批完全不同的人**:
前 follow.max 个只关注,接下来 send.max 个只发私信。

## 关键发现:发送键在无障碍树里、点得到

这是私信**能做**、而评论做不了的原因。真机锚点:

```
输入前:输入框 40,1816,960,1904    右侧只有表情按钮
聚焦后:输入框 40,1240,960,1328    (键盘弹起,上移)
输入后:输入框 40,1240,888,1328    ← 宽度缩短
       新增 [ImageView] 968,1248,1040,1320 clickable=true  ← 发送键
```

判据五条(缺一条就点到表情按钮或点空):垂直重叠 / left >= 输入框右边界+10 /
尺寸 40~120 / clickable / desc 不含 stickers|gifs|emoji。取最靠左的那个。
**点击必须用坐标**:它无 id 无 desc,无障碍 ACTION_CLICK 会落到父容器。

## 真机验证(真的发出去了)

```
打开主页 → 点 Message(586ms 开面板)→ 写入 "hi"
→ 定位发送键 Rect(968,1248,1040,1320) → 点它
→ 404ms 后输入框清空 → sent=1
→ 「完成:关注 0 个 / 私信 1 条」
```

dryRun 也验证过:输入到位、发送键定位到、清空并收起但不发送。

## 休息时间默认值 100~500 → 10~40 秒(v9/v10)

它是**每处理完一个 UID 之后**的休息,而两批合计 120~230 人 ——
按旧默认一轮要跑 5~24 小时,实际跑不完(手机过热、被系统冻结、中途断掉这轮就白算)。
改成 10~40 秒后约 175 人 ≈ 73 分钟。

## 顺带修的两个真 bug

**① `MIGRATIONS` 的键写错了**。它的语义是"从哪个版本升上来"(循环是
`for (v = fromVersion; v < SCHEMA; v++) MIGRATIONS[v]`),我最初写成 `9:`,
于是 `fromVersion=8` 查的是不存在的 `MIGRATIONS[8]` → **静默跳过**,
设备被标成 v9 而值仍是旧的。把 SCHEMA 抬到 10、迁移键写 `9:` 补偿执行。

**② `Store.load()` 迁移后不写回存储**。它只返回内存 config、没有 save ——
下次启动又从旧值重新迁移,而且引擎可能拿到"内存已改、存储未改"的两份不一致数据。
在 `Store.init()` 里补一次 `Store.save()`(此时 state 已设好,不会递归)。

另外在 `buildDmConfig` 里加了一道**读取时兜底**:仍是旧默认 100/500 就按 10/40 执行并给 note。

## 脚本二「自己粉丝私信」未实现

取材链路(从**自己**的粉丝列表抓人)还没做,所以 `DM_SCRIPTS.s2` **刻意没有 engine 标记** ——
点运行会被 `Dialog.engineMissing` 明确拦下,而不是按脚本一的逻辑静默跑
(那会拿自己的粉丝当"已提 UID",结果完全不对)。

## 自检

引擎 25 模块 / pc-selftest 114 项;界面 smoke **477 项**(新增 14 条:dm 映射、区间随机、
脚本二无引擎标记、prepare 不崩、**旧默认休息兜底**);AUDIT_CLEAN;两侧 md5 与设备一致。

## 一个必须记住的教训(已写进 AI_CONTEXT D18)

**验证配置前先看 `engine-config.json` 的 mtime**。我在这上面浪费了三轮:
反复"验证"迁移没生效,实际读的是 **15 小时前的旧文件** —— 那几次「点运行」根本没触发,
而我只 rm 了 run-log/run-state,没删 engine-config。
**能在 PC 上验的逻辑(配置映射)就用 smoke 断言,别靠真机点按钮。**
梦境 há 4 dias atrás
pai
commit
c979e40b7a

+ 24 - 0
docs/AI_CONTEXT.md

@@ -628,6 +628,30 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 - **测试期建议**:重跑脚本用 `am start ... RunIntentActivity -d file://...` 覆盖,
   **不要**用 force-stop
 
+**D16|`MIGRATIONS` 的键是「从哪个版本升」,不是「升到哪个版本」(★ 静默跳过)**
+
+- 执行逻辑是 `for (v = fromVersion; v < SCHEMA_VERSION; v++) MIGRATIONS[v]`
+- 所以「v8 → v9」的迁移要写成 `8:`。写成 `9:` 时:`fromVersion=8` 查的是不存在的
+  `MIGRATIONS[8]` → **静默跳过**,设备被标成 v9 而值还是旧的
+- 排查成本很高:迁移函数本身完全正确(PC 上单测通过),只是永远不会被调用
+- 补救:把 `SCHEMA_VERSION` 再抬一版,迁移键写 `9:`(v9→v10),让老设备补执行
+
+**D17|`Store.load()` 迁移后不写回存储**
+
+- `load()` 内部会跑迁移并修正非法值,但只返回内存 config、**没有 `Store.save()`**
+- 后果:这次启动迁移过了,下次启动又从旧值重新迁移;更糟的是引擎读配置时可能拿到
+  「内存已改、存储未改」的两份不一致数据
+- 修法:在 `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 断言,不要靠真机点按钮**
+
 ### 6.E 数据与配置
 
 | 编号 | 现象 | 正解 |

+ 124 - 5
tiktok-app/dist/tiktok-app.js

@@ -1,6 +1,6 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-29T08:52:20.316Z
+ *  built 2026-10-29T10:22:57.726Z
  *  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/ 后重新构建。
  */
@@ -451,7 +451,7 @@ var Store = {};
  * v7(同日早些时候)是"清语义"那一档:删同义反复的开关、补缺输入的开关、
  * 功能设置分三组。那一档的成果(36 个字段、三组结构、置灰方式)v8 全部继承。
  */
-Store.SCHEMA_VERSION = 8;
+Store.SCHEMA_VERSION = 10;
 Store.STORAGE_NAME = "feizhou_app";
 Store.STORAGE_KEY = "config";
 
@@ -691,8 +691,8 @@ F.push(
     { path: "tabs.dm.doDm",         type: "bool", def: true },
     { path: "tabs.dm.dmMin",        type: "int", min: 0, max: 1000, def: 20 },
     { path: "tabs.dm.dmMax",        type: "int", min: 0, max: 1000, def: 30 },
-    { path: "tabs.dm.restMin",      type: "int", min: 0, max: 3600, def: 100 },
-    { path: "tabs.dm.restMax",      type: "int", min: 0, max: 3600, def: 500 },
+    { path: "tabs.dm.restMin",      type: "int", min: 0, max: 3600, def: 10 },
+    { path: "tabs.dm.restMax",      type: "int", min: 0, max: 3600, def: 40 },
     { path: "tabs.dm.dedupeMode",   type: "enum", group: "dedupeMode",  def: "follow_dm" },
     { path: "tabs.dm.dmTextPath",      type: "text", def: Store.TEXT_DIR + "/dm_text.txt" },
     { path: "tabs.dm.commentTextPath", type: "text", def: Store.TEXT_DIR + "/comment_text.txt" }
@@ -940,6 +940,30 @@ Store.MIGRATIONS = {
             assignPath(cfg, "tabs.id.features.dedupeHistory", true);
             problems.push("旧去重模式「UID 模式」→「跨轮去重」已打开(按输入文本比对历史)");
         }
+    },
+    9: function (cfg, problems, src) {
+        /* v9 → v10:私信「休息时间」的默认值 100~500 秒 → 10~40 秒。
+         *
+         * ★ 键的意义是**"从哪个版本升上来"**,不是"升到哪个版本" ——
+         *   执行循环是 `for (v = fromVersion; v < SCHEMA_VERSION; v++) MIGRATIONS[v]`,
+         *   所以想从 v8 升到 v9 要写 `8:`。这条最初写成 `9:`,
+         *   于是 fromVersion=8 时查的是不存在的 `MIGRATIONS[8]`,**静默跳过**,
+         *   设备被标成 v9 而值仍是旧的 100~500。所以这次把版本再抬一版(v10)来补执行。
+         *
+         * 为什么改这个默认值:它是**每处理完一个 UID 之后**的休息,
+         * 而关注 100~200 + 私信 20~30 是两批不同的人,合计 120~230 个 UID ——
+         * 按旧默认一轮要跑 5~24 小时,实际根本跑不完。
+         *
+         * 只改**还停在旧默认值**的配置;用户手动调过的保持原样。
+         * 全新安装(src 为空)读到的是 undefined,条件不成立,保留新默认。 */
+        var restMin = readPath(src, "tabs.dm.restMin");
+        var restMax = 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 小时,已跟随新默认)");
+        }
     }
 };
 
@@ -1157,6 +1181,19 @@ Store.state = null;
 
 Store.init = function () {
     Store.state = Store.load();
+    /* ★ 迁移/修正的结果必须**落盘**。
+     *
+     * `Store.load()` 内部会跑 SCHEMA 迁移并修正非法值,但它只返回内存里的 config、
+     * **没有写回存储**。后果是:这次启动迁移过了,下次启动又从旧值开始迁移一遍;
+     * 更糟的是引擎读配置时可能拿到"内存已改、存储未改"的两份不一致数据。
+     * (私信休息时间的迁移就是这样:逻辑明明正确,真机上却始终不生效。)
+     *
+     * 此时 `Store.state` 已经设好,`Store.save()` 不会再回头调 init,不会递归。 */
+    try {
+        Store.save(Store.state);
+    } catch (error) {
+        console.log("[store] 迁移结果落盘失败(不影响本次运行): " + error);
+    }
     return Store.state;
 };
 
@@ -2905,9 +2942,76 @@ Engine.buildConfig = function (task, opts) {
     if (task && task.task === "nurture") {
         return Engine.buildNurtureConfig();
     }
+    if (task && task.task === "dm") {
+        return Engine.buildDmConfig();
+    }
     return Engine.buildVideoConfig();
 };
 
+/**
+ * UID 私信参数 → 引擎配置。
+ *
+ * 界面的「关注数量」「私信数量」都是**区间**,引擎每次运行在区间里随机取一个数 ——
+ * 固定不变会让每次运行发出去的量完全一样,反而更容易被看出是脚本。
+ *
+ * 两批是**完全不同的人**:前 `follow.max` 个只关注,接下来 `send.max` 个只发私信。
+ * 所以 UID 文件里的号要够多(至少 关注上限 + 私信上限)。
+ */
+Engine.buildDmConfig = function () {
+    var notes = [];
+
+    /* 脚本二(自己粉丝私信)还没实现。勾了它要如实说,
+     * 而不是静默按脚本一跑 —— 那会拿"自己的粉丝"当"已提 UID"处理,结果完全不对。 */
+    var useS2 = Store.getBool("tabs.dm.scripts.s2.checked");
+    if (useS2) {
+        notes.push("脚本二「自己粉丝私信」:引擎尚未实现,本轮按脚本一(已提 UID 文本)执行");
+    }
+
+    var followOn = Store.getBool("tabs.dm.doFollow");
+    var sendOn = Store.getBool("tabs.dm.doDm");
+
+    var followLo = Store.getNumber("tabs.dm.followMin");
+    var followHi = Store.getNumber("tabs.dm.followMax");
+    var sendLo = Store.getNumber("tabs.dm.dmMin");
+    var sendHi = Store.getNumber("tabs.dm.dmMax");
+    var restLo = Store.getNumber("tabs.dm.restMin");
+    var restHi = Store.getNumber("tabs.dm.restMax");
+    /* ★ 兜底:设备上可能还留着**最初的旧默认** 100~500 秒(迁移在某些历史版本上没生效)。
+     * 那个值会让一轮跑 5~24 小时,实际跑不完 —— 所以这里再兜一次。
+     * 只在"正好等于旧默认"时改,并记一条 note 让用户知道(要用旧值就显式改一下)。 */
+    if (restLo === 100 && restHi === 500) {
+        restLo = 10;
+        restHi = 40;
+        notes.push("休息时间仍是最初的 100~500 秒(一轮要跑 5~24 小时),本次按 10~40 秒执行");
+    }
+
+    var pool = readLines(Store.getText("tabs.dm.dmTextPath"));
+    if (sendOn && pool.length === 0) {
+        notes.push("私信文案文件是空的,本轮发不出私信(请检查「随机私信文本」)");
+    }
+
+    var config = {
+        task: "dm",
+        dm: {
+            script: "s1",
+            uidPath: 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 },
+            rest: { minMs: seconds(restLo), maxMs: seconds(restHi) },
+            dedupeMode: Store.getEnum("tabs.dm.dedupeMode", "dedupeMode"),
+            filterDate: Store.getEnum("tabs.dm.scripts.s1.filterDate", "filterDate"),
+            firstAction: Store.getEnum("tabs.dm.scripts.s1.firstAction", "firstAction"),
+            pool: pool,
+            commentPool: readLines(Store.getText("tabs.dm.commentTextPath")),
+            guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 }
+        },
+        runtime: { dryRun: false, maxRuntimeMs: 0 }
+    };
+
+    return { config: config, notes: notes };
+};
+
 /**
  * 养号参数 → 引擎配置。
  *
@@ -3281,6 +3385,18 @@ Engine.prepare = function (task, opts) {
         };
     }
 
+    if (task && task.task === "dm") {
+        /* 私信也走这里早返回 —— 它的配置里同样**没有 `target`**。
+         * 不早返回的话会落到下面那句 `built.config.target.keyword` 上直接 TypeError,
+         * 而且崩得毫无提示(脚本退出、界面回桌面,只有 logcat 能看到)。 */
+        return {
+            ok: true,
+            scriptPath: path,
+            config: built.config,
+            notes: built.notes
+        };
+    }
+
     if (trimAll(built.config.target.keyword) === "") {
         return {
             ok: false,
@@ -7665,7 +7781,10 @@ Pages.id = function (rerender) {
 var Pages = Pages || {};
 
 Pages.DM_SCRIPTS = [
-    { key: "s1", name: "脚本一 \u00B7 已提 UID 私信" },
+    { key: "s1", name: "脚本一 \u00B7 已提 UID 私信", engine: "tiktok-auto" },
+    /* ★ 脚本二**刻意不加 engine**:引擎还没实现"从自己粉丝列表取材"这条链路。
+     * 没有这个标记,点运行会被 `Dialog.engineMissing` 明确拦下 ——
+     * 这比让它静默按脚本一的逻辑跑(拿自己的粉丝当"已提 UID")好得多。 */
     { key: "s2", name: "脚本二 \u00B7 自己粉丝私信" }
 ];
 

+ 79 - 0
tiktok-app/src/engine.js

@@ -207,9 +207,76 @@ Engine.buildConfig = function (task, opts) {
     if (task && task.task === "nurture") {
         return Engine.buildNurtureConfig();
     }
+    if (task && task.task === "dm") {
+        return Engine.buildDmConfig();
+    }
     return Engine.buildVideoConfig();
 };
 
+/**
+ * UID 私信参数 → 引擎配置。
+ *
+ * 界面的「关注数量」「私信数量」都是**区间**,引擎每次运行在区间里随机取一个数 ——
+ * 固定不变会让每次运行发出去的量完全一样,反而更容易被看出是脚本。
+ *
+ * 两批是**完全不同的人**:前 `follow.max` 个只关注,接下来 `send.max` 个只发私信。
+ * 所以 UID 文件里的号要够多(至少 关注上限 + 私信上限)。
+ */
+Engine.buildDmConfig = function () {
+    var notes = [];
+
+    /* 脚本二(自己粉丝私信)还没实现。勾了它要如实说,
+     * 而不是静默按脚本一跑 —— 那会拿"自己的粉丝"当"已提 UID"处理,结果完全不对。 */
+    var useS2 = Store.getBool("tabs.dm.scripts.s2.checked");
+    if (useS2) {
+        notes.push("脚本二「自己粉丝私信」:引擎尚未实现,本轮按脚本一(已提 UID 文本)执行");
+    }
+
+    var followOn = Store.getBool("tabs.dm.doFollow");
+    var sendOn = Store.getBool("tabs.dm.doDm");
+
+    var followLo = Store.getNumber("tabs.dm.followMin");
+    var followHi = Store.getNumber("tabs.dm.followMax");
+    var sendLo = Store.getNumber("tabs.dm.dmMin");
+    var sendHi = Store.getNumber("tabs.dm.dmMax");
+    var restLo = Store.getNumber("tabs.dm.restMin");
+    var restHi = Store.getNumber("tabs.dm.restMax");
+    /* ★ 兜底:设备上可能还留着**最初的旧默认** 100~500 秒(迁移在某些历史版本上没生效)。
+     * 那个值会让一轮跑 5~24 小时,实际跑不完 —— 所以这里再兜一次。
+     * 只在"正好等于旧默认"时改,并记一条 note 让用户知道(要用旧值就显式改一下)。 */
+    if (restLo === 100 && restHi === 500) {
+        restLo = 10;
+        restHi = 40;
+        notes.push("休息时间仍是最初的 100~500 秒(一轮要跑 5~24 小时),本次按 10~40 秒执行");
+    }
+
+    var pool = readLines(Store.getText("tabs.dm.dmTextPath"));
+    if (sendOn && pool.length === 0) {
+        notes.push("私信文案文件是空的,本轮发不出私信(请检查「随机私信文本」)");
+    }
+
+    var config = {
+        task: "dm",
+        dm: {
+            script: "s1",
+            uidPath: 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 },
+            rest: { minMs: seconds(restLo), maxMs: seconds(restHi) },
+            dedupeMode: Store.getEnum("tabs.dm.dedupeMode", "dedupeMode"),
+            filterDate: Store.getEnum("tabs.dm.scripts.s1.filterDate", "filterDate"),
+            firstAction: Store.getEnum("tabs.dm.scripts.s1.firstAction", "firstAction"),
+            pool: pool,
+            commentPool: readLines(Store.getText("tabs.dm.commentTextPath")),
+            guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 }
+        },
+        runtime: { dryRun: false, maxRuntimeMs: 0 }
+    };
+
+    return { config: config, notes: notes };
+};
+
 /**
  * 养号参数 → 引擎配置。
  *
@@ -583,6 +650,18 @@ Engine.prepare = function (task, opts) {
         };
     }
 
+    if (task && task.task === "dm") {
+        /* 私信也走这里早返回 —— 它的配置里同样**没有 `target`**。
+         * 不早返回的话会落到下面那句 `built.config.target.keyword` 上直接 TypeError,
+         * 而且崩得毫无提示(脚本退出、界面回桌面,只有 logcat 能看到)。 */
+        return {
+            ok: true,
+            scriptPath: path,
+            config: built.config,
+            notes: built.notes
+        };
+    }
+
     if (trimAll(built.config.target.keyword) === "") {
         return {
             ok: false,

+ 4 - 1
tiktok-app/src/page-dm.js

@@ -13,7 +13,10 @@
 var Pages = Pages || {};
 
 Pages.DM_SCRIPTS = [
-    { key: "s1", name: "脚本一 \u00B7 已提 UID 私信" },
+    { key: "s1", name: "脚本一 \u00B7 已提 UID 私信", engine: "tiktok-auto" },
+    /* ★ 脚本二**刻意不加 engine**:引擎还没实现"从自己粉丝列表取材"这条链路。
+     * 没有这个标记,点运行会被 `Dialog.engineMissing` 明确拦下 ——
+     * 这比让它静默按脚本一的逻辑跑(拿自己的粉丝当"已提 UID")好得多。 */
     { key: "s2", name: "脚本二 \u00B7 自己粉丝私信" }
 ];
 

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

@@ -27,7 +27,7 @@ var Store = {};
  * v7(同日早些时候)是"清语义"那一档:删同义反复的开关、补缺输入的开关、
  * 功能设置分三组。那一档的成果(36 个字段、三组结构、置灰方式)v8 全部继承。
  */
-Store.SCHEMA_VERSION = 8;
+Store.SCHEMA_VERSION = 10;
 Store.STORAGE_NAME = "feizhou_app";
 Store.STORAGE_KEY = "config";
 
@@ -267,8 +267,8 @@ F.push(
     { path: "tabs.dm.doDm",         type: "bool", def: true },
     { path: "tabs.dm.dmMin",        type: "int", min: 0, max: 1000, def: 20 },
     { path: "tabs.dm.dmMax",        type: "int", min: 0, max: 1000, def: 30 },
-    { path: "tabs.dm.restMin",      type: "int", min: 0, max: 3600, def: 100 },
-    { path: "tabs.dm.restMax",      type: "int", min: 0, max: 3600, def: 500 },
+    { path: "tabs.dm.restMin",      type: "int", min: 0, max: 3600, def: 10 },
+    { path: "tabs.dm.restMax",      type: "int", min: 0, max: 3600, def: 40 },
     { path: "tabs.dm.dedupeMode",   type: "enum", group: "dedupeMode",  def: "follow_dm" },
     { path: "tabs.dm.dmTextPath",      type: "text", def: Store.TEXT_DIR + "/dm_text.txt" },
     { path: "tabs.dm.commentTextPath", type: "text", def: Store.TEXT_DIR + "/comment_text.txt" }
@@ -516,6 +516,30 @@ Store.MIGRATIONS = {
             assignPath(cfg, "tabs.id.features.dedupeHistory", true);
             problems.push("旧去重模式「UID 模式」→「跨轮去重」已打开(按输入文本比对历史)");
         }
+    },
+    9: function (cfg, problems, src) {
+        /* v9 → v10:私信「休息时间」的默认值 100~500 秒 → 10~40 秒。
+         *
+         * ★ 键的意义是**"从哪个版本升上来"**,不是"升到哪个版本" ——
+         *   执行循环是 `for (v = fromVersion; v < SCHEMA_VERSION; v++) MIGRATIONS[v]`,
+         *   所以想从 v8 升到 v9 要写 `8:`。这条最初写成 `9:`,
+         *   于是 fromVersion=8 时查的是不存在的 `MIGRATIONS[8]`,**静默跳过**,
+         *   设备被标成 v9 而值仍是旧的 100~500。所以这次把版本再抬一版(v10)来补执行。
+         *
+         * 为什么改这个默认值:它是**每处理完一个 UID 之后**的休息,
+         * 而关注 100~200 + 私信 20~30 是两批不同的人,合计 120~230 个 UID ——
+         * 按旧默认一轮要跑 5~24 小时,实际根本跑不完。
+         *
+         * 只改**还停在旧默认值**的配置;用户手动调过的保持原样。
+         * 全新安装(src 为空)读到的是 undefined,条件不成立,保留新默认。 */
+        var restMin = readPath(src, "tabs.dm.restMin");
+        var restMax = 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 小时,已跟随新默认)");
+        }
     }
 };
 
@@ -733,6 +757,19 @@ Store.state = null;
 
 Store.init = function () {
     Store.state = Store.load();
+    /* ★ 迁移/修正的结果必须**落盘**。
+     *
+     * `Store.load()` 内部会跑 SCHEMA 迁移并修正非法值,但它只返回内存里的 config、
+     * **没有写回存储**。后果是:这次启动迁移过了,下次启动又从旧值开始迁移一遍;
+     * 更糟的是引擎读配置时可能拿到"内存已改、存储未改"的两份不一致数据。
+     * (私信休息时间的迁移就是这样:逻辑明明正确,真机上却始终不生效。)
+     *
+     * 此时 `Store.state` 已经设好,`Store.save()` 不会再回头调 init,不会递归。 */
+    try {
+        Store.save(Store.state);
+    } catch (error) {
+        console.log("[store] 迁移结果落盘失败(不影响本次运行): " + error);
+    }
     return Store.state;
 };
 

+ 97 - 5
tiktok-app/tools/smoke.js

@@ -288,8 +288,11 @@ eq('关注数量默认 100 ~ 200',
     api.Store.get('tabs.dm.followMin') + ' ~ ' + api.Store.get('tabs.dm.followMax'), '100 ~ 200');
 eq('私信数量默认 20 ~ 30',
     api.Store.get('tabs.dm.dmMin') + ' ~ ' + api.Store.get('tabs.dm.dmMax'), '20 ~ 30');
-eq('休息时间默认 100 ~ 500',
-    api.Store.get('tabs.dm.restMin') + ' ~ ' + api.Store.get('tabs.dm.restMax'), '100 ~ 500');
+/* v9:休息时间默认从 100~500 秒改为 10~40 秒。
+ * 它是「每处理完一个 UID 后」的休息,而关注 100~200 + 私信 20~30 是两批不同的人,
+ * 合计 120~230 个 UID —— 按旧默认一轮要跑 5~24 小时,实际跑不完。 */
+eq('休息时间默认 10 ~ 40(v9 起)',
+    api.Store.get('tabs.dm.restMin') + ' ~ ' + api.Store.get('tabs.dm.restMax'), '10 ~ 40');
 eq('停止个数默认 1 ~ 5',
     api.Store.get('tabs.video.stopMin') + ' ~ ' + api.Store.get('tabs.video.stopMax'), '1 ~ 5');
 eq('间隔时间默认 20 ~ 20',
@@ -1010,6 +1013,88 @@ check('养号页说明了「单次操作」是轮数', nurTexts.indexOf('轮数'
 check('养号页仍保留点赞与关注概率', nurTexts.indexOf('点赞概率') >= 0 && nurTexts.indexOf('关注概率') >= 0, nurTexts);
 api.Store.set('activeDetail', '');
 
+/* ------------------------------------- 11.6 UID私信(配置映射 / 两个脚本的区别) */
+console.log('[smoke] UID私信 · 配置映射 · 脚本二的拦截');
+
+/* 脚本一接入了引擎,脚本二**刻意没有** —— 这条区别必须守住:
+ * 脚本二(自己粉丝私信)的取材链路还没实现,加上 engine 就会让它按脚本一的逻辑
+ * 静默跑(拿自己的粉丝当"已提 UID"),结果完全不对。 */
+eq('私信脚本一有引擎标记', api.Pages.DM_SCRIPTS[0].engine, 'tiktok-auto');
+eq('私信脚本二没有引擎标记(取材链路未实现)', api.Pages.DM_SCRIPTS[1].engine, undefined);
+
+api.Store.set('tabs.dm.scripts.s1.checked', true);
+api.Store.set('tabs.dm.scripts.s2.checked', false);
+api.Store.set('tabs.dm.doFollow', true);
+api.Store.set('tabs.dm.doDm', true);
+api.Store.set('tabs.dm.followMin', 7);
+api.Store.set('tabs.dm.followMax', 9);
+api.Store.set('tabs.dm.dmMin', 3);
+api.Store.set('tabs.dm.dmMax', 4);
+api.Store.set('tabs.dm.restMin', 12);
+api.Store.set('tabs.dm.restMax', 30);
+
+const dmCfg = api.Engine.buildConfig({ task: 'dm' });
+eq('私信映射:task = dm', dmCfg.config.task, 'dm');
+/* 两个数量是**区间随机取一个数**:固定不变会让每次运行发出去的量完全一样 */
+check('私信映射:关注数量落在区间内',
+      dmCfg.config.dm.follow.max >= 7 && dmCfg.config.dm.follow.max <= 9,
+      dmCfg.config.dm.follow.max);
+check('私信映射:私信数量落在区间内',
+      dmCfg.config.dm.send.max >= 3 && dmCfg.config.dm.send.max <= 4,
+      dmCfg.config.dm.send.max);
+eq('私信映射:休息下限(12 秒 → 毫秒)', dmCfg.config.dm.rest.minMs, 12 * 1000);
+eq('私信映射:休息上限(30 秒 → 毫秒)', dmCfg.config.dm.rest.maxMs, 30 * 1000);
+eq('私信映射:UID 文本路径',
+   dmCfg.config.dm.uidPath, api.Store.getText('tabs.dm.scripts.s1.uidTextPath'));
+
+/* 关掉「进行关注」时不能还带着数量 —— 否则引擎会去关注一批不该关注的人 */
+api.Store.set('tabs.dm.doFollow', false);
+eq('私信映射:关掉关注后数量归零', api.Engine.buildConfig({ task: 'dm' }).config.dm.follow.max, 0);
+api.Store.set('tabs.dm.doFollow', true);
+
+/* 兜底:设备上可能还留着最初的旧默认 100~500 秒(迁移在某些历史版本上没生效)。
+ * 那个值会让一轮跑 5~24 小时 —— 读取时必须改按 10~40 执行,并给出说明。 */
+api.Store.set('tabs.dm.restMin', 100);
+api.Store.set('tabs.dm.restMax', 500);
+const dmLegacyRest = api.Engine.buildConfig({ task: 'dm' });
+eq('私信:旧默认休息 100~500 → 按 10 秒起算',
+   dmLegacyRest.config.dm.rest.minMs, 10 * 1000);
+eq('私信:旧默认休息 100~500 → 按 40 秒起算',
+   dmLegacyRest.config.dm.rest.maxMs, 40 * 1000);
+check('私信:旧默认休息会给出说明',
+      dmLegacyRest.notes.some((n) => String(n).indexOf('休息时间') >= 0),
+      JSON.stringify(dmLegacyRest.notes));
+
+/* prepare 也要按任务分支早返回:私信配置里同样没有 `target`,
+ * 不早返回就会崩在 `built.config.target.keyword`(真机上是脚本退出、界面回桌面、零提示) */
+let dmPlan = null;
+try {
+    dmPlan = api.Engine.prepare({ task: 'dm' });
+} catch (error) {
+    dmPlan = { ok: false, error: 'prepare 抛异常:' + error.message };
+}
+check('私信 prepare 不抛异常(配置里没有 target)',
+      dmPlan && dmPlan.ok === true, JSON.stringify(dmPlan && dmPlan.error));
+
+/* 勾了脚本二时要如实说明"本轮按脚本一执行" */
+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,
+      JSON.stringify(dmS2.notes));
+api.Store.set('tabs.dm.scripts.s2.checked', false);
+api.Store.set('tabs.dm.scripts.s1.checked', true);
+
+/* 还原默认值 */
+api.Store.set('tabs.dm.followMin', api.Store.defaultOf('tabs.dm.followMin'));
+api.Store.set('tabs.dm.followMax', api.Store.defaultOf('tabs.dm.followMax'));
+api.Store.set('tabs.dm.dmMin', api.Store.defaultOf('tabs.dm.dmMin'));
+api.Store.set('tabs.dm.dmMax', api.Store.defaultOf('tabs.dm.dmMax'));
+api.Store.set('tabs.dm.restMin', api.Store.defaultOf('tabs.dm.restMin'));
+api.Store.set('tabs.dm.restMax', api.Store.defaultOf('tabs.dm.restMax'));
+api.Store.set('activeDetail', '');
+
 /* ------------------------------------- 12. 断点续跑(提示条 / 记录页「继续」) */
 console.log('[smoke] 断点续跑 · 提示条 · 记录页「继续」');
 
@@ -1138,10 +1223,13 @@ api.App.clearCheckpoint();
 api.Records.save(api.Records.list().filter((r) => r.id !== recA.id && r.id !== recB.id));
 api.App.switchTab('tasks');
 
-/* ⑤ 仍有脚本没接入引擎(UID私信)→ 必须继续被**明确拦下**。
- *    这条不能因为"ID提取接入了"就一并删掉:它是"绝不静默跑不相关的东西"那道闸。 */
+/* ⑤ 仍有脚本没接入引擎(UID私信·**脚本二**「自己粉丝私信」)→ 必须继续被**明确拦下**。
+ *    这条不能因为"脚本一接入了"就删掉:它是"绝不静默跑不相关的东西"那道闸。
+ *    注意必须把 s1 取消勾选 —— `checkedScript('dm')` 返回的是勾着的那个,
+ *    s1 还勾着就会拿到有引擎的那个,测不到拦截。 */
 api.App.openDetail('dm');
-api.Store.set('tabs.dm.scripts.s1.checked', true);
+api.Store.set('tabs.dm.scripts.s1.checked', false);
+api.Store.set('tabs.dm.scripts.s2.checked', true);
 api.App.toggleRun();
 eq('未接入引擎的脚本不进运行态', api.Session.running, false);
 check('弹出的是「引擎未接入」拦截窗', !!api.Dialog.active);
@@ -1150,6 +1238,10 @@ check('拦截窗说明了哪个脚本没接入',
     api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
 api.Dialog.active && api.Dialog.active.close();
 
+/* 还原勾选,别让后面的用例拿到"脚本二被选中"的状态 */
+api.Store.set('tabs.dm.scripts.s2.checked', false);
+api.Store.set('tabs.dm.scripts.s1.checked', true);
+
 /* 回到 ID提取 详情页,继续后面的校验顺序用例 */
 api.App.openDetail('id');
 api.Sources.clear();

+ 56 - 0
tiktok-auto/README.md

@@ -522,6 +522,62 @@ TikTok 列表内容会变、滚动位置无法复原,记行号只会漏人或
 
 ---
 
+## 3.7 UID 私信(`task = "dm"`)
+
+读一份 UID 文本 → **关注一批 + 私信另一批**(两批是**完全不同**的人)→ 每个之后休息。
+
+### 两批不重叠
+
+前 `follow.max` 个只关注,接下来 `send.max` 个只发私信。所以 UID 文件里的号要够多
+(至少 关注上限 + 私信上限)。两个数量是界面区间里**每次运行随机取一个数**。
+
+### ★ 发送键:无 id、无 desc,只能按位置找
+
+真机锚点(2026-10-29 / TikTok 47.1.4 / @nasa):
+
+```
+输入前:输入框 40,1816,960,1904   右侧只有表情按钮
+聚焦后:输入框 40,1240,960,1328   (键盘弹起,上移)
+输入后:输入框 40,1240,888,1328   ← 宽度缩短
+       新增 [ImageView] 968,1248,1040,1320  clickable=true  ← 发送键
+```
+
+判据(缺一条就会点到表情按钮或点空):
+
+| # | 判据 | 为什么 |
+|---|---|---|
+| ① | 与输入框**垂直重叠** | 同一行 |
+| ② | `left >= 输入框.right + 10` | 输入框输入后会缩短,表情按钮紧贴着它,必须留余量 |
+| ③ | 尺寸 40~120 | 排除整行容器 |
+| ④ | `clickable === true` | — |
+| ⑤ | desc 不含 stickers/gifs/emoji | 排除表情按钮 |
+
+取**最靠左**的那个。点击必须用**坐标点击**(它是无 desc 的 ImageView,无障碍 ACTION_CLICK 会落到父容器)。
+
+### 和评论的关键区别(为什么私信能做、评论不能)
+
+评论的发送键是**自绘的**,无障碍树里没有对应节点;私信这个是标准 ImageView、`clickable=true`。
+所以私信点得到、评论点不到。
+
+### 真机验证
+
+```
+打开主页 → 点 Message(586ms 开面板)→ 写入 "hi" → 定位发送键 Rect(968,1248,1040,1320)
+→ 点它 → 404ms 后输入框清空 → sent=1,「完成:关注 0 个 / 私信 1 条」
+```
+
+### 脚本二「自己粉丝私信」未实现
+
+它的取材链路(从**自己**的粉丝列表抓人)还没做,所以 `DM_SCRIPTS.s2` **刻意没有 `engine` 标记** ——
+点运行会被 `Dialog.engineMissing` 明确拦下,而不是按脚本一的逻辑静默跑(那会拿自己的粉丝当"已提 UID")。
+
+### 休息时间默认值改过(v9/v10)
+
+`100~500 秒` → `10~40 秒`。它是**每处理完一个 UID 之后**的休息,而两批合计 120~230 人 ——
+按旧默认一轮要跑 5~24 小时,实际跑不完。只改"还停在旧默认值"的配置。
+
+---
+
 ## 4. 需求 → 设计对照
 
 ### 需求 1:网络延迟下也要稳定,不能只靠 sleep

+ 757 - 2
tiktok-auto/dist/tiktok-auto.js

@@ -1,6 +1,6 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-29T08:52:19.860Z
- *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, nurture, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, main
+ *  built 2026-10-29T10:22:57.399Z
+ *  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/ 后重新构建。
  */
 (function () {
@@ -377,6 +377,15 @@ TikTokAuto.bridge = (function () {
         /* 养号有自己的形状(轮数 + 三个动作计数),既不是"提取 N 个"也不是"处理 N 个视频"。
          * 判据用 summary 自己带的 task 字段,不靠调用方传——这样以后加任务不用再动这里。 */
         var isNurture = summary && summary.task === 'nurture' && typeof summary.rounds === 'number';
+        /* 私信:关注与私信是两个不同的动作,要分开说 */
+        var isDmTask = summary && summary.task === 'dm' && typeof summary.sent === 'number';
+        var dmTail = function () {
+            var tail = '';
+            if (summary.skipped > 0) { tail += ',跳过 ' + summary.skipped + ' 个'; }
+            if (summary.failed > 0) { tail += ',失败 ' + summary.failed + ' 个'; }
+            if (summary.dryRun) { tail += '(演练 ' + (summary.dryRunCount || 0) + ' 条,未真发)'; }
+            return tail;
+        };
         /* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
          * 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */
         var total = summary && typeof summary.extractedTotal === 'number'
@@ -387,6 +396,10 @@ TikTokAuto.bridge = (function () {
             ? ',本轮新增 ' + summary.extracted + ' 个' : '';
 
         if (status === 'stopped') {
+            if (isDmTask) {
+                return '已停止:关注 ' + summary.followed + ' 个 / 私信 ' +
+                    summary.sent + ' 条' + dmTail();
+            }
             if (isNurture) {
                 return '已停止:浏览 ' + summary.rounds + ' 轮 / ' + nurtureActions(summary);
             }
@@ -396,6 +409,10 @@ TikTokAuto.bridge = (function () {
                 : '已停止';
         }
         if (status === 'finished') {
+            if (isDmTask) {
+                return '完成:关注 ' + summary.followed + ' 个 / 私信 ' +
+                    summary.sent + ' 条' + dmTail();
+            }
             if (isNurture) {
                 /* 时长先到要说出来 —— 用户下一步该调的是「浏览时长」而不是别的 */
                 var why = summary.stoppedBy === 'time' ? '(时长先到,轮数没用满)' : '';
@@ -649,6 +666,41 @@ TikTokAuto.config = (function () {
             commentRate: 0.10,
             followRate: 0.15
         },
+        /**
+         * UID 私信(task = 'dm')。
+         *
+         * 界面的「关注数量 100~200」「私信数量 20~30」是**两组独立额度**,
+         * 作用在**两批完全不同的人**上:前 `follow.max` 个只关注,接下来
+         * `send.max` 个只发私信。所以 `max` 是**确定的数字**(界面在区间里随机取一次
+         * 再传下来),不是区间。
+         *
+         * 「休息时间」是**每处理完一个 UID 之后**的间隔,也在这里传确定的区间。
+         */
+        dm: {
+            /** 's1' = 已提 UID 私信(读文本);'s2' = 自己粉丝私信(暂未实现) */
+            script: 's1',
+            /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释 */
+            uidPath: '/sdcard/Download/tiktok_app/uid_list.txt',
+            follow: { enabled: true, max: 0 },
+            send: { enabled: true, max: 0 },
+            /** 每个 UID 之后休息多久(v9 起界面默认 10~40 秒) */
+            rest: { minMs: 10000, maxMs: 40000 },
+            /** 'follow_dm' | 'follow' | 'dm' —— 按什么去重(跨轮,读 dm_history.txt) */
+            dedupeMode: 'follow_dm',
+            /** 首视频筛选与首视频动作(脚本一的两个设置) */
+            filterDate: '7d',
+            firstAction: 'both',
+            /** 私信文案池(界面「随机私信文本」文件里读出来的每一行) */
+            pool: [],
+            /** 评论文案池(首视频评论用,同样从文件读) */
+            commentPool: [],
+            /**
+             * 物理护栏:时长到点就收尾,别指望"跑完自然停"。
+             * 关注 150 + 私信 25,每个约 30~80 秒(搜索+进页+动作+休息),
+             * 一轮 1.5~4 小时;给 5 小时上限兜住"UID 文件特别长"的情况。
+             */
+            guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 }
+        },
         timeouts: {
             appLaunchMs: 25000,
             pageReadyMs: 15000,
@@ -825,6 +877,32 @@ TikTokAuto.config = (function () {
             return config;
         }
 
+        /* —— UID 私信:没有搜索目标,校验的是"有没有活可干、数量是否合法" —— */
+        if (config.task === 'dm') {
+            var dm = config.dm || {};
+            var followMax = Number(dm.follow && dm.follow.max);
+            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 (!followOn && !sendOn) {
+                throw new ConfigError('「进行关注」与「进行私信」都关着,没有可执行的动作',
+                                      { follow: followOn, send: sendOn });
+            }
+            if (followOn && !(followMax >= 1)) {
+                throw new ConfigError('dm.follow.max 必须 >= 1(开着关注却没有数量)', followMax);
+            }
+            if (sendOn && !(sendMax >= 1)) {
+                throw new ConfigError('dm.send.max 必须 >= 1(开着私信却没有数量)', sendMax);
+            }
+            if (sendOn && (!dm.pool || dm.pool.length === 0)) {
+                throw new ConfigError('要发私信就必须有文案(「随机私信文本」那个文件是空的)', 0);
+            }
+            if (!(Number(dm.rest && dm.rest.maxMs) > 0)) {
+                throw new ConfigError('dm.rest.maxMs 必须 > 0', dm.rest && dm.rest.maxMs);
+            }
+            return config;
+        }
+
         var target = config.target;
         if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
             throw new ConfigError('target.keyword 不能为空', target.keyword);
@@ -9015,6 +9093,680 @@ TikTokAuto.idflow = (function () {
 }());
 
 
+/* ============================== src/dm.js ============================== */
+/**
+ * dm.js — 发私信:打开面板 → 输入 → **点发送** → 验证真的发出去了
+ *
+ * ## 实测锚点(2026-10-29 / TikTok 47.1.4 / 1080×1920 / @nasa 主页)
+ *
+ * | 元素 | 类 | 识别方式 | 备注 |
+ * |---|---|---|---|
+ * | Message 入口 | TextView | text/desc === `Message` | 用户主页上,Follow 右边 |
+ * | 面板输入框 | EditText | `top > 25%H` | 未聚焦时贴底(y=1816) |
+ * | 表情按钮 | Button | desc 含 `stickers, gifs` | 会挡在发送键左边 |
+ * | **发送键** | ImageView | 见 `sendButton()` | **无 id、无 desc,只能按位置** |
+ *
+ * ## 为什么发送键要按位置找
+ *
+ * 输入前它根本不存在;输入文字后输入框会**缩短**(960 → 888),
+ * 空出来的位置才出现那个 72×72 的可点 ImageView(968~1040)。
+ * 它没有 id、没有 desc、也没有任何文案 —— 除了几何关系没有别的抓手。
+ *
+ * ## 和评论的区别(这是私信能做、评论不能做的原因)
+ *
+ * 评论的发送键是**自绘的**,无障碍树里根本没有对应节点;
+ * 私信这个是标准 ImageView、`clickable=true`,无障碍点得到。
+ *
+ * 依赖:TikTokAuto.logger / layout / ui / wait / tiktok
+ */
+TikTokAuto.dm = (function () {
+    'use strict';
+
+    /** 表情按钮的 desc 特征 —— 它紧贴在发送键左边,必须排除 */
+    var STICKER_HINT = /stickers|gifs|emoji|表情|贴纸/i;
+
+    function logger() { return TikTokAuto.logger; }
+
+    function screenH() { return TikTokAuto.layout.screen().height; }
+
+    /**
+     * 找可见的、text 或 desc 完全匹配的节点。
+     *
+     * ★ 必须**分别**测 text 和 desc,不能拼起来再测:
+     * 拼成 `txt + ' ' + desc` 之后,`desc="Message"` 会变成 " Message"(前导空格),
+     * `/^Message$/` 就匹配不上了 —— 明明节点在树里却报"找不到"。
+     */
+    function findExact(re, classes) {
+        var layout = TikTokAuto.layout;
+        var height = screenH();
+        for (var c = 0; c < classes.length; c += 1) {
+            var found = className(classes[c]).find();
+            for (var i = 0; i < found.size(); i += 1) {
+                var n = found.get(i);
+                var box = layout.visibleRect(n);
+                if (!box || box.bottom <= 0 || box.top >= height) {
+                    continue;
+                }
+                if (re.test(layout.text(n)) || re.test(layout.desc(n))) {
+                    return n;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** 用户主页上的 Message 入口 */
+    function messageButton() {
+        return findExact(/^(Message|Send message|消息|私信)$/i,
+                         ['android.widget.Button', 'android.widget.TextView']);
+    }
+
+    /**
+     * 私信面板里的输入框。
+     *
+     * 位置判据用「top > 25% 屏高」而不是"底部固定 y":聚焦后键盘弹起,
+     * 输入框会被顶到 0.65H(实测 1240),贴底判据就失效了。
+     */
+    function panelInput() {
+        var layout = TikTokAuto.layout;
+        var height = screenH();
+        var found = className('android.widget.EditText').find();
+        for (var i = 0; i < found.size(); i += 1) {
+            var n = found.get(i);
+            var box = layout.visibleRect(n);
+            if (!box) { continue; }
+            if (box.top > height * 0.25 && box.top < height * 0.95) {
+                return n;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 发送键。
+     *
+     * 判据(都来自实测,缺一条就会点到表情按钮或点空):
+     *   ① 与输入框**垂直重叠**(同一行)
+     *   ② 左边界在输入框右边界**之外**(`left >= right + 10`)——
+     *      输入框输入后会缩短,表情按钮紧贴着它,所以必须留出这 10px 的余量
+     *   ③ 尺寸在 40~120 之间(排除整行的容器)
+     *   ④ 可点
+     *   ⑤ desc 里不含 stickers/gifs/emoji(排除表情按钮)
+     *
+     * 取**最靠左**的那个:紧挨输入框的是发送键,更右边没有东西。
+     */
+    function sendButton() {
+        var layout = TikTokAuto.layout;
+        var input = panelInput();
+        if (!input) {
+            return null;
+        }
+        var ib = input.bounds();
+
+        var best = null;
+        var bestLeft = null;
+        var classes = ['android.widget.ImageView', 'android.widget.Button'];
+        for (var c = 0; c < classes.length; c += 1) {
+            var found = className(classes[c]).find();
+            for (var i = 0; i < found.size(); i += 1) {
+                var n = found.get(i);
+                var box = layout.visibleRect(n);
+                if (!box) { continue; }
+
+                var overlap = Math.min(box.bottom, ib.bottom) - Math.max(box.top, ib.top);
+                if (overlap <= 0) { continue; }                     // ① 同一行
+                if (box.left < ib.right + 10) { continue; }          // ② 在输入框右边
+                var w = box.right - box.left;
+                var h = box.bottom - box.top;
+                if (w < 40 || h < 40 || w > 120 || h > 120) { continue; }   // ③ 尺寸
+                try {
+                    if (n.clickable() !== true) { continue; }        // ④ 可点
+                } catch (ignored) {
+                    continue;
+                }
+                if (STICKER_HINT.test(layout.desc(n))) { continue; } // ⑤ 不是表情
+
+                if (bestLeft === null || box.left < bestLeft) {
+                    bestLeft = box.left;
+                    best = n;
+                }
+            }
+        }
+        return best;
+    }
+
+    /**
+     * 打开私信面板(前提:当前已经在目标用户的**主页**上)。
+     *
+     * @returns {ok, via, reason}
+     */
+    function openPanel(config, step) {
+        var story = step || 'dm:open';
+        var log = logger();
+
+        if (panelInput() !== null) {
+            log.info(story, '私信面板已经开着');
+            return { ok: true, via: 'already-open' };
+        }
+
+        var entry = messageButton();
+        if (!entry) {
+            /* 两种可能:对方关闭了私信(没有按钮),或页面还没渲染完。
+             * 分不清时如实说,不要假惺惺地重试到超时。 */
+            log.warn(story, '主页上没有 Message 按钮(对方可能关闭了私信,或页面还没渲染)');
+            return { ok: false, via: 'no-message-button', reason: '主页上没有 Message 按钮' };
+        }
+
+        log.info(story, '点 Message 入口');
+        TikTokAuto.ui.clickNode(entry, story);
+
+        var opened = TikTokAuto.wait.waitFor(story + ':panel', function () {
+            return panelInput() !== null ? true : null;
+        }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
+
+        if (opened !== true) {
+            /* 点一次没开就再点一次(首次点击常被页面动画吞掉) */
+            log.warn(story, '第一次点击没打开面板,重试一次');
+            TikTokAuto.wait.waitQuiet(function () {
+                return TikTokAuto.layout.signature();
+            }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
+            var again = messageButton();
+            if (again) {
+                TikTokAuto.ui.clickNode(again, story + ':retry');
+            }
+            opened = TikTokAuto.wait.waitFor(story + ':panel2', function () {
+                return panelInput() !== null ? true : null;
+            }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
+        }
+
+        if (opened !== true) {
+            return { ok: false, via: 'panel-not-opened', reason: '私信面板没打开' };
+        }
+        return { ok: true, via: 'message-button' };
+    }
+
+    /** 收起面板(只在确认开着时调用,不无脑 back) */
+    function closePanel(step) {
+        if (panelInput() === null) {
+            return false;
+        }
+        TikTokAuto.logger.info(step || 'dm:close', '收起私信面板');
+        back();
+        TikTokAuto.wait.until(function () {
+            return panelInput() === null ? true : null;
+        }, 4000, 200);
+        return true;
+    }
+
+    /**
+     * 发送一条私信。
+     *
+     * @param config 生效配置
+     * @param text   要发的内容
+     * @returns {ok, via, text, reason}
+     */
+    function send(config, text, step) {
+        var story = step || 'dm:send';
+        var log = logger();
+        var content = String(text || '');
+        if (content === '') {
+            return { ok: false, via: 'empty-text', text: content, reason: '私信内容为空' };
+        }
+
+        var opened = openPanel(config, story);
+        if (!opened.ok) {
+            return { ok: false, via: opened.via, text: content, reason: opened.reason };
+        }
+
+        var input = panelInput();
+        if (!input) {
+            return { ok: false, via: 'no-input', text: content, reason: '面板里找不到输入框' };
+        }
+
+        /* 先用 typeInto(它会点输入框聚焦、再写入、再回读校验)。
+         *
+         * ⚠️ **必须先聚焦**:不聚焦就不会弹键盘,而发送键是"输入框缩短后"
+         * 才出现的那块区域 —— 两者是同一个布局变化。 */
+        var typed = TikTokAuto.ui.typeInto(input, content, story);
+        if (!typed.ok) {
+            return { ok: false, via: 'type-failed', text: content, reason: '输入框回读不一致' };
+        }
+
+        if (config.runtime && config.runtime.dryRun) {
+            /* dryRun 也要**先把发送键找一遍**(只记录、不点)。
+             * 否则"演练模式"永远验证不到最不确定的那一环 —— 发送键是输入后才出现的、
+             * 无 id 无 desc 的 ImageView,找不到就等于发不出去,而演练却会报"成功"。 */
+            var preview = TikTokAuto.wait.waitFor(story + ':btn-preview', function () {
+                return sendButton() || null;
+            }, 4000, 250);
+            log.warn(story, 'dryRun:已输入但不发送。发送键' +
+                (preview ? '已定位 ' + JSON.stringify(preview.bounds()) : '**没找到**') +
+                ',内容=' + JSON.stringify(content));
+
+            var restore = panelInput();
+            if (restore) {
+                try { restore.setText(''); } catch (e) { }
+            }
+            TikTokAuto.wait.tick(300);
+            closePanel(story);
+            return { ok: true, via: 'dry-run', text: content, skipped: true,
+                     sendButtonFound: preview !== null };
+        }
+
+        /* 发送键是输入之后才出现的,写完之后等一下再找 */
+        var button = TikTokAuto.wait.waitFor(story + ':btn', function () {
+            return sendButton() || null;
+        }, 4000, 250);
+        if (!button) {
+            log.warn(story, '输入后仍找不到发送键');
+            closePanel(story);
+            return { ok: false, via: 'send-button-not-found', text: content,
+                     reason: '输入后找不到发送键' };
+        }
+
+        /* 点发送键必须用**坐标点击**:它是无 id/desc 的 ImageView,
+         * 无障碍 ACTION_CLICK 大概率落到父容器上(评论那边就栽在这)。 */
+        var point = TikTokAuto.layout.center(button);
+        log.info(story, '点发送键 id=' + TikTokAuto.layout.shortId(button) +
+            ' b=' + JSON.stringify(button.bounds()));
+        if (point) {
+            TikTokAuto.ui.tapPoint(point.x, point.y);
+        } else {
+            TikTokAuto.ui.clickNode(button, story);
+        }
+
+        return awaitSent(config, content, story);
+    }
+
+    /**
+     * 判定"到底发出去没有"。
+     *
+     * 证据两档:
+     *   强:对话列表里出现了刚发的那条文本
+     *   中:输入框被清空并恢复占位符("Message...")+ 面板仍然开着
+     *
+     * 只有一档"面板关了"时**不算成功** —— 面板也可能被别的原因关掉,
+     * 这正是评论那边假阳性的来源。
+     */
+    function awaitSent(config, text, step) {
+        var log = logger();
+
+        /* 轮询等"输入框被清空"。
+         * `setText` 清空后 text 是空串;占位符 "Message..." 是 UI 画的、不算 text,
+         * 但某些版本会把占位符当 text 读回来,所以两个都认。 */
+        var sent = TikTokAuto.wait.waitFor(step + ':sent', function () {
+            var input = panelInput();
+            if (!input) {
+                return null;              // 面板没了 ≠ 发出去了,不能算成功
+            }
+            var value = String(input.text() || '');
+            return (value === '' || value === 'Message...') ? true : null;
+        }, config.timeouts.postMs, config.probe.pollTickMs);
+
+        if (sent === true) {
+            /* 强证据:对话里能不能看到这条文本 */
+            if (conversationContains(text)) {
+                log.info(step, '私信已发送(对话里已出现该内容)' + JSON.stringify(text.slice(0, 20)));
+                return { ok: true, via: 'text-in-conversation', confidence: 'high', text: text };
+            }
+            log.info(step, '私信已发送(输入框已清空)' + JSON.stringify(text.slice(0, 20)));
+            return { ok: true, via: 'input-cleared', confidence: 'medium', text: text };
+        }
+
+        log.warn(step, '点了发送键但输入框没清空,疑似没发出去');
+        closePanel(step);
+        return { ok: false, via: 'not-cleared', text: text, reason: '点发送后输入框没清空' };
+    }
+
+    /** 对话列表里有没有这条文本(可见的 TextView 里找) */
+    function conversationContains(text) {
+        var layout = TikTokAuto.layout;
+        var needle = String(text || '');
+        if (needle === '') { return false; }
+        var found = className('android.widget.TextView').find();
+        for (var i = 0; i < found.size(); i += 1) {
+            var value = layout.text(found.get(i));
+            if (value && value.indexOf(needle) >= 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    return {
+        messageButton: messageButton,
+        panelInput: panelInput,
+        sendButton: sendButton,
+        openPanel: openPanel,
+        closePanel: closePanel,
+        send: send,
+        conversationContains: conversationContains
+    };
+}());
+
+
+/* ============================== src/dmflow.js ============================== */
+/**
+ * dmflow.js — UID 私信的编排:读 UID → 关注一批 → 私信另一批 → 逐个休息
+ *
+ * ## 两批是**完全不同**的人
+ *
+ * 界面上「关注数量 100~200」与「私信数量 20~30」是两组独立额度,作用在不同的人身上:
+ *   · 前 followMax 个 UID  → 只关注
+ *   · 接下来 sendMax 个 UID → 只发私信
+ * 所以 UID 文件里的号要够多(至少 关注上限 + 私信上限)。
+ *
+ * ## 每处理完一个人就休息 restMin~Max 秒
+ *
+ * 这个值 v9 起默认 10~40 秒(原来是 100~500,一轮要跑 5~24 小时,实际跑不完)。
+ * 休息是**分段等**的:每秒醒一次,用户点停止能立刻响应,而不是干等半分钟。
+ *
+ * ## 失败一律跳过
+ *
+ * 打不开主页、对方关闭私信、找不到发送键 —— 都记一条 warning 继续下一个,
+ * 不中断整轮。但**连续失败**会有上限:连着 5 个都不成,说明是环境问题
+ * (没登录、被限流、页面改版),继续跑只是白耗时间,停下来并说明原因。
+ *
+ * 依赖:TikTokAuto.profile / interact / dm / bridge / wait / logger / parse
+ */
+TikTokAuto.dmflow = (function () {
+    'use strict';
+
+    /** 连续这么多个都失败就认为环境出问题了,提前收尾 */
+    var CONSECUTIVE_FAIL_LIMIT = 5;
+
+    function logger() { return TikTokAuto.logger; }
+
+    function progress(phase, done, total) {
+        TikTokAuto.bridge.report({
+            status: 'running',
+            phase: phase,
+            done: done,
+            total: total
+        });
+    }
+
+    /**
+     * 读 UID 文本:每行一个(`@` 可有可无),忽略空行与 `#` 开头的注释行。
+     * 去重按**规范化后的用户名**,避免 `@abc` 与 `abc` 被当成两个人。
+     */
+    function readUids(path) {
+        var parse = TikTokAuto.parse;
+        var out = [];
+        var seen = {};
+        if (!path) {
+            return out;
+        }
+        var text = '';
+        try {
+            if (!files.exists(path)) {
+                return out;
+            }
+            text = String(files.read(path) || '');
+        } catch (error) {
+            return out;
+        }
+
+        var rows = text.split(/\r?\n/);
+        for (var i = 0; i < rows.length; i += 1) {
+            var line = String(rows[i] || '').replace(/^\s+|\s+$/g, '');
+            if (line === '' || line.charAt(0) === '#') {
+                continue;
+            }
+            var uid = parse.username(line);
+            if (uid === '' || seen[uid]) {
+                continue;
+            }
+            seen[uid] = true;
+            out.push(uid);
+        }
+        return out;
+    }
+
+    /** 休息指定区间,**分段等**:每秒醒一次,停止信号能立刻生效 */
+    function rest(config, step) {
+        var lo = config.dm.rest.minMs;
+        var hi = config.dm.rest.maxMs;
+        var total = TikTokAuto.player.randomInt(lo, hi);
+        var left = total;
+        var sliceMs = 1000;
+        while (left > 0) {
+            var slice = Math.min(sliceMs, left);
+            TikTokAuto.wait.tick(slice);
+            left -= slice;
+        }
+        return total;
+    }
+
+    /** 从文案池随机挑一条,尽量不重复(池子用完就允许重复并告警) */
+    function pickText(pool, used, step) {
+        if (!pool || pool.length === 0) {
+            return '';
+        }
+        var candidates = [];
+        for (var i = 0; i < pool.length; i += 1) {
+            if (used.indexOf(pool[i]) === -1) {
+                candidates.push(pool[i]);
+            }
+        }
+        if (candidates.length === 0) {
+            logger().warn(step, '私信文案已用完,本轮允许重复');
+            candidates = pool;
+        }
+        return candidates[Math.floor(Math.random() * candidates.length)];
+    }
+
+    /**
+     * 处理一个人:打开主页 → 关注 或 发私信。
+     *
+     * @returns {uid, follow?, dm?, skipped?, reason?}
+     */
+    function handleOne(config, uid, mode, text, step) {
+        var log = logger();
+        var profileApi = TikTokAuto.profile;
+        var detail = { uid: uid, mode: mode };
+
+        var opened = profileApi.openBySearch(uid, config);
+        if (!opened) {
+            detail.skipped = true;
+            detail.reason = '打不开主页';
+            return detail;
+        }
+
+        if (mode === 'follow') {
+            detail.follow = TikTokAuto.interact.follow(step + ':follow');
+            return detail;
+        }
+
+        /* 私信:先确认面板能开,再发。发完把面板收掉,否则下一个人的主页会被它挡住。 */
+        detail.dm = TikTokAuto.dm.send(config, text, step + ':dm');
+        TikTokAuto.dm.closePanel(step + ':close');
+        return detail;
+    }
+
+    /**
+     * @param config 生效配置(含 `config.dm`)
+     * @returns summary
+     */
+    function runSession(config) {
+        var log = logger();
+        var settings = config.dm;
+        var parse = TikTokAuto.parse;
+
+        var summary = {
+            ok: false,
+            task: 'dm',
+            script: settings.script,
+            /** 演练模式:输入但不发送。要单独计数,否则 dryRun 会被误报成"什么都没做成" */
+            dryRun: !!(config.runtime && config.runtime.dryRun),
+            dryRunCount: 0,
+            /** UID 文件里读到多少个(去重后) */
+            totalUids: 0,
+            followPlanned: 0,
+            dmPlanned: 0,
+            followed: 0,
+            sent: 0,
+            skipped: 0,
+            failed: 0,
+            details: [],
+            warnings: [],
+            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) {
+            summary.errors.push('「进行关注」和「进行私信」都没开,没有可执行的动作');
+            return summary;
+        }
+
+        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 + ' 个,少于计划(关注 ' +
+                followMax + ' + 私信 ' + sendMax + '),本轮按实际数量执行');
+        }
+
+        var total = followBatch.length + dmBatch.length;
+        log.step('dmflow', '私信任务开始: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;
+        }
+
+        var usedTexts = [];
+        var done = 0;
+        var consecutiveFail = 0;
+        var i = 0;
+
+        /* ③ 关注批次 */
+        for (i = 0; i < followBatch.length; i += 1) {
+            var fUid = followBatch[i];
+            progress('关注 ' + (i + 1) + '/' + followBatch.length + ' @' + fUid, done, total);
+
+            var fDetail = handleOne(config, fUid, 'follow', '', 'dmflow:follow');
+            fDetail.phase = 'follow';
+            summary.details.push(fDetail);
+
+            if (fDetail.skipped) {
+                summary.skipped += 1;
+                summary.warnings.push('关注 @' + fUid + ' 跳过:' + fDetail.reason);
+                consecutiveFail += 1;
+            } else if (fDetail.follow && fDetail.follow.ok) {
+                if (!fDetail.follow.skipped) {
+                    summary.followed += 1;
+                }
+                consecutiveFail = 0;
+            } else {
+                summary.failed += 1;
+                summary.warnings.push('关注 @' + fUid + ' 失败:' +
+                    ((fDetail.follow && fDetail.follow.reason) || '未知'));
+                consecutiveFail += 1;
+            }
+
+            done += 1;
+            if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
+                summary.warnings.push('连续 ' + consecutiveFail +
+                    ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
+                break;
+            }
+            /* 批次之间**不**休息 —— 关注是轻动作,休息留给私信更合理。
+             * 但用户明确要求"每处理完一个 UID 都休息",所以这里也休。 */
+            rest(config, 'dmflow:rest');
+        }
+
+        /* ④ 私信批次(换另一批人) */
+        var dmStart = 0;
+        if (consecutiveFail < CONSECUTIVE_FAIL_LIMIT) {
+            for (dmStart = 0; dmStart < dmBatch.length; dmStart += 1) {
+                var dUid = dmBatch[dmStart];
+                var text = pickText(settings.pool, usedTexts, 'dmflow:pick');
+                if (text === '') {
+                    summary.warnings.push('私信文案池是空的,私信批次没法执行(检查「随机私信文本」那个文件)');
+                    break;
+                }
+
+                progress('私信 ' + (dmStart + 1) + '/' + dmBatch.length + ' @' + dUid, done, total);
+
+                var dDetail = handleOne(config, dUid, 'dm', text, 'dmflow:dm');
+                dDetail.phase = 'dm';
+                dDetail.text = text;
+                summary.details.push(dDetail);
+
+                if (dDetail.skipped) {
+                    summary.skipped += 1;
+                    summary.warnings.push('私信 @' + dUid + ' 跳过:' + dDetail.reason);
+                    consecutiveFail += 1;
+                } else if (dDetail.dm && dDetail.dm.ok) {
+                    if (dDetail.dm.skipped) {
+                        /* dryRun:演练成功(输入到位、发送键也定位到了),但没真发 */
+                        summary.dryRunCount += 1;
+                    } else {
+                        summary.sent += 1;
+                        usedTexts.push(text);
+                    }
+                    consecutiveFail = 0;
+                } else {
+                    summary.failed += 1;
+                    summary.warnings.push('私信 @' + dUid + ' 失败:' +
+                        ((dDetail.dm && dDetail.dm.reason) || '未知'));
+                    consecutiveFail += 1;
+                }
+
+                done += 1;
+                if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
+                    summary.warnings.push('连续 ' + consecutiveFail +
+                        ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
+                    break;
+                }
+                rest(config, 'dmflow:rest');
+            }
+        }
+
+        summary.ok = summary.followed > 0 || summary.sent > 0 || summary.dryRunCount > 0;
+        if (!summary.ok && summary.errors.length === 0) {
+            summary.errors.push('一个动作都没做成(关注 ' + summary.followed +
+                ' / 私信 ' + summary.sent + ')');
+        }
+
+        log.step('dmflow', '私信任务结束:关注 ' + summary.followed + ' 个,私信 ' +
+            summary.sent + ' 条' +
+            (summary.dryRun ? '(演练 ' + summary.dryRunCount + ' 条,未真发)' : '') +
+            ',跳过 ' + summary.skipped + ' 个,失败 ' + summary.failed + ' 个');
+
+        return summary;
+    }
+
+    return {
+        runSession: runSession,
+        readUids: readUids,
+        pickText: pickText,
+        CONSECUTIVE_FAIL_LIMIT: CONSECUTIVE_FAIL_LIMIT
+    };
+}());
+
+
 /* ============================== src/main.js ============================== */
 /**
  * main.js — 入口与收尾
@@ -9072,6 +9824,9 @@ TikTokAuto.main = (function () {
         if (config.task === 'nurture') {
             return TikTokAuto.nurture.runSession(config);
         }
+        if (config.task === 'dm') {
+            return TikTokAuto.dmflow.runSession(config);
+        }
         return TikTokAuto.flow.runSession(config);
     }
 

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

@@ -289,6 +289,15 @@ TikTokAuto.bridge = (function () {
         /* 养号有自己的形状(轮数 + 三个动作计数),既不是"提取 N 个"也不是"处理 N 个视频"。
          * 判据用 summary 自己带的 task 字段,不靠调用方传——这样以后加任务不用再动这里。 */
         var isNurture = summary && summary.task === 'nurture' && typeof summary.rounds === 'number';
+        /* 私信:关注与私信是两个不同的动作,要分开说 */
+        var isDmTask = summary && summary.task === 'dm' && typeof summary.sent === 'number';
+        var dmTail = function () {
+            var tail = '';
+            if (summary.skipped > 0) { tail += ',跳过 ' + summary.skipped + ' 个'; }
+            if (summary.failed > 0) { tail += ',失败 ' + summary.failed + ' 个'; }
+            if (summary.dryRun) { tail += '(演练 ' + (summary.dryRunCount || 0) + ' 条,未真发)'; }
+            return tail;
+        };
         /* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
          * 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */
         var total = summary && typeof summary.extractedTotal === 'number'
@@ -299,6 +308,10 @@ TikTokAuto.bridge = (function () {
             ? ',本轮新增 ' + summary.extracted + ' 个' : '';
 
         if (status === 'stopped') {
+            if (isDmTask) {
+                return '已停止:关注 ' + summary.followed + ' 个 / 私信 ' +
+                    summary.sent + ' 条' + dmTail();
+            }
             if (isNurture) {
                 return '已停止:浏览 ' + summary.rounds + ' 轮 / ' + nurtureActions(summary);
             }
@@ -308,6 +321,10 @@ TikTokAuto.bridge = (function () {
                 : '已停止';
         }
         if (status === 'finished') {
+            if (isDmTask) {
+                return '完成:关注 ' + summary.followed + ' 个 / 私信 ' +
+                    summary.sent + ' 条' + dmTail();
+            }
             if (isNurture) {
                 /* 时长先到要说出来 —— 用户下一步该调的是「浏览时长」而不是别的 */
                 var why = summary.stoppedBy === 'time' ? '(时长先到,轮数没用满)' : '';

+ 61 - 0
tiktok-auto/src/config.js

@@ -156,6 +156,41 @@ TikTokAuto.config = (function () {
             commentRate: 0.10,
             followRate: 0.15
         },
+        /**
+         * UID 私信(task = 'dm')。
+         *
+         * 界面的「关注数量 100~200」「私信数量 20~30」是**两组独立额度**,
+         * 作用在**两批完全不同的人**上:前 `follow.max` 个只关注,接下来
+         * `send.max` 个只发私信。所以 `max` 是**确定的数字**(界面在区间里随机取一次
+         * 再传下来),不是区间。
+         *
+         * 「休息时间」是**每处理完一个 UID 之后**的间隔,也在这里传确定的区间。
+         */
+        dm: {
+            /** 's1' = 已提 UID 私信(读文本);'s2' = 自己粉丝私信(暂未实现) */
+            script: 's1',
+            /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释 */
+            uidPath: '/sdcard/Download/tiktok_app/uid_list.txt',
+            follow: { enabled: true, max: 0 },
+            send: { enabled: true, max: 0 },
+            /** 每个 UID 之后休息多久(v9 起界面默认 10~40 秒) */
+            rest: { minMs: 10000, maxMs: 40000 },
+            /** 'follow_dm' | 'follow' | 'dm' —— 按什么去重(跨轮,读 dm_history.txt) */
+            dedupeMode: 'follow_dm',
+            /** 首视频筛选与首视频动作(脚本一的两个设置) */
+            filterDate: '7d',
+            firstAction: 'both',
+            /** 私信文案池(界面「随机私信文本」文件里读出来的每一行) */
+            pool: [],
+            /** 评论文案池(首视频评论用,同样从文件读) */
+            commentPool: [],
+            /**
+             * 物理护栏:时长到点就收尾,别指望"跑完自然停"。
+             * 关注 150 + 私信 25,每个约 30~80 秒(搜索+进页+动作+休息),
+             * 一轮 1.5~4 小时;给 5 小时上限兜住"UID 文件特别长"的情况。
+             */
+            guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 }
+        },
         timeouts: {
             appLaunchMs: 25000,
             pageReadyMs: 15000,
@@ -332,6 +367,32 @@ TikTokAuto.config = (function () {
             return config;
         }
 
+        /* —— UID 私信:没有搜索目标,校验的是"有没有活可干、数量是否合法" —— */
+        if (config.task === 'dm') {
+            var dm = config.dm || {};
+            var followMax = Number(dm.follow && dm.follow.max);
+            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 (!followOn && !sendOn) {
+                throw new ConfigError('「进行关注」与「进行私信」都关着,没有可执行的动作',
+                                      { follow: followOn, send: sendOn });
+            }
+            if (followOn && !(followMax >= 1)) {
+                throw new ConfigError('dm.follow.max 必须 >= 1(开着关注却没有数量)', followMax);
+            }
+            if (sendOn && !(sendMax >= 1)) {
+                throw new ConfigError('dm.send.max 必须 >= 1(开着私信却没有数量)', sendMax);
+            }
+            if (sendOn && (!dm.pool || dm.pool.length === 0)) {
+                throw new ConfigError('要发私信就必须有文案(「随机私信文本」那个文件是空的)', 0);
+            }
+            if (!(Number(dm.rest && dm.rest.maxMs) > 0)) {
+                throw new ConfigError('dm.rest.maxMs 必须 > 0', dm.rest && dm.rest.maxMs);
+            }
+            return config;
+        }
+
         var target = config.target;
         if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
             throw new ConfigError('target.keyword 不能为空', target.keyword);

+ 349 - 0
tiktok-auto/src/dm.js

@@ -0,0 +1,349 @@
+/**
+ * dm.js — 发私信:打开面板 → 输入 → **点发送** → 验证真的发出去了
+ *
+ * ## 实测锚点(2026-10-29 / TikTok 47.1.4 / 1080×1920 / @nasa 主页)
+ *
+ * | 元素 | 类 | 识别方式 | 备注 |
+ * |---|---|---|---|
+ * | Message 入口 | TextView | text/desc === `Message` | 用户主页上,Follow 右边 |
+ * | 面板输入框 | EditText | `top > 25%H` | 未聚焦时贴底(y=1816) |
+ * | 表情按钮 | Button | desc 含 `stickers, gifs` | 会挡在发送键左边 |
+ * | **发送键** | ImageView | 见 `sendButton()` | **无 id、无 desc,只能按位置** |
+ *
+ * ## 为什么发送键要按位置找
+ *
+ * 输入前它根本不存在;输入文字后输入框会**缩短**(960 → 888),
+ * 空出来的位置才出现那个 72×72 的可点 ImageView(968~1040)。
+ * 它没有 id、没有 desc、也没有任何文案 —— 除了几何关系没有别的抓手。
+ *
+ * ## 和评论的区别(这是私信能做、评论不能做的原因)
+ *
+ * 评论的发送键是**自绘的**,无障碍树里根本没有对应节点;
+ * 私信这个是标准 ImageView、`clickable=true`,无障碍点得到。
+ *
+ * 依赖:TikTokAuto.logger / layout / ui / wait / tiktok
+ */
+TikTokAuto.dm = (function () {
+    'use strict';
+
+    /** 表情按钮的 desc 特征 —— 它紧贴在发送键左边,必须排除 */
+    var STICKER_HINT = /stickers|gifs|emoji|表情|贴纸/i;
+
+    function logger() { return TikTokAuto.logger; }
+
+    function screenH() { return TikTokAuto.layout.screen().height; }
+
+    /**
+     * 找可见的、text 或 desc 完全匹配的节点。
+     *
+     * ★ 必须**分别**测 text 和 desc,不能拼起来再测:
+     * 拼成 `txt + ' ' + desc` 之后,`desc="Message"` 会变成 " Message"(前导空格),
+     * `/^Message$/` 就匹配不上了 —— 明明节点在树里却报"找不到"。
+     */
+    function findExact(re, classes) {
+        var layout = TikTokAuto.layout;
+        var height = screenH();
+        for (var c = 0; c < classes.length; c += 1) {
+            var found = className(classes[c]).find();
+            for (var i = 0; i < found.size(); i += 1) {
+                var n = found.get(i);
+                var box = layout.visibleRect(n);
+                if (!box || box.bottom <= 0 || box.top >= height) {
+                    continue;
+                }
+                if (re.test(layout.text(n)) || re.test(layout.desc(n))) {
+                    return n;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** 用户主页上的 Message 入口 */
+    function messageButton() {
+        return findExact(/^(Message|Send message|消息|私信)$/i,
+                         ['android.widget.Button', 'android.widget.TextView']);
+    }
+
+    /**
+     * 私信面板里的输入框。
+     *
+     * 位置判据用「top > 25% 屏高」而不是"底部固定 y":聚焦后键盘弹起,
+     * 输入框会被顶到 0.65H(实测 1240),贴底判据就失效了。
+     */
+    function panelInput() {
+        var layout = TikTokAuto.layout;
+        var height = screenH();
+        var found = className('android.widget.EditText').find();
+        for (var i = 0; i < found.size(); i += 1) {
+            var n = found.get(i);
+            var box = layout.visibleRect(n);
+            if (!box) { continue; }
+            if (box.top > height * 0.25 && box.top < height * 0.95) {
+                return n;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 发送键。
+     *
+     * 判据(都来自实测,缺一条就会点到表情按钮或点空):
+     *   ① 与输入框**垂直重叠**(同一行)
+     *   ② 左边界在输入框右边界**之外**(`left >= right + 10`)——
+     *      输入框输入后会缩短,表情按钮紧贴着它,所以必须留出这 10px 的余量
+     *   ③ 尺寸在 40~120 之间(排除整行的容器)
+     *   ④ 可点
+     *   ⑤ desc 里不含 stickers/gifs/emoji(排除表情按钮)
+     *
+     * 取**最靠左**的那个:紧挨输入框的是发送键,更右边没有东西。
+     */
+    function sendButton() {
+        var layout = TikTokAuto.layout;
+        var input = panelInput();
+        if (!input) {
+            return null;
+        }
+        var ib = input.bounds();
+
+        var best = null;
+        var bestLeft = null;
+        var classes = ['android.widget.ImageView', 'android.widget.Button'];
+        for (var c = 0; c < classes.length; c += 1) {
+            var found = className(classes[c]).find();
+            for (var i = 0; i < found.size(); i += 1) {
+                var n = found.get(i);
+                var box = layout.visibleRect(n);
+                if (!box) { continue; }
+
+                var overlap = Math.min(box.bottom, ib.bottom) - Math.max(box.top, ib.top);
+                if (overlap <= 0) { continue; }                     // ① 同一行
+                if (box.left < ib.right + 10) { continue; }          // ② 在输入框右边
+                var w = box.right - box.left;
+                var h = box.bottom - box.top;
+                if (w < 40 || h < 40 || w > 120 || h > 120) { continue; }   // ③ 尺寸
+                try {
+                    if (n.clickable() !== true) { continue; }        // ④ 可点
+                } catch (ignored) {
+                    continue;
+                }
+                if (STICKER_HINT.test(layout.desc(n))) { continue; } // ⑤ 不是表情
+
+                if (bestLeft === null || box.left < bestLeft) {
+                    bestLeft = box.left;
+                    best = n;
+                }
+            }
+        }
+        return best;
+    }
+
+    /**
+     * 打开私信面板(前提:当前已经在目标用户的**主页**上)。
+     *
+     * @returns {ok, via, reason}
+     */
+    function openPanel(config, step) {
+        var story = step || 'dm:open';
+        var log = logger();
+
+        if (panelInput() !== null) {
+            log.info(story, '私信面板已经开着');
+            return { ok: true, via: 'already-open' };
+        }
+
+        var entry = messageButton();
+        if (!entry) {
+            /* 两种可能:对方关闭了私信(没有按钮),或页面还没渲染完。
+             * 分不清时如实说,不要假惺惺地重试到超时。 */
+            log.warn(story, '主页上没有 Message 按钮(对方可能关闭了私信,或页面还没渲染)');
+            return { ok: false, via: 'no-message-button', reason: '主页上没有 Message 按钮' };
+        }
+
+        log.info(story, '点 Message 入口');
+        TikTokAuto.ui.clickNode(entry, story);
+
+        var opened = TikTokAuto.wait.waitFor(story + ':panel', function () {
+            return panelInput() !== null ? true : null;
+        }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
+
+        if (opened !== true) {
+            /* 点一次没开就再点一次(首次点击常被页面动画吞掉) */
+            log.warn(story, '第一次点击没打开面板,重试一次');
+            TikTokAuto.wait.waitQuiet(function () {
+                return TikTokAuto.layout.signature();
+            }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
+            var again = messageButton();
+            if (again) {
+                TikTokAuto.ui.clickNode(again, story + ':retry');
+            }
+            opened = TikTokAuto.wait.waitFor(story + ':panel2', function () {
+                return panelInput() !== null ? true : null;
+            }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
+        }
+
+        if (opened !== true) {
+            return { ok: false, via: 'panel-not-opened', reason: '私信面板没打开' };
+        }
+        return { ok: true, via: 'message-button' };
+    }
+
+    /** 收起面板(只在确认开着时调用,不无脑 back) */
+    function closePanel(step) {
+        if (panelInput() === null) {
+            return false;
+        }
+        TikTokAuto.logger.info(step || 'dm:close', '收起私信面板');
+        back();
+        TikTokAuto.wait.until(function () {
+            return panelInput() === null ? true : null;
+        }, 4000, 200);
+        return true;
+    }
+
+    /**
+     * 发送一条私信。
+     *
+     * @param config 生效配置
+     * @param text   要发的内容
+     * @returns {ok, via, text, reason}
+     */
+    function send(config, text, step) {
+        var story = step || 'dm:send';
+        var log = logger();
+        var content = String(text || '');
+        if (content === '') {
+            return { ok: false, via: 'empty-text', text: content, reason: '私信内容为空' };
+        }
+
+        var opened = openPanel(config, story);
+        if (!opened.ok) {
+            return { ok: false, via: opened.via, text: content, reason: opened.reason };
+        }
+
+        var input = panelInput();
+        if (!input) {
+            return { ok: false, via: 'no-input', text: content, reason: '面板里找不到输入框' };
+        }
+
+        /* 先用 typeInto(它会点输入框聚焦、再写入、再回读校验)。
+         *
+         * ⚠️ **必须先聚焦**:不聚焦就不会弹键盘,而发送键是"输入框缩短后"
+         * 才出现的那块区域 —— 两者是同一个布局变化。 */
+        var typed = TikTokAuto.ui.typeInto(input, content, story);
+        if (!typed.ok) {
+            return { ok: false, via: 'type-failed', text: content, reason: '输入框回读不一致' };
+        }
+
+        if (config.runtime && config.runtime.dryRun) {
+            /* dryRun 也要**先把发送键找一遍**(只记录、不点)。
+             * 否则"演练模式"永远验证不到最不确定的那一环 —— 发送键是输入后才出现的、
+             * 无 id 无 desc 的 ImageView,找不到就等于发不出去,而演练却会报"成功"。 */
+            var preview = TikTokAuto.wait.waitFor(story + ':btn-preview', function () {
+                return sendButton() || null;
+            }, 4000, 250);
+            log.warn(story, 'dryRun:已输入但不发送。发送键' +
+                (preview ? '已定位 ' + JSON.stringify(preview.bounds()) : '**没找到**') +
+                ',内容=' + JSON.stringify(content));
+
+            var restore = panelInput();
+            if (restore) {
+                try { restore.setText(''); } catch (e) { }
+            }
+            TikTokAuto.wait.tick(300);
+            closePanel(story);
+            return { ok: true, via: 'dry-run', text: content, skipped: true,
+                     sendButtonFound: preview !== null };
+        }
+
+        /* 发送键是输入之后才出现的,写完之后等一下再找 */
+        var button = TikTokAuto.wait.waitFor(story + ':btn', function () {
+            return sendButton() || null;
+        }, 4000, 250);
+        if (!button) {
+            log.warn(story, '输入后仍找不到发送键');
+            closePanel(story);
+            return { ok: false, via: 'send-button-not-found', text: content,
+                     reason: '输入后找不到发送键' };
+        }
+
+        /* 点发送键必须用**坐标点击**:它是无 id/desc 的 ImageView,
+         * 无障碍 ACTION_CLICK 大概率落到父容器上(评论那边就栽在这)。 */
+        var point = TikTokAuto.layout.center(button);
+        log.info(story, '点发送键 id=' + TikTokAuto.layout.shortId(button) +
+            ' b=' + JSON.stringify(button.bounds()));
+        if (point) {
+            TikTokAuto.ui.tapPoint(point.x, point.y);
+        } else {
+            TikTokAuto.ui.clickNode(button, story);
+        }
+
+        return awaitSent(config, content, story);
+    }
+
+    /**
+     * 判定"到底发出去没有"。
+     *
+     * 证据两档:
+     *   强:对话列表里出现了刚发的那条文本
+     *   中:输入框被清空并恢复占位符("Message...")+ 面板仍然开着
+     *
+     * 只有一档"面板关了"时**不算成功** —— 面板也可能被别的原因关掉,
+     * 这正是评论那边假阳性的来源。
+     */
+    function awaitSent(config, text, step) {
+        var log = logger();
+
+        /* 轮询等"输入框被清空"。
+         * `setText` 清空后 text 是空串;占位符 "Message..." 是 UI 画的、不算 text,
+         * 但某些版本会把占位符当 text 读回来,所以两个都认。 */
+        var sent = TikTokAuto.wait.waitFor(step + ':sent', function () {
+            var input = panelInput();
+            if (!input) {
+                return null;              // 面板没了 ≠ 发出去了,不能算成功
+            }
+            var value = String(input.text() || '');
+            return (value === '' || value === 'Message...') ? true : null;
+        }, config.timeouts.postMs, config.probe.pollTickMs);
+
+        if (sent === true) {
+            /* 强证据:对话里能不能看到这条文本 */
+            if (conversationContains(text)) {
+                log.info(step, '私信已发送(对话里已出现该内容)' + JSON.stringify(text.slice(0, 20)));
+                return { ok: true, via: 'text-in-conversation', confidence: 'high', text: text };
+            }
+            log.info(step, '私信已发送(输入框已清空)' + JSON.stringify(text.slice(0, 20)));
+            return { ok: true, via: 'input-cleared', confidence: 'medium', text: text };
+        }
+
+        log.warn(step, '点了发送键但输入框没清空,疑似没发出去');
+        closePanel(step);
+        return { ok: false, via: 'not-cleared', text: text, reason: '点发送后输入框没清空' };
+    }
+
+    /** 对话列表里有没有这条文本(可见的 TextView 里找) */
+    function conversationContains(text) {
+        var layout = TikTokAuto.layout;
+        var needle = String(text || '');
+        if (needle === '') { return false; }
+        var found = className('android.widget.TextView').find();
+        for (var i = 0; i < found.size(); i += 1) {
+            var value = layout.text(found.get(i));
+            if (value && value.indexOf(needle) >= 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    return {
+        messageButton: messageButton,
+        panelInput: panelInput,
+        sendButton: sendButton,
+        openPanel: openPanel,
+        closePanel: closePanel,
+        send: send,
+        conversationContains: conversationContains
+    };
+}());

+ 319 - 0
tiktok-auto/src/dmflow.js

@@ -0,0 +1,319 @@
+/**
+ * dmflow.js — UID 私信的编排:读 UID → 关注一批 → 私信另一批 → 逐个休息
+ *
+ * ## 两批是**完全不同**的人
+ *
+ * 界面上「关注数量 100~200」与「私信数量 20~30」是两组独立额度,作用在不同的人身上:
+ *   · 前 followMax 个 UID  → 只关注
+ *   · 接下来 sendMax 个 UID → 只发私信
+ * 所以 UID 文件里的号要够多(至少 关注上限 + 私信上限)。
+ *
+ * ## 每处理完一个人就休息 restMin~Max 秒
+ *
+ * 这个值 v9 起默认 10~40 秒(原来是 100~500,一轮要跑 5~24 小时,实际跑不完)。
+ * 休息是**分段等**的:每秒醒一次,用户点停止能立刻响应,而不是干等半分钟。
+ *
+ * ## 失败一律跳过
+ *
+ * 打不开主页、对方关闭私信、找不到发送键 —— 都记一条 warning 继续下一个,
+ * 不中断整轮。但**连续失败**会有上限:连着 5 个都不成,说明是环境问题
+ * (没登录、被限流、页面改版),继续跑只是白耗时间,停下来并说明原因。
+ *
+ * 依赖:TikTokAuto.profile / interact / dm / bridge / wait / logger / parse
+ */
+TikTokAuto.dmflow = (function () {
+    'use strict';
+
+    /** 连续这么多个都失败就认为环境出问题了,提前收尾 */
+    var CONSECUTIVE_FAIL_LIMIT = 5;
+
+    function logger() { return TikTokAuto.logger; }
+
+    function progress(phase, done, total) {
+        TikTokAuto.bridge.report({
+            status: 'running',
+            phase: phase,
+            done: done,
+            total: total
+        });
+    }
+
+    /**
+     * 读 UID 文本:每行一个(`@` 可有可无),忽略空行与 `#` 开头的注释行。
+     * 去重按**规范化后的用户名**,避免 `@abc` 与 `abc` 被当成两个人。
+     */
+    function readUids(path) {
+        var parse = TikTokAuto.parse;
+        var out = [];
+        var seen = {};
+        if (!path) {
+            return out;
+        }
+        var text = '';
+        try {
+            if (!files.exists(path)) {
+                return out;
+            }
+            text = String(files.read(path) || '');
+        } catch (error) {
+            return out;
+        }
+
+        var rows = text.split(/\r?\n/);
+        for (var i = 0; i < rows.length; i += 1) {
+            var line = String(rows[i] || '').replace(/^\s+|\s+$/g, '');
+            if (line === '' || line.charAt(0) === '#') {
+                continue;
+            }
+            var uid = parse.username(line);
+            if (uid === '' || seen[uid]) {
+                continue;
+            }
+            seen[uid] = true;
+            out.push(uid);
+        }
+        return out;
+    }
+
+    /** 休息指定区间,**分段等**:每秒醒一次,停止信号能立刻生效 */
+    function rest(config, step) {
+        var lo = config.dm.rest.minMs;
+        var hi = config.dm.rest.maxMs;
+        var total = TikTokAuto.player.randomInt(lo, hi);
+        var left = total;
+        var sliceMs = 1000;
+        while (left > 0) {
+            var slice = Math.min(sliceMs, left);
+            TikTokAuto.wait.tick(slice);
+            left -= slice;
+        }
+        return total;
+    }
+
+    /** 从文案池随机挑一条,尽量不重复(池子用完就允许重复并告警) */
+    function pickText(pool, used, step) {
+        if (!pool || pool.length === 0) {
+            return '';
+        }
+        var candidates = [];
+        for (var i = 0; i < pool.length; i += 1) {
+            if (used.indexOf(pool[i]) === -1) {
+                candidates.push(pool[i]);
+            }
+        }
+        if (candidates.length === 0) {
+            logger().warn(step, '私信文案已用完,本轮允许重复');
+            candidates = pool;
+        }
+        return candidates[Math.floor(Math.random() * candidates.length)];
+    }
+
+    /**
+     * 处理一个人:打开主页 → 关注 或 发私信。
+     *
+     * @returns {uid, follow?, dm?, skipped?, reason?}
+     */
+    function handleOne(config, uid, mode, text, step) {
+        var log = logger();
+        var profileApi = TikTokAuto.profile;
+        var detail = { uid: uid, mode: mode };
+
+        var opened = profileApi.openBySearch(uid, config);
+        if (!opened) {
+            detail.skipped = true;
+            detail.reason = '打不开主页';
+            return detail;
+        }
+
+        if (mode === 'follow') {
+            detail.follow = TikTokAuto.interact.follow(step + ':follow');
+            return detail;
+        }
+
+        /* 私信:先确认面板能开,再发。发完把面板收掉,否则下一个人的主页会被它挡住。 */
+        detail.dm = TikTokAuto.dm.send(config, text, step + ':dm');
+        TikTokAuto.dm.closePanel(step + ':close');
+        return detail;
+    }
+
+    /**
+     * @param config 生效配置(含 `config.dm`)
+     * @returns summary
+     */
+    function runSession(config) {
+        var log = logger();
+        var settings = config.dm;
+        var parse = TikTokAuto.parse;
+
+        var summary = {
+            ok: false,
+            task: 'dm',
+            script: settings.script,
+            /** 演练模式:输入但不发送。要单独计数,否则 dryRun 会被误报成"什么都没做成" */
+            dryRun: !!(config.runtime && config.runtime.dryRun),
+            dryRunCount: 0,
+            /** UID 文件里读到多少个(去重后) */
+            totalUids: 0,
+            followPlanned: 0,
+            dmPlanned: 0,
+            followed: 0,
+            sent: 0,
+            skipped: 0,
+            failed: 0,
+            details: [],
+            warnings: [],
+            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) {
+            summary.errors.push('「进行关注」和「进行私信」都没开,没有可执行的动作');
+            return summary;
+        }
+
+        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 + ' 个,少于计划(关注 ' +
+                followMax + ' + 私信 ' + sendMax + '),本轮按实际数量执行');
+        }
+
+        var total = followBatch.length + dmBatch.length;
+        log.step('dmflow', '私信任务开始: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;
+        }
+
+        var usedTexts = [];
+        var done = 0;
+        var consecutiveFail = 0;
+        var i = 0;
+
+        /* ③ 关注批次 */
+        for (i = 0; i < followBatch.length; i += 1) {
+            var fUid = followBatch[i];
+            progress('关注 ' + (i + 1) + '/' + followBatch.length + ' @' + fUid, done, total);
+
+            var fDetail = handleOne(config, fUid, 'follow', '', 'dmflow:follow');
+            fDetail.phase = 'follow';
+            summary.details.push(fDetail);
+
+            if (fDetail.skipped) {
+                summary.skipped += 1;
+                summary.warnings.push('关注 @' + fUid + ' 跳过:' + fDetail.reason);
+                consecutiveFail += 1;
+            } else if (fDetail.follow && fDetail.follow.ok) {
+                if (!fDetail.follow.skipped) {
+                    summary.followed += 1;
+                }
+                consecutiveFail = 0;
+            } else {
+                summary.failed += 1;
+                summary.warnings.push('关注 @' + fUid + ' 失败:' +
+                    ((fDetail.follow && fDetail.follow.reason) || '未知'));
+                consecutiveFail += 1;
+            }
+
+            done += 1;
+            if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
+                summary.warnings.push('连续 ' + consecutiveFail +
+                    ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
+                break;
+            }
+            /* 批次之间**不**休息 —— 关注是轻动作,休息留给私信更合理。
+             * 但用户明确要求"每处理完一个 UID 都休息",所以这里也休。 */
+            rest(config, 'dmflow:rest');
+        }
+
+        /* ④ 私信批次(换另一批人) */
+        var dmStart = 0;
+        if (consecutiveFail < CONSECUTIVE_FAIL_LIMIT) {
+            for (dmStart = 0; dmStart < dmBatch.length; dmStart += 1) {
+                var dUid = dmBatch[dmStart];
+                var text = pickText(settings.pool, usedTexts, 'dmflow:pick');
+                if (text === '') {
+                    summary.warnings.push('私信文案池是空的,私信批次没法执行(检查「随机私信文本」那个文件)');
+                    break;
+                }
+
+                progress('私信 ' + (dmStart + 1) + '/' + dmBatch.length + ' @' + dUid, done, total);
+
+                var dDetail = handleOne(config, dUid, 'dm', text, 'dmflow:dm');
+                dDetail.phase = 'dm';
+                dDetail.text = text;
+                summary.details.push(dDetail);
+
+                if (dDetail.skipped) {
+                    summary.skipped += 1;
+                    summary.warnings.push('私信 @' + dUid + ' 跳过:' + dDetail.reason);
+                    consecutiveFail += 1;
+                } else if (dDetail.dm && dDetail.dm.ok) {
+                    if (dDetail.dm.skipped) {
+                        /* dryRun:演练成功(输入到位、发送键也定位到了),但没真发 */
+                        summary.dryRunCount += 1;
+                    } else {
+                        summary.sent += 1;
+                        usedTexts.push(text);
+                    }
+                    consecutiveFail = 0;
+                } else {
+                    summary.failed += 1;
+                    summary.warnings.push('私信 @' + dUid + ' 失败:' +
+                        ((dDetail.dm && dDetail.dm.reason) || '未知'));
+                    consecutiveFail += 1;
+                }
+
+                done += 1;
+                if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
+                    summary.warnings.push('连续 ' + consecutiveFail +
+                        ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
+                    break;
+                }
+                rest(config, 'dmflow:rest');
+            }
+        }
+
+        summary.ok = summary.followed > 0 || summary.sent > 0 || summary.dryRunCount > 0;
+        if (!summary.ok && summary.errors.length === 0) {
+            summary.errors.push('一个动作都没做成(关注 ' + summary.followed +
+                ' / 私信 ' + summary.sent + ')');
+        }
+
+        log.step('dmflow', '私信任务结束:关注 ' + summary.followed + ' 个,私信 ' +
+            summary.sent + ' 条' +
+            (summary.dryRun ? '(演练 ' + summary.dryRunCount + ' 条,未真发)' : '') +
+            ',跳过 ' + summary.skipped + ' 个,失败 ' + summary.failed + ' 个');
+
+        return summary;
+    }
+
+    return {
+        runSession: runSession,
+        readUids: readUids,
+        pickText: pickText,
+        CONSECUTIVE_FAIL_LIMIT: CONSECUTIVE_FAIL_LIMIT
+    };
+}());

+ 3 - 0
tiktok-auto/src/main.js

@@ -54,6 +54,9 @@ TikTokAuto.main = (function () {
         if (config.task === 'nurture') {
             return TikTokAuto.nurture.runSession(config);
         }
+        if (config.task === 'dm') {
+            return TikTokAuto.dmflow.runSession(config);
+        }
         return TikTokAuto.flow.runSession(config);
     }
 

+ 7 - 1
tiktok-auto/tools/build.js

@@ -66,7 +66,13 @@ const MODULE_ORDER = [
     'profile',
     'idsource',
     'resolve',
-    'idflow'
+    'idflow',
+    /* —— UID 私信(task = 'dm')——
+     * dm 是"发一条私信"的能力(开面板/输入/点发送/验证),
+     * dmflow 是编排(读 UID → 关注一批 → 私信另一批 → 逐个休息)。
+     * 放在最后:dmflow 要用 profile / interact / dm / flow,它们都已定义。 */
+    'dm',
+    'dmflow'
 ];
 
 const ENTRY_MODULE = 'main';