Переглянути джерело

养号引擎(task=nurture):信息流随机浏览 + 点赞 + 关注;评论如实标注不可用

## 做了什么

新增 `tiktok-auto/src/nurture.js`:在首页信息流里随机浏览、按概率点赞与关注,
模拟真实活跃度。**没有目标** —— 视频链路是"搜关键词看 N 个",养号是"打开信息流一个接一个刷"。

双约束(谁先到谁停,结果里记 stoppedBy,不笼统说"完成"):
  · 单次操作 10~30  → nurture.rounds  = **轮数**(规格书没写单位,已按你的确认定下来)
  · 浏览时长 3~8 分钟 → nurture.browse  = 墙钟上限

每轮:确认还在 feed → 判内容类型(照片帖没播放,短停留)→ watch 5~25 秒
→ 按概率点赞/关注(动作后 0.8~2.5 秒随机停顿)→ 上滑(判据是"新内容真的出现")。

**几乎全是复用**:flow.launchApp / flow.ensureFeed(应用级)、player.watch(有界观看 +
播放存活证据)、interact.like / interact.follow(点一下 + 验状态)、tiktok.feedSignature
(判上滑有没有真换视频)。新写的只有编排这一层。

## 三个必须修的地基

**① `player.watch` 的页面守卫可注入**。`onPlayerPage()` 要求「有返回箭头、没有底部
导航栏」,这两条在信息流上正好相反 —— 默认守卫在 feed 上恒为 false,watch 会在 3 次
采样(约 3 秒)内以 left-player-page 自杀,一个视频都看不完。养号传
`guard: () => pageState() === 'feed'`,视频链路不传、行为完全不变。

**② `panelInput()` 要认"键盘没弹"的形态**。首页信息流里点评论按钮打开面板,输入框
不会自动获得焦点、键盘也就不弹,它停在屏幕底部(实测 y=1816/1920),落不进原来的
0.45~0.85H 区间 → 面板明明开着却判成"没打开"。

**③ 评论计数多了一种格式**。新版 UI 把评论区拆成 Comments / Reviews 两个页签,
文案是 `Comments 785`(数字在后、与页签名同一个 TextView),旧正则
`^\d+\s*comments$` 一条都不匹配。

## 评论:当前版本做不到(已如实标注,不做静默降级)

`commentRate` 一律传 0,界面那行改成「当前版本不支持」,notes 里写明原因。

不是没实现,是**目标在无障碍树里不存在**:
  · 面板打开后右侧竖排动作栏的节点仍在树里,且与输入框同高
  · 把输入框整行右侧的所有节点 dump 过:只有动作栏那六个 + 每条评论自己的赞/表情
    + 作者头像,**没有发送键**(截图上那个粉色 ↑ 是自绘的)
  · 引擎是 execScriptFile 起的**子脚本**:gesture() 无效、shell 无注入权限
    (SecurityException)、root shell 报 Error opening shell: 'su'

顺带修掉一个**假阳性**:原来按"输入框下方取最右"找发送键,会稳定命中**点赞按钮**
(id=g8g)—— 点它 → 面板收起 + 视频被点赞 → 业务侧判成"评论成功"。
现在排除动作栏(它们的 desc 都有明确前缀)+ 找不到就如实报失败。

这意味着**视频链路的评论也一起失效了**(同一个发送键,TikTok 改版导致的既有回归),
现在的行为是如实报 `send-button-not-found` / `panel-still-open`。

## 一个会把 App 弄崩的 bug(真机上零提示)

`Engine.prepare` 只在 `task === "id"` 时早返回,养号于是落到末尾那句
「关键词为空」的校验上 —— 而养号的配置里**没有 target**(它没有搜索目标),
`built.config.target.keyword` 直接 `TypeError`。

在 AutoX 里这个崩溃**完全没提示**:脚本退出、界面回桌面、日志文件一个都没生成。
只有 logcat 能看到 `ScriptEngineService: TypeError ... tiktok-app.js#3270`。

已加分支早返回,并在 smoke 里加了断言防回归(PC 上会得到同一个异常)。

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

```
动作全关 3 轮     → 3 轮跑满;每次 watch 都有音频证据;上滑 916/1036ms 都等到新内容
likeRate=100%    → 2 轮 2 次点赞,落在两个不同视频,坐标都在屏内
followRate=100%  → 2 轮 2 次关注,都靠「关注按钮消失」验证
界面端到端        → 点运行 → engine-config task=nurture、
                    rounds 10-30 / browse 180000-480000 / like 0.3 / comment 0 / follow 0.15
                  → 引擎 ensureFeed → 真的跑起来(16.6s / 21.2s / 22s 三个视频,
                    上滑 890/918/831ms),跑了 3 轮后因设备无障碍服务掉线中断
                    (Accessibility service is enabled but not running —— 设备状态问题,
                     我用 force-stop 反复重启 AutoX 导致的,文档里写了恢复办法)
```

自检:引擎 114 项 / 界面 smoke **463 项**(新增 18 条)/ AUDIT_CLEAN / 两侧 md5 与设备一致。

## 文档

- `tiktok-auto/README.md` 新增 3.6 节(双约束 / 守卫为什么必须注入 / 评论为什么做不到 / 已知边界)
- `docs/AI_CONTEXT.md` 补 D12~D15(prepare 必须按任务分支、gesture 只在主脚本有效、
  发送键不在无障碍树、force-stop 会让无障碍服务掉线并给出恢复命令)
梦境 4 днів тому
батько
коміт
bc2d03dde7

+ 46 - 0
docs/AI_CONTEXT.md

@@ -582,6 +582,52 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 - 正解:本轮新增了 error → 保持 `running`(下次再试);否则 → `done`;
   另外"没抛错但一个人都没取到"要单独给一条 warning(列表空 / 「当前屏幕」页面不对)
 
+**D12|`Engine.prepare` 的校验必须按任务分支(★ 新增任务必查)**
+
+- 现象:养号点「开始运行」→ **脚本直接退出、界面回桌面,零提示**,日志文件一个都没生成
+- 根因:`prepare` 末尾那句「关键词为空」的校验读 `built.config.target.keyword`,
+  而养号的配置里**没有 `target`**(它没有搜索目标)→ `TypeError: Cannot read property "keyword" from undefined`
+- 只有 logcat 能看到:`ScriptEngineService: TypeError ... tiktok-app.js#3270`
+- 正解:新增任务时,`Engine.prepare` 必须像 `main.js` 的 `runByTask` 一样加分支早返回。
+  smoke 里有一条 `养号 prepare 不抛异常` 专防这个回归(PC 上会得到同一个异常)
+
+**D13|AutoX 里 `gesture()` 只在主脚本有效(★ 别指望它)**
+
+同一坐标、同一时机,三种点击方式的实测:
+
+| 方式 | 主脚本(RunIntentActivity 直跑) | 子脚本(`execScriptFile`) |
+|---|---|---|
+| `gesture(120, [[x,y],[x,y]])` | ✅ | ❌ **无效** |
+| `click(x, y)` 无障碍坐标 | ✅ | ✅ |
+| `shell('input tap x y')` | — | ❌ `SecurityException: Injecting input event` |
+| `shell('input tap x y', true)` | — | ❌ `Error opening shell: 'su'` |
+
+**引擎永远是子脚本**,所以只有无障碍点击可用。曾一度误判「TikTok 吞掉了坐标点击」而把
+`ui.tapPoint` 改成 `gesture`,方向是错的——真正的故障是键盘没弹导致输入框判据不命中。
+
+**D14|评论发送键在无障碍树里不存在**
+
+- 首页信息流的评论面板打开后,右侧竖排动作栏的节点**仍在树里**且与输入框同高
+- 把输入框整行右侧的所有节点 dump 过:只有动作栏那六个 + 每条评论自己的赞/表情 + 作者头像,
+  **没有发送键**(截图上那个粉色 ↑ 是自绘的,不暴露给无障碍)
+- 后果:评论发不出去。视频链路也受影响(同一个键)——这是 TikTok 改版的既有回归
+- 曾经的假阳性:按「输入框下方取最右」会稳定命中**点赞按钮**(`id=g8g`),
+  点它 → 面板收起 + 视频被点赞 → 业务侧判成「评论成功」。现在已排除动作栏 + 严格验证
+
+**D15|`force-stop` AutoX 会让无障碍服务「enabled but not running」**
+
+- 反复 `am force-stop org.autojs.autoxjs.v7` 之后,服务停在那里不恢复,
+  界面弹「无法开始运行 · 无障碍服务未开启」,脚本报
+  `Accessibility service is enabled but not running, this may be an Android bug`
+- 恢复办法(不用重启手机):
+  ```bash
+  adb shell "settings put secure enabled_accessibility_services '其他服务'"
+  adb shell "settings put secure enabled_accessibility_services 'org.autojs.autoxjs.v7/com.story.real.store.service.PeAccessibilityService:其他服务'"
+  ```
+  即「移除再加回」触发系统重绑
+- **测试期建议**:重跑脚本用 `am start ... RunIntentActivity -d file://...` 覆盖,
+  **不要**用 force-stop
+
 ### 6.E 数据与配置
 
 | 编号 | 现象 | 正解 |

+ 113 - 7
tiktok-app/dist/tiktok-app.js

@@ -1,6 +1,6 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-28T15:01:13.992Z
+ *  built 2026-10-29T08:52:20.316Z
  *  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/ 后重新构建。
  */
@@ -2870,6 +2870,25 @@ function seconds(ms) {
     return Math.max(1000, Math.floor(ms) * 1000);
 }
 
