Files
self-scripts/clearbluedot.js
2024-08-16 16:54:05 +08:00

943 lines
39 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==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 = []; // 空闲随机阅读的帖子列表,[[<topic_id>, <阅读楼层数>]]
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 =
'<div class="d-modal__title" style="flex: 1;"><h3 id="discourse-modal-title" class="d-modal__title-text">Task Queue & Logs</h3></div>';
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 = `<svg class="fa d-icon d-icon-cog svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#cog"></use></svg>`;
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 =
'<svg class="fa d-icon d-icon-times svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#times"></use></svg>';
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 = "<h4>Queue</h4>";
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 = "<h4>Logs</h4>";
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 =
'<svg class="fa d-icon svg-icon svg-string" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"></path></svg>';
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 = `<div id="settingDialog" style="padding: 0.5rem;display: none;"><form class="layui-form layui-form-pane" action="" lay-filter="settingDialog-filter">
<div class="layui-form-item">
<label class="layui-form-label" style="width: 45%;">开启助手</label>
<div class="layui-input-block">
<input type="checkbox" value="1" name="enableBrowseAssist" title="ON|OFF" lay-skin="switch">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="width: 45%;">开启空闲阅读</label>
<div class="layui-input-block">
<input type="checkbox" value="1" name="enableWindowPeriodRead" title="ON|OFF" lay-skin="switch">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="width: 45%;">从页面中获取csrfToken</label>
<div class="layui-input-block">
<input type="checkbox" value="1" name="getCsrfTokenFromHtml" title="ON|OFF" lay-skin="switch">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="width: 45%;">阅读主题所有帖子</label>
<div class="layui-input-block">
<input type="checkbox" value="1" name="readAllPostsInTopic" title="ON|OFF" lay-skin="switch">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="width: 45%;">空闲阅读帖子url</label>
<div class="layui-input-inline">
<input type="text" name="windowPeriodTopicUrls" placeholder="多个以 , 分隔" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="width: 45%;">单次阅读帖子数</label>
<div class="layui-input-inline">
<input type="number" name="singlePostsReading" autocomplete="off" placeholder="单次阅读帖子数" min="100" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="width: 45%;">最大重试次数</label>
<div class="layui-input-inline">
<input type="number" name="maxRetryTimes" autocomplete="off" placeholder="最大重试次数" min="1"
max="10" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label" style="width: 45%;">日志最大条数</label>
<div class="layui-input-inline">
<input type="number" name="maxLogLineNum" autocomplete="off" placeholder="日志最大条数"
min="100" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<button class="layui-btn" lay-submit lay-filter="saveSetting">保存更改</button>
</div>
</form></div>`;
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 `<li style="justify-content: space-between; align-items: center; display: flex; margin-bottom: 0.5rem;"><span style="font-size: ${uiLogFontSize};">${log.time} - ${log.message}</span><span style="${eachStatusStyle}">${log.level}</span></li>`;
})
.reverse()
.join("");
// logListElement.scrollTop = logListElement.scrollHeight;
};
const updateDialogQueue = () => {
const statRow = `<li style="justify-content: space-between; align-items: center; display: flex; margin-bottom: 0.5rem; padding: 0 0 .8em 0; border-bottom: 1px solid var(--primary-low);"><span style="${createTagStyle('info')}">待执行数:${taskQueue.length}</span><span style="${createTagStyle('success')}">成功数:${statData.totalSuccess}</span><span style="${createTagStyle('error')}">失败数:${statData.totalFail}</span><span style="${createTagStyle()}">未读帖子:${windowPeriodTopics.length}</span><span style="${createTagStyle('warning')}">阅读时间:${Math.round(statData.totalReadingTime / 60).toFixed(0)}分</span></li>`;
queueListElement.innerHTML = statRow + taskQueue
.map((task) => {
const eachStatusStyle = createTagStyle(task.status);
return `<li style="justify-content: space-between; align-items: center; display: flex; margin-bottom: 0.5rem;"><span style="font-size: ${uiQueueFontSize};">[${task.actionType}] ${task.topicId}</span><span style="${eachStatusStyle}">${task.status}</span></li>`;
})
.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();
})();