/** * 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; } var inline = tiktok.inlineInput(); if (!inline) { logger.error(story, '找不到评论入口(内联输入框)'); return false; } TikTokAuto.ui.clickNode(inline, story); var ready = TikTokAuto.wait.waitFor(story, function () { return tiktok.panelInput() !== null ? true : null; }, config.timeouts.panelReadyMs, config.probe.pollTickMs); return ready === true; } /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 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: '定位不到发送按钮' }; } 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.panelInput() !== null) { return null; } return tiktok.inlineInput() !== null ? 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 }; }());