/** * tiktok.js — TikTok 的应用知识(选择器 + 页面状态判定) * * 这个文件是**唯一**允许出现"TikTok 具体长什么样"的地方。 * 换 App、或 TikTok 改版时,只动这里,flow.js 一行都不用改。 * * 所有选择器都基于**真机实测**的锚点,并且在实测基础上刻意选了"语义锚点"而不是 * 混淆过的资源 id——TikTok 的 id 形如 `e4g`/`cn6`/`e8x`,每次改版都会重排; * 而 "Read or add comments" 这类无障碍文案相当稳定。 * 少数确实只能靠 id 的地方(发送按钮)走 config.selectors 覆盖,便于不改代码修。 * * 实测锚点(1080x1920,com.zhiliaoapp.musically): * 信息流搜索入口 ImageView desc="Search" [968,50,1080,162] * 搜索页输入框 EditText (top < 12%H) * 搜索页提交 Button text="Search" (left > 80%W) * 结果页分类页签 text/desc ∈ {Top,Users,Videos,...} y ≈ 152..232 * 结果页卡片 Button(卡片文案) width ≥ 35%W, top > 30%H * 播放页 "Read or add comments" + "Like video." 同时存在 * 播放页评论入口 Button desc="Read or add comments. N comments" * 播放页内联输入 EditText desc/text="Add comment..." * 评论面板输入 EditText top ∈ [50%H,80%H] * 面板工具行 desc="Stickers" / "Mention someone" / "Microphone" * 面板发送按钮 工具行同高、最右、且 right ≤ W-8 的可点击 Button(实测 id=cn6) * * 依赖:TikTokAuto.logger、TikTokAuto.layout */ TikTokAuto.tiktok = (function () { 'use strict'; var PKG = 'com.zhiliaoapp.musically'; var COMMENT_DESC_KEYS = ['Read or add comments', '评论']; 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 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']; /** * 结果页卡片搜索区的上边界(占屏高比例)。 * * 实测布局:搜索框 → 页签栏 y 152~232 → GridView 从 y=232 开始(屏高 1920)。 * 取 0.13(=250)刚好卡在网格起始线下方,把搜索框和页签排掉。 * 详见 videoCards() 的注释(用 0.3 会漏掉滚动后的上排卡片)。 */ var ROWS_TOP_RATIO = 0.13; var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证 var logger = TikTokAuto.logger; function screenWidth() { return device.width; } function screenHeight() { return device.height; } function startsWithAny(value, keys) { for (var i = 0; i < keys.length; i += 1) { if (value.indexOf(keys[i]) === 0) { return true; } } return false; } function equalsAny(value, keys) { for (var i = 0; i < keys.length; i += 1) { if (value === keys[i]) { return true; } } return false; } function widthOf(node) { var box = node.bounds(); return box.right - box.left; } function topOf(node) { return node.bounds().top; } function bottomOf(node) { return node.bounds().bottom; } /** 按"文案或描述命中"收集可见节点 */ function findByLabel(selector, predicate) { var result = []; var collection = selector.find(); for (var i = 0; i < collection.size(); i += 1) { var node = collection.get(i); if (predicate(node) && layout.isVisible(node)) { result.push(node); } } return result; } // ---------------------------------------------------------------- 信息流 /** 信息流右上角的搜索入口 */ function searchEntry() { var hits = findByLabel(className('android.widget.ImageView'), function (node) { var point = layout.center(node); return layout.desc(node) === 'Search' && point !== null && point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.85; }); return hits.length > 0 ? hits[0] : null; } /** 信息流就绪判据:底部导航存在 */ function bottomNavPresent() { return layout.findOnce(desc('Home')) !== null && layout.findOnce(desc('Profile')) !== null; } /** * 底部导航的「Profile」项 —— 用来打开**自己的**主页(脚本二要自己的粉丝列表)。 * * 判据刻意很松:`desc === 'Profile'` 精确匹配 + 中心落在屏幕下缘。 * 不限定 class(它是 FrameLayout,和历史记忆里"首页顶部 Following 页签是 * FrameLayout"同一族,限定 Button 会一个都找不到),也不认文案 —— * 底部导航这一项没有 text。 * * 为什么还要判"在下缘":右侧竖排作者头像的 desc 是 `xxx profile`(小写、 * 带用户名前缀),精确匹配 `Profile` 本来就排除掉了;但万一新版把它改成 * 纯 `Profile`,这一条能挡住"点到了头像"这种最坏情况。 */ function profileNav() { var node = layout.findOnce(desc('Profile')); if (!node) { return null; } var point = layout.center(node); if (!point || point.y < screenHeight() * 0.88) { return null; } return node; } // ---------------------------------------------------------------- 搜索页 /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */ function searchInput() { var hits = findByLabel(className('android.widget.EditText'), function (node) { return topOf(node) < screenHeight() * 0.12; }); return hits.length > 0 ? hits[0] : null; } function searchSubmit() { var hits = findByLabel(className('android.widget.Button'), function (node) { var point = layout.center(node); return layout.text(node) === 'Search' && point !== null && point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.8; }); return hits.length > 0 ? hits[0] : null; } /** 搜索页特征:搜索历史行(带删除按钮)或"发现"按钮 */ function searchHistoryPresent() { return layout.findOnce(desc('Close')) !== null || layout.findOnce(desc('Find')) !== null; } // ---------------------------------------------------------------- 结果页 /** 结果页顶部分类页签 */ function resultTab(name) { var hits = findByLabel(className('android.widget.TextView'), function (node) { return equalsAny(layout.text(node) || layout.desc(node), [name]) && topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16; }); if (hits.length > 0) { return hits[0]; } // 页签的文案节点本身常常不可点,真正可点的是外层 FrameLayout var frames = findByLabel(className('android.widget.FrameLayout'), function (node) { return equalsAny(layout.desc(node) || layout.text(node), [name]) && topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16; }); return frames.length > 0 ? frames[0] : null; } function resultTabStripPresent() { var found = 0; for (var i = 0; i < RESULT_TABS.length; i += 1) { if (resultTab(RESULT_TABS[i])) { found += 1; } } return found >= 3; } /** * 从卡片文案锚点向上找"卡片容器"。 * 为什么不能直接点文案:文案节点多数不可点,而它的可点祖先可能是**整屏容器**—— * 点下去等于点在屏幕中间,什么也不会发生(实测踩过)。 * 因此要求容器高度落在合理的卡片区间内,否则认为锚点自己就是最佳目标。 */ function cardContainer(anchor) { var height = screenHeight(); var cursor = layout.clickableAncestor(anchor); var depth = 0; while (cursor && depth < 6) { var cardHeight = bottomOf(cursor) - topOf(cursor); /* 上限从 0.8 放宽到 1.05(2026-10-28):卡片滚动到贴着屏幕上下边缘时, * 容器会有一部分在屏幕外,`bounds()` 报的高度于是**被裁切得又大又怪**, * 实测超过 80% 后被这里挡掉 → 整排卡片变成"找不到容器" → 上层误判 * "卡片用尽"。点击点由 `layout.center` 用 visibleRect 夹取,仍然安全。 */ if (cardHeight >= height * 0.18 && cardHeight <= height * 1.05) { return cursor; } cursor = layout.clickableAncestor(cursor); depth += 1; } return anchor; } /** * 这个节点是不是在"结果网格"里(GridView / RecyclerView 的子孙)。 * * 为什么需要:结果页上会浮出各种**不是视频卡但同样又宽又有文案**的东西 —— * 授权弹窗的 `Customize Settings` / `Confirm My Choices`、商品卡的 `Shop now`、 * 「Users」分区的表头。它们能通过"宽 + 有文案"的判据,被当成卡片点开白等 20 秒。 * 而真正的结果卡片一定在滚动容器里,弹窗不出现在其中。 */ function insideResultGrid(node) { var cursor = node; for (var depth = 0; depth < 8; depth += 1) { try { cursor = cursor.parent(); } catch (ignored) { return false; } if (!cursor) { return false; } var name = ''; try { name = String(cursor.className() || ''); } catch (ignoredToo) { name = ''; } if (name.indexOf('GridView') >= 0 || name.indexOf('RecyclerView') >= 0) { return true; } } return false; } /** * 当前页面是"照片轮播帖"吗(不是视频)? * * 真机实测(2026-10-28):照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView, * 视频帖是「`Video` · …」。照片帖**没有播放页**,`onPlayerPage()` 也认不出来, * 于是 openCard 会白等满 20 秒(playerReadyMs)再失败。 * * 判据限定文案**完全等于** `Photo` 且位于屏幕下半部分(信息行), * 避免误伤说明文字里出现 Photo 的普通视频。 */ function photoPost() { var height = screenHeight(); var hits = findByLabel(className('android.widget.TextView'), function (node) { return (layout.text(node) || '') === 'Photo' && topOf(node) > height * 0.5; }); return hits.length > 0 ? hits[0] : null; } /** * 结果网格里当前可见的卡片(按屏幕从上到下)。 * * 判据(2026-10-28 用 AutoX 无障碍树真机核对过): * - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉; * - **必须在 GridView/RecyclerView 里**(排掉浮层/弹窗按钮,如 `Customize Settings`); * - **必须能找到一个"卡片尺寸"的可点容器**(`cardContainer` 找不到时会退回节点 * 本身,那种情况说明它只是一个孤立按钮,不是卡片); * - 可见性看**容器**,不看文案。 * * ★ 为什么不再要求"文案在屏幕内"(这次最关键的修正): * 卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 8,1242 → 536,1920+)。 * 结果页上只要多一条通栏广告或一行筛选 chips,网格就被推下去,文案跟着落到 * **屏幕外**(实测 y=2099 > 1920)。旧的 `bottomOf(node) <= height - 12` 于是把这些 * **明明看得见、点得着**的卡片全部丢掉 —— 表现成"只处理了 1 个就说卡片用尽"。 * * 正确做法:文案照读(无障碍树里它在,只是不在可视区),能不能点由**容器**决定; * 点击点由 `layout.center` 用 `visibleRect` 夹取,永远落在屏幕内。 */ function videoCards() { var width = screenWidth(); var height = screenHeight(); var gridTop = height * ROWS_TOP_RATIO; /* 页签栏底部,网格从这里开始 */ var minVisible = height * 0.08; /* 露出来的太少就不点(点下去容易点到邻居) */ var cards = []; var seen = {}; var hits = findByLabel(className('android.widget.Button'), function (node) { return widthOf(node) >= width * 0.35 && insideResultGrid(node); }); for (var i = 0; i < hits.length; i += 1) { var caption = layout.text(hits[i]) || layout.desc(hits[i]); if (!caption) { continue; } var container = cardContainer(hits[i]); if (!container || container === hits[i]) { /* 找不到卡片尺寸的可点容器 → 这是个孤立按钮(弹窗按钮、分区表头等) */ continue; } var boxTop = topOf(container); var boxBottom = bottomOf(container); if (boxBottom <= gridTop) { /* 整个容器都在页签栏之上 —— 不是结果卡片 */ continue; } if (boxTop >= height) { /* 完全在屏幕下方,看不见也点不着 */ continue; } if (Math.min(boxBottom, height) - Math.max(boxTop, gridTop) < minVisible) { /* 只露出一条缝,点下去很容易落到相邻卡片上 */ continue; } var key = caption + '@' + boxTop; if (seen[key]) { continue; } seen[key] = true; cards.push({ anchor: hits[i], container: container, caption: caption, top: boxTop }); } cards.sort(function (a, b) { return a.top - b.top; }); return cards; } // ---------------------------------------------------------------- 播放页 function commentButton() { var hits = findByLabel(className('android.widget.Button'), function (node) { return startsWithAny(layout.desc(node), COMMENT_DESC_KEYS); }); return hits.length > 0 ? hits[0] : null; } function likeButton() { var hits = findByLabel(className('android.widget.Button'), function (node) { return startsWithAny(layout.desc(node), LIKE_DESC_KEYS); }); 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; } /** * 信息流当前视频的指纹 —— **只用来判断"上滑有没有真的换视频"**。 * * ★ 它**不能**当业务上的"视频身份"用(判断是不是同一个视频): * 点赞之后 `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' * * ★ 「已关注」的唯一表现是**按钮整个消失**(实测),所以不能只看"找不到按钮" * —— 页面没渲染完也找不到。用作者头像是否存在来区分这两种情况: * 头像在、按钮不在 = 已关注;两个都不在 = 页面还没好。 */ function followState() { if (followButton() !== null) { return 'not-following'; } if (authorAvatar() !== null) { return 'following'; } return 'unknown'; } /** * 播放页判据。 * * 教训(实测踩到):**信息流首页也有 "Like video." 和 "Read or add comments" 这一竖排**, * 所以只看这两个会一路把首页判成播放页,整个流程直接跑歪。 * 真正能区分"从搜索结果点进来的播放页"的是: * - 左上角有返回箭头(首页没有) * - 没有底部导航栏(首页有 Home/Friends/Inbox/Profile) * 四条同时成立才算播放页。 */ function onPlayerPage() { return commentButton() !== null && likeButton() !== null && backButton() !== null && !bottomNavPresent(); } function backButton() { var hits = findByLabel(className('android.widget.ImageView'), function (node) { return layout.desc(node) === 'Back'; }); return hits.length > 0 ? hits[0] : null; } /** 从 "Read or add comments. 150 comments" 里把 150 抠出来 */ function commentCount() { var button = commentButton(); if (!button) { return null; } var matched = layout.desc(button).match(/([\d][\d,.\s]*)\s*(?:comments?|条评论|评论)/i); if (!matched) { return null; } return parseInt(String(matched[1]).replace(/[^\d]/g, ''), 10); } /** 视频页底部的内联评论输入框(面板未打开时就是它) */ function inlineInput() { var hits = findByLabel(className('android.widget.EditText'), function (node) { return equalsAny(layout.text(node) || layout.desc(node), COMMENT_PLACEHOLDER) && topOf(node) > screenHeight() * 0.8; }); return hits.length > 0 ? hits[0] : null; } /** 评论面板里的输入框(打开面板后会出现在屏幕中部) */ function panelInput() { var screenH = screenHeight(); /* ① 经典位置:键盘弹起之后,输入框会被顶到屏幕中部。 */ var mid = findByLabel(className('android.widget.EditText'), function (node) { var top = topOf(node); return top > screenH * 0.45 && top < screenH * 0.85; }); 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() { for (var i = 0; i < TOOL_ROW_ANCHORS.length; i += 1) { var node = layout.findOnce(desc(TOOL_ROW_ANCHORS[i])); if (node && layout.isVisible(node)) { return node; } } return null; } /** * 评论发送按钮。 * * 它没有可用的文案(实测其 content-desc 是一个无关的账号串),图标是粉色箭头, * 所以只能靠**结构**定位: * 面板里"工具行"(表情/提及/语音那一行)的同高区域里, * 最靠右、且不属于右侧竖排动作栏(动作栏 right == 屏宽)的那个可点击按钮。 * * 优先用 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) { var byId = layout.findOnce(id(override)); if (byId && layout.isVisible(byId)) { logger.info('comment', '发送按钮:使用配置 id ' + override); return byId; } } /* 先用几何判据(输入框那一行 + 右端)—— 它对"工具行 desc 变成 @资源id" * 和"右侧动作栏节点残留在树里"这两个新版变化都免疫。 */ var rowButton = sendButtonOnInputRow(); if (rowButton) { return rowButton; } var anchor = toolRowAnchor(); if (!anchor) { return null; } var bandTop = topOf(anchor); var bandBottom = bottomOf(anchor); var width = screenWidth(); var best = null; var hits = findByLabel(className('android.widget.Button'), function (node) { var point = layout.center(node); if (point === null) { return false; } var box = node.bounds(); if (box.top < bandTop || box.top > bandBottom) { return false; // 不在工具行高度带里 } if (box.right > width - layout.SAFE_MARGIN) { return false; // 属于右侧竖排动作栏 } return box.left >= width * 0.5; // 必须是右半边 }); for (var i = 0; i < hits.length; i += 1) { if (best === null || hits[i].bounds().left > best.bounds().left) { best = hits[i]; } } if (best) { logger.info('comment', '发送按钮:结构化定位 id=' + layout.shortId(best) + ' b=' + JSON.stringify(best.bounds())); } return best; } // ---------------------------------------------------------------- 评论列表 /** * 评论列表面板顶部的计数,形如 "150 comments"。 * * 为什么需要它:实测发现**视频页那个评论角标不一定会立刻刷新** * (发完评论后仍读回旧值),而评论列表头部的计数是即时的。 * 所以"计数自增"这条证据要用列表头部的那个,而不是角标的。 */ /** * 从评论区头部的文案里取出评论数。**两种格式都要认**: * * · 旧版:`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 parseCommentCountLabel(layout.text(node)) !== null; }); if (hits.length === 0) { return null; } return parseCommentCountLabel(layout.text(hits[0])); } /** * 评论面板是否处于打开状态。 * 注意:点内联输入框打开的是"快捷面板"(有中部输入框), * 点评论角标打开的是"列表面板"(没有中部输入框,但有 "N comments" 头部)。 * 两种都要认,否则 back() 会漏按、面板关不掉。 */ function commentPanelOpen() { return panelInput() !== null || listCommentCount() !== null; } /** * 评论列表里是否出现了某条完全一致的文案。 * * 这里刻意**不设数量上限**:早先为了省事写成"只看前 25 条可见文本", * 结果遍历顺序是"先视频页、后评论面板",25 条全被视频页的文案占满了, * 评论条目根本没轮到,于是最强的那条证据永远拿不到。 * 可见 TextView 也就几百个,全扫一遍的开销远小于"判错"的代价。 */ function commentListContains(value) { var hits = findByLabel(className('android.widget.TextView'), function (node) { return layout.text(node) === value; }); return hits.length > 0; } /** 当前屏幕上所有可见的文本文案(按屏幕顺序) */ function visibleTexts(limit) { var hits = findByLabel(className('android.widget.TextView'), function () { return true; }); var texts = []; for (var i = 0; i < hits.length; i += 1) { var value = layout.text(hits[i]); if (value && value.length >= 2) { texts.push(value); } if (limit && texts.length >= limit) { break; } } return texts; } // ============================================================ ID 提取 /* * 下面这一整段是 2026-10-20 在真机上逐页 dump 出来的锚点 * (Pixel 5a / 1080x1920 / TikTok 47.1.4 / 英文界面)。**每条都是实测值**, * 不是猜的 —— 猜出来的选择器在这个项目里从来没有一次是对的。 * * ★ 一个重要发现:这一片的**资源 id 是语义化的**(`tv_username` / `txt_desc` / * `user_avatar`),与信息流那批混淆 id(`e4g`/`cn6`)完全不同。 * 原因是这些页面来自 TikTok 的搜索/社交业务模块,用的是另一套布局。 * 所以这里优先按 id 定位,文案只作为兜底(改版时 id 比文案更稳)。 * * 实测到的三处文本陷阱: * ① 用户名被 **FID 隔离符** 包裹:显示 `⁨ajman_fashion_week⁩`(U+2068/U+2069), * 肉眼看不见,`layout.sanitize` 也不剥(那不是零宽字符)→ 统一走 `parse.username`; * ② 搜索用户列表里 **`tv_aweme_id` 装的是昵称**(id 名违反直觉),`tv_username` 才是 uid; * ③ 粉丝列表与搜索列表的同义控件**id 不同**: * 搜索列表 `tv_username`/`tv_desc`,粉丝列表 `txt_desc`(uid)/`txt_user_name`(昵称)。 */ /** 是否在用户主页:有一个文案形如 "@xxx" 的可点 Button,且下面跟着三组数字 */ function profileUsernameNode() { var hits = findByLabel(className('android.widget.Button'), function (node) { var value = layout.text(node); return value.charAt(0) === '@' && value.length > 1 && value.length < 40 && topOf(node) < screenHeight() * 0.3; }); return hits.length > 0 ? hits[0] : null; } /** 用户主页的昵称(在 @xxx 上面那一行) */ function profileNicknameNode() { var userNode = profileUsernameNode(); if (!userNode) { return null; } var userTop = topOf(userNode); var hits = findByLabel(className('android.widget.Button'), function (node) { var box = node.bounds(); return box.top < userTop && box.bottom > userTop - screenHeight() * 0.12 && layout.text(node) !== '' && layout.text(node).charAt(0) !== '@'; }); return hits.length > 0 ? hits[0] : null; } /** 用户页顶部三组数字的标签文案。英文界面实测为这三个词。 */ var STAT_LABELS = { 'Following': 'follow', 'Followers': 'fans', 'Likes': 'likes', '关注': 'follow', '粉丝': 'fans', '获赞': 'likes', 'Abonnements': 'follow', 'Abonnés': 'fans', "J'aime": 'likes' }; /** * 用户页顶部三组数字:`{ follow: 343, fans: 594, likes: 2491 }`。 * * 结构(实测):每个数字是一个 LinearLayout,里面两个 TextView —— * 数字在上、标签在下。所以按"标签找容器、再取容器里的数字"读。 * 注意**没有视频数**:这个页面只显示 关注/粉丝/获赞 三组,这是平台行为。 */ function statNumbers() { var out = { follow: null, fans: null, likes: null }; var parse = TikTokAuto.parse; var labels = findByLabel(className('android.widget.TextView'), function () { return true; }); for (var i = 0; i < labels.length; i += 1) { var labelText = layout.text(labels[i]); var key = STAT_LABELS[labelText]; if (!key) { continue; } /* 标签必须在页面上部(三组数字区),否则可能是别处长文本里恰好含这个词 */ if (topOf(labels[i]) > screenHeight() * 0.25) { continue; } var container = parentOf(labels[i]); var value = container ? numberInside(container) : null; if (value === null) { value = parse.count(labelText); } if (out[key] === null) { out[key] = value; } } return out; } function parentOf(node) { try { return node.parent(); } catch (ignored) { return null; } } /** 在一个容器里找那个"纯数字"的 TextView,解析成整数 */ function numberInside(container) { var parse = TikTokAuto.parse; var found = null; var walk = [container]; var guard = 0; while (walk.length > 0 && guard < 30) { guard += 1; var node = walk.shift(); if (!node) { continue; } var value = layout.text(node); if (value !== '' && /^[\d,.\s]+[kKmMbBwW万萬亿億]?$/.test(value)) { var parsed = parse.count(value); if (parsed !== null && (found === null || parsed > found)) { found = parsed; } } var count = 0; try { count = node.childCount(); } catch (ignored) { count = 0; } for (var i = 0; i < count; i += 1) { try { walk.push(node.child(i)); } catch (ignoredToo) { /* 遍历中失效就跳过 */ } } } return found; } /** 用户主页的个签:页面中上部一段较长的可点文本(实测是个 Button,不是 TextView) */ function profileSignatureNode() { var hits = findByLabel(className('android.widget.Button'), function (node) { var value = layout.text(node); var box = node.bounds(); return value.length >= 4 && value.charAt(0) !== '@' && box.top > screenHeight() * 0.22 && box.top < screenHeight() * 0.34 && (box.right - box.left) > screenWidth() * 0.4; }); return hits.length > 0 ? hits[0] : null; } /** * 私密账号提示。 * 实测:私密账号的主页会在视频区位置显示一行提示("This account is private" 等)。 * 这里只在**视频网格明显不存在**时才认这条,避免把正常账号的某条视频文案误判成提示。 */ function privateHint() { var keys = ['private', "isn't available", 'no videos', 'No videos']; var texts = visibleTexts(0); for (var i = 0; i < texts.length; i += 1) { for (var j = 0; j < keys.length; j += 1) { if (String(texts[i]).toLowerCase().indexOf(String(keys[j]).toLowerCase()) >= 0) { return texts[i]; } } } return ''; } /** 用户主页的视频网格格子数(判断"有没有内容"用,不用于视频量门槛) */ function videoGridCount() { var hits = findByLabel(className('android.widget.FrameLayout'), function (node) { var point = layout.center(node); return point !== null && point.y > screenHeight() * 0.33; }); return hits.length; } /** 用户主页上的「关注/粉丝」入口(点它打开列表页) */ function statEntry(which) { var wanted = which === 'following' ? 'follow' : 'fans'; var labels = findByLabel(className('android.widget.TextView'), function () { return true; }); for (var i = 0; i < labels.length; i += 1) { if (STAT_LABELS[layout.text(labels[i])] !== wanted) { continue; } if (topOf(labels[i]) > screenHeight() * 0.25) { continue; } /* 可点的是外层容器,标签自己不可点 */ return layout.clickableAncestor(labels[i]) || parentOf(labels[i]); } return null; } /** * 粉丝/关注列表页的页签("Following 343" / "Followers 594" / "Suggested")。 * * 实测这个列表是**从用户页点进来的一张覆盖页**,顶部有三个页签, * 可以直接在两个页签之间切换 —— 所以"先收粉丝再收关注"不必来回退。 */ function userListTabs() { var out = []; var hits = findByLabel(className('android.widget.TextView'), function (node) { var value = layout.text(node); return /^(Following|Followers|Suggested|关注|粉丝|推荐)\b/.test(value) && topOf(node) < screenHeight() * 0.16; }); for (var i = 0; i < hits.length; i += 1) { var value = layout.text(hits[i]); var key = value.indexOf('Follower') === 0 || value.indexOf('粉丝') === 0 ? 'fans' : (value.indexOf('Suggested') === 0 || value.indexOf('推荐') === 0 ? 'suggested' : 'following'); out.push({ key: key, label: value, node: layout.clickableAncestor(hits[i]) || hits[i] }); } return out; } /** 当前列表页选中的是哪个页签(用 selected 属性判,实测可用) */ function selectedListTab() { var tabs = userListTabs(); for (var i = 0; i < tabs.length; i += 1) { var selected = false; try { selected = tabs[i].node.selected() === true || tabs[i].node.isSelected() === true; } catch (ignored) { selected = false; } if (selected) { return tabs[i].key; } } /* selected 读不到时退回文案判断:选中态的页签文案与未选中态无差异, * 所以这里只能返回 unknown,由调用方用"点完再看内容变没变"确认。 */ return 'unknown'; } /** * 粉丝/关注列表里的每一行。 * * 实测结构:`LinearLayout`(整行可点)> `TextView txt_user_name`(昵称) * + `TextView txt_desc`(**uid**)+ `Button ubp`(Follow)。 * 注意与搜索用户列表**id 不同**(那边是 tv_username/tv_desc)。 * * 返回的 `node` 是行容器,点它进用户页;`x` 是"安全点击的横向位置"—— * 靠左,避开右侧的 Follow 按钮(实测按钮左边界 x≈872)。 */ function userListRows() { var parse = TikTokAuto.parse; var rows = []; var uidNodes = findByLabel(className('android.widget.TextView'), function (node) { return resourceIdOf(node).indexOf('txt_desc') >= 0 && layout.text(node) !== ''; }); /* 有的版本 uid 放在 txt_user_name、昵称放在 txt_desc,所以按"哪个像 uid"再兜一层 */ if (uidNodes.length === 0) { uidNodes = findByLabel(className('android.widget.TextView'), function (node) { return resourceIdOf(node).indexOf('txt_user_name') >= 0 && parse.looksLikeUsername(layout.text(node)); }); } for (var i = 0; i < uidNodes.length; i += 1) { var uidNode = uidNodes[i]; var uid = parse.username(layout.text(uidNode)); if (!parse.looksLikeUsername(uid)) { continue; } var row = findRowContainer(uidNode); var box = row ? row.bounds() : uidNode.bounds(); rows.push({ uid: uid, nickname: nicknameOf(uidNode, row), node: row || uidNode, top: box.top, x: Math.round(box.left + (box.right - box.left) * 0.3), y: Math.round((box.top + box.bottom) / 2) }); } rows.sort(function (a, b) { return a.top - b.top; }); return rows; } function resourceIdOf(node) { return layout.resourceId(node); } /** 从 uid 文本往上找"整行"容器(高度落在一行的高度区间内) */ function findRowContainer(uidNode) { var cursor = uidNode; var depth = 0; while (cursor && depth < 5) { var box = cursor.bounds(); var rowHeight = box.bottom - box.top; if (layout.isClickable(cursor) && rowHeight >= screenHeight() * 0.05 && rowHeight <= screenHeight() * 0.12) { return cursor; } cursor = parentOf(cursor); depth += 1; } return layout.clickableAncestor(uidNode) || parentOf(uidNode); } /** 行里的昵称:找同一容器内的另一个 TextView(不是 uid 那个) */ function nicknameOf(uidNode, row) { if (!row) { return ''; } var found = ''; var walk = [row]; var guard = 0; while (walk.length > 0 && guard < 40) { guard += 1; var node = walk.shift(); if (!node) { continue; } var id = resourceIdOf(node); var value = layout.text(node); if (value !== '' && (id.indexOf('txt_user_name') >= 0 || id.indexOf('tv_aweme_id') >= 0)) { found = value; break; } var count = 0; try { count = node.childCount(); } catch (ignored) { count = 0; } for (var i = 0; i < count; i += 1) { try { walk.push(node.child(i)); } catch (ignoredToo) { /* 跳过 */ } } } return found; } /** * 搜索结果的「用户」列表行。 * * 实测结构:`Button`(整行可点)> `TextView tv_username`(uid) * + `TextView tv_aweme_id`(**昵称**,id 名违反直觉)+ `TextView tv_desc`("594 followers · 2.5K likes")。 * * 额外好处:`tv_desc` 里就有粉丝数与获赞数 —— 所以按数量筛选时 * **这一条来源不必逐个打开用户页**(其余来源没有这个信息)。 */ function usersResultRows() { var parse = TikTokAuto.parse; var rows = []; var uidNodes = findByLabel(className('android.widget.TextView'), function (node) { return resourceIdOf(node).indexOf('tv_username') >= 0 && layout.text(node) !== ''; }); for (var i = 0; i < uidNodes.length; i += 1) { var uid = parse.username(layout.text(uidNodes[i])); if (!parse.looksLikeUsername(uid)) { continue; } var box = uidNodes[i].bounds(); var desc = ''; var nickname = ''; var rowNode = null; /* 向上两层找行容器,再在容器里读昵称与计数 */ var cursor = uidNodes[i]; for (var up = 0; up < 4 && cursor; up += 1) { cursor = parentOf(cursor); if (cursor && layout.isClickable(cursor)) { rowNode = cursor; break; } } if (rowNode) { var children = findByLabelInside(rowNode, className('android.widget.TextView')); for (var j = 0; j < children.length; j += 1) { var id = resourceIdOf(children[j]); var value = layout.text(children[j]); if (id.indexOf('tv_aweme_id') >= 0) { nickname = value; } else if (id.indexOf('tv_desc') >= 0) { desc = value; } } var rowBox = rowNode.bounds(); box = { left: rowBox.left, top: rowBox.top, right: rowBox.right, bottom: rowBox.bottom }; } rows.push({ uid: uid, nickname: nickname, node: rowNode || uidNodes[i], top: box.top, fans: parseFollowerLine(desc, 'followers'), likes: parseFollowerLine(desc, 'likes'), x: Math.round(box.left + (box.right - box.left) * 0.3), y: Math.round((box.top + box.bottom) / 2) }); } rows.sort(function (a, b) { return a.top - b.top; }); return rows; } /** 在一个节点内部找符合条件的子孙(不含自身之外的范围) */ function findByLabelInside(rootNode, selector) { var out = []; var collection = selector.find(); for (var i = 0; i < collection.size(); i += 1) { var node = collection.get(i); var cursor = node; var depth = 0; var inside = false; while (cursor && depth < 12) { if (cursor === rootNode) { inside = true; break; } cursor = parentOf(cursor); depth += 1; } if (inside) { out.push(node); } } return out; } /** * 从 "594 followers · 2.5K likes" 里分别抠出两个数。 * 分隔符实测是 " · "(中点),但不同语言/版本可能不同,所以按**关键词**切而不是按分隔符。 */ function parseFollowerLine(text, which) { var parse = TikTokAuto.parse; if (!text) { return null; } var parts = String(text).split(/[·•|]/); for (var i = 0; i < parts.length; i += 1) { var part = parts[i]; var lower = part.toLowerCase(); if (which === 'followers' && (lower.indexOf('follower') >= 0 || lower.indexOf('粉丝') >= 0)) { return parse.count(part); } if (which === 'likes' && (lower.indexOf('like') >= 0 || lower.indexOf('获赞') >= 0)) { return parse.count(part); } } return null; } /** 播放页底部的作者 uid(实测:`Button l2m` 的 text = "@2131823375") */ function playerAuthorUid() { var parse = TikTokAuto.parse; var hits = findByLabel(className('android.widget.Button'), function (node) { var value = layout.text(node); return value.charAt(0) === '@' && parse.looksLikeUsername(value); }); return hits.length > 0 ? parse.username(layout.text(hits[0])) : ''; } /** * 评论区里的每一条:**只有昵称,没有 uid**(实测 2026-10-20)。 * * 所以"从评论区取评论者"必须逐个点头像/昵称进用户页 —— 成本比列表高一个数量级。 * 这里返回每个人可点的节点(头像 ImageView `blm`)与昵称,供上层决定怎么进。 */ function commentRowers() { var out = []; var avatars = findByLabel(className('android.widget.ImageView'), function (node) { return resourceIdOf(node).indexOf('blm') >= 0; }); for (var i = 0; i < avatars.length; i += 1) { var box = avatars[i].bounds(); /* 头像必须在评论列表区域内(屏幕中下部),不是视频右上角的作者头像 */ if (box.top < screenHeight() * 0.38) { continue; } out.push({ node: avatars[i], top: box.top, x: Math.round((box.left + box.right) / 2), y: Math.round((box.top + box.bottom) / 2) }); } out.sort(function (a, b) { return a.top - b.top; }); return out; } /** 结果页的某个页签当前是否选中(Users 页签用) */ function resultTabSelected(name) { var node = resultTab(name); if (!node) { return false; } try { return node.selected() === true || node.isSelected() === true; } catch (ignored) { return false; } } /** * ID 提取这条链路自己在用的页面判定。 * * **刻意与 pageState() 分开**:pageState 是视频链路的判据, * 往里加分支就有可能把"播放页 vs 结果页"的老判定带歪(方案 §12 明确要求回归)。 * 新开一个函数,视频链路一行都不用动。 * * @returns 'profile' | 'user-list' | 'users-results' | 'comment-panel' | 'search' | 'feed' | 'other' */ function idPageState() { if (commentPanelOpen()) { return 'comment-panel'; } if (profileUsernameNode() !== null) { return 'profile'; } if (userListTabs().length >= 2 && userListRows().length > 0) { return 'user-list'; } if (usersResultRows().length > 0) { return 'users-results'; } if (searchInput() !== null) { return 'search'; } if (bottomNavPresent()) { return 'feed'; } return 'other'; } // ---------------------------------------------------------------- 页面状态 /** * 判定当前处于哪个页面。 * 顺序即优先级:播放页最特征化先判,信息流最宽松最后判。 */ function pageState() { // 评论面板要最先判:面板一盖上来,播放页的返回箭头就被遮住了, // 于是 onPlayerPage() 会变 false,整个页面掉进 unknown 再也出不来 // (实测踩到:脚本自己打开评论列表校验后没关掉,下一轮就彻底迷路)。 if (commentPanelOpen()) { return 'comment-panel'; } if (onPlayerPage()) { return 'player'; } if (resultTabStripPresent()) { return 'results'; } // 搜索页判据:顶部有输入框,且**没有**结果页的页签条。 // 早先还要求存在搜索历史行,结果"历史为空 / 已被清空"时整页被判成 unknown, // 于是 ensureFeed 一路僵住。页签条的有无已经足够区分搜索页和结果页。 if (searchInput() !== null && !resultTabStripPresent() && !bottomNavPresent()) { return 'search'; } if (searchEntry() !== null && bottomNavPresent()) { return 'feed'; } return 'unknown'; } /** 排障用:把当前可见的关键节点打出来 */ function describeScreen(tag) { var nodes = []; var classes = ['android.widget.Button', 'android.widget.EditText', 'android.widget.ImageView', 'android.widget.TextView']; for (var i = 0; i < classes.length; i += 1) { var collection = className(classes[i]).find(); for (var j = 0; j < collection.size(); j += 1) { if (layout.isVisible(collection.get(j))) { nodes.push(collection.get(j)); } } } logger.info(tag || 'screen', layout.describe(nodes, 40)); } return { PKG: PKG, pageState: pageState, searchEntry: searchEntry, searchInput: searchInput, searchSubmit: searchSubmit, searchHistoryPresent: searchHistoryPresent, bottomNavPresent: bottomNavPresent, profileNav: profileNav, resultTab: resultTab, resultTabStripPresent: resultTabStripPresent, videoCards: videoCards, commentButton: commentButton, likeButton: likeButton, likedButton: likedButton, likeIcon: likeIcon, likeState: likeState, followButton: followButton, authorAvatar: authorAvatar, feedSignature: feedSignature, parseCommentCountLabel: parseCommentCountLabel, followState: followState, onPlayerPage: onPlayerPage, photoPost: photoPost, backButton: backButton, commentCount: commentCount, inlineInput: inlineInput, panelInput: panelInput, toolRowAnchor: toolRowAnchor, sendButton: sendButton, listCommentCount: listCommentCount, commentPanelOpen: commentPanelOpen, commentListContains: commentListContains, visibleTexts: visibleTexts, describeScreen: describeScreen, /* —— ID 提取(2026-10-20 真机实测锚点,见上面的长注释) —— */ profileUsernameNode: profileUsernameNode, profileNicknameNode: profileNicknameNode, profileSignatureNode: profileSignatureNode, statNumbers: statNumbers, statEntry: statEntry, privateHint: privateHint, videoGridCount: videoGridCount, userListTabs: userListTabs, selectedListTab: selectedListTab, userListRows: userListRows, usersResultRows: usersResultRows, playerAuthorUid: playerAuthorUid, commentRowers: commentRowers, resultTabSelected: resultTabSelected, idPageState: idPageState }; }());