// ==UserScript==
// @name LinuxDoReadBooster
// @namespace https://www.klaio.top/
// @version 1.0.0
// @description 自动为Linux.do的帖子和评论标记已读,快速提升账号等级。
// @author NianBroken
// @match *://*.linux.do/*
// @grant none
// @icon https://linux.do/uploads/default/optimized/3X/9/d/9dd49731091ce8656e94433a26a3ef36062b3994_2_32x32.png
// @copyright Copyright © 2025 NianBroken. All rights reserved.
// @license Apache-2.0 license
// ==/UserScript==
(function () {
'use strict'; // 启用 JavaScript 严格模式,以获得更优的代码质量和错误检查
// =================================================================================
// I. 全局常量与脚本标识符 (Global Constants & Script Identifiers)
// =================================================================================
/**
* @constant {string} SCRIPT_ID_PREFIX
* @description 用于生成脚本相关的 DOM 元素 ID 和 CSS 类名的统一前缀。
* 这有助于确保脚本生成的元素具有唯一性,避免与页面原有元素或其他脚本产生冲突。
*/
const SCRIPT_ID_PREFIX = 'linuxdo-reader-pro';
/**
* @constant {string} CONFIG_STORAGE_KEY
* @description 脚本配置信息在浏览器 LocalStorage 中存储时所使用的键名。
* 通过版本化命名 (例如 "-v1"),可以在未来脚本升级时平滑过渡或区分不同版本的配置。
*/
const CONFIG_STORAGE_KEY = 'linuxdo-reader-pro-settings-v1';
/**
* @constant {object} DEFAULT_CONFIG
* @description 脚本的默认配置对象。
* 当用户首次运行脚本,或当存储的配置信息丢失/损坏,或用户选择重置配置时,将使用此对象中的值。
* 每个配置项都有详细注释说明其用途。
*/
const DEFAULT_CONFIG = {
delayBase: 1000, // 每轮标记操作的基础延迟时间(单位:毫秒)。实际延迟会在此基础上叠加一个随机值。
delayRandom: 500, // 每轮标记操作的随机延迟范围(单位:毫秒)。最终延迟 = delayBase + getRandomInt(0, delayRandom)。
minFloor: 20, // 处理帖子楼层时,每批次最少处理的楼层数。
maxFloor: 50, // 处理帖子楼层时,每批次最多处理的楼层数。实际数量会在此范围内随机选取。
minPostReadTime: 30000, // 模拟阅读一篇完整帖子的最短时间(单位:毫秒)。此值用于API参数 `topic_time`。
maxPostReadTime: 60000, // 模拟阅读一篇完整帖子的最长时间(单位:毫秒)。此值用于API参数 `topic_time`。
minCommentReadTime: 30000, // 模拟阅读一条评论的最短时间(单位:毫秒)。此值用于API参数 `timings[post_number]`。
maxCommentReadTime: 60000, // 模拟阅读一条评论的最长时间(单位:毫秒)。此值用于API参数 `timings[post_number]`。
maxRetriesPerBatch: 3, // 单个楼层批次标记失败时,允许的最大自动重试次数(指首次尝试失败后的额外重试机会)。
bulkReadStartTopicId: 1, // “批量阅读”功能启动时,默认开始处理的帖子ID。
bulkReadDirection: 'forward', // “批量阅读”功能默认的帖子遍历方向。'forward' 表示正序(ID递增),'reverse' 表示倒序(ID递减)。
requestTimeout: 15000 // 执行网络请求(如API调用)的超时时间(单位:毫秒)。超过此时间未收到响应,则请求被视为失败。
};
// =================================================================================
// II. 全局状态管理变量 (Global State Management Variables)
// =================================================================================
/**
* @type {object} currentScriptConfig
* @description 存储当前脚本正在使用的配置。
* 在脚本初始化时,会尝试从 LocalStorage 加载用户保存的配置;
* 如果加载失败或无配置,则使用 `DEFAULT_CONFIG`。
*/
let currentScriptConfig = {};
/**
* @type {boolean} isBulkReadingSessionActive
* @description 标记“批量阅读”功能当前是否处于活动状态。
* `true` 表示正在运行,`false` 表示未运行或已手动停止。
*/
let isBulkReadingSessionActive = false;
/**
* @type {number} currentBulkReadTopicIdInProgress
* @description 在“批量阅读”会话期间,记录当前正在处理或即将处理的帖子的ID。
* 默认为1,在批量阅读启动时会根据用户设置或已保存的断点进行更新。
*/
let currentBulkReadTopicIdInProgress = 1;
// =================================================================================
// III. 通用工具函数模块 (Utility Functions Module)
// =================================================================================
/**
* @function getRandomInt
* @description 生成一个介于最小值 `min` 和最大值 `max` 之间(包含两者)的随机整数。
* @param {number} min - 随机数区间的最小值。
* @param {number} max - 随机数区间的最大值。
* @returns {number} 返回生成的随机整数。
*/
function getRandomInt(min, max) {
min = Math.ceil(min); // 确保 `min` 是整数,向上取整
max = Math.floor(max); // 确保 `max` 是整数,向下取整
return Math.floor(Math.random() * (max - min + 1)) + min; // 计算并返回随机数
}
/**
* @async
* @function interruptibleDelay
* @description 创建一个可被外部条件中断的异步延迟。
* 在延迟期间,会周期性地检查 `stopConditionFn` 的返回值。
* @param {number} durationMs - 需要延迟的总时长(单位:毫秒)。
* @param {function} stopConditionFn - 一个无参数的函数,在延迟的每个检查间隔被调用。
* 如果此函数返回 `true`,则延迟会提前结束。
* @returns {Promise<boolean>} 返回一个 Promise。如果延迟被中断,Promise 解析为 `true`;
* 如果延迟正常完成,Promise 解析为 `false`。
*/
async function interruptibleDelay(durationMs, stopConditionFn) {
const endTime = Date.now() + durationMs; // 计算延迟结束的精确时间戳
while (Date.now() < endTime) { // 循环直到当前时间达到或超过结束时间
if (stopConditionFn && stopConditionFn()) { // 如果提供了停止条件函数,并且其返回值为true
return true; // 表示延迟被中断
}
// 等待一个较短的时间间隔(100毫秒或剩余的延迟时间中的较小者)
// 这样做是为了允许中断条件检查,并避免长时间阻塞JavaScript主线程
await new Promise(resolve => setTimeout(resolve, Math.min(100, endTime - Date.now())));
}
return false; // 延迟正常完成,未被中断
}
/**
* @async
* @function fetchWithTimeout
* @description 执行一个带有超时机制的 `Workspace` 网络请求。
* @param {RequestInfo} resource - 要请求的资源,可以是 URL 字符串或一个 `Request` 对象。
* @param {RequestInit} [options={}] - `Workspace` 请求的选项对象 (例如 method, headers, body 等)。
* @param {number} [timeout] - 本次请求特定的超时时间(单位:毫秒)。
* 如果未提供,则使用全局配置中的 `requestTimeout`。
* @returns {Promise<Response>} 成功时,返回 `Workspace` API 的 `Response` 对象。
* @throws {Error} 如果请求超时(`AbortError`)或发生其他网络错误,则抛出错误。
*/
async function fetchWithTimeout(resource, options = {}, timeout) {
// 决定本次请求实际使用的超时时间
const effectiveTimeout = timeout || currentScriptConfig.requestTimeout || DEFAULT_CONFIG.requestTimeout;
const controller = new AbortController(); // 创建 AbortController 实例以控制请求的取消
const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout); // 设置超时计时器,到时自动中止请求
try {
// 发起 fetch 请求,并将 AbortController 的 signal 关联到请求选项中
const response = await fetch(resource, {
...options, // 合并用户传入的 options
signal: controller.signal // 关键:允许通过 controller.abort() 中止此 fetch 请求
});
clearTimeout(timeoutId); // 如果请求成功或失败(非超时原因),清除超时计时器
return response;
} catch (error) {
clearTimeout(timeoutId); // 确保在发生任何错误时都清除超时计时器
if (error.name === 'AbortError') {
// 如果错误是由于 AbortController 中止请求(通常意味着超时)
const resourceUrl = typeof resource === 'string' ? resource : resource.url;
console.warn(`网络请求 ${resourceUrl} 因超时 (${effectiveTimeout / 1000}秒) 而被中止。`);
}
// 重新抛出错误,以便上层调用代码可以捕获和处理
throw error;
}
}
/**
* @function waitForCondition
* @description 周期性地检查某个条件(`conditionFn`)是否满足。
* 一旦条件满足,执行回调函数 `callbackFn`。
* 主要用于等待页面上某些异步加载的 DOM 元素出现。
* @param {function} conditionFn - 条件检查函数。该函数应返回一个布尔值,`true` 表示条件已满足。
* @param {function} callbackFn - 当条件满足后要执行的回调函数。
* @param {number} [intervalMs=500] - 检查条件的间隔时间(单位:毫秒)。
* @param {number} [timeoutMs=Infinity] - 总的等待超时时间(单位:毫秒)。
* 若设为 `Infinity`,则会无限期等待直到条件满足。
* 如果超过此时间条件仍未满足,则停止检查并打印警告。
*/
function waitForCondition(conditionFn, callbackFn, intervalMs = 500, timeoutMs = Infinity) {
const startTime = Date.now(); // 记录开始等待的时间点
const timer = setInterval(() => { // 设置一个定时器,周期性执行检查
if (conditionFn()) { // 调用条件函数,检查条件是否满足
clearInterval(timer); // 条件满足,清除定时器
callbackFn(); // 执行回调函数
} else if (Date.now() - startTime > timeoutMs) { // 检查是否已超过总等待时间
clearInterval(timer); // 超时,清除定时器
console.warn(`waitForCondition 等待超时 (超过 ${timeoutMs / 1000} 秒),条件未满足。`); // 打印超时警告
}
}, intervalMs);
}
/**
* @function getCsrfToken
* @description 从当前页面的 `<meta>` 标签中获取 CSRF (Cross-Site Request Forgery) Token。
* 此 Token 通常用于验证 POST 等修改性请求的合法性,以防止 CSRF 攻击。
* @returns {string|null} 如果找到 CSRF Token,则返回其字符串值;
* 否则返回 `null`,并在控制台打印错误信息。
*/
function getCsrfToken() {
// 尝试查找 name 属性为 "csrf-token" 的 meta 标签
const csrfTokenElement = document.querySelector('meta[name="csrf-token"]');
if (csrfTokenElement && csrfTokenElement.content) {
// 如果找到该元素并且其 content 属性有值,则返回该 Token
return csrfTokenElement.content;
}
// 如果未找到 CSRF Token,打印错误日志
console.error("严重错误:无法在页面中找到 CSRF Token。部分操作可能因此失败。");
return null;
}
// =================================================================================
// IV. 配置管理模块 (Configuration Management Module)
// =================================================================================
/**
* @function loadConfiguration
* @description 加载脚本的配置信息。
* 首先尝试从浏览器的 LocalStorage 中读取之前保存的配置。
* 如果 LocalStorage 中没有配置、配置格式错误或解析失败,则使用 `DEFAULT_CONFIG` 中定义的默认配置。
* 加载后,会对各项配置值进行类型检查和有效性校验与修正。
*/
function loadConfiguration() {
let storedConfigJson; // 用于存储从 LocalStorage 读取到的原始 JSON 字符串
try {
storedConfigJson = localStorage.getItem(CONFIG_STORAGE_KEY); // 从 LocalStorage 读取配置字符串
if (storedConfigJson) {
// 如果存在已存储的配置,则尝试解析 JSON
currentScriptConfig = JSON.parse(storedConfigJson);
} else {
// 如果没有存储的配置,则直接使用默认配置
currentScriptConfig = {
...DEFAULT_CONFIG
};
}
} catch (error) {
// 如果解析 JSON 字符串时发生错误,打印错误信息并回退到默认配置
console.error("错误:解析存储在 LocalStorage 中的配置信息失败。将使用默认配置。错误详情:", error);
currentScriptConfig = {
...DEFAULT_CONFIG
};
}
// 合并默认配置和已加载的配置,确保所有配置项都存在,优先使用已加载(或已存储)的值
// 这一步也确保了如果 DEFAULT_CONFIG 新增了字段,而已存配置没有,则新字段会被正确初始化
const config = {
...DEFAULT_CONFIG,
...currentScriptConfig // 用户存储的配置会覆盖默认值
};
// 定义需要进行数值类型和非负数校验的配置项字段名列表
const numericFields = [
'delayBase', 'delayRandom', 'minFloor', 'maxFloor',
'minPostReadTime', 'maxPostReadTime', 'minCommentReadTime', 'maxCommentReadTime',
'maxRetriesPerBatch', 'bulkReadStartTopicId', 'requestTimeout'
];
numericFields.forEach(field => {
// 校验每个字段是否为数字、非 NaN、且非负
if (typeof config[field] !== 'number' || isNaN(config[field]) || config[field] < 0) {
const defaultValue = DEFAULT_CONFIG[field]; // 获取该字段的默认值
// 如果校验失败,打印警告,并将该字段的值重置为其默认值
console.warn(`配置警告:配置项 "${field}" 的值 (${config[field]}) 无效或非数字/非负数,已重置为默认值: ${defaultValue}`);
config[field] = defaultValue;
}
});
// 对特定配置项进行额外的范围或格式校验
if (config.bulkReadStartTopicId < 1) { // “批量阅读”的起始帖子ID必须至少为1
console.warn(`配置警告:配置项 "bulkReadStartTopicId" 的值 (${config.bulkReadStartTopicId}) 小于1,已重置为默认值: ${DEFAULT_CONFIG.bulkReadStartTopicId}`);
config.bulkReadStartTopicId = DEFAULT_CONFIG.bulkReadStartTopicId;
}
if (config.requestTimeout < 1000) { // 网络请求超时时间建议至少为1000毫秒(1秒)
console.warn(`配置警告:配置项 "requestTimeout" 的值 (${config.requestTimeout}) 小于1000ms,已重置为默认值: ${DEFAULT_CONFIG.requestTimeout}`);
config.requestTimeout = DEFAULT_CONFIG.requestTimeout;
}
if (!['forward', 'reverse'].includes(config.bulkReadDirection)) { // “批量阅读”方向必须是 'forward' 或 'reverse'
console.warn(`配置警告:配置项 "bulkReadDirection" 的值 (${config.bulkReadDirection}) 无效,已重置为默认值: ${DEFAULT_CONFIG.bulkReadDirection}`);
config.bulkReadDirection = DEFAULT_CONFIG.bulkReadDirection;
}
// 校验各种 min/max 对,确保 min 值不超过对应的 max 值
if (config.minFloor > config.maxFloor) {
console.warn(`配置警告:"minFloor" (${config.minFloor}) 不能大于 "maxFloor" (${config.maxFloor})。已将 "minFloor" 调整为 "maxFloor" 的值: ${config.maxFloor}`);
config.minFloor = config.maxFloor;
}
if (config.minPostReadTime > config.maxPostReadTime) {
console.warn(`配置警告:"minPostReadTime" (${config.minPostReadTime}) 不能大于 "maxPostReadTime" (${config.maxPostReadTime})。已将 "minPostReadTime" 调整为 "maxPostReadTime" 的值: ${config.maxPostReadTime}`);
config.minPostReadTime = config.maxPostReadTime;
}
if (config.minCommentReadTime > config.maxCommentReadTime) {
console.warn(`配置警告:"minCommentReadTime" (${config.minCommentReadTime}) 不能大于 "maxCommentReadTime" (${config.maxCommentReadTime})。已将 "minCommentReadTime" 调整为 "maxCommentReadTime" 的值: ${config.maxCommentReadTime}`);
config.minCommentReadTime = config.maxCommentReadTime;
}
// 将最终校验和修正后的配置对象赋值给全局的 currentScriptConfig 变量
currentScriptConfig = config;
}
/**
* @function saveConfiguration
* @description 将当前脚本的配置(存储在全局变量 `currentScriptConfig` 中)保存到浏览器的 LocalStorage。
* 这样即使用户关闭浏览器或刷新页面,配置也能被持久化。
*/
function saveConfiguration() {
try {
// 将 `currentScriptConfig` 对象序列化为 JSON 字符串,并存储到 LocalStorage
localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(currentScriptConfig));
} catch (error) {
// 如果存储过程中发生错误(例如 LocalStorage 已满或禁止写入),打印错误信息
console.error("严重错误:保存配置到 LocalStorage 失败。配置可能不会被持久化。错误详情:", error);
}
}
/**
* @function resetConfiguration
* @description 将脚本的配置重置为 `DEFAULT_CONFIG` 中定义的默认设置。
* 它会从 LocalStorage 中移除已保存的配置项,然后重新调用 `loadConfiguration` 函数,
* 这将导致 `DEFAULT_CONFIG` 被加载到 `currentScriptConfig` 中,并自动保存一次。
*/
function resetConfiguration() {
// 从 LocalStorage中移除与此脚本相关的配置项
localStorage.removeItem(CONFIG_STORAGE_KEY);
// 重新加载配置,此时由于 LocalStorage 中没有相关项,将加载默认配置
loadConfiguration(); // loadConfiguration 内部会处理默认值的应用
saveConfiguration(); // 重置后立即保存一次,确保默认配置被持久化
// 提示用户配置已重置(此日志主要用于UI操作后的反馈,或直接调用此函数时的确认)
console.log("操作提示:所有配置已成功重置为默认值。");
}
// =================================================================================
// V. 论坛 API 交互模块 (Forum API Interaction Module)
// =================================================================================
/**
* @constant {string} BASE_URL
* @description Linux.do 论坛的基础 URL,用于构建所有 API 请求的完整地址。
*/
const BASE_URL = 'https://linux.do';
/**
* @async
* @function checkTopicExists
* @description 异步检查具有指定 ID 的帖子是否存在且当前用户是否可以访问。
* 它通过请求该帖子的 JSON 数据接口 (`/t/{topicId}.json`) 来实现。
* @param {string|number} topicId - 需要检查其存在性的帖子的 ID。
* @returns {Promise<boolean>} 如果帖子存在且可访问(HTTP 状态码为 2xx),则 Promise 解析为 `true`。
* 如果帖子不存在(HTTP 404)或由于其他原因不可访问(非 2xx 状态码,或网络错误),
* 则 Promise 解析为 `false`,并在控制台打印相应信息。
*/
async function checkTopicExists(topicId) {
try {
// 使用带超时的 fetch 函数请求帖子的 .json 接口
const response = await fetchWithTimeout(`${BASE_URL}/t/${topicId}.json`);
if (!response.ok) { // 如果 HTTP 响应状态码不是成功 (即非 2xx 范围)
if (response.status === 404) {
// 状态码 404 通常明确表示帖子不存在
console.log(`API提示:检查帖子 ID ${topicId} 时,服务器返回 404 (未找到)。`);
} else {
// 对于其他非 2xx 的错误状态码,打印警告
console.warn(`API警告:检查帖子 ID ${topicId} 可访问性时,服务器返回了非预期的状态码:${response.status}`);
}
return false; // 视为帖子不可访问
}
// 响应状态码为 2xx,表示帖子存在且可访问
return true;
} catch (err) {
// fetchWithTimeout 内部已经处理了超时并打印了相关信息
// 此处仅处理非 AbortError (即非超时) 的其他网络错误
if (err.name !== 'AbortError') {
console.error(`网络错误:在检查帖子 ID ${topicId} 是否存在时发生通讯错误。错误详情:`, err);
}
// 任何网络层面的错误(包括超时)都视为帖子不可访问
return false;
}
}
/**
* @async
* @function fetchTopicDetails
* @description 异步获取指定 ID 帖子的详细信息。
* 主要目的是获取帖子的总楼层数 (`highest_post_number`),但也返回完整的帖子数据对象。
* @param {string|number} topicId - 需要获取详情的帖子的 ID。
* @returns {Promise<object|null>} 如果成功获取并解析了帖子信息,并且信息中包含有效的楼层数,
* 则 Promise 解析为一个包含帖子数据的对象。
* 如果获取失败(网络错误、帖子不存在、无权访问、数据格式不正确等),
* 则 Promise 解析为 `null`,并在控制台打印相关错误或提示信息。
*/
async function fetchTopicDetails(topicId) {
try {
// 请求帖子的 .json 接口以获取详细数据
const response = await fetchWithTimeout(`${BASE_URL}/t/${topicId}.json`);
if (!response.ok) {
console.error(`API错误:获取帖子 ID ${topicId} 的数据失败,HTTP状态码:${response.status}。可能原因:帖子不存在、无权访问或服务器内部错误。`);
return null;
}
const json = await response.json(); // 解析响应体为 JSON 对象
// 校验获取到的数据中是否包含有效的 `highest_post_number` (总楼层数)
if (typeof json.highest_post_number !== 'number' || json.highest_post_number <= 0) {
// 如果帖子没有评论或者 `highest_post_number` 无效,则打印提示并认为无法处理
console.log(`数据提示:帖子 ID ${topicId} 的评论数 (highest_post_number: ${json.highest_post_number}) 无效或数据格式不正确,将跳过此帖子的标记处理。`);
return null;
}
return json; // 返回包含帖子详情的完整 JSON 对象
} catch (err) {
// 如果在 fetch 或 JSON 解析过程中发生错误 (fetchWithTimeout 已处理超时)
if (err.name !== 'AbortError') { // 非超时错误
console.error(`网络或解析错误:获取帖子 ID ${topicId} 的详细数据时发生错误。错误详情:`, err);
}
return null; // 出错则返回 null
}
}
/**
* @async
* @function submitTimingsBatch
* @description 向服务器提交一批楼层的已读信息(模拟阅读时间)。
* 这是实现“标记已读”功能的核心 API 调用。
* @param {string|number} topicId - 目标帖子的 ID。此参数主要用于错误日志和调试信息。
* @param {number} startFloor - 本次提交批次中的起始楼层号。
* @param {number} endFloor - 本次提交批次中的结束楼层号。
* @param {string} csrfToken - 用于请求验证的 CSRF Token。
* @returns {Promise<boolean>} 如果 API 请求成功(HTTP 状态码 2xx),则 Promise 解析为 `true`,表示标记成功。
* 否则解析为 `false`,表示标记失败,并在控制台打印相关错误信息。
*/
async function submitTimingsBatch(topicId, startFloor, endFloor, csrfToken) {
// 生成一个随机的帖子总阅读时间,模拟用户在该帖子上的总停留时间
const topicTime = getRandomInt(currentScriptConfig.minPostReadTime, currentScriptConfig.maxPostReadTime);
const params = new URLSearchParams(); // 用于构建 x-www-form-urlencoded 格式的请求体
const loggedParams = { // 创建一个对象,用于在控制台以更易读的格式记录将要发送的参数
topic_id: topicId.toString(),
topic_time: topicTime.toString(),
timings: {}
};
// 日志:准备标记指定范围的楼层
console.log(`准备将 ${startFloor} ~ ${endFloor} 楼标记为已读...`);
// 为本批次中的每一个楼层生成一个随机的阅读时间,并添加到请求参数中
for (let postNumber = startFloor; postNumber <= endFloor; postNumber++) {
const commentReadTime = getRandomInt(currentScriptConfig.minCommentReadTime, currentScriptConfig.maxCommentReadTime);
params.append(`timings[${postNumber}]`, commentReadTime.toString()); // 添加到 URLSearchParams
loggedParams.timings[postNumber.toString()] = commentReadTime; // 记录到日志对象
}
// 将帖子 ID 和总阅读时间添加到请求参数
params.append("topic_id", topicId.toString());
params.append("topic_time", topicTime.toString());
// 在控制台以折叠组的形式输出详细的请求参数,方便调试,默认折叠以保持日志简洁
console.groupCollapsed(`请求参数 (帖子ID: ${topicId}, 楼层: ${startFloor}-${endFloor})`);
console.log(loggedParams);
console.groupEnd();
try {
// 发送 POST 请求到论坛的 timings 接口
const response = await fetchWithTimeout(`${BASE_URL}/topics/timings`, {
method: "POST",
credentials: "include", // 关键:确保请求时携带 cookies,用于用户身份验证
headers: {
"accept": "*/*", // 表示客户端接受任意类型的响应
"content-type": "application/x-www-form-urlencoded; charset=UTF-8", // 指定请求体格式
"x-csrf-token": csrfToken, // CSRF Token,用于安全验证
"x-requested-with": "XMLHttpRequest" // 标记此请求为 AJAX (异步JavaScript和XML) 请求
},
body: params.toString() // 将 URLSearchParams 对象转换为字符串作为请求体
});
if (response.ok) { // HTTP 状态码为 2xx 表示请求成功
console.log(`响应状态为 ${response.status},成功将 ${startFloor} ~ ${endFloor} 楼标记为已读`);
return true;
} else {
// 如果请求失败(例如服务器错误 5xx,或权限问题 4xx),获取响应体文本(可能包含错误信息)
const responseBody = await response.text();
console.error(`API错误:标记帖子 ID ${topicId} 的 ${startFloor} ~ ${endFloor} 楼失败,HTTP状态码:${response.status}`);
console.error(`服务器响应内容 (前500字符): ${responseBody.substring(0, 500)}`); // 输出部分响应体
return false;
}
} catch (err) {
// 处理网络通信层面发生的错误 (fetchWithTimeout 已处理超时并打印相应信息)
if (err.name !== 'AbortError') { // 非超时错误
console.error(`网络错误:发送“标记帖子 ID ${topicId} 的 ${startFloor} ~ ${endFloor} 楼为已读”请求时发生通讯错误。错误详情:`, err);
}
return false; // 任何此类错误(包括超时)都应视为提交失败
}
}
// =================================================================================
// VI. 核心业务逻辑模块 (Core Business Logic Module)
// =================================================================================
/**
* @async
* @function processSingleTopic
* @description 核心功能函数,负责完整处理单个帖子的所有楼层,将它们分批次标记为已读。
* 它会首先获取帖子详情(如总楼层数),然后循环调用 `submitTimingsBatch` 来向服务器提交已读信息。
* 函数内部包含了错误重试机制、操作间的智能延迟,以及在“批量阅读”模式下的可中断检查。
* @param {string|number} topicId - 需要处理的帖子的 ID。
* @param {boolean} [isBulkMode=false] - 一个布尔值,指示当前是否在“批量阅读”模式下运行。
* 在此模式下 (true),函数会检查全局的 `isBulkReadingSessionActive` 状态,
* 以允许用户从外部中断长时间运行的批量处理任务。
*/
async function processSingleTopic(topicId, isBulkMode = false) {
// `operationConcludedForTopic` 标记此主题的处理是否因任何原因(成功、失败、跳过、中止)已经结束。
// 用于确保在 `finally` 块中能正确打印主题处理结束后的分隔符 "---"。
let operationConcludedForTopic = false;
try {
// 步骤 1: 获取帖子详细信息 (主要是总楼层数 `highest_post_number`)
// `WorkspaceTopicDetails` 内部已包含针对 `topicId` 的日志记录
const topicDetails = await fetchTopicDetails(topicId);
if (!topicDetails) {
// 如果获取详情失败或帖子数据无效(例如无评论),则无法继续处理此帖子。
// `WorkspaceTopicDetails` 内部已打印相关的错误或提示信息。
operationConcludedForTopic = true;
return; // 终止此帖子的处理流程
}
const totalPosts = topicDetails.highest_post_number; // 从帖子详情中获取总楼层(评论)数
// 日志:报告当前帖子的基本信息
console.log(`ID 为 ${topicId} 的帖子共有 ${totalPosts} 条评论`);
// 步骤 2: 获取 CSRF Token,这是执行后续 API(如标记已读)请求所必需的
const csrfToken = getCsrfToken();
if (!csrfToken) {
// 如果无法获取 CSRF Token,则无法发送标记请求。`getCsrfToken` 内部已打印错误。
console.error("操作中止:由于未能获取 CSRF Token,无法继续自动标记已读功能。");
operationConcludedForTopic = true;
return; // 终止此帖子的处理流程
}
let currentFloor = 1; // 初始化当前处理到的楼层号,从第一楼开始
let roundCounter = 0; // 记录已执行的处理轮次(即批次提交的次数)
const configuredRetries = currentScriptConfig.maxRetriesPerBatch; // 从配置中获取允许的额外重试次数
const totalAttemptsPerBatch = 1 + configuredRetries; // 计算每个批次总的尝试次数(首次尝试 + 配置的重试次数)
// 定义一个停止条件检查函数。
// 在“批量阅读”模式 (`isBulkMode`为true)下,它会检查全局的 `isBulkReadingSessionActive` 状态。
// 在单帖模式下,它始终返回 `false`,意味着除非页面卸载或发生不可恢复错误,否则不会主动中断。
const stopConditionChecker = () => isBulkMode && !isBulkReadingSessionActive;
// 初始延迟:仅在非批量模式(即用户直接打开帖子页面自动触发时)且是从第一楼开始处理时执行。
// 这是为了模拟用户打开页面后先浏览片刻再开始“阅读”的行为。
if (!isBulkMode && currentFloor === 1) {
const initialDelay = getRandomInt(currentScriptConfig.delayBase, currentScriptConfig.delayBase + currentScriptConfig.delayRandom);
console.log(`延迟 ${initialDelay} 毫秒后开始刷已读 (帖子ID: ${topicId})`);
// 此处的 `interruptibleDelay` 第二个参数是 `() => false`,表示此初始延迟理论上不可被外部信号中断。
if (await interruptibleDelay(initialDelay, () => false)) {
// 正常情况下不应进入此分支,因为停止条件是 `false`。作为代码的防御性检查。
return;
}
}
// 步骤 3: 循环处理帖子的所有楼层,直到 `currentFloor` 超过帖子的总楼层数 `totalPosts`
while (currentFloor <= totalPosts) {
roundCounter++; // 增加轮次(批次)计数器
// 在每轮(处理一个新批次)开始前,检查是否需要中止处理(主要用于“批量阅读”模式下的外部停止信号)
if (stopConditionChecker()) {
console.log(`操作中止:帖子 ${topicId} 的标记过程已因全局停止信号而中止。`);
operationConcludedForTopic = true;
return; // 中止对此帖子的进一步处理
}
// 特殊检查:在“批量阅读”模式下,如果全局“正在处理的帖子ID” (`currentBulkReadTopicIdInProgress`)
// 已经改变(例如用户在UI上操作,切换到其他帖子),则应中止当前这个帖子的处理,以响应新的指令。
if (isBulkMode && isBulkReadingSessionActive && currentBulkReadTopicIdInProgress.toString() !== topicId.toString()) {
console.log(`操作切换:帖子 ${topicId} 的标记过程已中止,因为“批量阅读”功能已切换到处理其他帖子 ID ${currentBulkReadTopicIdInProgress}。`);
operationConcludedForTopic = true;
return; // 中止对此帖子的进一步处理
}
// 打印轮次间的分隔符:仅在单帖模式(非批量)且不是第一轮时打印,以增强日志可读性。
if (roundCounter > 1 && !isBulkMode) {
console.log("---"); // 日志分隔符
}
// 构造并打印轮次开始的日志信息
// 在单帖模式下,包含帖子ID;在批量模式下,不包含,因为上层日志已指明当前帖子ID。
let roundStartLogMessage = `开始进行第 ${roundCounter} 轮的刷已读`;
if (!isBulkMode) {
roundStartLogMessage += ` (帖子ID: ${topicId})`;
}
console.log(roundStartLogMessage);
// 决定本批次实际处理的楼层数量,在配置的 `minFloor` 和 `maxFloor` 之间随机选择
const batchSize = getRandomInt(currentScriptConfig.minFloor, currentScriptConfig.maxFloor);
const startFloorInBatch = currentFloor; // 本批次的起始楼层号
// 计算本批次的结束楼层号,确保不超过帖子的总楼层数
const endFloorInBatch = Math.min(currentFloor + batchSize - 1, totalPosts);
let batchSuccess = false; // 标记本批次是否已成功提交
let attemptsMadeThisBatch = 0; // 记录本批次已进行的尝试次数 (从1开始计数)
// 步骤 4: 尝试提交本批次的已读信息,包含重试机制
// 循环 `totalAttemptsPerBatch` 次 (即首次尝试 + `configuredRetries` 次重试)
while (attemptsMadeThisBatch < totalAttemptsPerBatch && !batchSuccess) {
attemptsMadeThisBatch++; // 增加本批次的尝试次数计数
const currentAttemptNumber = attemptsMadeThisBatch; // 当前是第几次尝试 (例如,1, 2, ...)
const currentRetryNumber = currentAttemptNumber - 1; // 当前是第几次重试 (0表示首次尝试,1表示第1次重试, ...)
// 在每次尝试(包括首次和重试)前,再次检查是否需要中止
if (stopConditionChecker()) {
console.log(`操作中止:在尝试标记批次(楼层 ${startFloorInBatch}-${endFloorInBatch})时,帖子 ${topicId} 的操作因全局停止信号而中止。`);
operationConcludedForTopic = true;
return;
}
// 仅在进行重试时(即非首次尝试)打印特定的重试提示信息
if (currentRetryNumber > 0) { // `currentRetryNumber > 0` 意味着这是至少第1次重试
console.log(`正在对楼层 ${startFloorInBatch} ~ ${endFloorInBatch} 进行第 ${currentRetryNumber} 次重试... (共 ${configuredRetries} 次重试机会)`);
}
// 对于首次尝试 (`currentRetryNumber === 0`),不在此处打印额外信息,
// 因为 `submitTimingsBatch` 函数内部会打印 "准备将..." 的初始操作日志。
// 调用 API 函数提交本批次的已读数据
batchSuccess = await submitTimingsBatch(topicId, startFloorInBatch, endFloorInBatch, csrfToken);
if (!batchSuccess) { // 如果本次尝试(首次或重试)失败
if (currentAttemptNumber < totalAttemptsPerBatch) { // 如果还未达到最大尝试次数(即还有重试机会)
const retryDelay = currentScriptConfig.delayBase + getRandomInt(0, currentScriptConfig.delayRandom);
// 打印失败和即将重试的提示信息
console.log(`标记楼层 ${startFloorInBatch}-${endFloorInBatch} 失败。第 ${currentRetryNumber + 1} 次重试将在 ${retryDelay}ms 后开始 (共 ${configuredRetries} 次重试机会)。`);
// 等待一段时间后进行下一次重试,此延迟同样可被 `stopConditionChecker` 中断
if (await interruptibleDelay(retryDelay, stopConditionChecker)) {
console.log(`操作中止:在等待重试(楼层 ${startFloorInBatch}-${endFloorInBatch})期间,帖子 ${topicId} 的操作因全局停止信号而中止。`);
operationConcludedForTopic = true;
return;
}
} else { // 已达到最大尝试次数(首次尝试 +所有配置的重试次数),仍失败
const failMessage = `错误:标记帖子 ID ${topicId} 的楼层 ${startFloorInBatch}-${endFloorInBatch} 彻底失败。已完成首次尝试及所有 ${configuredRetries} 次重试 (共 ${totalAttemptsPerBatch} 次尝试)。此帖子的自动标记流程已终止。`;
console.error(failMessage); // 在控制台打印严重错误
alert(failMessage); // 通过弹窗提示用户
operationConcludedForTopic = true;
return; // 终止对此帖子的进一步处理
}
}
} // 单个楼层批次的尝试循环结束
// 理论上,如果上面的循环结束而 `batchSuccess` 仍为 false,说明所有尝试都失败了,
// 并且相应的 return 语句已经执行。此处的检查作为最后一道防线,以防逻辑意外。
if (!batchSuccess) {
const criticalFailMessage = `严重错误(逻辑意外):标记帖子 ID ${topicId} 的楼层 ${startFloorInBatch}-${endFloorInBatch} 在所有尝试后仍未成功,且未按预期中止。此帖子的自动标记流程已终止。`;
console.error(criticalFailMessage);
alert(criticalFailMessage);
operationConcludedForTopic = true;
return;
}
// 本批次成功处理,更新 `currentFloor` 到下一批次的起始楼层
currentFloor = endFloorInBatch + 1;
// 步骤 5: 如果还有楼层未处理,则在处理下一批次前进行一次延迟
if (currentFloor <= totalPosts) {
const delayBetweenBatches = currentScriptConfig.delayBase + getRandomInt(0, currentScriptConfig.delayRandom);
// 批次间的延迟日志不加帖子ID,因为轮次开始时上下文已明确
console.log(`延迟 ${delayBetweenBatches} 毫秒后继续处理下一批`);
// 此延迟也可被 `stopConditionChecker` 中断
if (await interruptibleDelay(delayBetweenBatches, stopConditionChecker)) {
console.log(`操作中止:在批次间延迟期间,帖子 ${topicId} 的操作因全局停止信号而中止。`);
operationConcludedForTopic = true;
return;
}
}
} // 所有楼层处理循环结束 (当 `currentFloor > totalPosts`)
// 步骤 6: 处理完成或中止后的总结性日志
if (currentFloor > totalPosts) {
// 所有楼层均已成功标记
console.log(`帖子 ID 为 ${topicId} 的所有 ${totalPosts} 个评论已全部成功标记为已读,总共用了 ${roundCounter} 轮`);
} else {
// 如果循环因其他原因(例如未预期的中断逻辑,或非批量模式下的特殊情况)提前退出,
// 且尚未通过 `return` 语句结束函数,则打印当前状态。
// 正常情况下,此分支通常由 `stopConditionChecker` 或错误处理中的 `return` 覆盖。
console.log(`操作提示:帖子 ${topicId} 的处理在 ${currentFloor - 1} 楼后结束 (总楼层: ${totalPosts}),可能被用户中止或因其他条件提前结束。`);
}
operationConcludedForTopic = true; // 标记此主题处理正常结束(或按预期中止)
} catch (error) {
// 捕获在 `processSingleTopic` 函数内部发生的任何未被明确处理的同步或异步错误
console.error(`严重错误:在处理帖子ID ${topicId} 的过程中发生未预料的错误。错误详情:`, error);
operationConcludedForTopic = true; // 标记因不可预料的错误而结束
} finally {
// 无论此帖子的处理是成功、失败、被跳过还是被中止,
// 只要其处理流程告一段落 (`operationConcludedForTopic` 为 true),就打印分隔符。
// 这是为了确保在控制台日志中,每个帖子的处理记录在视觉上是独立的。
if (operationConcludedForTopic) {
console.log("---"); // 主题处理日志的结束分隔符
}
}
}
/**
* @async
* @function startBulkReadingSession
* @description 启动“批量阅读”功能。
* 此功能会根据用户在设置中配置的起始帖子ID和读取顺序(正序/倒序),
* 来依次自动处理一系列帖子,调用 `processSingleTopic` 对每个帖子进行标记。
* @param {number|string} startId - 用户在UI上指定的起始帖子ID。如果无效,会使用配置中的默认值。
*/
async function startBulkReadingSession(startId) {
// 解析和验证传入的起始ID
let parsedStartId = parseInt(startId, 10);
if (isNaN(parsedStartId) || parsedStartId < 1) {
// 如果输入ID无效,弹窗提示并使用配置中的起始ID
alert("起始帖子ID无效,请输入一个大于0的数字。将使用配置中已保存或默认的起始ID。");
parsedStartId = currentScriptConfig.bulkReadStartTopicId;
}
currentBulkReadTopicIdInProgress = parsedStartId; // 设置当前批量阅读会话中正在处理的帖子ID
const direction = currentScriptConfig.bulkReadDirection; // 获取配置的读取方向(正序/倒序)
isBulkReadingSessionActive = true; // 激活全局的“批量阅读”会话状态标志
UIManager.updateBulkReadControls(true); // 更新UI控件状态(例如,禁用输入框,更改按钮文本为“停止运行”)
const directionText = direction === 'forward' ? '正序' : '倒序';
console.log(`“批量阅读”功能已启动。`); // 日志:批量阅读启动
console.log(`当前起始帖子 ID 为 ${currentBulkReadTopicIdInProgress}`); // 日志:报告起始ID
console.log(`读取顺序为 ${directionText}`); // 日志:报告读取方向
// 更新UI面板上的状态显示文本
UIManager.setBulkReadStatus(`运行中... (${directionText}) 正在准备处理帖子ID: ${currentBulkReadTopicIdInProgress}`);
// 主循环:持续处理帖子,直到 `isBulkReadingSessionActive` 变为 `false` (用户停止) 或满足其他退出条件
while (isBulkReadingSessionActive) {
// 退出条件 1: 如果是倒序读取,并且当前帖子ID已小于1,则停止
if (direction === 'reverse' && currentBulkReadTopicIdInProgress < 1) {
console.log(`“批量阅读” (${directionText}): 当前帖子 ID (${currentBulkReadTopicIdInProgress}) 已小于1,批量操作结束。`);
break; // 退出主循环
}
// 实时保存断点:在处理每个帖子之前,将当前帖子ID更新到配置中并保存。
// 这样即使用户意外关闭页面,下次启动也能从中断处继续。
currentScriptConfig.bulkReadStartTopicId = currentBulkReadTopicIdInProgress;
saveConfiguration(); // 保存当前配置(包含最新的起始ID)到 LocalStorage
// 更新UI状态,显示当前正在尝试处理的ID
UIManager.setBulkReadStatus(`运行中... (${directionText}) 当前尝试ID: ${currentBulkReadTopicIdInProgress}`);
// 每次循环迭代开始时,再次检查会话是否仍然激活 (可能在之前的异步操作或延迟中被用户停止)
if (!isBulkReadingSessionActive) break;
// 步骤 1: 检查当前帖子ID是否存在且当前用户可访问
// `checkTopicExists` 内部会打印相关日志(如404或访问错误)
const topicAccessible = await checkTopicExists(currentBulkReadTopicIdInProgress);
// 在异步操作 `checkTopicExists` 后,再次检查会话激活状态
if (!isBulkReadingSessionActive) break;
if (topicAccessible) {
// 如果帖子可访问,打印提示并调用 `processSingleTopic` 进行处理
console.log(`“批量阅读”检测到 ID 为 ${currentBulkReadTopicIdInProgress} 的帖子可读,准备处理...`);
// 调用核心处理函数,并传入 `true` 表示当前是批量模式
// `processSingleTopic` 内部会处理其自身的日志分隔符 "---"
await processSingleTopic(currentBulkReadTopicIdInProgress.toString(), true);
} else {
// 如果帖子不存在或不可访问,打印跳过信息
console.log(`“批量阅读”检测到 ID 为 ${currentBulkReadTopicIdInProgress} 的帖子不存在或无法访问,已跳过。`);
console.log("---"); // 为保持日志格式一致性,跳过帖子后也打印分隔符
}
// 处理完一个帖子(无论成功、失败还是跳过)后,再次检查会话激活状态
if (!isBulkReadingSessionActive) break;
// 步骤 2: 更新到下一个帖子ID,根据配置的读取方向(正序或倒序)
if (direction === 'forward') {
currentBulkReadTopicIdInProgress++; // 正序:帖子ID递增
} else { // 'reverse'
currentBulkReadTopicIdInProgress--; // 倒序:帖子ID递减
if (currentBulkReadTopicIdInProgress < 1) {
// 如果倒序读取使得下一个ID将小于1,打印提示信息预告即将结束
console.log(`“批量阅读” (${directionText}): 下一个帖子 ID 将是 ${currentBulkReadTopicIdInProgress},即将结束批量操作。`);
}
}
// 步骤 3: 帖子间延迟
// 仅当会话仍活动,并且(如果是倒序读取)下一个ID仍然有效(不小于1)时执行。
if (isBulkReadingSessionActive && !(direction === 'reverse' && currentBulkReadTopicIdInProgress < 1)) {
const delayBetweenTopics = getRandomInt(1000, 3000); // 设置一个固定的主题间延迟范围 (例如1-3秒)
// 更新UI状态,显示等待信息和下一个待处理的ID
UIManager.setBulkReadStatus(`等待 ${delayBetweenTopics}ms 后处理ID: ${currentBulkReadTopicIdInProgress} (${directionText})`);
// 使用可中断延迟,允许用户在此期间通过UI停止批量阅读
if (await interruptibleDelay(delayBetweenTopics, () => !isBulkReadingSessionActive)) {
console.log("操作提示:“批量阅读”在帖子间延迟时被用户中止。");
break; // 中断延迟,并退出主循环
}
}
} // “批量阅读”主循环结束 (当 `isBulkReadingSessionActive` 为 false 或 `break` 被执行)
// “批量阅读”会话结束后的清理和日志记录
const finalMessage = isBulkReadingSessionActive ? '已完成所有可处理帖子(或达到末端条件)' : '已被用户或程序内部逻辑停止';
// 获取最后保存的(即最近尝试处理或已处理完成的)帖子ID,作为下次可能的起点
const lastProcessedOrAttemptedId = currentScriptConfig.bulkReadStartTopicId;
console.log(`“批量阅读”功能已${finalMessage}。最后保存的起始帖子 ID 为: ${lastProcessedOrAttemptedId} (当前读取方向配置: ${currentScriptConfig.bulkReadDirection === 'forward' ? '正序' : '倒序'})`);
console.log("---"); // 整个批量操作结束后的最终分隔符
// 更新UI状态面板的文本,以反映最终状态和下次启动的配置
UIManager.setBulkReadStatus(`已${finalMessage.includes("停止") ? "停止" : "结束"}。下次将从ID ${lastProcessedOrAttemptedId} (${currentScriptConfig.bulkReadDirection === 'forward' ? '正序' : '倒序'}) 开始。`);
// 调用 `stopBulkReadingSession` 来确保所有相关的全局状态和UI控件都正确更新,
// 即使循环是自然结束(例如倒序读取到0),也执行此操作以保持一致性。
stopBulkReadingSession();
}
/**
* @function stopBulkReadingSession
* @description 停止当前正在运行的“批量阅读”会话。
* 它通过设置全局标志 `isBulkReadingSessionActive` 为 `false` 来实现,
* 这将导致 `startBulkReadingSession` 中的主循环在下次迭代检查时中止。
* 同时,它还会更新UI上相关控件的状态(例如,重新启用输入框,将按钮文本改回“开始运行”)。
*/
function stopBulkReadingSession() {
const wasActive = isBulkReadingSessionActive; // 记录调用此函数前批量阅读会话是否处于活动状态
isBulkReadingSessionActive = false; // 设置全局停止标记,这将有效地中止批量阅读循环
UIManager.updateBulkReadControls(false); // 更新UI控件,反映批量阅读已停止的状态
// 更新UI状态面板的文本。仅当之前确实在运行时,才明确显示“已停止”的状态。
const statusElement = document.getElementById(`${SCRIPT_ID_PREFIX}-bulk-read-status`);
if (statusElement && wasActive) { // 确保状态元素存在,并且之前会话是活动的
// 如果状态文本以“运行中”或“等待”开头,则更新为停止后的状态
if (statusElement.textContent.startsWith("运行中") || statusElement.textContent.startsWith("等待")) {
statusElement.textContent = `已停止。下次将从ID ${currentScriptConfig.bulkReadStartTopicId} (${currentScriptConfig.bulkReadDirection === 'forward' ? '正序' : '倒序'}) 开始。`;
}
}
// 相关的停止操作日志主要由 `startBulkReadingSession` 函数的结束部分统一处理,此处不再重复打印,
// 避免在控制台产生冗余信息。此函数主要负责状态变更和UI更新。
}
// =================================================================================
// VIII. 用户界面管理模块 (User Interface Management Module)
// =================================================================================
/**
* @object UIManager
* @description 这是一个包含了所有与用户界面(UI)创建、管理和交互相关方法的对象。
* 它封装了DOM操作、样式注入、面板渲染和事件处理等UI逻辑。
*/
const UIManager = {
/**
* @type {HTMLElement|null} panelContainer
* @description 指向当前显示在页面上的设置面板的顶层覆盖容器 (overlay DOM element)。
* 初始值为 `null`,在面板创建时被赋值,在面板移除时重置为 `null`。
* @memberof UIManager
*/
panelContainer: null,
/**
* @function injectStyles
* @memberof UIManager
* @description 向当前页面的 `<head>` 部分注入脚本所需的CSS样式。
* 这些样式定义了设置面板(包括遮罩层、面板本身、输入框、按钮等)的外观和布局。
* 此函数通常只在脚本初始化时调用一次。
*/
injectStyles: function () {
const css = `
/* 脚本UI遮罩层样式:固定定位,覆盖整个视口,半透明背景,内容居中 */
.${SCRIPT_ID_PREFIX}-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.6);
display: flex; justify-content: center; align-items: center;
z-index: 10000; /* 确保在页面顶层显示 */
}
/* 设置面板主体样式:背景色,内边距,圆角,宽度,最大宽高,溢出滚动,阴影,字体 */
.${SCRIPT_ID_PREFIX}-panel {
background: #f9f9f9; padding: 25px; border-radius: 12px;
width: 420px; max-width: 90vw; max-height: 90vh;
overflow-y: auto; /* 内容超出时显示垂直滚动条 */
box-shadow: 0 6px 25px rgba(0,0,0,0.3);
font-family: "Segoe UI", Roboto, sans-serif;
scrollbar-width: thin; /* Firefox 滚动条样式 */
scrollbar-color: rgba(150,150,150,0.5) transparent; /* Firefox 滚动条颜色 */
}
/* Webkit (Chrome, Safari) 浏览器滚动条样式 */
.${SCRIPT_ID_PREFIX}-panel::-webkit-scrollbar { width: 8px; }
.${SCRIPT_ID_PREFIX}-panel::-webkit-scrollbar-track { background: transparent; border-radius: 10px; }
.${SCRIPT_ID_PREFIX}-panel::-webkit-scrollbar-thumb {
background: rgba(150,150,150,0.4); border-radius: 10px;
border: 2px solid transparent; background-clip: padding-box;
}
/* 面板标题样式 */
.${SCRIPT_ID_PREFIX}-panel h2 {
font-size: 20px; margin-top:0; margin-bottom: 20px;
color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px;
}
/* 输入组(标签 + 输入框)样式 */
.${SCRIPT_ID_PREFIX}-input-group { margin-bottom: 15px; }
/* 标签样式 */
.${SCRIPT_ID_PREFIX}-label {
font-size: 14px; margin-bottom: 6px; display: block;
color: #555; font-weight: 500;
}
/* 输入框和选择框通用样式 */
.${SCRIPT_ID_PREFIX}-input, .${SCRIPT_ID_PREFIX}-select {
width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 6px;
font-size: 14px; box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s; /* 过渡效果 */
}
/* 输入框和选择框获取焦点时的样式 */
.${SCRIPT_ID_PREFIX}-input:focus, .${SCRIPT_ID_PREFIX}-select:focus {
border-color: #4CAF50; /* 边框高亮颜色 */
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); /* 外发光效果 */
outline: none; /* 移除默认的outline */
}
/* 禁用的输入框和选择框样式 */
.${SCRIPT_ID_PREFIX}-input:disabled, .${SCRIPT_ID_PREFIX}-select:disabled {
background-color: #eee; cursor: not-allowed;
}
/* 按钮容器样式:Flex布局,自动换行,间距,上边距 */
.${SCRIPT_ID_PREFIX}-buttons {
display: flex; flex-wrap: wrap; gap: 12px; margin-top: 20px;
}
/* 按钮通用样式 */
.${SCRIPT_ID_PREFIX}-button {
flex: 1; /* Flex项目等分布局 */
padding: 10px 15px; border: none; border-radius: 6px;
font-size: 14px !important; font-family: "Segoe UI", Roboto, sans-serif !important;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s; /* 过渡效果 */
text-align: center;
}
/* 按钮悬停效果(未禁用时)*/
.${SCRIPT_ID_PREFIX}-button:hover:not(:disabled) { opacity: 0.9; }
/* 按钮激活(点击时)效果(未禁用时)*/
.${SCRIPT_ID_PREFIX}-button:active:not(:disabled) { transform: translateY(1px); }
/* 禁用按钮样式 */
.${SCRIPT_ID_PREFIX}-button:disabled {
background-color: #ccc !important; color: #777 !important; cursor: not-allowed;
}
/* 特定功能按钮的颜色样式 */
.${SCRIPT_ID_PREFIX}-button.save { background: #4caf50; color: white; } /* 保存按钮 */
.${SCRIPT_ID_PREFIX}-button.reset { background: #ff9800; color: white; } /* 重置按钮 */
.${SCRIPT_ID_PREFIX}-button.run { background: #4caf50; color: white; } /* 开始运行按钮 */
.${SCRIPT_ID_PREFIX}-button.stop { background: #f44336; color: white; } /* 停止运行按钮 */
.${SCRIPT_ID_PREFIX}-button.close { background: #9e9e9e; color: white; } /* 关闭按钮 */
.${SCRIPT_ID_PREFIX}-button.fullread { /* 进入批量阅读设置按钮 */
background: #2196f3; color: white;
width: 100%; margin-top: 15px; flex-basis: 100%;
}
/* 批量阅读状态显示区域样式 */
#${SCRIPT_ID_PREFIX}-bulk-read-status {
font-size: 13px; color: #333; margin-top: 12px; min-height: 1.3em;
word-wrap: break-word; background-color: #f0f0f0;
padding: 8px; border-radius: 4px; text-align: center;
}
`;
const styleElement = document.createElement('style'); // 创建 `<style>` 元素
styleElement.id = `${SCRIPT_ID_PREFIX}-styles`; // 为样式元素设置ID,方便管理或移除
styleElement.textContent = css; // 将CSS文本内容赋值给 `<style>` 元素
document.head.appendChild(styleElement); // 将 `<style>` 元素添加到文档的 `<head>` 部分
},
/**
* @function createInputField
* @memberof UIManager
* @description 创建一个包含标签(`<label>`)和输入框(`<input>`)的 DOM 结构,用于设置面板中的配置项。
* @param {string} labelText - 显示在输入框上方的标签文本。
* @param {string} configKey - 此输入框对应的配置项在 `currentScriptConfig` 对象中的键名。
* 也用于生成输入框的 `id` 属性。
* @param {any} currentValue - 输入框的当前值(通常从 `currentScriptConfig` 获取)。
* @param {string} [inputType='number'] - HTML `<input>` 元素的 `type` 属性 (例如 'number', 'text')。
* @returns {HTMLElement} 返回一个 `<div>` 元素,其中包含了创建的标签和输入框。
*/
createInputField: function (labelText, configKey, currentValue, inputType = 'number') {
const groupDiv = document.createElement('div'); // 创建外层 `<div>` 容器
groupDiv.className = `${SCRIPT_ID_PREFIX}-input-group`; // 设置CSS类
const label = document.createElement('label'); // 创建 `<label>` 元素
label.textContent = labelText; // 设置标签显示的文本
label.className = `${SCRIPT_ID_PREFIX}-label`; // 设置CSS类
label.htmlFor = `${SCRIPT_ID_PREFIX}-config-input-${configKey}`; // 关联 `label` 和 `input`,提高可访问性
const input = document.createElement('input'); // 创建 `<input>` 元素
input.type = inputType; // 设置输入类型
// 设置输入框的初始值,处理 `null` 或 `undefined` 的情况,确保 `value` 属性是字符串
input.value = (currentValue === null || typeof currentValue === 'undefined') ? '' : currentValue.toString();
input.className = `${SCRIPT_ID_PREFIX}-input`; // 设置CSS类
input.id = `${SCRIPT_ID_PREFIX}-config-input-${configKey}`; // 设置ID,用于 `label` 关联和后续通过ID获取值
if (inputType === 'number') {
// 为数字类型的输入框设置合理的 `min` 属性值
input.min = (configKey === 'requestTimeout') ? "1000" : "0"; // 例如,requestTimeout 最低1000ms
if (configKey === 'bulkReadStartTopicId') input.min = "1"; // 起始帖子ID至少为1
// 添加事件监听器,以阻止数字输入框在获得焦点时响应鼠标滚轮事件,
// 这可以防止用户在滚动页面时意外修改输入框中的数值。
input.addEventListener('wheel', (event) => {
if (document.activeElement === input) { // 仅当输入框本身是活动元素时阻止
event.preventDefault();
}
});
}
groupDiv.append(label, input); // 将标签和输入框添加到 `<div>` 容器中
return groupDiv; // 返回创建的 DOM 元素组
},
/**
* @function getInputValue
* @memberof UIManager
* @description 从UI设置面板上的指定输入框获取其当前值,并进行基本的类型转换和校验。
* @param {string} configKey - 对应配置项的键名,用于构造输入框的ID以定位元素。
* @param {boolean} [isNumeric=true] - 一个布尔值,指示该输入值是否应被视为数字并进行相应转换和校验。
* 如果为 `false`,则按字符串处理。
* @returns {any} 如果获取和转换成功,返回用户输入的值(数字或字符串)。
* 如果输入框元素不存在、输入值无效(例如非数字的数字输入)或(对于数字)小于设定的最小值,
* 则会进行修正(通常修正为默认值或允许的最小值),更新UI显示,并返回修正后的值。
*/
getInputValue: function (configKey, isNumeric = true) {
const inputElement = document.getElementById(`${SCRIPT_ID_PREFIX}-config-input-${configKey}`);
if (!inputElement) {
// 如果输入框元素在DOM中未找到,返回该配置项在 DEFAULT_CONFIG 中的默认值
console.warn(`UI警告:未能找到ID为 "${SCRIPT_ID_PREFIX}-config-input-${configKey}" 的输入框元素。将使用默认值。`);
return DEFAULT_CONFIG[configKey];
}
let value = inputElement.value; // 获取输入框的原始字符串值
if (isNumeric) {
const originalStringValue = value; // 保存原始字符串值,用于日志
value = Number(value); // 尝试将值转换为数字
// 为数字类型的值确定允许的最小业务逻辑值
let minValue = 0; // 默认最小值为0
if (configKey === 'bulkReadStartTopicId') minValue = 1; // 起始帖子ID最小为1
if (configKey === 'requestTimeout') minValue = 1000; // 网络请求超时最小为1000ms
// 校验转换后的数字是否有效 (非NaN 且不小于业务逻辑要求的 minValue)
if (isNaN(value) || value < minValue) {
const defaultValue = DEFAULT_CONFIG[configKey]; // 获取该配置项的默认值
// 警告用户输入无效,并准备修正
console.warn(`UI校验警告:输入框 "${configKey}" 的值 "${originalStringValue}" 无效或小于允许的最小值 (${minValue})。将使用默认值或修正后的值。`);
// 将值修正为 minValue 和 defaultValue 中的较大者,确保不低于业务要求的最小下限,也考虑了默认值可能高于minValue的情况
value = Math.max(minValue, defaultValue);
inputElement.value = value.toString(); // 更新UI输入框中显示的值为修正后的值
}
}
return value; // 返回获取或修正后的值
},
/**
* @function createButton
* @memberof UIManager
* @description 创建一个标准化的按钮 (`<button>`) 元素,并为其绑定点击事件。
* @param {string} label - 按钮上显示的文本内容。
* @param {string} typeClass - 应用于按钮的额外CSS类名,通常用于定义按钮的特定样式
* (例如 'save', 'run', 'close',对应 `injectStyles` 中定义的类)。
* @param {function} onClickAction - 当按钮被点击时需要执行的回调函数。
* @returns {HTMLButtonElement} 返回创建并配置好的 `<button>` 元素。
*/
createButton: function (label, typeClass, onClickAction) {
const button = document.createElement('button'); // 创建 `<button>` 元素
// 设置按钮的CSS类,包括一个基础类和传入的特定类型类
button.className = `${SCRIPT_ID_PREFIX}-button ${typeClass}`;
button.textContent = label; // 设置按钮上显示的文本
button.onclick = onClickAction; // 绑定点击事件处理函数
return button; // 返回创建的按钮
},
/**
* @function renderGeneralSettingsPanel
* @memberof UIManager
* @description 渲染并显示脚本的“通用设置”面板。
* 如果页面上已存在由此脚本创建的任何面板,会先将其移除,以确保每次只显示一个面板。
* @param {number} [scrollTop=0] - (可选)面板重新渲染后,其内容区域的滚动条应恢复到的垂直滚动位置。
* 这主要用于在重置配置等操作后,保持用户之前的视图位置,提升体验。
* @param {function} [callback=null] - (可选)一个回调函数,在面板的DOM元素完全添加到页面并渲染完成后执行。
*/
renderGeneralSettingsPanel: function (scrollTop = 0, callback = null) {
this.removeExistingPanel(); // 确保移除任何已存在的面板,防止重复渲染或叠加
// 创建半透明的遮罩层 (overlay),用于覆盖整个页面,突出显示设置面板
this.panelContainer = document.createElement('div');
this.panelContainer.className = `${SCRIPT_ID_PREFIX}-overlay`;
// 注意:点击遮罩层本身不关闭面板,关闭操作必须通过面板内部的“关闭”按钮进行。
// 创建设置面板的主体 `<div>` 元素
const panel = document.createElement('div');
panel.className = `${SCRIPT_ID_PREFIX}-panel`;
// 阻止面板内部的点击事件冒泡到遮罩层,以防止意外关闭面板
panel.onclick = (event) => event.stopPropagation();
panel.innerHTML = `<h2>脚本通用设置</h2>`; // 设置面板的标题
// 定义通用设置中的各个配置项及其在UI上显示的标签文本
// 格式:[标签文本, 配置项在currentScriptConfig中的键名]
const generalFields = [
['每轮基础延迟(ms)', 'delayBase'],
['每轮随机延迟范围(ms)', 'delayRandom'],
['每轮最小请求楼层数', 'minFloor'],
['每轮最大请求楼层数', 'maxFloor'],
['每篇帖子最小阅读时间(ms)', 'minPostReadTime'],
['每篇帖子最大阅读时间(ms)', 'maxPostReadTime'],
['每条评论最小阅读时间(ms)', 'minCommentReadTime'],
['每条评论最大阅读时间(ms)', 'maxCommentReadTime'],
['失败后额外重试次数', 'maxRetriesPerBatch'],
['网络请求超时(ms)', 'requestTimeout']
];
try {
// 遍历配置项定义,为每一项创建对应的输入字段并将其添加到面板中
generalFields.forEach(([labelText, configKey]) => {
// 使用 `currentScriptConfig` 中的值作为输入框的当前值,
// 如果 `currentScriptConfig` 中某项未定义(理论上不太可能,因为 `loadConfiguration` 会填充),
// 则回退到 `DEFAULT_CONFIG` 中的值作为备用。
const currentValue = currentScriptConfig[configKey] !== undefined ?
currentScriptConfig[configKey] : DEFAULT_CONFIG[configKey];
panel.appendChild(this.createInputField(labelText, configKey, currentValue));
});
} catch (error) {
// 如果在创建设置字段的过程中发生任何错误,记录到控制台,并在面板上显示错误提示
console.error("UI错误:创建通用设置面板的输入字段时出错。错误详情:", error);
panel.innerHTML += `<p style="color:red; font-weight:bold;">创建设置字段时发生错误,部分设置可能无法显示或操作。请检查浏览器控制台获取详细信息。</p>`;
}
const buttonRow = document.createElement('div'); // 创建用于容纳按钮的 `<div>` 行
buttonRow.className = `${SCRIPT_ID_PREFIX}-buttons`; // 应用按钮容器的样式
// 创建“保存通用配置”按钮
const saveBtn = this.createButton('保存通用配置', 'save', () => {
// 从UI输入框收集所有通用配置项的当前值
generalFields.forEach(([_, configKey]) => {
currentScriptConfig[configKey] = this.getInputValue(configKey); // getInputValue内部包含校验
});
// 此处可以再次进行一些跨字段的逻辑校验,例如确保min不超过max等,
// 不过 getInputValue 和 loadConfiguration 中已有部分校验。
// 为确保稳健,重新校验依赖关系(已在loadConfiguration和getInputValue中处理大部分)
if (currentScriptConfig.minFloor > currentScriptConfig.maxFloor) currentScriptConfig.minFloor = currentScriptConfig.maxFloor;
if (currentScriptConfig.minPostReadTime > currentScriptConfig.maxPostReadTime) currentScriptConfig.minPostReadTime = currentScriptConfig.maxPostReadTime;
if (currentScriptConfig.minCommentReadTime > currentScriptConfig.maxCommentReadTime) currentScriptConfig.minCommentReadTime = currentScriptConfig.maxCommentReadTime;
if (currentScriptConfig.requestTimeout < 1000) currentScriptConfig.requestTimeout = DEFAULT_CONFIG.requestTimeout;
if (currentScriptConfig.maxRetriesPerBatch < 0) currentScriptConfig.maxRetriesPerBatch = DEFAULT_CONFIG.maxRetriesPerBatch;
saveConfiguration(); // 调用保存配置到 LocalStorage 的函数
alert('通用配置已成功保存!'); // 弹窗提示用户
console.log("UI操作提示:通用配置已更新并成功保存到LocalStorage。");
});
saveBtn.style.flexBasis = '100%'; // 使“保存”按钮占据按钮行的整行宽度,更醒目
// 创建“重置所有配置”按钮
const resetBtn = this.createButton('重置所有配置', 'reset', () => {
if (confirm("您确定要将所有配置(包括“批量阅读”的设置)恢复到初始默认值吗?此操作不可撤销。")) {
const currentPanelScrollTop = panel.scrollTop; // 记录当前面板的滚动位置
resetConfiguration(); // 调用重置配置的函数(会加载默认配置并保存)
this.removeExistingPanel(); // 移除当前面板
// 重新渲染通用设置面板,并传入之前的滚动位置,以及一个回调函数来在面板渲染后显示提示
this.renderGeneralSettingsPanel(currentPanelScrollTop, () => {
// 使用 setTimeout 确保 alert 在面板完全渲染后执行,避免阻塞UI
setTimeout(() => alert('所有配置已成功重置为默认值!'), 0);
});
}
});
// 创建“关闭”按钮
const closeBtn = this.createButton('关闭', 'close', () => this.removeExistingPanel());
buttonRow.append(saveBtn, resetBtn, closeBtn); // 将按钮添加到按钮行
panel.appendChild(buttonRow); // 将按钮行添加到面板
// 创建进入“批量阅读设置”面板的入口按钮
const bulkReadEntryBtn = this.createButton('进入“批量阅读”设置', 'fullread', () => {
const currentPanelScrollTop = panel.scrollTop; // 记录当前通用设置面板的滚动位置
this.removeExistingPanel(); // 移除当前通用设置面板
// 渲染“批量阅读”设置面板,并传递之前记录的滚动位置,
// 以便从批量阅读面板返回时能恢复通用面板的视图。
this.renderBulkReadPanel(currentPanelScrollTop);
});
panel.appendChild(bulkReadEntryBtn); // 将入口按钮添加到面板
this.panelContainer.appendChild(panel); // 将设置面板主体添加到遮罩层容器
document.body.appendChild(this.panelContainer); // 将遮罩层(及其包含的面板)添加到文档的 `<body>`
if (scrollTop > 0) panel.scrollTop = scrollTop; // 如果传入了 `scrollTop` 值,则恢复面板内容的滚动位置
if (typeof callback === 'function') callback(); // 如果传入了回调函数,则执行它
},
/**
* @function renderBulkReadPanel
* @memberof UIManager
* @description 渲染并显示“批量阅读”功能的专属设置面板。
* 同样,如果已存在面板,会先移除。
* @param {number} [restoreScrollOnReturn=0] - (可选)一个数值,表示当从这个“批量阅读”面板
* 返回到“通用设置”面板时,通用面板内容区域应恢复到的滚动位置。
*/
renderBulkReadPanel: function (restoreScrollOnReturn = 0) {
this.removeExistingPanel(); // 移除任何已存在的面板
this.panelContainer = document.createElement('div'); // 创建遮罩层
this.panelContainer.className = `${SCRIPT_ID_PREFIX}-overlay`;
const panel = document.createElement('div'); // 创建“批量阅读”面板主体
panel.className = `${SCRIPT_ID_PREFIX}-panel`;
panel.id = `${SCRIPT_ID_PREFIX}-bulk-read-panel`; // 为面板设置特定ID,用于区分和控制
panel.onclick = (event) => event.stopPropagation(); // 阻止点击穿透
panel.innerHTML = `<h2>批量阅读 设置</h2>`; // 面板标题
// 创建“起始帖子ID”输入字段
panel.appendChild(this.createInputField(
'起始帖子ID',
'bulkReadStartTopicId',
currentScriptConfig.bulkReadStartTopicId
));
// 创建“读取顺序”选择框 (`<select>`)
const directionGroup = document.createElement('div');
directionGroup.className = `${SCRIPT_ID_PREFIX}-input-group`;
const directionLabel = document.createElement('label');
directionLabel.textContent = '读取顺序:';
directionLabel.className = `${SCRIPT_ID_PREFIX}-label`;
directionLabel.htmlFor = `${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`;
const directionSelect = document.createElement('select');
directionSelect.id = `${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`;
directionSelect.className = `${SCRIPT_ID_PREFIX}-select`; // 复用输入框的样式
// 添加选项:'forward' (正序) 和 'reverse' (倒序)
['forward', 'reverse'].forEach(directionValue => {
const option = document.createElement('option');
option.value = directionValue;
option.textContent = directionValue === 'forward' ? '正序 (ID 递增)' : '倒序 (ID 递减)';
directionSelect.appendChild(option);
});
// 设置选择框的当前选中值,基于 `currentScriptConfig` 或默认值
directionSelect.value = currentScriptConfig.bulkReadDirection || DEFAULT_CONFIG.bulkReadDirection;
directionGroup.append(directionLabel, directionSelect);
panel.appendChild(directionGroup);
// 创建操作按钮行(保存当前设置、开始/停止运行)
const bulkReadButtonRow = document.createElement('div');
bulkReadButtonRow.className = `${SCRIPT_ID_PREFIX}-buttons`;
// 创建“保存当前(批量阅读)设置”按钮
const saveBulkConfigBtn = this.createButton('保存当前设置', 'save', () => {
// 获取UI上输入的起始ID和选择的读取顺序
const newStartId = this.getInputValue('bulkReadStartTopicId');
const newDirection = document.getElementById(`${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`).value;
// 更新全局配置对象中的相应值
currentScriptConfig.bulkReadStartTopicId = newStartId;
currentScriptConfig.bulkReadDirection = newDirection;
saveConfiguration(); // 保存更新后的配置到 LocalStorage
const directionText = newDirection === 'forward' ? '正序' : '倒序';
alert(`“批量阅读”设置已保存:起始ID ${newStartId}, 读取顺序 ${directionText}`);
console.log(`UI操作提示:“批量阅读”的特定设置已手动保存。起始ID: ${newStartId}, 读取顺序: ${directionText}`);
// 如果 `getInputValue` 对起始ID进行了校验修正,同步更新UI输入框的显示值
const idInputElement = document.getElementById(`${SCRIPT_ID_PREFIX}-config-input-bulkReadStartTopicId`);
if (idInputElement) idInputElement.value = currentScriptConfig.bulkReadStartTopicId.toString();
});
saveBulkConfigBtn.id = `${SCRIPT_ID_PREFIX}-bulk-save-button`; // 为按钮设置ID,便于后续控制
// 创建“开始运行”/“停止运行”按钮(状态动态变化)
const runStopBtn = this.createButton(
isBulkReadingSessionActive ? '停止运行' : '开始运行', // 根据当前运行状态决定按钮文本
isBulkReadingSessionActive ? 'stop' : 'run', // 根据当前运行状态决定按钮样式类
() => { // 点击事件处理函数
if (isBulkReadingSessionActive) {
// 如果当前正在运行,则调用停止函数
stopBulkReadingSession();
} else {
// 如果当前未运行,则获取面板上的最新设置,保存,然后启动批量阅读
const startIdFromInput = this.getInputValue('bulkReadStartTopicId');
const directionFromSelect = document.getElementById(`${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`).value;
currentScriptConfig.bulkReadStartTopicId = startIdFromInput;
currentScriptConfig.bulkReadDirection = directionFromSelect;
saveConfiguration(); // 在启动前,确保当前面板上的设置被保存
startBulkReadingSession(currentScriptConfig.bulkReadStartTopicId); // 调用全局的批量阅读启动函数
}
}
);
runStopBtn.id = `${SCRIPT_ID_PREFIX}-bulk-runstop-button`; // 为按钮设置ID
bulkReadButtonRow.append(saveBulkConfigBtn, runStopBtn);
panel.appendChild(bulkReadButtonRow);
// 创建状态显示区域的 `<div>`
const statusDiv = document.createElement('div');
statusDiv.id = `${SCRIPT_ID_PREFIX}-bulk-read-status`;
this.setBulkReadStatus(); // 初始化状态显示区域的文本(会根据 `isBulkReadingSessionActive` 自动判断)
panel.appendChild(statusDiv);
// 创建“返回通用设置”按钮
const backBtn = this.createButton('返回通用设置', 'close', () => {
if (isBulkReadingSessionActive) { // 如果“批量阅读”功能正在运行中
// 提示用户是否要停止运行中的任务,并确认
if (!confirm("“批量阅读”功能当前正在运行中。确定要停止该功能并返回到通用设置页面吗?")) {
return; // 用户取消操作,则不执行任何后续动作
}
stopBulkReadingSession(); // 用户确认,则先停止批量阅读
}
this.removeExistingPanel(); // 移除当前“批量阅读”面板
// 渲染“通用设置”面板,并传递 `restoreScrollOnReturn` 值,以便恢复其滚动条位置
this.renderGeneralSettingsPanel(restoreScrollOnReturn);
});
backBtn.style.flexBasis = '100%'; // 使返回按钮占据整行宽度
backBtn.style.marginTop = '20px'; // 添加一些上边距,与其他按钮组分隔
const backButtonRow = document.createElement('div'); // 为返回按钮创建一个单独的行容器
backButtonRow.className = `${SCRIPT_ID_PREFIX}-buttons`;
backButtonRow.appendChild(backBtn);
panel.appendChild(backButtonRow);
this.panelContainer.appendChild(panel); // 将面板添加到遮罩层
document.body.appendChild(this.panelContainer); // 将遮罩层添加到文档主体
// 根据当前是否正在运行批量阅读,初始化面板上各控件的启用/禁用状态
this.updateBulkReadControls(isBulkReadingSessionActive);
},
/**
* @function updateBulkReadControls
* @memberof UIManager
* @description 更新“批量阅读”设置面板中各个交互控件(如输入框、选择框、按钮)的启用/禁用状态和文本内容。
* 此函数通常在“批量阅读”功能开始或停止时被调用,以反映当前的操作状态。
* @param {boolean} isRunning - 一个布尔值,指示“批量阅读”功能当前是否正在运行 (`true` 为正在运行)。
*/
updateBulkReadControls: function (isRunning) {
// 获取相关的UI元素
const startIdInput = document.getElementById(`${SCRIPT_ID_PREFIX}-config-input-bulkReadStartTopicId`);
const directionSelect = document.getElementById(`${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`);
const saveButton = document.getElementById(`${SCRIPT_ID_PREFIX}-bulk-save-button`);
const runStopButton = document.getElementById(`${SCRIPT_ID_PREFIX}-bulk-runstop-button`);
// 如果正在运行,则禁用起始ID输入框、读取顺序选择框和“保存当前设置”按钮
if (startIdInput) {
startIdInput.disabled = isRunning;
// 如果不是在运行状态,确保输入框显示的是最新的配置值 (可能在后台被其他逻辑修改过,例如批量读取自动更新断点)
if (!isRunning) startIdInput.value = currentScriptConfig.bulkReadStartTopicId.toString();
}
if (directionSelect) {
directionSelect.disabled = isRunning;
// 同理,更新选择框的显示值
if (!isRunning) directionSelect.value = currentScriptConfig.bulkReadDirection;
}
if (saveButton) {
saveButton.disabled = isRunning;
}
// 更新“开始运行”/“停止运行”按钮的文本和样式类
if (runStopButton) {
runStopButton.textContent = isRunning ? '停止运行' : '开始运行';
runStopButton.className = `${SCRIPT_ID_PREFIX}-button ${isRunning ? 'stop' : 'run'}`;
}
// 注意:状态显示区域的文本 (`bulk-read-status`) 由 `setBulkReadStatus` 函数独立负责更新,
// 此处不直接修改,以保持逻辑分离。
},
/**
* @function setBulkReadStatus
* @memberof UIManager
* @description 设置“批量阅读”面板中状态显示区域 (`#${SCRIPT_ID_PREFIX}-bulk-read-status`) 的文本内容。
* @param {string} [statusText=null] - (可选)需要直接显示的状态文本。
* 如果提供此参数,则直接使用它。
* 如果为 `null` (默认),则函数会根据全局的 `isBulkReadingSessionActive`
* 和相关的配置信息自动生成合适的状态文本。
*/
setBulkReadStatus: function (statusText = null) {
const statusElement = document.getElementById(`${SCRIPT_ID_PREFIX}-bulk-read-status`);
if (statusElement) { // 确保状态显示元素存在于DOM中
if (statusText !== null) { // 如果直接提供了状态文本,则使用该文本
statusElement.textContent = statusText;
} else {
// 如果未提供 `statusText`,则根据当前脚本的运行状态自动生成状态文本
const directionText = currentScriptConfig.bulkReadDirection === 'forward' ? '正序' : '倒序';
if (isBulkReadingSessionActive) {
// 如果“批量阅读”正在运行,通常状态文本会由 `startBulkReadingSession` 函数动态更新。
// 此处提供一个备用的/初始的文本,以防万一在 `startBulkReadingSession` 更新前被调用。
// 检查当前状态文本是否已是运行中的信息,避免不必要的重复设置。
if (!statusElement.textContent.startsWith("运行中") && !statusElement.textContent.startsWith("等待")) {
statusElement.textContent = `运行中... (${directionText}) 当前尝试ID: ${currentBulkReadTopicIdInProgress}`;
}
} else {
// 如果“批量阅读”未运行,显示准备状态和下次启动时将使用的配置信息
statusElement.textContent = `未运行。下次将从ID ${currentScriptConfig.bulkReadStartTopicId} (${directionText}) 开始。`;
}
}
}
},
/**
* @function removeExistingPanel
* @memberof UIManager
* @description 从 DOM 中移除当前显示的设置面板(如果存在的话)。
* 它会查找并移除 `this.panelContainer` 指向的元素,并将其重置为 `null`。
*/
removeExistingPanel: function () {
if (this.panelContainer && this.panelContainer.parentNode) {
// 如果 `panelContainer` 存在并且它有一个父节点,则安全地从其父节点中移除它
this.panelContainer.parentNode.removeChild(this.panelContainer);
}
this.panelContainer = null; // 重置引用,表示当前没有活动的面板
},
/**
* @function insertSettingsButton
* @memberof UIManager
* @description 在页面的头部图标区域(通常是 Discourse 论坛右上角的 `.d-header-icons` 容器)
* 插入一个用于打开本脚本设置面板的按钮。
* 此函数会无限期等待目标容器加载完成,确保按钮能被正确插入。
*/
insertSettingsButton: function () {
// 使用 `waitForCondition` 来等待 Discourse 论坛的头部图标容器 `.d-header-icons` 加载完成。
// `Infinity` 表示无限期等待,确保即使在网络缓慢或页面结构复杂的情况下也能成功插入。
waitForCondition(
() => document.querySelector('.d-header-icons'), // 条件函数:检查目标容器是否存在
() => { // 回调函数:当目标容器加载完成后执行此处的逻辑
console.log("UI提示:目标容器 '.d-header-icons' 已成功加载。准备插入脚本设置按钮。");
const headerIconsContainer = document.querySelector('.d-header-icons');
// 防止重复添加按钮(例如,在SPA页面切换或脚本被意外多次执行时)
if (headerIconsContainer.querySelector(`.${SCRIPT_ID_PREFIX}-settings-button-container`)) {
console.log("UI提示:脚本设置按钮似乎已存在,跳过重复插入。");
return;
}
const listItem = document.createElement('li'); // 创建一个 `<li>` 元素来容纳按钮,以匹配论坛头部图标的列表结构
// 沿用 Discourse 头部图标项的现有 CSS 类,使其在外观上与原生图标按钮保持一致,
// 并添加一个脚本特定的类名用于标识和可能的进一步样式控制。
listItem.className = `header-dropdown-toggle ${SCRIPT_ID_PREFIX}-settings-button-container`;
const button = document.createElement('button'); // 创建按钮元素
// 沿用 Discourse 图标按钮的 CSS 类,如 'btn', 'no-text', 'btn-icon', 'icon', 'btn-flat'
button.className = 'btn no-text btn-icon icon btn-flat';
button.title = `脚本设置 (${GM_info.script.name})`; // 设置鼠标悬停时的提示文本
button.setAttribute('aria-label', `脚本设置 (${GM_info.script.name})`); // 设置 ARIA 标签,增强可访问性
button.type = 'button'; // 明确按钮类型,避免在表单中意外触发表单提交
// 创建并使用 SVG 图标 (通常是一个齿轮图标,代表“设置”)
const svgIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
// 应用 Discourse 用于 SVG 图标的类名
svgIcon.classList.add('fa', 'd-icon', 'd-icon-gear', 'svg-icon', 'svg-string');
svgIcon.setAttribute('aria-hidden', 'true'); // 对辅助技术隐藏装饰性图标
const useElement = document.createElementNS('http://www.w3.org/2000/svg', 'use');
// 引用 Discourse 内置的 `#gear` SVG 定义(通常在页面的某个地方定义了所有图标)
useElement.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#gear');
svgIcon.appendChild(useElement);
button.appendChild(svgIcon); // 将 SVG 图标添加到按钮中
// 为设置按钮添加点击事件监听器
button.addEventListener('click', (event) => {
event.preventDefault(); // 阻止可能的默认行为(例如,如果按钮在链接内)
event.stopPropagation(); // 阻止事件冒泡,避免触发父元素上可能存在的点击事件
// 检查设置面板是否已打开
const existingPanel = document.querySelector(`.${SCRIPT_ID_PREFIX}-overlay`);
if (isBulkReadingSessionActive) { // 如果“批量阅读”功能当前正在运行
// 如果“批量阅读”面板已打开且正在运行,提示用户应在面板内操作
if (existingPanel && existingPanel.querySelector(`#${SCRIPT_ID_PREFIX}-bulk-read-panel`)) {
alert("“批量阅读”功能正在运行中。请使用面板内的“停止运行”按钮,或通过“返回通用设置”按钮(将提示您停止运行)来管理。");
return;
}
// 如果“批量阅读”正在后台运行,但当前面板未打开,或者打开的是通用设置面板
// 提示用户是否需要切换到“批量阅读”面板进行管理
if (confirm("“批量阅读”功能当前正在后台运行中。\n\n要打开设置,建议先通过“批量阅读”面板停止该功能,或直接在此处打开面板进行管理。\n\n是否现在打开/切换到“批量阅读”设置面板?")) {
this.renderBulkReadPanel(); // 渲染并显示“批量阅读”面板
}
} else {
// 如果“批量阅读”未运行,则正常切换/打开设置面板
if (existingPanel) {
this.removeExistingPanel(); // 如果面板已打开,则关闭它(实现点击按钮切换显示/隐藏)
} else {
this.renderGeneralSettingsPanel(); // 如果面板未打开,则打开“通用设置”面板
}
}
});
listItem.appendChild(button); // 将按钮添加到 `<li>` 元素中
// 尝试将设置按钮插入到搜索图标 (`.search-dropdown`) 之前,如果搜索图标存在且是容器的直接子元素。
// 这是为了让脚本按钮尽可能地融入原生UI的布局顺序。
const searchIconLi = headerIconsContainer.querySelector('.search-dropdown');
if (searchIconLi && searchIconLi.parentNode === headerIconsContainer) {
headerIconsContainer.insertBefore(listItem, searchIconLi);
} else {
// 否则(例如搜索图标不存在或结构不同),将设置按钮插入到头部图标容器的开头
headerIconsContainer.insertBefore(listItem, headerIconsContainer.firstChild);
}
console.log("UI提示:脚本设置按钮已成功添加到页面头部。");
},
500, // 检查间隔:每500毫秒检查一次目标容器是否加载
Infinity // 总等待超时:Infinity 表示无限期等待,直到容器加载完成
);
}
};
// =================================================================================
// IX. 初始化与主执行逻辑 (Initialization & Main Execution Logic)
// =================================================================================
/**
* @function isTopicPage
* @description 判断当前浏览器的 URL 是否指向一个论坛的帖子详情页面。
* Discourse 论坛的帖子 URL 通常具有 `/t/topic-slug/topic-id` 这样的结构,
* 后面可能还跟着楼层号或分页参数等。
* @returns {boolean} 如果当前 URL 符合帖子详情页的模式,则返回 `true`;否则返回 `false`。
*/
function isTopicPage() {
// 正则表达式解析:
// `^/t/` : 路径以 `/t/` 开头 (Discourse 帖子路径的标志)
// `[^/]+` : 后面跟着至少一个非斜杠字符 (通常是帖子的 slug,即标题的 URL友好版本)
// `/\d+` : 再后面跟着一个斜杠和至少一个数字 (这是帖子的 ID)
// `(?:\/.*|\?.*)?`: 这是一个可选的非捕获组,匹配以下任一情况:
// `\/.*` : 斜杠后跟任意字符 (例如 `/楼层号` 或 `/楼层号/编辑`)
// `|\?.*` : 或者问号后跟任意字符 (例如 `?page=2`)
// `?` : 使整个非捕获组可选
// 此正则旨在更准确地识别帖子页面,同时允许 URL末尾有其他参数或路径段。
return /^\/t\/[^/]+\/\d+(?:\/.*|\?.*)?$/.test(window.location.pathname + window.location.search);
}
/**
* @function extractTopicIdFromUrl
* @description 从当前浏览器的 URL 中提取帖子的 ID。
* @returns {string|null} 如果成功从 URL (路径部分) 中提取到帖子 ID (一串数字),则返回该 ID 字符串。
* 如果 URL 不符合预期的帖子详情页格式或无法提取 ID,则返回 `null`。
*/
function extractTopicIdFromUrl() {
// 正则表达式解析:
// `\/t\/` : 匹配路径中的 `/t/` 部分。
// `[^/]+` : 匹配帖子 slug (至少一个非斜杠字符)。
// `\/(\d+)`: 匹配一个斜杠,然后捕获 (`()`) 后面跟着的至少一个数字 (`\d+`),这就是帖子 ID。
const match = window.location.pathname.match(/\/t\/[^/]+\/(\d+)/);
// 如果匹配成功,`match` 是一个数组,其中 `match[1]` 包含捕获到的帖子 ID。
return match ? match[1] : null;
}
/**
* @function initializeScript
* @description 脚本的总入口和初始化函数。
* 它负责执行脚本启动时需要进行的所有设置和检查:
* 1. 加载用户配置(或默认配置)。
* 2. 在控制台打印脚本加载信息和当前生效的配置,方便用户调试。
* 3. 向页面注入 UI 所需的 CSS 样式。
* 4. 在页面头部(如果找到合适位置)创建并插入设置按钮。
* 5. 检查当前页面是否为帖子详情页:
* 如果是,并且“批量阅读”功能未在后台运行,则自动开始处理当前页面的帖子,将其标记为已读。
*/
function initializeScript() {
// 步骤 1: 加载脚本配置
loadConfiguration();
// 步骤 2: 在控制台打印脚本加载信息和当前生效的各项配置值
console.log(`脚本 ${GM_info.script.name} 已加载,版本 ${GM_info.script.version}。下面是当前配置信息:`);
console.log(` 每轮基础延迟(ms):${currentScriptConfig.delayBase}`);
console.log(` 每轮随机延迟范围(ms):${currentScriptConfig.delayRandom}`);
console.log(` 每轮最小请求楼层数:${currentScriptConfig.minFloor}`);
console.log(` 每轮最大请求楼层数:${currentScriptConfig.maxFloor}`);
console.log(` 每篇帖子最小阅读时间(ms):${currentScriptConfig.minPostReadTime}`);
console.log(` 每篇帖子最大阅读时间(ms):${currentScriptConfig.maxPostReadTime}`);
console.log(` 每条评论最小阅读时间(ms):${currentScriptConfig.minCommentReadTime}`);
console.log(` 每条评论最大阅读时间(ms):${currentScriptConfig.maxCommentReadTime}`);
console.log(` 失败后额外重试次数:${currentScriptConfig.maxRetriesPerBatch} (总尝试次数为 1 + 重试次数)`);
console.log(` 网络请求超时(ms):${currentScriptConfig.requestTimeout}`);
console.log(` 批量阅读起始帖子ID:${currentScriptConfig.bulkReadStartTopicId}`);
console.log(` 批量阅读读取方向:${currentScriptConfig.bulkReadDirection === 'forward' ? '正序 (ID递增)' : '倒序 (ID递减)'}`);
console.log("---"); // 日志分隔符,使配置信息与后续操作日志分开
// 步骤 3: 注入脚本 UI (设置面板等) 所需的 CSS 样式
UIManager.injectStyles();
// 步骤 4: 在页面上创建并插入用于打开设置面板的按钮
UIManager.insertSettingsButton();
// 步骤 5: 检查当前是否处于一个帖子详情页面,并据此决定是否自动开始标记
if (isTopicPage()) { // 判断当前页面是否为帖子详情页
const topicId = extractTopicIdFromUrl(); // 尝试从 URL 中提取帖子 ID
if (topicId) { // 如果成功提取到帖子 ID
// 如果“批量阅读”功能当前正在后台运行,则不应自动处理当前页面的帖子,以避免冲突或混乱。
if (isBulkReadingSessionActive) {
console.log("操作提示:“批量阅读”任务当前正在后台运行,脚本将暂时不自动标记当前打开的帖子页面,以避免冲突。");
} else {
// 如果“批量阅读”未运行,则开始处理当前页面的帖子
console.log("页面检测:检测到已进入帖子详情页面。"); // 明确指出进入了详情页
// `processSingleTopic` 函数内部会在开始处理时打印更详细的帖子信息(如总楼层数)
// 此处不再重复打印 "当前帖子 ID 为..."
processSingleTopic(topicId, false); // 调用核心处理函数,`isBulkMode` 参数为 `false` 表示非批量模式
}
} else {
// 虽然 `isTopicPage` 判断为真,但未能成功提取到帖子 ID,这通常不应发生,但作为健壮性考虑,打印警告。
console.warn("逻辑警告:当前页面被识别为帖子详情页,但未能从 URL 中成功提取帖子 ID。自动标记功能可能因此无法针对此页面启动。");
}
} else {
// 如果当前页面不是帖子详情页,则脚本不执行自动标记操作,仅提供设置入口。
console.log("页面检测:当前页面非帖子详情页,脚本不自动执行标记操作。您可以通过设置按钮进行配置或启动批量阅读。");
}
}
// 监听浏览器窗口或标签页即将被关闭或刷新的事件 (`beforeunload`)
// 这提供了一个机会,在用户离开页面前执行一些清理操作或给出提示。
window.addEventListener('beforeunload', () => {
// 如果“批量阅读”功能正在运行中,当用户尝试关闭页面时,
// 打印一条提示信息,告知用户其进度(即下一个要处理的帖子ID)通常已在每次处理帖子前被保存。
// 这是为了让用户放心,即使意外关闭页面,下次启动时通常也能从中断的地方继续。
if (isBulkReadingSessionActive) {
// `currentScriptConfig.bulkReadStartTopicId` 会在 `startBulkReadingSession` 循环中实时更新并保存到 LocalStorage。
console.log("操作提示:页面即将关闭或刷新。如果“批量阅读”功能正在运行,其进度(下一个待处理帖子ID)已在处理每个帖子前自动保存。");
}
});
// =================================================================================
// 脚本启动执行点 (Script Execution Start Point)
// =================================================================================
initializeScript(); // 调用初始化函数,启动脚本的全部功能
})();