Greasy Fork 支持简体中文。

LINUX DO ReadBoost

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

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