添加 clearbluedot.js

This commit is contained in:
lua
2024-08-16 16:54:05 +08:00
parent 28570e5461
commit f0e3420e89

943
clearbluedot.js Normal file
View File

@ -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 = []; // 空闲随机阅读的帖子列表,[[<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();
})();