// ==UserScript== // @name 天雪论坛自动回复刷分用户脚本 // @namespace https://skyeysnow.com/ // @version 1.1.0 // @author Roach Lin / Modified // @description 天雪论坛水楼自动回复脚本(超级防御型代码,详见说明)。仅供学习与研究,使用者需自行承担账号风险。 // @match https://skyeysnow.com/* // @match http://skyeysnow.com/* // @match https://www.skyey2.com/* // @match http://www.skyey2.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @run-at document-idle // @license MIT // ==/UserScript== /* * 免责声明 * 本脚本用于在该论坛水楼帖自动回复。论坛将此类自动化行为视为违规操作 * 可能导致封号、扣分、封 IP 等后果。请自行评估并承担风险,作者不对使用后果负责。 */ /* * ============================================================================ * 脚本说明 * ---------------------------------------------------------------------------- * 天雪论坛水楼自动回复:按随机间隔在指定水楼帖内回复一条诗句, * 内置夜间限额、防连续刷楼、多标签页主控选举与操作日志。 * * 代码分层(自上而下,便于定位): * §0 存储键与默认值 §1 安全存储层 §2 通用工具 * §3 配置读取 §4 主控标签页选举 §5 操作日志 * §6 页面适配 §7 调度器 §8 设置面板 * §9 设置面板:日志区域 §10 倒计时 §11 主入口 * * 设计要点: * · GM_* 与 sessionStorage 的读写一律经 §1 安全层:读取失败退化为默认值, * 写入/删除失败只告警,不让存储异常中断主流程;配置读取统一走 §3 * readNumber(),默认值与校验规则各只写一份。 * · 定时到点统一经 _onTimerTick() 进入(1 秒轮询与可见性恢复共用), * 启动期立即触发(NEED_REFRESH / 已过期的持久化定时器)经 _triggerReplyOnStartup(), * 设置面板的「测试发送」也套同一层异常兜底;三处异常时都复位 pending 状态并 * 保留原有的重新排程(定时/启动期 3 秒、测试发送 5 秒),避免调度器静默停摆。 * * 已知限制(当前实现有意保留,改动即属行为变更): * 1. canPostNow() 把「作者解析失败」(author 为 null,如匿名/已注销楼层) * 计为"别人的帖子",极端情况下会放宽防刷判定; * 2. 多标签主控选举是尽力而为:GM 存储非原子,极端并发下仍有双页同发窗口; * 3. 提交后设置的 8 秒兜底刷新不会执行:3 秒后的定时器重启会先清除它, * 实际依赖 Discuz 自身的自动跳转,提交失败时页面不会因此刷新; * 4. 测试发送(immediate)不参与主控协商,会把共享的 PENDING_TARGET 清零; * 5. canPostNow() 只统计当前页 DOM 的楼层,跨页数据不参与判定。 * ============================================================================ */ (function () { 'use strict'; // ================= §0 存储键与默认值 ================= var STORAGE_KEY_URL = 'skyeysnow_water_tid'; var STORAGE_KEY_DATE = 'skyeysnow_water_set_ym'; var STORAGE_KEY_DELAY_MIN = 'skyeysnow_reply_delay_min'; var STORAGE_KEY_DELAY_MAX = 'skyeysnow_reply_delay_max'; var STORAGE_KEY_AUTO_JUMP = 'skyeysnow_auto_jump'; var STORAGE_KEY_POEMS = 'skyeysnow_poems'; var STORAGE_KEY_NIGHT_START = 'skyeysnow_night_start'; var STORAGE_KEY_NIGHT_END = 'skyeysnow_night_end'; var STORAGE_KEY_NIGHT_DELAY_MIN = 'skyeysnow_night_delay_min'; var STORAGE_KEY_NIGHT_DELAY_MAX = 'skyeysnow_night_delay_max'; var STORAGE_KEY_NIGHT_MAX_COUNT = 'skyeysnow_night_max_count'; var STORAGE_KEY_NIGHT_SENT = 'skyeysnow_night_sent_count'; var STORAGE_KEY_NIGHT_DATE = 'skyeysnow_night_date'; var STORAGE_KEY_NIGHT_WARNED = 'skyeysnow_night_warned'; var STORAGE_KEY_PENDING_TARGET = 'skyeysnow_pending_target_time'; var STORAGE_KEY_LOG = 'skyeysnow_operation_log'; var STORAGE_KEY_MAX_LOG = 'skyeysnow_max_log'; var STORAGE_KEY_MY_USERNAME = 'skyeysnow_my_username'; var STORAGE_KEY_POST_GAP = 'skyeysnow_post_gap'; // 主控选举相关键 var STORAGE_KEY_LEADER_ID = 'skyeysnow_leader_id'; var STORAGE_KEY_LEADER_HB = 'skyeysnow_leader_heartbeat'; // 一次性会话标志(sessionStorage:同标签页刷新保留,关闭即失效) var SESSION_KEY_NEED_REFRESH = 'skyeysnow_need_refresh'; var SESSION_KEY_NO_USERNAME_ALERTED = 'skyeysnow_no_username_alerted'; var SESSION_KEY_SKIP_TID_PROMPT = 'skyeysnow_skip_tid_prompt'; var SESSION_KEY_TAB_ID = 'skyeysnow_tab_id'; // 各配置项的默认值集中定义,配置读取(§3)与设置面板共用这一份 var DEFAULT_DAY_DELAY_MIN = 25; var DEFAULT_DAY_DELAY_MAX = 35; var DEFAULT_NIGHT_START = 23; var DEFAULT_NIGHT_END = 8; var DEFAULT_NIGHT_DELAY_MIN = 60; var DEFAULT_NIGHT_DELAY_MAX = 120; var DEFAULT_NIGHT_MAX_COUNT = 3; var DEFAULT_POST_GAP = 1; var DEFAULT_MAX_LOG = 100; var MIN_MAX_LOG = 10; // 日志渲染回调(模块内私有,避免污染 window) var _logDisplayCallback = null; var _settingsOpen = false; // 本标签页唯一 ID(sessionStorage:刷新保留、关标签页失效) // sessionStorage 在隐私模式 / 企业策略下可能抛 SecurityError, // 若此处不捕获会导致整个脚本静默不执行,故降级为内存级 ID。 var TAB_ID = (function () { try { var id = sessionStorage.getItem(SESSION_KEY_TAB_ID); if (!id) { id = 'tab_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); sessionStorage.setItem(SESSION_KEY_TAB_ID, id); } return id; } catch (e) { console.warn('[AutoReply] sessionStorage 不可用,TAB_ID 降级为内存级:', e); return 'tab_volatile_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); } })(); // 主控心跳参数 var LEADER_TIMEOUT_MS = 6000; // 心跳超过此时间未更新,主控视为已失效 var HEARTBEAT_INTERVAL_MS = 2000; // 主控心跳间隔 var _heartbeatTimer = null; // ================= §1 安全存储层 ================= // 所有存储访问统一收口:读取失败退化为默认值,写入/删除失败只告警, // 不让存储异常中断脚本主流程。 function gmGet(key, defValue) { try { return GM_getValue(key, defValue); } catch (e) { console.warn('[AutoReply] 读取配置失败(' + key + '):', e); return defValue; } } function gmSet(key, value) { try { GM_setValue(key, value); return true; } catch (e) { console.warn('[AutoReply] 写入配置失败(' + key + '):', e); return false; } } function sessionGet(key) { try { return sessionStorage.getItem(key); } catch (e) { return null; } } function sessionSet(key, value) { try { sessionStorage.setItem(key, value); } catch (e) { // 降级为「无会话记忆」,仅影响跨刷新的一次性标志 } } function sessionRemove(key) { try { sessionStorage.removeItem(key); } catch (e) { // 同 sessionSet,降级处理 } } // ================= §2 通用工具 ================= function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function getThreadTid() { var params = new URLSearchParams(window.location.search); return params.get('tid'); } function getCurrentYM() { var d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); } // 日志时间戳(含日期,便于跨天区分) function getTimestamp() { var d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); } // 上限非法时的统一回退规则:至少比 min 大 10,且不低于该配置的默认上限。 function fallbackMax(min, defMax) { return Math.max(min + 10, defMax); } function getCurrentOrigin() { return window.location.origin || 'https://skyeysnow.com'; } // ================= §3 配置读取 ================= // 所有「Number(存储值) + isNaN/范围校验 + 回退」的唯一入口。 /** * 读取数值型配置,非法时回退。 * @param {string} key 存储键 * @param {number} defValue 缺省值(键不存在时的基准值,也是默认回退值) * @param {function(number):boolean} [valid] 额外校验,返回 false 视为非法 * @param {function(number):number} [makeFallback] 非法时按基准值计算回退值 * @returns {number} */ function readNumber(key, defValue, valid, makeFallback) { var v = Number(gmGet(key, defValue)); if (isNaN(v) || (valid && !valid(v))) { return makeFallback ? makeFallback(defValue) : defValue; } return v; } function getDelayMin() { return readNumber(STORAGE_KEY_DELAY_MIN, DEFAULT_DAY_DELAY_MIN, function (v) { return v >= 1; }); } function getDelayMax() { var min = getDelayMin(); return readNumber(STORAGE_KEY_DELAY_MAX, DEFAULT_DAY_DELAY_MAX, function (v) { return v >= min; }, function () { return fallbackMax(min, DEFAULT_DAY_DELAY_MAX); }); } function shouldAutoJump() { var v = gmGet(STORAGE_KEY_AUTO_JUMP, true); return v !== false && v !== 'false'; } function getPoems() { var raw = gmGet(STORAGE_KEY_POEMS, ''); if (raw && String(raw).trim().length > 0) { return String(raw).split('\n').filter(function (line) { return line.trim().length > 0; }); } return [ '山有木兮木有枝,心悦君兮君不知。', '人生若只如初见,何事秋风悲画扇。', '十年生死两茫茫,不思量,自难忘。', '曾经沧海难为水,除却巫山不是云。', '玲珑骰子安红豆,入骨相思知不知。', '只愿君心似我心,定不负相思意。', '平生不会相思,才会相思,便害相思。', '愿得一心人,白头不相离。', '山无陵,江水为竭。冬雷震震,夏雨雪。天地合,乃敢与君绝。', '入我相思门,知我相思苦。' ]; } function getNightStart() { return readNumber(STORAGE_KEY_NIGHT_START, DEFAULT_NIGHT_START); } function getNightEnd() { return readNumber(STORAGE_KEY_NIGHT_END, DEFAULT_NIGHT_END); } function getNightDelayMin() { return readNumber(STORAGE_KEY_NIGHT_DELAY_MIN, DEFAULT_NIGHT_DELAY_MIN, function (v) { return v >= 1; }); } function getNightDelayMax() { var min = getNightDelayMin(); return readNumber(STORAGE_KEY_NIGHT_DELAY_MAX, DEFAULT_NIGHT_DELAY_MAX, function (v) { return v >= min; }, function () { return fallbackMax(min, DEFAULT_NIGHT_DELAY_MAX); }); } function getNightMaxCount() { return readNumber(STORAGE_KEY_NIGHT_MAX_COUNT, DEFAULT_NIGHT_MAX_COUNT, function (v) { return v >= 1; }); } function getPostGap() { return readNumber(STORAGE_KEY_POST_GAP, DEFAULT_POST_GAP, function (v) { return v >= 0; }); } // 夜间时段窗口:一次算出起止、当前小时、是否夜间,以及本时段的归属日期。 // 跨零点时(start > end),零点之后仍归属到"开始那天"的夜间时段。 function getNightWindow() { var start = getNightStart(); var end = getNightEnd(); var now = new Date(); var hour = now.getHours(); var isNight = (start <= end) ? (hour >= start && hour < end) : (hour >= start || hour < end); var periodDate = new Date(now); if (start > end && hour < end) { periodDate.setDate(periodDate.getDate() - 1); } return { start: start, end: end, hour: hour, isNight: isNight, periodDate: periodDate }; } function isNightTime() { return getNightWindow().isNight; } // 当前夜间时段的唯一标识(跨零点时归属到开始那天) function getNightPeriodKey() { var periodDate = getNightWindow().periodDate; return periodDate.getFullYear() + '-' + String(periodDate.getMonth() + 1).padStart(2, '0') + '-' + String(periodDate.getDate()).padStart(2, '0'); } function getCurrentDelayRange() { if (isNightTime()) { return { min: getNightDelayMin(), max: getNightDelayMax() }; } return { min: getDelayMin(), max: getDelayMax() }; } function canSendAtNight() { if (!isNightTime()) return true; var maxCount = getNightMaxCount(); var periodKey = getNightPeriodKey(); var savedPeriod = gmGet(STORAGE_KEY_NIGHT_DATE, ''); var sentCount = Number(gmGet(STORAGE_KEY_NIGHT_SENT, 0)); if (isNaN(sentCount)) { // 存储值被写坏时按「今夜还没发过」处理,避免整夜静默不发;顺手把坏值修回数字。 console.warn('[AutoReply] 夜间已发条数不是数字,已按 0 处理。'); sentCount = 0; gmSet(STORAGE_KEY_NIGHT_SENT, 0); } if (savedPeriod !== periodKey) { gmSet(STORAGE_KEY_NIGHT_DATE, periodKey); gmSet(STORAGE_KEY_NIGHT_SENT, 0); gmSet(STORAGE_KEY_NIGHT_WARNED, ''); return true; } return sentCount < maxCount; } function recordNightSent() { if (!isNightTime()) return; gmSet(STORAGE_KEY_NIGHT_DATE, getNightPeriodKey()); var sentCount = Number(gmGet(STORAGE_KEY_NIGHT_SENT, 0)); gmSet(STORAGE_KEY_NIGHT_SENT, isNaN(sentCount) ? 1 : sentCount + 1); } // ================= §4 主控标签页选举 ================= // 设计: // - GM 存储中保存 leaderId + 心跳时间戳 // - 每个标签页在 sessionStorage 中有唯一 TAB_ID // - doReply / 主入口先尝试成为主控;抢不到则静默等待 // - 主控启动 2 秒心跳;超过 LEADER_TIMEOUT_MS 未更新则其他页可抢占 // - 刷新页面 TAB_ID 不变,主控身份自然保留 // - 关闭页面不主动释放(等待心跳超时自动失效),避免刷新瞬间被其他页抢占 function _isLeader() { return gmGet(STORAGE_KEY_LEADER_ID, '') === TAB_ID; } function _tryBecomeLeader() { var leaderId = gmGet(STORAGE_KEY_LEADER_ID, ''); var hb = Number(gmGet(STORAGE_KEY_LEADER_HB, 0)); // 心跳值被写坏时按「已超时」处理:否则 (now - NaN) > 超时 恒为 false,本页永不接管主控。 if (isNaN(hb)) hb = 0; var now = Date.now(); // 自己已是主控 → 刷新心跳 if (leaderId === TAB_ID) { gmSet(STORAGE_KEY_LEADER_HB, now); return true; } // 无主控 或 主控心跳超时 → 抢占 if (!leaderId || (now - hb) > LEADER_TIMEOUT_MS) { gmSet(STORAGE_KEY_LEADER_ID, TAB_ID); gmSet(STORAGE_KEY_LEADER_HB, now); // 写入后回读校验:GM 存储非原子,两个标签页可能同时判定超时后都写入, // 回读可确保最终只有后写入的 TAB_ID 存活,避免双方都认为抢到主控。 if (gmGet(STORAGE_KEY_LEADER_ID, '') !== TAB_ID) { console.log('[AutoReply][多标签] 抢主控失败,已被其他标签页抢先'); return false; } console.log('[AutoReply][多标签] 本页接管主控身份(原主控: ' + (leaderId || '无') + ')'); return true; } return false; } function _startHeartbeat() { if (_heartbeatTimer) return; _heartbeatTimer = setInterval(function () { // 心跳异常不能中断定时器本身,否则主控会在 6 秒后被误判失效 try { if (_isLeader()) { gmSet(STORAGE_KEY_LEADER_HB, Date.now()); } } catch (e) { console.warn('[AutoReply][多标签] 心跳续期失败:', e); } }, HEARTBEAT_INTERVAL_MS); } function _stopHeartbeat() { if (_heartbeatTimer) { clearInterval(_heartbeatTimer); _heartbeatTimer = null; } } // ================= §5 操作日志 ================= function addLog(type, message) { var timestamp = getTimestamp(); var icon = ''; switch (type) { case 'auto': icon = '[自动发送] '; break; case 'test': icon = '[测试发送] '; break; case 'error': icon = '[错误] '; break; case 'info': icon = '[信息] '; break; default: icon = '[日志] '; break; } var logEntry = { time: timestamp, type: type, message: icon + message }; var logs = getLogs(); logs.unshift(logEntry); var maxLog = readNumber(STORAGE_KEY_MAX_LOG, DEFAULT_MAX_LOG, function (v) { return v >= MIN_MAX_LOG; }); if (logs.length > maxLog) logs = logs.slice(0, maxLog); gmSet(STORAGE_KEY_LOG, JSON.stringify(logs)); if (_logDisplayCallback) { // 渲染失败不能反过来打断调用方(发帖 / 调度)的主流程 try { _logDisplayCallback(logs); } catch (e) { console.warn('[AutoReply] 日志渲染失败:', e); } } } function getLogs() { var raw = gmGet(STORAGE_KEY_LOG, '[]'); var logs; try { logs = JSON.parse(raw); } catch (e) { return []; } // 存储值被改写为非数组时按空日志处理,否则 addLog 的 unshift // 会抛错并顺着调用链中断本次发帖流程。 return Array.isArray(logs) ? logs : []; } function clearLogs() { gmSet(STORAGE_KEY_LOG, '[]'); if (_logDisplayCallback) { try { _logDisplayCallback([]); } catch (e) { console.warn('[AutoReply] 日志渲染失败:', e); } } } // ================= §6 页面适配 ================= function getMyUsername() { var v = gmGet(STORAGE_KEY_MY_USERNAME, ''); var trimmed = String(v).trim(); if (trimmed) return trimmed; try { var el = document.querySelector('#um .vwmy a, .vwmy a'); if (el && el.innerText) return el.innerText.trim(); } catch (e) {} return ''; } function getRecentPosts() { var posts = []; var postDivs = document.querySelectorAll('div[id^="post_"]'); postDivs.forEach(function (postDiv) { var postnumLink = postDiv.querySelector('a[id^="postnum"]'); var floor = null; if (postnumLink) { var em = postnumLink.querySelector('em'); if (em) floor = parseInt(em.innerText, 10); } var author = null; var authi = postDiv.querySelector('div.authi'); if (authi) { var authorLink = authi.querySelector('a.xw1'); if (authorLink) author = authorLink.innerText.trim(); } // parseInt 失败会返回 NaN,需一并过滤,否则会污染后续楼层排序 if (floor !== null && !isNaN(floor)) { posts.push({ floor: floor, author: author, pid: postDiv.id }); } }); posts.sort(function (a, b) { return b.floor - a.floor; }); return posts; } function canPostNow() { var myUsername = getMyUsername(); var minGap = getPostGap(); if (minGap === 0) return true; if (!myUsername) { return false; } var posts = getRecentPosts(); if (posts.length === 0) return true; var myLastPostIndex = -1; for (var i = 0; i < posts.length; i++) { if (posts[i].author === myUsername) { myLastPostIndex = i; break; } } if (myLastPostIndex === -1) return true; var otherPostsAfterMe = 0; for (var j = 0; j < myLastPostIndex; j++) { if (posts[j].author !== myUsername) otherPostsAfterMe++; } if (otherPostsAfterMe >= minGap) { console.log('[AutoReply] 防刷检查通过: 间隔 ' + otherPostsAfterMe + ' 条其他帖子 >= 要求 ' + minGap); return true; } console.log('[AutoReply] 防刷检查跳过: 间隔 ' + otherPostsAfterMe + ' 条其他帖子 < 要求 ' + minGap); addLog('info', '防连续刷楼检查跳过(间隔 ' + otherPostsAfterMe + ' 条其他帖子,需 ' + minGap + ' 条)'); return false; } function ensureJumpToLastPage() { var refreshCheckBox = document.getElementById('fastpostrefresh'); if (refreshCheckBox) { refreshCheckBox.checked = true; console.log('[AutoReply] 已自动勾选跳转到最后一页'); } else { console.warn('[AutoReply] fastpostrefresh 控件未找到,跳过跳转设置'); } } // ================= §7 调度器 ================= var _autoReplyPending = false; var _autoReplyCheckInterval = null; var _autoReplyVisibilityHandler = null; var _postReloadTimeout = null; // 「延迟重启」句柄:提交成功(post)与异常路径(exception)各一个槽位, // 由 _scheduleRestart() 设置、cleanupTimers() 统一清理。 var _restartTimers = { post: null, exception: null }; // 延迟 3 秒重新排程自动回复:提交成功、提交异常、定时触发异常三处共用。 function _scheduleRestart(slotKey, logMessage) { _restartTimers[slotKey] = setTimeout(function () { _restartTimers[slotKey] = null; startAutoReply(); addLog('info', logMessage); }, 3000); } // 「本轮跳过,把调度器交还下一轮」:先停表、再按新的随机间隔起表、 // 最后复位 pending,供各跳过分支共用。 function _skipRoundAndReschedule() { stopAutoReply(); startAutoReply(); _autoReplyPending = false; } function doReply(immediate) { // ================= 主控检查 ================= // 非测试模式下,先尝试抢主控;抢不到说明其他标签页正在负责发帖, // 本页静默跳过本轮,重启定时器继续等待。 if (!immediate) { if (_tryBecomeLeader()) { _startHeartbeat(); } else { console.log('[AutoReply][多标签] 本页非主控,本轮静默跳过'); _skipRoundAndReschedule(); return; } } // ---- 1. 夜间限额检查 ---- if (!immediate && !canSendAtNight()) { console.log('[AutoReply] 夜间发送条数已达上限,本次跳过。'); var periodKey = getNightPeriodKey(); if (gmGet(STORAGE_KEY_NIGHT_WARNED, '') !== periodKey) { addLog('error', '夜间发送条数已达上限,本次跳过'); gmSet(STORAGE_KEY_NIGHT_WARNED, periodKey); } _skipRoundAndReschedule(); return; } // 用户名缺失时暂停自动回复,避免"跳过 → 重启 → 再跳过"的死循环 var gap = getPostGap(); if (!immediate && gap > 0 && !getMyUsername()) { addLog('error', '未检测到用户名,防刷检查无法执行,自动回复已暂停'); stopAutoReply(); _autoReplyPending = false; if (!sessionGet(SESSION_KEY_NO_USERNAME_ALERTED)) { sessionSet(SESSION_KEY_NO_USERNAME_ALERTED, '1'); setTimeout(function () { alert('[天雪自动回复] 未检测到用户名,防刷检查无法执行,自动回复已暂停。\n' + '请在脚本猫弹窗里点本脚本的「打开设置」,填写"我的用户名"。'); }, 100); } return; } // 防刷检测前强制刷新页面(用 sessionStorage 记录一次性标志) if (!immediate && gap > 0) { if (sessionGet(SESSION_KEY_NEED_REFRESH) === '1') { sessionRemove(SESSION_KEY_NEED_REFRESH); console.log('[AutoReply] 页面已刷新,使用最新 DOM 数据执行防刷检测...'); } else { addLog('info', '防刷检测前强制刷新页面以获取最新帖子数据...'); sessionSet(SESSION_KEY_NEED_REFRESH, '1'); if (_autoReplyCheckInterval) { clearInterval(_autoReplyCheckInterval); _autoReplyCheckInterval = null; } location.reload(); return; } } // ---- 2. 防刷检查 ---- if (!immediate && !canPostNow()) { console.log('[AutoReply] 防连续刷楼:跳过本次发送,等待更多其他帖子。'); addLog('info', '防连续刷楼:跳过本次发送,等待其他用户发帖'); _skipRoundAndReschedule(); return; } // ---- 3. 内容准备 ---- var poems = getPoems(); if (poems.length === 0) { console.warn('[AutoReply] 诗句列表为空,本次不回复。'); addLog('error', '诗句列表为空,本次不回复'); _skipRoundAndReschedule(); return; } var messageEl = document.getElementById('fastpostmessage'); var submitEl = document.getElementById('fastpostsubmit'); if (!messageEl || !submitEl) { console.warn('[AutoReply] 未找到快速回复框或提交按钮,本次自动回复取消。'); addLog('error', '未找到快速回复框或提交按钮,本次自动回复取消'); _skipRoundAndReschedule(); return; } var poem = poems[Math.floor(Math.random() * poems.length)]; messageEl.value = poem; ensureJumpToLastPage(); try { submitEl.click(); // 兜底刷新:8 秒内未跳转则手动刷新页面。 // 注意:3 秒后的定时器重启会先清掉它,此分支实际不会执行。 if (!immediate) { _postReloadTimeout = setTimeout(function () { if (document.readyState === 'complete' || document.readyState === 'interactive') { console.log('[AutoReply] Discuz 原生跳转未生效,手动刷新页面以获取最新帖子状态'); location.reload(); } }, 8000); } if (immediate) { addLog('test', '测试回复已触发(诗句:' + poem + ')'); console.log('[AutoReply] 测试回复已触发'); } else { addLog('auto', '自动回复已触发(诗句:' + poem + ')'); console.log('[AutoReply] 自动回复已触发'); } if (!immediate) recordNightSent(); if (!immediate) { _scheduleRestart('post', '自动回复定时器已重新启动'); } _autoReplyPending = false; gmSet(STORAGE_KEY_PENDING_TARGET, 0); if (_autoReplyVisibilityHandler) { document.removeEventListener('visibilitychange', _autoReplyVisibilityHandler); _autoReplyVisibilityHandler = null; } } catch (e) { console.error('[AutoReply] 点击提交按钮失败:', e); addLog('error', '点击提交按钮失败:' + e.message); if (!immediate) { _scheduleRestart('exception', '异常后自动回复定时器已重新启动'); } _autoReplyPending = false; gmSet(STORAGE_KEY_PENDING_TARGET, 0); } } // 通用定时器清理 function cleanupTimers(keepPendingTarget) { if (_autoReplyCheckInterval) { clearInterval(_autoReplyCheckInterval); _autoReplyCheckInterval = null; } if (_autoReplyVisibilityHandler) { document.removeEventListener('visibilitychange', _autoReplyVisibilityHandler); _autoReplyVisibilityHandler = null; } if (_postReloadTimeout) { clearTimeout(_postReloadTimeout); _postReloadTimeout = null; } if (_restartTimers.post) { clearTimeout(_restartTimers.post); _restartTimers.post = null; } if (_restartTimers.exception) { clearTimeout(_restartTimers.exception); _restartTimers.exception = null; } _stopHeartbeat(); if (!keepPendingTarget) { gmSet(STORAGE_KEY_PENDING_TARGET, 0); } } function stopAutoReply() { // 保留 PENDING_TARGET:stopAutoReply 也用于"重启定时器"路径, // 若在此清零会破坏主控写下的目标时间(非主控页重启时尤为明显)。 // 真正需要清零的场景(发帖成功、发帖异常)已在 doReply 内部显式处理。 cleanupTimers(true); } // 定时到点时的统一入口:1 秒轮询与可见性恢复都经此进入。 function _onTimerTick(targetTime) { if (_autoReplyPending) return; if (Date.now() < targetTime) return; try { stopAutoReply(); _autoReplyPending = true; doReply(false); } catch (e) { // 万一仍有未预料的异常:必须复位 pending 并重新排程, // 否则 _autoReplyPending 会永久卡在 true,调度器彻底静默。 console.error('[AutoReply] 定时触发异常:', e); addLog('error', '定时触发异常:' + (e && e.message ? e.message : e)); _autoReplyPending = false; if (_restartTimers.exception) { clearTimeout(_restartTimers.exception); _restartTimers.exception = null; } _scheduleRestart('exception', '异常后自动回复定时器已重新启动'); } } // 启动期立即触发一轮回复(NEED_REFRESH 与「已过期的持久化定时器」两条路径共用): // 与 _onTimerTick 相同的异常兜底——若不兜住,异常会冲出 IIFE, // 初始化日志与 beforeunload 注册都会被跳过,且 _autoReplyPending 永久卡在 true。 function _triggerReplyOnStartup() { _autoReplyPending = true; try { doReply(false); } catch (e) { console.error('[AutoReply] 启动时触发回复异常:', e); addLog('error', '启动时触发回复异常:' + (e && e.message ? e.message : e)); _autoReplyPending = false; _scheduleRestart('exception', '异常后自动回复定时器已重新启动'); } } function startAutoReply(overrideTargetTime) { stopAutoReply(); var range = getCurrentDelayRange(); var delayMin = range.min; var delayMax = range.max; var randomMinutes; var targetTime; if (overrideTargetTime !== undefined && overrideTargetTime !== null) { targetTime = Number(overrideTargetTime); randomMinutes = Math.round((targetTime - Date.now()) / 60000); console.log('[AutoReply] 从持久化恢复计时,目标时间: ' + new Date(targetTime).toLocaleString()); } else { randomMinutes = Math.floor(Math.random() * (delayMax - delayMin + 1)) + delayMin; targetTime = Date.now() + randomMinutes * 60 * 1000; var periodLabel = isNightTime() ? '夜间' : '白天'; console.log('[AutoReply] ' + periodLabel + '模式,将在 ' + randomMinutes + ' 分钟后回复(目标时间: ' + new Date(targetTime).toLocaleString() + ')。'); } // ================= 只有主控才持久化 PENDING_TARGET ================= // 非主控页也会跑定时器(用于感知何时能接管),但不污染共享存储, // 避免多标签页互相覆盖对方的目标时间,导致刷新恢复时读到错误值。 if (_isLeader()) { gmSet(STORAGE_KEY_PENDING_TARGET, targetTime); } _autoReplyCheckInterval = setInterval(function () { _onTimerTick(targetTime); }, 1000); _autoReplyVisibilityHandler = function () { if (document.visibilityState === 'visible') { _onTimerTick(targetTime); } }; document.addEventListener('visibilitychange', _autoReplyVisibilityHandler); // 主控身份需要靠心跳续期。stopAutoReply 会停掉心跳, // 若此处不补回,发帖成功 3 秒后重启定时器时主控会在 6 秒后失效, // 导致另一个标签页抢走主控并重复发帖。 if (_isLeader()) { _startHeartbeat(); } } // ================= §8 设置面板 ================= // 提示用户输入水楼 tid(最多尝试 10 次) function promptForTid() { var MAX_TRIES = 10; for (var i = 0; i < MAX_TRIES; i++) { var tid = prompt('请输入水楼帖的 tid(帖子ID,可在URL中查看,如 forum.php?mod=viewthread&tid=12345):'); if (tid === null) { sessionSet(SESSION_KEY_SKIP_TID_PROMPT, '1'); return; } tid = tid.trim(); if (tid.length === 0) { alert('tid 不能为空,请重新输入。'); continue; } if (/^\d+$/.test(tid)) { gmSet(STORAGE_KEY_URL, tid); gmSet(STORAGE_KEY_DATE, getCurrentYM()); addLog('info', '已保存水楼 tid: ' + tid); sessionSet(SESSION_KEY_SKIP_TID_PROMPT, '1'); window.location.href = getCurrentOrigin() + '/forum.php?mod=viewthread&tid=' + tid; return; } alert('tid 必须是纯数字,请重新输入。'); } sessionSet(SESSION_KEY_SKIP_TID_PROMPT, '1'); alert('多次输入无效,已跳过本次 tid 设置。可稍后从脚本猫弹窗里点「打开设置」重新配置。'); } function openSettings() { if (_settingsOpen) return; _settingsOpen = true; var savedTid = gmGet(STORAGE_KEY_URL, ''); var savedYM = gmGet(STORAGE_KEY_DATE, ''); var delayMin = getDelayMin(); var delayMax = getDelayMax(); var autoJump = shouldAutoJump(); var poemsText = getPoems().join('\n'); var nightStart = getNightStart(); var nightEnd = getNightEnd(); var nightDelayMin = getNightDelayMin(); var nightDelayMax = getNightDelayMax(); var nightMaxCount = getNightMaxCount(); var remainingTime = getRemainingTimeString(); var html = '
' + '

