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