您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
自动为Linux.do的帖子和评论标记已读,快速提升账号等级。
- // ==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(); // 调用初始化函数,启动脚本的全部功能
- })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址