// ==UserScript== // @name skyeysnow auto reply // @namespace Violentmonkey Scripts // @version 1.0.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 等后果。请自行评估并承担风险,作者不对使用后果负责。 */ (function () { 'use strict'; // ================= 存储键 ================= 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'; // 日志渲染回调(模块内私有,避免污染 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; // ================= 工具函数 ================= 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,且不低于该配置的默认上限。 // 抽取以避免各处回退规则不一致(历史上曾出现 min+10 与固定值 120 两种写法)。 function fallbackMax(min, defMax) { return Math.max(min + 10, defMax); } function getDelayMin() { var v = Number(GM_getValue(STORAGE_KEY_DELAY_MIN, 25)); return isNaN(v) || v < 1 ? 25 : v; } function getDelayMax() { var v = Number(GM_getValue(STORAGE_KEY_DELAY_MAX, 35)); var min = getDelayMin(); return isNaN(v) || v < min ? fallbackMax(min, 35) : v; } function shouldAutoJump() { var v = GM_getValue(STORAGE_KEY_AUTO_JUMP, true); return v !== false && v !== 'false'; } function getPoems() { var raw = GM_getValue(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 isNightTime() { var start = Number(GM_getValue(STORAGE_KEY_NIGHT_START, 23)); var end = Number(GM_getValue(STORAGE_KEY_NIGHT_END, 8)); start = isNaN(start) ? 23 : start; end = isNaN(end) ? 8 : end; var hour = new Date().getHours(); if (start <= end) { return hour >= start && hour < end; } return hour >= start || hour < end; } // 当前夜间时段的唯一标识(跨零点时归属到开始那天) function getNightPeriodKey() { var start = Number(GM_getValue(STORAGE_KEY_NIGHT_START, 23)); var end = Number(GM_getValue(STORAGE_KEY_NIGHT_END, 8)); start = isNaN(start) ? 23 : start; end = isNaN(end) ? 8 : end; var now = new Date(); var hour = now.getHours(); var periodDate = new Date(now); // 跨零点时段,零点之后仍属于"昨天开始"的那个夜间时段 if (start > end && hour < end) { periodDate.setDate(periodDate.getDate() - 1); } return periodDate.getFullYear() + '-' + String(periodDate.getMonth() + 1).padStart(2, '0') + '-' + String(periodDate.getDate()).padStart(2, '0'); } function getCurrentDelayRange() { if (isNightTime()) { var nMin = Number(GM_getValue(STORAGE_KEY_NIGHT_DELAY_MIN, 60)); var nMax = Number(GM_getValue(STORAGE_KEY_NIGHT_DELAY_MAX, 120)); nMin = isNaN(nMin) || nMin < 1 ? 60 : nMin; nMax = isNaN(nMax) || nMax < nMin ? fallbackMax(nMin, 120) : nMax; return { min: nMin, max: nMax }; } return { min: getDelayMin(), max: getDelayMax() }; } function canSendAtNight() { if (!isNightTime()) return true; var maxCount = Number(GM_getValue(STORAGE_KEY_NIGHT_MAX_COUNT, 3)); maxCount = isNaN(maxCount) || maxCount < 1 ? 3 : maxCount; var periodKey = getNightPeriodKey(); var savedPeriod = GM_getValue(STORAGE_KEY_NIGHT_DATE, ''); var sentCount = Number(GM_getValue(STORAGE_KEY_NIGHT_SENT, 0)); if (savedPeriod !== periodKey) { GM_setValue(STORAGE_KEY_NIGHT_DATE, periodKey); GM_setValue(STORAGE_KEY_NIGHT_SENT, 0); GM_setValue(STORAGE_KEY_NIGHT_WARNED, ''); return true; } return sentCount < maxCount; } function recordNightSent() { if (!isNightTime()) return; GM_setValue(STORAGE_KEY_NIGHT_DATE, getNightPeriodKey()); var sentCount = Number(GM_getValue(STORAGE_KEY_NIGHT_SENT, 0)); GM_setValue(STORAGE_KEY_NIGHT_SENT, isNaN(sentCount) ? 1 : sentCount + 1); } function getCurrentOrigin() { return window.location.origin || 'https://skyeysnow.com'; } // ================= 主控标签页选举 ================= // 设计: // - GM 存储中保存 leaderId + 心跳时间戳 // - 每个标签页在 sessionStorage 中有唯一 TAB_ID // - doReply / 主入口先尝试成为主控;抢不到则静默等待 // - 主控启动 2 秒心跳;超过 LEADER_TIMEOUT_MS 未更新则其他页可抢占 // - 刷新页面 TAB_ID 不变,主控身份自然保留 // - 关闭页面不主动释放(等待心跳超时自动失效),避免刷新瞬间被其他页抢占 function _isLeader() { return GM_getValue(STORAGE_KEY_LEADER_ID, '') === TAB_ID; } function _tryBecomeLeader() { var leaderId = GM_getValue(STORAGE_KEY_LEADER_ID, ''); var hb = Number(GM_getValue(STORAGE_KEY_LEADER_HB, 0)); var now = Date.now(); // 自己已是主控 → 刷新心跳 if (leaderId === TAB_ID) { GM_setValue(STORAGE_KEY_LEADER_HB, now); return true; } // 无主控 或 主控心跳超时 → 抢占 if (!leaderId || (now - hb) > LEADER_TIMEOUT_MS) { GM_setValue(STORAGE_KEY_LEADER_ID, TAB_ID); GM_setValue(STORAGE_KEY_LEADER_HB, now); // 写入后回读校验:GM 存储非原子,两个标签页可能同时判定超时后都写入, // 回读可确保最终只有后写入的 TAB_ID 存活,避免双方都认为抢到主控。 if (GM_getValue(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 () { if (_isLeader()) { GM_setValue(STORAGE_KEY_LEADER_HB, Date.now()); } }, HEARTBEAT_INTERVAL_MS); } function _stopHeartbeat() { if (_heartbeatTimer) { clearInterval(_heartbeatTimer); _heartbeatTimer = null; } } // ================= 日志 ================= 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 = []; try { logs = JSON.parse(GM_getValue(STORAGE_KEY_LOG, '[]')); } catch (e) { logs = []; } logs.unshift(logEntry); var maxLog = Number(GM_getValue(STORAGE_KEY_MAX_LOG, 100)); maxLog = isNaN(maxLog) || maxLog < 10 ? 100 : maxLog; if (logs.length > maxLog) logs = logs.slice(0, maxLog); try { GM_setValue(STORAGE_KEY_LOG, JSON.stringify(logs)); } catch (e) { console.warn('[AutoReply] 日志持久化失败:', e); } if (_logDisplayCallback) { _logDisplayCallback(logs); } } function getLogs() { try { return JSON.parse(GM_getValue(STORAGE_KEY_LOG, '[]')); } catch (e) { return []; } } function clearLogs() { GM_setValue(STORAGE_KEY_LOG, '[]'); if (_logDisplayCallback) _logDisplayCallback([]); } // ================= 倒计时 ================= function getRemainingTimeString() { var targetTime = Number(GM_getValue(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') + '秒'; } // ================= 设置面板:日志区域 ================= 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]; 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; // ================= 核心逻辑 ================= function getMyUsername() { var v = GM_getValue(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 getPostGap() { var v = Number(GM_getValue(STORAGE_KEY_POST_GAP, 1)); return isNaN(v) || v < 0 ? 1 : v; } 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 控件未找到,跳过跳转设置'); } } var _autoReplyPending = false; var _autoReplyCheckInterval = null; var _autoReplyVisibilityHandler = null; var _postReloadTimeout = null; var _postRestartTimeout = null; var _postExceptionTimeout = null; function doReply(immediate) { // ================= 主控检查 ================= // 非测试模式下,先尝试抢主控;抢不到说明其他标签页正在负责发帖, // 本页静默跳过本轮,重启定时器继续等待。 if (!immediate) { if (_tryBecomeLeader()) { _startHeartbeat(); } else { console.log('[AutoReply][多标签] 本页非主控,本轮静默跳过'); stopAutoReply(); startAutoReply(); _autoReplyPending = false; return; } } // ---- 1. 夜间限额检查 ---- if (!immediate && !canSendAtNight()) { console.log('[AutoReply] 夜间发送条数已达上限,本次跳过。'); var periodKey = getNightPeriodKey(); if (GM_getValue(STORAGE_KEY_NIGHT_WARNED, '') !== periodKey) { addLog('error', '夜间发送条数已达上限,本次跳过'); GM_setValue(STORAGE_KEY_NIGHT_WARNED, periodKey); } stopAutoReply(); startAutoReply(); _autoReplyPending = false; return; } // 用户名缺失时暂停自动回复,避免"跳过 → 重启 → 再跳过"的死循环 var gap = getPostGap(); if (!immediate && gap > 0 && !getMyUsername()) { addLog('error', '未检测到用户名,防刷检查无法执行,自动回复已暂停'); stopAutoReply(); _autoReplyPending = false; if (!sessionStorage.getItem(SESSION_KEY_NO_USERNAME_ALERTED)) { sessionStorage.setItem(SESSION_KEY_NO_USERNAME_ALERTED, '1'); setTimeout(function () { alert('[天雪自动回复] 未检测到用户名,防刷检查无法执行,自动回复已暂停。\n' + '请在油猴菜单中打开脚本设置,填写"我的用户名"。'); }, 100); } return; } // 防刷检测前强制刷新页面(用 sessionStorage 记录一次性标志) if (!immediate && gap > 0) { if (sessionStorage.getItem(SESSION_KEY_NEED_REFRESH) === '1') { sessionStorage.removeItem(SESSION_KEY_NEED_REFRESH); console.log('[AutoReply] 页面已刷新,使用最新 DOM 数据执行防刷检测...'); } else { addLog('info', '防刷检测前强制刷新页面以获取最新帖子数据...'); sessionStorage.setItem(SESSION_KEY_NEED_REFRESH, '1'); if (_autoReplyCheckInterval) { clearInterval(_autoReplyCheckInterval); _autoReplyCheckInterval = null; } location.reload(); return; } } // ---- 2. 防刷检查 ---- if (!immediate && !canPostNow()) { console.log('[AutoReply] 防连续刷楼:跳过本次发送,等待更多其他帖子。'); addLog('info', '防连续刷楼:跳过本次发送,等待其他用户发帖'); stopAutoReply(); startAutoReply(); _autoReplyPending = false; return; } // ---- 3. 内容准备 ---- var poems = getPoems(); if (poems.length === 0) { console.warn('[AutoReply] 诗句列表为空,本次不回复。'); addLog('error', '诗句列表为空,本次不回复'); stopAutoReply(); startAutoReply(); _autoReplyPending = false; return; } var messageEl = document.getElementById('fastpostmessage'); var submitEl = document.getElementById('fastpostsubmit'); if (!messageEl || !submitEl) { console.warn('[AutoReply] 未找到快速回复框或提交按钮,本次自动回复取消。'); addLog('error', '未找到快速回复框或提交按钮,本次自动回复取消'); stopAutoReply(); startAutoReply(); _autoReplyPending = false; return; } var poem = poems[Math.floor(Math.random() * poems.length)]; messageEl.value = poem; ensureJumpToLastPage(); try { submitEl.click(); // 兜底刷新:8 秒内未跳转说明 Discuz 自动刷新未生效,手动刷新 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) { _postRestartTimeout = setTimeout(function () { startAutoReply(); addLog('info', '自动回复定时器已重新启动'); }, 3000); } _autoReplyPending = false; GM_setValue(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) { _postExceptionTimeout = setTimeout(function () { startAutoReply(); addLog('info', '异常后自动回复定时器已重新启动'); }, 3000); } _autoReplyPending = false; GM_setValue(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 (_postRestartTimeout) { clearTimeout(_postRestartTimeout); _postRestartTimeout = null; } if (_postExceptionTimeout) { clearTimeout(_postExceptionTimeout); _postExceptionTimeout = null; } _stopHeartbeat(); if (!keepPendingTarget) { GM_setValue(STORAGE_KEY_PENDING_TARGET, 0); } } function stopAutoReply() { // 保留 PENDING_TARGET:stopAutoReply 也用于"重启定时器"路径, // 若在此清零会破坏主控写下的目标时间(非主控页重启时尤为明显)。 // 真正需要清零的场景(发帖成功、发帖异常)已在 doReply 内部显式处理。 cleanupTimers(true); } 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()) { try { GM_setValue(STORAGE_KEY_PENDING_TARGET, targetTime); } catch (e) { console.warn('[AutoReply] 持久化定时器失败:', e); } } _autoReplyCheckInterval = setInterval(function () { if (Date.now() >= targetTime && !_autoReplyPending) { stopAutoReply(); _autoReplyPending = true; doReply(false); } }, 1000); _autoReplyVisibilityHandler = function () { if (document.visibilityState === 'visible' && Date.now() >= targetTime && !_autoReplyPending) { stopAutoReply(); _autoReplyPending = true; doReply(false); } }; document.addEventListener('visibilitychange', _autoReplyVisibilityHandler); // 主控身份需要靠心跳续期。stopAutoReply 会停掉心跳, // 若此处不补回,发帖成功 3 秒后重启定时器时主控会在 6 秒后失效, // 导致另一个标签页抢走主控并重复发帖。 if (_isLeader()) { _startHeartbeat(); } } // 提示用户输入水楼 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) { sessionStorage.setItem(SESSION_KEY_SKIP_TID_PROMPT, '1'); return; } tid = tid.trim(); if (tid.length === 0) { alert('tid 不能为空,请重新输入。'); continue; } if (/^\d+$/.test(tid)) { GM_setValue(STORAGE_KEY_URL, tid); GM_setValue(STORAGE_KEY_DATE, getCurrentYM()); addLog('info', '已保存水楼 tid: ' + tid); sessionStorage.setItem(SESSION_KEY_SKIP_TID_PROMPT, '1'); window.location.href = getCurrentOrigin() + '/forum.php?mod=viewthread&tid=' + tid; return; } alert('tid 必须是纯数字,请重新输入。'); } sessionStorage.setItem(SESSION_KEY_SKIP_TID_PROMPT, '1'); alert('多次输入无效,已跳过本次 tid 设置。可稍后从油猴菜单打开设置重新配置。'); } function openSettings() { if (_settingsOpen) return; _settingsOpen = true; var savedTid = GM_getValue(STORAGE_KEY_URL, ''); var savedYM = GM_getValue(STORAGE_KEY_DATE, ''); var delayMin = getDelayMin(); var delayMax = getDelayMax(); var autoJump = shouldAutoJump(); var poemsText = getPoems().join('\n'); var nightStart = Number(GM_getValue(STORAGE_KEY_NIGHT_START, 23)); var nightEnd = Number(GM_getValue(STORAGE_KEY_NIGHT_END, 8)); var nightDelayMin = Number(GM_getValue(STORAGE_KEY_NIGHT_DELAY_MIN, 60)); var nightDelayMax = Number(GM_getValue(STORAGE_KEY_NIGHT_DELAY_MAX, 120)); var nightMaxCount = Number(GM_getValue(STORAGE_KEY_NIGHT_MAX_COUNT, 3)); nightStart = isNaN(nightStart) ? 23 : nightStart; nightEnd = isNaN(nightEnd) ? 8 : nightEnd; nightDelayMin = isNaN(nightDelayMin) || nightDelayMin < 1 ? 60 : nightDelayMin; nightDelayMax = isNaN(nightDelayMax) || nightDelayMax < nightDelayMin ? fallbackMax(nightDelayMin, 120) : nightDelayMax; nightMaxCount = isNaN(nightMaxCount) || nightMaxCount < 1 ? 3 : nightMaxCount; 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); // ---- 保存按钮 ---- document.getElementById('s_save').onclick = 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 = GM_getValue(STORAGE_KEY_URL, ''); var tidChanged = tid && tid !== oldTid; var jumpAfterSave = false; // tid 变更时询问是否立即跳转(取消仅保存,下次打开页面时自动跳转) if (tidChanged) { jumpAfterSave = confirm( 'tid 已修改为 ' + tid + '\n\n' + '点"确定":保存并立即跳转到新帖\n' + '点"取消":保存设置但暂不跳转(下次打开页面时会自动跳转)' ); } GM_setValue(STORAGE_KEY_DELAY_MIN, dMin); GM_setValue(STORAGE_KEY_DELAY_MAX, dMax); GM_setValue(STORAGE_KEY_AUTO_JUMP, jump); GM_setValue(STORAGE_KEY_POEMS, poemsRaw); GM_setValue(STORAGE_KEY_NIGHT_START, nStart); GM_setValue(STORAGE_KEY_NIGHT_END, nEnd); GM_setValue(STORAGE_KEY_NIGHT_DELAY_MIN, nDMin); GM_setValue(STORAGE_KEY_NIGHT_DELAY_MAX, nDMax); GM_setValue(STORAGE_KEY_NIGHT_MAX_COUNT, nMax); GM_setValue(STORAGE_KEY_MY_USERNAME, myUsername); GM_setValue(STORAGE_KEY_POST_GAP, isNaN(postGap) || postGap < 0 ? 1 : postGap); if (tid) { GM_setValue(STORAGE_KEY_URL, tid); GM_setValue(STORAGE_KEY_DATE, getCurrentYM()); } if (tidChanged && jumpAfterSave) { overlay.remove(); clearInterval(countdownInterval); _settingsOpen = false; window.location.href = getCurrentOrigin() + '/forum.php?mod=viewthread&tid=' + tid; return; } stopAutoReply(); var currentTidVal = getThreadTid(); var savedTidVal = GM_getValue(STORAGE_KEY_URL, ''); if (currentTidVal && currentTidVal === savedTidVal) { startAutoReply(); addLog('info', '设置已保存并生效'); } alert('已保存。'); clearInterval(countdownInterval); overlay.remove(); _settingsOpen = false; }; // ---- 测试按钮 ---- document.getElementById('s_test').onclick = function () { var currentTid = getThreadTid(); var savedTid = GM_getValue(STORAGE_KEY_URL, ''); if (!currentTid || currentTid !== savedTid) { alert('当前不在水楼帖页面,请先打开水楼帖再测试。'); return; } stopAutoReply(); overlay.remove(); _settingsOpen = false; clearInterval(countdownInterval); doReply(true); setTimeout(function () { var t = getThreadTid(); var s = GM_getValue(STORAGE_KEY_URL, ''); if (t && t === s) { startAutoReply(); addLog('info', '测试完成后自动回复定时器已重新启动'); } }, 5000); }; // ---- 重置月份按钮 ---- document.getElementById('s_reset_month').onclick = function () { GM_setValue(STORAGE_KEY_DATE, ''); stopAutoReply(); sessionStorage.removeItem(SESSION_KEY_SKIP_TID_PROMPT); addLog('info', '月份检测已重置,刷新页面后会重新提示输入水楼 tid'); alert('月份检测已重置,刷新页面后会重新提示输入水楼 tid。'); }; // ---- 关闭按钮 ---- document.getElementById('s_close').onclick = function () { clearInterval(countdownInterval); overlay.remove(); _settingsOpen = false; }; } // ================= 主入口 ================= var currentTid = getThreadTid(); var savedTid = GM_getValue(STORAGE_KEY_URL, ''); var savedYM = GM_getValue(STORAGE_KEY_DATE, ''); var currentYM = getCurrentYM(); GM_registerMenuCommand('打开设置', openSettings); if (!savedTid || savedYM !== currentYM) { if (sessionStorage.getItem(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 (sessionStorage.getItem(SESSION_KEY_NEED_REFRESH) === '1') { _autoReplyPending = true; doReply(false); } else { var savedTargetTimeNum = Number(GM_getValue(STORAGE_KEY_PENDING_TARGET, 0)); if (Number.isFinite(savedTargetTimeNum) && savedTargetTimeNum > 0) { if (Date.now() >= savedTargetTimeNum) { console.log('[AutoReply] 检测到过期的持久化定时器,立即执行回复。'); _autoReplyPending = true; doReply(false); } else { console.log('[AutoReply] 恢复持久化定时器,还需等待。'); startAutoReply(savedTargetTimeNum); } } else { startAutoReply(); } } addLog('info', '脚本初始化完成,自动回复已启动'); } // 页面卸载时清理定时器,但保留 PENDING_TARGET(跨刷新持久化) // 不主动释放主控身份:让心跳自然超时(6 秒),刷新页面时主控身份可以平滑保留 window.addEventListener('beforeunload', function () { cleanupTimers(true); _settingsOpen = false; }); })();