Discourse Read Boost

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Advertisement:

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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)
        }
    })
})()