tiktok.js 61 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490
  1. /**
  2. * tiktok.js — TikTok 的应用知识(选择器 + 页面状态判定)
  3. *
  4. * 这个文件是**唯一**允许出现"TikTok 具体长什么样"的地方。
  5. * 换 App、或 TikTok 改版时,只动这里,flow.js 一行都不用改。
  6. *
  7. * 所有选择器都基于**真机实测**的锚点,并且在实测基础上刻意选了"语义锚点"而不是
  8. * 混淆过的资源 id——TikTok 的 id 形如 `e4g`/`cn6`/`e8x`,每次改版都会重排;
  9. * 而 "Read or add comments" 这类无障碍文案相当稳定。
  10. * 少数确实只能靠 id 的地方(发送按钮)走 config.selectors 覆盖,便于不改代码修。
  11. *
  12. * 实测锚点(1080x1920,com.zhiliaoapp.musically):
  13. * 信息流搜索入口 ImageView desc="Search" [968,50,1080,162]
  14. * 搜索页输入框 EditText (top < 12%H)
  15. * 搜索页提交 Button text="Search" (left > 80%W)
  16. * 结果页分类页签 text/desc ∈ {Top,Users,Videos,...} y ≈ 152..232
  17. * 结果页卡片 Button(卡片文案) width ≥ 35%W, top > 30%H
  18. * 播放页 "Read or add comments" + "Like video." 同时存在
  19. * 播放页评论入口 Button desc="Read or add comments. N comments"
  20. * 播放页内联输入 EditText desc/text="Add comment..."
  21. * 评论面板输入 EditText top ∈ [50%H,80%H]
  22. * 面板工具行 desc="Stickers" / "Mention someone" / "Microphone"
  23. * 面板发送按钮 工具行同高、最右、且 right ≤ W-8 的可点击 Button(实测 id=cn6)
  24. *
  25. * 依赖:TikTokAuto.logger、TikTokAuto.layout
  26. */
  27. TikTokAuto.tiktok = (function () {
  28. 'use strict';
  29. var PKG = 'com.zhiliaoapp.musically';
  30. var COMMENT_DESC_KEYS = ['Read or add comments', '评论'];
  31. var LIKE_DESC_KEYS = ['Like video.', '点赞'];
  32. /** 已点赞后同一个按钮的 desc(实测是 `Video liked`,不是 `Unlike`) */
  33. var LIKED_DESC_KEYS = ['Video liked', '已点赞', 'Unlike'];
  34. /** 关注按钮 desc 前缀(后面跟作者名)。`Followers` 会被 followButton 里单独排除。 */
  35. var FOLLOW_DESC_KEYS = ['Follow ', '关注'];
  36. var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
  37. var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
  38. /**
  39. * 右侧竖排动作栏的 desc 前缀(作者 / 点赞 / 评论 / 收藏 / 分享 / 音乐)。
  40. * 用于把"和输入框同高的动作栏按钮"从发送键候选里排掉 —— 详见 isActionBarButton。
  41. */
  42. var ACTION_BAR_DESC_PREFIXES = [
  43. 'Follow', 'Following', 'Unfollow',
  44. 'Like video', 'Video liked',
  45. 'Read or add comments', 'Add or remove this video',
  46. 'Share video', 'Sound:'
  47. ];
  48. var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
  49. /**
  50. * 结果页卡片搜索区的上边界(占屏高比例)。
  51. *
  52. * 实测布局:搜索框 → 页签栏 y 152~232 → GridView 从 y=232 开始(屏高 1920)。
  53. * 取 0.13(=250)刚好卡在网格起始线下方,把搜索框和页签排掉。
  54. * 详见 videoCards() 的注释(用 0.3 会漏掉滚动后的上排卡片)。
  55. */
  56. var ROWS_TOP_RATIO = 0.13;
  57. var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证
  58. var logger = TikTokAuto.logger;
  59. function screenWidth() { return device.width; }
  60. function screenHeight() { return device.height; }
  61. function startsWithAny(value, keys) {
  62. for (var i = 0; i < keys.length; i += 1) {
  63. if (value.indexOf(keys[i]) === 0) {
  64. return true;
  65. }
  66. }
  67. return false;
  68. }
  69. function equalsAny(value, keys) {
  70. for (var i = 0; i < keys.length; i += 1) {
  71. if (value === keys[i]) {
  72. return true;
  73. }
  74. }
  75. return false;
  76. }
  77. function widthOf(node) {
  78. var box = node.bounds();
  79. return box.right - box.left;
  80. }
  81. function topOf(node) {
  82. return node.bounds().top;
  83. }
  84. function bottomOf(node) {
  85. return node.bounds().bottom;
  86. }
  87. /** 按"文案或描述命中"收集可见节点 */
  88. function findByLabel(selector, predicate) {
  89. var result = [];
  90. var collection = selector.find();
  91. for (var i = 0; i < collection.size(); i += 1) {
  92. var node = collection.get(i);
  93. if (predicate(node) && layout.isVisible(node)) {
  94. result.push(node);
  95. }
  96. }
  97. return result;
  98. }
  99. // ---------------------------------------------------------------- 信息流
  100. /** 信息流右上角的搜索入口 */
  101. function searchEntry() {
  102. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  103. var point = layout.center(node);
  104. return layout.desc(node) === 'Search' && point !== null &&
  105. point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.85;
  106. });
  107. return hits.length > 0 ? hits[0] : null;
  108. }
  109. /** 信息流就绪判据:底部导航存在 */
  110. function bottomNavPresent() {
  111. return layout.findOnce(desc('Home')) !== null && layout.findOnce(desc('Profile')) !== null;
  112. }
  113. /**
  114. * 底部导航的「Profile」项 —— 用来打开**自己的**主页(脚本二要自己的粉丝列表)。
  115. *
  116. * 判据刻意很松:`desc === 'Profile'` 精确匹配 + 中心落在屏幕下缘。
  117. * 不限定 class(它是 FrameLayout,和历史记忆里"首页顶部 Following 页签是
  118. * FrameLayout"同一族,限定 Button 会一个都找不到),也不认文案 ——
  119. * 底部导航这一项没有 text。
  120. *
  121. * 为什么还要判"在下缘":右侧竖排作者头像的 desc 是 `xxx profile`(小写、
  122. * 带用户名前缀),精确匹配 `Profile` 本来就排除掉了;但万一新版把它改成
  123. * 纯 `Profile`,这一条能挡住"点到了头像"这种最坏情况。
  124. */
  125. function profileNav() {
  126. var node = layout.findOnce(desc('Profile'));
  127. if (!node) {
  128. return null;
  129. }
  130. var point = layout.center(node);
  131. if (!point || point.y < screenHeight() * 0.88) {
  132. return null;
  133. }
  134. return node;
  135. }
  136. // ---------------------------------------------------------------- 搜索页
  137. /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */
  138. function searchInput() {
  139. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  140. return topOf(node) < screenHeight() * 0.12;
  141. });
  142. return hits.length > 0 ? hits[0] : null;
  143. }
  144. function searchSubmit() {
  145. var hits = findByLabel(className('android.widget.Button'), function (node) {
  146. var point = layout.center(node);
  147. return layout.text(node) === 'Search' && point !== null &&
  148. point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.8;
  149. });
  150. return hits.length > 0 ? hits[0] : null;
  151. }
  152. /** 搜索页特征:搜索历史行(带删除按钮)或"发现"按钮 */
  153. function searchHistoryPresent() {
  154. return layout.findOnce(desc('Close')) !== null || layout.findOnce(desc('Find')) !== null;
  155. }
  156. // ---------------------------------------------------------------- 结果页
  157. /** 结果页顶部分类页签 */
  158. function resultTab(name) {
  159. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  160. return equalsAny(layout.text(node) || layout.desc(node), [name]) &&
  161. topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
  162. });
  163. if (hits.length > 0) {
  164. return hits[0];
  165. }
  166. // 页签的文案节点本身常常不可点,真正可点的是外层 FrameLayout
  167. var frames = findByLabel(className('android.widget.FrameLayout'), function (node) {
  168. return equalsAny(layout.desc(node) || layout.text(node), [name]) &&
  169. topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
  170. });
  171. return frames.length > 0 ? frames[0] : null;
  172. }
  173. function resultTabStripPresent() {
  174. var found = 0;
  175. for (var i = 0; i < RESULT_TABS.length; i += 1) {
  176. if (resultTab(RESULT_TABS[i])) {
  177. found += 1;
  178. }
  179. }
  180. return found >= 3;
  181. }
  182. /**
  183. * 从卡片文案锚点向上找"卡片容器"。
  184. * 为什么不能直接点文案:文案节点多数不可点,而它的可点祖先可能是**整屏容器**——
  185. * 点下去等于点在屏幕中间,什么也不会发生(实测踩过)。
  186. * 因此要求容器高度落在合理的卡片区间内,否则认为锚点自己就是最佳目标。
  187. */
  188. function cardContainer(anchor) {
  189. var height = screenHeight();
  190. var cursor = layout.clickableAncestor(anchor);
  191. var depth = 0;
  192. while (cursor && depth < 6) {
  193. var cardHeight = bottomOf(cursor) - topOf(cursor);
  194. /* 上限从 0.8 放宽到 1.05(2026-10-28):卡片滚动到贴着屏幕上下边缘时,
  195. * 容器会有一部分在屏幕外,`bounds()` 报的高度于是**被裁切得又大又怪**,
  196. * 实测超过 80% 后被这里挡掉 → 整排卡片变成"找不到容器" → 上层误判
  197. * "卡片用尽"。点击点由 `layout.center` 用 visibleRect 夹取,仍然安全。 */
  198. if (cardHeight >= height * 0.18 && cardHeight <= height * 1.05) {
  199. return cursor;
  200. }
  201. cursor = layout.clickableAncestor(cursor);
  202. depth += 1;
  203. }
  204. return anchor;
  205. }
  206. /**
  207. * 这个节点是不是在"结果网格"里(GridView / RecyclerView 的子孙)。
  208. *
  209. * 为什么需要:结果页上会浮出各种**不是视频卡但同样又宽又有文案**的东西 ——
  210. * 授权弹窗的 `Customize Settings` / `Confirm My Choices`、商品卡的 `Shop now`、
  211. * 「Users」分区的表头。它们能通过"宽 + 有文案"的判据,被当成卡片点开白等 20 秒。
  212. * 而真正的结果卡片一定在滚动容器里,弹窗不出现在其中。
  213. */
  214. function insideResultGrid(node) {
  215. var cursor = node;
  216. for (var depth = 0; depth < 8; depth += 1) {
  217. try {
  218. cursor = cursor.parent();
  219. } catch (ignored) {
  220. return false;
  221. }
  222. if (!cursor) {
  223. return false;
  224. }
  225. var name = '';
  226. try {
  227. name = String(cursor.className() || '');
  228. } catch (ignoredToo) {
  229. name = '';
  230. }
  231. if (name.indexOf('GridView') >= 0 || name.indexOf('RecyclerView') >= 0) {
  232. return true;
  233. }
  234. }
  235. return false;
  236. }
  237. /**
  238. * 当前页面是"照片轮播帖"吗(不是视频)?
  239. *
  240. * 真机实测(2026-10-28):照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView,
  241. * 视频帖是「`Video` · …」。照片帖**没有播放页**,`onPlayerPage()` 也认不出来,
  242. * 于是 openCard 会白等满 20 秒(playerReadyMs)再失败。
  243. *
  244. * 判据限定文案**完全等于** `Photo` 且位于屏幕下半部分(信息行),
  245. * 避免误伤说明文字里出现 Photo 的普通视频。
  246. */
  247. function photoPost() {
  248. var height = screenHeight();
  249. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  250. return (layout.text(node) || '') === 'Photo' && topOf(node) > height * 0.5;
  251. });
  252. return hits.length > 0 ? hits[0] : null;
  253. }
  254. /**
  255. * 结果网格里当前可见的卡片(按屏幕从上到下)。
  256. *
  257. * 判据(2026-10-28 用 AutoX 无障碍树真机核对过):
  258. * - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉;
  259. * - **必须在 GridView/RecyclerView 里**(排掉浮层/弹窗按钮,如 `Customize Settings`);
  260. * - **必须能找到一个"卡片尺寸"的可点容器**(`cardContainer` 找不到时会退回节点
  261. * 本身,那种情况说明它只是一个孤立按钮,不是卡片);
  262. * - 可见性看**容器**,不看文案。
  263. *
  264. * ★ 为什么不再要求"文案在屏幕内"(这次最关键的修正):
  265. * 卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 8,1242 → 536,1920+)。
  266. * 结果页上只要多一条通栏广告或一行筛选 chips,网格就被推下去,文案跟着落到
  267. * **屏幕外**(实测 y=2099 > 1920)。旧的 `bottomOf(node) <= height - 12` 于是把这些
  268. * **明明看得见、点得着**的卡片全部丢掉 —— 表现成"只处理了 1 个就说卡片用尽"。
  269. *
  270. * 正确做法:文案照读(无障碍树里它在,只是不在可视区),能不能点由**容器**决定;
  271. * 点击点由 `layout.center` 用 `visibleRect` 夹取,永远落在屏幕内。
  272. */
  273. function videoCards() {
  274. var width = screenWidth();
  275. var height = screenHeight();
  276. var gridTop = height * ROWS_TOP_RATIO; /* 页签栏底部,网格从这里开始 */
  277. var minVisible = height * 0.08; /* 露出来的太少就不点(点下去容易点到邻居) */
  278. var cards = [];
  279. var seen = {};
  280. var hits = findByLabel(className('android.widget.Button'), function (node) {
  281. return widthOf(node) >= width * 0.35 && insideResultGrid(node);
  282. });
  283. for (var i = 0; i < hits.length; i += 1) {
  284. var caption = layout.text(hits[i]) || layout.desc(hits[i]);
  285. if (!caption) {
  286. continue;
  287. }
  288. var container = cardContainer(hits[i]);
  289. if (!container || container === hits[i]) {
  290. /* 找不到卡片尺寸的可点容器 → 这是个孤立按钮(弹窗按钮、分区表头等) */
  291. continue;
  292. }
  293. var boxTop = topOf(container);
  294. var boxBottom = bottomOf(container);
  295. if (boxBottom <= gridTop) {
  296. /* 整个容器都在页签栏之上 —— 不是结果卡片 */
  297. continue;
  298. }
  299. if (boxTop >= height) {
  300. /* 完全在屏幕下方,看不见也点不着 */
  301. continue;
  302. }
  303. if (Math.min(boxBottom, height) - Math.max(boxTop, gridTop) < minVisible) {
  304. /* 只露出一条缝,点下去很容易落到相邻卡片上 */
  305. continue;
  306. }
  307. var key = caption + '@' + boxTop;
  308. if (seen[key]) {
  309. continue;
  310. }
  311. seen[key] = true;
  312. cards.push({
  313. anchor: hits[i],
  314. container: container,
  315. caption: caption,
  316. top: boxTop
  317. });
  318. }
  319. cards.sort(function (a, b) { return a.top - b.top; });
  320. return cards;
  321. }
  322. // ---------------------------------------------------------------- 播放页
  323. function commentButton() {
  324. var hits = findByLabel(className('android.widget.Button'), function (node) {
  325. return startsWithAny(layout.desc(node), COMMENT_DESC_KEYS);
  326. });
  327. return hits.length > 0 ? hits[0] : null;
  328. }
  329. function likeButton() {
  330. var hits = findByLabel(className('android.widget.Button'), function (node) {
  331. return startsWithAny(layout.desc(node), LIKE_DESC_KEYS);
  332. });
  333. return hits.length > 0 ? hits[0] : null;
  334. }
  335. /**
  336. * 「已点赞」状态的点赞按钮。
  337. *
  338. * ★ 实测(2026-10-28,TikTok 47.1.4):点过赞之后同一个 `Button` 的 desc
  339. * 从 `Like video. 2.4M likes` 变成 **`Video liked`**(不是 `Unlike`),
  340. * 而里面那个 ImageView 的 `selected` 从 false 变成 true。
  341. *
  342. * 两个判据都要留:desc 受本地化影响,selected 是状态位不受文案影响 ——
  343. * 任一命中就算已赞,比只认一个更耐改版。
  344. */
  345. function likedButton() {
  346. var hits = findByLabel(className('android.widget.Button'), function (node) {
  347. return startsWithAny(layout.desc(node), LIKED_DESC_KEYS);
  348. });
  349. return hits.length > 0 ? hits[0] : null;
  350. }
  351. /**
  352. * 点赞按钮里的图标节点(用它读 `selected`)。
  353. * 实测:未赞 selected=false,已赞 selected=true。
  354. */
  355. function likeIcon() {
  356. var button = likeButton() || likedButton();
  357. if (!button) {
  358. return null;
  359. }
  360. var icons = button.find(className('android.widget.ImageView'));
  361. return icons.size() > 0 ? icons.get(0) : null;
  362. }
  363. /** 'liked' | 'unliked' | 'unknown' */
  364. function likeState() {
  365. if (likedButton() !== null) {
  366. return 'liked';
  367. }
  368. if (likeButton() !== null) {
  369. return 'unliked';
  370. }
  371. /* 按钮本身找不到时,退回看图标状态位(有些版本用同一个 desc,只翻 selected) */
  372. var icon = null;
  373. try { icon = likeIcon(); } catch (ignored) { icon = null; }
  374. if (icon !== null && icon.selected()) {
  375. return 'liked';
  376. }
  377. return 'unknown';
  378. }
  379. /**
  380. * 播放页的「关注作者」按钮。
  381. *
  382. * 实测 desc 形如 `Follow Marcgagilang`(`Follow` + 空格 + 作者名),
  383. * 是 `android.widget.Button`。
  384. * ⚠️ 首页顶部那个 `Following` 页签是 **FrameLayout**,不是 Button,
  385. * 所以限定 className 就能把它排除掉,不必额外按坐标过滤。
  386. */
  387. function followButton() {
  388. var hits = findByLabel(className('android.widget.Button'), function (node) {
  389. var value = layout.desc(node);
  390. if (!startsWithAny(value, FOLLOW_DESC_KEYS)) {
  391. return false;
  392. }
  393. /* `Followers` 是粉丝列表的入口,不是关注按钮 —— 前缀匹配会误命中 */
  394. return value.indexOf('Followers') !== 0;
  395. });
  396. return hits.length > 0 ? hits[0] : null;
  397. }
  398. /**
  399. * 作者头像节点。用来区分「已关注(按钮消失)」和「页面还没渲染」。
  400. * 实测 desc 形如 `Marcgagilang profile`。
  401. */
  402. function authorAvatar() {
  403. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  404. var value = layout.desc(node);
  405. return value !== '' && / profile$/i.test(value);
  406. });
  407. return hits.length > 0 ? hits[0] : null;
  408. }
  409. /**
  410. * 信息流当前视频的指纹 —— **只用来判断"上滑有没有真的换视频"**。
  411. *
  412. * ★ 它**不能**当业务上的"视频身份"用(判断是不是同一个视频):
  413. * 点赞之后 `Like video. 340.2K likes` 会变成 `Video liked`,指纹必然变化。
  414. * 所以它天生只适合回答"变了没有",不适合回答"这是不是同一个"。
  415. *
  416. * 「会不会重复操作同一个视频」不靠指纹,靠状态:
  417. * · 已经点过赞 → `likeState() === 'liked'` → interact.like 自己会 skip
  418. * · 已经关注 → `followState() === 'following'` → interact.follow 自己会 skip
  419. * 那两条是地面真相,比任何拼出来的指纹都可靠。
  420. *
  421. * 信息流的树里**同时存在上/下两个视频**的节点(实测:上一个在 y=-702、
  422. * 当前在 y=1149、下一个在 y=3069),所以这里只能用已经过 `isVisible` 的
  423. * likeButton/authorAvatar/commentButton —— 它们只返回当前那个视频的节点。
  424. */
  425. function feedSignature() {
  426. var parts = [];
  427. var avatar = authorAvatar();
  428. if (avatar) {
  429. parts.push('a:' + layout.desc(avatar));
  430. }
  431. var like = likeButton() || likedButton();
  432. if (like) {
  433. parts.push('l:' + layout.desc(like));
  434. }
  435. var comment = commentButton();
  436. if (comment) {
  437. parts.push('c:' + layout.desc(comment));
  438. }
  439. return parts.join('|');
  440. }
  441. /**
  442. * 'following' | 'not-following' | 'unknown'
  443. *
  444. * ★ 「已关注」的唯一表现是**按钮整个消失**(实测),所以不能只看"找不到按钮"
  445. * —— 页面没渲染完也找不到。用作者头像是否存在来区分这两种情况:
  446. * 头像在、按钮不在 = 已关注;两个都不在 = 页面还没好。
  447. */
  448. function followState() {
  449. if (followButton() !== null) {
  450. return 'not-following';
  451. }
  452. if (authorAvatar() !== null) {
  453. return 'following';
  454. }
  455. return 'unknown';
  456. }
  457. /**
  458. * 播放页判据。
  459. *
  460. * 教训(实测踩到):**信息流首页也有 "Like video." 和 "Read or add comments" 这一竖排**,
  461. * 所以只看这两个会一路把首页判成播放页,整个流程直接跑歪。
  462. * 真正能区分"从搜索结果点进来的播放页"的是:
  463. * - 左上角有返回箭头(首页没有)
  464. * - 没有底部导航栏(首页有 Home/Friends/Inbox/Profile)
  465. * 四条同时成立才算播放页。
  466. */
  467. function onPlayerPage() {
  468. return commentButton() !== null &&
  469. likeButton() !== null &&
  470. backButton() !== null &&
  471. !bottomNavPresent();
  472. }
  473. function backButton() {
  474. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  475. return layout.desc(node) === 'Back';
  476. });
  477. return hits.length > 0 ? hits[0] : null;
  478. }
  479. /** 从 "Read or add comments. 150 comments" 里把 150 抠出来 */
  480. function commentCount() {
  481. var button = commentButton();
  482. if (!button) {
  483. return null;
  484. }
  485. var matched = layout.desc(button).match(/([\d][\d,.\s]*)\s*(?:comments?|条评论|评论)/i);
  486. if (!matched) {
  487. return null;
  488. }
  489. return parseInt(String(matched[1]).replace(/[^\d]/g, ''), 10);
  490. }
  491. /** 视频页底部的内联评论输入框(面板未打开时就是它) */
  492. function inlineInput() {
  493. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  494. return equalsAny(layout.text(node) || layout.desc(node), COMMENT_PLACEHOLDER) &&
  495. topOf(node) > screenHeight() * 0.8;
  496. });
  497. return hits.length > 0 ? hits[0] : null;
  498. }
  499. /** 评论面板里的输入框(打开面板后会出现在屏幕中部) */
  500. function panelInput() {
  501. var screenH = screenHeight();
  502. /* ① 经典位置:键盘弹起之后,输入框会被顶到屏幕中部。 */
  503. var mid = findByLabel(className('android.widget.EditText'), function (node) {
  504. var top = topOf(node);
  505. return top > screenH * 0.45 && top < screenH * 0.85;
  506. });
  507. if (mid.length > 0) {
  508. return mid[0];
  509. }
  510. /* ② 键盘**没弹起**时,输入框停在屏幕底部(实测 y=1816 @1080×1920,即 0.95H),
  511. * 完全落不进上面那个区间。
  512. *
  513. * 这是首页信息流里特有的情形:点右侧评论按钮打开面板,输入框不会自动获得
  514. * 焦点、键盘也就不弹;而播放页里点的是内联输入框,它天生带焦点、键盘必弹。
  515. * 所以只按位置判的话,**信息流里每一次都会判成"面板没打开"** ——
  516. * 而面板明明已经开了,输入框就在底部等着被点。
  517. *
  518. * 这时只能靠"评论区头部计数"来确认面板确实开着(那是面板独有的元素),
  519. * 再把 EditText 认成输入框。视频链路上这条分支不会命中(① 已经返回了)。
  520. */
  521. if (listCommentCount() !== null) {
  522. var any = findByLabel(className('android.widget.EditText'), function () {
  523. return true;
  524. });
  525. if (any.length > 0) {
  526. return any[0];
  527. }
  528. }
  529. return null;
  530. }
  531. function toolRowAnchor() {
  532. for (var i = 0; i < TOOL_ROW_ANCHORS.length; i += 1) {
  533. var node = layout.findOnce(desc(TOOL_ROW_ANCHORS[i]));
  534. if (node && layout.isVisible(node)) {
  535. return node;
  536. }
  537. }
  538. return null;
  539. }
  540. /**
  541. * 评论发送按钮。
  542. *
  543. * 它没有可用的文案(实测其 content-desc 是一个无关的账号串),图标是粉色箭头,
  544. * 所以只能靠**结构**定位:
  545. * 面板里"工具行"(表情/提及/语音那一行)的同高区域里,
  546. * 最靠右、且不属于右侧竖排动作栏(动作栏 right == 屏宽)的那个可点击按钮。
  547. *
  548. * 优先用 config.selectors.sendButtonId(若配置里给了 id),
  549. * 这样 TikTok 改版时改配置即可,不用改代码。
  550. */
  551. /**
  552. * 这个节点是不是右侧竖排动作栏(点赞/评论/收藏/分享/作者/音乐)里的按钮。
  553. *
  554. * ★ 为什么需要它(2026-10-28):评论面板一打开,输入框会上移到屏幕中部,
  555. * 正好和那六个按钮**同高**,光靠坐标完全分不开 —— 实测按"输入框右端"
  556. * 找到的是 `Follow xxx` 和 `Like video.`,点下去是关注/点赞,评论根本没发。
  557. *
  558. * 而这六个按钮每一个都有明确的 desc 前缀,发送键没有。这是唯一稳定的分界。
  559. */
  560. function isActionBarButton(node) {
  561. var value = layout.desc(node);
  562. if (!value) {
  563. return false;
  564. }
  565. for (var i = 0; i < ACTION_BAR_DESC_PREFIXES.length; i += 1) {
  566. if (value.indexOf(ACTION_BAR_DESC_PREFIXES[i]) === 0) {
  567. return true;
  568. }
  569. }
  570. return false;
  571. }
  572. /**
  573. * 按"输入框那一行"找发送按钮。
  574. *
  575. * 判据只有两条,都落在几何事实上:
  576. * ① 节点的**中心 y** 落在输入框上下边界之间(= 同一行)
  577. * ② 节点的**右边界**贴着输入框右端(或更右)
  578. *
  579. * ★ 为什么不能用"输入框下方那一行"(2026-10-28 血泪):
  580. * 面板打开后,**播放页那套右侧竖排动作栏(点赞/评论/收藏/分享)的无障碍节点
  581. * 仍然留在树里**,y 范围(实测 1218~1822)与面板区域大幅重叠。
  582. * 按"下方取最右"会稳定命中**点赞按钮**(id=`g8g`),后果是:
  583. * 面板被收起 + 视频被点了个赞,而业务侧因为"面板关了"判定成评论发送成功。
  584. * 假阳性比失败更糟 —— 用户以为发出去了。
  585. *
  586. * 而"中心 y 在输入框行内"这条能干净地排掉右侧竖排:
  587. * 点赞按钮 1218~1338 的中心是 1278,落在输入框 1115~1222 之外。
  588. *
  589. * ★ 发送键**只在键盘弹起时才存在**。所以调用方必须先让输入框聚焦
  590. * (`ui.typeInto` 里已经做了),否则这里返回 null 是正常的 ——
  591. * 那种情况会如实报"定位不到发送按钮",不会乱点。
  592. */
  593. function sendButtonOnInputRow() {
  594. var input = panelInput();
  595. if (!input) {
  596. logger.warn('comment', '没有面板输入框,无法按行定位发送按钮');
  597. return null;
  598. }
  599. var ib = input.bounds();
  600. var inputMidY = (ib.top + ib.bottom) / 2;
  601. var screenW = screenWidth();
  602. var best = null;
  603. var bestDist = null;
  604. var groups = ['android.widget.Button', 'android.widget.ImageView'];
  605. for (var g = 0; g < groups.length; g += 1) {
  606. var hits = findByLabel(className(groups[g]), function (node) {
  607. /* ★ 先排掉右侧竖排动作栏(2026-10-28 血泪):
  608. * 面板打开后输入框会上移到中部,正好和那六个按钮**同高**,
  609. * 光靠几何分不开。但它们每一个都有明确的 desc 前缀,
  610. * 而发送键没有 —— 这是唯一稳定可靠的分界。 */
  611. if (isActionBarButton(node)) {
  612. return false;
  613. }
  614. var nb = node.bounds();
  615. var midY = (nb.top + nb.bottom) / 2;
  616. if (midY < ib.top || midY > ib.bottom) {
  617. return false; // 不在输入框这一行
  618. }
  619. if (nb.right < ib.right - 60) {
  620. return false; // 不在输入框右端
  621. }
  622. if (nb.left < screenW * 0.5) {
  623. return false; // 只看右半边
  624. }
  625. if (nb.bottom - nb.top < 20 || nb.right - nb.left < 20) {
  626. return false; // 太小的装饰不收
  627. }
  628. try {
  629. return node.clickable() === true;
  630. } catch (ignored) {
  631. return false;
  632. }
  633. });
  634. /* 同一行里可能有多个(清空、表情…),取**离输入框中心最近**的那个。
  635. * 用局部变量记距离 —— 绝不能往 AutoX 的 UiObject 上挂自定义属性:
  636. * 那是 Java 宿主对象,赋值会在运行期抛异常,而且崩得很安静
  637. * (界面只看到"未开始")。 */
  638. for (var i = 0; i < hits.length; i += 1) {
  639. var hb = hits[i].bounds();
  640. var dist = Math.abs((hb.top + hb.bottom) / 2 - inputMidY);
  641. if (bestDist === null || dist < bestDist) {
  642. bestDist = dist;
  643. best = hits[i];
  644. }
  645. }
  646. if (best) {
  647. break;
  648. }
  649. }
  650. if (!best) {
  651. /* 排障:输入框这一行右侧到底有什么 */
  652. logger.warn('comment', '输入框 b=[' + ib.left + ',' + ib.top + ',' +
  653. ib.right + ',' + ib.bottom + '] 这一行的右侧没有可点按钮(键盘没弹起?)');
  654. var probes = [];
  655. try {
  656. var probeClasses = ['android.widget.Button', 'android.widget.ImageView',
  657. 'android.view.View', 'android.widget.FrameLayout'];
  658. for (var p = 0; p < probeClasses.length; p += 1) {
  659. var cands = className(probeClasses[p]).find();
  660. for (var c = 0; c < cands.size(); c += 1) {
  661. var nb2 = cands.get(c).bounds();
  662. var mid2 = (nb2.top + nb2.bottom) / 2;
  663. if (mid2 < ib.top - 40 || mid2 > ib.bottom + 40) { continue; }
  664. if (nb2.left < screenW * 0.5) { continue; }
  665. probes.push(String(probeClasses[p]).split('.').pop() +
  666. '[' + nb2.left + ',' + nb2.top + ',' + nb2.right + ',' + nb2.bottom +
  667. ']' + (isActionBarButton(cands.get(c)) ? '(动作栏)' : ''));
  668. }
  669. }
  670. } catch (ignoredProbe) { }
  671. logger.warn('comment', '这一行右侧实际有:' + (probes.join(' ') || '(空)'));
  672. return null;
  673. }
  674. logger.info('comment', '发送按钮:按输入框这一行定位 id=' + layout.shortId(best) +
  675. ' desc=' + JSON.stringify(layout.desc(best)) +
  676. ' b=' + JSON.stringify(best.bounds()));
  677. return best;
  678. }
  679. function sendButton(config) {
  680. var override = config && config.selectors && config.selectors.sendButtonId;
  681. if (override) {
  682. var byId = layout.findOnce(id(override));
  683. if (byId && layout.isVisible(byId)) {
  684. logger.info('comment', '发送按钮:使用配置 id ' + override);
  685. return byId;
  686. }
  687. }
  688. /* 先用几何判据(输入框那一行 + 右端)—— 它对"工具行 desc 变成 @资源id"
  689. * 和"右侧动作栏节点残留在树里"这两个新版变化都免疫。 */
  690. var rowButton = sendButtonOnInputRow();
  691. if (rowButton) {
  692. return rowButton;
  693. }
  694. var anchor = toolRowAnchor();
  695. if (!anchor) {
  696. return null;
  697. }
  698. var bandTop = topOf(anchor);
  699. var bandBottom = bottomOf(anchor);
  700. var width = screenWidth();
  701. var best = null;
  702. var hits = findByLabel(className('android.widget.Button'), function (node) {
  703. var point = layout.center(node);
  704. if (point === null) {
  705. return false;
  706. }
  707. var box = node.bounds();
  708. if (box.top < bandTop || box.top > bandBottom) {
  709. return false; // 不在工具行高度带里
  710. }
  711. if (box.right > width - layout.SAFE_MARGIN) {
  712. return false; // 属于右侧竖排动作栏
  713. }
  714. return box.left >= width * 0.5; // 必须是右半边
  715. });
  716. for (var i = 0; i < hits.length; i += 1) {
  717. if (best === null || hits[i].bounds().left > best.bounds().left) {
  718. best = hits[i];
  719. }
  720. }
  721. if (best) {
  722. logger.info('comment', '发送按钮:结构化定位 id=' +
  723. layout.shortId(best) + ' b=' + JSON.stringify(best.bounds()));
  724. }
  725. return best;
  726. }
  727. // ---------------------------------------------------------------- 评论列表
  728. /**
  729. * 评论列表面板顶部的计数,形如 "150 comments"。
  730. *
  731. * 为什么需要它:实测发现**视频页那个评论角标不一定会立刻刷新**
  732. * (发完评论后仍读回旧值),而评论列表头部的计数是即时的。
  733. * 所以"计数自增"这条证据要用列表头部的那个,而不是角标的。
  734. */
  735. /**
  736. * 从评论区头部的文案里取出评论数。**两种格式都要认**:
  737. *
  738. * · 旧版:`150 comments` / `1,203 comments` —— 数字在前
  739. * · 新版:`Comments 785` —— 数字在后,而且与页签名在**同一个 TextView** 里
  740. * (2026-10 实测:评论区拆成了 `Comments` / `Reviews` 两个页签)
  741. *
  742. * ★ 只认旧格式的后果非常隐蔽:首页信息流里点开评论面板后,
  743. * `listCommentCount()` 返回 null → `panelInput()` 的第二条分支进不去 →
  744. * 整个面板被判成"没打开",而屏幕上明明已经有评论列表和输入框。
  745. * 日志里只会看到一句"面板没打开(该视频可能关闭了评论)",完全是误导。
  746. */
  747. function parseCommentCountLabel(value) {
  748. var text = String(value || '').replace(/^\s+|\s+$/g, '');
  749. var newer = text.match(/^Comments?\s+([\d][\d,.]*)$/i);
  750. if (newer) {
  751. return parseInt(String(newer[1]).replace(/[^\d]/g, ''), 10);
  752. }
  753. var legacy = text.match(/^([\d][\d,.]*)\s*(?:comments?|条评论)$/i);
  754. if (legacy) {
  755. return parseInt(String(legacy[1]).replace(/[^\d]/g, ''), 10);
  756. }
  757. return null;
  758. }
  759. function listCommentCount() {
  760. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  761. return parseCommentCountLabel(layout.text(node)) !== null;
  762. });
  763. if (hits.length === 0) {
  764. return null;
  765. }
  766. return parseCommentCountLabel(layout.text(hits[0]));
  767. }
  768. /**
  769. * 评论面板是否处于打开状态。
  770. * 注意:点内联输入框打开的是"快捷面板"(有中部输入框),
  771. * 点评论角标打开的是"列表面板"(没有中部输入框,但有 "N comments" 头部)。
  772. * 两种都要认,否则 back() 会漏按、面板关不掉。
  773. */
  774. function commentPanelOpen() {
  775. return panelInput() !== null || listCommentCount() !== null;
  776. }
  777. /**
  778. * 评论列表里是否出现了某条完全一致的文案。
  779. *
  780. * 这里刻意**不设数量上限**:早先为了省事写成"只看前 25 条可见文本",
  781. * 结果遍历顺序是"先视频页、后评论面板",25 条全被视频页的文案占满了,
  782. * 评论条目根本没轮到,于是最强的那条证据永远拿不到。
  783. * 可见 TextView 也就几百个,全扫一遍的开销远小于"判错"的代价。
  784. */
  785. function commentListContains(value) {
  786. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  787. return layout.text(node) === value;
  788. });
  789. return hits.length > 0;
  790. }
  791. /** 当前屏幕上所有可见的文本文案(按屏幕顺序) */
  792. function visibleTexts(limit) {
  793. var hits = findByLabel(className('android.widget.TextView'), function () { return true; });
  794. var texts = [];
  795. for (var i = 0; i < hits.length; i += 1) {
  796. var value = layout.text(hits[i]);
  797. if (value && value.length >= 2) {
  798. texts.push(value);
  799. }
  800. if (limit && texts.length >= limit) {
  801. break;
  802. }
  803. }
  804. return texts;
  805. }
  806. // ============================================================ ID 提取
  807. /*
  808. * 下面这一整段是 2026-10-20 在真机上逐页 dump 出来的锚点
  809. * (Pixel 5a / 1080x1920 / TikTok 47.1.4 / 英文界面)。**每条都是实测值**,
  810. * 不是猜的 —— 猜出来的选择器在这个项目里从来没有一次是对的。
  811. *
  812. * ★ 一个重要发现:这一片的**资源 id 是语义化的**(`tv_username` / `txt_desc` /
  813. * `user_avatar`),与信息流那批混淆 id(`e4g`/`cn6`)完全不同。
  814. * 原因是这些页面来自 TikTok 的搜索/社交业务模块,用的是另一套布局。
  815. * 所以这里优先按 id 定位,文案只作为兜底(改版时 id 比文案更稳)。
  816. *
  817. * 实测到的三处文本陷阱:
  818. * ① 用户名被 **FID 隔离符** 包裹:显示 `⁨ajman_fashion_week⁩`(U+2068/U+2069),
  819. * 肉眼看不见,`layout.sanitize` 也不剥(那不是零宽字符)→ 统一走 `parse.username`;
  820. * ② 搜索用户列表里 **`tv_aweme_id` 装的是昵称**(id 名违反直觉),`tv_username` 才是 uid;
  821. * ③ 粉丝列表与搜索列表的同义控件**id 不同**:
  822. * 搜索列表 `tv_username`/`tv_desc`,粉丝列表 `txt_desc`(uid)/`txt_user_name`(昵称)。
  823. */
  824. /** 是否在用户主页:有一个文案形如 "@xxx" 的可点 Button,且下面跟着三组数字 */
  825. function profileUsernameNode() {
  826. var hits = findByLabel(className('android.widget.Button'), function (node) {
  827. var value = layout.text(node);
  828. return value.charAt(0) === '@' && value.length > 1 &&
  829. value.length < 40 && topOf(node) < screenHeight() * 0.3;
  830. });
  831. return hits.length > 0 ? hits[0] : null;
  832. }
  833. /** 用户主页的昵称(在 @xxx 上面那一行) */
  834. function profileNicknameNode() {
  835. var userNode = profileUsernameNode();
  836. if (!userNode) {
  837. return null;
  838. }
  839. var userTop = topOf(userNode);
  840. var hits = findByLabel(className('android.widget.Button'), function (node) {
  841. var box = node.bounds();
  842. return box.top < userTop && box.bottom > userTop - screenHeight() * 0.12 &&
  843. layout.text(node) !== '' && layout.text(node).charAt(0) !== '@';
  844. });
  845. return hits.length > 0 ? hits[0] : null;
  846. }
  847. /** 用户页顶部三组数字的标签文案。英文界面实测为这三个词。 */
  848. var STAT_LABELS = {
  849. 'Following': 'follow',
  850. 'Followers': 'fans',
  851. 'Likes': 'likes',
  852. '关注': 'follow',
  853. '粉丝': 'fans',
  854. '获赞': 'likes',
  855. 'Abonnements': 'follow',
  856. 'Abonnés': 'fans',
  857. "J'aime": 'likes'
  858. };
  859. /**
  860. * 用户页顶部三组数字:`{ follow: 343, fans: 594, likes: 2491 }`。
  861. *
  862. * 结构(实测):每个数字是一个 LinearLayout,里面两个 TextView ——
  863. * 数字在上、标签在下。所以按"标签找容器、再取容器里的数字"读。
  864. * 注意**没有视频数**:这个页面只显示 关注/粉丝/获赞 三组,这是平台行为。
  865. */
  866. function statNumbers() {
  867. var out = { follow: null, fans: null, likes: null };
  868. var parse = TikTokAuto.parse;
  869. var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
  870. for (var i = 0; i < labels.length; i += 1) {
  871. var labelText = layout.text(labels[i]);
  872. var key = STAT_LABELS[labelText];
  873. if (!key) {
  874. continue;
  875. }
  876. /* 标签必须在页面上部(三组数字区),否则可能是别处长文本里恰好含这个词 */
  877. if (topOf(labels[i]) > screenHeight() * 0.25) {
  878. continue;
  879. }
  880. var container = parentOf(labels[i]);
  881. var value = container ? numberInside(container) : null;
  882. if (value === null) {
  883. value = parse.count(labelText);
  884. }
  885. if (out[key] === null) {
  886. out[key] = value;
  887. }
  888. }
  889. return out;
  890. }
  891. function parentOf(node) {
  892. try {
  893. return node.parent();
  894. } catch (ignored) {
  895. return null;
  896. }
  897. }
  898. /** 在一个容器里找那个"纯数字"的 TextView,解析成整数 */
  899. function numberInside(container) {
  900. var parse = TikTokAuto.parse;
  901. var found = null;
  902. var walk = [container];
  903. var guard = 0;
  904. while (walk.length > 0 && guard < 30) {
  905. guard += 1;
  906. var node = walk.shift();
  907. if (!node) {
  908. continue;
  909. }
  910. var value = layout.text(node);
  911. if (value !== '' && /^[\d,.\s]+[kKmMbBwW万萬亿億]?$/.test(value)) {
  912. var parsed = parse.count(value);
  913. if (parsed !== null && (found === null || parsed > found)) {
  914. found = parsed;
  915. }
  916. }
  917. var count = 0;
  918. try { count = node.childCount(); } catch (ignored) { count = 0; }
  919. for (var i = 0; i < count; i += 1) {
  920. try { walk.push(node.child(i)); } catch (ignoredToo) { /* 遍历中失效就跳过 */ }
  921. }
  922. }
  923. return found;
  924. }
  925. /** 用户主页的个签:页面中上部一段较长的可点文本(实测是个 Button,不是 TextView) */
  926. function profileSignatureNode() {
  927. var hits = findByLabel(className('android.widget.Button'), function (node) {
  928. var value = layout.text(node);
  929. var box = node.bounds();
  930. return value.length >= 4 && value.charAt(0) !== '@' &&
  931. box.top > screenHeight() * 0.22 && box.top < screenHeight() * 0.34 &&
  932. (box.right - box.left) > screenWidth() * 0.4;
  933. });
  934. return hits.length > 0 ? hits[0] : null;
  935. }
  936. /**
  937. * 私密账号提示。
  938. * 实测:私密账号的主页会在视频区位置显示一行提示("This account is private" 等)。
  939. * 这里只在**视频网格明显不存在**时才认这条,避免把正常账号的某条视频文案误判成提示。
  940. */
  941. function privateHint() {
  942. var keys = ['private', "isn't available", 'no videos', 'No videos'];
  943. var texts = visibleTexts(0);
  944. for (var i = 0; i < texts.length; i += 1) {
  945. for (var j = 0; j < keys.length; j += 1) {
  946. if (String(texts[i]).toLowerCase().indexOf(String(keys[j]).toLowerCase()) >= 0) {
  947. return texts[i];
  948. }
  949. }
  950. }
  951. return '';
  952. }
  953. /** 用户主页的视频网格格子数(判断"有没有内容"用,不用于视频量门槛) */
  954. function videoGridCount() {
  955. var hits = findByLabel(className('android.widget.FrameLayout'), function (node) {
  956. var point = layout.center(node);
  957. return point !== null && point.y > screenHeight() * 0.33;
  958. });
  959. return hits.length;
  960. }
  961. /** 用户主页上的「关注/粉丝」入口(点它打开列表页) */
  962. function statEntry(which) {
  963. var wanted = which === 'following' ? 'follow' : 'fans';
  964. var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
  965. for (var i = 0; i < labels.length; i += 1) {
  966. if (STAT_LABELS[layout.text(labels[i])] !== wanted) {
  967. continue;
  968. }
  969. if (topOf(labels[i]) > screenHeight() * 0.25) {
  970. continue;
  971. }
  972. /* 可点的是外层容器,标签自己不可点 */
  973. return layout.clickableAncestor(labels[i]) || parentOf(labels[i]);
  974. }
  975. return null;
  976. }
  977. /**
  978. * 粉丝/关注列表页的页签("Following 343" / "Followers 594" / "Suggested")。
  979. *
  980. * 实测这个列表是**从用户页点进来的一张覆盖页**,顶部有三个页签,
  981. * 可以直接在两个页签之间切换 —— 所以"先收粉丝再收关注"不必来回退。
  982. */
  983. function userListTabs() {
  984. var out = [];
  985. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  986. var value = layout.text(node);
  987. return /^(Following|Followers|Suggested|关注|粉丝|推荐)\b/.test(value) &&
  988. topOf(node) < screenHeight() * 0.16;
  989. });
  990. for (var i = 0; i < hits.length; i += 1) {
  991. var value = layout.text(hits[i]);
  992. var key = value.indexOf('Follower') === 0 || value.indexOf('粉丝') === 0 ? 'fans'
  993. : (value.indexOf('Suggested') === 0 || value.indexOf('推荐') === 0 ? 'suggested' : 'following');
  994. out.push({
  995. key: key,
  996. label: value,
  997. node: layout.clickableAncestor(hits[i]) || hits[i]
  998. });
  999. }
  1000. return out;
  1001. }
  1002. /** 当前列表页选中的是哪个页签(用 selected 属性判,实测可用) */
  1003. function selectedListTab() {
  1004. var tabs = userListTabs();
  1005. for (var i = 0; i < tabs.length; i += 1) {
  1006. var selected = false;
  1007. try {
  1008. selected = tabs[i].node.selected() === true || tabs[i].node.isSelected() === true;
  1009. } catch (ignored) {
  1010. selected = false;
  1011. }
  1012. if (selected) {
  1013. return tabs[i].key;
  1014. }
  1015. }
  1016. /* selected 读不到时退回文案判断:选中态的页签文案与未选中态无差异,
  1017. * 所以这里只能返回 unknown,由调用方用"点完再看内容变没变"确认。 */
  1018. return 'unknown';
  1019. }
  1020. /**
  1021. * 粉丝/关注列表里的每一行。
  1022. *
  1023. * 实测结构:`LinearLayout`(整行可点)> `TextView txt_user_name`(昵称)
  1024. * + `TextView txt_desc`(**uid**)+ `Button ubp`(Follow)。
  1025. * 注意与搜索用户列表**id 不同**(那边是 tv_username/tv_desc)。
  1026. *
  1027. * 返回的 `node` 是行容器,点它进用户页;`x` 是"安全点击的横向位置"——
  1028. * 靠左,避开右侧的 Follow 按钮(实测按钮左边界 x≈872)。
  1029. */
  1030. function userListRows() {
  1031. var parse = TikTokAuto.parse;
  1032. var rows = [];
  1033. var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
  1034. return resourceIdOf(node).indexOf('txt_desc') >= 0 && layout.text(node) !== '';
  1035. });
  1036. /* 有的版本 uid 放在 txt_user_name、昵称放在 txt_desc,所以按"哪个像 uid"再兜一层 */
  1037. if (uidNodes.length === 0) {
  1038. uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
  1039. return resourceIdOf(node).indexOf('txt_user_name') >= 0 &&
  1040. parse.looksLikeUsername(layout.text(node));
  1041. });
  1042. }
  1043. for (var i = 0; i < uidNodes.length; i += 1) {
  1044. var uidNode = uidNodes[i];
  1045. var uid = parse.username(layout.text(uidNode));
  1046. if (!parse.looksLikeUsername(uid)) {
  1047. continue;
  1048. }
  1049. var row = findRowContainer(uidNode);
  1050. var box = row ? row.bounds() : uidNode.bounds();
  1051. rows.push({
  1052. uid: uid,
  1053. nickname: nicknameOf(uidNode, row),
  1054. node: row || uidNode,
  1055. top: box.top,
  1056. x: Math.round(box.left + (box.right - box.left) * 0.3),
  1057. y: Math.round((box.top + box.bottom) / 2)
  1058. });
  1059. }
  1060. rows.sort(function (a, b) { return a.top - b.top; });
  1061. return rows;
  1062. }
  1063. function resourceIdOf(node) {
  1064. return layout.resourceId(node);
  1065. }
  1066. /** 从 uid 文本往上找"整行"容器(高度落在一行的高度区间内) */
  1067. function findRowContainer(uidNode) {
  1068. var cursor = uidNode;
  1069. var depth = 0;
  1070. while (cursor && depth < 5) {
  1071. var box = cursor.bounds();
  1072. var rowHeight = box.bottom - box.top;
  1073. if (layout.isClickable(cursor) && rowHeight >= screenHeight() * 0.05 &&
  1074. rowHeight <= screenHeight() * 0.12) {
  1075. return cursor;
  1076. }
  1077. cursor = parentOf(cursor);
  1078. depth += 1;
  1079. }
  1080. return layout.clickableAncestor(uidNode) || parentOf(uidNode);
  1081. }
  1082. /** 行里的昵称:找同一容器内的另一个 TextView(不是 uid 那个) */
  1083. function nicknameOf(uidNode, row) {
  1084. if (!row) {
  1085. return '';
  1086. }
  1087. var found = '';
  1088. var walk = [row];
  1089. var guard = 0;
  1090. while (walk.length > 0 && guard < 40) {
  1091. guard += 1;
  1092. var node = walk.shift();
  1093. if (!node) {
  1094. continue;
  1095. }
  1096. var id = resourceIdOf(node);
  1097. var value = layout.text(node);
  1098. if (value !== '' && (id.indexOf('txt_user_name') >= 0 || id.indexOf('tv_aweme_id') >= 0)) {
  1099. found = value;
  1100. break;
  1101. }
  1102. var count = 0;
  1103. try { count = node.childCount(); } catch (ignored) { count = 0; }
  1104. for (var i = 0; i < count; i += 1) {
  1105. try { walk.push(node.child(i)); } catch (ignoredToo) { /* 跳过 */ }
  1106. }
  1107. }
  1108. return found;
  1109. }
  1110. /**
  1111. * 搜索结果的「用户」列表行。
  1112. *
  1113. * 实测结构:`Button`(整行可点)> `TextView tv_username`(uid)
  1114. * + `TextView tv_aweme_id`(**昵称**,id 名违反直觉)+ `TextView tv_desc`("594 followers · 2.5K likes")。
  1115. *
  1116. * 额外好处:`tv_desc` 里就有粉丝数与获赞数 —— 所以按数量筛选时
  1117. * **这一条来源不必逐个打开用户页**(其余来源没有这个信息)。
  1118. */
  1119. function usersResultRows() {
  1120. var parse = TikTokAuto.parse;
  1121. var rows = [];
  1122. var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
  1123. return resourceIdOf(node).indexOf('tv_username') >= 0 && layout.text(node) !== '';
  1124. });
  1125. for (var i = 0; i < uidNodes.length; i += 1) {
  1126. var uid = parse.username(layout.text(uidNodes[i]));
  1127. if (!parse.looksLikeUsername(uid)) {
  1128. continue;
  1129. }
  1130. var box = uidNodes[i].bounds();
  1131. var desc = '';
  1132. var nickname = '';
  1133. var rowNode = null;
  1134. /* 向上两层找行容器,再在容器里读昵称与计数 */
  1135. var cursor = uidNodes[i];
  1136. for (var up = 0; up < 4 && cursor; up += 1) {
  1137. cursor = parentOf(cursor);
  1138. if (cursor && layout.isClickable(cursor)) {
  1139. rowNode = cursor;
  1140. break;
  1141. }
  1142. }
  1143. if (rowNode) {
  1144. var children = findByLabelInside(rowNode, className('android.widget.TextView'));
  1145. for (var j = 0; j < children.length; j += 1) {
  1146. var id = resourceIdOf(children[j]);
  1147. var value = layout.text(children[j]);
  1148. if (id.indexOf('tv_aweme_id') >= 0) {
  1149. nickname = value;
  1150. } else if (id.indexOf('tv_desc') >= 0) {
  1151. desc = value;
  1152. }
  1153. }
  1154. var rowBox = rowNode.bounds();
  1155. box = { left: rowBox.left, top: rowBox.top, right: rowBox.right, bottom: rowBox.bottom };
  1156. }
  1157. rows.push({
  1158. uid: uid,
  1159. nickname: nickname,
  1160. node: rowNode || uidNodes[i],
  1161. top: box.top,
  1162. fans: parseFollowerLine(desc, 'followers'),
  1163. likes: parseFollowerLine(desc, 'likes'),
  1164. x: Math.round(box.left + (box.right - box.left) * 0.3),
  1165. y: Math.round((box.top + box.bottom) / 2)
  1166. });
  1167. }
  1168. rows.sort(function (a, b) { return a.top - b.top; });
  1169. return rows;
  1170. }
  1171. /** 在一个节点内部找符合条件的子孙(不含自身之外的范围) */
  1172. function findByLabelInside(rootNode, selector) {
  1173. var out = [];
  1174. var collection = selector.find();
  1175. for (var i = 0; i < collection.size(); i += 1) {
  1176. var node = collection.get(i);
  1177. var cursor = node;
  1178. var depth = 0;
  1179. var inside = false;
  1180. while (cursor && depth < 12) {
  1181. if (cursor === rootNode) {
  1182. inside = true;
  1183. break;
  1184. }
  1185. cursor = parentOf(cursor);
  1186. depth += 1;
  1187. }
  1188. if (inside) {
  1189. out.push(node);
  1190. }
  1191. }
  1192. return out;
  1193. }
  1194. /**
  1195. * 从 "594 followers · 2.5K likes" 里分别抠出两个数。
  1196. * 分隔符实测是 " · "(中点),但不同语言/版本可能不同,所以按**关键词**切而不是按分隔符。
  1197. */
  1198. function parseFollowerLine(text, which) {
  1199. var parse = TikTokAuto.parse;
  1200. if (!text) {
  1201. return null;
  1202. }
  1203. var parts = String(text).split(/[·•|]/);
  1204. for (var i = 0; i < parts.length; i += 1) {
  1205. var part = parts[i];
  1206. var lower = part.toLowerCase();
  1207. if (which === 'followers' && (lower.indexOf('follower') >= 0 || lower.indexOf('粉丝') >= 0)) {
  1208. return parse.count(part);
  1209. }
  1210. if (which === 'likes' && (lower.indexOf('like') >= 0 || lower.indexOf('获赞') >= 0)) {
  1211. return parse.count(part);
  1212. }
  1213. }
  1214. return null;
  1215. }
  1216. /** 播放页底部的作者 uid(实测:`Button l2m` 的 text = "@2131823375") */
  1217. function playerAuthorUid() {
  1218. var parse = TikTokAuto.parse;
  1219. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1220. var value = layout.text(node);
  1221. return value.charAt(0) === '@' && parse.looksLikeUsername(value);
  1222. });
  1223. return hits.length > 0 ? parse.username(layout.text(hits[0])) : '';
  1224. }
  1225. /**
  1226. * 评论区里的每一条:**只有昵称,没有 uid**(实测 2026-10-20)。
  1227. *
  1228. * 所以"从评论区取评论者"必须逐个点头像/昵称进用户页 —— 成本比列表高一个数量级。
  1229. * 这里返回每个人可点的节点(头像 ImageView `blm`)与昵称,供上层决定怎么进。
  1230. */
  1231. function commentRowers() {
  1232. var out = [];
  1233. var avatars = findByLabel(className('android.widget.ImageView'), function (node) {
  1234. return resourceIdOf(node).indexOf('blm') >= 0;
  1235. });
  1236. for (var i = 0; i < avatars.length; i += 1) {
  1237. var box = avatars[i].bounds();
  1238. /* 头像必须在评论列表区域内(屏幕中下部),不是视频右上角的作者头像 */
  1239. if (box.top < screenHeight() * 0.38) {
  1240. continue;
  1241. }
  1242. out.push({
  1243. node: avatars[i],
  1244. top: box.top,
  1245. x: Math.round((box.left + box.right) / 2),
  1246. y: Math.round((box.top + box.bottom) / 2)
  1247. });
  1248. }
  1249. out.sort(function (a, b) { return a.top - b.top; });
  1250. return out;
  1251. }
  1252. /** 结果页的某个页签当前是否选中(Users 页签用) */
  1253. function resultTabSelected(name) {
  1254. var node = resultTab(name);
  1255. if (!node) {
  1256. return false;
  1257. }
  1258. try {
  1259. return node.selected() === true || node.isSelected() === true;
  1260. } catch (ignored) {
  1261. return false;
  1262. }
  1263. }
  1264. /**
  1265. * ID 提取这条链路自己在用的页面判定。
  1266. *
  1267. * **刻意与 pageState() 分开**:pageState 是视频链路的判据,
  1268. * 往里加分支就有可能把"播放页 vs 结果页"的老判定带歪(方案 §12 明确要求回归)。
  1269. * 新开一个函数,视频链路一行都不用动。
  1270. *
  1271. * @returns 'profile' | 'user-list' | 'users-results' | 'comment-panel' | 'search' | 'feed' | 'other'
  1272. */
  1273. function idPageState() {
  1274. if (commentPanelOpen()) {
  1275. return 'comment-panel';
  1276. }
  1277. if (profileUsernameNode() !== null) {
  1278. return 'profile';
  1279. }
  1280. if (userListTabs().length >= 2 && userListRows().length > 0) {
  1281. return 'user-list';
  1282. }
  1283. if (usersResultRows().length > 0) {
  1284. return 'users-results';
  1285. }
  1286. if (searchInput() !== null) {
  1287. return 'search';
  1288. }
  1289. if (bottomNavPresent()) {
  1290. return 'feed';
  1291. }
  1292. return 'other';
  1293. }
  1294. // ---------------------------------------------------------------- 页面状态
  1295. /**
  1296. * 判定当前处于哪个页面。
  1297. * 顺序即优先级:播放页最特征化先判,信息流最宽松最后判。
  1298. */
  1299. function pageState() {
  1300. // 评论面板要最先判:面板一盖上来,播放页的返回箭头就被遮住了,
  1301. // 于是 onPlayerPage() 会变 false,整个页面掉进 unknown 再也出不来
  1302. // (实测踩到:脚本自己打开评论列表校验后没关掉,下一轮就彻底迷路)。
  1303. if (commentPanelOpen()) {
  1304. return 'comment-panel';
  1305. }
  1306. if (onPlayerPage()) {
  1307. return 'player';
  1308. }
  1309. if (resultTabStripPresent()) {
  1310. return 'results';
  1311. }
  1312. // 搜索页判据:顶部有输入框,且**没有**结果页的页签条。
  1313. // 早先还要求存在搜索历史行,结果"历史为空 / 已被清空"时整页被判成 unknown,
  1314. // 于是 ensureFeed 一路僵住。页签条的有无已经足够区分搜索页和结果页。
  1315. if (searchInput() !== null && !resultTabStripPresent() && !bottomNavPresent()) {
  1316. return 'search';
  1317. }
  1318. if (searchEntry() !== null && bottomNavPresent()) {
  1319. return 'feed';
  1320. }
  1321. return 'unknown';
  1322. }
  1323. /** 排障用:把当前可见的关键节点打出来 */
  1324. function describeScreen(tag) {
  1325. var nodes = [];
  1326. var classes = ['android.widget.Button', 'android.widget.EditText', 'android.widget.ImageView', 'android.widget.TextView'];
  1327. for (var i = 0; i < classes.length; i += 1) {
  1328. var collection = className(classes[i]).find();
  1329. for (var j = 0; j < collection.size(); j += 1) {
  1330. if (layout.isVisible(collection.get(j))) {
  1331. nodes.push(collection.get(j));
  1332. }
  1333. }
  1334. }
  1335. logger.info(tag || 'screen', layout.describe(nodes, 40));
  1336. }
  1337. return {
  1338. PKG: PKG,
  1339. pageState: pageState,
  1340. searchEntry: searchEntry,
  1341. searchInput: searchInput,
  1342. searchSubmit: searchSubmit,
  1343. searchHistoryPresent: searchHistoryPresent,
  1344. bottomNavPresent: bottomNavPresent,
  1345. profileNav: profileNav,
  1346. resultTab: resultTab,
  1347. resultTabStripPresent: resultTabStripPresent,
  1348. videoCards: videoCards,
  1349. commentButton: commentButton,
  1350. likeButton: likeButton,
  1351. likedButton: likedButton,
  1352. likeIcon: likeIcon,
  1353. likeState: likeState,
  1354. followButton: followButton,
  1355. authorAvatar: authorAvatar,
  1356. feedSignature: feedSignature,
  1357. parseCommentCountLabel: parseCommentCountLabel,
  1358. followState: followState,
  1359. onPlayerPage: onPlayerPage,
  1360. photoPost: photoPost,
  1361. backButton: backButton,
  1362. commentCount: commentCount,
  1363. inlineInput: inlineInput,
  1364. panelInput: panelInput,
  1365. toolRowAnchor: toolRowAnchor,
  1366. sendButton: sendButton,
  1367. listCommentCount: listCommentCount,
  1368. commentPanelOpen: commentPanelOpen,
  1369. commentListContains: commentListContains,
  1370. visibleTexts: visibleTexts,
  1371. describeScreen: describeScreen,
  1372. /* —— ID 提取(2026-10-20 真机实测锚点,见上面的长注释) —— */
  1373. profileUsernameNode: profileUsernameNode,
  1374. profileNicknameNode: profileNicknameNode,
  1375. profileSignatureNode: profileSignatureNode,
  1376. statNumbers: statNumbers,
  1377. statEntry: statEntry,
  1378. privateHint: privateHint,
  1379. videoGridCount: videoGridCount,
  1380. userListTabs: userListTabs,
  1381. selectedListTab: selectedListTab,
  1382. userListRows: userListRows,
  1383. usersResultRows: usersResultRows,
  1384. playerAuthorUid: playerAuthorUid,
  1385. commentRowers: commentRowers,
  1386. resultTabSelected: resultTabSelected,
  1387. idPageState: idPageState
  1388. };
  1389. }());