// ==UserScript== // @name Discourse Read Boost // @namespace https://github.com/VKKKV/discourse-read-boost // @version 1.5 // @author VKKKV // @description 自动化刷取 Discourse 论坛已读帖量,温和、可配置,支持多个 Discourse 论坛 // @license GPL-3.0 // @icon https://www.google.com/s2/favicons?domain=linux.do // @match https://linux.do/t/* // @match https://nodeloc.com/t/* // @match https://idcflare.com/t/* // @match https://www.nodeloc.com/t/* // @match https://meta.discourse.org/t/* // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addStyle // @updateURL https://raw.githubusercontent.com/VKKKV/discourse-read-boost/main/discourse-read-boost.user.js // @downloadURL https://raw.githubusercontent.com/VKKKV/discourse-read-boost/main/discourse-read-boost.user.js // ==/UserScript== (function () { 'use strict' const SCRIPT_NAME = 'Discourse Read Boost' // ── 风险确认(仅首次) ────────────────────────────────────────────── const hasAgreed = GM_getValue('hasAgreed', false) if (!hasAgreed) { const msg = [ `[ ${SCRIPT_NAME} ]`, '检测到这是你第一次使用,使用前你必须知晓:', '使用该第三方脚本可能会导致包括但不限于账号被限制、被封禁的潜在风险。', '脚本不对出现的任何风险负责,这是一个开源脚本,你可以自由审核其中的内容。', '如果你同意以上内容,请输入"明白"' ].join('\n') const userInput = prompt(msg) if (userInput !== '明白') { alert('您未同意风险提示,脚本已停止运行。') throw new Error('未同意风险提示') } GM_setValue('hasAgreed', true) } // ── DOM 就绪等待 ──────────────────────────────────────────────────── const ready = (() => { if (document.readyState === 'loading') { return new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, { once: true })) } return Promise.resolve() })() // ── 配置 ──────────────────────────────────────────────────────────── const BASE_URL = window.location.origin const DEFAULT_CONFIG = Object.freeze({ baseDelay: 2000, randomDelayRange: 300, minReqSize: 8, maxReqSize: 20, minReadTime: 800, maxReadTime: 3000, autoStart: false }) const CONFIG_META = [ { key: 'baseDelay', label: '基础延迟(ms)', min: 100, max: 30000 }, { key: 'randomDelayRange', label: '随机延迟范围(ms)', min: 0, max: 10000 }, { key: 'minReqSize', label: '最小每次请求阅读量', min: 1, max: 100 }, { key: 'maxReqSize', label: '最大每次请求阅读量', min: 1, max: 200 }, { key: 'minReadTime', label: '最小阅读时间(ms)', min: 100, max: 60000 }, { key: 'maxReadTime', label: '最大阅读时间(ms)', min: 100, max: 60000 } ] let config = loadConfig() let isRunning = false let abortFlag = false let currentTopicId = null let currentTotalReplies = 0 // ── 存储 ──────────────────────────────────────────────────────────── function loadConfig() { const stored = {} CONFIG_META.forEach(({ key, min, max }) => { const val = GM_getValue(key, DEFAULT_CONFIG[key]) stored[key] = clampInt(val, DEFAULT_CONFIG[key], min, max) }) stored.autoStart = toBoolean(GM_getValue('autoStart', DEFAULT_CONFIG.autoStart), DEFAULT_CONFIG.autoStart) return normalizeConfig({ ...DEFAULT_CONFIG, ...stored }) } function saveConfig(cfg) { const normalized = normalizeConfig(cfg) CONFIG_META.forEach(({ key }) => GM_setValue(key, normalized[key])) GM_setValue('autoStart', normalized.autoStart) return normalized } function resetConfig() { CONFIG_META.forEach(({ key }) => GM_setValue(key, DEFAULT_CONFIG[key])) GM_setValue('autoStart', DEFAULT_CONFIG.autoStart) } function clampInt(val, fallback, min, max) { const n = parseInt(val, 10) if (isNaN(n)) return fallback return Math.max(min, Math.min(max, n)) } function normalizeConfig(cfg) { const normalized = { ...DEFAULT_CONFIG } CONFIG_META.forEach(({ key, min, max }) => { normalized[key] = clampInt(cfg[key], DEFAULT_CONFIG[key], min, max) }) if (normalized.minReqSize > normalized.maxReqSize) { normalized.maxReqSize = normalized.minReqSize } if (normalized.minReadTime > normalized.maxReadTime) { normalized.maxReadTime = normalized.minReadTime } normalized.autoStart = toBoolean(cfg.autoStart, DEFAULT_CONFIG.autoStart) return normalized } function toBoolean(val, fallback = false) { if (typeof val === 'boolean') return val if (typeof val === 'number') return val !== 0 if (typeof val === 'string') { const normalized = val.trim().toLowerCase() if (['true', '1', 'yes', 'on'].includes(normalized)) return true if (['false', '0', 'no', 'off', ''].includes(normalized)) return false } return Boolean(fallback) } // ── DOM 工具 ──────────────────────────────────────────────────────── function getElem(sel) { return document.querySelector(sel) } function getElemSafe(sel, name) { const el = getElem(sel) if (!el) console.warn(`ReadBoost: 未找到元素 ${sel} (${name})`) return el } function parseTopicId() { const match = window.location.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/|$)/) return match ? match[1] : null } function parseTotalReplies() { const timelineEl = getElem('div.timeline-replies') if (!timelineEl) return 0 const text = timelineEl.textContent.trim() const parts = text.split('/').map(s => parseInt(s.replace(/,/g, '').trim(), 10)) return parts.length >= 2 && !isNaN(parts[1]) ? parts[1] : 0 } async function waitForElem(selector, timeout = 10000) { const existing = getElem(selector) if (existing) return existing return new Promise(resolve => { const timer = setTimeout(() => { observer.disconnect() resolve(null) }, timeout) const observer = new MutationObserver(() => { const el = getElem(selector) if (!el) return clearTimeout(timer) observer.disconnect() resolve(el) }) observer.observe(document.body, { childList: true, subtree: true }) }) } // ── 注入 Tactical HUD 样式 ──────────────────────────────────────────── GM_addStyle(` .rb-controls, .rb-modal { --rb-hud-bg: #000; --rb-hud-fg: #0f0; --rb-hud-fg-dim: rgba(0, 255, 0, 0.48); --rb-hud-fg-muted: rgba(0, 255, 0, 0.68); --rb-hud-fg-bright: #fff; --rb-hud-border: rgba(0, 255, 0, 0.14); --rb-hud-border-strong: rgba(0, 255, 0, 0.32); --rb-hud-card-bg: rgba(0, 255, 0, 0.04); --rb-hud-warn: #ffb000; --rb-hud-error: #ff3b3b; --rb-hud-font: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-family: var(--rb-hud-font); font-variant-numeric: tabular-nums; } .rb-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); isolation: isolate; overflow: hidden; padding: 22px 24px; border: 1px solid var(--rb-hud-border-strong); border-top-color: rgba(0, 255, 0, 0.55); border-radius: 0; background: linear-gradient(90deg, rgba(0, 255, 0, 0.16), transparent 34%, rgba(0, 255, 0, 0.08) 68%, transparent), linear-gradient(rgba(0, 255, 0, 0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 255, 0, 0.035) 1px, transparent 1px), var(--rb-hud-bg); background-size: 100% 3px, 40px 40px, 40px 40px, auto; color: var(--rb-hud-fg); z-index: 10000; box-sizing: border-box; width: min(420px, calc(100vw - 32px)); box-shadow: 0 0 0 1px rgba(0, 255, 0, 0.06), 0 0 24px rgba(0, 255, 0, 0.2), 0 22px 54px rgba(0, 0, 0, 0.5); font-size: 13px; line-height: 1.45; } .rb-modal::before { content: ""; position: absolute; inset: -40% 0 100%; z-index: 0; pointer-events: none; background: linear-gradient(180deg, transparent, rgba(0, 255, 0, 0.18), transparent); animation: rb-hud-scan 4.8s linear infinite; } .rb-modal::after { content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none; background: repeating-linear-gradient(180deg, transparent 0 3px, rgba(0, 255, 0, 0.045) 3px 4px); opacity: 0.65; } .rb-modal > * { position: relative; z-index: 1; } .rb-modal h3 { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 0 16px; padding-bottom: 10px; border-bottom: 1px solid var(--rb-hud-border); color: var(--rb-hud-fg-bright); font-size: 14px; font-weight: 700; letter-spacing: 0.08em; line-height: 1.2; text-transform: uppercase; } .rb-modal .rb-kicker { color: var(--rb-hud-fg-dim); font-size: 9px; font-weight: 600; letter-spacing: 0.18em; } .rb-modal .rb-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 14px; } .rb-modal .rb-stat { padding: 9px 10px; border: 1px solid var(--rb-hud-border); background: var(--rb-hud-card-bg); } .rb-modal .rb-stat span { display: block; margin-bottom: 4px; color: var(--rb-hud-fg-dim); font-size: 9px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; } .rb-modal .rb-stat strong { color: var(--rb-hud-fg); font-size: 22px; font-weight: 700; line-height: 1; text-shadow: 0 0 14px rgba(0, 255, 0, 0.38); } .rb-modal .rb-fields { display: grid; gap: 7px; } .rb-modal label { display: grid; grid-template-columns: minmax(0, 1fr) 112px; align-items: center; gap: 10px; margin: 0; color: var(--rb-hud-fg-muted); font-size: 11px; letter-spacing: 0.04em; } .rb-modal label > span { min-width: 0; } .rb-modal input[type="number"] { width: 100%; min-height: 28px; box-sizing: border-box; padding: 3px 7px; border: 1px solid var(--rb-hud-border-strong); border-radius: 0; outline: none; background: rgba(0, 0, 0, 0.72); color: var(--rb-hud-fg-bright); font-family: var(--rb-hud-font); font-size: 12px; font-variant-numeric: tabular-nums; } .rb-modal input[type="number"]:focus { border-color: var(--rb-hud-fg); box-shadow: 0 0 0 1px rgba(0, 255, 0, 0.18), 0 0 12px rgba(0, 255, 0, 0.25); } .rb-modal .rb-checkbox { grid-template-columns: 16px minmax(0, 1fr); justify-content: start; margin-top: 4px; padding-top: 8px; border-top: 1px solid var(--rb-hud-border); text-transform: uppercase; } .rb-modal input[type="checkbox"] { width: 14px; height: 14px; margin: 0; accent-color: #0f0; } .rb-modal .btn-row { margin-top: 16px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; } .rb-modal .btn-row button { flex: 0 1 auto; } .rb-controls { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; margin-left: 8px; padding: 3px 4px 3px 8px; border: 1px solid var(--rb-hud-border); border-top-color: var(--rb-hud-border-strong); background: linear-gradient(rgba(0, 255, 0, 0.05) 50%, transparent 50%), rgba(0, 0, 0, 0.9); background-size: 100% 4px, auto; color: var(--rb-hud-fg); white-space: nowrap; } .rb-button-wrap { display: inline-flex; align-items: center; flex: 0 0 auto; } .rb-button-wrap .btn, .rb-modal .btn-row .btn { min-height: 28px; margin: 0; padding: 4px 9px; border: 1px solid var(--rb-hud-border-strong); border-radius: 0; background: rgba(0, 255, 0, 0.035); color: var(--rb-hud-fg); font-family: var(--rb-hud-font); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; line-height: 1; text-transform: uppercase; transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease, text-shadow 0.12s ease; } .rb-button-wrap .btn:hover, .rb-button-wrap .btn:focus, .rb-modal .btn-row .btn:hover, .rb-modal .btn-row .btn:focus { border-color: var(--rb-hud-fg); background: rgba(0, 255, 0, 0.12); color: var(--rb-hud-fg-bright); text-shadow: 0 0 12px rgba(0, 255, 0, 0.65); } .rb-button-wrap .btn.btn-danger, .rb-modal .btn-row .btn.rb-action-danger { border-color: rgba(255, 59, 59, 0.5); color: var(--rb-hud-error); } .rb-modal .btn-row .btn.rb-action-primary { border-color: rgba(0, 255, 0, 0.56); background: rgba(0, 255, 0, 0.1); } .rb-modal .btn-row .btn.rb-action-muted { color: var(--rb-hud-fg-muted); } .rb-status { display: inline-flex; align-items: center; margin: 0 2px 0 0; color: var(--rb-hud-fg-muted); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; line-height: 1; text-transform: uppercase; transition: color 0.16s ease, text-shadow 0.16s ease; } .rb-status::before { content: ">"; margin-right: 5px; color: var(--rb-hud-fg-dim); } .rb-status-ok { color: var(--rb-hud-fg); text-shadow: 0 0 12px rgba(0, 255, 0, 0.48); } .rb-status-warn { color: var(--rb-hud-warn); } .rb-status-error { color: var(--rb-hud-error); } @keyframes rb-hud-scan { 0% { transform: translateY(0); } 100% { transform: translateY(360%); } } @media (max-width: 700px) { .rb-controls { margin-left: 4px; gap: 4px; padding: 2px; } .rb-status { display: none; } .rb-modal { padding: 18px; } .rb-modal h3 { align-items: flex-start; flex-direction: column; gap: 5px; } .rb-modal label { grid-template-columns: minmax(0, 1fr); gap: 5px; } .rb-modal .rb-checkbox { grid-template-columns: 16px minmax(0, 1fr); } } `) // ── UI 构建 ───────────────────────────────────────────────────────── function createButton(label, id, extraClass = '') { const wrapper = document.createElement('span') wrapper.className = 'rb-button-wrap' const btn = document.createElement('button') btn.className = `btn btn-small ${extraClass}` btn.id = id btn.type = 'button' const span = document.createElement('span') span.className = 'd-button-label' span.textContent = label btn.appendChild(span) wrapper.appendChild(btn) return wrapper } function createStatusLabel(text) { const el = document.createElement('span') el.className = 'rb-status rb-status-idle' el.id = 'rbStatus' el.textContent = text return el } function updateStatus(text, color = '#555') { const el = document.getElementById('rbStatus') if (!el) return const normalized = String(color).toLowerCase() const state = normalized === 'green' || normalized === '#0f0' ? 'ok' : normalized === 'orange' ? 'warn' : normalized === 'red' ? 'error' : 'idle' el.textContent = text el.classList.remove('rb-status-idle', 'rb-status-ok', 'rb-status-warn', 'rb-status-error') el.classList.add(`rb-status-${state}`) } function removeStopButton() { const stopEl = document.getElementById('rbStopBtn') if (!stopEl) return const wrapper = stopEl.closest('.rb-button-wrap') if (wrapper) wrapper.remove() } // ── 设置弹窗 ───────────────────────────────────────────────────────── function showSettings() { if (isRunning) { alert('脚本正在运行,请先停止后再修改设置。') return } const existing = document.getElementById('rbSettings') if (existing) existing.remove() const div = document.createElement('div') div.className = 'rb-modal' div.id = 'rbSettings' const advancedChecked = config.autoStart ? 'checked' : '' const inputsHTML = CONFIG_META.map(({ key, label }) => `` ).join('\n') div.innerHTML = `