LINUX DO ReadBoost

基于【LINUXDO ReadBoost】改编,支持了响应式更新内容的论坛;LINUX DO ReadBoost 是一个刷取 LINUX DO 论坛已读帖量脚本,理论上支持所有的 Discourse 论坛

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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         LINUX DO ReadBoost
// @author       hmjz100
// @namespace    github.com/hmjz100
// @version      1.0.1
// @description  基于【LINUXDO ReadBoost】改编,支持了响应式更新内容的论坛;LINUX DO ReadBoost 是一个刷取 LINUX DO 论坛已读帖量脚本,理论上支持所有的 Discourse 论坛
// @icon         data:image/webp;base64,UklGRtQCAABXRUJQVlA4TMcCAAAvH8AHEB8EtbZt18rc9+fosKCwdEX/Jqez23Aiybar9LnkqMCCwrL/VeFyeNeJJNuu0veRo8NmNPtfCe4XFpkzvAu3tm1V1XyCRWTuLh0QQQ1UzKASIjKH73ICEPjFPwJBKbf/EMiQI0GCSrumbWbUczuh287jjJr9vkm34Y/IyG6038M1wD8Ch3//03m0vhZ3DWoTW6yCtHeElhrib+prO+wcwAZVlNDC7O/33b/fH18YYo0MGQm/bZDLX1XzK2ZUWVy93YrNoVHVfy7KN2nyKpI+q/FdRyTSRx385eR9skeqREUqSCTSqBAlGf5KNijKd4G0/A3xuhyUVxWryt9x2c+SiCQSQkISdrOOJCQhCauKJCTh+bJ13089nuZWFQ/HmafzQgLhuOzB93U7KSasKl6uu51DoiTJpm3NtW3btu+zbevatvHpR2vH+4E1vTAR/Z8ACpuWAaC7t78gz7ODEGDTEG06IavPXxwfrK06ObAXvi8LSI8iA+jJPdmKT1ha34BeErflbQ8hI0ImsgdOlxNWdvd2nHOOLYws64Ec8mEykJ9ylLS6t7UZpdtbhUnRlAEgnXLcYeLu9maMAs3OUDqlAQNHet8q9NoIwBnoOUvyW0o9zjVZZN2LS15cUOqtkSxg8MHlzdW1Um3lAROmClNTlIvRuB2bvBug59shHuEAPWOYR4MQtsIBC//3EpRYliCELdgH4RmOJ6YDdHxL40XVVcqtM4Ta+08fPlJsn2ghNN/5++e3YmPuNKODGp7Nv/uilOdtAzoqCU2v5t59VchzRgOVaEN555u595++RSfasO8sRxuAVpSZ1/Pf332OBqyN7SyjVoS2o7zp+c+ZHx/ef/xijPHvHWbsc11O7QjfCtSXvvg8++/Xd621t7OQUkNwiNwGtDQWP375NuQti280QBuireggoKuupqTY6xp2ADqEwgIA
// @license      MIT
// @match        *://linux.do/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        unsafeWindow
// @run-at       document-body
// @require      https://unpkg.com/[email protected]/dist/jquery.min.js
// ==/UserScript==

