/** * comment.js — 评论:挑文案 → 打开面板 → 输入 → 发送 → **验证真的发出去了** * * 关键设计:发送后必须**验证**,而且要"多条证据同时成立"才敢说成功。 * 只看着"面板关掉了"就宣布成功,是这类脚本最典型的假阳性来源 * (面板也可能因为别的原因关掉,而评论被平台拒了)。 * * 实测有效的两条证据: * 1. 评论计数自增: "Read or add comments. 149 comments" → "150 comments" * 2. 输入框被清空并恢复占位符 "Add comment...",且面板收起 * 默认以"计数自增"为强证据;计数有延迟时退回到第 2 条(可用 * comments.strictPostCheck 强制只认第 1 条)。 * * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait、TikTokAuto.tiktok */ TikTokAuto.comment = (function () { 'use strict'; var PLACEHOLDER = 'Add comment...'; /** 从评论池里随机挑一条,尽量不重复(池子用完了就允许重复并告警) */ function pickText(config, used, step) { var pool = config.comments.pool; var candidates = []; for (var i = 0; i < pool.length; i += 1) { if (!config.comments.avoidRepeatWithinRun || used.indexOf(pool[i]) === -1) { candidates.push(pool[i]); } } if (candidates.length === 0) { TikTokAuto.logger.warn(step || 'comment', '评论池已用尽,本轮允许重复'); candidates = pool; } return candidates[Math.floor(Math.random() * candidates.length)]; } /** 打开评论面板:点内联输入框 / 点评论按钮 → 等面板输入框出现 */ function openPanel(config, step) { var story = step || 'openCommentPanel'; var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; if (tiktok.panelInput() !== null) { logger.info(story, '评论面板已经是打开状态'); return true; } /* 两个入口,按页面形态选: * · 播放页(从搜索结果/主页点进来的)底部有一条内联输入框 "Add comment..." * · **首页信息流底部是导航栏**(Home/Friends/Inbox/Profile),没有内联输入框 * —— 只能点右侧那个 "Read or add comments. N comments" 按钮。 * 以前只有第一种,于是养号在信息流里发评论会直接报"找不到评论入口"。 */ var entry = tiktok.inlineInput(); var via = 'inline-input'; if (!entry) { entry = tiktok.commentButton(); via = 'comment-button'; } if (!entry) { logger.error(story, '找不到评论入口(内联输入框与评论按钮都没有)'); return false; } logger.info(story, '打开评论面板(入口=' + via + ')'); var ready = false; if (via === 'comment-button') { ready = openByCommentButton(config, entry, story); if (!ready) { /* 换一种点击手段再试 —— 入口节点可能已经失效(页面重排过) */ entry = tiktok.commentButton(); if (entry) { TikTokAuto.ui.clickNode(entry, story); } } } else { TikTokAuto.ui.clickNode(entry, story); } if (ready) { return true; } var opened = TikTokAuto.wait.waitFor(story, function () { return tiktok.panelInput() !== null ? true : null; }, config.timeouts.panelReadyMs, config.probe.pollTickMs); if (!opened) { /* 把当时所有输入框的位置记下来。"点了没反应"和"面板开了但认不出输入框" * 是两种完全不同的故障,而在日志里它们长得一模一样 —— 没有这行就只能靠猜。 */ var seen = []; try { var ets = className('android.widget.EditText').find(); for (var e = 0; e < ets.size(); e += 1) { var box = ets.get(e).bounds(); seen.push(box.top + '~' + box.bottom); } logger.warn(story, '面板没打开;此刻 EditText ' + ets.size() + ' 个 @ ' + seen.join(', ') + '(判据区间 ' + Math.floor(TikTokAuto.layout.screen().height * 0.45) + '~' + Math.floor(TikTokAuto.layout.screen().height * 0.85) + ')'); } catch (ignored) { logger.warn(story, '面板没打开;连输入框都列举失败'); } /* 有的视频关闭了评论 / 点到了别的地方。把话说清楚, * 否则上层只看到"评论发不出去"却不知道为什么。 */ logger.warn(story, '点了入口但面板没打开(该视频可能关闭了评论)'); } return opened === true; } /** * 通过点右侧那个 "Read or add comments" 按钮打开面板(首页信息流里唯一的入口)。 * * ★ 两个都必须做,缺一个就会"点击成功但面板死活不出来"(2026-10-28 真机实测): * * ① **点之前先等页面安静**。watch 刚结束、或者面板刚收起时,这一下点击会被吞掉。 * 症状极其迷惑:点击返回成功、`panelInput()` 却 8 秒都等不到;而隔一秒手动点 * 同一个坐标,400ms 就出面板。这和 `verifyInCommentList` 里"发布后立刻点角标 * 会被吞"是同一类问题,处理手法也一样(settle + 重试)。 * * ② **不能用 node.click()**。那个无障碍 ACTION_CLICK 返回 true,但 TikTok 不响应。 * 这一竖条按钮又窄(宽 128px)位置又固定,坐标点击才可靠。 */ function openByCommentButton(config, entry, story) { var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; var attempts = 0; var maxAttempts = 2; var ready = false; while (!ready && attempts < maxAttempts) { attempts += 1; TikTokAuto.wait.waitQuiet(function () { return TikTokAuto.layout.signature(); }, { maxMs: 1500, tickMs: 200, stableTimes: 2 }); var point = TikTokAuto.layout.center(entry); if (point) { logger.info(story, '第 ' + attempts + ' 次坐标点击评论入口 @(' + point.x + ',' + point.y + ')'); TikTokAuto.ui.tapPoint(point.x, point.y); } else { logger.warn(story, '算不出评论入口的中心点,改用无障碍点击'); TikTokAuto.ui.clickNode(entry, story); } ready = TikTokAuto.wait.waitFor(story, function () { return tiktok.panelInput() !== null ? true : null; }, 4000, config.probe.pollTickMs) === true; if (!ready) { /* 页面可能重排过,旧节点已失效 —— 重新找一个再试 */ entry = tiktok.commentButton(); if (!entry) { logger.warn(story, '重试时找不到评论入口了'); break; } } } return ready; } /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 back) */ function closePanel(step) { var tiktok = TikTokAuto.tiktok; if (!tiktok.commentPanelOpen()) { return false; } TikTokAuto.logger.info(step || 'closeCommentPanel', '收起评论面板'); back(); TikTokAuto.wait.until(function () { return tiktok.commentPanelOpen() ? null : true; }, 4000, 200); return true; } /** * 发送一条评论。 * * @returns {ok, via, text, countBefore, countAfter, reason} */ function post(config, text, step) { var story = step || 'postComment'; var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; var countBefore = tiktok.commentCount(); var before = { countBefore: countBefore }; if (!openPanel(config, story)) { return { ok: false, via: 'panel-not-opened', text: text, reason: '评论面板未打开' }; } var input = tiktok.panelInput(); var typed = TikTokAuto.ui.typeInto(input, text, story); if (!typed.ok) { return { ok: false, via: 'type-failed', text: text, reason: '输入框回读不一致' }; } if (config.runtime.dryRun) { logger.warn(story, 'dryRun 模式:已输入但**不发送**,清空并收起面板。待发文案=' + JSON.stringify(text)); var restore = tiktok.panelInput(); if (restore) { restore.setText(''); } TikTokAuto.wait.tick(300); closePanel(story); return { ok: true, via: 'dry-run', text: text, countBefore: countBefore, countAfter: countBefore, skipped: true }; } var button = tiktok.sendButton(config); if (!button) { return { ok: false, via: 'send-button-not-found', text: text, reason: '定位不到发送按钮' }; } /* ★ 发送键必须用**坐标点击**(2026-10-28 实测): * 它是个纯图标按钮,无障碍 ACTION_CLICK 打上去会落到祖先容器 —— * 实测点到了屏幕正中 (540,1465),那是评论列表的内容区,等于什么都没发。 * 而它形状固定、位置刚从输入框推出来,坐标点最准。 */ var sendPoint = TikTokAuto.layout.center(button); if (sendPoint) { logger.info(story, '坐标点击发送键 @(' + sendPoint.x + ',' + sendPoint.y + ')'); TikTokAuto.ui.tapPoint(sendPoint.x, sendPoint.y); } else { TikTokAuto.ui.clickNode(button, story); } return awaitOutcome(config, text, before, story); } /** * 等待并判定"评论是否真的发出去了",输出**带置信度**的结论。 * * 证据分三级,从强到弱: * 高 comment-list —— 评论列表里真的出现了这条文案(地面真相) * 中 comment-count —— 列表头部计数自增 * 低 panel-closed —— 面板收起 + 输入框复位为占位符 * * 为什么不能只用计数(实测踩到):视频页那个评论角标不一定会立刻刷新, * 明明发成功却读回旧值。反过来,只看"面板关了"又可能被平台拒评骗过。 * 所以用 waitFor 逐级升级证据,并把最终置信度**如实**报给上层。 */ function awaitOutcome(config, text, before, step) { var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; // 必要条件:面板收起且输入框复位 var closed = TikTokAuto.wait.waitFor(step, function () { if (tiktok.commentPanelOpen()) { return null; } /* 播放页要等内联输入框真的回来 —— 这比"面板关了"更严(说明回到了视频页)。 * 但**信息流底部是导航栏、本来就没有内联输入框**:那里再等它就永远等不到, * 明明发成功了也会被判成"面板没有收起"。所以那种形态只认"回到了 feed"。 */ if (tiktok.inlineInput() !== null) { return true; } return tiktok.pageState() === 'feed' ? true : null; }, config.timeouts.postMs, config.probe.pollTickMs); if (closed === null) { logger.error(step, '发送后面板未收起,疑似未发布成功'); closePanel(step); return { ok: false, via: 'panel-still-open', text: text, reason: '发送后面板没有收起' }; } // 中级证据:评论数自增。 // **必须"等"而不是"看一眼"**——实测角标有延迟:发布返回后立刻读还是旧值, // 大约 1~2 秒后才 +1。看一眼就下结论的话,明明发成功了也会被判成弱证据。 var counted = TikTokAuto.wait.waitFor(step, function () { var after = tiktok.commentCount(); if (after === null || before.countBefore === null) { return null; } return after !== before.countBefore ? after : null; }, config.timeouts.countRefreshMs, 400); if (counted !== null) { logger.info(step, '评论已发布(计数自增)' + before.countBefore + '→' + counted + ' ' + JSON.stringify(text)); return { ok: true, via: 'comment-count', confidence: 'medium', text: text, countBefore: before.countBefore, countAfter: counted }; } // 高级证据:去评论列表里找这条文案(地面真相) if (config.comments.verifyInList) { var inList = verifyInCommentList(config, text, before, step); if (inList) { logger.info(step, '评论已发布(列表确认 via=' + inList.via + ')内容=' + JSON.stringify(text) + ' 计数=' + inList.count); return { ok: true, via: 'comment-list', confidence: 'high', text: text, countBefore: before.countBefore, countAfter: inList.count }; } if (config.comments.strictPostCheck) { logger.error(step, '严格校验开启但评论列表里找不到该文案,判定为失败'); return { ok: false, via: 'not-found-in-list', text: text, countBefore: before.countBefore, countAfter: tiktok.commentCount(), reason: '评论列表里找不到刚发布的文案' }; } } logger.warn(step, '评论已发布,但只有弱证据(面板收起)。计数=' + before.countBefore + '→' + tiktok.commentCount() + ' 内容=' + JSON.stringify(text)); return { ok: true, via: 'panel-closed-and-input-reset', confidence: 'low', text: text, countBefore: before.countBefore, countAfter: tiktok.commentCount() }; } /** * 打开评论列表,找这条评论是否在列表里。 * 返回 {count} 或 null。无论成败都会把面板收回去,不污染后续步骤。 * * 为什么要"重试 + 等页面安静":紧跟着发布之后点评论角标,面板还在收起动画里, * 这一下点击会被吞掉(实测:列表死活不出来,等到超时)。 * 所以每次点击前先等签名稳定,并且允许最多点三次。 */ function verifyInCommentList(config, text, before, step) { var tiktok = TikTokAuto.tiktok; var logger = TikTokAuto.logger; var clickAttempts = 0; var maxClicks = 3; var deadline = Date.now() + config.timeouts.panelReadyMs; while (Date.now() < deadline) { var headerCount = tiktok.listCommentCount(); if (headerCount !== null) { // 最强:列表里真的出现了这条文案 if (tiktok.commentListContains(text)) { closePanel(step); return { via: 'text-in-list', count: headerCount }; } // 次强:列表头部计数变了(说明确实多了一条) if (before.countBefore !== null && headerCount !== before.countBefore) { closePanel(step); return { via: 'list-count', count: headerCount }; } } else if (clickAttempts < maxClicks) { settle(); var button = tiktok.commentButton(); if (!button) { logger.warn(step, '找不到评论角标,跳过列表校验'); return null; } clickAttempts += 1; logger.info(step, '第 ' + clickAttempts + ' 次打开评论列表'); TikTokAuto.ui.clickNode(button, step); } TikTokAuto.wait.tick(400); } logger.warn(step, '评论列表校验未通过(点了 ' + clickAttempts + ' 次,列表计数=' + tiktok.listCommentCount() + ',发布前计数=' + before.countBefore + ')'); closePanel(step); return null; } /** 等页面签名稳定下来(收起动画结束),再去做下一次点击 */ function settle() { TikTokAuto.wait.waitQuiet(function () { return TikTokAuto.layout.signature(); }, { maxMs: 1500, tickMs: 200, stableTimes: 2 }); } return { PLACEHOLDER: PLACEHOLDER, pickText: pickText, openPanel: openPanel, closePanel: closePanel, post: post, awaitOutcome: awaitOutcome, verifyInCommentList: verifyInCommentList }; }());