+/** 分钟 → 毫秒。下限给 1 分钟:填 0 会让"浏览时长"立刻到点,一轮都跑不完 */
+function minutesToMs(value) {
+    return Math.max(60000, Math.floor(value) * 60 * 1000);
+}
+
+/**
+ * 概率档位(`r30`)→ 小数(0.30)。
+ *
+ * 认不出来就返回 0 —— **宁可不做,不可乱做**:把非法值当成某个默认概率,
+ * 用户看到的档位和引擎实际执行的概率就悄悄不一致了。
+ */
+function rateOf(key) {
+    var matched = String(key || "").match(/^r(\d+)$/);
+    if (!matched) {
+        return 0;
+    }
+    return Number(matched[1]) / 100;
+}
+
 /**
  * 界面配置 → 引擎配置。
  *
@@ -2883,9 +2902,69 @@ Engine.buildConfig = function (task, opts) {
     if (task && task.task === "id") {
         return Engine.buildIdConfig(opts);
     }
+    if (task && task.task === "nurture") {
+        return Engine.buildNurtureConfig();
+    }
     return Engine.buildVideoConfig();
 };
 
+/**
+ * 养号参数 → 引擎配置。
+ *
+ * 界面只有 7 个字段,其中两对是区间:
+ *   · 浏览时长(分钟)  → nurture.browse
+ *   · 单次操作          → nurture.rounds(**是轮数,不是时间间隔**)
+ *   · 点赞/关注概率     → 档位 r30 → 0.30
+ *   · 评论概率          → **不接**(见下)
+ */
+Engine.buildNurtureConfig = function () {
+    var notes = [];
+
+    var roundsMin = Store.getNumber("tabs.nurture.actionMin");
+    var roundsMax = Store.getNumber("tabs.nurture.actionMax");
+    var browseMin = Store.getNumber("tabs.nurture.browseMin");
+    var browseMax = Store.getNumber("tabs.nurture.browseMax");
+
+    var likeRate = rateOf(Store.getEnum("tabs.nurture.likeRate", "likeRate"));
+    var followRate = rateOf(Store.getEnum("tabs.nurture.followRate", "followRate"));
+    var commentWanted = rateOf(Store.getEnum("tabs.nurture.commentRate", "commentRate"));
+
+    /* ★ 评论强制 0(2026-10-28 实测结论)。
+     *
+     * 养号是在**首页信息流**里跑的。那里的评论面板一打开,右侧竖排动作栏
+     * (关注/点赞/评论/收藏/分享/音乐)的无障碍节点仍然留在树里,且与输入框同高;
+     * 而真正那个"发送"按钮**在无障碍树里根本没有对应节点**
+     * (把输入框整行右侧的所有节点 dump 出来,只有动作栏那六个
+     *  + 每条评论自己的赞/表情 + 作者头像,没有发送键)。
+     *
+     * 引擎是 `execScriptFile` 起的子脚本,`gesture()` 在这个环境里无效、
+     * `shell('input tap')` 没有注入权限(SecurityException)——
+     * 唯一能用的无障碍点击打不到一个"树里不存在"的按钮。
+     *
+     * 所以这里如实传 0 并把原因写进 notes,让界面告诉用户"本轮不会评论",
+     * 而不是传一个会让引擎白试一轮、最后只留下一条 warning 的概率。 */
+    if (commentWanted > 0) {
+        notes.push("评论:当前 TikTok 版本不支持自动发送(发送键不在无障碍树里),本轮不会评论");
+    }
+
+    var config = {
+        task: "nurture",
+        nurture: {
+            /* 「单次操作」= 轮数(看一个视频 + 可能互动,算一轮) */
+            rounds: { min: roundsMin, max: roundsMax },
+            /* 「浏览时长」= 墙钟上限。与轮数**各自独立**,谁先到谁停 */
+            browse: { minMs: minutesToMs(browseMin), maxMs: minutesToMs(browseMax) },
+            likeRate: likeRate,
+            commentRate: 0,
+            followRate: followRate
+        },
+        comments: { enabled: false, pool: [] },
+        runtime: { dryRun: false, maxRuntimeMs: 0 }
+    };
+
+    return { config: config, notes: notes };
+};
+
 /* ------------------------------------------------------------ ID 提取 */
 
 /** ID 提取的产物目录(与其它通道文件同根,方便一起清理/查看) */
