Discourse Read Boost

自动化刷取 Discourse 论坛已读帖量,温和、可配置,支持多个 Discourse 论坛

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==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
// ==/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 }) =>
            `<label><span>${label}</span><input id="rb_${key}" type="number" value="${config[key]}"></label>`
        ).join('\n')

        div.innerHTML = `
            <h3><span>${SCRIPT_NAME}</span><span class="rb-kicker">HUD CONFIG</span></h3>
            <div class="rb-metrics">
                <div class="rb-stat"><span>topic</span><strong>${currentTopicId || '--'}</strong></div>
                <div class="rb-stat"><span>replies</span><strong>${currentTotalReplies || 0}</strong></div>
            </div>
            <div class="rb-fields">
                ${inputsHTML}
                <label class="rb-checkbox"><input type="checkbox" id="rb_autoStart" ${advancedChecked}><span>自动运行</span></label>
            </div>
            <div class="btn-row">
                <button class="btn btn-small rb-action-primary" id="rb_startBtn" type="button"><span class="d-button-label">手动开始</span></button>
                <button class="btn btn-small rb-action-primary" id="rb_saveBtn" type="button"><span class="d-button-label">保存</span></button>
                <button class="btn btn-small rb-action-muted" id="rb_resetBtn" type="button"><span class="d-button-label">恢复默认</span></button>
                <button class="btn btn-small rb-action-muted" id="rb_closeBtn" type="button"><span class="d-button-label">关闭</span></button>
            </div>
        `

        document.body.appendChild(div)

        document.getElementById('rb_startBtn').addEventListener('click', () => {
            currentTopicId = currentTopicId || parseTopicId()
            currentTotalReplies = parseTotalReplies() || currentTotalReplies
            if (!currentTopicId || currentTotalReplies <= 0) {
                alert('未能识别当前帖子或回复数,无法开始。')
                return
            }
            div.remove()
            readTopic(currentTopicId, currentTotalReplies)
        })

        document.getElementById('rb_saveBtn').addEventListener('click', () => {
            CONFIG_META.forEach(({ key, min, max }) => {
                const el = document.getElementById('rb_' + key)
                config[key] = clampInt(el.value, DEFAULT_CONFIG[key], min, max)
                el.value = config[key]
            })
            config.autoStart = document.getElementById('rb_autoStart').checked
            config = saveConfig(config)
            alert('设置已保存!如需自动运行请刷新页面。')
            div.remove()
        })

        document.getElementById('rb_resetBtn').addEventListener('click', () => {
            if (!confirm('确认恢复所有设置为默认值?')) return
            resetConfig()
            config = loadConfig()
            CONFIG_META.forEach(({ key }) => {
                const el = document.getElementById('rb_' + key)
                if (el) el.value = config[key]
            })
            document.getElementById('rb_autoStart').checked = config.autoStart
            alert('已恢复默认设置!')
        })

        document.getElementById('rb_closeBtn').addEventListener('click', () => div.remove())
    }

    // ── 核心:刷已读 ──────────────────────────────────────────────────────
    function getRandomInt(min, max) {
        return Math.floor(Math.random() * (max - min + 1)) + min
    }

    function createBatchParams(startId, endId, topicId) {
        const params = new URLSearchParams()
        const count = endId - startId + 1
        for (let i = startId; i <= endId; i++) {
            params.append(`timings[${i}]`, getRandomInt(config.minReadTime, config.maxReadTime))
        }
        params.append('topic_time', getRandomInt(config.minReadTime * count, config.maxReadTime * count))
        params.append('topic_id', topicId)
        return params
    }

    async function sendBatch(startId, endId, topicId, csrf, retries = 3) {
        const params = createBatchParams(startId, endId, topicId)
        for (let attempt = 0; attempt <= retries; attempt++) {
            if (abortFlag) return false
            try {
                const res = await fetch(`${BASE_URL}/topics/timings`, {
                    method: 'POST',
                    headers: {
                        'accept': '*/*',
                        'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
                        'discourse-background': 'true',
                        'discourse-logged-in': 'true',
                        'discourse-present': 'true',
                        'x-csrf-token': csrf,
                        'x-requested-with': 'XMLHttpRequest',
                        'x-silence-logger': 'true'
                    },
                    credentials: 'include',
                    referrer: BASE_URL + '/',
                    body: params.toString()
                })
                if (!res.ok) throw new Error(`HTTP ${res.status}`)
                updateStatus(`已读 ${startId} - ${endId}`, 'green')
                console.log(`ReadBoost OK: ${startId}-${endId}`)
                return true
            } catch (e) {
                console.warn(`ReadBoost 重试 ${attempt}/${retries}: ${startId}-${endId}`, e)
                if (attempt < retries) {
                    updateStatus(`重试 ${startId}-${endId} (${attempt + 1}/${retries})`, 'orange')
                    await sleep(2000)
                } else {
                    updateStatus(`跳过 ${startId}-${endId}`, 'red')
                    console.error(`ReadBoost FAIL: ${startId}-${endId}`, e)
                }
            }
        }
        return false
    }

    function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }

    async function readTopic(topicId, totalReplies) {
        if (isRunning) return
        isRunning = true
        abortFlag = false

        // 注入停止按钮
        const stopBtn = createButton('停止', 'rbStopBtn', 'btn-danger')
        const statusEl = document.getElementById('rbStatus')
        if (statusEl) {
            const parent = statusEl.parentNode
            parent.insertBefore(stopBtn, statusEl.nextSibling)
        }
        const stopEl = document.getElementById('rbStopBtn')
        if (stopEl) stopEl.addEventListener('click', () => {
            abortFlag = true
            updateStatus('正在停止...', 'red')
        })

        try {
            const csrfEl = getElemSafe('meta[name=csrf-token]', 'CSRF meta')
            if (!csrfEl) {
                updateStatus('错误:未找到 CSRF token', 'red')
                return
            }
            const csrf = csrfEl.getAttribute('content')

            console.log(`ReadBoost 开始: topic=${topicId}, 回复数=${totalReplies}`)
            updateStatus(`开始阅读 (0/${totalReplies})`, '#555')

            let skippedCount = 0
            for (let i = 1; i <= totalReplies && !abortFlag;) {
                const batchSize = getRandomInt(config.minReqSize, config.maxReqSize)
                const endId = Math.min(i + batchSize - 1, totalReplies)
                const ok = await sendBatch(i, endId, topicId, csrf)
                if (abortFlag) break
                if (ok) {
                    updateStatus(`进度 ${Math.min(endId, totalReplies)}/${totalReplies}`, '#555')
                } else {
                    skippedCount += endId - i + 1
                }
                i = endId + 1

                // 最后一批之后不再延迟
                if (i <= totalReplies && !abortFlag) {
                    await sleep(config.baseDelay + getRandomInt(0, config.randomDelayRange))
                }
            }

            if (abortFlag) {
                updateStatus('已手动停止', 'red')
                console.log('ReadBoost 已手动停止')
            } else if (skippedCount > 0) {
                updateStatus(`完成,跳过 ${skippedCount} 条`, 'orange')
                console.warn(`ReadBoost 完成,但跳过 ${skippedCount} 条`)
            } else {
                updateStatus('全部完成 ✓', 'green')
                console.log('ReadBoost 全部完成')
            }
        } finally {
            isRunning = false
            removeStopButton()
        }
    }

    // ── 初始化 ────────────────────────────────────────────────────────────
    ready.then(async () => {
        const headerButtons = await waitForElem('.header-buttons')
        if (!headerButtons) {
            console.warn('ReadBoost: 未找到 .header-buttons,放弃加载')
            return
        }

        // 解析 topic ID 和回复数
        currentTopicId = parseTopicId()
        if (!currentTopicId) {
            console.warn('ReadBoost: 无法解析 topic ID', window.location.pathname)
            return
        }

        await waitForElem('div.timeline-replies', 5000)
        currentTotalReplies = parseTotalReplies()

        console.log('ReadBoost 已加载', { topicID: currentTopicId, totalReplies: currentTotalReplies })

        // 注入 UI
        const rbControls = document.createElement('span')
        rbControls.className = 'rb-controls'
        const statusLabel = createStatusLabel(currentTotalReplies > 0 ? 'ReadBoost 待命中' : 'ReadBoost (无回复)')
        const settingsBtn = createButton('设置', 'rbSettingsBtn', 'btn-icon-text')

        rbControls.appendChild(statusLabel)
        rbControls.appendChild(settingsBtn)
        headerButtons.appendChild(rbControls)
        settingsBtn.addEventListener('click', showSettings)

        // 自启动
        if (config.autoStart && currentTotalReplies > 0) {
            setTimeout(() => readTopic(currentTopicId, currentTotalReplies), 500)
        }
    })
})()