DISCOURSE reader

DISCOURSE reader 可以帮您实现自动阅读指定主题

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        DISCOURSE reader
// @namespace   discourse_reader
// @match       https://*/*
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_registerMenuCommand
// @version     0.1.1
// @author      sean908
// @license     GPL-3.0-or-later
// @description DISCOURSE reader 可以帮您实现自动阅读指定主题
// @description:en DISCOURSE reader can help you automatically read specified topics
// ==/UserScript==

(function () {
    'use strict'
    // 语言包
    const LANG_PACK = {
        zh: {
            firstTimePrompt: "[ DISCOURSE reader ]\n检测到这是你第一次使用DISCOURSE reader,使用前你必须知晓:使用该第三方脚本可能会导致包括并不限于账号被限制、被封禁的潜在风险,脚本不对出现的任何风险负责,这是一个开源脚本,你可以自由审核其中的内容,如果你同意以上内容,请输入'明白'",
            understand: "明白",
            riskNotAgreed: "您未同意风险提示,脚本已停止运行。",
            notOnTopicPage: "不在帖子页面",
            cannotGetPageInfo: "无法获取页面信息,请确认在正确的帖子页面",
            scriptRunning: "脚本正在运行中...",
            starting: "正在启动...",
            processComplete: "处理完成",
            executionFailed: "执行失败: ",
            userStopped: "用户停止执行",
            stopping: "正在停止...",
            settingsTitle: "DISCOURSE reader 设置",
            baseDelay: "基础延迟(ms):",
            randomDelayRange: "随机延迟范围(ms):",
            minReqSize: "最小每次请求量:",
            maxReqSize: "最大每次请求量:",
            minReadTime: "最小阅读时间(ms):",
            maxReadTime: "最大阅读时间(ms):",
            language: "语言:",
            advancedMode: "高级设置模式",
            autoStart: "自动运行",
            startFromCurrent: "从当前浏览位置开始",
            saveSettings: "保存设置",
            resetDefaults: "重置默认",
            close: "关闭",
            advancedWarning: "高级设置可能增加账号风险,确定要启用吗?",
            settingsSaved: "设置已保存",
            resetToDefaults: "已重置为默认设置",
            confirmReset: "确定要重置为默认设置吗?",
            processing: "处理回复",
            retrying: "重试",
            remaining: "剩余",
            times: "次",
            menuStart: "🚀 开始执行",
            menuSettings: "⚙️ 设置",
            initializationFailed: "初始化失败:",
            loadedMessage: "DISCOURSE reader 已加载",
            topicInfo: "帖子ID:",
            totalReplies: "总回复:"
        },
        en: {
            firstTimePrompt: "[ DISCOURSE reader ]\nThis is your first time using DISCOURSE reader. Please be aware: using this third-party script may result in risks including but not limited to account restriction or ban. The script is not responsible for any risks. This is an open-source script that you can freely review. If you agree to the above, please enter 'OK'",
            understand: "OK",
            riskNotAgreed: "You did not agree to the risk notice. The script has been stopped.",
            notOnTopicPage: "Not on topic page",
            cannotGetPageInfo: "Cannot get page information, please confirm you are on the correct topic page",
            scriptRunning: "Script is running...",
            starting: "Starting...",
            processComplete: "Process completed",
            executionFailed: "Execution failed: ",
            userStopped: "User stopped execution",
            stopping: "Stopping...",
            settingsTitle: "DISCOURSE reader Settings",
            baseDelay: "Base Delay (ms):",
            randomDelayRange: "Random Delay Range (ms):",
            minReqSize: "Min Request Size:",
            maxReqSize: "Max Request Size:",
            minReadTime: "Min Read Time (ms):",
            maxReadTime: "Max Read Time (ms):",
            language: "Language:",
            advancedMode: "Advanced Mode",
            autoStart: "Auto Start",
            startFromCurrent: "Start from Current Position",
            saveSettings: "Save Settings",
            resetDefaults: "Reset Defaults",
            close: "Close",
            advancedWarning: "Advanced settings may increase account risk, are you sure to enable?",
            settingsSaved: "Settings saved",
            resetToDefaults: "Reset to default settings",
            confirmReset: "Are you sure to reset to default settings?",
            processing: "Processing replies",
            retrying: "Retrying",
            remaining: "remaining",
            times: "times",
            menuStart: "🚀 Start",
            menuStop: "⏹️ Stop",
            menuSettings: "⚙️ Settings",
            initializationFailed: "Initialization failed:",
            loadedMessage: "DISCOURSE reader loaded",
            topicInfo: "Topic ID:",
            totalReplies: "Total replies:"
        }
    }

    // 默认参数
    const DEFAULT_CONFIG = {
        baseDelay: 2500,
        randomDelayRange: 800,
        minReqSize: 8,
        maxReqSize: 20,
        minReadTime: 800,
        maxReadTime: 3000,
        autoStart: false,
        startFromCurrent: false,
        language: 'auto'
    }

    function getStoredConfig() {
        return {
            baseDelay: GM_getValue("baseDelay", DEFAULT_CONFIG.baseDelay),
            randomDelayRange: GM_getValue("randomDelayRange", DEFAULT_CONFIG.randomDelayRange),
            minReqSize: GM_getValue("minReqSize", DEFAULT_CONFIG.minReqSize),
            maxReqSize: GM_getValue("maxReqSize", DEFAULT_CONFIG.maxReqSize),
            minReadTime: GM_getValue("minReadTime", DEFAULT_CONFIG.minReadTime),
            maxReadTime: GM_getValue("maxReadTime", DEFAULT_CONFIG.maxReadTime),
            autoStart: GM_getValue("autoStart", DEFAULT_CONFIG.autoStart),
            startFromCurrent: GM_getValue("startFromCurrent", DEFAULT_CONFIG.startFromCurrent),
            language: GM_getValue("language", DEFAULT_CONFIG.language)
        }
    }

    let config = { ...DEFAULT_CONFIG, ...getStoredConfig() }
    // 获取浏览器语言
    function detectLanguage() {
        const browserLang = navigator.language || navigator.userLanguage
        if (browserLang.startsWith('zh')) {
            return 'zh'
        }
        return 'en'
    }

    // 翻译函数
    function t(key) {
        const currentLang = config && config.language && config.language !== 'auto' ? config.language : detectLanguage()
        return LANG_PACK[currentLang]?.[key] || LANG_PACK.en[key] || key
    }

    const hasAgreed = GM_getValue("hasAgreed", false)
    if (!hasAgreed) {
        const userInput = prompt(t('firstTimePrompt'))
        if (userInput !== t('understand')) {
            alert(t('riskNotAgreed'))
            return
        }
        GM_setValue("hasAgreed", true)
    }

    let isRunning = false
    let shouldStop = false
    let statusLabel = null
    let initTimeout = null
    function isTopicPage() {
        return /\/t\/[^\/]+\/\d+/.test(window.location.pathname)
    }

    function getPageInfo() {
        if (!isTopicPage()) {
            throw new Error(t('notOnTopicPage'))
        }
        const topicID = window.location.pathname.split("/")[3]
        const repliesElement = document.querySelector("div[class=timeline-replies]")
        const csrfElement = document.querySelector("meta[name=csrf-token]")

        if (!repliesElement || !csrfElement) {
            throw new Error(t('cannotGetPageInfo'))
        }

        const repliesInfo = repliesElement.textContent.trim()
        const [currentPosition, totalReplies] = repliesInfo.split("/").map(part => parseInt(part.trim(), 10))
        const csrfToken = csrfElement.getAttribute("content")

        return { topicID, currentPosition, totalReplies, csrfToken }
    }


    function saveConfig(newConfig) {
        Object.keys(newConfig).forEach(key => {
            GM_setValue(key, newConfig[key])
            config[key] = newConfig[key]
        })
        // 如果语言设置改变,重新创建状态标签以更新文本
        if (statusLabel) {
            const existingLabel = document.getElementById("readBoostStatusLabel")
            if (existingLabel) {
                existingLabel.remove()
            }
            statusLabel = createStatusLabel()
        }
        location.reload()
    }

    function createStatusLabel() {
        // 移除已存在的状态标签
        const existingLabel = document.getElementById("readBoostStatusLabel")
        if (existingLabel) {
            existingLabel.remove()
        }

        const headerButtons = document.querySelector(".header-buttons")
        if (!headerButtons) return null

        const labelSpan = document.createElement("span")
        labelSpan.id = "readBoostStatusLabel"
        labelSpan.style.cssText = `
            margin-left: 10px;
            margin-right: 10px;
            padding: 5px 10px;
            border-radius: 4px;
            background: var(--primary-low);
            color: var(--primary);
            font-size: 12px;
            font-weight: bold;
            cursor: pointer;
        `
        labelSpan.textContent = "DISCOURSE reader"+" ⚙️"
        labelSpan.addEventListener("click", showSettingsUI)

        headerButtons.appendChild(labelSpan)
        return labelSpan
    }

    // 更新状态
    function updateStatus(text, type = "info") {
        if (!statusLabel) return

        const colors = {
            info: "var(--primary)",
            success: "#2e8b57",
            warning: "#ff8c00",
            error: "#dc3545",
            running: "#007bff"
        }

        statusLabel.textContent = text + " ⚙️"
        statusLabel.style.color = colors[type] || colors.info
    }

    function showSettingsUI() {
        const settingsDiv = document.createElement("div")
        settingsDiv.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            padding: 25px;
            border-radius: 12px;
            z-index: 10000;
            background: var(--secondary);
            color: var(--primary);
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
            border: 1px solid var(--primary-low);
            min-width: 400px;
            max-width: 500px;
        `

        const autoStartChecked = config.autoStart ? "checked" : ""
        const startFromCurrentChecked = config.startFromCurrent ? "checked" : ""
        settingsDiv.innerHTML = `
            <h3 style="margin-top: 0; color: var(--primary); text-align: center;">${t('settingsTitle')}</h3>
            <div style="display: grid; gap: 15px;">
                <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
                    <label style="display: flex; flex-direction: column; gap: 5px;">
                        <span>${t('baseDelay')}</span>
                        <input id="baseDelay" type="number" value="${config.baseDelay}"
                               style="padding: 8px; border: 1px solid var(--primary-low); border-radius: 4px; background: var(--secondary);">
                    </label>
                    <label style="display: flex; flex-direction: column; gap: 5px;">
                        <span>${t('randomDelayRange')}</span>
                        <input id="randomDelayRange" type="number" value="${config.randomDelayRange}"
                               style="padding: 8px; border: 1px solid var(--primary-low); border-radius: 4px; background: var(--secondary);">
                    </label>
                </div>
                <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
                    <label style="display: flex; flex-direction: column; gap: 5px;">
                        <span>${t('minReqSize')}</span>
                        <input id="minReqSize" type="number" value="${config.minReqSize}"
                               style="padding: 8px; border: 1px solid var(--primary-low); border-radius: 4px; background: var(--secondary);">
                    </label>
                    <label style="display: flex; flex-direction: column; gap: 5px;">
                        <span>${t('maxReqSize')}</span>
                        <input id="maxReqSize" type="number" value="${config.maxReqSize}"
                               style="padding: 8px; border: 1px solid var(--primary-low); border-radius: 4px; background: var(--secondary);">
                    </label>
                </div>
                <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
                    <label style="display: flex; flex-direction: column; gap: 5px;">
                        <span>${t('minReadTime')}</span>
                        <input id="minReadTime" type="number" value="${config.minReadTime}"
                               style="padding: 8px; border: 1px solid var(--primary-low); border-radius: 4px; background: var(--secondary);">
                    </label>
                    <label style="display: flex; flex-direction: column; gap: 5px;">
                        <span>${t('maxReadTime')}</span>
                        <input id="maxReadTime" type="number" value="${config.maxReadTime}"
                               style="padding: 8px; border: 1px solid var(--primary-low); border-radius: 4px; background: var(--secondary);">
                    </label>
                </div>
                <div style="display: grid; grid-template-columns: 1fr; gap: 10px;">
                    <label style="display: flex; flex-direction: column; gap: 5px;">
                        <span>${t('language')}</span>
                        <select id="language" style="padding: 8px; border: 1px solid var(--primary-low); border-radius: 4px; background: var(--secondary);">
                            <option value="auto" ${config.language === 'auto' ? 'selected' : ''}>Auto (${config.language === 'auto' ? (detectLanguage() === 'zh' ? '中文' : 'English') : (config.language === 'zh' ? '中文' : 'English')})</option>
                            <option value="zh" ${config.language === 'zh' ? 'selected' : ''}>中文</option>
                            <option value="en" ${config.language === 'en' ? 'selected' : ''}>English</option>
                        </select>
                    </label>
                </div>
                <div style="display: flex; gap: 15px; align-items: center; flex-wrap: wrap;">
                    <label style="display: flex; align-items: center; gap: 8px;">
                        <input type="checkbox" id="advancedMode" style="transform: scale(1.2);">
                        <span>${t('advancedMode')}</span>
                    </label>
                    <label style="display: flex; align-items: center; gap: 8px;">
                        <input type="checkbox" id="autoStart" ${autoStartChecked} style="transform: scale(1.2);">
                        <span>${t('autoStart')}</span>
                    </label>
                    <label style="display: flex; align-items: center; gap: 8px;">
                        <input type="checkbox" id="startFromCurrent" ${startFromCurrentChecked} style="transform: scale(1.2);">
                        <span>${t('startFromCurrent')}</span>
                    </label>
                </div>
                <div style="display: flex; gap: 10px; justify-content: center; margin-top: 10px;">
                    <button id="saveSettings" style="padding: 10px 20px; border: none; border-radius: 6px; background: #007bff; color: white; cursor: pointer;">${t('saveSettings')}</button>
                    <button id="resetDefaults" style="padding: 10px 20px; border: none; border-radius: 6px; background: #6c757d; color: white; cursor: pointer;">${t('resetDefaults')}</button>
                    <button id="closeSettings" style="padding: 10px 20px; border: none; border-radius: 6px; background: #dc3545; color: white; cursor: pointer;">${t('close')}</button>
                </div>
            </div>
        `

        document.body.appendChild(settingsDiv)

        toggleAdvancedInputs(false)

        document.getElementById("advancedMode").addEventListener("change", (e) => {
            if (e.target.checked) {
                const confirmed = confirm(t('advancedWarning'))
                if (!confirmed) {
                    e.target.checked = false
                    return
                }
            }
            toggleAdvancedInputs(e.target.checked)
        })

        document.getElementById("saveSettings").addEventListener("click", () => {
            const newConfig = {
                baseDelay: parseInt(document.getElementById("baseDelay").value, 10),
                randomDelayRange: parseInt(document.getElementById("randomDelayRange").value, 10),
                minReqSize: parseInt(document.getElementById("minReqSize").value, 10),
                maxReqSize: parseInt(document.getElementById("maxReqSize").value, 10),
                minReadTime: parseInt(document.getElementById("minReadTime").value, 10),
                maxReadTime: parseInt(document.getElementById("maxReadTime").value, 10),
                autoStart: document.getElementById("autoStart").checked,
                startFromCurrent: document.getElementById("startFromCurrent").checked,
                language: document.getElementById("language").value
            }

            saveConfig(newConfig)
            settingsDiv.remove()
            updateStatus(t('settingsSaved'), "success")

        })

        document.getElementById("resetDefaults").addEventListener("click", () => {
            if (confirm(t('confirmReset'))) {
                saveConfig(DEFAULT_CONFIG)
                settingsDiv.remove()
                updateStatus(t('resetToDefaults'), "info")
            }
        })

        document.getElementById("closeSettings").addEventListener("click", () => {
            settingsDiv.remove()
        })

        function toggleAdvancedInputs(enabled) {
            const inputs = ["baseDelay", "randomDelayRange", "minReqSize", "maxReqSize", "minReadTime", "maxReadTime"]
            inputs.forEach(id => {
                const input = document.getElementById(id)
                if (input) {
                    input.disabled = !enabled
                    input.style.opacity = enabled ? "1" : "0.6"
                }
            })
        }
    }

    async function startReading() {
        if (isRunning) {
            updateStatus(t('scriptRunning'), "warning")
            return
        }

        try {
            const pageInfo = getPageInfo()
            isRunning = true
            shouldStop = false

            updateStatus(t('starting'), "running")

            await processReading(pageInfo)

            updateStatus(t('processComplete'), "success")
        } catch (error) {
            console.error("执行错误:", error)
            if (error.message === t('userStopped')) {
                updateStatus("DISCOURSE reader", "info")
            } else {
                updateStatus(t('executionFailed') + error.message, "error")
            }
        } finally {
            isRunning = false
        }
    }


    function stopReading() {
        shouldStop = true
        updateStatus(t('stopping'), "warning")
    }

    // 处理阅读逻辑
    async function processReading(pageInfo) {
        const { topicID, currentPosition, totalReplies, csrfToken } = pageInfo
        const startPosition = config.startFromCurrent ? currentPosition : 1

        console.log(`开始处理,起始位置: ${startPosition}, 总回复: ${totalReplies}`)

        function getRandomInt(min, max) {
            return Math.floor(Math.random() * (max - min + 1)) + min
        }

        async function sendBatch(startId, endId, retryCount = 3) {
            // 停止检查
            if (shouldStop) throw new Error(t('userStopped'))

            const params = new URLSearchParams()

            for (let i = startId; i <= endId; i++) {
                params.append(`timings[${i}]`, getRandomInt(config.minReadTime, config.maxReadTime).toString())
            }

            const topicTime = getRandomInt(
                config.minReadTime * (endId - startId + 1),
                config.maxReadTime * (endId - startId + 1)
            ).toString()

            params.append('topic_time', topicTime)
            params.append('topic_id', topicID)

            try {
                const response = await fetch(`${window.location.origin}/topics/timings`, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
                        "X-CSRF-Token": csrfToken,
                        "X-Requested-With": "XMLHttpRequest"
                    },
                    body: params,
                    credentials: "include"
                })

                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}`)
                }

                // 再次检查是否应该停止
                if (shouldStop) throw new Error(t('userStopped'))

                updateStatus(`${t('processing')} ${startId}-${endId} (${Math.round((endId / totalReplies) * 100)}%)`, "running")

            } catch (error) {
                if (shouldStop) throw error // 如果是停止信号,直接抛出

                if (retryCount > 0) {
                    updateStatus(`${t('retrying')} ${startId}-${endId} (${t('remaining')}${retryCount}${t('times')})`, "warning")
                    await new Promise(r => setTimeout(r, 2000))
                    return await sendBatch(startId, endId, retryCount - 1)
                }
                throw error
            }

            // 延迟期间也检查停止信号
            const delay = config.baseDelay + getRandomInt(0, config.randomDelayRange)
            for (let i = 0; i < delay; i += 100) {
                if (shouldStop) throw new Error(t('userStopped'))
                await new Promise(r => setTimeout(r, Math.min(100, delay - i)))
            }
        }

        // 批量处理
        for (let i = startPosition; i <= totalReplies;) {
            if (shouldStop) break

            const batchSize = getRandomInt(config.minReqSize, config.maxReqSize)
            const startId = i
            const endId = Math.min(i + batchSize - 1, totalReplies)

            await sendBatch(startId, endId)
            i = endId + 1
        }
    }

    // 注册菜单命令
    GM_registerMenuCommand(t('menuStart'), startReading)
    GM_registerMenuCommand(t('menuStop'), stopReading)
    GM_registerMenuCommand(t('menuSettings'), showSettingsUI)

    function init() {
        statusLabel = createStatusLabel()
        // 强制停止之前的任务
        shouldStop = true

        // 等待当前任务停止后再继续
        if (isRunning) {
            setTimeout(init, 1000)
            return
        }

        // 重置状态
        isRunning = false
        shouldStop = false

        // 清除之前的超时
        if (initTimeout) {
            clearTimeout(initTimeout)
        }
        if (!isTopicPage()) return

        try {
            const pageInfo = getPageInfo()
            console.log(t('loadedMessage'))
            console.log(`${t('topicInfo')} ${pageInfo.topicID}, ${t('totalReplies')} ${pageInfo.totalReplies}`)

            statusLabel = createStatusLabel()


            if (config.autoStart) {
                initTimeout = setTimeout(startReading, 1000)
            }

        } catch (error) {
            console.error(t('initializationFailed'), error)
            initTimeout = setTimeout(init, 1000)
        }
    }
    // 监听 URL 变化
    function setupRouteListener() {
        let lastUrl = location.href
        const originalPushState = history.pushState
        history.pushState = function () {
            originalPushState.apply(history, arguments)
            if (location.href !== lastUrl) {
                lastUrl = location.href
                setTimeout(init, 500)
            }
        }
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            init()
            setupRouteListener()
        })
    } else {
        init()
        setupRouteListener()
    }
})()