(function ReadBoost() {
	'use strict';

	let reading = [];
	let readed = [];

	let originPushState = history.pushState;
	unsafeWindow.history.pushState = function (state, title, src) {
		setTimeout(() => {
			boost(new URL(src, location.href));
		}, 1500)
		return originPushState.call(unsafeWindow.history, state, title, src);
	};

	let originReplaceState = history.replaceState;
	unsafeWindow.history.replaceState = function (state, title, src) {
		setTimeout(() => {
			boost(new URL(src, location.href));
		}, 1500)
		return originReplaceState.call(unsafeWindow.history, state, title, src);
	};

	let style = $(`<style id="readBoostStyle">
		#readBoost {
			position: fixed;
			top: 50%;
			left: 50%;
			transform: translate(-50%, -50%);
			padding: 1.3em;
			border-radius: 16px;
			z-index: 1000;
			background: var(--tertiary-medium);
			color: var(--primary);
			box-shadow: 0 8px 32px #0000001a;
		}
		div.readboost {
			padding-top: 10px;
			font-size: 16px;
		}
		label.readboost {
			display: flex;
			align-items: center;
			justify-content: space-between;
			padding-top: 10px;
			color: var(--primary);
			font-weight: normal;
		}
		label.readboost input {
			margin: 0;
			padding: 3px 5px;
		}
		.readboost.buttonCollection {
			display: flex;
			align-items: center;
			justify-content: space-evenly;
		}

		div.topic-owner .topic-body .contents>.cooked::after {
			color: var(--tertiary-medium);
			content: "题主";
		}
	</style>`)

	let settingsButton = $(`<span class="auth-buttons"><button id="settingsButton" class="btn btn-small btn-icon-text"><svg class="fa svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#gear"></use></svg></button></span>`)
	let statusLabel = $('<span id="statusLabel" style="margin: 0 10px 0">ReadBoost 待命中</span>')
	settingsButton.on('click', showSettingsUI)

	waitForKeyElements('.header-buttons', (element) => {
		element.append(statusLabel)
		element.append(settingsButton)
	}, true)

	waitForKeyElements('body', (element) => {
		element.after(style)
	}, true)

	let defaultConfig = {
		baseDelay: 2500,
		randomDelayRange: 800,
		minReqSize: 8,
		maxReqSize: 20,
		minReadTime: 800,
		maxReadTime: 3000,
		autoStart: false
	}

	let config = { ...defaultConfig, ...getStoredConfig() }
	let csrfToken = $('meta[name=csrf-token]').attr('content')

	function boost(url = (new URL(location.href)), auto = false) {
		console.log(`【LINUX DO ReadBoost】Init\n收到新链接`, `\n链接:${url.href}`)

		// 初始化
		let topicId = url?.pathname?.split("/")?.[3]
		let repliesInfo = $('div[class=timeline-replies]').text().trim()
		if (!topicId || !csrfToken || !repliesInfo) {
			console.log(`【LINUX DO ReadBoost】Init\n缺失关键标识,跳过`)
			return;
		};
		let [currentPosition, totalReplies] = repliesInfo?.split("/")?.map(part => parseInt(part?.trim(), 10))

		// 自启动处理
		if (config.autoStart || auto) {
			startReading(topicId, totalReplies)
		}
	}
	boost()

	/**
	 * 开始刷取已读话题
	 * @param {string} topicId 主题ID
	 * @param {number} totalReplies 总帖子数
	 */
	async function startReading(topicId, totalReplies) {
		if (!reading.includes(topicId)) {
			reading.push(topicId);
		} else {
			console.log(`【LINUX DO ReadBoost】Read\n正在处理此话题,跳过`)
			return;
		}
		if (readed.includes(topicId)) {
			console.log(`【LINUX DO ReadBoost】Read\n已读过此话题,跳过`)
			let index = reading.indexOf(topicId);
			if (index !== -1) {
				reading.splice(index, 1);
			}
			return;
		}
		console.log(`【LINUX DO ReadBoost】Read\n开始阅读……`, `\n话题标识:${topicId}`, `\n帖子数量:${totalReplies}`)

		let baseRequestDelay = config.baseDelay
		let randomDelayRange = config.randomDelayRange
		let minBatchReplyCount = config.minReqSize
		let maxBatchReplyCount = config.maxReqSize
		let minReadTime = config.minReadTime
		let maxReadTime = config.maxReadTime

		// 随机数生成
		function getRandomInt(min, max) {
			return Math.floor(Math.random() * (max - min + 1)) + min
		}

		// 发起读帖请求
		async function sendBatch(startId, endId, retryCount = 3) {
			let params = createBatchParams(startId, endId)
			try {
				let response = await fetch("https://linux.do/topics/timings", {
					headers: {
						"accept": "*/*",
						"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
						"discourse-background": "true",
						"discourse-logged-in": "true",
						"discourse-present": "true",
						"priority": "u=1, i",
						"sec-fetch-dest": "empty",
						"sec-fetch-mode": "cors",
						"sec-fetch-site": "same-origin",
						"x-csrf-token": csrfToken,
						"x-requested-with": "XMLHttpRequest",
						"x-silence-logger": "true"
					},
					referrer: `https://linux.do/`,
					body: params.toString(),
					method: "POST",
					mode: "cors",
					credentials: "include"
				})
				if (!response.ok) {
					throw new Error(`请求失败,状态:${response.status}`)
				}
				console.log(`【LINUX DO ReadBoost】Read\n处理成功`, `\n话题标识:${topicId}`, `\n帖子标识:${startId}~${endId}`)
				updateStatus(`话题 ${topicId} 的帖子 ${startId}~${endId} 处理成功`, "green")
			} catch (error) {
				console.error(`【LINUX DO ReadBoost】Read\n处理失败`, `\n话题标识:${topicId}`, `\n帖子标识:${startId}~${endId}`, `\n错误详情:`, error)

				if (retryCount > 0) {
					console.error(`【LINUX DO ReadBoost】Read\n重新处理`, `\n话题标识:${topicId}`, `\n帖子标识:${startId}~${endId}`)
					updateStatus(`重新处理话题 ${topicId} 的帖子 ${startId}~${endId}(${retryCount})`, "orange")

					let retryDelay = 2000
					await new Promise(r => setTimeout(r, retryDelay))
					await sendBatch(startId, endId, retryCount - 1)
				} else {
					console.error(`【LINUX DO ReadBoost】Read\n处理失败`, `\n话题标识:${topicId}`, `\n帖子标识:${startId}~${endId}`, `\n错误详情:`, error)
					updateStatus(`话题 ${topicId} 的帖子 ${startId}~${endId} 处理失败`, "red")
				}
			}
			let delay = baseRequestDelay + getRandomInt(0, randomDelayRange)
			await new Promise(r => setTimeout(r, delay))
		}

		function createBatchParams(startId, endId) {
			let params = new URLSearchParams()

			for (let i = startId; i <= endId; i++) {
				params.append(`timings[${i}]`, getRandomInt(minReadTime, maxReadTime).toString())
			}
			let topicTime = getRandomInt(minReadTime * (endId - startId + 1), maxReadTime * (endId - startId + 1)).toString()
			params.append('topic_time', topicTime)
			params.append('topic_id', topicId)
			return params
		}

		// 批量阅读处理
		for (let i = 1; i <= totalReplies;) {
			let batchSize = getRandomInt(minBatchReplyCount, maxBatchReplyCount)
			let startId = i
			let endId = Math.min(i + batchSize - 1, totalReplies)

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

		console.log(`【LINUX DO ReadBoost】Read\n处理完成`, `\n话题标识:${topicId}`)
		updateStatus(`话题 ${topicId} 处理完成`, "green")

		if (!readed.includes(topicId)) {
			readed.push(topicId);
		}
		let index = reading.indexOf(topicId);
		if (index !== -1) {
			reading.splice(index, 1);
		}

		setTimeout(() => {
			updateStatus("ReadBoost 待命中", "")
		}, 3000)
	}

	/**
	 * 更新状态标签内容
	 */
	function updateStatus(text, color) {
		statusLabel.text(text)
		if (color !== "") {
			statusLabel.css({ 'background-color': color, 'color': '#fff' })
		} else {
			statusLabel.css({ 'background-color': '', 'color': '' })
		}
	}

	/**
	 * 显示设置UI界面
	 */
	function showSettingsUI() {
		if ($('#readBoost').length) return;
		let settingsDiv = $(`<div id="readBoost">
			<h3>ReadBoost 设置</h3>
			<div class="readboost">
				<label class="readboost"><span>基础延迟(ms)</span><input id="baseDelay" type="number" value="${config.baseDelay}"></label>
				<label class="readboost"><span>随机延迟范围(ms)</span><input id="randomDelayRange" type="number" value="${config.randomDelayRange}"></label>
				<label class="readboost"><span>最小请求量</span><input id="minReqSize" type="number" value="${config.minReqSize}"></label>
				<label class="readboost"><span>最大请求量</span><input id="maxReqSize" type="number" value="${config.maxReqSize}"></label>
				<label class="readboost"><span>最小时间(ms)</span><input id="minReadTime" type="number" value="${config.minReadTime}"></label>
				<label class="readboost"><span>最大时间(ms)</span><input id="maxReadTime" type="number" value="${config.maxReadTime}"></label>
				<label class="readboost"><span>解锁参数</span><input type="checkbox" id="advancedMode"></label>
				<label class="readboost"><span>自动运行</span><input type="checkbox" id="autoStart" ${config.autoStart ? "checked" : ""}></label>
			</div>
			<div class="readboost buttonCollection">
				<button class="btn btn-small" id="saveSettings">
					<span class="d-button-label">保存</span>
				</button>
				<button class="btn btn-small" id="resetDefaults">
					<span class="d-button-label">重置</span>
				</button>
				<button class="btn btn-small" id="startManually">
					<span class="d-button-label">运行</span>
				</button>
				<button class="btn btn-small" id="closeSettings">
					<span class="d-button-label">关闭</span>
				</button>
			</div>
		</div>`)

		settingsDiv.find("#saveSettings").on("click", () => {
			config.baseDelay = parseInt(settingsDiv.find("#baseDelay").val(), 10);
			config.randomDelayRange = parseInt(settingsDiv.find("#randomDelayRange").val(), 10);
			config.minReqSize = parseInt(settingsDiv.find("#minReqSize").val(), 10);
			config.maxReqSize = parseInt(settingsDiv.find("#maxReqSize").val(), 10);
			config.minReadTime = parseInt(settingsDiv.find("#minReadTime").val(), 10);
			config.maxReadTime = parseInt(settingsDiv.find("#maxReadTime").val(), 10);
			config.autoStart = settingsDiv.find("#autoStart").prop("checked");

			// 持久化保存设置
			GM_setValue("baseDelay", config.baseDelay);
			GM_setValue("randomDelayRange", config.randomDelayRange);
			GM_setValue("minReqSize", config.minReqSize);
			GM_setValue("maxReqSize", config.maxReqSize);
			GM_setValue("minReadTime", config.minReadTime);
			GM_setValue("maxReadTime", config.maxReadTime);
			GM_setValue("autoStart", config.autoStart);

			settingsDiv.remove();
			location.reload();
		});

		settingsDiv.find("#resetDefaults").on("click", () => {
			let result = confirm("你确定要重置吗?所有自定义数据都将丢失!");
			if (result) {
				config = { ...defaultConfig };

				GM_setValue("baseDelay", defaultConfig.baseDelay);
				GM_setValue("randomDelayRange", defaultConfig.randomDelayRange);
				GM_setValue("minReqSize", defaultConfig.minReqSize);
				GM_setValue("maxReqSize", defaultConfig.maxReqSize);
				GM_setValue("minReadTime", defaultConfig.minReadTime);
				GM_setValue("maxReadTime", defaultConfig.maxReadTime);
				GM_setValue("autoStart", defaultConfig.autoStart);

				settingsDiv.remove();
				location.reload();
			}
		});

		settingsDiv.find("#startManually").on("click", () => {
			boost(location, true)
			settingsDiv.remove();
		});

		function toggleSettingsInputs(enabled) {
			let inputs = [
				"baseDelay", "randomDelayRange", "minReqSize",
				"maxReqSize", "minReadTime", "maxReadTime"
			];
			inputs.forEach(inputId => {
				let inputElement = settingsDiv.find(`#${inputId}`);
				if (inputElement.length) {
					inputElement.prop("disabled", !enabled);
				}
			});
		}
		toggleSettingsInputs(false);

		settingsDiv.find("#advancedMode").on("change", (event) => {
			if ($(event.target).prop("checked")) {
				toggleSettingsInputs(true);
			} else {
				toggleSettingsInputs(false);
			}
		});

		settingsDiv.find("#closeSettings").on("click", () => {
			settingsDiv.remove();
		});

		$("body").append(settingsDiv);
	}

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

	function waitForKeyElements(selectorTxt, actionFunction, bWaitOnce, iframeSelector) {
		function findInShadowRoots(root, selector) {
			let elements = $(root).find(selector).toArray();
			$(root).find('*').each(function () {
				let shadowRoot = this.shadowRoot;
				if (shadowRoot) {
					elements = elements.concat(findInShadowRoots(shadowRoot, selector));
				}
			});
			return elements;
		}
		var targetElements;
		if (iframeSelector) {
			targetElements = $(iframeSelector).contents();
		} else {
			targetElements = $(document);
		}
		let allElements = findInShadowRoots(targetElements, selectorTxt);
		if (allElements.length > 0) {
			allElements.forEach(function (element) {
				var jThis = $(element);
				var uniqueIdentifier = 'alreadyFound';
				var alreadyFound = jThis.data(uniqueIdentifier) || false;
				if (!alreadyFound) {
					var cancelFound = actionFunction(jThis);
					if (cancelFound) {
						return false;
					} else {
						jThis.data(uniqueIdentifier, true);
					}
				}
			});
		}
		var controlObj = waitForKeyElements.controlObj || {};
		var controlKey = selectorTxt.replace(/[^\w]/g, "_");
		var timeControl = controlObj[controlKey];
		if (allElements.length > 0 && bWaitOnce && timeControl) {
			clearInterval(timeControl);
			delete controlObj[controlKey];
		} else {
			if (!timeControl) {
				timeControl = setInterval(function () {
					waitForKeyElements(selectorTxt, actionFunction, bWaitOnce, iframeSelector);
				}, 1000);
				controlObj[controlKey] = timeControl;
			}
		}
		waitForKeyElements.controlObj = controlObj;
	}
})();