@@ -3170,8 +3249,11 @@ Engine.prepare = function (task, opts) {
     var built = Engine.buildConfig(task, opts);
 
     /* 「关键词为空」是**视频链路**的硬前提(空关键词搜索必失败)。
-     * ID 提取不需要关键词 —— 它可以只从粉丝列表或文本文件取材,
-     * 所以这条拦截必须按任务分支,否则 ID 提取第一次点运行就会被无理由挡下。 */
+     * ID 提取与养号都不需要关键词:前者可以只从粉丝列表或文本文件取材,
+     * 后者是在首页信息流里随机刷、根本没有目标。
+     * 所以这条拦截必须按任务分支 —— 不分支的话,养号一点运行就会
+     * 崩在 `built.config.target.keyword`(target 根本不存在,TypeError),
+     * 而且崩得**毫无提示**(AutoX 上脚本直接退出,界面回桌面)。 */
     if (task && task.task === "id") {
         if (!built.config.id.sources || built.config.id.sources.length === 0) {
             return {
@@ -3188,6 +3270,17 @@ Engine.prepare = function (task, opts) {
         };
     }
 
+    if (task && task.task === "nurture") {
+        /* 养号没有"必填项"要拦:轮数与时长都有默认值,概率档位也总是合法的。
+         * 直接放行(真正的保护在引擎侧:config.validate 会校验 rounds/browse)。 */
+        return {
+            ok: true,
+            scriptPath: path,
+            config: built.config,
+            notes: built.notes
+        };
+    }
+
     if (trimAll(built.config.target.keyword) === "") {
         return {
             ok: false,
@@ -7730,7 +7823,7 @@ Pages.dm = function (rerender) {
  * 而时长与次数天然有「最少/最多」两个端点,用区间表达最贴切。
  */
 Pages.NURTURE_SCRIPTS = [
-    { key: "s1", name: "\u968F\u673A\u64CD\u4F5C\u517B\u53F7" }
+    { key: "s1", name: "\u968F\u673A\u64CD\u4F5C\u517B\u53F7", engine: "tiktok-auto" }
 ];
 
 Pages.registerScripts("nurture", Pages.NURTURE_SCRIPTS);
@@ -7743,8 +7836,16 @@ function buildNurtureParams() {
         W.rangeRow(base + "browseMin", base + "browseMax", "\u6D4F\u89C8\u65F6\u957F\uFF08\u5206\u949F\uFF09", "~").root,
         W.settingRow("\u70B9\u8D5E\u6982\u7387",
             W.dropdown("likeRate", base + "likeRate").root, 8),
+        /* ★ 评论概率:当前做不到,所以**不做成下拉**。
+         *
+         * 首页信息流的评论面板里,那个"发送"按钮不在无障碍树里
+         * (把输入框整行右侧节点全 dump 过,只有动作栏那六个和评论自己的赞/表情)。
+         * 而引擎是子脚本,gesture 无效、shell 没有注入权限 —— 点不到就发不出去。
+         *
+         * 一个点不动的下拉,用户会以为"选了 10% 就会评论",比直接说"不支持"更糟。 */
         W.settingRow("\u8BC4\u8BBA\u6982\u7387",
-            W.dropdown("commentRate", base + "commentRate").root, 8),
+            U.text("\u5F53\u524D\u7248\u672C\u4E0D\u652F\u6301", Theme.m.hintSize,
+                   Theme.hex.textFaint, false), 8),
         W.settingRow("\u5173\u6CE8\u6982\u7387",
             W.dropdown("followRate", base + "followRate").root, 8),
         W.rangeRow(base + "actionMin", base + "actionMax", "\u5355\u6B21\u64CD\u4F5C", "~").root
@@ -7789,8 +7890,13 @@ Pages.nurture = function (rerender) {
     blocks.push(buildNurtureParams());
 
     blocks.push(U.spacer(8));
-    /* 设计稿 3:506:这行是左对齐的,不像其它页居中 */
-    blocks.push(W.hint("\u968F\u673A\u6D4F\u89C8\u3001\u70B9\u8D5E\u3001\u8BC4\u8BBA\u6A21\u62DF\u771F\u5B9E\u6D3B\u8DC3\u5EA6", false));
+    /* 设计稿 3:506:这行是左对齐的,不像其它页居中。
+     *
+     * 文案要交代两件规格书里没写清的事(不写用户就会猜错):
+     *   ① 「单次操作」是**轮数**,不是间隔秒数 —— 与「浏览时长」各自独立,谁先到谁停
+     *   ② 不含评论(那个当前做不到,见上面评论概率那行的注释) */
+    blocks.push(W.hint("\u968F\u673A\u6D4F\u89C8\u3001\u70B9\u8D5E\u3001\u5173\u6CE8\u6A21\u62DF\u771F\u5B9E\u6D3B\u8DC3\u5EA6\uFF1B"
+                       + "\u300C\u5355\u6B21\u64CD\u4F5C\u300D\u662F\u8F6E\u6570\uFF0C\u4E0E\u6D4F\u89C8\u65F6\u957F\u8C01\u5148\u5230\u8C01\u505C", false));
 
     W.stack(inner, blocks, Theme.m.gapPage);
 

+ 95 - 2
tiktok-app/src/engine.js

@@ -172,6 +172,25 @@ function seconds(ms) {
     return Math.max(1000, Math.floor(ms) * 1000);
 }
 
+/** 分钟 → 毫秒。下限给 1 分钟:填 0 会让"浏览时长"立刻到点,一轮都跑不完 */
+function minutesToMs(value) {
+    return Math.max(60000, Math.floor(value) * 60 * 1000);
+}
+
+/**
+ * 概率档位(`r30`)→ 小数(0.30)。
+ *
+ * 认不出来就返回 0 —— **宁可不做,不可乱做**:把非法值当成某个默认概率,
+ * 用户看到的档位和引擎实际执行的概率就悄悄不一致了。
+ */
+function rateOf(key) {
+    var matched = String(key || "").match(/^r(\d+)$/);
+    if (!matched) {
+        return 0;
+    }
+    return Number(matched[1]) / 100;
+}
+
 /**
  * 界面配置 → 引擎配置。
  *
@@ -185,9 +204,69 @@ Engine.buildConfig = function (task, opts) {
     if (task && task.task === "id") {
         return Engine.buildIdConfig(opts);
     }
+    if (task && task.task === "nurture") {
+        return Engine.buildNurtureConfig();
+    }
     return Engine.buildVideoConfig();
 };
 
+/**
+ * 养号参数 → 引擎配置。
+ *
+ * 界面只有 7 个字段,其中两对是区间:
+ *   · 浏览时长(分钟)  → nurture.browse
+ *   · 单次操作          → nurture.rounds(**是轮数,不是时间间隔**)
+ *   · 点赞/关注概率     → 档位 r30 → 0.30
+ *   · 评论概率          → **不接**(见下)
+ */
+Engine.buildNurtureConfig = function () {
+    var notes = [];
+
+    var roundsMin = Store.getNumber("tabs.nurture.actionMin");
+    var roundsMax = Store.getNumber("tabs.nurture.actionMax");
+    var browseMin = Store.getNumber("tabs.nurture.browseMin");
+    var browseMax = Store.getNumber("tabs.nurture.browseMax");
+
+    var likeRate = rateOf(Store.getEnum("tabs.nurture.likeRate", "likeRate"));
+    var followRate = rateOf(Store.getEnum("tabs.nurture.followRate", "followRate"));
+    var commentWanted = rateOf(Store.getEnum("tabs.nurture.commentRate", "commentRate"));
+
+    /* ★ 评论强制 0(2026-10-28 实测结论)。
+     *
+     * 养号是在**首页信息流**里跑的。那里的评论面板一打开,右侧竖排动作栏
+     * (关注/点赞/评论/收藏/分享/音乐)的无障碍节点仍然留在树里,且与输入框同高;
+     * 而真正那个"发送"按钮**在无障碍树里根本没有对应节点**
+     * (把输入框整行右侧的所有节点 dump 出来,只有动作栏那六个
+     *  + 每条评论自己的赞/表情 + 作者头像,没有发送键)。
+     *
+     * 引擎是 `execScriptFile` 起的子脚本,`gesture()` 在这个环境里无效、
+     * `shell('input tap')` 没有注入权限(SecurityException)——
+     * 唯一能用的无障碍点击打不到一个"树里不存在"的按钮。
+     *
+     * 所以这里如实传 0 并把原因写进 notes,让界面告诉用户"本轮不会评论",
+     * 而不是传一个会让引擎白试一轮、最后只留下一条 warning 的概率。 */
+    if (commentWanted > 0) {
+        notes.push("评论:当前 TikTok 版本不支持自动发送(发送键不在无障碍树里),本轮不会评论");
+    }
+
+    var config = {
+        task: "nurture",
+        nurture: {
+            /* 「单次操作」= 轮数(看一个视频 + 可能互动,算一轮) */
+            rounds: { min: roundsMin, max: roundsMax },
+            /* 「浏览时长」= 墙钟上限。与轮数**各自独立**,谁先到谁停 */
+            browse: { minMs: minutesToMs(browseMin), maxMs: minutesToMs(browseMax) },
+            likeRate: likeRate,
+            commentRate: 0,
+            followRate: followRate
+        },
+        comments: { enabled: false, pool: [] },
+        runtime: { dryRun: false, maxRuntimeMs: 0 }
+    };
+
+    return { config: config, notes: notes };
+};
+
 /* ------------------------------------------------------------ ID 提取 */
 
 /** ID 提取的产物目录(与其它通道文件同根,方便一起清理/查看) */
@@ -472,8 +551,11 @@ Engine.prepare = function (task, opts) {
     var built = Engine.buildConfig(task, opts);
 
     /* 「关键词为空」是**视频链路**的硬前提(空关键词搜索必失败)。
-     * ID 提取不需要关键词 —— 它可以只从粉丝列表或文本文件取材,
-     * 所以这条拦截必须按任务分支,否则 ID 提取第一次点运行就会被无理由挡下。 */
+     * ID 提取与养号都不需要关键词:前者可以只从粉丝列表或文本文件取材,
+     * 后者是在首页信息流里随机刷、根本没有目标。
+     * 所以这条拦截必须按任务分支 —— 不分支的话,养号一点运行就会
+     * 崩在 `built.config.target.keyword`(target 根本不存在,TypeError),
+     * 而且崩得**毫无提示**(AutoX 上脚本直接退出,界面回桌面)。 */
     if (task && task.task === "id") {
         if (!built.config.id.sources || built.config.id.sources.length === 0) {
             return {
@@ -490,6 +572,17 @@ Engine.prepare = function (task, opts) {
         };
     }
 
+    if (task && task.task === "nurture") {
+        /* 养号没有"必填项"要拦:轮数与时长都有默认值,概率档位也总是合法的。
+         * 直接放行(真正的保护在引擎侧:config.validate 会校验 rounds/browse)。 */
+        return {
+            ok: true,
+            scriptPath: path,
+            config: built.config,
+            notes: built.notes
+        };
+    }
+
     if (trimAll(built.config.target.keyword) === "") {
         return {
             ok: false,

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

@@ -171,7 +171,7 @@ Pages.dm = function (rerender) {
  * 而时长与次数天然有「最少/最多」两个端点,用区间表达最贴切。
  */
 Pages.NURTURE_SCRIPTS = [
-    { key: "s1", name: "\u968F\u673A\u64CD\u4F5C\u517B\u53F7" }
+    { key: "s1", name: "\u968F\u673A\u64CD\u4F5C\u517B\u53F7", engine: "tiktok-auto" }
 ];
 
 Pages.registerScripts("nurture", Pages.NURTURE_SCRIPTS);
@@ -184,8 +184,16 @@ function buildNurtureParams() {
         W.rangeRow(base + "browseMin", base + "browseMax", "\u6D4F\u89C8\u65F6\u957F\uFF08\u5206\u949F\uFF09", "~").root,
         W.settingRow("\u70B9\u8D5E\u6982\u7387",
             W.dropdown("likeRate", base + "likeRate").root, 8),
+        /* ★ 评论概率:当前做不到,所以**不做成下拉**。
+         *
+         * 首页信息流的评论面板里,那个"发送"按钮不在无障碍树里
+         * (把输入框整行右侧节点全 dump 过,只有动作栏那六个和评论自己的赞/表情)。
+         * 而引擎是子脚本,gesture 无效、shell 没有注入权限 —— 点不到就发不出去。
+         *
+         * 一个点不动的下拉,用户会以为"选了 10% 就会评论",比直接说"不支持"更糟。 */
         W.settingRow("\u8BC4\u8BBA\u6982\u7387",
-            W.dropdown("commentRate", base + "commentRate").root, 8),
+            U.text("\u5F53\u524D\u7248\u672C\u4E0D\u652F\u6301", Theme.m.hintSize,
+                   Theme.hex.textFaint, false), 8),
         W.settingRow("\u5173\u6CE8\u6982\u7387",
             W.dropdown("followRate", base + "followRate").root, 8),
         W.rangeRow(base + "actionMin", base + "actionMax", "\u5355\u6B21\u64CD\u4F5C", "~").root
@@ -230,8 +238,13 @@ Pages.nurture = function (rerender) {
     blocks.push(buildNurtureParams());
 
     blocks.push(U.spacer(8));
-    /* 设计稿 3:506:这行是左对齐的,不像其它页居中 */
-    blocks.push(W.hint("\u968F\u673A\u6D4F\u89C8\u3001\u70B9\u8D5E\u3001\u8BC4\u8BBA\u6A21\u62DF\u771F\u5B9E\u6D3B\u8DC3\u5EA6", false));
+    /* 设计稿 3:506:这行是左对齐的,不像其它页居中。
+     *
+     * 文案要交代两件规格书里没写清的事(不写用户就会猜错):
+     *   ① 「单次操作」是**轮数**,不是间隔秒数 —— 与「浏览时长」各自独立,谁先到谁停
+     *   ② 不含评论(那个当前做不到,见上面评论概率那行的注释) */
+    blocks.push(W.hint("\u968F\u673A\u6D4F\u89C8\u3001\u70B9\u8D5E\u3001\u5173\u6CE8\u6A21\u62DF\u771F\u5B9E\u6D3B\u8DC3\u5EA6\uFF1B"
+                       + "\u300C\u5355\u6B21\u64CD\u4F5C\u300D\u662F\u8F6E\u6570\uFF0C\u4E0E\u6D4F\u89C8\u65F6\u957F\u8C01\u5148\u5230\u8C01\u505C", false));
 
     W.stack(inner, blocks, Theme.m.gapPage);
 

+ 5 - 1
tiktok-app/tools/audit-values.js

@@ -296,7 +296,11 @@ pageShows('mine', '未登录', '卡密状态有默认值');
 /* 养号参数:两个区间 + 三个概率档位,都不能出现空框 */
 pageShows('nurture', '浏览时长(分钟)', '浏览时长是区间行');
 pageShows('nurture', '30%', '点赞概率有默认档位');
-pageShows('nurture', '10%', '评论概率有默认档位');
+/* ★ 评论概率**故意不渲染**(2026-10-28):首页信息流的评论面板里,发送键不在
+ * 无障碍树中,引擎点不到(gesture 在子脚本无效、shell 无注入权限)。
+ * 所以界面上那行改成了「当前版本不支持」的说明文字,而不是一个点了没用的下拉。
+ * 断言跟着改成"显示不支持",否则这条永远红着。 */
+pageShows('nurture', '当前版本不支持', '评论概率如实标注不可用');
 pageShows('nurture', '15%', '关注概率有默认档位');
 pageShows('nurture', '单次操作', '单次操作是区间行');
 /* 新增的 ID提取功能设置 */

+ 76 - 0
tiktok-app/tools/smoke.js

@@ -934,6 +934,82 @@ env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
 env.flushIntervals();
 eq('ID提取的终止态被接住', api.Session.running, false);
 
+/* ------------------------------------- 11.5 养号(配置映射 / 页面 / 不可用项) */
+console.log('[smoke] 养号 · 配置映射 · 评论不可用的如实告知');
+
+/* 界面只有 7 个字段,但两个区间 + 三个概率档位都要能正确落到引擎配置上。
+ * 映射错一个,用户看到的是"填了 30%",引擎执行的是别的数 —— 而且不会报错。 */
+api.Store.set('tabs.nurture.actionMin', 5);
+api.Store.set('tabs.nurture.actionMax', 9);
+api.Store.set('tabs.nurture.browseMin', 4);
+api.Store.set('tabs.nurture.browseMax', 7);
+api.Store.set('tabs.nurture.likeRate', 'r50');
+api.Store.set('tabs.nurture.followRate', 'r15');
+
+const nur = api.Engine.buildConfig({ task: 'nurture' });
+const nurtureTask = nur.config.task;
+eq('养号映射:task = nurture', nurtureTask, 'nurture');
+eq('养号映射:轮数下限', nur.config.nurture.rounds.min, 5);
+eq('养号映射:轮数上限', nur.config.nurture.rounds.max, 9);
+eq('养号映射:浏览时长下限(4 分钟 → 毫秒)', nur.config.nurture.browse.minMs, 4 * 60 * 1000);
+eq('养号映射:浏览时长上限(7 分钟 → 毫秒)', nur.config.nurture.browse.maxMs, 7 * 60 * 1000);
+eq('养号映射:点赞概率 50% → 0.5', nur.config.nurture.likeRate, 0.5);
+eq('养号映射:关注概率 15% → 0.15', nur.config.nurture.followRate, 0.15);
+
+/* ★ 评论一律传 0,并且**必须在 notes 里说出来**。
+ * 静默降级是这个项目最忌讳的事:用户看到"10%",跑完却发现一条评论都没发,
+ * 而界面上没有任何解释。 */
+eq('养号映射:评论概率强制 0(当前版本发不出去)', nur.config.nurture.commentRate, 0);
+eq('养号映射:评论在引擎侧关闭', nur.config.comments.enabled, false);
+api.Store.set('tabs.nurture.commentRate', 'r10');
+const nurWithComment = api.Engine.buildConfig({ task: 'nurture' });
+check('养号:选了评论概率 → notes 里如实说明不支持',
+      nurWithComment.notes.length > 0 && nurWithComment.notes[0].indexOf('评论') >= 0,
+      JSON.stringify(nurWithComment.notes));
+eq('养号:说明之后评论仍然是 0', nurWithComment.config.nurture.commentRate, 0);
+
+/* 存了非法档位时:`Store.getEnum` 会把它兜回该枚举的默认项(likeRate 默认 r30),
+ * 所以引擎拿到的是 0.3 而不是 0 —— 这是**有意的**:下拉永远显示一个合法档位,
+ * 不会出现"框里是空的、引擎却没做任何动作"这种沉默的不一致。
+ * (engine.js 里 rateOf 对非 rN 格式返回 0 是第二层兜底,正常路径不会走到。) */
+api.Store.set('tabs.nurture.likeRate', 'garbage');
+eq('养号映射:非法档位被兜回默认档位(r30 → 0.3)',
+   api.Engine.buildConfig({ task: 'nurture' }).config.nurture.likeRate, 0.3);
+api.Store.set('tabs.nurture.likeRate', api.Store.defaultOf('tabs.nurture.likeRate'));
+api.Store.set('tabs.nurture.commentRate', api.Store.defaultOf('tabs.nurture.commentRate'));
+
+/* 脚本必须声明 engine,否则点运行会被 Dialog.engineMissing 拦下 ——
+ * 这是"引擎已接入"的唯一标记,漏了它养号永远是灰的。 */
+const nurtureScripts = api.Pages.NURTURE_SCRIPTS;
+eq('养号脚本声明了引擎', nurtureScripts.length, 1);
+eq('养号脚本的 engine 标记', nurtureScripts[0].engine, 'tiktok-auto');
+
+/* ★ prepare 必须按任务分支放行。
+ *
+ * 养号的配置里**没有 `target`**(它没有搜索目标),而 `prepare` 末尾那句
+ * 「关键词为空」的校验会去读 `config.target.keyword` —— 真机上直接
+ * `TypeError: Cannot read property "keyword" from undefined`,而且**毫无提示**:
+ * AutoX 里脚本直接退出、界面回桌面,只能靠 logcat 才看得到。
+ *
+ * 这条断言在 PC 上跑会得到同一个异常,所以是这类"新增任务漏改校验分派"的防火墙。 */
+let nurturePlan = null;
+try {
+    nurturePlan = api.Engine.prepare({ task: 'nurture' });
+} catch (error) {
+    nurturePlan = { ok: false, error: 'prepare 抛异常:' + error.message };
+}
+check('养号 prepare 不抛异常(配置里没有 target)',
+      nurturePlan && nurturePlan.ok === true, JSON.stringify(nurturePlan && nurturePlan.error));
+
+/* 页面上必须显示"当前版本不支持",而不是给一个点不动的下拉 */
+api.App.openDetail('nurture');
+api.App.renderScreen();
+const nurTexts = textsOf(api.App.contentHost).join(' | ');
+check('养号页显示评论不可用', nurTexts.indexOf('当前版本不支持') >= 0, nurTexts);
+check('养号页说明了「单次操作」是轮数', nurTexts.indexOf('轮数') >= 0, nurTexts);
+check('养号页仍保留点赞与关注概率', nurTexts.indexOf('点赞概率') >= 0 && nurTexts.indexOf('关注概率') >= 0, nurTexts);
+api.Store.set('activeDetail', '');
+
 /* ------------------------------------- 12. 断点续跑(提示条 / 记录页「继续」) */
 console.log('[smoke] 断点续跑 · 提示条 · 记录页「继续」');
 

+ 67 - 0
tiktok-auto/README.md

@@ -455,6 +455,73 @@ TikTok 列表内容会变、滚动位置无法复原,记行号只会漏人或
 
 ---
 
+## 3.6 养号(`task = "nurture"`)
+
+在首页信息流里随机浏览、按概率点赞与关注,模拟真实活跃度。**没有目标**——
+视频链路是「搜关键词看 N 个」,养号是「打开信息流一个接一个往下刷」。
+
+### 双约束:轮数与时长,谁先到谁停
+
+| 界面字段 | 引擎字段 | 含义 |
+|---|---|---|
+| 单次操作 10~30 | `nurture.rounds` | **轮数**(看一个视频 + 可能互动算一轮) |
+| 浏览时长 3~8 分钟 | `nurture.browse` | 墙钟上限 |
+
+两者**各自独立**,不是「用一项算出另一项」。结果里记 `stoppedBy`(`rounds` / `time` / `aborted`)——
+轮数先满 vs 时长先到,对用户下一步该调哪个参数完全是指向不同的,不能笼统说「完成」。
+
+### 每轮做什么
+
+```
+确认还在 feed(不是就交回 flow.ensureFeed 拉回来)
+  → 判内容类型(照片帖没有播放,短停留即可)
+  → player.watch(5~25 秒随机,带 feed 守卫)
+  → 按概率 点赞 → 关注 →(评论,见下)
+  → ui.dragScreen 上滑,判据是「新内容真的出现了」
+```
+
+动作后各有一个 0.8~2.5 秒的随机停顿——真人不会点完立刻滑。
+
+### ★ `player.watch` 必须能注入页面守卫
+
+`onPlayerPage()` 的判据里要求**有返回箭头、没有底部导航栏**,这两条在信息流上正好相反。
+所以默认守卫在 feed 上恒为 `false`,watch 会在 3 次采样(约 3 秒)内以 `left-player-page` 退出,
+一个视频都看不完。养号传 `guard: () => pageState() === 'feed'` 进去即可,视频链路不传、行为不变。
+
+### 复用了什么,没新写什么
+
+`flow.launchApp` / `flow.ensureFeed`(应用级动作)、`player.watch`(有界观看 + 播放存活证据)、
+`interact.like` / `interact.follow`(点一下 + 验状态)、`tiktok.feedSignature`(判上滑有没有真换视频)。
+新写的只有「编排」这一层:双约束、概率、节奏、异常兜底。
+
+### ★ 评论:当前版本做不到(`commentRate` 一律传 0)
+
+不是没实现,是**目标在无障碍树里不存在**:
+
+- 首页信息流的评论面板一打开,右侧竖排动作栏(关注/点赞/评论/收藏/分享/音乐)的节点
+  **仍然留在树里**,且与输入框同高;真正那个「发送」按钮**没有对应节点**
+  (把输入框整行右侧的所有节点全 dump 过:只有动作栏那六个 + 每条评论自己的赞/表情 + 作者头像)
+- 引擎是 `execScriptFile` 起的**子脚本**,这个环境里:
+  `gesture()` 无效(主脚本有效)、`shell('input tap')` 无注入权限(`SecurityException`)、
+  root shell 报 `Error opening shell: 'su'`
+- 唯一能用的无障碍点击打不到一个「树里不存在」的按钮
+
+所以界面上「评论概率」那行改成了 `当前版本不支持` 的说明文字,`engine.buildNurtureConfig`
+一律传 `commentRate: 0`,并在 `notes` 里如实告知。**不做静默降级**——用户看到「10%」
+却一条都没发出去、界面上还没有任何解释,是最糟的那种失败。
+
+> 同样的原因让**视频链路的评论**也一起失效了(同一个发送键)。那是 TikTok 改版导致的既有回归,
+> 不是养号引入的。现在的行为是如实报 `send-button-not-found` / `panel-still-open`,
+> 不会再「点了点赞按钮 → 面板关了 → 报成功」那种假阳性。
+
+### 已知边界
+
+- **照片帖**:信息行是 `Photo`,没有播放。按短停留(1.5~4 秒)处理,点赞/关注照做。
+- **直播 / 广告落地页**:`pageState()` 会变成别的值,每轮开工前检查,不对就交回 `ensureFeed`。
+- **连续 3 次上滑都换不了视频** → 判定卡死,提前结束并记 warning。
+
+---
+
 ## 4. 需求 → 设计对照
 
 ### 需求 1:网络延迟下也要稳定,不能只靠 sleep

Різницю між файлами не показано, бо вона завелика
+ 816 - 27
tiktok-auto/dist/tiktok-auto.js


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

@@ -270,12 +270,25 @@ TikTokAuto.bridge = (function () {
         log('info', '终态已上报:' + status);
     }
 
+    /**
+     * 养号的三个动作计数拼成一句话。
+     * 只写"真的做了"的个数(跳过的不算),否则用户会以为自己点了 N 个赞、
+     * 实际全是"本来就已经赞过"。
+     */
+    function nurtureActions(summary) {
+        return '点赞 ' + summary.liked + ' / 关注 ' + summary.followed +
+            ' / 评论 ' + summary.commented;
+    }
+
     /** 给界面看的一句人话,不要求界面自己去解析 summary 结构 */
     function describe(status, summary) {
         /* ID 提取与视频链路的 summary 形状不同(提取个数 vs 视频个数),
          * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。
          * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */
         var isIdTask = summary && typeof summary.extracted === 'number';
+        /* 养号有自己的形状(轮数 + 三个动作计数),既不是"提取 N 个"也不是"处理 N 个视频"。
+         * 判据用 summary 自己带的 task 字段,不靠调用方传——这样以后加任务不用再动这里。 */
+        var isNurture = summary && summary.task === 'nurture' && typeof summary.rounds === 'number';
         /* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
          * 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */
         var total = summary && typeof summary.extractedTotal === 'number'
@@ -286,12 +299,21 @@ TikTokAuto.bridge = (function () {
             ? ',本轮新增 ' + summary.extracted + ' 个' : '';
 
         if (status === 'stopped') {
+            if (isNurture) {
+                return '已停止:浏览 ' + summary.rounds + ' 轮 / ' + nurtureActions(summary);
+            }
             return isIdTask
                 ? '已停止' + resumedTag + ':累计 ' + total + ' 个 ID' + thisRound +
                   '(可点「继续」接着跑)'
                 : '已停止';
         }
         if (status === 'finished') {
+            if (isNurture) {
+                /* 时长先到要说出来 —— 用户下一步该调的是「浏览时长」而不是别的 */
+                var why = summary.stoppedBy === 'time' ? '(时长先到,轮数没用满)' : '';
+                return '完成:浏览 ' + summary.rounds + ' 轮 / ' +
+                    nurtureActions(summary) + why;
+            }
             if (isIdTask) {
                 var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
                 /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */

+ 131 - 10
tiktok-auto/src/comment.js

@@ -34,7 +34,7 @@ TikTokAuto.comment = (function () {
         return candidates[Math.floor(Math.random() * candidates.length)];
     }
 
-    /** 打开评论面板:点内联输入框 → 等面板输入框出现 */
+    /** 打开评论面板:点内联输入框 / 点评论按钮 → 等面板输入框出现 */
     function openPanel(config, step) {
         var story = step || 'openCommentPanel';
         var logger = TikTokAuto.logger;
@@ -45,18 +45,122 @@ TikTokAuto.comment = (function () {
             return true;
         }
 
-        var inline = tiktok.inlineInput();
-        if (!inline) {
-            logger.error(story, '找不到评论入口(内联输入框)');
+        /* 两个入口,按页面形态选:
+         *   · 播放页(从搜索结果/主页点进来的)底部有一条内联输入框 "Add comment..."
+         *   · **首页信息流底部是导航栏**(Home/Friends/Inbox/Profile),没有内联输入框
+         *     —— 只能点右侧那个 "Read or add comments. N comments" 按钮。
+         * 以前只有第一种,于是养号在信息流里发评论会直接报"找不到评论入口"。
+         */
+        var entry = tiktok.inlineInput();
+        var via = 'inline-input';
+        if (!entry) {
+            entry = tiktok.commentButton();
+            via = 'comment-button';
+        }
+        if (!entry) {
+            logger.error(story, '找不到评论入口(内联输入框与评论按钮都没有)');
             return false;
         }
-        TikTokAuto.ui.clickNode(inline, story);
+        logger.info(story, '打开评论面板(入口=' + via + ')');
+
+        var ready = false;
+        if (via === 'comment-button') {
+            ready = openByCommentButton(config, entry, story);
+            if (!ready) {
+                /* 换一种点击手段再试 —— 入口节点可能已经失效(页面重排过) */
+                entry = tiktok.commentButton();
+                if (entry) {
+                    TikTokAuto.ui.clickNode(entry, story);
+                }
+            }
+        } else {
+            TikTokAuto.ui.clickNode(entry, story);
+        }
+
+        if (ready) {
+            return true;
+        }
 
-        var ready = TikTokAuto.wait.waitFor(story, function () {
+        var opened = TikTokAuto.wait.waitFor(story, function () {
             return tiktok.panelInput() !== null ? true : null;
         }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
 
-        return ready === true;
+        if (!opened) {
+            /* 把当时所有输入框的位置记下来。"点了没反应"和"面板开了但认不出输入框"
+             * 是两种完全不同的故障,而在日志里它们长得一模一样 —— 没有这行就只能靠猜。 */
+            var seen = [];
+            try {
+                var ets = className('android.widget.EditText').find();
+                for (var e = 0; e < ets.size(); e += 1) {
+                    var box = ets.get(e).bounds();
+                    seen.push(box.top + '~' + box.bottom);
+                }
+                logger.warn(story, '面板没打开;此刻 EditText ' + ets.size() +
+                    ' 个 @ ' + seen.join(', ') + '(判据区间 ' +
+                    Math.floor(TikTokAuto.layout.screen().height * 0.45) + '~' +
+                    Math.floor(TikTokAuto.layout.screen().height * 0.85) + ')');
+            } catch (ignored) {
+                logger.warn(story, '面板没打开;连输入框都列举失败');
+            }
+            /* 有的视频关闭了评论 / 点到了别的地方。把话说清楚,
+             * 否则上层只看到"评论发不出去"却不知道为什么。 */
+            logger.warn(story, '点了入口但面板没打开(该视频可能关闭了评论)');
+        }
+        return opened === true;
+    }
+
+    /**
+     * 通过点右侧那个 "Read or add comments" 按钮打开面板(首页信息流里唯一的入口)。
+     *
+     * ★ 两个都必须做,缺一个就会"点击成功但面板死活不出来"(2026-10-28 真机实测):
+     *
+     * ① **点之前先等页面安静**。watch 刚结束、或者面板刚收起时,这一下点击会被吞掉。
+     *    症状极其迷惑:点击返回成功、`panelInput()` 却 8 秒都等不到;而隔一秒手动点
+     *    同一个坐标,400ms 就出面板。这和 `verifyInCommentList` 里"发布后立刻点角标
+     *    会被吞"是同一类问题,处理手法也一样(settle + 重试)。
+     *
+     * ② **不能用 node.click()**。那个无障碍 ACTION_CLICK 返回 true,但 TikTok 不响应。
+     *    这一竖条按钮又窄(宽 128px)位置又固定,坐标点击才可靠。
+     */
+    function openByCommentButton(config, entry, story) {
+        var logger = TikTokAuto.logger;
+        var tiktok = TikTokAuto.tiktok;
+        var attempts = 0;
+        var maxAttempts = 2;
+        var ready = false;
+
+        while (!ready && attempts < maxAttempts) {
+            attempts += 1;
+
+            TikTokAuto.wait.waitQuiet(function () {
+                return TikTokAuto.layout.signature();
+            }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
+
+            var point = TikTokAuto.layout.center(entry);
+            if (point) {
+                logger.info(story, '第 ' + attempts + ' 次坐标点击评论入口 @(' +
+                    point.x + ',' + point.y + ')');
+                TikTokAuto.ui.tapPoint(point.x, point.y);
+            } else {
+                logger.warn(story, '算不出评论入口的中心点,改用无障碍点击');
+                TikTokAuto.ui.clickNode(entry, story);
+            }
+
+            ready = TikTokAuto.wait.waitFor(story, function () {
+                return tiktok.panelInput() !== null ? true : null;
+            }, 4000, config.probe.pollTickMs) === true;
+
+            if (!ready) {
+                /* 页面可能重排过,旧节点已失效 —— 重新找一个再试 */
+                entry = tiktok.commentButton();
+                if (!entry) {
+                    logger.warn(story, '重试时找不到评论入口了');
+                    break;
+                }
+            }
+        }
+
+        return ready;
     }
 
     /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 back) */
@@ -111,7 +215,17 @@ TikTokAuto.comment = (function () {
         if (!button) {
             return { ok: false, via: 'send-button-not-found', text: text, reason: '定位不到发送按钮' };
         }
-        TikTokAuto.ui.clickNode(button, story);
+        /* ★ 发送键必须用**坐标点击**(2026-10-28 实测):
+         * 它是个纯图标按钮,无障碍 ACTION_CLICK 打上去会落到祖先容器 ——
+         * 实测点到了屏幕正中 (540,1465),那是评论列表的内容区,等于什么都没发。
+         * 而它形状固定、位置刚从输入框推出来,坐标点最准。 */
+        var sendPoint = TikTokAuto.layout.center(button);
+        if (sendPoint) {
+            logger.info(story, '坐标点击发送键 @(' + sendPoint.x + ',' + sendPoint.y + ')');
+            TikTokAuto.ui.tapPoint(sendPoint.x, sendPoint.y);
+        } else {
+            TikTokAuto.ui.clickNode(button, story);
+        }
 
         return awaitOutcome(config, text, before, story);
     }
@@ -134,10 +248,17 @@ TikTokAuto.comment = (function () {
 
         // 必要条件:面板收起且输入框复位
         var closed = TikTokAuto.wait.waitFor(step, function () {
-            if (tiktok.panelInput() !== null) {
+            if (tiktok.commentPanelOpen()) {
                 return null;
             }
-            return tiktok.inlineInput() !== null ? true : null;
+            /* 播放页要等内联输入框真的回来 —— 这比"面板关了"更严(说明回到了视频页)。
+             * 但**信息流底部是导航栏、本来就没有内联输入框**:那里再等它就永远等不到,
+             * 明明发成功了也会被判成"面板没有收起"。所以那种形态只认"回到了 feed"。
+             */
+            if (tiktok.inlineInput() !== null) {
+                return true;
+            }
+            return tiktok.pageState() === 'feed' ? true : null;
         }, config.timeouts.postMs, config.probe.pollTickMs);
 
         if (closed === null) {

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

@@ -131,6 +131,31 @@ TikTokAuto.config = (function () {
             strictPostCheck: false,
             verifyInList: true
         },
+        /**
+         * 养号(task = 'nurture'):在首页信息流里随机浏览 + 按概率互动。
+         *
+         * 与视频链路共用 `watch` / `comments` / `probe` 不够——养号有两处语义不同:
+         *   ① **没有目标**:视频链路是"搜关键词看 N 个",养号是"往下刷",
+         *      所以没有 target.keyword / maxVideos,换成 rounds 与 browse 双约束
+         *   ② **单次观看时长要短**:视频链路为了"观看有效"默认 10~35 秒,
+         *      养号模拟的是"划过去看一眼",5~25 秒之间随机更像真人
+         *
+         * 概率是**小数**(0.3 = 30%),界面那边是档位(r30),由 engine.js 换算。
+         * 顶层 `comments` 段(文案池、是否校验)在这里同样生效——一次只跑一个任务,不会打架。
+         */
+        nurture: {
+            /** 单次操作的**轮数**(界面「单次操作 10~30」)。与 browse 各自独立,谁先到谁停 */
+            rounds: { min: 10, max: 30 },
+            /** 浏览时长(界面「浏览时长 3~8 分钟」) */
+            browse: { minMs: 3 * 60 * 1000, maxMs: 8 * 60 * 1000 },
+            /** 单个视频看多久。养号是"划过去看一眼",不该按视频链路的 10~35 秒 */
+            watch: { minMs: 5000, maxMs: 25000 },
+            /** 做完一个动作后的随机停顿(真人不会点完立刻滑) */
+            pause: { minMs: 800, maxMs: 2500 },
+            likeRate: 0.30,
+            commentRate: 0.10,
+            followRate: 0.15
+        },
         timeouts: {
             appLaunchMs: 25000,
             pageReadyMs: 15000,
@@ -280,6 +305,33 @@ TikTokAuto.config = (function () {
             return config;
         }
 
+        /* —— 养号任务:没有搜索目标,校验的是"轮数 / 时长 / 概率" ——
+         * ★ 这一支必须放在 `var target = config.target` **之前**:
+         * 下面那段会硬性要求 `target.keyword` 非空,那是视频链路的前提。
+         * 养号根本不搜索,落到那里就会被判成"配置错误",而且报的是
+         * "target.keyword 不能为空"这种让人完全摸不着头脑的原因。 */
+        if (config.task === 'nurture') {
+            var nurture = config.nurture || {};
+            var rounds = nurture.rounds || {};
+            var browse = nurture.browse || {};
+            if (!(Number(rounds.min) >= 1)) {
+                throw new ConfigError('nurture.rounds.min 必须 >= 1', rounds.min);
+            }
+            if (Number(rounds.max) < Number(rounds.min)) {
+                throw new ConfigError('nurture.rounds.max 不能小于 rounds.min', rounds);
+            }
+            if (!(Number(browse.maxMs) > 0)) {
+                throw new ConfigError('nurture.browse.maxMs 必须 > 0', browse.maxMs);
+            }
+            /* 要发评论就必须有文案 —— 养号页补了「评论文案文件」正是为了这件事。
+             * 池子为空却把 commentRate 设成 10%,跑起来只会一轮轮地告警。 */
+            if (config.comments && config.comments.enabled &&
+                (!config.comments.pool || config.comments.pool.length === 0)) {
+                throw new ConfigError('要发评论就必须有评论文案(comments.pool 为空)', 0);
+            }
+            return config;
+        }
+
         var target = config.target;
         if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
             throw new ConfigError('target.keyword 不能为空', target.keyword);
@@ -323,6 +375,23 @@ TikTokAuto.config = (function () {
                 dedupeHistory: config.id.output.dedupeHistory,
                 dryRun: config.id.output.dryRun
             }));
+        } else if (config.task === 'nurture') {
+            /* 养号没有关键词,报 video 那套字段(keyword/maxVideos)等于什么都没说。
+             * 排障时最需要的恰恰是"概率和轮数到底生效成了多少"。 */
+            var nur = config.nurture;
+            logger.info('config', '生效配置(养号):' + JSON.stringify({
+                rounds: [nur.rounds.min, nur.rounds.max],
+                browseMaxMin: Math.round(nur.browse.maxMs / 60000),
+                watchMs: [nur.watch.minMs, nur.watch.maxMs],
+                rates: {
+                    like: nur.likeRate,
+                    comment: nur.commentRate,
+                    follow: nur.followRate
+                },
+                commentEnabled: config.comments.enabled,
+                commentCount: config.comments.pool.length,
+                dryRun: config.runtime.dryRun
+            }));
         } else {
             logger.info('config', '生效配置:' + JSON.stringify({
                 keyword: config.target.keyword,

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

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

+ 290 - 0
tiktok-auto/src/nurture.js

@@ -0,0 +1,290 @@
+/**
+ * nurture.js — 养号:在首页信息流里随机浏览 + 按概率点赞 / 关注 / 评论
+ *
+ * 与视频链路(flow.js)的区别只有一句话:**没有目标**。
+ * 视频链路是"搜关键词 → 点进指定数量的视频",养号是"打开信息流 → 一个接一个往下刷"。
+ * 所以这里复用了 flow 的应用级动作(`launchApp` / `ensureFeed`)与
+ * player / interact / comment 三个能力模块,自己只负责**编排节奏**。
+ *
+ * ## 双约束:轮数与时长,谁先到谁停
+ *
+ * 界面给的是「单次操作 10~30」(轮数)与「浏览时长 3~8 分钟」。两者**各自独立**,
+ * 不是"用一项算出另一项":
+ *   · 轮数先满 —— 真人式收工,正常结束
+ *   · 时长先到 —— 可能刚好卡在某个视频中间,如实说明"做了 N 轮,没用满轮数"
+ * 所以结果里记 `stoppedBy`,不假装两个都达标。
+ *
+ * ## 为什么每轮都要重新确认"还在信息流"
+ *
+ * 实测信息流里会滑到这些东西:**照片轮播帖**(没有播放)、**带货卡**
+ * (`Creator earns commission`)、以及可能的**直播**。前两种滑走就好,
+ * 直播会让 `pageState()` 变成别的值、上滑也未必切得动。
+ * 每轮开工前确认一次,不对就交回 `ensureFeed` 拉回(它自己会按返回,多次失败会重启应用)。
+ *
+ * 依赖:TikTokAuto.flow / player / interact / comment / tiktok / wait / bridge
+ */
+TikTokAuto.nurture = (function () {
+    'use strict';
+
+    /** 连续这么多次上滑都换不了视频,就认为卡死了 */
+    var SWIPE_FAIL_LIMIT = 3;
+
+    function logger() { return TikTokAuto.logger; }
+
+    /** 概率命中。rate 是 0~1;0 或非法值一律不命中(宁可不做,不可乱做) */
+    function hit(rate) {
+        var value = Number(rate);
+        if (!(value > 0)) {
+            return false;
+        }
+        return Math.random() < value;
+    }
+
+    function progress(phase, done, total) {
+        TikTokAuto.bridge.report({
+            status: 'running',
+            phase: phase,
+            done: done,
+            total: total
+        });
+    }
+
+    /**
+     * 上滑换下一个视频。
+     *
+     * 判据是"**新内容真的出现了**",不是"滑动手势发出去了"。
+     * 手势会被吞:网络慢、正在加载、恰好卡在上一帧,都会出现"滑了但没动"。
+     */
+    function nextVideo() {
+        var tiktok = TikTokAuto.tiktok;
+        var view = TikTokAuto.layout.screen();
+        var before = tiktok.feedSignature();
+
+        TikTokAuto.ui.dragScreen(
+            Math.floor(view.width * 0.5),
+            Math.floor(view.height * 0.78),
+            Math.floor(view.width * 0.5),
+            Math.floor(view.height * 0.28),
+            280
+        );
+
+        var changed = TikTokAuto.wait.waitFor('nextVideo', function () {
+            var now = tiktok.feedSignature();
+            if (now === '' || now === before) {
+                return null;
+            }
+            return true;
+        }, 6000, 250);
+
+        return changed === true;
+    }
+
+    /** 做完一个动作后随机停顿一下(真人不会点完立刻滑) */
+    function pause(config) {
+        var lo = config.nurture.pause.minMs;
+        var hi = config.nurture.pause.maxMs;
+        TikTokAuto.wait.tick(TikTokAuto.player.randomInt(lo, hi));
+    }
+
+    /**
+     * 一轮:看一个视频 + 按概率互动。
+     *
+     * @returns detail(结构与 video 链路的 details[] 同族,便于记录页复用)
+     */
+    function oneRound(config, index, usedComments, summary) {
+        var tiktok = TikTokAuto.tiktok;
+        var settings = config.nurture;
+        var detail = { index: index + 1 };
+
+        /* 照片帖没有播放页,也没有"播放中"这回事 —— 拿 watch 去等
+         * `playbackStartMs`(12 秒)纯属白等。短停留即可,点赞/关注照样能做。 */
+        var photo = tiktok.photoPost() !== null;
+        detail.photoPost = photo;
+
+        if (photo) {
+            var dwell = TikTokAuto.player.randomInt(1500, 4000);
+            TikTokAuto.wait.tick(dwell);
+            detail.watch = { completed: true, actualMs: dwell, probeMode: 'photo-post' };
+            summary.photoPosts += 1;
+        } else {
+            detail.watch = TikTokAuto.player.watch(config, {
+                step: 'watch',
+                /* 信息流没有返回箭头、有底部导航 —— `onPlayerPage()` 在这里恒为 false,
+                 * 不注入这个守卫的话 watch 会在 3 秒内以 left-player-page 自杀。 */
+                guard: function () { return tiktok.pageState() === 'feed'; },
+                minMs: settings.watch.minMs,
+                maxMs: settings.watch.maxMs
+            });
+        }
+        summary.watchedMs += detail.watch.actualMs || 0;
+
+        /* 动作顺序:点赞 → 关注 → **评论放最后**。
+         * 评论会打开面板盖住右侧动作栏,之后再点赞就得多一步收面板。 */
+        if (hit(settings.likeRate)) {
+            summary.likeHits += 1;
+            detail.like = TikTokAuto.interact.like('like');
+            if (detail.like.ok && !detail.like.skipped) {
+                summary.liked += 1;
+            }
+            pause(config);
+        }
+
+        if (hit(settings.followRate)) {
+            summary.followHits += 1;
+            detail.follow = TikTokAuto.interact.follow('follow');
+            if (detail.follow.ok && !detail.follow.skipped) {
+                summary.followed += 1;
+            }
+            pause(config);
+        }
+
+        if (config.comments.enabled && hit(settings.commentRate)) {
+            summary.commentHits += 1;
+            var text = TikTokAuto.comment.pickText(config, usedComments, 'comment');
+            detail.comment = TikTokAuto.comment.post(config, text, 'comment');
+            if (detail.comment.ok && !detail.comment.skipped) {
+                usedComments.push(text);
+                summary.commented += 1;
+            } else {
+                /* 失败了**必须把面板收掉**:面板盖在信息流上时,
+                 * 后面的上滑会变成"在评论列表里滚动",整个流程就歪了。 */
+                TikTokAuto.comment.closePanel('comment:cleanup');
+                if (!detail.comment.ok) {
+                    summary.warnings.push('评论没发出去:' +
+                        (detail.comment.reason || detail.comment.via || '未知'));
+                }
+            }
+            pause(config);
+        }
+
+        return detail;
+    }
+
+    /**
+     * @param config 生效配置(含 `config.nurture` 与顶层 `config.comments`)
+     * @returns summary
+     */
+    function runSession(config) {
+        var log = logger();
+        var settings = config.nurture;
+
+        var summary = {
+            ok: false,
+            task: 'nurture',
+            /* 实际做完的轮数 / 本轮随机出来的目标轮数 */
+            rounds: 0,
+            targetRounds: 0,
+            /** 真正在看视频上花掉的毫秒数(不含动作与停顿) */
+            watchedMs: 0,
+            /* 真的做成功的个数:跳过(已赞/已关注)不计入 */
+            liked: 0,
+            followed: 0,
+            commented: 0,
+            /* 概率命中的次数:用来区分"没打算做"和"想做但失败了" */
+            likeHits: 0,
+            followHits: 0,
+            commentHits: 0,
+            photoPosts: 0,
+            /** 'rounds' | 'time' | 'aborted' —— 是哪个约束先到 */
+            stoppedBy: '',
+            details: [],
+            /* 不算失败但值得说的事。必须在这里初始化:后面直接 push,
+             * 不初始化就是运行期 TypeError,而 AutoX 上这种异常不弹窗、只表现成"流程停住"。 */
+            warnings: [],
+            errors: []
+        };
+
+        progress('启动应用', 0, 0);
+        if (!TikTokAuto.flow.launchApp(config)) {
+            summary.errors.push('启动应用失败');
+            return summary;
+        }
+
+        progress('进入信息流', 0, 0);
+        if (!TikTokAuto.flow.ensureFeed(config)) {
+            summary.errors.push('未能进入信息流首页');
+            return summary;
+        }
+
+        var targetRounds = TikTokAuto.player.randomInt(settings.rounds.min, settings.rounds.max);
+        summary.targetRounds = targetRounds;
+        var deadline = Date.now() + settings.browse.maxMs;
+        var beganAt = Date.now();
+
+        log.step('nurture', '养号开始:目标 ' + targetRounds + ' 轮 / 最长 ' +
+            Math.round(settings.browse.maxMs / 60000) + ' 分钟;概率 点赞 ' +
+            Math.round(settings.likeRate * 100) + '% / 关注 ' +
+            Math.round(settings.followRate * 100) + '% / 评论 ' +
+            Math.round(settings.commentRate * 100) + '%');
+
+        var usedComments = [];
+        var swipeFailures = 0;
+
+        while (summary.rounds < targetRounds && Date.now() < deadline) {
+            var state = TikTokAuto.tiktok.pageState();
+            if (state !== 'feed') {
+                /* 滑进了直播 / 广告落地页 / 评论面板,或者页面还在加载。
+                 * 交回 ensureFeed 拉回信息流 —— 别在这里自己写一套"返回几次"的逻辑。 */
+                log.warn('nurture', '当前不在信息流(' + state + '),尝试回到信息流');
+                if (!TikTokAuto.flow.ensureFeed(config)) {
+                    summary.warnings.push('中途离开信息流且回不去,提前结束(做了 ' +
+                        summary.rounds + ' 轮)');
+                    summary.stoppedBy = 'aborted';
+                    break;
+                }
+            }
+
+            progress('第 ' + (summary.rounds + 1) + ' / ' + targetRounds + ' 轮',
+                     summary.rounds, targetRounds);
+
+            summary.details.push(oneRound(config, summary.rounds, usedComments, summary));
+            summary.rounds += 1;
+
+            if (!nextVideo()) {
+                swipeFailures += 1;
+                log.warn('nurture', '上滑后内容没变(连续第 ' + swipeFailures + ' 次)');
+                if (swipeFailures >= SWIPE_FAIL_LIMIT) {
+                    summary.warnings.push('连续 ' + swipeFailures +
+                        ' 次上滑都换不了视频,提前结束(做了 ' + summary.rounds + ' 轮)');
+                    summary.stoppedBy = 'aborted';
+                    break;
+                }
+            } else {
+                swipeFailures = 0;
+            }
+        }
+
+        /* 如实说明是哪个约束先到 —— 不要笼统说"完成"。
+         * 轮数先满 vs 时长先到,对用户下一步该调哪个参数完全是指向不同。 */
+        if (!summary.stoppedBy) {
+            var byTime = Date.now() >= deadline;
+            var byRounds = summary.rounds >= targetRounds;
+            summary.stoppedBy = byTime && byRounds ? 'rounds' : (byRounds ? 'rounds' : 'time');
+        }
+        if (summary.stoppedBy === 'time') {
+            summary.warnings.push('浏览时长到了(' +
+                Math.round(settings.browse.maxMs / 60000) + ' 分钟),只做了 ' +
+                summary.rounds + '/' + targetRounds + ' 轮 —— 想多做几轮请把时长上限调大');
+        }
+
+        summary.ok = summary.rounds > 0;
+        if (!summary.ok && summary.errors.length === 0) {
+            summary.errors.push('一轮都没能完成(信息流没有加载出来?)');
+        }
+
+        log.step('nurture', '养号结束:' + summary.rounds + ' 轮,用时 ' +
+            Math.round((Date.now() - beganAt) / 1000) + ' 秒,真正浏览 ' +
+            Math.round(summary.watchedMs / 1000) + ' 秒;点赞 ' + summary.liked +
+            ' / 关注 ' + summary.followed + ' / 评论 ' + summary.commented +
+            '(' + summary.stoppedBy + ' 先到)');
+
+        return summary;
+    }
+
+    return {
+        runSession: runSession,
+        nextVideo: nextVideo,
+        hit: hit,
+        SWIPE_FAIL_LIMIT: SWIPE_FAIL_LIMIT
+    };
+}());

+ 18 - 4
tiktok-auto/src/player.js

@@ -157,16 +157,30 @@ TikTokAuto.player = (function () {
      * 观看视频。
      *
      * @param config 生效配置
-     * @param options { step, onInterrupt }
+     * @param options {
+     *   step,
+     *   guard,      // 页面守卫,默认"还在播放页"(见下)
+     *   minMs/maxMs // 覆盖 config.watch 的观看时长区间
+     * }
      * @returns 观看报告(对象,不是布尔——上层需要知道"为什么/可信度如何")
+     *
+     * ★ 为什么 `guard` 必须可注入(2026-10-28 真机实测):
+     * 默认守卫是 `onPlayerPage()`,而它的判据里要求**有返回箭头、没有底部导航栏**——
+     * 这两条在首页信息流上正好相反(没有返回箭头、有 Home/Profile 导航)。
+     * 所以在信息流里跑 watch 时,默认守卫会恒为 false,3 次采样(约 3 秒)就
+     * 以 `left-player-page` 提前退出,一个视频都看不完。
+     * 养号传 `pageState() === 'feed'` 进来即可,视频链路不传、行为完全不变。
      */
     function watch(config, options) {
         var settings = options || {};
         var step = settings.step || 'watch';
         var logger = TikTokAuto.logger;
         var tiktok = TikTokAuto.tiktok;
+        var guard = settings.guard || function () { return tiktok.onPlayerPage(); };
 
-        var targetMs = randomInt(config.watch.minMs, config.watch.maxMs);
+        var minMs = settings.minMs === undefined ? config.watch.minMs : settings.minMs;
+        var maxMs = settings.maxMs === undefined ? config.watch.maxMs : settings.maxMs;
+        var targetMs = randomInt(minMs, maxMs);
         var start = waitPlaybackStart(config, step);
         var probe = start.probe;
 
@@ -182,11 +196,11 @@ TikTokAuto.player = (function () {
         var aborted = null;
 
         logger.info(step, '开始观看,计划 ' + targetMs + 'ms(区间 ' +
-            config.watch.minMs + '-' + config.watch.maxMs + 'ms),证据源=' + probe.mode);
+            minMs + '-' + maxMs + 'ms),证据源=' + probe.mode);
 
         while (Date.now() < deadline) {
             // (a) 页面身份守卫:不在播放页就不是"在看视频"
-            if (!tiktok.onPlayerPage()) {
+            if (!guard()) {
                 offPage += 1;
                 logger.warn(step, '已离开播放页(第 ' + offPage + ' 次检测)');
                 if (offPage >= maxOffPage) {

+ 245 - 6
tiktok-auto/src/tiktok.js

@@ -36,6 +36,17 @@ TikTokAuto.tiktok = (function () {
     var FOLLOW_DESC_KEYS = ['Follow ', '关注'];
     var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
     var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
+
+    /**
+     * 右侧竖排动作栏的 desc 前缀(作者 / 点赞 / 评论 / 收藏 / 分享 / 音乐)。
+     * 用于把"和输入框同高的动作栏按钮"从发送键候选里排掉 —— 详见 isActionBarButton。
+     */
+    var ACTION_BAR_DESC_PREFIXES = [
+        'Follow', 'Following', 'Unfollow',
+        'Like video', 'Video liked',
+        'Read or add comments', 'Add or remove this video',
+        'Share video', 'Sound:'
+    ];
     var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
 
     /**
@@ -409,6 +420,39 @@ TikTokAuto.tiktok = (function () {
         return hits.length > 0 ? hits[0] : null;
     }
 
+    /**
+     * 信息流当前视频的指纹 —— **只用来判断"上滑有没有真的换视频"**。
+     *
+     * ★ 它**不能**当业务上的"视频身份"用(判断是不是同一个视频):
+     * 点赞之后 `Like video. 340.2K likes` 会变成 `Video liked`,指纹必然变化。
+     * 所以它天生只适合回答"变了没有",不适合回答"这是不是同一个"。
+     *
+     * 「会不会重复操作同一个视频」不靠指纹,靠状态:
+     *   · 已经点过赞 → `likeState() === 'liked'` → interact.like 自己会 skip
+     *   · 已经关注   → `followState() === 'following'` → interact.follow 自己会 skip
+     * 那两条是地面真相,比任何拼出来的指纹都可靠。
+     *
+     * 信息流的树里**同时存在上/下两个视频**的节点(实测:上一个在 y=-702、
+     * 当前在 y=1149、下一个在 y=3069),所以这里只能用已经过 `isVisible` 的
+     * likeButton/authorAvatar/commentButton —— 它们只返回当前那个视频的节点。
+     */
+    function feedSignature() {
+        var parts = [];
+        var avatar = authorAvatar();
+        if (avatar) {
+            parts.push('a:' + layout.desc(avatar));
+        }
+        var like = likeButton() || likedButton();
+        if (like) {
+            parts.push('l:' + layout.desc(like));
+        }
+        var comment = commentButton();
+        if (comment) {
+            parts.push('c:' + layout.desc(comment));
+        }
+        return parts.join('|');
+    }
+
     /**
      * 'following' | 'not-following' | 'unknown'
      *
@@ -474,11 +518,37 @@ TikTokAuto.tiktok = (function () {
 
     /** 评论面板里的输入框(打开面板后会出现在屏幕中部) */
     function panelInput() {
-        var hits = findByLabel(className('android.widget.EditText'), function (node) {
+        var screenH = screenHeight();
+
+        /* ① 经典位置:键盘弹起之后,输入框会被顶到屏幕中部。 */
+        var mid = findByLabel(className('android.widget.EditText'), function (node) {
             var top = topOf(node);
-            return top > screenHeight() * 0.45 && top < screenHeight() * 0.85;
+            return top > screenH * 0.45 && top < screenH * 0.85;
         });
-        return hits.length > 0 ? hits[0] : null;
+        if (mid.length > 0) {
+            return mid[0];
+        }
+
+        /* ② 键盘**没弹起**时,输入框停在屏幕底部(实测 y=1816 @1080×1920,即 0.95H),
+         *    完全落不进上面那个区间。
+         *
+         *    这是首页信息流里特有的情形:点右侧评论按钮打开面板,输入框不会自动获得
+         *    焦点、键盘也就不弹;而播放页里点的是内联输入框,它天生带焦点、键盘必弹。
+         *    所以只按位置判的话,**信息流里每一次都会判成"面板没打开"** ——
+         *    而面板明明已经开了,输入框就在底部等着被点。
+         *
+         *    这时只能靠"评论区头部计数"来确认面板确实开着(那是面板独有的元素),
+         *    再把 EditText 认成输入框。视频链路上这条分支不会命中(① 已经返回了)。
+         */
+        if (listCommentCount() !== null) {
+            var any = findByLabel(className('android.widget.EditText'), function () {
+                return true;
+            });
+            if (any.length > 0) {
+                return any[0];
+            }
+        }
+        return null;
     }
 
     function toolRowAnchor() {
@@ -502,6 +572,139 @@ TikTokAuto.tiktok = (function () {
      * 优先用 config.selectors.sendButtonId(若配置里给了 id),
      * 这样 TikTok 改版时改配置即可,不用改代码。
      */
+    /**
+     * 这个节点是不是右侧竖排动作栏(点赞/评论/收藏/分享/作者/音乐)里的按钮。
+     *
+     * ★ 为什么需要它(2026-10-28):评论面板一打开,输入框会上移到屏幕中部,
+     * 正好和那六个按钮**同高**,光靠坐标完全分不开 —— 实测按"输入框右端"
+     * 找到的是 `Follow xxx` 和 `Like video.`,点下去是关注/点赞,评论根本没发。
+     *
+     * 而这六个按钮每一个都有明确的 desc 前缀,发送键没有。这是唯一稳定的分界。
+     */
+    function isActionBarButton(node) {
+        var value = layout.desc(node);
+        if (!value) {
+            return false;
+        }
+        for (var i = 0; i < ACTION_BAR_DESC_PREFIXES.length; i += 1) {
+            if (value.indexOf(ACTION_BAR_DESC_PREFIXES[i]) === 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 按"输入框那一行"找发送按钮。
+     *
+     * 判据只有两条,都落在几何事实上:
+     *   ① 节点的**中心 y** 落在输入框上下边界之间(= 同一行)
+     *   ② 节点的**右边界**贴着输入框右端(或更右)
+     *
+     * ★ 为什么不能用"输入框下方那一行"(2026-10-28 血泪):
+     * 面板打开后,**播放页那套右侧竖排动作栏(点赞/评论/收藏/分享)的无障碍节点
+     * 仍然留在树里**,y 范围(实测 1218~1822)与面板区域大幅重叠。
+     * 按"下方取最右"会稳定命中**点赞按钮**(id=`g8g`),后果是:
+     * 面板被收起 + 视频被点了个赞,而业务侧因为"面板关了"判定成评论发送成功。
+     * 假阳性比失败更糟 —— 用户以为发出去了。
+     *
+     * 而"中心 y 在输入框行内"这条能干净地排掉右侧竖排:
+     * 点赞按钮 1218~1338 的中心是 1278,落在输入框 1115~1222 之外。
+     *
+     * ★ 发送键**只在键盘弹起时才存在**。所以调用方必须先让输入框聚焦
+     * (`ui.typeInto` 里已经做了),否则这里返回 null 是正常的 ——
+     * 那种情况会如实报"定位不到发送按钮",不会乱点。
+     */
+    function sendButtonOnInputRow() {
+        var input = panelInput();
+        if (!input) {
+            logger.warn('comment', '没有面板输入框,无法按行定位发送按钮');
+            return null;
+        }
+        var ib = input.bounds();
+        var inputMidY = (ib.top + ib.bottom) / 2;
+        var screenW = screenWidth();
+
+        var best = null;
+        var bestDist = null;
+        var groups = ['android.widget.Button', 'android.widget.ImageView'];
+        for (var g = 0; g < groups.length; g += 1) {
+            var hits = findByLabel(className(groups[g]), function (node) {
+                /* ★ 先排掉右侧竖排动作栏(2026-10-28 血泪):
+                 * 面板打开后输入框会上移到中部,正好和那六个按钮**同高**,
+                 * 光靠几何分不开。但它们每一个都有明确的 desc 前缀,
+                 * 而发送键没有 —— 这是唯一稳定可靠的分界。 */
+                if (isActionBarButton(node)) {
+                    return false;
+                }
+                var nb = node.bounds();
+                var midY = (nb.top + nb.bottom) / 2;
+                if (midY < ib.top || midY > ib.bottom) {
+                    return false;              // 不在输入框这一行
+                }
+                if (nb.right < ib.right - 60) {
+                    return false;              // 不在输入框右端
+                }
+                if (nb.left < screenW * 0.5) {
+                    return false;              // 只看右半边
+                }
+                if (nb.bottom - nb.top < 20 || nb.right - nb.left < 20) {
+                    return false;              // 太小的装饰不收
+                }
+                try {
+                    return node.clickable() === true;
+                } catch (ignored) {
+                    return false;
+                }
+            });
+            /* 同一行里可能有多个(清空、表情…),取**离输入框中心最近**的那个。
+             * 用局部变量记距离 —— 绝不能往 AutoX 的 UiObject 上挂自定义属性:
+             * 那是 Java 宿主对象,赋值会在运行期抛异常,而且崩得很安静
+             * (界面只看到"未开始")。 */
+            for (var i = 0; i < hits.length; i += 1) {
+                var hb = hits[i].bounds();
+                var dist = Math.abs((hb.top + hb.bottom) / 2 - inputMidY);
+                if (bestDist === null || dist < bestDist) {
+                    bestDist = dist;
+                    best = hits[i];
+                }
+            }
+            if (best) {
+                break;
+            }
+        }
+
+        if (!best) {
+            /* 排障:输入框这一行右侧到底有什么 */
+            logger.warn('comment', '输入框 b=[' + ib.left + ',' + ib.top + ',' +
+                ib.right + ',' + ib.bottom + '] 这一行的右侧没有可点按钮(键盘没弹起?)');
+            var probes = [];
+            try {
+                var probeClasses = ['android.widget.Button', 'android.widget.ImageView',
+                                    'android.view.View', 'android.widget.FrameLayout'];
+                for (var p = 0; p < probeClasses.length; p += 1) {
+                    var cands = className(probeClasses[p]).find();
+                    for (var c = 0; c < cands.size(); c += 1) {
+                        var nb2 = cands.get(c).bounds();
+                        var mid2 = (nb2.top + nb2.bottom) / 2;
+                        if (mid2 < ib.top - 40 || mid2 > ib.bottom + 40) { continue; }
+                        if (nb2.left < screenW * 0.5) { continue; }
+                        probes.push(String(probeClasses[p]).split('.').pop() +
+                            '[' + nb2.left + ',' + nb2.top + ',' + nb2.right + ',' + nb2.bottom +
+                            ']' + (isActionBarButton(cands.get(c)) ? '(动作栏)' : ''));
+                    }
+                }
+            } catch (ignoredProbe) { }
+            logger.warn('comment', '这一行右侧实际有:' + (probes.join(' ') || '(空)'));
+            return null;
+        }
+
+        logger.info('comment', '发送按钮:按输入框这一行定位 id=' + layout.shortId(best) +
+            ' desc=' + JSON.stringify(layout.desc(best)) +
+            ' b=' + JSON.stringify(best.bounds()));
+        return best;
+    }
+
     function sendButton(config) {
         var override = config && config.selectors && config.selectors.sendButtonId;
         if (override) {
@@ -512,9 +715,15 @@ TikTokAuto.tiktok = (function () {
             }
         }
 
+        /* 先用几何判据(输入框那一行 + 右端)—— 它对"工具行 desc 变成 @资源id"
+         * 和"右侧动作栏节点残留在树里"这两个新版变化都免疫。 */
+        var rowButton = sendButtonOnInputRow();
+        if (rowButton) {
+            return rowButton;
+        }
+
         var anchor = toolRowAnchor();
         if (!anchor) {
-            logger.warn('comment', '找不到面板工具行,无法结构化定位发送按钮');
             return null;
         }
         var bandTop = topOf(anchor);
@@ -558,14 +767,42 @@ TikTokAuto.tiktok = (function () {
      * (发完评论后仍读回旧值),而评论列表头部的计数是即时的。
      * 所以"计数自增"这条证据要用列表头部的那个,而不是角标的。
      */
+    /**
+     * 从评论区头部的文案里取出评论数。**两种格式都要认**:
+     *
+     *   · 旧版:`150 comments` / `1,203 comments` —— 数字在前
+     *   · 新版:`Comments 785` —— 数字在后,而且与页签名在**同一个 TextView** 里
+     *     (2026-10 实测:评论区拆成了 `Comments` / `Reviews` 两个页签)
+     *
+     * ★ 只认旧格式的后果非常隐蔽:首页信息流里点开评论面板后,
+     * `listCommentCount()` 返回 null → `panelInput()` 的第二条分支进不去 →
+     * 整个面板被判成"没打开",而屏幕上明明已经有评论列表和输入框。
+     * 日志里只会看到一句"面板没打开(该视频可能关闭了评论)",完全是误导。
+     */
+    function parseCommentCountLabel(value) {
+        var text = String(value || '').replace(/^\s+|\s+$/g, '');
+
+        var newer = text.match(/^Comments?\s+([\d][\d,.]*)$/i);
+        if (newer) {
+            return parseInt(String(newer[1]).replace(/[^\d]/g, ''), 10);
+        }
+
+        var legacy = text.match(/^([\d][\d,.]*)\s*(?:comments?|条评论)$/i);
+        if (legacy) {
+            return parseInt(String(legacy[1]).replace(/[^\d]/g, ''), 10);
+        }
+
+        return null;
+    }
+
     function listCommentCount() {
         var hits = findByLabel(className('android.widget.TextView'), function (node) {
-            return /^[\d,\.]+\s*(comments?|条评论)$/i.test(layout.text(node));
+            return parseCommentCountLabel(layout.text(node)) !== null;
         });
         if (hits.length === 0) {
             return null;
         }
-        return parseInt(layout.text(hits[0]).replace(/[^\d]/g, ''), 10);
+        return parseCommentCountLabel(layout.text(hits[0]));
     }
 
     /**
@@ -1191,6 +1428,8 @@ TikTokAuto.tiktok = (function () {
         likeState: likeState,
         followButton: followButton,
         authorAvatar: authorAvatar,
+        feedSignature: feedSignature,
+        parseCommentCountLabel: parseCommentCountLabel,
         followState: followState,
         onPlayerPage: onPlayerPage,
         photoPost: photoPost,

+ 36 - 5
tiktok-auto/src/ui.js

@@ -17,6 +17,20 @@
 TikTokAuto.ui = (function () {
     'use strict';
 
+    /**
+     * 在坐标上点一下。
+     *
+     * ★ 用无障碍坐标点击 `click(x, y)`,**不要**用 `gesture()`(2026-10-28 实测)
+     *
+     * 踩过的弯路,记下来免得再走:
+     * 那天排查"点评论按钮没反应",一度以为 TikTok 吞掉了无障碍坐标点击,
+     * 于是把这里改成 `gesture(120, [[x,y],[x,y]])` —— 主脚本里探针确实点开了,
+     * 但**引擎(`execScriptFile` 起的子脚本)里 gesture 是无效的**:
+     * 同坐标下 gesture / click / shell 三种方式全部失败,而 `click` 在主脚本里也有效。
+     *
+     * 真正的故障其实在别处(键盘没弹 → 输入框停在屏幕底部 → `panelInput()` 判据不命中),
+     * 跟点击方式无关。引擎里后来一直用 `click` 打开评论面板,每次都能成功 —— 这就是证据。
+     */
     function tapPoint(x, y) {
         click(x, y);
     }
@@ -117,8 +131,9 @@ TikTokAuto.ui = (function () {
                 }
                 point = safe;
             }
-            click(point.x, point.y);
-            return finish(story, true, 'gesture click(visibleCenter)', targetClass, targetLabel, point);
+            /* 走 gesture 而不是 click —— 理由见 tapPoint 上面那段(TikTok 吞无障碍坐标点击)。 */
+            tapPoint(point.x, point.y);
+            return finish(story, true, 'gesture tap(visibleCenter)', targetClass, targetLabel, point);
         }
 
         return finish(story, false, 'no-clickable-target', targetClass, targetLabel, null);
@@ -153,11 +168,27 @@ TikTokAuto.ui = (function () {
             logger.error(story, '输入目标为空');
             return { ok: false, via: 'no-target' };
         }
-        // 先让它真正获得焦点,否则部分输入法/控件不会提交文本
+        /* 先让输入框**真正获得焦点**。
+         *
+         * ★ 两种手段都上(2026-10-28 实测):无障碍 ACTION_CLICK 对输入框时灵时不灵,
+         * 坐标点击在子脚本(引擎)里也可能被 TikTok 吞掉。两个都试、都没有副作用
+         * (点输入框只会让它聚焦)。
+         *
+         * **必须聚焦**,不只是为了输入 —— 输入框不聚焦就不会弹键盘,
+         * 而首页信息流的评论面板里,**"发送"按钮只有在键盘弹起时才存在**。
+         * 少了这一步,整条评论链路会断在"定位不到发送按钮",而日志完全看不出是这个原因。
+         */
+        try {
+            node.click();
+        } catch (ignoredClick) {
+            /* 输入框不可点也没关系,下一种手段继续 */
+        }
+        TikTokAuto.wait.tick(150);
+
         var point = layout.center(node);
         if (point) {
-            click(point.x, point.y);
-            TikTokAuto.wait.tick(200);
+            tapPoint(point.x, point.y);
+            TikTokAuto.wait.tick(250);
         }
 
         node.setText(value);

+ 3 - 0
tiktok-auto/tools/build.js

@@ -49,6 +49,9 @@ const MODULE_ORDER = [
     'comment',
     'interact',
     'flow',
+    /* 养号(task = 'nurture'):没有目标,在信息流里随机浏览 + 概率互动。
+     * 放 flow 之后,因为它借用 flow 的 launchApp / ensureFeed(应用级动作)。 */
+    'nurture',
     /* —— ID 提取(task = 'id')——
      * 顺序按依赖走:parse 是纯函数地基;uidset/filters 只依赖 parse;
      * profile 调 tiktok 与 flow;idsource 调 profile;idflow 调全部。

Деякі файли не було показано, через те що забагато файлів було змінено