From f0e3420e89efee4e20a4250b720baa000dfc7732 Mon Sep 17 00:00:00 2001 From: lua Date: Fri, 16 Aug 2024 16:54:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20clearbluedot.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clearbluedot.js | 943 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 943 insertions(+) create mode 100644 clearbluedot.js diff --git a/clearbluedot.js b/clearbluedot.js new file mode 100644 index 0000000..32f7e92 --- /dev/null +++ b/clearbluedot.js @@ -0,0 +1,943 @@ +// ==UserScript== +// @name linux.do 消灭蓝点 +// @namespace http://tampermonkey.net/ +// @version 1.0.0 +// @description try to take over the world! +// @author Yous +// @match https://linux.do/* +// @exclude https://linux.do/*.json +// @icon https://cdn.linux.do/uploads/default/original/3X/9/d/9dd49731091ce8656e94433a26a3ef36062b3994.png +// @require https://cdn.jsdelivr.net/npm/layui@2.9.14/dist/layui.min.js +// @resource layuiCSS https://cdn.jsdelivr.net/npm/layui@2.9.14/dist/css/layui.min.css +// @grant GM_addStyle +// @grant GM_getResourceText +// @grant GM_setValue +// @grant GM_getValue +// ==/UserScript== + +/* global layui */ +(function () { + 'use strict'; + + // 加载外部 CSS + let layuiCSS = GM_getResourceText("layuiCSS"); + // 替换 CSS 中的相对路径 + layuiCSS = layuiCSS.replace(/url\(\s*['"]?(\.\.\/font\/[^'"\s]+)['"]?\s*\)/g, function (match, url) { + // 假设字体文件位于一个可以访问的绝对路径 + return match.replace(url, 'https://cdn.jsdelivr.net/npm/layui@2.9.14/dist/font/' + url.split('/').pop()); + }); + GM_addStyle(layuiCSS); + + // ------ config ------ + let CONFIG = { + enableBrowseAssist: true, // 开启助手 + enableWindowPeriodRead: true, // 开启空闲阅读 + readAllPostsInTopic: false, // 是否阅读主题所有帖子,false 从最后的内容开始看 + singlePostsReading: 1000, // 单次阅读帖子数,控制 timings 请求 body 行为 + maxRetryTimes: 5, // 最大重试次数 + windowPeriodTopicUrls: ['https://linux.do/new.json', 'https://linux.do/unread.json'], // 空窗期获取帖子 url + getCsrfTokenFromHtml: false, // 是否从 html 中获取 csrf token + maxLogLineNum: 100, // 日志最大条数 + }; + + // ------ css ------ + let uiWidth = "32rem"; // ui 宽度 + let uiQueueHeight = "150px"; // ui 任务队列高度 + let uiLogHeight = "300px"; // ui 日志高度 + let uiTagFontSize = "0.75rem"; // ui 标签字体大小 + let uiQueueFontSize = "0.75rem"; // ui 队列字体大小 + let uiLogFontSize = "0.75rem"; // ui 日志字体大小 + + // ------ logic ------ + let lastTaskTime = new Date("1970-01-01T00:00:00"); + let statData = { // 维护统计 + totalSuccess: 0, + totalFail: 0, + totalReadingTime: 0, + }; + let taskQueue = []; // 维护任务队列 + let windowPeriodTopics = []; // 空闲随机阅读的帖子列表,[[, <阅读楼层数>]] + let excludeTopic = []; // 将5分钟内阅读过的 topic 排除 + let logs = []; // 维护日志 + let globalCsrfToken = null; // 维护 csrf token + let nativeXMLHttpRequestOpen; // 维护原生 XMLHttpRequest 的 open 方法 + let nativeXMLHttpRequestSend; // 维护原生 XMLHttpRequest 的 send 方法 + let dialogElement = document.createElement("div"); // 维护 UI + let queueListElement = document.createElement("ul"); + let logListElement = document.createElement("ul"); + + const isNativeFunction = (func) => { + if (typeof func !== "function") { + return false; + } + + // 获取函数的字符串表示形式 + const funcString = func.toString(); + + // 检查字符串是否包含 "[native code]" + return funcString.includes("[native code]"); + }; + + const ensureNativeMethods = (func) => { + if (isNativeFunction(func)) { + return func; + } else { + throw new Error(`${func.name} is not native`); + } + }; + + // 随机整数 + const getRandomInt = (start, end) => { + return Math.floor(Math.random() * (end - start + 1)) + start; + }; + + const matchUrl = (url, pattern) => { + return pattern.test(url); + }; + + const isTopicUrl = (url) => { + const patternTopic = /^\/t\/[0-9]+\.json($|\?)/; + const patternPost = /^\/t\/[0-9]+\/[0-9]+\.json($|\?)/; + return matchUrl(url, patternPost) || matchUrl(url, patternTopic); + }; + + const isTimingsUrl = (url) => { + const patternTimings = "/topics/timings"; + return url === patternTimings; + }; + + const isPollUrl = (url) => { + const patternPoll = /^\/message-bus\/[a-z0-9]+\/poll/; + return matchUrl(url, patternPoll); + }; + + const formatDate = (d) => { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`; + }; + + // 获取预加载数据 + const getPreloadedData = () => { + const preloadedDataElement = document.querySelector("#data-preloaded"); + if (!preloadedDataElement) { + throw new Error("Preloaded data element not found"); + } + const preloadedData = preloadedDataElement.getAttribute("data-preloaded"); + return JSON.parse(preloadedData); + }; + + // 获取用户名 + const getUsername = () => { + const preloadedData = getPreloadedData(); + const preloadedCurrentUserData = JSON.parse(preloadedData.currentUser); + return preloadedCurrentUserData.username; + }; + + // 获取 csrf token + const getCsrfToken = async () => { + if (globalCsrfToken) { + return globalCsrfToken; + } + + if (CONFIG.getCsrfTokenFromHtml) { + const csrfTokenElement = document.querySelector('meta[name="csrf-token"]'); + if (!csrfTokenElement) { + throw new Error("CSRF token element not found"); + } + globalCsrfToken = csrfTokenElement.getAttribute("content") + return globalCsrfToken; + } else { + const csrfRes = await fetch("https://linux.do/session/csrf", { + headers: { + "x-csrf-token": "undefined", + "x-requested-with": "XMLHttpRequest", + }, + body: null, + method: "GET", + mode: "cors", + credentials: "include", + }) + .then((res) => res.json()) + .catch((err) => { }); + + if (!csrfRes || !csrfRes.csrf) { + throw new Error("CSRF token not fetch"); + } + + globalCsrfToken = csrfRes.csrf + return globalCsrfToken; + } + }; + + // 定义一个函数,用于每隔1-2秒处理1000个阅读 + const handleReadingPosts = async ( + task, + topicId, + numbers, + csrfToken, + maxReadPosts = CONFIG.singlePostsReading, + retryTimes = 0 + ) => { + // 如果列表为空 或 超过最大限制 跳出 while + while (numbers.length > 0 && retryTimes <= CONFIG.maxRetryTimes) { + // 取出前 1000 个字符串,如果不足 1000 个,则取出所有剩余的字符串 + let toProcess = numbers.slice(0, maxReadPosts); + toProcess = toProcess[0] === 0 ? toProcess.slice(1) : toProcess; + + // 受后端限制,生成随机整数 randTime,范围在 60 秒到 61秒之间 + const randTime = getRandomInt(60000, 61000); + // 使用模板生成新的字符串列表 + const newStrings = toProcess.map((num) => `timings%5B${num}%5D=${randTime}`); + // 使用"&"连接字符串 + const resultString = [...newStrings, `topic_time=${randTime}`, `topic_id=${topicId}`].join("&"); + + // 生成随机整数 睡眠时间,范围在 2000ms 到 3000ms 之间 + await new Promise(resolve => setTimeout(resolve, getRandomInt(2000, 3000))); + + try { + // 请求 timing + const res = await fetch("https://linux.do/topics/timings", { + headers: { + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "x-csrf-token": csrfToken, + "x-requested-with": "XMLHttpRequest", + }, + body: resultString, + method: "POST", + mode: "cors", + credentials: "include", + }); + + if (res.status === 200) { + addLog("success", `已完成话题[${topicId}]${toProcess[0]}至${toProcess[toProcess.length - 1]}层话题阅读`); + numbers = numbers.slice(maxReadPosts); + retryTimes = 0; + await new Promise(resolve => setTimeout(resolve, getRandomInt(1000, 2000))); + } else if (res.status >= 400 && res.status < 600) { + addLog("warning", `阅读话题[${topicId}]出现错误(${res.status})!正在重试……`); + task.status = "retrying"; + updateDialogQueue(); + retryTimes++; + await new Promise(resolve => setTimeout(resolve, getRandomInt(3000, 5000))); + } else { + throw new Error(`Unexpected status: ${res.status}`); + } + } catch (err) { + console.error(err); + retryTimes++; + addLog("error", `阅读话题[${topicId}]发生未知错误: ${err.message}`); + await new Promise(resolve => setTimeout(resolve, getRandomInt(3000, 5000))); + } + } + + if (retryTimes > CONFIG.maxRetryTimes) { + return { topicId, error: true, detail: "超过最大重试次数" }; + } + + return { topicId, error: false, detail: "已完成阅读" }; + }; + + // 配置 对话框 + const settingDialog = () => { + let layer = layui.layer; + let form = layui.form; + let $ = layui.$; + + layer.open({ + type: 1, + title: '设置', + shade: false, + area: ['34rem', '100%'], + offset: 'r', + anim: 'slideLeft', + move: false, + id: 'settingDialog-layer', + content: $("#settingDialog"), + }); + // 初始化界面值 + form.val('settingDialog-filter', { + "enableBrowseAssist": CONFIG.enableBrowseAssist, + "enableWindowPeriodRead": CONFIG.enableWindowPeriodRead, + "getCsrfTokenFromHtml": CONFIG.getCsrfTokenFromHtml, + "readAllPostsInTopic": CONFIG.readAllPostsInTopic, + "windowPeriodTopicUrls": CONFIG.windowPeriodTopicUrls.join(","), + "singlePostsReading": CONFIG.singlePostsReading, + "maxRetryTimes": CONFIG.maxRetryTimes, + "maxLogLineNum": CONFIG.maxLogLineNum, + }); + // 保存设置事件 + form.on('submit(saveSetting)', function (data) { + // 获取表单字段值 + let field = data.field; + let enableBrowseAssist = field?.enableBrowseAssist && field?.enableBrowseAssist === "1" ? true : false; + // 助手开关是否被操作 + let flag = enableBrowseAssist != CONFIG.enableBrowseAssist ? true : false; + + CONFIG.enableBrowseAssist = enableBrowseAssist; + CONFIG.enableWindowPeriodRead = field?.enableWindowPeriodRead && field?.enableWindowPeriodRead === "1" ? true : false; + CONFIG.getCsrfTokenFromHtml = field?.getCsrfTokenFromHtml && field?.getCsrfTokenFromHtml === "1" ? true : false; + CONFIG.readAllPostsInTopic = field?.readAllPostsInTopic && field?.readAllPostsInTopic === "1" ? true : false; + CONFIG.windowPeriodTopicUrls = field?.windowPeriodTopicUrls && field?.windowPeriodTopicUrls.length > 0 ? field?.windowPeriodTopicUrls.split(",") : []; + CONFIG.singlePostsReading = field?.singlePostsReading ? field?.singlePostsReading : 1000; + CONFIG.maxRetryTimes = field?.maxRetryTimes ? field?.maxRetryTimes : 5; + CONFIG.maxLogLineNum = field?.maxLogLineNum ? field?.maxLogLineNum : 100; + + let username = getUsername(); + GM_setValue(`${username}_eliminate_blue_dot_config`, JSON.stringify(CONFIG)); + layer.msg('保存成功', { icon: 1, offset: 'rt', anim: 'slideLeft' }, function () { + layer.closeLast('page'); + addLog("success", "配置变更完成"); + if (flag) { + if (CONFIG.enableBrowseAssist) { + helperStart(); + } else { + helperStop(); + } + } + }); + return false; // 阻止默认 form 跳转 + }); + }; + + // 创建对话框 + const createDialog = () => { + const dialog = document.createElement("div"); + dialog.id = "task-dialog"; + dialog.className = "d-modal__container"; + dialog.style.cssText = + `position: fixed; bottom: 4rem; right: 1rem; width: ${uiWidth}; border-radius: 0.5rem; display: none; z-index: 1000;`; + + const header = document.createElement("div"); + header.className = "d-modal__header"; + header.style.cssText = ""; + header.innerHTML = + '

