Ver Fonte

视频链路补上点赞与关注两个动作

之前「执行操作」四项里只有评论真正生效,点赞/关注/私信勾了只会弹一条
"引擎尚未实现"。这次把点赞和关注做完,视频功能从"能看能评"变成"能互动"。

## 先探测再写代码(真机 dump 拿到的锚点)

| 动作 | 未做 | 已做 |
|---|---|---|
| 点赞 | Button desc `Like video. 2.4M likes`,ImageView selected=false | desc 变 **`Video liked`**,selected=**true** |
| 关注 | Button desc `Follow <作者名>` | **按钮整个消失**(不是改成 Following) |

两个容易踩的点:
- 点赞按钮与分享按钮**共用 resource-id `g8g`**,只能靠 desc 区分
- 「关注按钮找不到」有两种含义(已关注 / 页面没渲染完),用作者头像
  (desc 形如 `<作者名> profile`)的存在性做交叉判断才能区分
- 首页顶部那个 `Following` 页签是 FrameLayout,限定 className('Button') 即可排除

## 实现

- 新模块 `src/interact.js`:`like()` / `follow()`,照 comment.js 的分级证据模式
- `tiktok.js` 新增 likedButton / likeIcon / likeState / followButton / authorAvatar / followState
- `config.js` 新增 likes/follows 两个 enabled(与 comments 平级)
- `flow.js` 顺序:观看 → 点赞 → 关注 → 评论(评论面板会盖住动作栏,放最后)
- 界面 engine.js 映射两个开关;`unsupportedNotes` 去掉点赞/关注两条,只剩私信

**两条安全原则**(写在文件头):
1. 状态不明就不点 —— 读不到"是否已赞"时点一下可能把已赞的取消了
2. 点了必须验 —— 状态真变了才计成功,否则记 not-verified,不虚报

## 验证

真机(fashion 关键词 / 1 个视频):
  [like]   点击 Button "Like video. 82.6K likes" → verify 34ms → 点赞成功
  [follow] 点击 Button "Follow ningbitsandpieces" → verify 1547ms → 关注成功
  summary: likedVideos=1 / followedAuthors=1
           like.via=state-changed, follow.via=button-gone

自检:引擎 pc-selftest 68 项、界面 smoke 417 项(新增 3 条映射断言)、构建通过。

## 顺带记录一个既有问题(非本次引入)

关键词 `cat` 的搜索结果页第一条是广告,`videoCards()` 读不到卡片,
报"结果页已无更多卡片"。`fashion` 正常。这是搜索结果的卡片解析问题,待单独处理。
梦境 há 5 dias atrás
pai
commit
b68d8834c5

+ 19 - 0
docs/AI_CONTEXT.md

@@ -453,6 +453,21 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 - 根因:标题出来了但数字区还在渲染,`statEntry()` 返回 null
 - 根因:标题出来了但数字区还在渲染,`statEntry()` 返回 null
 - 正解:等 3 秒再找一次
 - 正解:等 3 秒再找一次
 
 
+**C12|点赞按钮与分享按钮共用同一个 resource-id**
+
+- 事实:两个都是 `g8g`,只有 desc 不同
+- 正解:**只能按 desc 定位**,不要用 id
+- 状态判据:未赞 `Like video.` 开头;已赞 desc 变成 `Video liked`、且里面 ImageView 的 `selected=true`
+  (两个判据都留:desc 受本地化影响,selected 不受文案影响)
+
+**C13|关注按钮"消失"就是已关注**
+
+- 事实:关注后 `Follow <作者名>` 这个 Button **整个不存在了**(不是变成 `Following`)
+- 陷阱:页面没渲染完也找不到按钮 —— 两者必须区分
+- 正解:用作者头像(desc 形如 `<作者名> profile`)的存在性做交叉判断:
+  头像在 + 按钮不在 = 已关注;两个都不在 = 页面还没好
+- 另外:首页顶部那个 `Following` 页签是 **FrameLayout**,限定 `className('android.widget.Button')` 就能排除
+
 ### 6.D 流程与状态机
 ### 6.D 流程与状态机
 
 
 **D1|阶段性的"停止"不能共用(★ 踩了两次)**
 **D1|阶段性的"停止"不能共用(★ 踩了两次)**
@@ -584,6 +599,10 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 | | `TextView.tv_aweme_id` | 昵称(**id 名违反直觉**) |
 | | `TextView.tv_aweme_id` | 昵称(**id 名违反直觉**) |
 | | `TextView.tv_desc` | `594 followers · 2.5K likes`(行内就有计数) |
 | | `TextView.tv_desc` | `594 followers · 2.5K likes`(行内就有计数) |
 | 播放页 | 底部 `Button` 文案 `@xxx` | 作者 uid |
 | 播放页 | 底部 `Button` 文案 `@xxx` | 作者 uid |
+| 播放页·点赞 | `Button` desc `Like video. 2.4M likes` | 未赞;里面 ImageView `selected=false` |
+| 播放页·已赞 | 同一个 `Button` desc → **`Video liked`** | ImageView `selected=**true**`(不是 `Unlike`) |
+| 播放页·关注 | `Button` desc `Follow <作者名>` | 已关注时**整个按钮消失**(不是改文案) |
+| 播放页·作者头像 | `ImageView` desc `<作者名> profile` | 用来区分「已关注」与「页面没渲染完」 |
 | 评论区 | `Button.title` | 只有昵称,**没有 uid** |
 | 评论区 | `Button.title` | 只有昵称,**没有 uid** |
 | | `ImageView.blm`(约 72×72,左侧) | 评论者头像(可点进主页) |
 | | `ImageView.blm`(约 72×72,左侧) | 评论者头像(可点进主页) |
 | 用户主页头像 | `ImageView` 192×192 @ `[856,168][1048,360]` | 无 text/desc/不可点 → 像素法 |
 | 用户主页头像 | `ImageView` 192×192 @ `[856,168][1048,360]` | 无 text/desc/不可点 → 像素法 |

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

