| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490 |
- /**
- * 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
- };
- }());
|