Task Queue & Logs

'; + + const switchButton = document.createElement("button"); + switchButton.className = "btn no-text btn-icon btn-flat"; + switchButton.style = "flex: 0;"; + switchButton.title = "设置"; + switchButton.type = "button"; + switchButton.innerHTML = ``; + switchButton.onclick = () => { + settingDialog(); + }; + header.appendChild(switchButton); + + const headerCloseButton = document.createElement("button"); + headerCloseButton.className = "btn no-text btn-icon btn-flat modal-close"; + headerCloseButton.style = "flex: 0;"; + headerCloseButton.title = "关闭"; + headerCloseButton.type = "button"; + headerCloseButton.innerHTML = + ''; + headerCloseButton.onclick = () => { + dialog.style.display = dialog.style.display === "none" ? "block" : "none"; + }; + header.appendChild(headerCloseButton); + + const content = document.createElement("div"); + content.className = "d-modal__body"; + content.style.cssText = "padding: 0.5rem; display: flex; flex-direction: column; height: calc(80vh - 4rem); overflow: hidden;"; + + const queueContainer = document.createElement("section"); + queueContainer.id = "queue-container"; + queueContainer.style.cssText = "flex: 1; margin-bottom: 1rem; width: 100%;"; + queueContainer.innerHTML = "