天雪水楼脚本设置

' + '
' + '距离下次发帖:' + remainingTime + '' + '
' + '' + '' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + '

夜间模式

' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '

防连续刷楼

' + '' + '' + '' + '' + '

设为 0 表示不检查防刷。建议设为 1,即至少要有 1 条别人的帖子才回复。

' + '' + '' + '
' + '' + '' + '' + '' + '
' + '

' + '当前记录月份:' + escapeHtml(savedYM || '未设置') + '
' + '注意:脚本不会自动打开浏览器,仍需你访问天雪论坛页面后才会运行。' + '

' + '
'; var overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;' + 'background:rgba(0,0,0,0.45);z-index:2147483647;' + 'display:flex;align-items:center;justify-content:center;'; var box = document.createElement('div'); box.style.cssText = 'background:#fff;border-radius:10px;padding:14px;' + 'max-width:600px;width:92%;box-shadow:0 8px 24px rgba(0,0,0,0.25);'; box.innerHTML = html; overlay.appendChild(box); document.documentElement.appendChild(overlay); injectSettingsExtras(box); var countdownInterval = setInterval(function () { var el = document.getElementById('settings-countdown'); if (el) el.innerText = getRemainingTimeString(); }, 1000); // 统一关闭动作:清倒计时 + 摘除遮罩 + 复位开关。 function closeSettings() { clearInterval(countdownInterval); overlay.remove(); _settingsOpen = false; } // 元素缺失时只告警不抛错,否则 _settingsOpen 会卡在 true,面板再也打不开 function bindClick(id, handler) { var el = document.getElementById(id); if (!el) { console.warn('[AutoReply] 设置面板缺少元素 #' + id + ',已跳过绑定'); return; } el.onclick = handler; } // ---- 保存按钮 ---- bindClick('s_save', function () { var tid = document.getElementById('s_tid').value.trim(); var dMin = Number(document.getElementById('s_delay_min').value); var dMax = Number(document.getElementById('s_delay_max').value); var jump = document.getElementById('s_jump').checked; var poemsRaw = document.getElementById('s_poems').value; var nStart = Number(document.getElementById('s_night_start').value); var nEnd = Number(document.getElementById('s_night_end').value); var nDMin = Number(document.getElementById('s_night_delay_min').value); var nDMax = Number(document.getElementById('s_night_delay_max').value); var nMax = Number(document.getElementById('s_night_max').value); var myUsername = document.getElementById('s_my_username').value.trim(); var postGap = Number(document.getElementById('s_post_gap').value); if (tid && !/^\d+$/.test(tid)) { alert('tid 必须是纯数字。'); return; } if (isNaN(dMin) || dMin < 1) { alert('白天延迟下限必须是大于 0 的数字。'); return; } if (isNaN(dMax) || dMax < dMin) { alert('白天延迟上限不能小于下限。'); return; } if (isNaN(nStart) || nStart < 0 || nStart > 23) { alert('夜间开始小时必须是 0-23。'); return; } if (isNaN(nEnd) || nEnd < 0 || nEnd > 23) { alert('夜间结束小时必须是 0-23。'); return; } if (isNaN(nDMin) || nDMin < 1) { alert('夜间延迟下限必须是大于 0 的数字。'); return; } if (isNaN(nDMax) || nDMax < nDMin) { alert('夜间延迟上限不能小于下限。'); return; } if (isNaN(nMax) || nMax < 1) { alert('夜间最多发送条数必须是大于 0 的数字。'); return; } var oldTid = gmGet(STORAGE_KEY_URL, ''); var tidChanged = tid && tid !== oldTid; var jumpAfterSave = false; // tid 变更时询问是否立即跳转(取消仅保存,下次打开页面时自动跳转) if (tidChanged) { jumpAfterSave = confirm( 'tid 已修改为 ' + tid + '\n\n' + '点"确定":保存并立即跳转到新帖\n' + '点"取消":保存设置但暂不跳转(下次打开页面时会自动跳转)' ); } gmSet(STORAGE_KEY_DELAY_MIN, dMin); gmSet(STORAGE_KEY_DELAY_MAX, dMax); gmSet(STORAGE_KEY_AUTO_JUMP, jump); gmSet(STORAGE_KEY_POEMS, poemsRaw); gmSet(STORAGE_KEY_NIGHT_START, nStart); gmSet(STORAGE_KEY_NIGHT_END, nEnd); gmSet(STORAGE_KEY_NIGHT_DELAY_MIN, nDMin); gmSet(STORAGE_KEY_NIGHT_DELAY_MAX, nDMax); gmSet(STORAGE_KEY_NIGHT_MAX_COUNT, nMax); gmSet(STORAGE_KEY_MY_USERNAME, myUsername); gmSet(STORAGE_KEY_POST_GAP, isNaN(postGap) || postGap < 0 ? DEFAULT_POST_GAP : postGap); if (tid) { gmSet(STORAGE_KEY_URL, tid); gmSet(STORAGE_KEY_DATE, getCurrentYM()); } if (tidChanged && jumpAfterSave) { closeSettings(); window.location.href = getCurrentOrigin() + '/forum.php?mod=viewthread&tid=' + tid; return; } stopAutoReply(); var currentTidVal = getThreadTid(); var savedTidVal = gmGet(STORAGE_KEY_URL, ''); if (currentTidVal && currentTidVal === savedTidVal) { startAutoReply(); addLog('info', '设置已保存并生效'); } alert('已保存。'); closeSettings(); }); // ---- 测试按钮 ---- bindClick('s_test', function () { var currentTid = getThreadTid(); var savedTid = gmGet(STORAGE_KEY_URL, ''); if (!currentTid || currentTid !== savedTid) { alert('当前不在水楼帖页面,请先打开水楼帖再测试。'); return; } stopAutoReply(); closeSettings(); // 测试发送同样需要异常边界:doReply 抛错会冲出点击处理函数, // 下面的 5 秒兜底就注册不上,调度器会一直停到刷新页面为止。 try { doReply(true); } catch (e) { console.error('[AutoReply] 测试发送异常:', e); addLog('error', '测试发送异常:' + (e && e.message ? e.message : e)); _autoReplyPending = false; } setTimeout(function () { var t = getThreadTid(); var s = gmGet(STORAGE_KEY_URL, ''); if (t && t === s) { startAutoReply(); addLog('info', '测试完成后自动回复定时器已重新启动'); } }, 5000); }); // ---- 重置月份按钮 ---- bindClick('s_reset_month', function () { gmSet(STORAGE_KEY_DATE, ''); stopAutoReply(); sessionRemove(SESSION_KEY_SKIP_TID_PROMPT); addLog('info', '月份检测已重置,刷新页面后会重新提示输入水楼 tid'); alert('月份检测已重置,刷新页面后会重新提示输入水楼 tid。'); }); // ---- 关闭按钮 ---- bindClick('s_close', function () { closeSettings(); }); } // ================= §9 设置面板:日志区域 ================= function injectSettingsExtras(settingsBox) { var logSection = document.createElement('div'); logSection.style.cssText = 'margin-top:12px;border-top:1px solid #eee;padding-top:10px;'; logSection.innerHTML = '
' + '操作日志' + '' + '
' + '
'; settingsBox.appendChild(logSection); var clearBtn = settingsBox.querySelector('#clear-log-btn'); if (clearBtn) { clearBtn.onclick = function () { if (confirm('确定要清空所有操作日志吗?')) { clearLogs(); addLog('info', '日志已清空'); } }; } updateLogDisplay(); } function updateLogDisplay() { var logContainer = document.getElementById('auto-reply-log'); if (!logContainer) return; var logs = getLogs(); if (logs.length === 0) { logContainer.innerHTML = '
暂无日志
'; return; } var html = ''; for (var i = 0; i < logs.length; i++) { var log = logs[i]; // 损坏条目(非对象)直接跳过,避免渲染时抛错反向打断调用方 if (!log || typeof log !== 'object') continue; var color = '#555'; if (log.type === 'auto') color = '#28a745'; else if (log.type === 'test') color = '#e88e00'; else if (log.type === 'error') color = '#dc3545'; else if (log.type === 'info') color = '#007bff'; html += '
' + '' + escapeHtml(log.time) + ' ' + escapeHtml(log.message) + '
'; } logContainer.innerHTML = html; } // 注册日志渲染回调 _logDisplayCallback = updateLogDisplay; // ================= §10 倒计时 ================= function getRemainingTimeString() { var targetTime = Number(gmGet(STORAGE_KEY_PENDING_TARGET, 0)); if (!Number.isFinite(targetTime) || targetTime <= 0) return '未启动'; if (targetTime <= Date.now()) return '即将触发...'; var remaining = targetTime - Date.now(); var m = Math.floor(remaining / 60000); var s = Math.floor((remaining % 60000) / 1000); return m + '分' + s.toString().padStart(2, '0') + '秒'; } // ================= §11 主入口 ================= var currentTid = getThreadTid(); var savedTid = gmGet(STORAGE_KEY_URL, ''); var savedYM = gmGet(STORAGE_KEY_DATE, ''); var currentYM = getCurrentYM(); GM_registerMenuCommand('打开设置', openSettings); if (!savedTid || savedYM !== currentYM) { if (sessionGet(SESSION_KEY_SKIP_TID_PROMPT) !== '1') { promptForTid(); } } else if (shouldAutoJump() && (!currentTid || currentTid !== savedTid)) { window.location.href = getCurrentOrigin() + '/forum.php?mod=viewthread&tid=' + savedTid; } else if (currentTid && currentTid === savedTid) { // ================= 启动时抢一次主控并启动心跳 ================= // 第一个打开的标签页立即成为主控,其 PENDING_TARGET 写入是权威值; // 后续打开的标签页抢不到主控,startAutoReply 内部不会写 PENDING_TARGET。 if (_tryBecomeLeader()) { _startHeartbeat(); console.log('[AutoReply][多标签] 本页启动即成为主控,TAB_ID=' + TAB_ID); } else { console.log('[AutoReply][多标签] 本页为非主控,等待其他标签页发帖;本页定时器仅在到点时尝试接管'); } if (sessionGet(SESSION_KEY_NEED_REFRESH) === '1') { _triggerReplyOnStartup(); } else { var savedTargetTimeNum = Number(gmGet(STORAGE_KEY_PENDING_TARGET, 0)); if (Number.isFinite(savedTargetTimeNum) && savedTargetTimeNum > 0) { if (Date.now() >= savedTargetTimeNum) { console.log('[AutoReply] 检测到过期的持久化定时器,立即执行回复。'); _triggerReplyOnStartup(); } else { console.log('[AutoReply] 恢复持久化定时器,还需等待。'); startAutoReply(savedTargetTimeNum); } } else { startAutoReply(); } } addLog('info', '脚本初始化完成,自动回复已启动'); } // 页面卸载时清理定时器,但保留 PENDING_TARGET(跨刷新持久化) // 不主动释放主控身份:让心跳自然超时(6 秒),刷新页面时主控身份可以平滑保留 window.addEventListener('beforeunload', function () { cleanupTimers(true); _settingsOpen = false; }); })();