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