Queue

"; + const queueList = document.createElement("ul"); + queueList.style.cssText = `height: ${uiQueueHeight}; overflow-y: auto; --scrollbarBg: transparent; --scrollbarThumbBg: var(--primary-low); --scrollbarWidth: 0.5em; scrollbar-color: rgba(0, 0, 0, 0.3) var(--scrollbarBg); margin: 1em 0 0 0.25em;`; + queueContainer.appendChild(queueList); + + const logContainer = document.createElement("section"); + logContainer.id = "log-container"; + logContainer.style.cssText = "flex: 1; margin-bottom: 0; width: 100%;"; + logContainer.innerHTML = "

Logs

"; + const logList = document.createElement("ul"); + logList.style.cssText = `height: ${uiLogHeight}; overflow-y: auto; --scrollbarBg: transparent; --scrollbarThumbBg: var(--primary-low); --scrollbarWidth: 0.5em; scrollbar-color: rgba(0, 0, 0, 0.3) var(--scrollbarBg); margin-left: 0.25em;`; + logContainer.appendChild(logList); + + content.appendChild(queueContainer); + content.appendChild(logContainer); + + const toggleButton = document.createElement("button"); + toggleButton.title = "疯狂阅读"; + toggleButton.className = "btn btn-default no-text btn-icon"; + toggleButton.style.cssText = + "z-index: 1001; position: fixed; bottom: 1rem; right: 1rem;"; + toggleButton.innerHTML = + ''; + toggleButton.onclick = () => { + dialog.style.display = dialog.style.display === "none" ? "block" : "none"; + }; + + document.body.appendChild(toggleButton); + dialog.appendChild(header); + dialog.appendChild(content); + document.body.appendChild(dialog); + + // 设置界面 + let settingHtml = ``; + layui.$("body").append(settingHtml); + + return { dialog, queueList, logList }; + }; + + // 创建 tag 样式 + const createTagStyle = (tagType) => { + // 基础样式 + const baseStatusStyle = + `font-size: ${uiTagFontSize}; line-height: 1rem; padding-top: .25rem; padding-bottom: .25rem; padding-left: .5rem; padding-right: .5rem; border-radius: .25rem;`; + + let statusStyle; + switch (tagType) { + case "warning": + case "pending": + statusStyle = `${baseStatusStyle} color: rgba(146,64,14,1); background-color: rgba(253,230,138,1);`; + break; + case "info": + case "processing": + statusStyle = `${baseStatusStyle} color: rgba(30,64,175,1); background-color: rgba(191,219,254,1);`; + break; + case "success": + case "completed": + statusStyle = `${baseStatusStyle} color: rgba(6,95,70,1); background-color: rgba(167,243,208,1);`; + break; + case "error": + case "retrying": + case "failed": + statusStyle = `${baseStatusStyle} color: rgba(160,0,1,1); background-color: rgba(255,209,209,1);`; + break; + default: + statusStyle = `${baseStatusStyle} color: rgba(60,60,60,1); background-color: rgba(120, 120, 120,0.5);`; + break; + } + + return statusStyle; + }; + + // 更新对话框内容 + const updateDialogLog = () => { + logListElement.innerHTML = logs + .map((log) => { + const eachStatusStyle = createTagStyle(log.level); + return `
  • ${log.time} - ${log.message}${log.level}
  • `; + }) + .reverse() + .join(""); + // logListElement.scrollTop = logListElement.scrollHeight; + }; + const updateDialogQueue = () => { + const statRow = `
  • 待执行数:${taskQueue.length}成功数:${statData.totalSuccess}失败数:${statData.totalFail}未读帖子:${windowPeriodTopics.length}阅读时间:${Math.round(statData.totalReadingTime / 60).toFixed(0)}分
  • `; + queueListElement.innerHTML = statRow + taskQueue + .map((task) => { + const eachStatusStyle = createTagStyle(task.status); + return `
  • [${task.actionType}] ${task.topicId}${task.status}
  • `; + }) + .join(""); + // queueListElement.scrollTop = queueListElement.scrollHeight; + }; + + // 添加日志 + const addLog = (level, message) => { + if (logs.length >= CONFIG.maxLogLineNum) logs.shift(); + logs.push({ time: formatDate(new Date()), level, message }); + updateDialogLog(); + }; + + // 向队列中加入任务的方法 + const addTask = (topicId, numbers, csrfToken, maxReadPosts, actionType) => { + if (CONFIG.enableBrowseAssist) { + // 检查队列中是否已存在相同的 topicId + const isDuplicate = taskQueue.some(task => task.topicId === topicId) || excludeTopic.some(e => e.topicId === topicId); + if (!isDuplicate) { + taskQueue.push({ + topicId, + numbers, + csrfToken, + maxReadPosts, + actionType, + status: "pending", + }); + addLog("info", `任务已添加,目前队列长度:${taskQueue.length}`); + updateDialogQueue(); + + // 如果这是队列中的第一个任务,立即开始处理 + if (taskQueue.length === 1) { + processQueue(); + } + } + } + }; + + const addInitTask = async () => { + if (windowPeriodTopics.length > 0) { + addLog("info", "空闲期任务开始执行"); + const csrfToken = await getCsrfToken(); + let indicesToRemove = []; + for (const [index, windowPeriodTopicSelected] of windowPeriodTopics.entries()) { + if (index > 29) { + // 每次往任务队列中 最多加30个任务 + break; + } + indicesToRemove.push(index); + + let windowPeriodTopicId = windowPeriodTopicSelected[0]; + let windowPeriodTopicNums = Array.from( + { length: windowPeriodTopicSelected[1] }, + (_, i) => i + 1 + ); + addTask( + windowPeriodTopicId, + windowPeriodTopicNums, + csrfToken, + CONFIG.singlePostsReading, + "无限阅读" + ); + } + // 移除已经加入任务列表的未读 topic + windowPeriodTopics = windowPeriodTopics.filter((_, index) => !indicesToRemove.includes(index)); + + if (windowPeriodTopics.length == 0) { + await addWindowPeriodTopics(); + } + } + }; + + // 处理队列中任务的方法 + const processQueue = async () => { + while (taskQueue.length > 0) { + const task = taskQueue[0]; + addLog("info", `正在阅读:${task.topicId}`); + + task.status = "processing"; + updateDialogQueue(); + + try { + const readingRes = await handleReadingPosts( + task, + task.topicId, + task.numbers, + task.csrfToken, + task.maxReadPosts + ); + + const finishTime = new Date(); + const timeDiff = (finishTime - lastTaskTime) / 1000; + + if (readingRes.error) { + statData.totalFail += 1; + task.status = "failed"; + addLog("error", readingRes.detail); + } else { + statData.totalReadingTime += Math.min(timeDiff, 60); + statData.totalSuccess += 1; + lastTaskTime = finishTime; + task.status = "completed"; + addLog("success", `任务已完成:${task.topicId}`); + } + } catch (err) { + console.error(err); + statData.totalFail += 1; + task.status = "failed"; + addLog("error", `处理任务时发生错误:${err.message}`); + } + + taskQueue.shift(); + updateDialogQueue(); + + await new Promise(resolve => setTimeout(resolve, 2000)); + } + }; + + const handleProcessTopic = async (request) => { + // console.log("[LINUXDO NEXT] 这是一条帖子信息 Topic"); + try { + const topicData = JSON.parse(request.response); + const csrfToken = await getCsrfToken(); + const highestPostNumber = topicData.highest_post_number; + let lastReadPostNumber; + if (CONFIG.readAllPostsInTopic) { + lastReadPostNumber = 1; // 强制读所有 + } else { + lastReadPostNumber = topicData.last_read_post_number; // 读最近的 + } + // 创建包含 lastReadPostNumber 到 highestPostNumber 的整数列表 + let postNumbers = Array.from( + { length: highestPostNumber - lastReadPostNumber + 1 }, + (_, i) => i + lastReadPostNumber + ); + addTask( + topicData.id, + [...postNumbers], + csrfToken, + CONFIG.singlePostsReading, + "主动出击" + ); + } catch (err) { + console.log(request.response); + console.log(err); + addLog("error", "未知错误,请查看控制台!"); + } + }; + + const isBlankString = (str) => { + return !str || str.trim() === ""; + }; + + const extractPollString = (str) => { + if (typeof str === "string") { + const response = str.trim(); + if (isBlankString(response)) { + return []; + } + + if (response.includes("\n|")) { + const pollDataAll = response.split("\n|").map((pollData) => { + if (isBlankString(pollData)) { + return []; + } + + try { + return JSON.parse(pollData); + } catch (err) { + console.log(err); + console.log(pollData); + return []; + } + }); + + return pollDataAll; + } else { + try { + return JSON.parse(response); + } catch (err) { + console.log(err); + console.log(response); + return []; + } + } + } else { + console.log(str); + return []; + } + }; + + const handleProcessPoll = (request) => { + console.log("[LinuxDo Assist] 这是一条拉取消息 poll"); + console.log(typeof request.response); + if (!!request.response) { + const extractedData = extractPollString(request.response); + console.log(extractedData); + } else { + addLog("error", "哦豁,Poll(扑)了个空"); + } + }; + + const clearExcludeTopicArray = () => { + if (excludeTopic.length > 0) { + // 5分钟之前的时间戳 + let fiveMinutesAgo = Math.floor(Date.now() / 1000) - 5 * 60; + excludeTopic = excludeTopic.filter(item => item.readTime > fiveMinutesAgo); + addLog('success', '清理5分钟之前阅读过的 Topic'); + } + }; + + const addWindowPeriodTopics = async () => { + if (CONFIG.enableBrowseAssist && CONFIG.enableWindowPeriodRead) { + let page = 0; + let maxPage = 9; // 最多获取10页未读列表 + + if (CONFIG.windowPeriodTopicUrls.length === 0) { + CONFIG.windowPeriodTopicUrls.push(`https://linux.do/unread.json`); + } + const csrfToken = await getCsrfToken(); + for (let topicUrl of CONFIG.windowPeriodTopicUrls) { + addLog('info', `开始获取[${topicUrl}]的帖子`); + let unreadTopic = await fetchUnreadTopic(csrfToken, topicUrl, page); + handleUnreadTopic(unreadTopic); + while (true) { + page++; + if (unreadTopic.more_topics_url) { + unreadTopic = await fetchUnreadTopic(csrfToken, topicUrl, page); + handleUnreadTopic(unreadTopic); + + // 生成随机整数 randSleepTime,范围在 1000ms 到 1500ms 之间 + const randSleepTime = getRandomInt(1000, 1500); + // 睡眠时间 + await new Promise((resolve) => setTimeout(resolve, randSleepTime)); + } else { + break; + } + if (page >= maxPage) { + // 若未读列表大于10页则仅获取10页 + break; + } + } + addLog('info', `获取[${topicUrl}]的帖子完成`); + } + addLog('info', `将未读Topic加入空闲阅读的列表,数量[${windowPeriodTopics.length}]`); + if (windowPeriodTopics.length === 0) { + const randSleepTime = getRandomInt(60000, 65000); + addLog('info', `睡眠 ${Math.floor(randSleepTime / 1000)} s 后重新获取未读Topic`); + // 睡眠时间, 范围在 60000ms 到 65000ms 之间 + await new Promise((resolve) => setTimeout(resolve, randSleepTime)); + if (CONFIG.enableBrowseAssist && CONFIG.enableWindowPeriodRead && windowPeriodTopics.length === 0) { + addWindowPeriodTopics(); + } + } + updateDialogQueue(); + } + }; + + const handleUnreadTopic = (unreadTopic) => { + for (let item of unreadTopic.topics) { + // if (item.unread_posts > 0) { + windowPeriodTopics.push([item.id, item.highest_post_number]); + // } + } + }; + + const fetchUnreadTopic = async (csrfToken, topicUrl, page) => { + let response = await fetchGetJson({ + url: `${topicUrl}?page=${page}`, + csrfToken, + }); + if (response.status === 200) { + let jsonData = response.detail; + return jsonData.topic_list || {}; + } else { + throw new Error(`Unexpected status: ${response.status}, message: ${response.detail}`); + } + }; + + /** + * 基础 get 请求 + * + * @param {{url: string; csrfToken?: string; otherHeaders?: any;}} props 请求参数 + * @returns {Promise<{status: number; error: boolean; detail: any[]}>} + */ + const fetchGetJson = async (props) => { + let retryTimes = 0; + + while (retryTimes <= CONFIG.maxRetryTimes) { + try { + const response = await fetch(props.url, { + headers: { + ...(props.csrfToken && { + "x-csrf-token": props.csrfToken, + }), + ...(props.otherHeaders && { ...props.otherHeaders }), + }, + body: null, + method: props.method || 'GET', + mode: "cors", + credentials: "include", + }); + + if (response.status >= 200 && response.status < 300) { + const data = await response.json(); + return { + status: response.status, + error: false, + detail: data, + }; + } else if (response.status >= 400 && response.status < 600) { + // 在 2-5 秒之间随机等待 + await new Promise(resolve => setTimeout(resolve, getRandomInt(2000, 5000))); + retryTimes++; + console.warn(`fetchGetJson: ${genErrMsg(response.status)}`); + continue; + } else { + return { + status: response.status, + error: true, + detail: "未知错误" + }; + } + } catch (error) { + // 在 2-5 秒之间随机等待 + await new Promise(resolve => setTimeout(resolve, getRandomInt(2000, 5000))); + retryTimes++; + continue; + } + } + if (retryTimes > CONFIG.maxRetryTimes) { + return { status: 500, error: true, detail: "超过最大重试次数" }; + } + }; + + const genErrMsg = (statusCode) => { + switch (statusCode) { + case 403: + return '无权限查看'; + case 429: + return '频率限制'; + case 500: + return '服务器内部错误'; + case 503: + return '服务器尚未处于可以接受请求的状态'; + default: + return '请求失败'; + } + }; + + function enableXMLHttpRequestHooks() { + XMLHttpRequest.prototype.open = function (method, url, async, user, password) { + // 拦截open + this._custom_storage = { method, url }; + return nativeXMLHttpRequestOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (data) { + this.addEventListener( + "readystatechange", + function () { + // 拦截 response + if (this.readyState === 4) { + // 判断 topic + if ( + isTopicUrl(this._custom_storage.url) && + this._custom_storage.method === "GET" + ) { + handleProcessTopic(this); + } + // // 判断 timings + // if ( + // isTimingsUrl(this._custom_storage.url) && + // this._custom_storage.method === "POST" + // ) { + // console.log("[LINUXDO NEXT] 这是一条阅读计时 timings"); + // console.log(this); + // } + // 判断 poll + // if ( + // isPollUrl(this._custom_storage.url) && + // this._custom_storage.method === "POST" + // ) { + // handleProcessPoll(this); + // } + } + }, + false + ); + return nativeXMLHttpRequestSend.apply(this, arguments); + }; + } + + function disableXMLHttpRequestHooks() { + XMLHttpRequest.prototype.open = nativeXMLHttpRequestOpen; + XMLHttpRequest.prototype.send = nativeXMLHttpRequestSend; + } + + const helperStart = () => { + enableXMLHttpRequestHooks(); + addLog('success', CONFIG.enableBrowseAssist ? '助手已开启' : '助手已关闭'); + addLog('success', CONFIG.enableWindowPeriodRead ? '空闲阅读已开启' : '空闲阅读已关闭'); + + addWindowPeriodTopics(); + }; + + const helperStop = () => { + disableXMLHttpRequestHooks(); + if (taskQueue.length > 1) { + addLog('warning', '正在删除多余任务,仅保留最后进行的任务'); + taskQueue = taskQueue.slice(0, 1); + } + if (windowPeriodTopics.length > 0) { + addLog('warning', '正在删除空闲阅读列表中的任务'); + windowPeriodTopics = []; + } + addLog('error', '助手已停止'); + } + + const init = () => { + nativeXMLHttpRequestOpen = ensureNativeMethods( + XMLHttpRequest.prototype.open + ); + nativeXMLHttpRequestSend = ensureNativeMethods( + XMLHttpRequest.prototype.send + ); + const uiElements = createDialog(); + dialogElement = uiElements.dialog; + queueListElement = uiElements.queueList; + logListElement = uiElements.logList; + + // 获取缓存中的配置 + let username = getUsername(); + let configCache = GM_getValue(`${username}_eliminate_blue_dot_config`, ""); + if (configCache.length > 0) { + CONFIG = JSON.parse(configCache); + } + + // 初始化 空闲任务, 定时执行 + // 生成随机整数 randSleepTime,范围在 10000ms 到 15000ms 之间 + const randSleepTime = getRandomInt(10000, 15000); + setInterval(() => { + if (CONFIG.enableBrowseAssist && CONFIG.enableWindowPeriodRead && taskQueue.length == 0) { + addInitTask(); + } + }, randSleepTime); + + // 定时清理 排除topic 数组, 每5分钟调用一次 + setInterval(clearExcludeTopicArray, 5 * 60 * 1000); + } + + // 初始化 + init(); + // 开始 + helperStart(); + +})(); \ No newline at end of file