|
|
@@ -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,
|