@@ -1,6 +1,6 @@
 "ui";
 "ui";
 /*! tiktok-app v2026.10.13
 /*! tiktok-app v2026.10.13
- *  built 2026-10-22T07:14:22.045Z
+ *  built 2026-10-28T09:52:53.962Z
  *  modules: theme, store, records, sources, device, service, engine, session, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  modules: theme, store, records, sources, device, service, engine, session, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
  */
@@ -2802,6 +2802,14 @@ Engine.buildVideoConfig = function () {
             minMs: seconds(intervalMin),
             minMs: seconds(intervalMin),
             maxMs: seconds(intervalMax)
             maxMs: seconds(intervalMax)
         },
         },
+        /* 点赞 / 关注:与评论同级的两个动作开关。
+         * 界面上「执行操作」是四项多选,用户勾了才做 —— 没勾就一个都不点。 */
+        likes: {
+            enabled: Store.getBool("tabs.video.actions.like")
+        },
+        follows: {
+            enabled: Store.getBool("tabs.video.actions.follow")
+        },
         comments: {
         comments: {
             /* ★ 勾了「评论」才发评论。没勾就只是浏览 ——
             /* ★ 勾了「评论」才发评论。没勾就只是浏览 ——
              * 引擎侧据此跳过评论动作,而不是照默认值照发。
              * 引擎侧据此跳过评论动作,而不是照默认值照发。
@@ -2836,12 +2844,8 @@ Engine.unsupportedNotes = function (task) {
     if (task && task.task === "id") {
     if (task && task.task === "id") {
         return Engine.unsupportedIdNotes();
         return Engine.unsupportedIdNotes();
     }
     }
-    if (Store.getBool("tabs.video.actions.like")) {
-        notes.push("\u70B9\u8D5E\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
-    }
-    if (Store.getBool("tabs.video.actions.follow")) {
-        notes.push("\u5173\u6CE8\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
-    }
+    /* 点赞与关注已由引擎实现(src/interact.js),不再报"尚未实现"。
+     * 只剩私信一项还在等(见 UID私信 任务)。 */
     if (Store.getBool("tabs.video.actions.dm")) {
     if (Store.getBool("tabs.video.actions.dm")) {
         notes.push("\u79C1\u4FE1\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
         notes.push("\u79C1\u4FE1\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
     }
     }

+ 10 - 6
tiktok-app/src/engine.js

@@ -308,6 +308,14 @@ Engine.buildVideoConfig = function () {
             minMs: seconds(intervalMin),
             minMs: seconds(intervalMin),
             maxMs: seconds(intervalMax)
             maxMs: seconds(intervalMax)
         },
         },
+        /* 点赞 / 关注:与评论同级的两个动作开关。
+         * 界面上「执行操作」是四项多选,用户勾了才做 —— 没勾就一个都不点。 */
+        likes: {
+            enabled: Store.getBool("tabs.video.actions.like")
+        },
+        follows: {
+            enabled: Store.getBool("tabs.video.actions.follow")
+        },
         comments: {
         comments: {
             /* ★ 勾了「评论」才发评论。没勾就只是浏览 ——
             /* ★ 勾了「评论」才发评论。没勾就只是浏览 ——
              * 引擎侧据此跳过评论动作,而不是照默认值照发。
              * 引擎侧据此跳过评论动作,而不是照默认值照发。
@@ -342,12 +350,8 @@ Engine.unsupportedNotes = function (task) {
     if (task && task.task === "id") {
     if (task && task.task === "id") {
         return Engine.unsupportedIdNotes();
         return Engine.unsupportedIdNotes();
     }
     }
-    if (Store.getBool("tabs.video.actions.like")) {
-        notes.push("\u70B9\u8D5E\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
-    }
-    if (Store.getBool("tabs.video.actions.follow")) {
-        notes.push("\u5173\u6CE8\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
-    }
+    /* 点赞与关注已由引擎实现(src/interact.js),不再报"尚未实现"。
+     * 只剩私信一项还在等(见 UID私信 任务)。 */
     if (Store.getBool("tabs.video.actions.dm")) {
     if (Store.getBool("tabs.video.actions.dm")) {
         notes.push("\u79C1\u4FE1\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
         notes.push("\u79C1\u4FE1\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
     }
     }

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

@@ -737,6 +737,17 @@ check('停止个数区间映射为 maxVideos(落在区间内)',
 eq('间隔时间(秒)映射为观看时长(毫秒)',
 eq('间隔时间(秒)映射为观看时长(毫秒)',
     engineConfig.watch.minMs, api.Store.getNumber('tabs.video.intervalMin') * 1000);
     engineConfig.watch.minMs, api.Store.getNumber('tabs.video.intervalMin') * 1000);
 eq('没勾评论时引擎不启用评论动作', engineConfig.comments.enabled, false);
 eq('没勾评论时引擎不启用评论动作', engineConfig.comments.enabled, false);
+/* 点赞 / 关注与评论同级的两个开关。这条断言防的是"界面勾了但引擎没收到"——
+ * 那种情况下运行看起来正常,只是点赞一个都没发生。 */
+eq('点赞开关映射到引擎 likes.enabled',
+    engineConfig.likes.enabled, api.Store.getBool('tabs.video.actions.like'));
+eq('关注开关映射到引擎 follows.enabled',
+    engineConfig.follows.enabled, api.Store.getBool('tabs.video.actions.follow'));
+/* 反过来也验一次:把点赞钩上,引擎配置要跟着变 true */
+api.Store.set('tabs.video.actions.like', true);
+const likedOn = api.Engine.buildConfig({ task: 'video' });
+eq('勾上点赞后引擎收到 enabled=true', likedOn.config.likes.enabled, true);
+api.Store.set('tabs.video.actions.like', false);
 eq('引擎配置写清了 configPath', engineConfig.runtime.configPath, api.Engine.CONFIG_PATH);
 eq('引擎配置写清了 configPath', engineConfig.runtime.configPath, api.Engine.CONFIG_PATH);
 
 
 /* 运行配置里带引擎信息与"本次不会生效的项",事后只看这一个文件就能复盘 */
 /* 运行配置里带引擎信息与"本次不会生效的项",事后只看这一个文件就能复盘 */

+ 25 - 0
tiktok-auto/README.md

@@ -150,6 +150,31 @@ tiktok-auto/
 
 
 ---
 ---
 
 
+## 3.4 播放页的四个动作
+
+`flow.runSession` 处理每个视频的顺序是:**观看 → 点赞 → 关注 → 评论**。
+
+点赞/关注放在评论之前,因为评论会打开面板盖住动作栏,之后再点要先把面板收掉。
+
+| 动作 | 实现 | 状态判据(实测 TikTok 47.1.4 / 2026-10-28) |
+|---|---|---|
+| 点赞 | `src/interact.js` `like()` | 未赞:`Button` desc 以 `Like video.` 开头;已赞:desc 变 **`Video liked`** 且 ImageView `selected=true` |
+| 关注 | `src/interact.js` `follow()` | 未关注:`Button` desc `Follow <作者名>`;已关注:**按钮整个消失**(配合作者头像存在性区分"页面没渲染完") |
+| 评论 | `src/comment.js` `post()` | 见第 4 节的分级证据 |
+
+**两条安全原则**(写在 `interact.js` 文件头):
+
+1. **状态不明就不点** —— 读不到"是否已赞"时点一下,可能把已赞的**取消**掉,和用户意图正好相反
+2. **点了必须验** —— 只有状态真的变了才计成功,否则记 `not-verified`,不虚报
+
+开关与界面「执行操作」四项一一对应:`likes.enabled` / `follows.enabled` / `comments.enabled`,
+用户没勾就不做。私信(`dm`)尚未实现,运行时会 note 告知。
+
+真机验证:`fashion` 关键词跑 1 个视频,点赞 `via=state-changed`、关注 `via=button-gone`,
+summary `likedVideos=1 / followedAuthors=1`。
+
+---
+
 ## 3.5 ID 提取(`task = "id"`)
 ## 3.5 ID 提取(`task = "id"`)
 
 
 第二条链路,与视频链路并列:同一套启动/守护/停止/上报基础设施,只在
 第二条链路,与视频链路并列:同一套启动/守护/停止/上报基础设施,只在

+ 283 - 3
tiktok-auto/dist/tiktok-auto.js

@@ -1,6 +1,6 @@
 /*! tiktok-auto v1.0.0
 /*! tiktok-auto v1.0.0
- *  built 2026-10-22T06:54:25.473Z
- *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, parse, uidset, filters, profile, idsource, resolve, idflow, main
+ *  built 2026-10-28T09:52:51.438Z
+ *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, parse, uidset, filters, profile, idsource, resolve, idflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
  */
 (function () {
 (function () {
@@ -355,7 +355,19 @@ TikTokAuto.bridge = (function () {
             }
             }
             var watched = summary ? summary.attemptedVideos : 0;
             var watched = summary ? summary.attemptedVideos : 0;
             var posted = summary ? summary.postedComments : 0;
             var posted = summary ? summary.postedComments : 0;
-            return '完成:处理 ' + watched + ' 个视频 / 发出 ' + posted + ' 条评论';
+            /* 只写"真的做了"的:跳过(已赞/已关注)不计入,否则用户会以为
+             * 自己点了 N 个赞,实际全是"本来就已经赞过"。 */
+            var liked = summary && summary.likedVideos ? summary.likedVideos : 0;
+            var followed = summary && summary.followedAuthors ? summary.followedAuthors : 0;
+            var actions = [];
+            if (liked > 0 || (summary && summary.likesRequested)) {
+                actions.push('点赞 ' + liked);
+            }
+            if (followed > 0 || (summary && summary.followsRequested)) {
+                actions.push('关注 ' + followed);
+            }
+            actions.push('评论 ' + posted);
+            return '完成:处理 ' + watched + ' 个视频 / ' + actions.join(' / ');
         }
         }
         var reason = summary && summary.errors && summary.errors.length > 0
         var reason = summary && summary.errors && summary.errors.length > 0
             ? summary.errors[0]
             ? summary.errors[0]
@@ -511,6 +523,21 @@ TikTokAuto.config = (function () {
             minMs: 10000,
             minMs: 10000,
             maxMs: 35000
             maxMs: 35000
         },
         },
+        /**
+         * 点赞 / 关注:与 comments 平级的两个动作开关。
+         *
+         * 界面的「执行操作」是四项多选(关注 / 私信 / 点赞 / 评论),
+         * 这四项各自映射到一个 enabled —— 用户没勾就不做,不能因为"顺手"就点上。
+         *
+         * 与 ID 提取里的 `id.filter.likes`(按获赞数量筛账号)**不是一回事**,
+         * 只是名字撞了,层级不同。
+         */
+        likes: {
+            enabled: true
+        },
+        follows: {
+            enabled: true
+        },
         comments: {
         comments: {
             /* 是否执行评论动作。关掉就是「只浏览不发评论」——
             /* 是否执行评论动作。关掉就是「只浏览不发评论」——
              * 界面把「执行操作」里的评论开关映射到这里:
              * 界面把「执行操作」里的评论开关映射到这里:
@@ -1695,6 +1722,10 @@ TikTokAuto.tiktok = (function () {
     var PKG = 'com.zhiliaoapp.musically';
     var PKG = 'com.zhiliaoapp.musically';
     var COMMENT_DESC_KEYS = ['Read or add comments', '评论'];
     var COMMENT_DESC_KEYS = ['Read or add comments', '评论'];
     var LIKE_DESC_KEYS = ['Like video.', '点赞'];
     var LIKE_DESC_KEYS = ['Like video.', '点赞'];
+    /** 已点赞后同一个按钮的 desc(实测是 `Video liked`,不是 `Unlike`) */
+    var LIKED_DESC_KEYS = ['Video liked', '已点赞', 'Unlike'];
+    /** 关注按钮 desc 前缀(后面跟作者名)。`Followers` 会被 followButton 里单独排除。 */
+    var FOLLOW_DESC_KEYS = ['Follow ', '关注'];
     var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
     var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
     var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
     var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
     var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
     var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
@@ -1898,6 +1929,102 @@ TikTokAuto.tiktok = (function () {
         return hits.length > 0 ? hits[0] : null;
         return hits.length > 0 ? hits[0] : null;
     }
     }
 
 
+    /**
+     * 「已点赞」状态的点赞按钮。
+     *
+     * ★ 实测(2026-10-28,TikTok 47.1.4):点过赞之后同一个 `Button` 的 desc
+     *   从 `Like video. 2.4M likes` 变成 **`Video liked`**(不是 `Unlike`),
+     *   而里面那个 ImageView 的 `selected` 从 false 变成 true。
+     *
+     * 两个判据都要留:desc 受本地化影响,selected 是状态位不受文案影响 ——
+     * 任一命中就算已赞,比只认一个更耐改版。
+     */
+    function likedButton() {
+        var hits = findByLabel(className('android.widget.Button'), function (node) {
+            return startsWithAny(layout.desc(node), LIKED_DESC_KEYS);
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /**
+     * 点赞按钮里的图标节点(用它读 `selected`)。
+     * 实测:未赞 selected=false,已赞 selected=true。
+     */
+    function likeIcon() {
+        var button = likeButton() || likedButton();
+        if (!button) {
+            return null;
+        }
+        var icons = button.find(className('android.widget.ImageView'));
+        return icons.size() > 0 ? icons.get(0) : null;
+    }
+
+    /** 'liked' | 'unliked' | 'unknown' */
+    function likeState() {
+        if (likedButton() !== null) {
+            return 'liked';
+        }
+        if (likeButton() !== null) {
+            return 'unliked';
+        }
+        /* 按钮本身找不到时,退回看图标状态位(有些版本用同一个 desc,只翻 selected) */
+        var icon = null;
+        try { icon = likeIcon(); } catch (ignored) { icon = null; }
+        if (icon !== null && icon.selected()) {
+            return 'liked';
+        }
+        return 'unknown';
+    }
+
+    /**
+     * 播放页的「关注作者」按钮。
+     *
+     * 实测 desc 形如 `Follow Marcgagilang`(`Follow` + 空格 + 作者名),
+     * 是 `android.widget.Button`。
+     * ⚠️ 首页顶部那个 `Following` 页签是 **FrameLayout**,不是 Button,
+     *    所以限定 className 就能把它排除掉,不必额外按坐标过滤。
+     */
+    function followButton() {
+        var hits = findByLabel(className('android.widget.Button'), function (node) {
+            var value = layout.desc(node);
+            if (!startsWithAny(value, FOLLOW_DESC_KEYS)) {
+                return false;
+            }
+            /* `Followers` 是粉丝列表的入口,不是关注按钮 —— 前缀匹配会误命中 */
+            return value.indexOf('Followers') !== 0;
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /**
+     * 作者头像节点。用来区分「已关注(按钮消失)」和「页面还没渲染」。
+     * 实测 desc 形如 `Marcgagilang profile`。
+     */
+    function authorAvatar() {
+        var hits = findByLabel(className('android.widget.ImageView'), function (node) {
+            var value = layout.desc(node);
+            return value !== '' && / profile$/i.test(value);
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /**
+     * 'following' | 'not-following' | 'unknown'
+     *
+     * ★ 「已关注」的唯一表现是**按钮整个消失**(实测),所以不能只看"找不到按钮"
+     *   —— 页面没渲染完也找不到。用作者头像是否存在来区分这两种情况:
+     *   头像在、按钮不在 = 已关注;两个都不在 = 页面还没好。
+     */
+    function followState() {
+        if (followButton() !== null) {
+            return 'not-following';
+        }
+        if (authorAvatar() !== null) {
+            return 'following';
+        }
+        return 'unknown';
+    }
+
     /**
     /**
      * 播放页判据。
      * 播放页判据。
      *
      *
@@ -2658,6 +2785,12 @@ TikTokAuto.tiktok = (function () {
         videoCards: videoCards,
         videoCards: videoCards,
         commentButton: commentButton,
         commentButton: commentButton,
         likeButton: likeButton,
         likeButton: likeButton,
+        likedButton: likedButton,
+        likeIcon: likeIcon,
+        likeState: likeState,
+        followButton: followButton,
+        authorAvatar: authorAvatar,
+        followState: followState,
         onPlayerPage: onPlayerPage,
         onPlayerPage: onPlayerPage,
         backButton: backButton,
         backButton: backButton,
         commentCount: commentCount,
         commentCount: commentCount,
@@ -3237,6 +3370,125 @@ TikTokAuto.comment = (function () {
 }());
 }());
 
 
 
 
+/* ============================== src/interact.js ============================== */
+/**
+ * interact.js — 播放页的两个轻量互动动作:**点赞** 与 **关注作者**
+ *
+ * ## 为什么单独一个模块
+ * 它们和评论一样是"在播放页上点一下、然后验证真的生效",但比评论简单得多:
+ * 不用开面板、不用输入、不用回读。所以不塞进 comment.js(那是评论发布的地盘)。
+ *
+ * ## 实测锚点(2026-10-28 / TikTok 47.1.4 / 1080×1920)
+ *
+ * | 元素 | 类 | content-desc | 备注 |
+ * |---|---|---|---|
+ * | 点赞(未赞) | Button | `Like video. 2.4M likes` | 里面的 ImageView selected=false |
+ * | 点赞(已赞) | Button | **`Video liked`** | ImageView selected=**true** |
+ * | 关注 | Button | `Follow <作者名>` | 已关注时**整个按钮消失** |
+ * | 作者头像 | ImageView | `<作者名> profile` | 用来区分"已关注"和"页面没渲染" |
+ *
+ * 点赞按钮和分享按钮**共用同一个 resource-id**(`g8g`),所以只能靠 desc 区分,
+ * 不要试图用 id 定位。
+ *
+ * ## 两条安全原则
+ *
+ * ① **状态不明就不点。** 读不到"当前是否已赞"时,点一下可能把已赞的取消掉 ——
+ *    和用户意图正好相反。宁可跳过并如实记录。
+ * ② **点了必须验。** 点击成功不代表生效(可能被弹窗挡、可能点偏)。
+ *    只有状态真的变了才算数,否则记失败,不虚报。
+ *
+ * 依赖:TikTokAuto.tiktok / ui / wait / logger
+ */
+TikTokAuto.interact = (function () {
+    'use strict';
+
+    function logger() { return TikTokAuto.logger; }
+
+    /**
+     * 给当前视频点赞。
+     *
+     * @returns { ok, skipped?, via, reason? }
+     */
+    function like(step) {
+        var tiktok = TikTokAuto.tiktok;
+        var tag = step || 'like';
+
+        var state = tiktok.likeState();
+        if (state === 'liked') {
+            return { ok: true, skipped: true, via: 'already-liked', reason: '这个视频已经点过赞' };
+        }
+        if (state !== 'unliked') {
+            return {
+                ok: false, via: 'state-unknown',
+                reason: '读不到点赞状态,跳过(不盲点:可能是已赞,点一下会变成取消)'
+            };
+        }
+
+        var button = tiktok.likeButton();
+        if (!button) {
+            return { ok: false, via: 'button-missing', reason: '找不到点赞按钮' };
+        }
+        TikTokAuto.ui.clickNode(button, tag);
+
+        /* 验证:desc 从 `Like video.` 变成 `Video liked`(或图标 selected 翻转) */
+        var done = TikTokAuto.wait.waitFor(tag + ':verify', function () {
+            return tiktok.likeState() === 'liked' ? true : null;
+        }, 6000, 300);
+        if (done === true) {
+            logger().info(tag, '点赞成功(状态已变为已赞)');
+            return { ok: true, via: 'state-changed' };
+        }
+        logger().warn(tag, '点赞后状态没有变化(可能被弹窗挡住或点偏了)');
+        return { ok: false, via: 'not-verified', reason: '点击后没有变成已赞' };
+    }
+
+    /**
+     * 关注当前视频的作者。
+     *
+     * @returns { ok, skipped?, via, reason? }
+     */
+    function follow(step) {
+        var tiktok = TikTokAuto.tiktok;
+        var tag = step || 'follow';
+
+        var state = tiktok.followState();
+        if (state === 'following') {
+            return { ok: true, skipped: true, via: 'already-following', reason: '已经关注过这个作者' };
+        }
+        if (state !== 'not-following') {
+            /* 头像和按钮都读不到 = 页面还没渲染好。
+             * 这时不点:点到空白处可能触发别的动作(播放/暂停),没有意义。 */
+            return {
+                ok: false, via: 'page-not-ready',
+                reason: '作者头像与关注按钮都读不到,页面可能还没渲染完'
+            };
+        }
+
+        var button = tiktok.followButton();
+        if (!button) {
+            return { ok: false, via: 'button-missing', reason: '找不到关注按钮' };
+        }
+        TikTokAuto.ui.clickNode(button, tag);
+
+        /* 验证:关注按钮**整个消失**(实测就是这种表现,不是改成 Following) */
+        var done = TikTokAuto.wait.waitFor(tag + ':verify', function () {
+            return tiktok.followState() === 'following' ? true : null;
+        }, 6000, 300);
+        if (done === true) {
+            logger().info(tag, '关注成功(关注按钮已消失)');
+            return { ok: true, via: 'button-gone' };
+        }
+        logger().warn(tag, '关注后按钮仍在,可能没生效');
+        return { ok: false, via: 'not-verified', reason: '点击后关注按钮仍然存在' };
+    }
+
+    return {
+        like: like,
+        follow: follow
+    };
+}());
+
+
 /* ============================== src/flow.js ============================== */
 /* ============================== src/flow.js ============================== */
 /**
 /**
  * flow.js — 业务流程编排
  * flow.js — 业务流程编排
@@ -3526,6 +3778,13 @@ TikTokAuto.flow = (function () {
             requestedVideos: config.target.maxVideos,
             requestedVideos: config.target.maxVideos,
             attemptedVideos: 0,
             attemptedVideos: 0,
             postedComments: 0,
             postedComments: 0,
+            /* 四个动作各自的成功计数:只有"真的做了"才加,跳过不算 */
+            likedVideos: 0,
+            followedAuthors: 0,
+            /* 用户是否勾了这两个动作 —— 用来决定结果文案里要不要提它们
+             * (勾了但全是"已赞"时显示 0 是有意义的信息;没勾就干脆不提) */
+            likesRequested: config.likes && config.likes.enabled === true,
+            followsRequested: config.follows && config.follows.enabled === true,
             details: [],
             details: [],
             errors: []
             errors: []
         };
         };
@@ -3587,6 +3846,27 @@ TikTokAuto.flow = (function () {
 
 
             detail.watch = TikTokAuto.player.watch(config, { step: 'watch' });
             detail.watch = TikTokAuto.player.watch(config, { step: 'watch' });
 
 
+            /* 点赞 / 关注:都在播放页上点一下就好,不必跳转。
+             * 顺序放在评论**之前** —— 评论会打开面板盖住动作栏,
+             * 之后再点赞就得先把面板收掉,多一步且更容易出错。 */
+            if (config.likes && config.likes.enabled) {
+                detail.like = TikTokAuto.interact.like('like');
+                if (detail.like.ok && !detail.like.skipped) {
+                    summary.likedVideos += 1;
+                }
+            } else {
+                detail.like = { ok: true, skipped: true, reason: '本次未启用点赞动作' };
+            }
+
+            if (config.follows && config.follows.enabled) {
+                detail.follow = TikTokAuto.interact.follow('follow');
+                if (detail.follow.ok && !detail.follow.skipped) {
+                    summary.followedAuthors += 1;
+                }
+            } else {
+                detail.follow = { ok: true, skipped: true, reason: '本次未启用关注动作' };
+            }
+
             /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式
             /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式
              * (界面上的「执行操作」没勾评论就是这个意思)。 */
              * (界面上的「执行操作」没勾评论就是这个意思)。 */
             if (config.comments.enabled) {
             if (config.comments.enabled) {

+ 13 - 1
tiktok-auto/src/bridge.js

@@ -267,7 +267,19 @@ TikTokAuto.bridge = (function () {
             }
             }
             var watched = summary ? summary.attemptedVideos : 0;
             var watched = summary ? summary.attemptedVideos : 0;
             var posted = summary ? summary.postedComments : 0;
             var posted = summary ? summary.postedComments : 0;
-            return '完成:处理 ' + watched + ' 个视频 / 发出 ' + posted + ' 条评论';
+            /* 只写"真的做了"的:跳过(已赞/已关注)不计入,否则用户会以为
+             * 自己点了 N 个赞,实际全是"本来就已经赞过"。 */
+            var liked = summary && summary.likedVideos ? summary.likedVideos : 0;
+            var followed = summary && summary.followedAuthors ? summary.followedAuthors : 0;
+            var actions = [];
+            if (liked > 0 || (summary && summary.likesRequested)) {
+                actions.push('点赞 ' + liked);
+            }
+            if (followed > 0 || (summary && summary.followsRequested)) {
+                actions.push('关注 ' + followed);
+            }
+            actions.push('评论 ' + posted);
+            return '完成:处理 ' + watched + ' 个视频 / ' + actions.join(' / ');
         }
         }
         var reason = summary && summary.errors && summary.errors.length > 0
         var reason = summary && summary.errors && summary.errors.length > 0
             ? summary.errors[0]
             ? summary.errors[0]

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

@@ -86,6 +86,21 @@ TikTokAuto.config = (function () {
             minMs: 10000,
             minMs: 10000,
             maxMs: 35000
             maxMs: 35000
         },
         },
+        /**
+         * 点赞 / 关注:与 comments 平级的两个动作开关。
+         *
+         * 界面的「执行操作」是四项多选(关注 / 私信 / 点赞 / 评论),
+         * 这四项各自映射到一个 enabled —— 用户没勾就不做,不能因为"顺手"就点上。
+         *
+         * 与 ID 提取里的 `id.filter.likes`(按获赞数量筛账号)**不是一回事**,
+         * 只是名字撞了,层级不同。
+         */
+        likes: {
+            enabled: true
+        },
+        follows: {
+            enabled: true
+        },
         comments: {
         comments: {
             /* 是否执行评论动作。关掉就是「只浏览不发评论」——
             /* 是否执行评论动作。关掉就是「只浏览不发评论」——
              * 界面把「执行操作」里的评论开关映射到这里:
              * 界面把「执行操作」里的评论开关映射到这里:

+ 28 - 0
tiktok-auto/src/flow.js

@@ -286,6 +286,13 @@ TikTokAuto.flow = (function () {
             requestedVideos: config.target.maxVideos,
             requestedVideos: config.target.maxVideos,
             attemptedVideos: 0,
             attemptedVideos: 0,
             postedComments: 0,
             postedComments: 0,
+            /* 四个动作各自的成功计数:只有"真的做了"才加,跳过不算 */
+            likedVideos: 0,
+            followedAuthors: 0,
+            /* 用户是否勾了这两个动作 —— 用来决定结果文案里要不要提它们
+             * (勾了但全是"已赞"时显示 0 是有意义的信息;没勾就干脆不提) */
+            likesRequested: config.likes && config.likes.enabled === true,
+            followsRequested: config.follows && config.follows.enabled === true,
             details: [],
             details: [],
             errors: []
             errors: []
         };
         };
@@ -347,6 +354,27 @@ TikTokAuto.flow = (function () {
 
 
             detail.watch = TikTokAuto.player.watch(config, { step: 'watch' });
             detail.watch = TikTokAuto.player.watch(config, { step: 'watch' });
 
 
+            /* 点赞 / 关注:都在播放页上点一下就好,不必跳转。
+             * 顺序放在评论**之前** —— 评论会打开面板盖住动作栏,
+             * 之后再点赞就得先把面板收掉,多一步且更容易出错。 */
+            if (config.likes && config.likes.enabled) {
+                detail.like = TikTokAuto.interact.like('like');
+                if (detail.like.ok && !detail.like.skipped) {
+                    summary.likedVideos += 1;
+                }
+            } else {
+                detail.like = { ok: true, skipped: true, reason: '本次未启用点赞动作' };
+            }
+
+            if (config.follows && config.follows.enabled) {
+                detail.follow = TikTokAuto.interact.follow('follow');
+                if (detail.follow.ok && !detail.follow.skipped) {
+                    summary.followedAuthors += 1;
+                }
+            } else {
+                detail.follow = { ok: true, skipped: true, reason: '本次未启用关注动作' };
+            }
+
             /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式
             /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式
              * (界面上的「执行操作」没勾评论就是这个意思)。 */
              * (界面上的「执行操作」没勾评论就是这个意思)。 */
             if (config.comments.enabled) {
             if (config.comments.enabled) {

+ 116 - 0
tiktok-auto/src/interact.js

@@ -0,0 +1,116 @@
+/**
+ * interact.js — 播放页的两个轻量互动动作:**点赞** 与 **关注作者**
+ *
+ * ## 为什么单独一个模块
+ * 它们和评论一样是"在播放页上点一下、然后验证真的生效",但比评论简单得多:
+ * 不用开面板、不用输入、不用回读。所以不塞进 comment.js(那是评论发布的地盘)。
+ *
+ * ## 实测锚点(2026-10-28 / TikTok 47.1.4 / 1080×1920)
+ *
+ * | 元素 | 类 | content-desc | 备注 |
+ * |---|---|---|---|
+ * | 点赞(未赞) | Button | `Like video. 2.4M likes` | 里面的 ImageView selected=false |
+ * | 点赞(已赞) | Button | **`Video liked`** | ImageView selected=**true** |
+ * | 关注 | Button | `Follow <作者名>` | 已关注时**整个按钮消失** |
+ * | 作者头像 | ImageView | `<作者名> profile` | 用来区分"已关注"和"页面没渲染" |
+ *
+ * 点赞按钮和分享按钮**共用同一个 resource-id**(`g8g`),所以只能靠 desc 区分,
+ * 不要试图用 id 定位。
+ *
+ * ## 两条安全原则
+ *
+ * ① **状态不明就不点。** 读不到"当前是否已赞"时,点一下可能把已赞的取消掉 ——
+ *    和用户意图正好相反。宁可跳过并如实记录。
+ * ② **点了必须验。** 点击成功不代表生效(可能被弹窗挡、可能点偏)。
+ *    只有状态真的变了才算数,否则记失败,不虚报。
+ *
+ * 依赖:TikTokAuto.tiktok / ui / wait / logger
+ */
+TikTokAuto.interact = (function () {
+    'use strict';
+
+    function logger() { return TikTokAuto.logger; }
+
+    /**
+     * 给当前视频点赞。
+     *
+     * @returns { ok, skipped?, via, reason? }
+     */
+    function like(step) {
+        var tiktok = TikTokAuto.tiktok;
+        var tag = step || 'like';
+
+        var state = tiktok.likeState();
+        if (state === 'liked') {
+            return { ok: true, skipped: true, via: 'already-liked', reason: '这个视频已经点过赞' };
+        }
+        if (state !== 'unliked') {
+            return {
+                ok: false, via: 'state-unknown',
+                reason: '读不到点赞状态,跳过(不盲点:可能是已赞,点一下会变成取消)'
+            };
+        }
+
+        var button = tiktok.likeButton();
+        if (!button) {
+            return { ok: false, via: 'button-missing', reason: '找不到点赞按钮' };
+        }
+        TikTokAuto.ui.clickNode(button, tag);
+
+        /* 验证:desc 从 `Like video.` 变成 `Video liked`(或图标 selected 翻转) */
+        var done = TikTokAuto.wait.waitFor(tag + ':verify', function () {
+            return tiktok.likeState() === 'liked' ? true : null;
+        }, 6000, 300);
+        if (done === true) {
+            logger().info(tag, '点赞成功(状态已变为已赞)');
+            return { ok: true, via: 'state-changed' };
+        }
+        logger().warn(tag, '点赞后状态没有变化(可能被弹窗挡住或点偏了)');
+        return { ok: false, via: 'not-verified', reason: '点击后没有变成已赞' };
+    }
+
+    /**
+     * 关注当前视频的作者。
+     *
+     * @returns { ok, skipped?, via, reason? }
+     */
+    function follow(step) {
+        var tiktok = TikTokAuto.tiktok;
+        var tag = step || 'follow';
+
+        var state = tiktok.followState();
+        if (state === 'following') {
+            return { ok: true, skipped: true, via: 'already-following', reason: '已经关注过这个作者' };
+        }
+        if (state !== 'not-following') {
+            /* 头像和按钮都读不到 = 页面还没渲染好。
+             * 这时不点:点到空白处可能触发别的动作(播放/暂停),没有意义。 */
+            return {
+                ok: false, via: 'page-not-ready',
+                reason: '作者头像与关注按钮都读不到,页面可能还没渲染完'
+            };
+        }
+
+        var button = tiktok.followButton();
+        if (!button) {
+            return { ok: false, via: 'button-missing', reason: '找不到关注按钮' };
+        }
+        TikTokAuto.ui.clickNode(button, tag);
+
+        /* 验证:关注按钮**整个消失**(实测就是这种表现,不是改成 Following) */
+        var done = TikTokAuto.wait.waitFor(tag + ':verify', function () {
+            return tiktok.followState() === 'following' ? true : null;
+        }, 6000, 300);
+        if (done === true) {
+            logger().info(tag, '关注成功(关注按钮已消失)');
+            return { ok: true, via: 'button-gone' };
+        }
+        logger().warn(tag, '关注后按钮仍在,可能没生效');
+        return { ok: false, via: 'not-verified', reason: '点击后关注按钮仍然存在' };
+    }
+
+    return {
+        like: like,
+        follow: follow
+    };
+}());

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

@@ -30,6 +30,10 @@ TikTokAuto.tiktok = (function () {
     var PKG = 'com.zhiliaoapp.musically';
     var PKG = 'com.zhiliaoapp.musically';
     var COMMENT_DESC_KEYS = ['Read or add comments', '评论'];
     var COMMENT_DESC_KEYS = ['Read or add comments', '评论'];
     var LIKE_DESC_KEYS = ['Like video.', '点赞'];
     var LIKE_DESC_KEYS = ['Like video.', '点赞'];
+    /** 已点赞后同一个按钮的 desc(实测是 `Video liked`,不是 `Unlike`) */
+    var LIKED_DESC_KEYS = ['Video liked', '已点赞', 'Unlike'];
+    /** 关注按钮 desc 前缀(后面跟作者名)。`Followers` 会被 followButton 里单独排除。 */
+    var FOLLOW_DESC_KEYS = ['Follow ', '关注'];
     var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
     var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
     var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
     var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
     var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
     var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
@@ -233,6 +237,102 @@ TikTokAuto.tiktok = (function () {
         return hits.length > 0 ? hits[0] : null;
         return hits.length > 0 ? hits[0] : null;
     }
     }
 
 
+    /**
+     * 「已点赞」状态的点赞按钮。
+     *
+     * ★ 实测(2026-10-28,TikTok 47.1.4):点过赞之后同一个 `Button` 的 desc
+     *   从 `Like video. 2.4M likes` 变成 **`Video liked`**(不是 `Unlike`),
+     *   而里面那个 ImageView 的 `selected` 从 false 变成 true。
+     *
+     * 两个判据都要留:desc 受本地化影响,selected 是状态位不受文案影响 ——
+     * 任一命中就算已赞,比只认一个更耐改版。
+     */
+    function likedButton() {
+        var hits = findByLabel(className('android.widget.Button'), function (node) {
+            return startsWithAny(layout.desc(node), LIKED_DESC_KEYS);
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /**
+     * 点赞按钮里的图标节点(用它读 `selected`)。
+     * 实测:未赞 selected=false,已赞 selected=true。
+     */
+    function likeIcon() {
+        var button = likeButton() || likedButton();
+        if (!button) {
+            return null;
+        }
+        var icons = button.find(className('android.widget.ImageView'));
+        return icons.size() > 0 ? icons.get(0) : null;
+    }
+
+    /** 'liked' | 'unliked' | 'unknown' */
+    function likeState() {
+        if (likedButton() !== null) {
+            return 'liked';
+        }
+        if (likeButton() !== null) {
+            return 'unliked';
+        }
+        /* 按钮本身找不到时,退回看图标状态位(有些版本用同一个 desc,只翻 selected) */
+        var icon = null;
+        try { icon = likeIcon(); } catch (ignored) { icon = null; }
+        if (icon !== null && icon.selected()) {
+            return 'liked';
+        }
+        return 'unknown';
+    }
+
+    /**
+     * 播放页的「关注作者」按钮。
+     *
+     * 实测 desc 形如 `Follow Marcgagilang`(`Follow` + 空格 + 作者名),
+     * 是 `android.widget.Button`。
+     * ⚠️ 首页顶部那个 `Following` 页签是 **FrameLayout**,不是 Button,
+     *    所以限定 className 就能把它排除掉,不必额外按坐标过滤。
+     */
+    function followButton() {
+        var hits = findByLabel(className('android.widget.Button'), function (node) {
+            var value = layout.desc(node);
+            if (!startsWithAny(value, FOLLOW_DESC_KEYS)) {
+                return false;
+            }
+            /* `Followers` 是粉丝列表的入口,不是关注按钮 —— 前缀匹配会误命中 */
+            return value.indexOf('Followers') !== 0;
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /**
+     * 作者头像节点。用来区分「已关注(按钮消失)」和「页面还没渲染」。
+     * 实测 desc 形如 `Marcgagilang profile`。
+     */
+    function authorAvatar() {
+        var hits = findByLabel(className('android.widget.ImageView'), function (node) {
+            var value = layout.desc(node);
+            return value !== '' && / profile$/i.test(value);
+        });
+        return hits.length > 0 ? hits[0] : null;
+    }
+
+    /**
+     * 'following' | 'not-following' | 'unknown'
+     *
+     * ★ 「已关注」的唯一表现是**按钮整个消失**(实测),所以不能只看"找不到按钮"
+     *   —— 页面没渲染完也找不到。用作者头像是否存在来区分这两种情况:
+     *   头像在、按钮不在 = 已关注;两个都不在 = 页面还没好。
+     */
+    function followState() {
+        if (followButton() !== null) {
+            return 'not-following';
+        }
+        if (authorAvatar() !== null) {
+            return 'following';
+        }
+        return 'unknown';
+    }
+
     /**
     /**
      * 播放页判据。
      * 播放页判据。
      *
      *
@@ -993,6 +1093,12 @@ TikTokAuto.tiktok = (function () {
         videoCards: videoCards,
         videoCards: videoCards,
         commentButton: commentButton,
         commentButton: commentButton,
         likeButton: likeButton,
         likeButton: likeButton,
+        likedButton: likedButton,
+        likeIcon: likeIcon,
+        likeState: likeState,
+        followButton: followButton,
+        authorAvatar: authorAvatar,
+        followState: followState,
         onPlayerPage: onPlayerPage,
         onPlayerPage: onPlayerPage,
         backButton: backButton,
         backButton: backButton,
         commentCount: commentCount,
         commentCount: commentCount,

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

@@ -47,6 +47,7 @@ const MODULE_ORDER = [
     'tiktok',
     'tiktok',
     'player',
     'player',
     'comment',
     'comment',
+    'interact',
     'flow',
     'flow',
     /* —— ID 提取(task = 'id')——
     /* —— ID 提取(task = 'id')——
      * 顺序按依赖走:parse 是纯函数地基;uidset/filters 只依赖 parse;
      * 顺序按依赖走:parse 是纯函数地基;uidset/filters 只依赖 parse;