// ==UserScript== // @name 小红书自动搜索评论助手 Pro // @namespace xhs-auto-commenter-pro // @version 1.1.2 // @description 按关键词表格在小红书网页版自动搜索并发送自定义评论;随机轮换、频率控制、跳过已评论、断点续跑。 // @author bfh994042019 // @match https://www.xiaohongshu.com/* // @match https://xiaohongshu.com/* // @match https://*.xiaohongshu.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @noframes // @run-at document-idle // ==/UserScript== (() => { // ===== 油猴兼容层:chrome.storage 适配 ===== const __HAS_GM__ = (typeof GM_getValue === 'function' && typeof GM_setValue === 'function'); const __CHROME__ = (() => { if (__HAS_GM__) { const get = (key) => new Promise((res) => { try { res({ [key]: GM_getValue(key) }); } catch (e) { res({}); } }); const set = (obj) => new Promise((res) => { try { for (const k in obj || {}) GM_setValue(k, obj[k]); } catch (e) { /* ignore */ } res(); }); const remove = (keys) => new Promise((res) => { try { (Array.isArray(keys) ? keys : [keys]).forEach((k) => { try { if (typeof GM_deleteValue === 'function') GM_deleteValue(k); } catch (e) { /* ignore */ } }); } catch (e) { /* ignore */ } res(); }); return { storage: { local: { get, set, remove } } }; } // 非油猴环境(本地宿主页测试用):localStorage 兜底 const LS = '__xhsUserScriptStorage__'; const read = () => { try { return JSON.parse(localStorage.getItem(LS) || '{}'); } catch (e) { return {}; } }; const write = (o) => { try { localStorage.setItem(LS, JSON.stringify(o || {})); } catch (e) { /* ignore */ } }; return { storage: { local: { get: async (key) => { const all = read(); return typeof key === 'string' ? (key in all ? { [key]: all[key] } : {}) : all; }, set: async (obj) => { const all = read(); Object.assign(all, obj || {}); write(all); }, remove: async (keys) => { const all = read(); (Array.isArray(keys) ? keys : [keys]).forEach((k) => delete all[k]); write(all); }, }, }, }; })(); // 在本 IIFE 作用域内绑定 chrome:内层各模块(shared/license/content/runner/panel) // 引用的 chrome 沿作用域链命中此处,避免命中宿主页面不完整的 window.chrome const chrome = __CHROME__; // ==================== shared.js ==================== // 小红书自动评论助手 - 共享层:选择器集中配置、存储封装、表格解析、URL/工具函数 // 所有页面选择器集中在此,页面改版失效后只改 SELECTORS 一处 (() => { if (window.__XHS_SHARED__) return; // 防止重复注入 const KEYS = { keywords: 'xhsAutoKeywords', comments: 'xhsAutoComments', settings: 'xhsAutoSettings', commented: 'xhsAutoCommentedIds', task: 'xhsAutoTaskState', panel: 'xhsAutoPanel', }; const DEFAULT_SETTINGS = { perKeywordCount: 3, // 每个关键词每轮评论篇数 minDelaySec: 40, // 评论最小间隔(秒) maxDelaySec: 90, // 评论最大间隔(秒) sessionMax: 30, // 单次会话总评论上限 skipCommented: true, // 跳过已评论过的笔记 loopKeywords: true, // 关键词循环往复 }; // 风控/未登录文案(在可见弹层中出现时触发自动暂停) const RISK_TEXTS = ['扫码登录', '登录小红书', '请完成安全验证', '验证码', '拖动滑块', '安全验证']; const SELECTORS = { noteItem: ["section.note-item", "div[class*='note-item']"], noteLink: ["a.cover[href]", "a[href*='/explore/']", "a[href*='/search_result/']", "a.title[href]"], noteDetail: ["div[class*='note-detail']", "div[id*='note-detail']"], commentTextarea: ["#content-textarea", "div[class*='note-detail'] [contenteditable='true']"], commentItem: [".comment-item", "div[class*='comment-item']"], closeBtn: [".close-circle", "div[class*='close-circle']"], sendBtnText: '发送', }; // ---------- 基础工具 ---------- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function randomInt(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } // 按选择器数组依次尝试,返回第一个命中元素 function q(list, root) { root = root || document; for (const sel of list) { try { const el = root.querySelector(sel); if (el) return el; } catch (e) { /* 选择器非法时跳过 */ } } return null; } // 按选择器数组依次尝试,返回第一个命中选择器的所有元素 function qa(list, root) { root = root || document; for (const sel of list) { try { const nodes = root.querySelectorAll(sel); if (nodes.length) return Array.from(nodes); } catch (e) { /* ignore */ } } return []; } function isVisible(el) { if (!el || !el.isConnected) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; } // ---------- 存储封装(值不存在时返回默认值) ---------- function storageGet(key, def) { return new Promise((resolve) => { try { chrome.storage.local.get(key).then((r) => { const v = r && r[key]; resolve(v === undefined || v === null ? def : v); }).catch(() => resolve(def)); } catch (e) { resolve(def); } }); } function storageSet(obj) { return new Promise((resolve) => { try { chrome.storage.local.set(obj).then(resolve, resolve); } catch (e) { resolve(); } }); } function storageRemove(keys) { return new Promise((resolve) => { try { chrome.storage.local.remove(keys).then(resolve, resolve); } catch (e) { resolve(); } }); } // ---------- 表格文本解析 ---------- // 支持:Excel 复制的 TSV、CSV 文本(中英文逗号)、双引号包裹与 "" 转义 // 每行取第一个非空单元格作为一条记录;过滤常见表头行;顺序去重 function parseTableText(text) { if (!text) return []; const out = []; const seen = new Set(); const lines = String(text).split(/\r?\n/); for (let line of lines) { line = line.trim(); if (!line) continue; const cells = line.includes('\t') ? line.split('\t') : line.split(/[,,]/); for (let cell of cells) { cell = cell.trim(); if (cell.length >= 2 && cell.startsWith('"') && cell.endsWith('"')) { cell = cell.slice(1, -1).replace(/""/g, '"').trim(); } if (!cell) continue; if (/^(关键词|搜索词|keyword)s?$/i.test(cell)) continue; // 表头 if (seen.has(cell)) continue; seen.add(cell); out.push(cell); } } return out; } // 简化版:每行只取第一个非空单元格(用于关键词列导入,避免把其他列混入) function parseKeywordRows(text) { if (!text) return []; const out = []; const seen = new Set(); for (let line of String(text).split(/\r?\n/)) { line = line.trim(); if (!line) continue; const cells = line.includes('\t') ? line.split('\t') : line.split(/[,,]/); let val = ''; for (let cell of cells) { cell = cell.trim(); if (cell.length >= 2 && cell.startsWith('"') && cell.endsWith('"')) { cell = cell.slice(1, -1).replace(/""/g, '"').trim(); } if (cell) { val = cell; break; } } if (!val) continue; if (/^(关键词|搜索词|keyword)s?$/i.test(val)) continue; // 表头 if (seen.has(val)) continue; seen.add(val); out.push(val); } return out; } // ---------- URL ---------- function currentKeyword() { try { const u = new URL(location.href); if (window.__XHS_MOCK__) return u.searchParams.get('keyword') || null; if (!u.pathname.startsWith('/search_result')) return null; return u.searchParams.get('keyword') || null; } catch (e) { return null; } } function searchUrl(kw) { if (window.__XHS_MOCK__) { return location.pathname + '?keyword=' + encodeURIComponent(kw); } return 'https://www.xiaohongshu.com/search_result?keyword=' + encodeURIComponent(kw); } // 从笔记链接提取稳定标识(/explore/、/search_result/ 等) // 不再限定 id 必须是 24 位 hex:真实站点链接格式变化时也不会漏掉笔记, // 否则漏提取会导致「已评论记录」存的是一个不匹配的 key,跳过逻辑失效 function noteIdFromHref(href) { if (!href) return null; let u; try { u = new URL(href, location.origin); } catch (e) { return null; } const m = u.pathname.match(/\/(?:explore|search_result|discovery\/item)\/([^/?#]+)/i); let id = m ? m[1] : ''; if (!id) { // 前缀未知时退化为取路径最后一段 const parts = u.pathname.split('/').filter(Boolean); const last = parts[parts.length - 1]; if (last && last !== 'search_result' && last !== 'explore') id = last; } if (!id) return null; try { id = decodeURIComponent(id); } catch (e) { /* 保留原值 */ } return { id, href: String(href) }; } // 已评论 id 池按时间截断,防止无限膨胀 function trimCommented(map, max) { const entries = Object.entries(map || {}).sort((a, b) => a[1] - b[1]); while (entries.length > max) entries.shift(); return Object.fromEntries(entries); } window.__XHS_SHARED__ = { KEYS, DEFAULT_SETTINGS, RISK_TEXTS, SELECTORS, sleep, randomInt, q, qa, isVisible, storageGet, storageSet, storageRemove, parseTableText, parseKeywordRows, currentKeyword, searchUrl, noteIdFromHref, trimCommented, }; })(); // ==================== license.js ==================== // 小红书自动评论助手 Pro - 授权模块: // 安装即锁定全部功能,锁定界面展示收款码 + 设备码 + 作者微信号。 // 客户付款后把设备码发给作者,作者用私钥签出激活码(sign.js),扩展用内置公钥验证。 // 非对称签名保证激活码只能由作者生成;扩展里没有私钥,客户无法自行签发。 // 依赖 shared.js 的 S.storageGet / S.storageSet(与 mock 桩兼容) (() => { if (window.__XHS_LICENSE__) return; const S = window.__XHS_SHARED__; if (!S) return; const KEYS = { device: 'xhsAutoDeviceId', license: 'xhsAutoLicense' }; // ECDSA P-256 公钥(作者私钥保存在扩展之外的 xhs-license-tools/) const PUB_JWK = { kty: 'EC', crv: 'P-256', x: 'a92dz3Ehk69ajRmvB2ZlCN9XZwNcjjYxvUpyJZma5qU', y: 'wBD7aERVDh6k2C6VsCm_mK-CwBEJODjvXdNCTIotVA4', }; const stGet = (k, d) => S.storageGet(k, d); const stSet = S.storageSet; // 设备码归一化:客户可能多打空格/横线/小写,签名与校验两侧统一处理 function normalizeDevice(s) { return String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, ''); } function formatDevice(id) { const n = normalizeDevice(id); return (n.match(/.{1,4}/g) || ['-']).join('-'); } async function getDeviceId() { let id = await stGet(KEYS.device, ''); if (!id) { const buf = new Uint8Array(6); crypto.getRandomValues(buf); id = Array.from(buf).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase(); await stSet({ [KEYS.device]: id }); } return id; } // ---------- base64url ---------- function b64uEnc(str) { const bytes = new TextEncoder().encode(str); let bin = ''; bytes.forEach((b) => { bin += String.fromCharCode(b); }); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function b64uDecBytes(s) { const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4)); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } // 校验激活码:格式 payload.sig(base64url),payload = {d:设备码, e:到期秒(0=永久)} async function verifyCode(code, deviceId) { const clean = String(code || '').trim().replace(/\s+/g, ''); const dot = clean.lastIndexOf('.'); if (dot <= 0) return { ok: false, msg: '激活码格式不对' }; const pB64 = clean.slice(0, dot); const sB64 = clean.slice(dot + 1); let payload; try { payload = JSON.parse(new TextDecoder().decode(b64uDecBytes(pB64))); } catch (e) { return { ok: false, msg: '激活码无法解析' }; } if (!payload || typeof payload.d !== 'string' || typeof payload.e !== 'number') { return { ok: false, msg: '激活码内容不完整' }; } let pub; try { pub = await crypto.subtle.importKey('jwk', PUB_JWK, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); } catch (e) { return { ok: false, msg: '公钥初始化失败(需在安全上下文中运行)' }; } let sigOk = false; try { sigOk = await crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, pub, b64uDecBytes(sB64), b64uDecBytes(pB64) ); } catch (e) { sigOk = false; } if (!sigOk) return { ok: false, msg: '激活码无效(签名不匹配)' }; if (normalizeDevice(payload.d) !== normalizeDevice(deviceId)) { return { ok: false, msg: '激活码与本设备不匹配,请核对设备码' }; } return { ok: true, untilMs: payload.e > 0 ? payload.e * 1000 : 0 }; } // 授权状态:{ locked, deviceId, until, trialLeftMs } async function getState() { const deviceId = await getDeviceId(); const lic = await stGet(KEYS.license, null) || {}; const now = Date.now(); // 时钟回拨粗检:把 now 提前超过 1 分钟视为篡改,直接锁定 if (lic.lastSeen && now < lic.lastSeen - 60 * 1000) { return { locked: true, deviceId, until: lic.until || 0, reason: '检测到系统时间回拨,授权已锁定' }; } const until = lic.until || 0; if (until && now < until) { await stSet({ [KEYS.license]: Object.assign({}, lic, { lastSeen: now }) }); return { locked: false, deviceId, until }; } // 安装即锁定:无有效授权一律锁定,等待激活 return { locked: true, deviceId, until }; } // 激活:验证通过后写入到期时间(只能延长不能缩短,防止旧码覆盖新码) async function activate(code) { const deviceId = await getDeviceId(); const v = await verifyCode(code, deviceId); if (!v.ok) return v; const FOREVER_MS = 200 * 365 * 86400 * 1000; // e=0 视为永久 const untilMs = v.untilMs || Date.now() + FOREVER_MS; const lic = (await stGet(KEYS.license, null)) || {}; lic.until = Math.max(lic.until || 0, untilMs); lic.lastSeen = Date.now(); await stSet({ [KEYS.license]: lic }); return { ok: true, until: lic.until }; } window.__XHS_LICENSE__ = { getState, activate, formatDevice }; })(); // ==================== content.js ==================== // 小红书自动评论助手 - 页面动作原语(无状态): // 找笔记卡片 / 打开弹窗 / 填评论 / 点发送 / 关弹窗 / 风控检测 / 滚动加载 // 注意:框架会重渲染节点,每次操作前都重新查询元素 (() => { if (window.__XHS_CONTENT__) return; const S = window.__XHS_SHARED__; if (!S) return; // 轮询等待条件成立,超时返回 null async function waitFor(fn, timeout, step) { timeout = timeout || 10000; step = step || 300; const t0 = Date.now(); while (Date.now() - t0 < timeout) { let v = null; try { v = fn(); } catch (e) { v = null; } if (v) return v; await S.sleep(step); } return null; } // ---------- 笔记卡片 ---------- function isSearchPage() { return window.__XHS_MOCK__ || location.pathname.startsWith('/search_result'); } // 收集当前搜索结果的笔记(按 DOM 顺序,按笔记 id 去重) function collectNotes() { const items = S.qa(S.SELECTORS.noteItem); const seen = new Set(); const out = []; for (const item of items) { if (!S.isVisible(item)) continue; const a = S.q(S.SELECTORS.noteLink, item); if (!a) continue; const info = S.noteIdFromHref(a.getAttribute('href') || ''); if (!info || seen.has(info.id)) continue; seen.add(info.id); out.push({ id: info.id, href: info.href, el: item, link: a }); } return out; } function scrollPage() { window.scrollBy(0, 900); } // ---------- 风控检测 ---------- // 只检查可见的弹层容器(fixed 定位遮罩/弹窗),避免把页面角落的「登录」按钮误判为风控 function detectRisk() { const nodes = document.querySelectorAll( "div[class*='login'], div[class*='sign'], div[class*='mask'], div[class*='modal'], " + "div[class*='verify'], div[class*='captcha'], div[style*='position: fixed'], div[style*='position:fixed']" ); for (const el of nodes) { if (!S.isVisible(el)) continue; const r = el.getBoundingClientRect(); if (r.width < 200 || r.height < 100) continue; // 过滤小提示条 const t = (el.innerText || '').slice(0, 400); for (const kw of S.RISK_TEXTS) { if (t.includes(kw)) { return { type: kw.indexOf('登录') >= 0 ? 'login' : 'captcha', text: kw }; } } } return null; } // ---------- 打开 / 关闭笔记弹窗 ---------- async function openNote(note) { // 每次重新查询卡片链接,避免持有已脱离 DOM 的旧节点 const fresh = collectNotes().find((n) => n.id === note.id) || note; const target = fresh.link || S.q(S.SELECTORS.noteLink, fresh.el); if (!target) return false; target.click(); const detail = await waitFor(() => { const d = S.q(S.SELECTORS.noteDetail); return S.isVisible(d) ? d : null; }, 12000); return !!detail; } async function isDetailOpen() { return S.isVisible(S.q(S.SELECTORS.noteDetail)); } async function closeNote() { if (!S.isVisible(S.q(S.SELECTORS.noteDetail))) return true; const btn = S.q(S.SELECTORS.closeBtn); if (btn && S.isVisible(btn)) { btn.click(); } else { // 兜底:Escape 关闭 document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); } const gone = await waitFor(() => !S.isVisible(S.q(S.SELECTORS.noteDetail)), 5000, 250); return !!gone; } async function closeNoteIfOpen() { if (await isDetailOpen()) await closeNote(); } // ---------- 评论 ---------- function getCommentBox() { return S.q(S.SELECTORS.commentTextarea); } // 笔记是否关闭了评论 async function isCommentsClosed() { await S.sleep(800); const detail = S.q(S.SELECTORS.noteDetail); if (!detail) return false; const t = detail.innerText || ''; if (/评论已关闭|关闭评论/.test(t)) return true; // 无评论框且有正文时视为不可评论 if (!getCommentBox() && t.length > 20) return true; return false; } // 用 execCommand('insertText') 模拟真实键入(React 受控组件只认 isTrusted 的 input 事件) async function fillComment(text) { const el = getCommentBox(); // 每次重新查询 if (!el) return false; try { el.focus(); } catch (e) { /* ignore */ } const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(range); let ok = false; try { ok = document.execCommand('insertText', false, text); } catch (e) { ok = false; } if (!ok || el.textContent !== text) { // 兜底:原生赋值 + InputEvent el.textContent = text; el.dispatchEvent(new InputEvent('input', { bubbles: true, data: text, inputType: 'insertText' })); } await S.sleep(400); return el.textContent.trim() === text; } function findSendBtn() { const root = S.q(S.SELECTORS.noteDetail) || document; const nodes = root.querySelectorAll('button, div[class*="submit"], span[class*="submit"]'); const want = S.SELECTORS.sendBtnText; let loose = null; for (const n of nodes) { if (!S.isVisible(n)) continue; const t = (n.textContent || '').trim(); if (t === want) return n; // 容忍图标/空白导致的文本差异,但用长度限制避免把外层大容器当成按钮 if (!loose && t.indexOf(want) >= 0 && t.length <= want.length + 4) loose = n; } return loose; } function isDisabled(el) { if (!el) return true; if (el.disabled) return true; const cls = typeof el.className === 'string' ? el.className : (el.className && el.className.baseVal) || ''; return /(^|\s)(disabled|disable)/.test(cls); } async function sendComment() { const btn = await waitFor(() => { const b = findSendBtn(); // 每次重新查询 return b && !isDisabled(b) ? b : null; }, 5000, 250); if (!btn) return false; await S.sleep(S.randomInt(500, 1500)); // 拟人抖动 btn.click(); return true; } // 发送成功判定(多信号,任一生效即视为成功): // ① 输入框被清空 // ② 发送按钮由「可点」变「置灰」—— React 组件发送完成后的状态翻转, // 不依赖读取 contenteditable 的 textContent,比 ① 更抗重渲染 // ③ 评论列表新增了含该文本的评论 // 超时放宽到 12 秒:真实网络慢,6 秒会把成功误判成失败 async function waitSendSuccess(text, timeout) { timeout = timeout || 12000; const t0 = Date.now(); const beforeCount = S.qa(S.SELECTORS.commentItem).length; while (Date.now() - t0 < timeout) { await S.sleep(400); const el = getCommentBox(); if (!el || el.textContent.trim() === '') return true; const btn = findSendBtn(); if (btn && isDisabled(btn)) return true; const items = S.qa(S.SELECTORS.commentItem); if (items.length > beforeCount) return true; for (const it of items) { if ((it.innerText || '').indexOf(text) >= 0) return true; } } return false; } window.__XHS_CONTENT__ = { isSearchPage, collectNotes, scrollPage, detectRisk, openNote, isDetailOpen, closeNote, closeNoteIfOpen, getCommentBox, isCommentsClosed, fillComment, sendComment, waitSendSuccess, waitFor, isVisible: S.isVisible, }; })(); // ==================== runner.js ==================== // 小红书自动评论助手 - 状态机主循环(有状态,可中断/恢复/断点续跑) // 架构:整轮「开弹窗→评论→关弹窗→下一篇」在 SPA 弹窗内完成(无导航,content script 存活); // 仅切换关键词时 location.href 跳转 → 跳转前进度写入 chrome.storage, // 新实例注入后读 taskState 续跑(校验 URL keyword 一致且 lastActiveTs 未过期) (() => { if (window.__XHS_RUNNER__) return; const S = window.__XHS_SHARED__; const C = window.__XHS_CONTENT__; if (!S || !C) return; const FRESH_MS = 5 * 60 * 1000; // 超过 5 分钟无活动的 running 状态视为僵尸 const COMMENTED_MAX = 5000; class Abort extends Error {} // ---------- 任务状态 ---------- function defaultState() { return { status: 'idle', // idle | running | paused keywordIndex: 0, // 当前关键词下标 doneCount: 0, // 当前关键词已评篇数 noteCursor: 0, // 已扫描到的结果卡片序号 sessionCommented: 0, // 本次会话累计评论数 phase: 'idle', // idle | navigating | scanning | opening | commenting | closing | waiting lastActiveTs: 0, message: '', }; } async function getState() { const st = await S.storageGet(S.KEYS.task, null); return st && typeof st === 'object' ? st : defaultState(); } async function patchState(patch) { const cur = await getState(); const next = Object.assign({}, cur, patch, { lastActiveTs: Date.now() }); await S.storageSet({ [S.KEYS.task]: next }); return next; } async function pauseTask(reason) { await patchState({ status: 'paused', phase: 'idle', message: reason || '已暂停' }); } async function stopTask(msg) { await patchState({ status: 'idle', phase: 'idle', message: msg || '已停止' }); } async function getSettings() { return Object.assign({}, S.DEFAULT_SETTINGS, await S.storageGet(S.KEYS.settings, {})); } // ---------- 可暂停的延时:暂停时挂起、停止时抛 Abort ---------- async function sleepPausable(ms) { let left = ms; while (left > 0) { const st = await getState(); if (st.status === 'idle') throw new Abort('stopped'); if (st.status === 'paused') { await S.sleep(700); continue; } const slice = Math.min(500, left); await S.sleep(slice); left -= slice; } } // 动作序列中的检查点:暂停时挂起等待、停止时抛 Abort,让控制指令即时生效 async function ensureActive() { while (true) { const st = await getState(); if (st.status === 'idle') throw new Abort('stopped'); if (st.status === 'running') return; await S.sleep(700); } } // ---------- 主循环 ---------- let looping = false; let failStreak = 0; const FAIL_LIMIT = 3; function findFreshNote(id) { return C.collectNotes().find((n) => n.id === id) || null; } // 滚动加载,确保候选池足够(noteCursor + 每词配额 + 余量) async function ensurePool(settings) { let attempts = 0; while (attempts < 8) { const st = await getState(); if (st.status !== 'running') throw new Abort('stopped'); const need = (st.noteCursor || 0) + settings.perKeywordCount + 5; if (C.collectNotes().length >= need) return; C.scrollPage(); await sleepPausable(1200); attempts++; } } async function runLoop() { if (looping) return; looping = true; failStreak = 0; try { while (true) { const st = await getState(); if (st.status !== 'running') break; const settings = await getSettings(); if (st.sessionCommented >= settings.sessionMax) { await stopTask(`已达单次会话上限 ${settings.sessionMax} 条,自动停止`); break; } const keywords = await S.storageGet(S.KEYS.keywords, []); const comments = await S.storageGet(S.KEYS.comments, []); if (!keywords.length || !comments.length) { await pauseTask('未配置关键词或评论内容,请在面板填写后点「继续」'); break; } // 关键词下标越界:循环或结束 let ki = st.keywordIndex; if (ki >= keywords.length) { if (!settings.loopKeywords) { await stopTask('所有关键词已完成,任务结束'); break; } ki = 0; } const kw = keywords[ki]; // 页面不是当前关键词的搜索页 → 跳转(跳转后由新实例续跑) if (S.currentKeyword() !== kw) { await patchState({ keywordIndex: ki, doneCount: 0, noteCursor: 0, phase: 'navigating', message: `正在搜索:${kw}` }); location.href = S.searchUrl(kw); break; // 页面即将卸载 } // 风控检测(登录弹窗 / 验证码) const risk = C.detectRisk(); if (risk) { await pauseTask(`检测到${risk.type === 'login' ? '登录窗口' : '验证码'},已暂停;请人工处理后点「继续」`); break; } // 上次暂停时可能遗留打开的弹窗 await C.closeNoteIfOpen(); await patchState({ keywordIndex: ki, phase: 'scanning', message: `「${kw}」扫描搜索结果` }); await ensurePool(settings); const cur = await getState(); if (cur.status !== 'running') break; const notes = C.collectNotes(); const commentedMap = await S.storageGet(S.KEYS.commented, {}); // 从 noteCursor 起找下一篇可评笔记(跳过已评论) let idx = cur.noteCursor || 0; let target = null; while (idx < notes.length) { const n = notes[idx]; if (!(settings.skipCommented && commentedMap[n.id])) { target = n; break; } idx++; } if (!target) { // 候选耗尽:滚动加载;无增长则切下一个关键词 const before = notes.length; C.scrollPage(); await sleepPausable(1600); const grew = C.collectNotes().length > before; if (!grew) { await patchState({ keywordIndex: ki + 1, doneCount: 0, noteCursor: 0, message: `「${kw}」没有更多可评笔记,切换下一个`, }); } else { await patchState({ noteCursor: idx }); } continue; } // ---- 打开笔记 ---- await patchState({ noteCursor: idx, phase: 'opening', message: `打开第 ${idx + 1} 篇笔记` }); const opened = await C.openNote(target); if (!opened) { failStreak++; await patchState({ message: `打开失败(连续失败 ${failStreak} 次)` }); if (failStreak >= FAIL_LIMIT) { await pauseTask('连续 3 次打开笔记失败,已自动暂停'); break; } await patchState({ noteCursor: idx + 1 }); continue; } // 弹窗内再次风控检测(未登录点开笔记会弹登录) const risk2 = C.detectRisk(); if (risk2) { await C.closeNote(); await pauseTask(`检测到${risk2.type === 'login' ? '登录窗口' : '验证码'},已暂停;请人工处理后点「继续」`); break; } await ensureActive(); // ---- 填评并发送 ---- const text = comments[Math.floor(Math.random() * comments.length)]; await patchState({ phase: 'commenting', message: `评论:${text.slice(0, 14)}` }); if (await C.isCommentsClosed()) { await C.closeNote(); await patchState({ noteCursor: idx + 1, message: '该笔记不可评论,跳过' }); await sleepPausable(1500); continue; } if (!(await C.fillComment(text))) { failStreak++; await C.closeNote(); if (failStreak >= FAIL_LIMIT) { await pauseTask('连续 3 次填充评论失败,已自动暂停'); break; } await patchState({ noteCursor: idx + 1, message: `填充失败(连续 ${failStreak} 次),跳过` }); continue; } await ensureActive(); // 填好后若用户已暂停/停止,挂起而不是继续发送 const clicked = await C.sendComment(); const confirmed = clicked ? await C.waitSendSuccess(text) : false; if (!clicked) { // 只有「连发送按钮都点不到」才算真失败 failStreak++; await C.closeNote(); if (failStreak >= FAIL_LIMIT) { await pauseTask('连续 3 次点不到发送按钮,可能被限流,已自动暂停'); break; } await patchState({ noteCursor: idx + 1, message: `未找到可用的发送按钮(连续 ${failStreak} 次),跳过` }); continue; } failStreak = 0; // 已点击发送即视为动作完成:即使没捕捉到确认信号,也照样记录该笔记 id, // 避免"实际已发布成功却被判失败"→ 未记录 → 下一轮重复评论同一篇 // ---- 成功:记录、关闭、随机延时 ---- commentedMap[target.id] = Date.now(); await S.storageSet({ [S.KEYS.commented]: S.trimCommented(commentedMap, COMMENTED_MAX) }); const after = await getState(); const doneCount = (after.doneCount || 0) + 1; const sessionCommented = (after.sessionCommented || 0) + 1; await patchState({ doneCount, sessionCommented, phase: 'closing', message: confirmed ? `评论成功 ✓ 本词 ${doneCount}/${settings.perKeywordCount},累计 ${sessionCommented}` : `已发送(未捕捉到确认信号,按成功处理)✓ 本词 ${doneCount}/${settings.perKeywordCount},累计 ${sessionCommented}`, }); await C.closeNote(); const minMs = Math.max(1, settings.minDelaySec) * 1000; const maxMs = Math.max(minMs + 1000, settings.maxDelaySec * 1000); const delay = S.randomInt(minMs, maxMs); await patchState({ phase: 'waiting', message: `休息 ${Math.round(delay / 1000)} 秒…` }); await sleepPausable(delay); await patchState({ noteCursor: idx + 1, phase: 'idle' }); if (doneCount >= settings.perKeywordCount) { await patchState({ keywordIndex: ki + 1, doneCount: 0, noteCursor: 0, message: `「${kw}」已完成 ${doneCount} 条,切换下一个关键词`, }); } } } catch (e) { if (!(e instanceof Abort)) { try { await pauseTask('运行异常:' + (e && e.message ? e.message : String(e))); } catch (_) { /* ignore */ } } } finally { looping = false; } } // ---------- 面板调用的控制接口 ---------- async function startTask() { // 授权门禁:试用到期/未激活时拒绝启动(与面板锁定双保险) const LIC = window.__XHS_LICENSE__; if (LIC) { let lic = null; try { lic = await LIC.getState(); } catch (e) { lic = null; } if (lic && lic.locked) return { ok: false, msg: lic.reason || '未激活:请在面板扫码付款并用激活码解锁' }; } const keywords = await S.storageGet(S.KEYS.keywords, []); const comments = await S.storageGet(S.KEYS.comments, []); if (!keywords.length) return { ok: false, msg: '关键词为空:请在面板粘贴关键词后点「解析添加」' }; if (!comments.length) return { ok: false, msg: '评论内容为空:请在面板「评论内容」里至少写一条' }; const st = await getState(); if (st.status === 'paused') { await patchState({ status: 'running', message: '继续运行…' }); runLoop(); return { ok: true }; } await patchState({ status: 'running', keywordIndex: 0, doneCount: 0, noteCursor: 0, sessionCommented: 0, phase: 'idle', message: '任务开始', }); runLoop(); return { ok: true }; } async function pause() { await pauseTask('手动暂停'); } async function resume() { await patchState({ status: 'running', message: '继续运行…' }); runLoop(); } async function stop() { await stopTask('已手动停止'); // 停止时顺手关闭遗留的笔记弹窗,页面回到干净状态 try { await C.closeNoteIfOpen(); } catch (e) { /* ignore */ } } // ---------- 页面加载时的断点续跑 ---------- (async () => { const st = await getState(); if (st.status !== 'running') return; if (Date.now() - (st.lastActiveTs || 0) > FRESH_MS) { await pauseTask('任务中断超过 5 分钟,请人工确认后点「继续」'); return; } const keywords = await S.storageGet(S.KEYS.keywords, []); const kw = keywords[st.keywordIndex]; if (!kw) { await stopTask('关键词配置已变化,任务结束'); return; } if (S.currentKeyword() !== kw) { // 运行中被导航到别的页面:转暂停,避免劫持用户操作 await pauseTask('任务被导航打断,回到目标搜索页后点「继续」'); return; } // 等页面渲染稳定后续跑 await S.sleep(2000); runLoop(); })(); window.__XHS_RUNNER__ = { startTask, pause, resume, stop, getState }; })(); // ==================== panel.js ==================== // 小红书自动评论助手 - 页面内悬浮面板: // 小方块常驻(点击展开/收起)、可拖动、位置记忆;注入时清理旧实例宿主防"幽灵"方块 // 与 runner.js / content.js 处于同一内容脚本隔离环境,直接函数调用 (() => { if (window.__XHS_PANEL__) return; if (window !== window.top) return; // 只在顶层窗口显示 const S = window.__XHS_SHARED__; if (!S) return; // 清理旧实例残留宿主(扩展重载后旧 DOM 不会自动移除) document.querySelectorAll('#xhs-auto-host').forEach((el) => el.remove()); const PANEL_KEY = S.KEYS.panel; const R = () => window.__XHS_RUNNER__; const host = document.createElement('div'); host.id = 'xhs-auto-host'; host.style.cssText = 'position:fixed;z-index:2147483647;top:80px;right:24px;left:auto;'; const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = `
评
`; document.body.appendChild(host); const $ = (sel) => shadow.querySelector(sel); const panelEl = $('.panel'); const miniEl = $('.mini'); const kwPasteEl = $('#kw-paste'); const kwFileEl = $('#kw-file'); const kwChipsEl = $('#kw-chips'); const kwCntEl = $('#kw-cnt'); const commentsEl = $('#comments'); const setStatusEl = $('#st-status'); const setMsgEl = $('#st-msg'); const stLineEl = $('#st-line'); const stProgressEl = $('#st-progress'); const startBtn = $('#start'); const pauseBtn = $('#pause'); const stopBtn = $('#stop'); const lockViewEl = $('#lock-view'); const licBarEl = $('#lic-bar'); const LIC = window.__XHS_LICENSE__; let minimized = false; let keywordsCache = []; // ---------- 位置记忆 ---------- function clampPos(x, y) { const off = 54; const w = minimized ? 46 : off + (panelEl.offsetWidth || 380); const h = minimized ? 46 : off + (panelEl.offsetHeight || 300); return { x: Math.min(Math.max(0, x), Math.max(0, window.innerWidth - w)), y: Math.min(Math.max(0, y), Math.max(0, window.innerHeight - h)), }; } function applyPos(x, y) { const p = clampPos(x, y); host.style.left = p.x + 'px'; host.style.top = p.y + 'px'; host.style.right = 'auto'; host.style.bottom = 'auto'; } function savePos() { try { const x = parseFloat(host.style.left); const y = parseFloat(host.style.top); if (Number.isNaN(x) || Number.isNaN(y)) return; S.storageSet({ [PANEL_KEY]: { x, y } }); } catch (e) { /* ignore */ } } // 只恢复位置,不恢复展开状态(避免异步恢复覆盖用户当前操作) (function restorePos() { try { S.storageGet(PANEL_KEY, null).then((st) => { if (st && typeof st.x === 'number' && typeof st.y === 'number') applyPos(st.x, st.y); }); } catch (e) { /* ignore */ } })(); // ---------- 拖动 ---------- function makeDraggable(handle, opts) { opts = opts || {}; let startX = 0, startY = 0, startPos = null, dragging = false, moved = false; handle.addEventListener('pointerdown', (e) => { if (e.target.closest && e.target.closest('.p-btn-sm')) return; dragging = true; moved = false; startX = e.clientX; startY = e.clientY; startPos = { x: parseFloat(host.style.left) || host.getBoundingClientRect().left, y: parseFloat(host.style.top) || host.getBoundingClientRect().top, }; try { handle.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ } e.preventDefault(); }); handle.addEventListener('pointermove', (e) => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 4 || Math.abs(dy) > 4) moved = true; if (moved) applyPos(startPos.x + dx, startPos.y + dy); }); const finish = () => { if (!dragging) return; dragging = false; if (moved) savePos(); else if (opts.clickToToggle) setMinimized(!minimized); }; handle.addEventListener('pointerup', finish); handle.addEventListener('pointercancel', finish); } makeDraggable($('.p-header')); makeDraggable(miniEl, { clickToToggle: true }); function setMinimized(min) { minimized = min; panelEl.classList.toggle('collapsed', min); if (min) savePos(); } $('[data-min]').addEventListener('click', () => setMinimized(true)); $('[data-close]').addEventListener('click', () => host.remove()); // ---------- 关键词管理 ---------- async function saveKeywords(list) { keywordsCache = list; await S.storageSet({ [S.KEYS.keywords]: list }); renderChips(); } async function mergeKeywords(parsed) { if (!parsed.length) return; const cur = await S.storageGet(S.KEYS.keywords, []); const seen = new Set(cur); const merged = cur.slice(); for (const kw of parsed) { if (!seen.has(kw)) { seen.add(kw); merged.push(kw); } } await saveKeywords(merged); } function renderChips() { kwChipsEl.innerHTML = ''; kwCntEl.textContent = keywordsCache.length ? `(${keywordsCache.length})` : ''; if (!keywordsCache.length) { const chip = document.createElement('span'); chip.className = 'chip empty'; chip.textContent = '暂无关键词'; kwChipsEl.appendChild(chip); return; } keywordsCache.forEach((kw, i) => { const chip = document.createElement('span'); chip.className = 'chip'; chip.title = '点击 × 移除'; const txt = document.createElement('span'); txt.textContent = kw; const x = document.createElement('i'); x.textContent = '×'; x.addEventListener('click', async () => { const list = keywordsCache.slice(); list.splice(i, 1); await saveKeywords(list); }); chip.appendChild(txt); chip.appendChild(x); kwChipsEl.appendChild(chip); }); } $('#kw-add').addEventListener('click', async () => { const parsed = S.parseKeywordRows(kwPasteEl.value); if (!parsed.length) { flashMsg('未解析到关键词', true); return; } await mergeKeywords(parsed); kwPasteEl.value = ''; flashMsg(`已添加 ${parsed.length} 个关键词 ✓`); }); $('#kw-csv').addEventListener('click', () => kwFileEl.click()); kwFileEl.addEventListener('change', () => { const f = kwFileEl.files && kwFileEl.files[0]; if (!f) return; const reader = new FileReader(); reader.onload = async () => { const parsed = S.parseKeywordRows(String(reader.result || '')); if (!parsed.length) { flashMsg('CSV 中未解析到关键词', true); return; } await mergeKeywords(parsed); flashMsg(`CSV 导入 ${parsed.length} 个关键词 ✓`); }; reader.readAsText(f, 'utf-8'); kwFileEl.value = ''; }); $('#kw-clear').addEventListener('click', () => saveKeywords([])); // ---------- 评论内容 / 设置:输入即自动保存(内容可随时更换) ---------- let saveTimer = null; function scheduleAutosave() { clearTimeout(saveTimer); saveTimer = setTimeout(saveAll, 700); } async function saveAll() { const settings = { perKeywordCount: clampNum($('#set-perkw').value, 1, 50, S.DEFAULT_SETTINGS.perKeywordCount), sessionMax: clampNum($('#set-session').value, 1, 200, S.DEFAULT_SETTINGS.sessionMax), minDelaySec: clampNum($('#set-min').value, 1, 3600, S.DEFAULT_SETTINGS.minDelaySec), maxDelaySec: clampNum($('#set-max').value, 1, 3600, S.DEFAULT_SETTINGS.maxDelaySec), skipCommented: $('#set-skip').checked, loopKeywords: $('#set-loop').checked, }; const comments = commentsEl.value.split('\n').map((s) => s.trim()).filter(Boolean); await S.storageSet({ [S.KEYS.settings]: settings, [S.KEYS.comments]: comments }); } function clampNum(v, min, max, def) { const n = parseInt(v, 10); if (Number.isNaN(n)) return def; return Math.min(max, Math.max(min, n)); } commentsEl.addEventListener('input', scheduleAutosave); ['#set-perkw', '#set-session', '#set-min', '#set-max', '#set-skip', '#set-loop'].forEach((sel) => { $(sel).addEventListener('change', scheduleAutosave); }); $('#save').addEventListener('click', async () => { clearTimeout(saveTimer); await saveAll(); flashMsg('设置已保存 ✓'); }); // 恢复已保存的配置 (async function restoreCfg() { const kw = await S.storageGet(S.KEYS.keywords, []); keywordsCache = kw; renderChips(); const comments = await S.storageGet(S.KEYS.comments, []); commentsEl.value = comments.join('\n'); const st = await S.storageGet(S.KEYS.settings, {}); const s = Object.assign({}, S.DEFAULT_SETTINGS, st); $('#set-perkw').value = s.perKeywordCount; $('#set-session').value = s.sessionMax; $('#set-min').value = s.minDelaySec; $('#set-max').value = s.maxDelaySec; $('#set-skip').checked = !!s.skipCommented; $('#set-loop').checked = !!s.loopKeywords; })(); // ---------- 授权锁定 ---------- function fmtRemaining(ms) { if (ms <= 0) return '已到期'; const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`; } async function applyLicenseState() { if (!LIC) return; let st; try { st = await LIC.getState(); } catch (e) { return; } if (st.locked) { licBarEl.classList.add('hidden'); lockViewEl.classList.remove('hidden'); $('.p-body').classList.add('hidden'); $('#dev-code').textContent = LIC.formatDevice(st.deviceId || ''); $('#lic-msg').textContent = st.reason || ''; return; } // 未锁定:显示授权条,恢复功能界面 lockViewEl.classList.add('hidden'); $('.p-body').classList.remove('hidden'); licBarEl.classList.remove('hidden'); const left = st.until - Date.now(); licBarEl.textContent = `🔑 授权有效 · 剩余 ${fmtRemaining(left)}(至 ${new Date(st.until).toLocaleString()})`; licBarEl.classList.toggle('expiring', left < 6 * 3600 * 1000); } $('#dev-copy').addEventListener('click', async () => { const txt = ($('#dev-code').textContent || '').replace(/-/g, ''); const msg = $('#lic-msg'); let ok = false; try { await navigator.clipboard.writeText(txt); ok = true; } catch (e) { try { const r = document.createRange(); r.selectNodeContents($('#dev-code')); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); ok = document.execCommand('copy'); sel.removeAllRanges(); } catch (e2) { ok = false; } } msg.classList.toggle('err', !ok); msg.textContent = ok ? '设备码已复制 ✓ 连同付款截图发给作者' : '复制失败,请手动选中设备码复制'; }); $('#lic-activate').addEventListener('click', async () => { if (!LIC) return; const msg = $('#lic-msg'); msg.classList.remove('err'); msg.textContent = '校验中…'; const r = await LIC.activate($('#lic-code').value); if (r.ok) { msg.textContent = '激活成功 ✓'; $('#lic-code').value = ''; await applyLicenseState(); flashMsg('授权已激活 ✓'); } else { msg.classList.add('err'); msg.textContent = r.msg || '激活失败'; } }); $('#lic-code').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#lic-activate').click(); }); applyLicenseState(); setInterval(applyLicenseState, 30 * 1000); // 到期瞬间自动切到锁定界面 // ---------- 运行控制 ---------- startBtn.addEventListener('click', async () => { // 门禁兜底:锁定时即使界面被绕过也不允许启动 if (LIC) { let lst = null; try { lst = await LIC.getState(); } catch (e) { /* 查询失败不拦 */ } if (lst && lst.locked) { await applyLicenseState(); flashMsg(lst.reason || '未激活:请扫码付款并激活(作者微信号见锁定页)', true); return; } } const r = R(); if (!r) { flashMsg('脚本未就绪,请刷新页面', true); return; } // 先把界面上还没落盘的内容写入 storage(评论是延迟自动保存的), // 否则"粘贴完立刻点开始"会因为还没写入而误报"未配置" clearTimeout(saveTimer); const leftover = S.parseKeywordRows(kwPasteEl.value); if (leftover.length) { await mergeKeywords(leftover); kwPasteEl.value = ''; } await saveAll(); const res = await r.startTask(); if (!res.ok) flashMsg(res.msg, true); }); pauseBtn.addEventListener('click', async () => { const r = R(); if (r) await r.pause(); }); stopBtn.addEventListener('click', async () => { const r = R(); if (r) await r.stop(); }); function flashMsg(msg, isErr) { setMsgEl.textContent = msg; stLineEl.classList.toggle('err', !!isErr); clearTimeout(flashMsg._t); flashMsg._t = setTimeout(() => { flashMsg._t = null; // 清除标记,恢复状态轮询 renderStatus(); }, 3000); } // 状态轮询(1 秒) async function renderStatus() { if (flashMsg._t) return; // 临时消息展示期间不覆盖 const st = await S.storageGet(S.KEYS.task, null); const status = (st && st.status) || 'idle'; const msg = (st && st.message) || ''; const running = status === 'running'; const paused = status === 'paused'; setStatusEl.textContent = running ? '运行中' : paused ? '已暂停' : '未运行'; stLineEl.className = 's-line ' + (running ? 'run' : paused ? 'paused' : ''); setMsgEl.textContent = msg; stLineEl.classList.remove('err'); startBtn.textContent = paused ? '继续' : '开始'; startBtn.disabled = running; pauseBtn.disabled = !running; stopBtn.disabled = !(running || paused); // 进度行 if (status === 'idle') { stProgressEl.textContent = ''; return; } const keywords = await S.storageGet(S.KEYS.keywords, []); const settings = Object.assign({}, S.DEFAULT_SETTINGS, await S.storageGet(S.KEYS.settings, {})); const commentedMap = await S.storageGet(S.KEYS.commented, {}); const ki = (st && st.keywordIndex) || 0; const kwNow = keywords[ki] ? `当前「${keywords[ki]}」` : ''; const total = keywords.length; stProgressEl.textContent = `关键词 ${Math.min(ki + 1, total)}/${total} ${kwNow} · 本词 ${st.doneCount || 0}/${settings.perKeywordCount} · 累计 ${st.sessionCommented || 0} 条 · 已评论过 ${Object.keys(commentedMap).length} 篇(会跳过)`; } setInterval(renderStatus, 1000); renderStatus(); })(); })();