comment.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. /**
  2. * comment.js — 评论:挑文案 → 打开面板 → 输入 → 发送 → **验证真的发出去了**
  3. *
  4. * 关键设计:发送后必须**验证**,而且要"多条证据同时成立"才敢说成功。
  5. * 只看着"面板关掉了"就宣布成功,是这类脚本最典型的假阳性来源
  6. * (面板也可能因为别的原因关掉,而评论被平台拒了)。
  7. *
  8. * 实测有效的两条证据:
  9. * 1. 评论计数自增: "Read or add comments. 149 comments" → "150 comments"
  10. * 2. 输入框被清空并恢复占位符 "Add comment...",且面板收起
  11. * 默认以"计数自增"为强证据;计数有延迟时退回到第 2 条(可用
  12. * comments.strictPostCheck 强制只认第 1 条)。
  13. *
  14. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait、TikTokAuto.tiktok
  15. */
  16. TikTokAuto.comment = (function () {
  17. 'use strict';
  18. var PLACEHOLDER = 'Add comment...';
  19. /** 从评论池里随机挑一条,尽量不重复(池子用完了就允许重复并告警) */
  20. function pickText(config, used, step) {
  21. var pool = config.comments.pool;
  22. var candidates = [];
  23. for (var i = 0; i < pool.length; i += 1) {
  24. if (!config.comments.avoidRepeatWithinRun || used.indexOf(pool[i]) === -1) {
  25. candidates.push(pool[i]);
  26. }
  27. }
  28. if (candidates.length === 0) {
  29. TikTokAuto.logger.warn(step || 'comment', '评论池已用尽,本轮允许重复');
  30. candidates = pool;
  31. }
  32. return candidates[Math.floor(Math.random() * candidates.length)];
  33. }
  34. /** 打开评论面板:点内联输入框 / 点评论按钮 → 等面板输入框出现 */
  35. function openPanel(config, step) {
  36. var story = step || 'openCommentPanel';
  37. var logger = TikTokAuto.logger;
  38. var tiktok = TikTokAuto.tiktok;
  39. if (tiktok.panelInput() !== null) {
  40. logger.info(story, '评论面板已经是打开状态');
  41. return true;
  42. }
  43. /* 两个入口,按页面形态选:
  44. * · 播放页(从搜索结果/主页点进来的)底部有一条内联输入框 "Add comment..."
  45. * · **首页信息流底部是导航栏**(Home/Friends/Inbox/Profile),没有内联输入框
  46. * —— 只能点右侧那个 "Read or add comments. N comments" 按钮。
  47. * 以前只有第一种,于是养号在信息流里发评论会直接报"找不到评论入口"。
  48. */
  49. var entry = tiktok.inlineInput();
  50. var via = 'inline-input';
  51. if (!entry) {
  52. entry = tiktok.commentButton();
  53. via = 'comment-button';
  54. }
  55. if (!entry) {
  56. logger.error(story, '找不到评论入口(内联输入框与评论按钮都没有)');
  57. return false;
  58. }
  59. logger.info(story, '打开评论面板(入口=' + via + ')');
  60. var ready = false;
  61. if (via === 'comment-button') {
  62. ready = openByCommentButton(config, entry, story);
  63. if (!ready) {
  64. /* 换一种点击手段再试 —— 入口节点可能已经失效(页面重排过) */
  65. entry = tiktok.commentButton();
  66. if (entry) {
  67. TikTokAuto.ui.clickNode(entry, story);
  68. }
  69. }
  70. } else {
  71. TikTokAuto.ui.clickNode(entry, story);
  72. }
  73. if (ready) {
  74. return true;
  75. }
  76. var opened = TikTokAuto.wait.waitFor(story, function () {
  77. return tiktok.panelInput() !== null ? true : null;
  78. }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
  79. if (!opened) {
  80. /* 把当时所有输入框的位置记下来。"点了没反应"和"面板开了但认不出输入框"
  81. * 是两种完全不同的故障,而在日志里它们长得一模一样 —— 没有这行就只能靠猜。 */
  82. var seen = [];
  83. try {
  84. var ets = className('android.widget.EditText').find();
  85. for (var e = 0; e < ets.size(); e += 1) {
  86. var box = ets.get(e).bounds();
  87. seen.push(box.top + '~' + box.bottom);
  88. }
  89. logger.warn(story, '面板没打开;此刻 EditText ' + ets.size() +
  90. ' 个 @ ' + seen.join(', ') + '(判据区间 ' +
  91. Math.floor(TikTokAuto.layout.screen().height * 0.45) + '~' +
  92. Math.floor(TikTokAuto.layout.screen().height * 0.85) + ')');
  93. } catch (ignored) {
  94. logger.warn(story, '面板没打开;连输入框都列举失败');
  95. }
  96. /* 有的视频关闭了评论 / 点到了别的地方。把话说清楚,
  97. * 否则上层只看到"评论发不出去"却不知道为什么。 */
  98. logger.warn(story, '点了入口但面板没打开(该视频可能关闭了评论)');
  99. }
  100. return opened === true;
  101. }
  102. /**
  103. * 通过点右侧那个 "Read or add comments" 按钮打开面板(首页信息流里唯一的入口)。
  104. *
  105. * ★ 两个都必须做,缺一个就会"点击成功但面板死活不出来"(2026-10-28 真机实测):
  106. *
  107. * ① **点之前先等页面安静**。watch 刚结束、或者面板刚收起时,这一下点击会被吞掉。
  108. * 症状极其迷惑:点击返回成功、`panelInput()` 却 8 秒都等不到;而隔一秒手动点
  109. * 同一个坐标,400ms 就出面板。这和 `verifyInCommentList` 里"发布后立刻点角标
  110. * 会被吞"是同一类问题,处理手法也一样(settle + 重试)。
  111. *
  112. * ② **不能用 node.click()**。那个无障碍 ACTION_CLICK 返回 true,但 TikTok 不响应。
  113. * 这一竖条按钮又窄(宽 128px)位置又固定,坐标点击才可靠。
  114. */
  115. function openByCommentButton(config, entry, story) {
  116. var logger = TikTokAuto.logger;
  117. var tiktok = TikTokAuto.tiktok;
  118. var attempts = 0;
  119. var maxAttempts = 2;
  120. var ready = false;
  121. while (!ready && attempts < maxAttempts) {
  122. attempts += 1;
  123. TikTokAuto.wait.waitQuiet(function () {
  124. return TikTokAuto.layout.signature();
  125. }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
  126. var point = TikTokAuto.layout.center(entry);
  127. if (point) {
  128. logger.info(story, '第 ' + attempts + ' 次坐标点击评论入口 @(' +
  129. point.x + ',' + point.y + ')');
  130. TikTokAuto.ui.tapPoint(point.x, point.y);
  131. } else {
  132. logger.warn(story, '算不出评论入口的中心点,改用无障碍点击');
  133. TikTokAuto.ui.clickNode(entry, story);
  134. }
  135. ready = TikTokAuto.wait.waitFor(story, function () {
  136. return tiktok.panelInput() !== null ? true : null;
  137. }, 4000, config.probe.pollTickMs) === true;
  138. if (!ready) {
  139. /* 页面可能重排过,旧节点已失效 —— 重新找一个再试 */
  140. entry = tiktok.commentButton();
  141. if (!entry) {
  142. logger.warn(story, '重试时找不到评论入口了');
  143. break;
  144. }
  145. }
  146. }
  147. return ready;
  148. }
  149. /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 back) */
  150. function closePanel(step) {
  151. var tiktok = TikTokAuto.tiktok;
  152. if (!tiktok.commentPanelOpen()) {
  153. return false;
  154. }
  155. TikTokAuto.logger.info(step || 'closeCommentPanel', '收起评论面板');
  156. back();
  157. TikTokAuto.wait.until(function () {
  158. return tiktok.commentPanelOpen() ? null : true;
  159. }, 4000, 200);
  160. return true;
  161. }
  162. /**
  163. * 发送一条评论。
  164. *
  165. * @returns {ok, via, text, countBefore, countAfter, reason}
  166. */
  167. function post(config, text, step) {
  168. var story = step || 'postComment';
  169. var logger = TikTokAuto.logger;
  170. var tiktok = TikTokAuto.tiktok;
  171. var countBefore = tiktok.commentCount();
  172. var before = { countBefore: countBefore };
  173. if (!openPanel(config, story)) {
  174. return { ok: false, via: 'panel-not-opened', text: text, reason: '评论面板未打开' };
  175. }
  176. var input = tiktok.panelInput();
  177. var typed = TikTokAuto.ui.typeInto(input, text, story);
  178. if (!typed.ok) {
  179. return { ok: false, via: 'type-failed', text: text, reason: '输入框回读不一致' };
  180. }
  181. if (config.runtime.dryRun) {
  182. logger.warn(story, 'dryRun 模式:已输入但**不发送**,清空并收起面板。待发文案=' + JSON.stringify(text));
  183. var restore = tiktok.panelInput();
  184. if (restore) {
  185. restore.setText('');
  186. }
  187. TikTokAuto.wait.tick(300);
  188. closePanel(story);
  189. return { ok: true, via: 'dry-run', text: text, countBefore: countBefore, countAfter: countBefore, skipped: true };
  190. }
  191. var button = tiktok.sendButton(config);
  192. if (!button) {
  193. return { ok: false, via: 'send-button-not-found', text: text, reason: '定位不到发送按钮' };
  194. }
  195. /* ★ 发送键必须用**坐标点击**(2026-10-28 实测):
  196. * 它是个纯图标按钮,无障碍 ACTION_CLICK 打上去会落到祖先容器 ——
  197. * 实测点到了屏幕正中 (540,1465),那是评论列表的内容区,等于什么都没发。
  198. * 而它形状固定、位置刚从输入框推出来,坐标点最准。 */
  199. var sendPoint = TikTokAuto.layout.center(button);
  200. if (sendPoint) {
  201. logger.info(story, '坐标点击发送键 @(' + sendPoint.x + ',' + sendPoint.y + ')');
  202. TikTokAuto.ui.tapPoint(sendPoint.x, sendPoint.y);
  203. } else {
  204. TikTokAuto.ui.clickNode(button, story);
  205. }
  206. return awaitOutcome(config, text, before, story);
  207. }
  208. /**
  209. * 等待并判定"评论是否真的发出去了",输出**带置信度**的结论。
  210. *
  211. * 证据分三级,从强到弱:
  212. * 高 comment-list —— 评论列表里真的出现了这条文案(地面真相)
  213. * 中 comment-count —— 列表头部计数自增
  214. * 低 panel-closed —— 面板收起 + 输入框复位为占位符
  215. *
  216. * 为什么不能只用计数(实测踩到):视频页那个评论角标不一定会立刻刷新,
  217. * 明明发成功却读回旧值。反过来,只看"面板关了"又可能被平台拒评骗过。
  218. * 所以用 waitFor 逐级升级证据,并把最终置信度**如实**报给上层。
  219. */
  220. function awaitOutcome(config, text, before, step) {
  221. var logger = TikTokAuto.logger;
  222. var tiktok = TikTokAuto.tiktok;
  223. // 必要条件:面板收起且输入框复位
  224. var closed = TikTokAuto.wait.waitFor(step, function () {
  225. if (tiktok.commentPanelOpen()) {
  226. return null;
  227. }
  228. /* 播放页要等内联输入框真的回来 —— 这比"面板关了"更严(说明回到了视频页)。
  229. * 但**信息流底部是导航栏、本来就没有内联输入框**:那里再等它就永远等不到,
  230. * 明明发成功了也会被判成"面板没有收起"。所以那种形态只认"回到了 feed"。
  231. */
  232. if (tiktok.inlineInput() !== null) {
  233. return true;
  234. }
  235. return tiktok.pageState() === 'feed' ? true : null;
  236. }, config.timeouts.postMs, config.probe.pollTickMs);
  237. if (closed === null) {
  238. logger.error(step, '发送后面板未收起,疑似未发布成功');
  239. closePanel(step);
  240. return { ok: false, via: 'panel-still-open', text: text, reason: '发送后面板没有收起' };
  241. }
  242. // 中级证据:评论数自增。
  243. // **必须"等"而不是"看一眼"**——实测角标有延迟:发布返回后立刻读还是旧值,
  244. // 大约 1~2 秒后才 +1。看一眼就下结论的话,明明发成功了也会被判成弱证据。
  245. var counted = TikTokAuto.wait.waitFor(step, function () {
  246. var after = tiktok.commentCount();
  247. if (after === null || before.countBefore === null) {
  248. return null;
  249. }
  250. return after !== before.countBefore ? after : null;
  251. }, config.timeouts.countRefreshMs, 400);
  252. if (counted !== null) {
  253. logger.info(step, '评论已发布(计数自增)' + before.countBefore + '→' + counted + ' ' + JSON.stringify(text));
  254. return {
  255. ok: true, via: 'comment-count', confidence: 'medium', text: text,
  256. countBefore: before.countBefore, countAfter: counted
  257. };
  258. }
  259. // 高级证据:去评论列表里找这条文案(地面真相)
  260. if (config.comments.verifyInList) {
  261. var inList = verifyInCommentList(config, text, before, step);
  262. if (inList) {
  263. logger.info(step, '评论已发布(列表确认 via=' + inList.via + ')内容=' +
  264. JSON.stringify(text) + ' 计数=' + inList.count);
  265. return {
  266. ok: true, via: 'comment-list', confidence: 'high', text: text,
  267. countBefore: before.countBefore, countAfter: inList.count
  268. };
  269. }
  270. if (config.comments.strictPostCheck) {
  271. logger.error(step, '严格校验开启但评论列表里找不到该文案,判定为失败');
  272. return {
  273. ok: false, via: 'not-found-in-list', text: text,
  274. countBefore: before.countBefore, countAfter: tiktok.commentCount(),
  275. reason: '评论列表里找不到刚发布的文案'
  276. };
  277. }
  278. }
  279. logger.warn(step, '评论已发布,但只有弱证据(面板收起)。计数=' +
  280. before.countBefore + '→' + tiktok.commentCount() + ' 内容=' + JSON.stringify(text));
  281. return {
  282. ok: true, via: 'panel-closed-and-input-reset', confidence: 'low', text: text,
  283. countBefore: before.countBefore, countAfter: tiktok.commentCount()
  284. };
  285. }
  286. /**
  287. * 打开评论列表,找这条评论是否在列表里。
  288. * 返回 {count} 或 null。无论成败都会把面板收回去,不污染后续步骤。
  289. *
  290. * 为什么要"重试 + 等页面安静":紧跟着发布之后点评论角标,面板还在收起动画里,
  291. * 这一下点击会被吞掉(实测:列表死活不出来,等到超时)。
  292. * 所以每次点击前先等签名稳定,并且允许最多点三次。
  293. */
  294. function verifyInCommentList(config, text, before, step) {
  295. var tiktok = TikTokAuto.tiktok;
  296. var logger = TikTokAuto.logger;
  297. var clickAttempts = 0;
  298. var maxClicks = 3;
  299. var deadline = Date.now() + config.timeouts.panelReadyMs;
  300. while (Date.now() < deadline) {
  301. var headerCount = tiktok.listCommentCount();
  302. if (headerCount !== null) {
  303. // 最强:列表里真的出现了这条文案
  304. if (tiktok.commentListContains(text)) {
  305. closePanel(step);
  306. return { via: 'text-in-list', count: headerCount };
  307. }
  308. // 次强:列表头部计数变了(说明确实多了一条)
  309. if (before.countBefore !== null && headerCount !== before.countBefore) {
  310. closePanel(step);
  311. return { via: 'list-count', count: headerCount };
  312. }
  313. } else if (clickAttempts < maxClicks) {
  314. settle();
  315. var button = tiktok.commentButton();
  316. if (!button) {
  317. logger.warn(step, '找不到评论角标,跳过列表校验');
  318. return null;
  319. }
  320. clickAttempts += 1;
  321. logger.info(step, '第 ' + clickAttempts + ' 次打开评论列表');
  322. TikTokAuto.ui.clickNode(button, step);
  323. }
  324. TikTokAuto.wait.tick(400);
  325. }
  326. logger.warn(step, '评论列表校验未通过(点了 ' + clickAttempts + ' 次,列表计数=' +
  327. tiktok.listCommentCount() + ',发布前计数=' + before.countBefore + ')');
  328. closePanel(step);
  329. return null;
  330. }
  331. /** 等页面签名稳定下来(收起动画结束),再去做下一次点击 */
  332. function settle() {
  333. TikTokAuto.wait.waitQuiet(function () {
  334. return TikTokAuto.layout.signature();
  335. }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
  336. }
  337. return {
  338. PLACEHOLDER: PLACEHOLDER,
  339. pickText: pickText,
  340. openPanel: openPanel,
  341. closePanel: closePanel,
  342. post: post,
  343. awaitOutcome: awaitOutcome,
  344. verifyInCommentList: verifyInCommentList
  345. };
  346. }());