LinuxDoReadBooster

自动为Linux.do的帖子和评论标记已读,快速提升账号等级。

  1. // ==UserScript==
  2. // @name LinuxDoReadBooster
  3. // @namespace https://www.klaio.top/
  4. // @version 1.0.0
  5. // @description 自动为Linux.do的帖子和评论标记已读,快速提升账号等级。
  6. // @author NianBroken
  7. // @match *://*.linux.do/*
  8. // @grant none
  9. // @icon https://linux.do/uploads/default/optimized/3X/9/d/9dd49731091ce8656e94433a26a3ef36062b3994_2_32x32.png
  10. // @copyright Copyright © 2025 NianBroken. All rights reserved.
  11. // @license Apache-2.0 license
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. 'use strict'; // 启用 JavaScript 严格模式,以获得更优的代码质量和错误检查
  16.  
  17. // =================================================================================
  18. // I. 全局常量与脚本标识符 (Global Constants & Script Identifiers)
  19. // =================================================================================
  20.  
  21. /**
  22. * @constant {string} SCRIPT_ID_PREFIX
  23. * @description 用于生成脚本相关的 DOM 元素 ID 和 CSS 类名的统一前缀。
  24. * 这有助于确保脚本生成的元素具有唯一性,避免与页面原有元素或其他脚本产生冲突。
  25. */
  26. const SCRIPT_ID_PREFIX = 'linuxdo-reader-pro';
  27.  
  28. /**
  29. * @constant {string} CONFIG_STORAGE_KEY
  30. * @description 脚本配置信息在浏览器 LocalStorage 中存储时所使用的键名。
  31. * 通过版本化命名 (例如 "-v1"),可以在未来脚本升级时平滑过渡或区分不同版本的配置。
  32. */
  33. const CONFIG_STORAGE_KEY = 'linuxdo-reader-pro-settings-v1';
  34.  
  35. /**
  36. * @constant {object} DEFAULT_CONFIG
  37. * @description 脚本的默认配置对象。
  38. * 当用户首次运行脚本,或当存储的配置信息丢失/损坏,或用户选择重置配置时,将使用此对象中的值。
  39. * 每个配置项都有详细注释说明其用途。
  40. */
  41. const DEFAULT_CONFIG = {
  42. delayBase: 1000, // 每轮标记操作的基础延迟时间(单位:毫秒)。实际延迟会在此基础上叠加一个随机值。
  43. delayRandom: 500, // 每轮标记操作的随机延迟范围(单位:毫秒)。最终延迟 = delayBase + getRandomInt(0, delayRandom)。
  44. minFloor: 20, // 处理帖子楼层时,每批次最少处理的楼层数。
  45. maxFloor: 50, // 处理帖子楼层时,每批次最多处理的楼层数。实际数量会在此范围内随机选取。
  46. minPostReadTime: 30000, // 模拟阅读一篇完整帖子的最短时间(单位:毫秒)。此值用于API参数 `topic_time`。
  47. maxPostReadTime: 60000, // 模拟阅读一篇完整帖子的最长时间(单位:毫秒)。此值用于API参数 `topic_time`。
  48. minCommentReadTime: 30000, // 模拟阅读一条评论的最短时间(单位:毫秒)。此值用于API参数 `timings[post_number]`。
  49. maxCommentReadTime: 60000, // 模拟阅读一条评论的最长时间(单位:毫秒)。此值用于API参数 `timings[post_number]`。
  50. maxRetriesPerBatch: 3, // 单个楼层批次标记失败时,允许的最大自动重试次数(指首次尝试失败后的额外重试机会)。
  51. bulkReadStartTopicId: 1, // “批量阅读”功能启动时,默认开始处理的帖子ID。
  52. bulkReadDirection: 'forward', // “批量阅读”功能默认的帖子遍历方向。'forward' 表示正序(ID递增),'reverse' 表示倒序(ID递减)。
  53. requestTimeout: 15000 // 执行网络请求(如API调用)的超时时间(单位:毫秒)。超过此时间未收到响应,则请求被视为失败。
  54. };
  55.  
  56. // =================================================================================
  57. // II. 全局状态管理变量 (Global State Management Variables)
  58. // =================================================================================
  59.  
  60. /**
  61. * @type {object} currentScriptConfig
  62. * @description 存储当前脚本正在使用的配置。
  63. * 在脚本初始化时,会尝试从 LocalStorage 加载用户保存的配置;
  64. * 如果加载失败或无配置,则使用 `DEFAULT_CONFIG`。
  65. */
  66. let currentScriptConfig = {};
  67.  
  68. /**
  69. * @type {boolean} isBulkReadingSessionActive
  70. * @description 标记“批量阅读”功能当前是否处于活动状态。
  71. * `true` 表示正在运行,`false` 表示未运行或已手动停止。
  72. */
  73. let isBulkReadingSessionActive = false;
  74.  
  75. /**
  76. * @type {number} currentBulkReadTopicIdInProgress
  77. * @description 在“批量阅读”会话期间,记录当前正在处理或即将处理的帖子的ID。
  78. * 默认为1,在批量阅读启动时会根据用户设置或已保存的断点进行更新。
  79. */
  80. let currentBulkReadTopicIdInProgress = 1;
  81.  
  82. // =================================================================================
  83. // III. 通用工具函数模块 (Utility Functions Module)
  84. // =================================================================================
  85.  
  86. /**
  87. * @function getRandomInt
  88. * @description 生成一个介于最小值 `min` 和最大值 `max` 之间(包含两者)的随机整数。
  89. * @param {number} min - 随机数区间的最小值。
  90. * @param {number} max - 随机数区间的最大值。
  91. * @returns {number} 返回生成的随机整数。
  92. */
  93. function getRandomInt(min, max) {
  94. min = Math.ceil(min); // 确保 `min` 是整数,向上取整
  95. max = Math.floor(max); // 确保 `max` 是整数,向下取整
  96. return Math.floor(Math.random() * (max - min + 1)) + min; // 计算并返回随机数
  97. }
  98.  
  99. /**
  100. * @async
  101. * @function interruptibleDelay
  102. * @description 创建一个可被外部条件中断的异步延迟。
  103. * 在延迟期间,会周期性地检查 `stopConditionFn` 的返回值。
  104. * @param {number} durationMs - 需要延迟的总时长(单位:毫秒)。
  105. * @param {function} stopConditionFn - 一个无参数的函数,在延迟的每个检查间隔被调用。
  106. * 如果此函数返回 `true`,则延迟会提前结束。
  107. * @returns {Promise<boolean>} 返回一个 Promise。如果延迟被中断,Promise 解析为 `true`;
  108. * 如果延迟正常完成,Promise 解析为 `false`。
  109. */
  110. async function interruptibleDelay(durationMs, stopConditionFn) {
  111. const endTime = Date.now() + durationMs; // 计算延迟结束的精确时间戳
  112. while (Date.now() < endTime) { // 循环直到当前时间达到或超过结束时间
  113. if (stopConditionFn && stopConditionFn()) { // 如果提供了停止条件函数,并且其返回值为true
  114. return true; // 表示延迟被中断
  115. }
  116. // 等待一个较短的时间间隔(100毫秒或剩余的延迟时间中的较小者)
  117. // 这样做是为了允许中断条件检查,并避免长时间阻塞JavaScript主线程
  118. await new Promise(resolve => setTimeout(resolve, Math.min(100, endTime - Date.now())));
  119. }
  120. return false; // 延迟正常完成,未被中断
  121. }
  122.  
  123. /**
  124. * @async
  125. * @function fetchWithTimeout
  126. * @description 执行一个带有超时机制的 `Workspace` 网络请求。
  127. * @param {RequestInfo} resource - 要请求的资源,可以是 URL 字符串或一个 `Request` 对象。
  128. * @param {RequestInit} [options={}] - `Workspace` 请求的选项对象 (例如 method, headers, body 等)。
  129. * @param {number} [timeout] - 本次请求特定的超时时间(单位:毫秒)。
  130. * 如果未提供,则使用全局配置中的 `requestTimeout`。
  131. * @returns {Promise<Response>} 成功时,返回 `Workspace` API 的 `Response` 对象。
  132. * @throws {Error} 如果请求超时(`AbortError`)或发生其他网络错误,则抛出错误。
  133. */
  134. async function fetchWithTimeout(resource, options = {}, timeout) {
  135. // 决定本次请求实际使用的超时时间
  136. const effectiveTimeout = timeout || currentScriptConfig.requestTimeout || DEFAULT_CONFIG.requestTimeout;
  137. const controller = new AbortController(); // 创建 AbortController 实例以控制请求的取消
  138. const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout); // 设置超时计时器,到时自动中止请求
  139.  
  140. try {
  141. // 发起 fetch 请求,并将 AbortController 的 signal 关联到请求选项中
  142. const response = await fetch(resource, {
  143. ...options, // 合并用户传入的 options
  144. signal: controller.signal // 关键:允许通过 controller.abort() 中止此 fetch 请求
  145. });
  146. clearTimeout(timeoutId); // 如果请求成功或失败(非超时原因),清除超时计时器
  147. return response;
  148. } catch (error) {
  149. clearTimeout(timeoutId); // 确保在发生任何错误时都清除超时计时器
  150. if (error.name === 'AbortError') {
  151. // 如果错误是由于 AbortController 中止请求(通常意味着超时)
  152. const resourceUrl = typeof resource === 'string' ? resource : resource.url;
  153. console.warn(`网络请求 ${resourceUrl} 因超时 (${effectiveTimeout / 1000}秒) 而被中止。`);
  154. }
  155. // 重新抛出错误,以便上层调用代码可以捕获和处理
  156. throw error;
  157. }
  158. }
  159.  
  160. /**
  161. * @function waitForCondition
  162. * @description 周期性地检查某个条件(`conditionFn`)是否满足。
  163. * 一旦条件满足,执行回调函数 `callbackFn`。
  164. * 主要用于等待页面上某些异步加载的 DOM 元素出现。
  165. * @param {function} conditionFn - 条件检查函数。该函数应返回一个布尔值,`true` 表示条件已满足。
  166. * @param {function} callbackFn - 当条件满足后要执行的回调函数。
  167. * @param {number} [intervalMs=500] - 检查条件的间隔时间(单位:毫秒)。
  168. * @param {number} [timeoutMs=Infinity] - 总的等待超时时间(单位:毫秒)。
  169. * 若设为 `Infinity`,则会无限期等待直到条件满足。
  170. * 如果超过此时间条件仍未满足,则停止检查并打印警告。
  171. */
  172. function waitForCondition(conditionFn, callbackFn, intervalMs = 500, timeoutMs = Infinity) {
  173. const startTime = Date.now(); // 记录开始等待的时间点
  174. const timer = setInterval(() => { // 设置一个定时器,周期性执行检查
  175. if (conditionFn()) { // 调用条件函数,检查条件是否满足
  176. clearInterval(timer); // 条件满足,清除定时器
  177. callbackFn(); // 执行回调函数
  178. } else if (Date.now() - startTime > timeoutMs) { // 检查是否已超过总等待时间
  179. clearInterval(timer); // 超时,清除定时器
  180. console.warn(`waitForCondition 等待超时 (超过 ${timeoutMs / 1000} 秒),条件未满足。`); // 打印超时警告
  181. }
  182. }, intervalMs);
  183. }
  184.  
  185. /**
  186. * @function getCsrfToken
  187. * @description 从当前页面的 `<meta>` 标签中获取 CSRF (Cross-Site Request Forgery) Token。
  188. * 此 Token 通常用于验证 POST 等修改性请求的合法性,以防止 CSRF 攻击。
  189. * @returns {string|null} 如果找到 CSRF Token,则返回其字符串值;
  190. * 否则返回 `null`,并在控制台打印错误信息。
  191. */
  192. function getCsrfToken() {
  193. // 尝试查找 name 属性为 "csrf-token" 的 meta 标签
  194. const csrfTokenElement = document.querySelector('meta[name="csrf-token"]');
  195. if (csrfTokenElement && csrfTokenElement.content) {
  196. // 如果找到该元素并且其 content 属性有值,则返回该 Token
  197. return csrfTokenElement.content;
  198. }
  199. // 如果未找到 CSRF Token,打印错误日志
  200. console.error("严重错误:无法在页面中找到 CSRF Token。部分操作可能因此失败。");
  201. return null;
  202. }
  203.  
  204. // =================================================================================
  205. // IV. 配置管理模块 (Configuration Management Module)
  206. // =================================================================================
  207.  
  208. /**
  209. * @function loadConfiguration
  210. * @description 加载脚本的配置信息。
  211. * 首先尝试从浏览器的 LocalStorage 中读取之前保存的配置。
  212. * 如果 LocalStorage 中没有配置、配置格式错误或解析失败,则使用 `DEFAULT_CONFIG` 中定义的默认配置。
  213. * 加载后,会对各项配置值进行类型检查和有效性校验与修正。
  214. */
  215. function loadConfiguration() {
  216. let storedConfigJson; // 用于存储从 LocalStorage 读取到的原始 JSON 字符串
  217. try {
  218. storedConfigJson = localStorage.getItem(CONFIG_STORAGE_KEY); // 从 LocalStorage 读取配置字符串
  219. if (storedConfigJson) {
  220. // 如果存在已存储的配置,则尝试解析 JSON
  221. currentScriptConfig = JSON.parse(storedConfigJson);
  222. } else {
  223. // 如果没有存储的配置,则直接使用默认配置
  224. currentScriptConfig = {
  225. ...DEFAULT_CONFIG
  226. };
  227. }
  228. } catch (error) {
  229. // 如果解析 JSON 字符串时发生错误,打印错误信息并回退到默认配置
  230. console.error("错误:解析存储在 LocalStorage 中的配置信息失败。将使用默认配置。错误详情:", error);
  231. currentScriptConfig = {
  232. ...DEFAULT_CONFIG
  233. };
  234. }
  235.  
  236. // 合并默认配置和已加载的配置,确保所有配置项都存在,优先使用已加载(或已存储)的值
  237. // 这一步也确保了如果 DEFAULT_CONFIG 新增了字段,而已存配置没有,则新字段会被正确初始化
  238. const config = {
  239. ...DEFAULT_CONFIG,
  240. ...currentScriptConfig // 用户存储的配置会覆盖默认值
  241. };
  242.  
  243. // 定义需要进行数值类型和非负数校验的配置项字段名列表
  244. const numericFields = [
  245. 'delayBase', 'delayRandom', 'minFloor', 'maxFloor',
  246. 'minPostReadTime', 'maxPostReadTime', 'minCommentReadTime', 'maxCommentReadTime',
  247. 'maxRetriesPerBatch', 'bulkReadStartTopicId', 'requestTimeout'
  248. ];
  249.  
  250. numericFields.forEach(field => {
  251. // 校验每个字段是否为数字、非 NaN、且非负
  252. if (typeof config[field] !== 'number' || isNaN(config[field]) || config[field] < 0) {
  253. const defaultValue = DEFAULT_CONFIG[field]; // 获取该字段的默认值
  254. // 如果校验失败,打印警告,并将该字段的值重置为其默认值
  255. console.warn(`配置警告:配置项 "${field}" 的值 (${config[field]}) 无效或非数字/非负数,已重置为默认值: ${defaultValue}`);
  256. config[field] = defaultValue;
  257. }
  258. });
  259.  
  260. // 对特定配置项进行额外的范围或格式校验
  261. if (config.bulkReadStartTopicId < 1) { // “批量阅读”的起始帖子ID必须至少为1
  262. console.warn(`配置警告:配置项 "bulkReadStartTopicId" 的值 (${config.bulkReadStartTopicId}) 小于1,已重置为默认值: ${DEFAULT_CONFIG.bulkReadStartTopicId}`);
  263. config.bulkReadStartTopicId = DEFAULT_CONFIG.bulkReadStartTopicId;
  264. }
  265. if (config.requestTimeout < 1000) { // 网络请求超时时间建议至少为1000毫秒(1秒)
  266. console.warn(`配置警告:配置项 "requestTimeout" 的值 (${config.requestTimeout}) 小于1000ms,已重置为默认值: ${DEFAULT_CONFIG.requestTimeout}`);
  267. config.requestTimeout = DEFAULT_CONFIG.requestTimeout;
  268. }
  269. if (!['forward', 'reverse'].includes(config.bulkReadDirection)) { // “批量阅读”方向必须是 'forward' 或 'reverse'
  270. console.warn(`配置警告:配置项 "bulkReadDirection" 的值 (${config.bulkReadDirection}) 无效,已重置为默认值: ${DEFAULT_CONFIG.bulkReadDirection}`);
  271. config.bulkReadDirection = DEFAULT_CONFIG.bulkReadDirection;
  272. }
  273.  
  274. // 校验各种 min/max 对,确保 min 值不超过对应的 max 值
  275. if (config.minFloor > config.maxFloor) {
  276. console.warn(`配置警告:"minFloor" (${config.minFloor}) 不能大于 "maxFloor" (${config.maxFloor})。已将 "minFloor" 调整为 "maxFloor" 的值: ${config.maxFloor}`);
  277. config.minFloor = config.maxFloor;
  278. }
  279. if (config.minPostReadTime > config.maxPostReadTime) {
  280. console.warn(`配置警告:"minPostReadTime" (${config.minPostReadTime}) 不能大于 "maxPostReadTime" (${config.maxPostReadTime})。已将 "minPostReadTime" 调整为 "maxPostReadTime" 的值: ${config.maxPostReadTime}`);
  281. config.minPostReadTime = config.maxPostReadTime;
  282. }
  283. if (config.minCommentReadTime > config.maxCommentReadTime) {
  284. console.warn(`配置警告:"minCommentReadTime" (${config.minCommentReadTime}) 不能大于 "maxCommentReadTime" (${config.maxCommentReadTime})。已将 "minCommentReadTime" 调整为 "maxCommentReadTime" 的值: ${config.maxCommentReadTime}`);
  285. config.minCommentReadTime = config.maxCommentReadTime;
  286. }
  287.  
  288. // 将最终校验和修正后的配置对象赋值给全局的 currentScriptConfig 变量
  289. currentScriptConfig = config;
  290. }
  291.  
  292. /**
  293. * @function saveConfiguration
  294. * @description 将当前脚本的配置(存储在全局变量 `currentScriptConfig` 中)保存到浏览器的 LocalStorage。
  295. * 这样即使用户关闭浏览器或刷新页面,配置也能被持久化。
  296. */
  297. function saveConfiguration() {
  298. try {
  299. // 将 `currentScriptConfig` 对象序列化为 JSON 字符串,并存储到 LocalStorage
  300. localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(currentScriptConfig));
  301. } catch (error) {
  302. // 如果存储过程中发生错误(例如 LocalStorage 已满或禁止写入),打印错误信息
  303. console.error("严重错误:保存配置到 LocalStorage 失败。配置可能不会被持久化。错误详情:", error);
  304. }
  305. }
  306.  
  307. /**
  308. * @function resetConfiguration
  309. * @description 将脚本的配置重置为 `DEFAULT_CONFIG` 中定义的默认设置。
  310. * 它会从 LocalStorage 中移除已保存的配置项,然后重新调用 `loadConfiguration` 函数,
  311. * 这将导致 `DEFAULT_CONFIG` 被加载到 `currentScriptConfig` 中,并自动保存一次。
  312. */
  313. function resetConfiguration() {
  314. // 从 LocalStorage中移除与此脚本相关的配置项
  315. localStorage.removeItem(CONFIG_STORAGE_KEY);
  316. // 重新加载配置,此时由于 LocalStorage 中没有相关项,将加载默认配置
  317. loadConfiguration(); // loadConfiguration 内部会处理默认值的应用
  318. saveConfiguration(); // 重置后立即保存一次,确保默认配置被持久化
  319. // 提示用户配置已重置(此日志主要用于UI操作后的反馈,或直接调用此函数时的确认)
  320. console.log("操作提示:所有配置已成功重置为默认值。");
  321. }
  322.  
  323. // =================================================================================
  324. // V. 论坛 API 交互模块 (Forum API Interaction Module)
  325. // =================================================================================
  326.  
  327. /**
  328. * @constant {string} BASE_URL
  329. * @description Linux.do 论坛的基础 URL,用于构建所有 API 请求的完整地址。
  330. */
  331. const BASE_URL = 'https://linux.do';
  332.  
  333. /**
  334. * @async
  335. * @function checkTopicExists
  336. * @description 异步检查具有指定 ID 的帖子是否存在且当前用户是否可以访问。
  337. * 它通过请求该帖子的 JSON 数据接口 (`/t/{topicId}.json`) 来实现。
  338. * @param {string|number} topicId - 需要检查其存在性的帖子的 ID。
  339. * @returns {Promise<boolean>} 如果帖子存在且可访问(HTTP 状态码为 2xx),则 Promise 解析为 `true`。
  340. * 如果帖子不存在(HTTP 404)或由于其他原因不可访问(非 2xx 状态码,或网络错误),
  341. * 则 Promise 解析为 `false`,并在控制台打印相应信息。
  342. */
  343. async function checkTopicExists(topicId) {
  344. try {
  345. // 使用带超时的 fetch 函数请求帖子的 .json 接口
  346. const response = await fetchWithTimeout(`${BASE_URL}/t/${topicId}.json`);
  347. if (!response.ok) { // 如果 HTTP 响应状态码不是成功 (即非 2xx 范围)
  348. if (response.status === 404) {
  349. // 状态码 404 通常明确表示帖子不存在
  350. console.log(`API提示:检查帖子 ID ${topicId} 时,服务器返回 404 (未找到)。`);
  351. } else {
  352. // 对于其他非 2xx 的错误状态码,打印警告
  353. console.warn(`API警告:检查帖子 ID ${topicId} 可访问性时,服务器返回了非预期的状态码:${response.status}`);
  354. }
  355. return false; // 视为帖子不可访问
  356. }
  357. // 响应状态码为 2xx,表示帖子存在且可访问
  358. return true;
  359. } catch (err) {
  360. // fetchWithTimeout 内部已经处理了超时并打印了相关信息
  361. // 此处仅处理非 AbortError (即非超时) 的其他网络错误
  362. if (err.name !== 'AbortError') {
  363. console.error(`网络错误:在检查帖子 ID ${topicId} 是否存在时发生通讯错误。错误详情:`, err);
  364. }
  365. // 任何网络层面的错误(包括超时)都视为帖子不可访问
  366. return false;
  367. }
  368. }
  369.  
  370. /**
  371. * @async
  372. * @function fetchTopicDetails
  373. * @description 异步获取指定 ID 帖子的详细信息。
  374. * 主要目的是获取帖子的总楼层数 (`highest_post_number`),但也返回完整的帖子数据对象。
  375. * @param {string|number} topicId - 需要获取详情的帖子的 ID。
  376. * @returns {Promise<object|null>} 如果成功获取并解析了帖子信息,并且信息中包含有效的楼层数,
  377. * 则 Promise 解析为一个包含帖子数据的对象。
  378. * 如果获取失败(网络错误、帖子不存在、无权访问、数据格式不正确等),
  379. * 则 Promise 解析为 `null`,并在控制台打印相关错误或提示信息。
  380. */
  381. async function fetchTopicDetails(topicId) {
  382. try {
  383. // 请求帖子的 .json 接口以获取详细数据
  384. const response = await fetchWithTimeout(`${BASE_URL}/t/${topicId}.json`);
  385. if (!response.ok) {
  386. console.error(`API错误:获取帖子 ID ${topicId} 的数据失败,HTTP状态码:${response.status}。可能原因:帖子不存在、无权访问或服务器内部错误。`);
  387. return null;
  388. }
  389. const json = await response.json(); // 解析响应体为 JSON 对象
  390.  
  391. // 校验获取到的数据中是否包含有效的 `highest_post_number` (总楼层数)
  392. if (typeof json.highest_post_number !== 'number' || json.highest_post_number <= 0) {
  393. // 如果帖子没有评论或者 `highest_post_number` 无效,则打印提示并认为无法处理
  394. console.log(`数据提示:帖子 ID ${topicId} 的评论数 (highest_post_number: ${json.highest_post_number}) 无效或数据格式不正确,将跳过此帖子的标记处理。`);
  395. return null;
  396. }
  397. return json; // 返回包含帖子详情的完整 JSON 对象
  398. } catch (err) {
  399. // 如果在 fetch 或 JSON 解析过程中发生错误 (fetchWithTimeout 已处理超时)
  400. if (err.name !== 'AbortError') { // 非超时错误
  401. console.error(`网络或解析错误:获取帖子 ID ${topicId} 的详细数据时发生错误。错误详情:`, err);
  402. }
  403. return null; // 出错则返回 null
  404. }
  405. }
  406.  
  407. /**
  408. * @async
  409. * @function submitTimingsBatch
  410. * @description 向服务器提交一批楼层的已读信息(模拟阅读时间)。
  411. * 这是实现“标记已读”功能的核心 API 调用。
  412. * @param {string|number} topicId - 目标帖子的 ID。此参数主要用于错误日志和调试信息。
  413. * @param {number} startFloor - 本次提交批次中的起始楼层号。
  414. * @param {number} endFloor - 本次提交批次中的结束楼层号。
  415. * @param {string} csrfToken - 用于请求验证的 CSRF Token。
  416. * @returns {Promise<boolean>} 如果 API 请求成功(HTTP 状态码 2xx),则 Promise 解析为 `true`,表示标记成功。
  417. * 否则解析为 `false`,表示标记失败,并在控制台打印相关错误信息。
  418. */
  419. async function submitTimingsBatch(topicId, startFloor, endFloor, csrfToken) {
  420. // 生成一个随机的帖子总阅读时间,模拟用户在该帖子上的总停留时间
  421. const topicTime = getRandomInt(currentScriptConfig.minPostReadTime, currentScriptConfig.maxPostReadTime);
  422. const params = new URLSearchParams(); // 用于构建 x-www-form-urlencoded 格式的请求体
  423. const loggedParams = { // 创建一个对象,用于在控制台以更易读的格式记录将要发送的参数
  424. topic_id: topicId.toString(),
  425. topic_time: topicTime.toString(),
  426. timings: {}
  427. };
  428.  
  429. // 日志:准备标记指定范围的楼层
  430. console.log(`准备将 ${startFloor} ~ ${endFloor} 楼标记为已读...`);
  431.  
  432. // 为本批次中的每一个楼层生成一个随机的阅读时间,并添加到请求参数中
  433. for (let postNumber = startFloor; postNumber <= endFloor; postNumber++) {
  434. const commentReadTime = getRandomInt(currentScriptConfig.minCommentReadTime, currentScriptConfig.maxCommentReadTime);
  435. params.append(`timings[${postNumber}]`, commentReadTime.toString()); // 添加到 URLSearchParams
  436. loggedParams.timings[postNumber.toString()] = commentReadTime; // 记录到日志对象
  437. }
  438.  
  439. // 将帖子 ID 和总阅读时间添加到请求参数
  440. params.append("topic_id", topicId.toString());
  441. params.append("topic_time", topicTime.toString());
  442.  
  443. // 在控制台以折叠组的形式输出详细的请求参数,方便调试,默认折叠以保持日志简洁
  444. console.groupCollapsed(`请求参数 (帖子ID: ${topicId}, 楼层: ${startFloor}-${endFloor})`);
  445. console.log(loggedParams);
  446. console.groupEnd();
  447.  
  448. try {
  449. // 发送 POST 请求到论坛的 timings 接口
  450. const response = await fetchWithTimeout(`${BASE_URL}/topics/timings`, {
  451. method: "POST",
  452. credentials: "include", // 关键:确保请求时携带 cookies,用于用户身份验证
  453. headers: {
  454. "accept": "*/*", // 表示客户端接受任意类型的响应
  455. "content-type": "application/x-www-form-urlencoded; charset=UTF-8", // 指定请求体格式
  456. "x-csrf-token": csrfToken, // CSRF Token,用于安全验证
  457. "x-requested-with": "XMLHttpRequest" // 标记此请求为 AJAX (异步JavaScript和XML) 请求
  458. },
  459. body: params.toString() // 将 URLSearchParams 对象转换为字符串作为请求体
  460. });
  461.  
  462. if (response.ok) { // HTTP 状态码为 2xx 表示请求成功
  463. console.log(`响应状态为 ${response.status},成功将 ${startFloor} ~ ${endFloor} 楼标记为已读`);
  464. return true;
  465. } else {
  466. // 如果请求失败(例如服务器错误 5xx,或权限问题 4xx),获取响应体文本(可能包含错误信息)
  467. const responseBody = await response.text();
  468. console.error(`API错误:标记帖子 ID ${topicId} ${startFloor} ~ ${endFloor} 楼失败,HTTP状态码:${response.status}`);
  469. console.error(`服务器响应内容 (前500字符): ${responseBody.substring(0, 500)}`); // 输出部分响应体
  470. return false;
  471. }
  472. } catch (err) {
  473. // 处理网络通信层面发生的错误 (fetchWithTimeout 已处理超时并打印相应信息)
  474. if (err.name !== 'AbortError') { // 非超时错误
  475. console.error(`网络错误:发送“标记帖子 ID ${topicId} ${startFloor} ~ ${endFloor} 楼为已读”请求时发生通讯错误。错误详情:`, err);
  476. }
  477. return false; // 任何此类错误(包括超时)都应视为提交失败
  478. }
  479. }
  480.  
  481. // =================================================================================
  482. // VI. 核心业务逻辑模块 (Core Business Logic Module)
  483. // =================================================================================
  484.  
  485. /**
  486. * @async
  487. * @function processSingleTopic
  488. * @description 核心功能函数,负责完整处理单个帖子的所有楼层,将它们分批次标记为已读。
  489. * 它会首先获取帖子详情(如总楼层数),然后循环调用 `submitTimingsBatch` 来向服务器提交已读信息。
  490. * 函数内部包含了错误重试机制、操作间的智能延迟,以及在“批量阅读”模式下的可中断检查。
  491. * @param {string|number} topicId - 需要处理的帖子的 ID。
  492. * @param {boolean} [isBulkMode=false] - 一个布尔值,指示当前是否在“批量阅读”模式下运行。
  493. * 在此模式下 (true),函数会检查全局的 `isBulkReadingSessionActive` 状态,
  494. * 以允许用户从外部中断长时间运行的批量处理任务。
  495. */
  496. async function processSingleTopic(topicId, isBulkMode = false) {
  497. // `operationConcludedForTopic` 标记此主题的处理是否因任何原因(成功、失败、跳过、中止)已经结束。
  498. // 用于确保在 `finally` 块中能正确打印主题处理结束后的分隔符 "---"。
  499. let operationConcludedForTopic = false;
  500.  
  501. try {
  502. // 步骤 1: 获取帖子详细信息 (主要是总楼层数 `highest_post_number`)
  503. // `WorkspaceTopicDetails` 内部已包含针对 `topicId` 的日志记录
  504. const topicDetails = await fetchTopicDetails(topicId);
  505. if (!topicDetails) {
  506. // 如果获取详情失败或帖子数据无效(例如无评论),则无法继续处理此帖子。
  507. // `WorkspaceTopicDetails` 内部已打印相关的错误或提示信息。
  508. operationConcludedForTopic = true;
  509. return; // 终止此帖子的处理流程
  510. }
  511.  
  512. const totalPosts = topicDetails.highest_post_number; // 从帖子详情中获取总楼层(评论)数
  513. // 日志:报告当前帖子的基本信息
  514. console.log(`ID ${topicId} 的帖子共有 ${totalPosts} 条评论`);
  515.  
  516. // 步骤 2: 获取 CSRF Token,这是执行后续 API(如标记已读)请求所必需的
  517. const csrfToken = getCsrfToken();
  518. if (!csrfToken) {
  519. // 如果无法获取 CSRF Token,则无法发送标记请求。`getCsrfToken` 内部已打印错误。
  520. console.error("操作中止:由于未能获取 CSRF Token,无法继续自动标记已读功能。");
  521. operationConcludedForTopic = true;
  522. return; // 终止此帖子的处理流程
  523. }
  524.  
  525. let currentFloor = 1; // 初始化当前处理到的楼层号,从第一楼开始
  526. let roundCounter = 0; // 记录已执行的处理轮次(即批次提交的次数)
  527. const configuredRetries = currentScriptConfig.maxRetriesPerBatch; // 从配置中获取允许的额外重试次数
  528. const totalAttemptsPerBatch = 1 + configuredRetries; // 计算每个批次总的尝试次数(首次尝试 + 配置的重试次数)
  529.  
  530. // 定义一个停止条件检查函数。
  531. // 在“批量阅读”模式 (`isBulkMode`为true)下,它会检查全局的 `isBulkReadingSessionActive` 状态。
  532. // 在单帖模式下,它始终返回 `false`,意味着除非页面卸载或发生不可恢复错误,否则不会主动中断。
  533. const stopConditionChecker = () => isBulkMode && !isBulkReadingSessionActive;
  534.  
  535. // 初始延迟:仅在非批量模式(即用户直接打开帖子页面自动触发时)且是从第一楼开始处理时执行。
  536. // 这是为了模拟用户打开页面后先浏览片刻再开始“阅读”的行为。
  537. if (!isBulkMode && currentFloor === 1) {
  538. const initialDelay = getRandomInt(currentScriptConfig.delayBase, currentScriptConfig.delayBase + currentScriptConfig.delayRandom);
  539. console.log(`延迟 ${initialDelay} 毫秒后开始刷已读 (帖子ID: ${topicId})`);
  540. // 此处的 `interruptibleDelay` 第二个参数是 `() => false`,表示此初始延迟理论上不可被外部信号中断。
  541. if (await interruptibleDelay(initialDelay, () => false)) {
  542. // 正常情况下不应进入此分支,因为停止条件是 `false`。作为代码的防御性检查。
  543. return;
  544. }
  545. }
  546.  
  547. // 步骤 3: 循环处理帖子的所有楼层,直到 `currentFloor` 超过帖子的总楼层数 `totalPosts`
  548. while (currentFloor <= totalPosts) {
  549. roundCounter++; // 增加轮次(批次)计数器
  550.  
  551. // 在每轮(处理一个新批次)开始前,检查是否需要中止处理(主要用于“批量阅读”模式下的外部停止信号)
  552. if (stopConditionChecker()) {
  553. console.log(`操作中止:帖子 ${topicId} 的标记过程已因全局停止信号而中止。`);
  554. operationConcludedForTopic = true;
  555. return; // 中止对此帖子的进一步处理
  556. }
  557.  
  558. // 特殊检查:在“批量阅读”模式下,如果全局“正在处理的帖子ID” (`currentBulkReadTopicIdInProgress`)
  559. // 已经改变(例如用户在UI上操作,切换到其他帖子),则应中止当前这个帖子的处理,以响应新的指令。
  560. if (isBulkMode && isBulkReadingSessionActive && currentBulkReadTopicIdInProgress.toString() !== topicId.toString()) {
  561. console.log(`操作切换:帖子 ${topicId} 的标记过程已中止,因为“批量阅读”功能已切换到处理其他帖子 ID ${currentBulkReadTopicIdInProgress}。`);
  562. operationConcludedForTopic = true;
  563. return; // 中止对此帖子的进一步处理
  564. }
  565.  
  566. // 打印轮次间的分隔符:仅在单帖模式(非批量)且不是第一轮时打印,以增强日志可读性。
  567. if (roundCounter > 1 && !isBulkMode) {
  568. console.log("---"); // 日志分隔符
  569. }
  570.  
  571. // 构造并打印轮次开始的日志信息
  572. // 在单帖模式下,包含帖子ID;在批量模式下,不包含,因为上层日志已指明当前帖子ID。
  573. let roundStartLogMessage = `开始进行第 ${roundCounter} 轮的刷已读`;
  574. if (!isBulkMode) {
  575. roundStartLogMessage += ` (帖子ID: ${topicId})`;
  576. }
  577. console.log(roundStartLogMessage);
  578.  
  579. // 决定本批次实际处理的楼层数量,在配置的 `minFloor` 和 `maxFloor` 之间随机选择
  580. const batchSize = getRandomInt(currentScriptConfig.minFloor, currentScriptConfig.maxFloor);
  581. const startFloorInBatch = currentFloor; // 本批次的起始楼层号
  582. // 计算本批次的结束楼层号,确保不超过帖子的总楼层数
  583. const endFloorInBatch = Math.min(currentFloor + batchSize - 1, totalPosts);
  584.  
  585. let batchSuccess = false; // 标记本批次是否已成功提交
  586. let attemptsMadeThisBatch = 0; // 记录本批次已进行的尝试次数 (从1开始计数)
  587.  
  588. // 步骤 4: 尝试提交本批次的已读信息,包含重试机制
  589. // 循环 `totalAttemptsPerBatch` 次 (即首次尝试 + `configuredRetries` 次重试)
  590. while (attemptsMadeThisBatch < totalAttemptsPerBatch && !batchSuccess) {
  591. attemptsMadeThisBatch++; // 增加本批次的尝试次数计数
  592. const currentAttemptNumber = attemptsMadeThisBatch; // 当前是第几次尝试 (例如,1, 2, ...)
  593. const currentRetryNumber = currentAttemptNumber - 1; // 当前是第几次重试 (0表示首次尝试,1表示第1次重试, ...)
  594.  
  595. // 在每次尝试(包括首次和重试)前,再次检查是否需要中止
  596. if (stopConditionChecker()) {
  597. console.log(`操作中止:在尝试标记批次(楼层 ${startFloorInBatch}-${endFloorInBatch})时,帖子 ${topicId} 的操作因全局停止信号而中止。`);
  598. operationConcludedForTopic = true;
  599. return;
  600. }
  601.  
  602. // 仅在进行重试时(即非首次尝试)打印特定的重试提示信息
  603. if (currentRetryNumber > 0) { // `currentRetryNumber > 0` 意味着这是至少第1次重试
  604. console.log(`正在对楼层 ${startFloorInBatch} ~ ${endFloorInBatch} 进行第 ${currentRetryNumber} 次重试... (共 ${configuredRetries} 次重试机会)`);
  605. }
  606. // 对于首次尝试 (`currentRetryNumber === 0`),不在此处打印额外信息,
  607. // 因为 `submitTimingsBatch` 函数内部会打印 "准备将..." 的初始操作日志。
  608.  
  609. // 调用 API 函数提交本批次的已读数据
  610. batchSuccess = await submitTimingsBatch(topicId, startFloorInBatch, endFloorInBatch, csrfToken);
  611.  
  612. if (!batchSuccess) { // 如果本次尝试(首次或重试)失败
  613. if (currentAttemptNumber < totalAttemptsPerBatch) { // 如果还未达到最大尝试次数(即还有重试机会)
  614. const retryDelay = currentScriptConfig.delayBase + getRandomInt(0, currentScriptConfig.delayRandom);
  615. // 打印失败和即将重试的提示信息
  616. console.log(`标记楼层 ${startFloorInBatch}-${endFloorInBatch} 失败。第 ${currentRetryNumber + 1} 次重试将在 ${retryDelay}ms 后开始 (共 ${configuredRetries} 次重试机会)。`);
  617. // 等待一段时间后进行下一次重试,此延迟同样可被 `stopConditionChecker` 中断
  618. if (await interruptibleDelay(retryDelay, stopConditionChecker)) {
  619. console.log(`操作中止:在等待重试(楼层 ${startFloorInBatch}-${endFloorInBatch})期间,帖子 ${topicId} 的操作因全局停止信号而中止。`);
  620. operationConcludedForTopic = true;
  621. return;
  622. }
  623. } else { // 已达到最大尝试次数(首次尝试 +所有配置的重试次数),仍失败
  624. const failMessage = `错误:标记帖子 ID ${topicId} 的楼层 ${startFloorInBatch}-${endFloorInBatch} 彻底失败。已完成首次尝试及所有 ${configuredRetries} 次重试 (共 ${totalAttemptsPerBatch} 次尝试)。此帖子的自动标记流程已终止。`;
  625. console.error(failMessage); // 在控制台打印严重错误
  626. alert(failMessage); // 通过弹窗提示用户
  627. operationConcludedForTopic = true;
  628. return; // 终止对此帖子的进一步处理
  629. }
  630. }
  631. } // 单个楼层批次的尝试循环结束
  632.  
  633. // 理论上,如果上面的循环结束而 `batchSuccess` 仍为 false,说明所有尝试都失败了,
  634. // 并且相应的 return 语句已经执行。此处的检查作为最后一道防线,以防逻辑意外。
  635. if (!batchSuccess) {
  636. const criticalFailMessage = `严重错误(逻辑意外):标记帖子 ID ${topicId} 的楼层 ${startFloorInBatch}-${endFloorInBatch} 在所有尝试后仍未成功,且未按预期中止。此帖子的自动标记流程已终止。`;
  637. console.error(criticalFailMessage);
  638. alert(criticalFailMessage);
  639. operationConcludedForTopic = true;
  640. return;
  641. }
  642.  
  643. // 本批次成功处理,更新 `currentFloor` 到下一批次的起始楼层
  644. currentFloor = endFloorInBatch + 1;
  645.  
  646. // 步骤 5: 如果还有楼层未处理,则在处理下一批次前进行一次延迟
  647. if (currentFloor <= totalPosts) {
  648. const delayBetweenBatches = currentScriptConfig.delayBase + getRandomInt(0, currentScriptConfig.delayRandom);
  649. // 批次间的延迟日志不加帖子ID,因为轮次开始时上下文已明确
  650. console.log(`延迟 ${delayBetweenBatches} 毫秒后继续处理下一批`);
  651. // 此延迟也可被 `stopConditionChecker` 中断
  652. if (await interruptibleDelay(delayBetweenBatches, stopConditionChecker)) {
  653. console.log(`操作中止:在批次间延迟期间,帖子 ${topicId} 的操作因全局停止信号而中止。`);
  654. operationConcludedForTopic = true;
  655. return;
  656. }
  657. }
  658. } // 所有楼层处理循环结束 (当 `currentFloor > totalPosts`)
  659.  
  660. // 步骤 6: 处理完成或中止后的总结性日志
  661. if (currentFloor > totalPosts) {
  662. // 所有楼层均已成功标记
  663. console.log(`帖子 ID ${topicId} 的所有 ${totalPosts} 个评论已全部成功标记为已读,总共用了 ${roundCounter} 轮`);
  664. } else {
  665. // 如果循环因其他原因(例如未预期的中断逻辑,或非批量模式下的特殊情况)提前退出,
  666. // 且尚未通过 `return` 语句结束函数,则打印当前状态。
  667. // 正常情况下,此分支通常由 `stopConditionChecker` 或错误处理中的 `return` 覆盖。
  668. console.log(`操作提示:帖子 ${topicId} 的处理在 ${currentFloor - 1} 楼后结束 (总楼层: ${totalPosts}),可能被用户中止或因其他条件提前结束。`);
  669. }
  670. operationConcludedForTopic = true; // 标记此主题处理正常结束(或按预期中止)
  671.  
  672. } catch (error) {
  673. // 捕获在 `processSingleTopic` 函数内部发生的任何未被明确处理的同步或异步错误
  674. console.error(`严重错误:在处理帖子ID ${topicId} 的过程中发生未预料的错误。错误详情:`, error);
  675. operationConcludedForTopic = true; // 标记因不可预料的错误而结束
  676. } finally {
  677. // 无论此帖子的处理是成功、失败、被跳过还是被中止,
  678. // 只要其处理流程告一段落 (`operationConcludedForTopic` 为 true),就打印分隔符。
  679. // 这是为了确保在控制台日志中,每个帖子的处理记录在视觉上是独立的。
  680. if (operationConcludedForTopic) {
  681. console.log("---"); // 主题处理日志的结束分隔符
  682. }
  683. }
  684. }
  685.  
  686. /**
  687. * @async
  688. * @function startBulkReadingSession
  689. * @description 启动“批量阅读”功能。
  690. * 此功能会根据用户在设置中配置的起始帖子ID和读取顺序(正序/倒序),
  691. * 来依次自动处理一系列帖子,调用 `processSingleTopic` 对每个帖子进行标记。
  692. * @param {number|string} startId - 用户在UI上指定的起始帖子ID。如果无效,会使用配置中的默认值。
  693. */
  694. async function startBulkReadingSession(startId) {
  695. // 解析和验证传入的起始ID
  696. let parsedStartId = parseInt(startId, 10);
  697. if (isNaN(parsedStartId) || parsedStartId < 1) {
  698. // 如果输入ID无效,弹窗提示并使用配置中的起始ID
  699. alert("起始帖子ID无效,请输入一个大于0的数字。将使用配置中已保存或默认的起始ID。");
  700. parsedStartId = currentScriptConfig.bulkReadStartTopicId;
  701. }
  702. currentBulkReadTopicIdInProgress = parsedStartId; // 设置当前批量阅读会话中正在处理的帖子ID
  703. const direction = currentScriptConfig.bulkReadDirection; // 获取配置的读取方向(正序/倒序)
  704.  
  705. isBulkReadingSessionActive = true; // 激活全局的“批量阅读”会话状态标志
  706. UIManager.updateBulkReadControls(true); // 更新UI控件状态(例如,禁用输入框,更改按钮文本为“停止运行”)
  707.  
  708. const directionText = direction === 'forward' ? '正序' : '倒序';
  709. console.log(`“批量阅读”功能已启动。`); // 日志:批量阅读启动
  710. console.log(`当前起始帖子 ID ${currentBulkReadTopicIdInProgress}`); // 日志:报告起始ID
  711. console.log(`读取顺序为 ${directionText}`); // 日志:报告读取方向
  712. // 更新UI面板上的状态显示文本
  713. UIManager.setBulkReadStatus(`运行中... (${directionText}) 正在准备处理帖子ID: ${currentBulkReadTopicIdInProgress}`);
  714.  
  715. // 主循环:持续处理帖子,直到 `isBulkReadingSessionActive` 变为 `false` (用户停止) 或满足其他退出条件
  716. while (isBulkReadingSessionActive) {
  717. // 退出条件 1: 如果是倒序读取,并且当前帖子ID已小于1,则停止
  718. if (direction === 'reverse' && currentBulkReadTopicIdInProgress < 1) {
  719. console.log(`“批量阅读” (${directionText}): 当前帖子 ID (${currentBulkReadTopicIdInProgress}) 已小于1,批量操作结束。`);
  720. break; // 退出主循环
  721. }
  722.  
  723. // 实时保存断点:在处理每个帖子之前,将当前帖子ID更新到配置中并保存。
  724. // 这样即使用户意外关闭页面,下次启动也能从中断处继续。
  725. currentScriptConfig.bulkReadStartTopicId = currentBulkReadTopicIdInProgress;
  726. saveConfiguration(); // 保存当前配置(包含最新的起始ID)到 LocalStorage
  727. // 更新UI状态,显示当前正在尝试处理的ID
  728. UIManager.setBulkReadStatus(`运行中... (${directionText}) 当前尝试ID: ${currentBulkReadTopicIdInProgress}`);
  729.  
  730. // 每次循环迭代开始时,再次检查会话是否仍然激活 (可能在之前的异步操作或延迟中被用户停止)
  731. if (!isBulkReadingSessionActive) break;
  732.  
  733. // 步骤 1: 检查当前帖子ID是否存在且当前用户可访问
  734. // `checkTopicExists` 内部会打印相关日志(如404或访问错误)
  735. const topicAccessible = await checkTopicExists(currentBulkReadTopicIdInProgress);
  736.  
  737. // 在异步操作 `checkTopicExists` 后,再次检查会话激活状态
  738. if (!isBulkReadingSessionActive) break;
  739.  
  740. if (topicAccessible) {
  741. // 如果帖子可访问,打印提示并调用 `processSingleTopic` 进行处理
  742. console.log(`“批量阅读”检测到 ID ${currentBulkReadTopicIdInProgress} 的帖子可读,准备处理...`);
  743. // 调用核心处理函数,并传入 `true` 表示当前是批量模式
  744. // `processSingleTopic` 内部会处理其自身的日志分隔符 "---"
  745. await processSingleTopic(currentBulkReadTopicIdInProgress.toString(), true);
  746. } else {
  747. // 如果帖子不存在或不可访问,打印跳过信息
  748. console.log(`“批量阅读”检测到 ID ${currentBulkReadTopicIdInProgress} 的帖子不存在或无法访问,已跳过。`);
  749. console.log("---"); // 为保持日志格式一致性,跳过帖子后也打印分隔符
  750. }
  751.  
  752. // 处理完一个帖子(无论成功、失败还是跳过)后,再次检查会话激活状态
  753. if (!isBulkReadingSessionActive) break;
  754.  
  755. // 步骤 2: 更新到下一个帖子ID,根据配置的读取方向(正序或倒序)
  756. if (direction === 'forward') {
  757. currentBulkReadTopicIdInProgress++; // 正序:帖子ID递增
  758. } else { // 'reverse'
  759. currentBulkReadTopicIdInProgress--; // 倒序:帖子ID递减
  760. if (currentBulkReadTopicIdInProgress < 1) {
  761. // 如果倒序读取使得下一个ID将小于1,打印提示信息预告即将结束
  762. console.log(`“批量阅读” (${directionText}): 下一个帖子 ID 将是 ${currentBulkReadTopicIdInProgress},即将结束批量操作。`);
  763. }
  764. }
  765.  
  766. // 步骤 3: 帖子间延迟
  767. // 仅当会话仍活动,并且(如果是倒序读取)下一个ID仍然有效(不小于1)时执行。
  768. if (isBulkReadingSessionActive && !(direction === 'reverse' && currentBulkReadTopicIdInProgress < 1)) {
  769. const delayBetweenTopics = getRandomInt(1000, 3000); // 设置一个固定的主题间延迟范围 (例如1-3秒)
  770. // 更新UI状态,显示等待信息和下一个待处理的ID
  771. UIManager.setBulkReadStatus(`等待 ${delayBetweenTopics}ms 后处理ID: ${currentBulkReadTopicIdInProgress} (${directionText})`);
  772.  
  773. // 使用可中断延迟,允许用户在此期间通过UI停止批量阅读
  774. if (await interruptibleDelay(delayBetweenTopics, () => !isBulkReadingSessionActive)) {
  775. console.log("操作提示:“批量阅读”在帖子间延迟时被用户中止。");
  776. break; // 中断延迟,并退出主循环
  777. }
  778. }
  779. } // “批量阅读”主循环结束 (当 `isBulkReadingSessionActive` 为 false 或 `break` 被执行)
  780.  
  781. // “批量阅读”会话结束后的清理和日志记录
  782. const finalMessage = isBulkReadingSessionActive ? '已完成所有可处理帖子(或达到末端条件)' : '已被用户或程序内部逻辑停止';
  783. // 获取最后保存的(即最近尝试处理或已处理完成的)帖子ID,作为下次可能的起点
  784. const lastProcessedOrAttemptedId = currentScriptConfig.bulkReadStartTopicId;
  785.  
  786. console.log(`“批量阅读”功能已${finalMessage}。最后保存的起始帖子 ID 为: ${lastProcessedOrAttemptedId} (当前读取方向配置: ${currentScriptConfig.bulkReadDirection === 'forward' ? '正序' : '倒序'})`);
  787. console.log("---"); // 整个批量操作结束后的最终分隔符
  788.  
  789. // 更新UI状态面板的文本,以反映最终状态和下次启动的配置
  790. UIManager.setBulkReadStatus(`已${finalMessage.includes("停止") ? "停止" : "结束"}。下次将从ID ${lastProcessedOrAttemptedId} (${currentScriptConfig.bulkReadDirection === 'forward' ? '正序' : '倒序'}) 开始。`);
  791. // 调用 `stopBulkReadingSession` 来确保所有相关的全局状态和UI控件都正确更新,
  792. // 即使循环是自然结束(例如倒序读取到0),也执行此操作以保持一致性。
  793. stopBulkReadingSession();
  794. }
  795.  
  796. /**
  797. * @function stopBulkReadingSession
  798. * @description 停止当前正在运行的“批量阅读”会话。
  799. * 它通过设置全局标志 `isBulkReadingSessionActive` 为 `false` 来实现,
  800. * 这将导致 `startBulkReadingSession` 中的主循环在下次迭代检查时中止。
  801. * 同时,它还会更新UI上相关控件的状态(例如,重新启用输入框,将按钮文本改回“开始运行”)。
  802. */
  803. function stopBulkReadingSession() {
  804. const wasActive = isBulkReadingSessionActive; // 记录调用此函数前批量阅读会话是否处于活动状态
  805. isBulkReadingSessionActive = false; // 设置全局停止标记,这将有效地中止批量阅读循环
  806. UIManager.updateBulkReadControls(false); // 更新UI控件,反映批量阅读已停止的状态
  807.  
  808. // 更新UI状态面板的文本。仅当之前确实在运行时,才明确显示“已停止”的状态。
  809. const statusElement = document.getElementById(`${SCRIPT_ID_PREFIX}-bulk-read-status`);
  810. if (statusElement && wasActive) { // 确保状态元素存在,并且之前会话是活动的
  811. // 如果状态文本以“运行中”或“等待”开头,则更新为停止后的状态
  812. if (statusElement.textContent.startsWith("运行中") || statusElement.textContent.startsWith("等待")) {
  813. statusElement.textContent = `已停止。下次将从ID ${currentScriptConfig.bulkReadStartTopicId} (${currentScriptConfig.bulkReadDirection === 'forward' ? '正序' : '倒序'}) 开始。`;
  814. }
  815. }
  816. // 相关的停止操作日志主要由 `startBulkReadingSession` 函数的结束部分统一处理,此处不再重复打印,
  817. // 避免在控制台产生冗余信息。此函数主要负责状态变更和UI更新。
  818. }
  819.  
  820.  
  821. // =================================================================================
  822. // VIII. 用户界面管理模块 (User Interface Management Module)
  823. // =================================================================================
  824.  
  825. /**
  826. * @object UIManager
  827. * @description 这是一个包含了所有与用户界面(UI)创建、管理和交互相关方法的对象。
  828. * 它封装了DOM操作、样式注入、面板渲染和事件处理等UI逻辑。
  829. */
  830. const UIManager = {
  831. /**
  832. * @type {HTMLElement|null} panelContainer
  833. * @description 指向当前显示在页面上的设置面板的顶层覆盖容器 (overlay DOM element)。
  834. * 初始值为 `null`,在面板创建时被赋值,在面板移除时重置为 `null`。
  835. * @memberof UIManager
  836. */
  837. panelContainer: null,
  838.  
  839. /**
  840. * @function injectStyles
  841. * @memberof UIManager
  842. * @description 向当前页面的 `<head>` 部分注入脚本所需的CSS样式。
  843. * 这些样式定义了设置面板(包括遮罩层、面板本身、输入框、按钮等)的外观和布局。
  844. * 此函数通常只在脚本初始化时调用一次。
  845. */
  846. injectStyles: function () {
  847. const css = `
  848. /* 脚本UI遮罩层样式:固定定位,覆盖整个视口,半透明背景,内容居中 */
  849. .${SCRIPT_ID_PREFIX}-overlay {
  850. position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
  851. background: rgba(0,0,0,0.6);
  852. display: flex; justify-content: center; align-items: center;
  853. z-index: 10000; /* 确保在页面顶层显示 */
  854. }
  855. /* 设置面板主体样式:背景色,内边距,圆角,宽度,最大宽高,溢出滚动,阴影,字体 */
  856. .${SCRIPT_ID_PREFIX}-panel {
  857. background: #f9f9f9; padding: 25px; border-radius: 12px;
  858. width: 420px; max-width: 90vw; max-height: 90vh;
  859. overflow-y: auto; /* 内容超出时显示垂直滚动条 */
  860. box-shadow: 0 6px 25px rgba(0,0,0,0.3);
  861. font-family: "Segoe UI", Roboto, sans-serif;
  862. scrollbar-width: thin; /* Firefox 滚动条样式 */
  863. scrollbar-color: rgba(150,150,150,0.5) transparent; /* Firefox 滚动条颜色 */
  864. }
  865. /* Webkit (Chrome, Safari) 浏览器滚动条样式 */
  866. .${SCRIPT_ID_PREFIX}-panel::-webkit-scrollbar { width: 8px; }
  867. .${SCRIPT_ID_PREFIX}-panel::-webkit-scrollbar-track { background: transparent; border-radius: 10px; }
  868. .${SCRIPT_ID_PREFIX}-panel::-webkit-scrollbar-thumb {
  869. background: rgba(150,150,150,0.4); border-radius: 10px;
  870. border: 2px solid transparent; background-clip: padding-box;
  871. }
  872. /* 面板标题样式 */
  873. .${SCRIPT_ID_PREFIX}-panel h2 {
  874. font-size: 20px; margin-top:0; margin-bottom: 20px;
  875. color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px;
  876. }
  877. /* 输入组(标签 + 输入框)样式 */
  878. .${SCRIPT_ID_PREFIX}-input-group { margin-bottom: 15px; }
  879. /* 标签样式 */
  880. .${SCRIPT_ID_PREFIX}-label {
  881. font-size: 14px; margin-bottom: 6px; display: block;
  882. color: #555; font-weight: 500;
  883. }
  884. /* 输入框和选择框通用样式 */
  885. .${SCRIPT_ID_PREFIX}-input, .${SCRIPT_ID_PREFIX}-select {
  886. width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 6px;
  887. font-size: 14px; box-sizing: border-box;
  888. transition: border-color 0.2s, box-shadow 0.2s; /* 过渡效果 */
  889. }
  890. /* 输入框和选择框获取焦点时的样式 */
  891. .${SCRIPT_ID_PREFIX}-input:focus, .${SCRIPT_ID_PREFIX}-select:focus {
  892. border-color: #4CAF50; /* 边框高亮颜色 */
  893. box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); /* 外发光效果 */
  894. outline: none; /* 移除默认的outline */
  895. }
  896. /* 禁用的输入框和选择框样式 */
  897. .${SCRIPT_ID_PREFIX}-input:disabled, .${SCRIPT_ID_PREFIX}-select:disabled {
  898. background-color: #eee; cursor: not-allowed;
  899. }
  900. /* 按钮容器样式:Flex布局,自动换行,间距,上边距 */
  901. .${SCRIPT_ID_PREFIX}-buttons {
  902. display: flex; flex-wrap: wrap; gap: 12px; margin-top: 20px;
  903. }
  904. /* 按钮通用样式 */
  905. .${SCRIPT_ID_PREFIX}-button {
  906. flex: 1; /* Flex项目等分布局 */
  907. padding: 10px 15px; border: none; border-radius: 6px;
  908. font-size: 14px !important; font-family: "Segoe UI", Roboto, sans-serif !important;
  909. cursor: pointer;
  910. transition: background-color 0.2s, transform 0.1s; /* 过渡效果 */
  911. text-align: center;
  912. }
  913. /* 按钮悬停效果(未禁用时)*/
  914. .${SCRIPT_ID_PREFIX}-button:hover:not(:disabled) { opacity: 0.9; }
  915. /* 按钮激活(点击时)效果(未禁用时)*/
  916. .${SCRIPT_ID_PREFIX}-button:active:not(:disabled) { transform: translateY(1px); }
  917. /* 禁用按钮样式 */
  918. .${SCRIPT_ID_PREFIX}-button:disabled {
  919. background-color: #ccc !important; color: #777 !important; cursor: not-allowed;
  920. }
  921. /* 特定功能按钮的颜色样式 */
  922. .${SCRIPT_ID_PREFIX}-button.save { background: #4caf50; color: white; } /* 保存按钮 */
  923. .${SCRIPT_ID_PREFIX}-button.reset { background: #ff9800; color: white; } /* 重置按钮 */
  924. .${SCRIPT_ID_PREFIX}-button.run { background: #4caf50; color: white; } /* 开始运行按钮 */
  925. .${SCRIPT_ID_PREFIX}-button.stop { background: #f44336; color: white; } /* 停止运行按钮 */
  926. .${SCRIPT_ID_PREFIX}-button.close { background: #9e9e9e; color: white; } /* 关闭按钮 */
  927. .${SCRIPT_ID_PREFIX}-button.fullread { /* 进入批量阅读设置按钮 */
  928. background: #2196f3; color: white;
  929. width: 100%; margin-top: 15px; flex-basis: 100%;
  930. }
  931. /* 批量阅读状态显示区域样式 */
  932. #${SCRIPT_ID_PREFIX}-bulk-read-status {
  933. font-size: 13px; color: #333; margin-top: 12px; min-height: 1.3em;
  934. word-wrap: break-word; background-color: #f0f0f0;
  935. padding: 8px; border-radius: 4px; text-align: center;
  936. }
  937. `;
  938. const styleElement = document.createElement('style'); // 创建 `<style>` 元素
  939. styleElement.id = `${SCRIPT_ID_PREFIX}-styles`; // 为样式元素设置ID,方便管理或移除
  940. styleElement.textContent = css; // 将CSS文本内容赋值给 `<style>` 元素
  941. document.head.appendChild(styleElement); // 将 `<style>` 元素添加到文档的 `<head>` 部分
  942. },
  943.  
  944. /**
  945. * @function createInputField
  946. * @memberof UIManager
  947. * @description 创建一个包含标签(`<label>`)和输入框(`<input>`)的 DOM 结构,用于设置面板中的配置项。
  948. * @param {string} labelText - 显示在输入框上方的标签文本。
  949. * @param {string} configKey - 此输入框对应的配置项在 `currentScriptConfig` 对象中的键名。
  950. * 也用于生成输入框的 `id` 属性。
  951. * @param {any} currentValue - 输入框的当前值(通常从 `currentScriptConfig` 获取)。
  952. * @param {string} [inputType='number'] - HTML `<input>` 元素的 `type` 属性 (例如 'number', 'text')。
  953. * @returns {HTMLElement} 返回一个 `<div>` 元素,其中包含了创建的标签和输入框。
  954. */
  955. createInputField: function (labelText, configKey, currentValue, inputType = 'number') {
  956. const groupDiv = document.createElement('div'); // 创建外层 `<div>` 容器
  957. groupDiv.className = `${SCRIPT_ID_PREFIX}-input-group`; // 设置CSS类
  958.  
  959. const label = document.createElement('label'); // 创建 `<label>` 元素
  960. label.textContent = labelText; // 设置标签显示的文本
  961. label.className = `${SCRIPT_ID_PREFIX}-label`; // 设置CSS类
  962. label.htmlFor = `${SCRIPT_ID_PREFIX}-config-input-${configKey}`; // 关联 `label` 和 `input`,提高可访问性
  963.  
  964. const input = document.createElement('input'); // 创建 `<input>` 元素
  965. input.type = inputType; // 设置输入类型
  966. // 设置输入框的初始值,处理 `null` 或 `undefined` 的情况,确保 `value` 属性是字符串
  967. input.value = (currentValue === null || typeof currentValue === 'undefined') ? '' : currentValue.toString();
  968. input.className = `${SCRIPT_ID_PREFIX}-input`; // 设置CSS类
  969. input.id = `${SCRIPT_ID_PREFIX}-config-input-${configKey}`; // 设置ID,用于 `label` 关联和后续通过ID获取值
  970.  
  971. if (inputType === 'number') {
  972. // 为数字类型的输入框设置合理的 `min` 属性值
  973. input.min = (configKey === 'requestTimeout') ? "1000" : "0"; // 例如,requestTimeout 最低1000ms
  974. if (configKey === 'bulkReadStartTopicId') input.min = "1"; // 起始帖子ID至少为1
  975. // 添加事件监听器,以阻止数字输入框在获得焦点时响应鼠标滚轮事件,
  976. // 这可以防止用户在滚动页面时意外修改输入框中的数值。
  977. input.addEventListener('wheel', (event) => {
  978. if (document.activeElement === input) { // 仅当输入框本身是活动元素时阻止
  979. event.preventDefault();
  980. }
  981. });
  982. }
  983. groupDiv.append(label, input); // 将标签和输入框添加到 `<div>` 容器中
  984. return groupDiv; // 返回创建的 DOM 元素组
  985. },
  986.  
  987. /**
  988. * @function getInputValue
  989. * @memberof UIManager
  990. * @description 从UI设置面板上的指定输入框获取其当前值,并进行基本的类型转换和校验。
  991. * @param {string} configKey - 对应配置项的键名,用于构造输入框的ID以定位元素。
  992. * @param {boolean} [isNumeric=true] - 一个布尔值,指示该输入值是否应被视为数字并进行相应转换和校验。
  993. * 如果为 `false`,则按字符串处理。
  994. * @returns {any} 如果获取和转换成功,返回用户输入的值(数字或字符串)。
  995. * 如果输入框元素不存在、输入值无效(例如非数字的数字输入)或(对于数字)小于设定的最小值,
  996. * 则会进行修正(通常修正为默认值或允许的最小值),更新UI显示,并返回修正后的值。
  997. */
  998. getInputValue: function (configKey, isNumeric = true) {
  999. const inputElement = document.getElementById(`${SCRIPT_ID_PREFIX}-config-input-${configKey}`);
  1000. if (!inputElement) {
  1001. // 如果输入框元素在DOM中未找到,返回该配置项在 DEFAULT_CONFIG 中的默认值
  1002. console.warn(`UI警告:未能找到ID "${SCRIPT_ID_PREFIX}-config-input-${configKey}" 的输入框元素。将使用默认值。`);
  1003. return DEFAULT_CONFIG[configKey];
  1004. }
  1005.  
  1006. let value = inputElement.value; // 获取输入框的原始字符串值
  1007.  
  1008. if (isNumeric) {
  1009. const originalStringValue = value; // 保存原始字符串值,用于日志
  1010. value = Number(value); // 尝试将值转换为数字
  1011.  
  1012. // 为数字类型的值确定允许的最小业务逻辑值
  1013. let minValue = 0; // 默认最小值为0
  1014. if (configKey === 'bulkReadStartTopicId') minValue = 1; // 起始帖子ID最小为1
  1015. if (configKey === 'requestTimeout') minValue = 1000; // 网络请求超时最小为1000ms
  1016.  
  1017. // 校验转换后的数字是否有效 (非NaN 且不小于业务逻辑要求的 minValue)
  1018. if (isNaN(value) || value < minValue) {
  1019. const defaultValue = DEFAULT_CONFIG[configKey]; // 获取该配置项的默认值
  1020. // 警告用户输入无效,并准备修正
  1021. console.warn(`UI校验警告:输入框 "${configKey}" 的值 "${originalStringValue}" 无效或小于允许的最小值 (${minValue})。将使用默认值或修正后的值。`);
  1022. // 将值修正为 minValue 和 defaultValue 中的较大者,确保不低于业务要求的最小下限,也考虑了默认值可能高于minValue的情况
  1023. value = Math.max(minValue, defaultValue);
  1024. inputElement.value = value.toString(); // 更新UI输入框中显示的值为修正后的值
  1025. }
  1026. }
  1027. return value; // 返回获取或修正后的值
  1028. },
  1029.  
  1030. /**
  1031. * @function createButton
  1032. * @memberof UIManager
  1033. * @description 创建一个标准化的按钮 (`<button>`) 元素,并为其绑定点击事件。
  1034. * @param {string} label - 按钮上显示的文本内容。
  1035. * @param {string} typeClass - 应用于按钮的额外CSS类名,通常用于定义按钮的特定样式
  1036. * (例如 'save', 'run', 'close',对应 `injectStyles` 中定义的类)。
  1037. * @param {function} onClickAction - 当按钮被点击时需要执行的回调函数。
  1038. * @returns {HTMLButtonElement} 返回创建并配置好的 `<button>` 元素。
  1039. */
  1040. createButton: function (label, typeClass, onClickAction) {
  1041. const button = document.createElement('button'); // 创建 `<button>` 元素
  1042. // 设置按钮的CSS类,包括一个基础类和传入的特定类型类
  1043. button.className = `${SCRIPT_ID_PREFIX}-button ${typeClass}`;
  1044. button.textContent = label; // 设置按钮上显示的文本
  1045. button.onclick = onClickAction; // 绑定点击事件处理函数
  1046. return button; // 返回创建的按钮
  1047. },
  1048.  
  1049. /**
  1050. * @function renderGeneralSettingsPanel
  1051. * @memberof UIManager
  1052. * @description 渲染并显示脚本的“通用设置”面板。
  1053. * 如果页面上已存在由此脚本创建的任何面板,会先将其移除,以确保每次只显示一个面板。
  1054. * @param {number} [scrollTop=0] - (可选)面板重新渲染后,其内容区域的滚动条应恢复到的垂直滚动位置。
  1055. * 这主要用于在重置配置等操作后,保持用户之前的视图位置,提升体验。
  1056. * @param {function} [callback=null] - (可选)一个回调函数,在面板的DOM元素完全添加到页面并渲染完成后执行。
  1057. */
  1058. renderGeneralSettingsPanel: function (scrollTop = 0, callback = null) {
  1059. this.removeExistingPanel(); // 确保移除任何已存在的面板,防止重复渲染或叠加
  1060.  
  1061. // 创建半透明的遮罩层 (overlay),用于覆盖整个页面,突出显示设置面板
  1062. this.panelContainer = document.createElement('div');
  1063. this.panelContainer.className = `${SCRIPT_ID_PREFIX}-overlay`;
  1064. // 注意:点击遮罩层本身不关闭面板,关闭操作必须通过面板内部的“关闭”按钮进行。
  1065.  
  1066. // 创建设置面板的主体 `<div>` 元素
  1067. const panel = document.createElement('div');
  1068. panel.className = `${SCRIPT_ID_PREFIX}-panel`;
  1069. // 阻止面板内部的点击事件冒泡到遮罩层,以防止意外关闭面板
  1070. panel.onclick = (event) => event.stopPropagation();
  1071.  
  1072. panel.innerHTML = `<h2>脚本通用设置</h2>`; // 设置面板的标题
  1073.  
  1074. // 定义通用设置中的各个配置项及其在UI上显示的标签文本
  1075. // 格式:[标签文本, 配置项在currentScriptConfig中的键名]
  1076. const generalFields = [
  1077. ['每轮基础延迟(ms)', 'delayBase'],
  1078. ['每轮随机延迟范围(ms)', 'delayRandom'],
  1079. ['每轮最小请求楼层数', 'minFloor'],
  1080. ['每轮最大请求楼层数', 'maxFloor'],
  1081. ['每篇帖子最小阅读时间(ms)', 'minPostReadTime'],
  1082. ['每篇帖子最大阅读时间(ms)', 'maxPostReadTime'],
  1083. ['每条评论最小阅读时间(ms)', 'minCommentReadTime'],
  1084. ['每条评论最大阅读时间(ms)', 'maxCommentReadTime'],
  1085. ['失败后额外重试次数', 'maxRetriesPerBatch'],
  1086. ['网络请求超时(ms)', 'requestTimeout']
  1087. ];
  1088.  
  1089. try {
  1090. // 遍历配置项定义,为每一项创建对应的输入字段并将其添加到面板中
  1091. generalFields.forEach(([labelText, configKey]) => {
  1092. // 使用 `currentScriptConfig` 中的值作为输入框的当前值,
  1093. // 如果 `currentScriptConfig` 中某项未定义(理论上不太可能,因为 `loadConfiguration` 会填充),
  1094. // 则回退到 `DEFAULT_CONFIG` 中的值作为备用。
  1095. const currentValue = currentScriptConfig[configKey] !== undefined ?
  1096. currentScriptConfig[configKey] : DEFAULT_CONFIG[configKey];
  1097. panel.appendChild(this.createInputField(labelText, configKey, currentValue));
  1098. });
  1099. } catch (error) {
  1100. // 如果在创建设置字段的过程中发生任何错误,记录到控制台,并在面板上显示错误提示
  1101. console.error("UI错误:创建通用设置面板的输入字段时出错。错误详情:", error);
  1102. panel.innerHTML += `<p style="color:red; font-weight:bold;">创建设置字段时发生错误,部分设置可能无法显示或操作。请检查浏览器控制台获取详细信息。</p>`;
  1103. }
  1104.  
  1105. const buttonRow = document.createElement('div'); // 创建用于容纳按钮的 `<div>` 行
  1106. buttonRow.className = `${SCRIPT_ID_PREFIX}-buttons`; // 应用按钮容器的样式
  1107.  
  1108. // 创建“保存通用配置”按钮
  1109. const saveBtn = this.createButton('保存通用配置', 'save', () => {
  1110. // 从UI输入框收集所有通用配置项的当前值
  1111. generalFields.forEach(([_, configKey]) => {
  1112. currentScriptConfig[configKey] = this.getInputValue(configKey); // getInputValue内部包含校验
  1113. });
  1114. // 此处可以再次进行一些跨字段的逻辑校验,例如确保min不超过max等,
  1115. // 不过 getInputValue 和 loadConfiguration 中已有部分校验。
  1116. // 为确保稳健,重新校验依赖关系(已在loadConfiguration和getInputValue中处理大部分)
  1117. if (currentScriptConfig.minFloor > currentScriptConfig.maxFloor) currentScriptConfig.minFloor = currentScriptConfig.maxFloor;
  1118. if (currentScriptConfig.minPostReadTime > currentScriptConfig.maxPostReadTime) currentScriptConfig.minPostReadTime = currentScriptConfig.maxPostReadTime;
  1119. if (currentScriptConfig.minCommentReadTime > currentScriptConfig.maxCommentReadTime) currentScriptConfig.minCommentReadTime = currentScriptConfig.maxCommentReadTime;
  1120. if (currentScriptConfig.requestTimeout < 1000) currentScriptConfig.requestTimeout = DEFAULT_CONFIG.requestTimeout;
  1121. if (currentScriptConfig.maxRetriesPerBatch < 0) currentScriptConfig.maxRetriesPerBatch = DEFAULT_CONFIG.maxRetriesPerBatch;
  1122.  
  1123.  
  1124. saveConfiguration(); // 调用保存配置到 LocalStorage 的函数
  1125. alert('通用配置已成功保存!'); // 弹窗提示用户
  1126. console.log("UI操作提示:通用配置已更新并成功保存到LocalStorage。");
  1127. });
  1128. saveBtn.style.flexBasis = '100%'; // 使“保存”按钮占据按钮行的整行宽度,更醒目
  1129.  
  1130. // 创建“重置所有配置”按钮
  1131. const resetBtn = this.createButton('重置所有配置', 'reset', () => {
  1132. if (confirm("您确定要将所有配置(包括“批量阅读”的设置)恢复到初始默认值吗?此操作不可撤销。")) {
  1133. const currentPanelScrollTop = panel.scrollTop; // 记录当前面板的滚动位置
  1134. resetConfiguration(); // 调用重置配置的函数(会加载默认配置并保存)
  1135. this.removeExistingPanel(); // 移除当前面板
  1136. // 重新渲染通用设置面板,并传入之前的滚动位置,以及一个回调函数来在面板渲染后显示提示
  1137. this.renderGeneralSettingsPanel(currentPanelScrollTop, () => {
  1138. // 使用 setTimeout 确保 alert 在面板完全渲染后执行,避免阻塞UI
  1139. setTimeout(() => alert('所有配置已成功重置为默认值!'), 0);
  1140. });
  1141. }
  1142. });
  1143.  
  1144. // 创建“关闭”按钮
  1145. const closeBtn = this.createButton('关闭', 'close', () => this.removeExistingPanel());
  1146.  
  1147. buttonRow.append(saveBtn, resetBtn, closeBtn); // 将按钮添加到按钮行
  1148. panel.appendChild(buttonRow); // 将按钮行添加到面板
  1149.  
  1150. // 创建进入“批量阅读设置”面板的入口按钮
  1151. const bulkReadEntryBtn = this.createButton('进入“批量阅读”设置', 'fullread', () => {
  1152. const currentPanelScrollTop = panel.scrollTop; // 记录当前通用设置面板的滚动位置
  1153. this.removeExistingPanel(); // 移除当前通用设置面板
  1154. // 渲染“批量阅读”设置面板,并传递之前记录的滚动位置,
  1155. // 以便从批量阅读面板返回时能恢复通用面板的视图。
  1156. this.renderBulkReadPanel(currentPanelScrollTop);
  1157. });
  1158. panel.appendChild(bulkReadEntryBtn); // 将入口按钮添加到面板
  1159.  
  1160. this.panelContainer.appendChild(panel); // 将设置面板主体添加到遮罩层容器
  1161. document.body.appendChild(this.panelContainer); // 将遮罩层(及其包含的面板)添加到文档的 `<body>`
  1162. if (scrollTop > 0) panel.scrollTop = scrollTop; // 如果传入了 `scrollTop` 值,则恢复面板内容的滚动位置
  1163. if (typeof callback === 'function') callback(); // 如果传入了回调函数,则执行它
  1164. },
  1165.  
  1166. /**
  1167. * @function renderBulkReadPanel
  1168. * @memberof UIManager
  1169. * @description 渲染并显示“批量阅读”功能的专属设置面板。
  1170. * 同样,如果已存在面板,会先移除。
  1171. * @param {number} [restoreScrollOnReturn=0] - (可选)一个数值,表示当从这个“批量阅读”面板
  1172. * 返回到“通用设置”面板时,通用面板内容区域应恢复到的滚动位置。
  1173. */
  1174. renderBulkReadPanel: function (restoreScrollOnReturn = 0) {
  1175. this.removeExistingPanel(); // 移除任何已存在的面板
  1176.  
  1177. this.panelContainer = document.createElement('div'); // 创建遮罩层
  1178. this.panelContainer.className = `${SCRIPT_ID_PREFIX}-overlay`;
  1179.  
  1180. const panel = document.createElement('div'); // 创建“批量阅读”面板主体
  1181. panel.className = `${SCRIPT_ID_PREFIX}-panel`;
  1182. panel.id = `${SCRIPT_ID_PREFIX}-bulk-read-panel`; // 为面板设置特定ID,用于区分和控制
  1183. panel.onclick = (event) => event.stopPropagation(); // 阻止点击穿透
  1184. panel.innerHTML = `<h2>批量阅读 设置</h2>`; // 面板标题
  1185.  
  1186. // 创建“起始帖子ID”输入字段
  1187. panel.appendChild(this.createInputField(
  1188. '起始帖子ID',
  1189. 'bulkReadStartTopicId',
  1190. currentScriptConfig.bulkReadStartTopicId
  1191. ));
  1192.  
  1193. // 创建“读取顺序”选择框 (`<select>`)
  1194. const directionGroup = document.createElement('div');
  1195. directionGroup.className = `${SCRIPT_ID_PREFIX}-input-group`;
  1196. const directionLabel = document.createElement('label');
  1197. directionLabel.textContent = '读取顺序:';
  1198. directionLabel.className = `${SCRIPT_ID_PREFIX}-label`;
  1199. directionLabel.htmlFor = `${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`;
  1200. const directionSelect = document.createElement('select');
  1201. directionSelect.id = `${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`;
  1202. directionSelect.className = `${SCRIPT_ID_PREFIX}-select`; // 复用输入框的样式
  1203. // 添加选项:'forward' (正序) 和 'reverse' (倒序)
  1204. ['forward', 'reverse'].forEach(directionValue => {
  1205. const option = document.createElement('option');
  1206. option.value = directionValue;
  1207. option.textContent = directionValue === 'forward' ? '正序 (ID 递增)' : '倒序 (ID 递减)';
  1208. directionSelect.appendChild(option);
  1209. });
  1210. // 设置选择框的当前选中值,基于 `currentScriptConfig` 或默认值
  1211. directionSelect.value = currentScriptConfig.bulkReadDirection || DEFAULT_CONFIG.bulkReadDirection;
  1212. directionGroup.append(directionLabel, directionSelect);
  1213. panel.appendChild(directionGroup);
  1214.  
  1215. // 创建操作按钮行(保存当前设置、开始/停止运行)
  1216. const bulkReadButtonRow = document.createElement('div');
  1217. bulkReadButtonRow.className = `${SCRIPT_ID_PREFIX}-buttons`;
  1218.  
  1219. // 创建“保存当前(批量阅读)设置”按钮
  1220. const saveBulkConfigBtn = this.createButton('保存当前设置', 'save', () => {
  1221. // 获取UI上输入的起始ID和选择的读取顺序
  1222. const newStartId = this.getInputValue('bulkReadStartTopicId');
  1223. const newDirection = document.getElementById(`${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`).value;
  1224. // 更新全局配置对象中的相应值
  1225. currentScriptConfig.bulkReadStartTopicId = newStartId;
  1226. currentScriptConfig.bulkReadDirection = newDirection;
  1227. saveConfiguration(); // 保存更新后的配置到 LocalStorage
  1228. const directionText = newDirection === 'forward' ? '正序' : '倒序';
  1229. alert(`“批量阅读”设置已保存:起始ID ${newStartId}, 读取顺序 ${directionText}`);
  1230. console.log(`UI操作提示:“批量阅读”的特定设置已手动保存。起始ID: ${newStartId}, 读取顺序: ${directionText}`);
  1231. // 如果 `getInputValue` 对起始ID进行了校验修正,同步更新UI输入框的显示值
  1232. const idInputElement = document.getElementById(`${SCRIPT_ID_PREFIX}-config-input-bulkReadStartTopicId`);
  1233. if (idInputElement) idInputElement.value = currentScriptConfig.bulkReadStartTopicId.toString();
  1234. });
  1235. saveBulkConfigBtn.id = `${SCRIPT_ID_PREFIX}-bulk-save-button`; // 为按钮设置ID,便于后续控制
  1236.  
  1237. // 创建“开始运行”/“停止运行”按钮(状态动态变化)
  1238. const runStopBtn = this.createButton(
  1239. isBulkReadingSessionActive ? '停止运行' : '开始运行', // 根据当前运行状态决定按钮文本
  1240. isBulkReadingSessionActive ? 'stop' : 'run', // 根据当前运行状态决定按钮样式类
  1241. () => { // 点击事件处理函数
  1242. if (isBulkReadingSessionActive) {
  1243. // 如果当前正在运行,则调用停止函数
  1244. stopBulkReadingSession();
  1245. } else {
  1246. // 如果当前未运行,则获取面板上的最新设置,保存,然后启动批量阅读
  1247. const startIdFromInput = this.getInputValue('bulkReadStartTopicId');
  1248. const directionFromSelect = document.getElementById(`${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`).value;
  1249. currentScriptConfig.bulkReadStartTopicId = startIdFromInput;
  1250. currentScriptConfig.bulkReadDirection = directionFromSelect;
  1251. saveConfiguration(); // 在启动前,确保当前面板上的设置被保存
  1252. startBulkReadingSession(currentScriptConfig.bulkReadStartTopicId); // 调用全局的批量阅读启动函数
  1253. }
  1254. }
  1255. );
  1256. runStopBtn.id = `${SCRIPT_ID_PREFIX}-bulk-runstop-button`; // 为按钮设置ID
  1257. bulkReadButtonRow.append(saveBulkConfigBtn, runStopBtn);
  1258. panel.appendChild(bulkReadButtonRow);
  1259.  
  1260. // 创建状态显示区域的 `<div>`
  1261. const statusDiv = document.createElement('div');
  1262. statusDiv.id = `${SCRIPT_ID_PREFIX}-bulk-read-status`;
  1263. this.setBulkReadStatus(); // 初始化状态显示区域的文本(会根据 `isBulkReadingSessionActive` 自动判断)
  1264. panel.appendChild(statusDiv);
  1265.  
  1266. // 创建“返回通用设置”按钮
  1267. const backBtn = this.createButton('返回通用设置', 'close', () => {
  1268. if (isBulkReadingSessionActive) { // 如果“批量阅读”功能正在运行中
  1269. // 提示用户是否要停止运行中的任务,并确认
  1270. if (!confirm("“批量阅读”功能当前正在运行中。确定要停止该功能并返回到通用设置页面吗?")) {
  1271. return; // 用户取消操作,则不执行任何后续动作
  1272. }
  1273. stopBulkReadingSession(); // 用户确认,则先停止批量阅读
  1274. }
  1275. this.removeExistingPanel(); // 移除当前“批量阅读”面板
  1276. // 渲染“通用设置”面板,并传递 `restoreScrollOnReturn` 值,以便恢复其滚动条位置
  1277. this.renderGeneralSettingsPanel(restoreScrollOnReturn);
  1278. });
  1279. backBtn.style.flexBasis = '100%'; // 使返回按钮占据整行宽度
  1280. backBtn.style.marginTop = '20px'; // 添加一些上边距,与其他按钮组分隔
  1281.  
  1282. const backButtonRow = document.createElement('div'); // 为返回按钮创建一个单独的行容器
  1283. backButtonRow.className = `${SCRIPT_ID_PREFIX}-buttons`;
  1284. backButtonRow.appendChild(backBtn);
  1285. panel.appendChild(backButtonRow);
  1286.  
  1287. this.panelContainer.appendChild(panel); // 将面板添加到遮罩层
  1288. document.body.appendChild(this.panelContainer); // 将遮罩层添加到文档主体
  1289. // 根据当前是否正在运行批量阅读,初始化面板上各控件的启用/禁用状态
  1290. this.updateBulkReadControls(isBulkReadingSessionActive);
  1291. },
  1292.  
  1293. /**
  1294. * @function updateBulkReadControls
  1295. * @memberof UIManager
  1296. * @description 更新“批量阅读”设置面板中各个交互控件(如输入框、选择框、按钮)的启用/禁用状态和文本内容。
  1297. * 此函数通常在“批量阅读”功能开始或停止时被调用,以反映当前的操作状态。
  1298. * @param {boolean} isRunning - 一个布尔值,指示“批量阅读”功能当前是否正在运行 (`true` 为正在运行)。
  1299. */
  1300. updateBulkReadControls: function (isRunning) {
  1301. // 获取相关的UI元素
  1302. const startIdInput = document.getElementById(`${SCRIPT_ID_PREFIX}-config-input-bulkReadStartTopicId`);
  1303. const directionSelect = document.getElementById(`${SCRIPT_ID_PREFIX}-config-select-bulkReadDirection`);
  1304. const saveButton = document.getElementById(`${SCRIPT_ID_PREFIX}-bulk-save-button`);
  1305. const runStopButton = document.getElementById(`${SCRIPT_ID_PREFIX}-bulk-runstop-button`);
  1306.  
  1307. // 如果正在运行,则禁用起始ID输入框、读取顺序选择框和“保存当前设置”按钮
  1308. if (startIdInput) {
  1309. startIdInput.disabled = isRunning;
  1310. // 如果不是在运行状态,确保输入框显示的是最新的配置值 (可能在后台被其他逻辑修改过,例如批量读取自动更新断点)
  1311. if (!isRunning) startIdInput.value = currentScriptConfig.bulkReadStartTopicId.toString();
  1312. }
  1313. if (directionSelect) {
  1314. directionSelect.disabled = isRunning;
  1315. // 同理,更新选择框的显示值
  1316. if (!isRunning) directionSelect.value = currentScriptConfig.bulkReadDirection;
  1317. }
  1318. if (saveButton) {
  1319. saveButton.disabled = isRunning;
  1320. }
  1321.  
  1322. // 更新“开始运行”/“停止运行”按钮的文本和样式类
  1323. if (runStopButton) {
  1324. runStopButton.textContent = isRunning ? '停止运行' : '开始运行';
  1325. runStopButton.className = `${SCRIPT_ID_PREFIX}-button ${isRunning ? 'stop' : 'run'}`;
  1326. }
  1327. // 注意:状态显示区域的文本 (`bulk-read-status`) 由 `setBulkReadStatus` 函数独立负责更新,
  1328. // 此处不直接修改,以保持逻辑分离。
  1329. },
  1330.  
  1331. /**
  1332. * @function setBulkReadStatus
  1333. * @memberof UIManager
  1334. * @description 设置“批量阅读”面板中状态显示区域 (`#${SCRIPT_ID_PREFIX}-bulk-read-status`) 的文本内容。
  1335. * @param {string} [statusText=null] - (可选)需要直接显示的状态文本。
  1336. * 如果提供此参数,则直接使用它。
  1337. * 如果为 `null` (默认),则函数会根据全局的 `isBulkReadingSessionActive`
  1338. * 和相关的配置信息自动生成合适的状态文本。
  1339. */
  1340. setBulkReadStatus: function (statusText = null) {
  1341. const statusElement = document.getElementById(`${SCRIPT_ID_PREFIX}-bulk-read-status`);
  1342. if (statusElement) { // 确保状态显示元素存在于DOM中
  1343. if (statusText !== null) { // 如果直接提供了状态文本,则使用该文本
  1344. statusElement.textContent = statusText;
  1345. } else {
  1346. // 如果未提供 `statusText`,则根据当前脚本的运行状态自动生成状态文本
  1347. const directionText = currentScriptConfig.bulkReadDirection === 'forward' ? '正序' : '倒序';
  1348. if (isBulkReadingSessionActive) {
  1349. // 如果“批量阅读”正在运行,通常状态文本会由 `startBulkReadingSession` 函数动态更新。
  1350. // 此处提供一个备用的/初始的文本,以防万一在 `startBulkReadingSession` 更新前被调用。
  1351. // 检查当前状态文本是否已是运行中的信息,避免不必要的重复设置。
  1352. if (!statusElement.textContent.startsWith("运行中") && !statusElement.textContent.startsWith("等待")) {
  1353. statusElement.textContent = `运行中... (${directionText}) 当前尝试ID: ${currentBulkReadTopicIdInProgress}`;
  1354. }
  1355. } else {
  1356. // 如果“批量阅读”未运行,显示准备状态和下次启动时将使用的配置信息
  1357. statusElement.textContent = `未运行。下次将从ID ${currentScriptConfig.bulkReadStartTopicId} (${directionText}) 开始。`;
  1358. }
  1359. }
  1360. }
  1361. },
  1362.  
  1363. /**
  1364. * @function removeExistingPanel
  1365. * @memberof UIManager
  1366. * @description 从 DOM 中移除当前显示的设置面板(如果存在的话)。
  1367. * 它会查找并移除 `this.panelContainer` 指向的元素,并将其重置为 `null`。
  1368. */
  1369. removeExistingPanel: function () {
  1370. if (this.panelContainer && this.panelContainer.parentNode) {
  1371. // 如果 `panelContainer` 存在并且它有一个父节点,则安全地从其父节点中移除它
  1372. this.panelContainer.parentNode.removeChild(this.panelContainer);
  1373. }
  1374. this.panelContainer = null; // 重置引用,表示当前没有活动的面板
  1375. },
  1376.  
  1377. /**
  1378. * @function insertSettingsButton
  1379. * @memberof UIManager
  1380. * @description 在页面的头部图标区域(通常是 Discourse 论坛右上角的 `.d-header-icons` 容器)
  1381. * 插入一个用于打开本脚本设置面板的按钮。
  1382. * 此函数会无限期等待目标容器加载完成,确保按钮能被正确插入。
  1383. */
  1384. insertSettingsButton: function () {
  1385. // 使用 `waitForCondition` 来等待 Discourse 论坛的头部图标容器 `.d-header-icons` 加载完成。
  1386. // `Infinity` 表示无限期等待,确保即使在网络缓慢或页面结构复杂的情况下也能成功插入。
  1387. waitForCondition(
  1388. () => document.querySelector('.d-header-icons'), // 条件函数:检查目标容器是否存在
  1389. () => { // 回调函数:当目标容器加载完成后执行此处的逻辑
  1390. console.log("UI提示:目标容器 '.d-header-icons' 已成功加载。准备插入脚本设置按钮。");
  1391. const headerIconsContainer = document.querySelector('.d-header-icons');
  1392.  
  1393. // 防止重复添加按钮(例如,在SPA页面切换或脚本被意外多次执行时)
  1394. if (headerIconsContainer.querySelector(`.${SCRIPT_ID_PREFIX}-settings-button-container`)) {
  1395. console.log("UI提示:脚本设置按钮似乎已存在,跳过重复插入。");
  1396. return;
  1397. }
  1398.  
  1399. const listItem = document.createElement('li'); // 创建一个 `<li>` 元素来容纳按钮,以匹配论坛头部图标的列表结构
  1400. // 沿用 Discourse 头部图标项的现有 CSS 类,使其在外观上与原生图标按钮保持一致,
  1401. // 并添加一个脚本特定的类名用于标识和可能的进一步样式控制。
  1402. listItem.className = `header-dropdown-toggle ${SCRIPT_ID_PREFIX}-settings-button-container`;
  1403.  
  1404. const button = document.createElement('button'); // 创建按钮元素
  1405. // 沿用 Discourse 图标按钮的 CSS 类,如 'btn', 'no-text', 'btn-icon', 'icon', 'btn-flat'
  1406. button.className = 'btn no-text btn-icon icon btn-flat';
  1407. button.title = `脚本设置 (${GM_info.script.name})`; // 设置鼠标悬停时的提示文本
  1408. button.setAttribute('aria-label', `脚本设置 (${GM_info.script.name})`); // 设置 ARIA 标签,增强可访问性
  1409. button.type = 'button'; // 明确按钮类型,避免在表单中意外触发表单提交
  1410.  
  1411. // 创建并使用 SVG 图标 (通常是一个齿轮图标,代表“设置”)
  1412. const svgIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  1413. // 应用 Discourse 用于 SVG 图标的类名
  1414. svgIcon.classList.add('fa', 'd-icon', 'd-icon-gear', 'svg-icon', 'svg-string');
  1415. svgIcon.setAttribute('aria-hidden', 'true'); // 对辅助技术隐藏装饰性图标
  1416. const useElement = document.createElementNS('http://www.w3.org/2000/svg', 'use');
  1417. // 引用 Discourse 内置的 `#gear` SVG 定义(通常在页面的某个地方定义了所有图标)
  1418. useElement.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#gear');
  1419. svgIcon.appendChild(useElement);
  1420. button.appendChild(svgIcon); // 将 SVG 图标添加到按钮中
  1421.  
  1422. // 为设置按钮添加点击事件监听器
  1423. button.addEventListener('click', (event) => {
  1424. event.preventDefault(); // 阻止可能的默认行为(例如,如果按钮在链接内)
  1425. event.stopPropagation(); // 阻止事件冒泡,避免触发父元素上可能存在的点击事件
  1426.  
  1427. // 检查设置面板是否已打开
  1428. const existingPanel = document.querySelector(`.${SCRIPT_ID_PREFIX}-overlay`);
  1429.  
  1430. if (isBulkReadingSessionActive) { // 如果“批量阅读”功能当前正在运行
  1431. // 如果“批量阅读”面板已打开且正在运行,提示用户应在面板内操作
  1432. if (existingPanel && existingPanel.querySelector(`#${SCRIPT_ID_PREFIX}-bulk-read-panel`)) {
  1433. alert("“批量阅读”功能正在运行中。请使用面板内的“停止运行”按钮,或通过“返回通用设置”按钮(将提示您停止运行)来管理。");
  1434. return;
  1435. }
  1436. // 如果“批量阅读”正在后台运行,但当前面板未打开,或者打开的是通用设置面板
  1437. // 提示用户是否需要切换到“批量阅读”面板进行管理
  1438. if (confirm("“批量阅读”功能当前正在后台运行中。\n\n要打开设置,建议先通过“批量阅读”面板停止该功能,或直接在此处打开面板进行管理。\n\n是否现在打开/切换到“批量阅读”设置面板?")) {
  1439. this.renderBulkReadPanel(); // 渲染并显示“批量阅读”面板
  1440. }
  1441. } else {
  1442. // 如果“批量阅读”未运行,则正常切换/打开设置面板
  1443. if (existingPanel) {
  1444. this.removeExistingPanel(); // 如果面板已打开,则关闭它(实现点击按钮切换显示/隐藏)
  1445. } else {
  1446. this.renderGeneralSettingsPanel(); // 如果面板未打开,则打开“通用设置”面板
  1447. }
  1448. }
  1449. });
  1450.  
  1451. listItem.appendChild(button); // 将按钮添加到 `<li>` 元素中
  1452.  
  1453. // 尝试将设置按钮插入到搜索图标 (`.search-dropdown`) 之前,如果搜索图标存在且是容器的直接子元素。
  1454. // 这是为了让脚本按钮尽可能地融入原生UI的布局顺序。
  1455. const searchIconLi = headerIconsContainer.querySelector('.search-dropdown');
  1456. if (searchIconLi && searchIconLi.parentNode === headerIconsContainer) {
  1457. headerIconsContainer.insertBefore(listItem, searchIconLi);
  1458. } else {
  1459. // 否则(例如搜索图标不存在或结构不同),将设置按钮插入到头部图标容器的开头
  1460. headerIconsContainer.insertBefore(listItem, headerIconsContainer.firstChild);
  1461. }
  1462. console.log("UI提示:脚本设置按钮已成功添加到页面头部。");
  1463. },
  1464. 500, // 检查间隔:每500毫秒检查一次目标容器是否加载
  1465. Infinity // 总等待超时:Infinity 表示无限期等待,直到容器加载完成
  1466. );
  1467. }
  1468. };
  1469.  
  1470. // =================================================================================
  1471. // IX. 初始化与主执行逻辑 (Initialization & Main Execution Logic)
  1472. // =================================================================================
  1473.  
  1474. /**
  1475. * @function isTopicPage
  1476. * @description 判断当前浏览器的 URL 是否指向一个论坛的帖子详情页面。
  1477. * Discourse 论坛的帖子 URL 通常具有 `/t/topic-slug/topic-id` 这样的结构,
  1478. * 后面可能还跟着楼层号或分页参数等。
  1479. * @returns {boolean} 如果当前 URL 符合帖子详情页的模式,则返回 `true`;否则返回 `false`。
  1480. */
  1481. function isTopicPage() {
  1482. // 正则表达式解析:
  1483. // `^/t/` : 路径以 `/t/` 开头 (Discourse 帖子路径的标志)
  1484. // `[^/]+` : 后面跟着至少一个非斜杠字符 (通常是帖子的 slug,即标题的 URL友好版本)
  1485. // `/\d+` : 再后面跟着一个斜杠和至少一个数字 (这是帖子的 ID)
  1486. // `(?:\/.*|\?.*)?`: 这是一个可选的非捕获组,匹配以下任一情况:
  1487. // `\/.*` : 斜杠后跟任意字符 (例如 `/楼层号` 或 `/楼层号/编辑`)
  1488. // `|\?.*` : 或者问号后跟任意字符 (例如 `?page=2`)
  1489. // `?` : 使整个非捕获组可选
  1490. // 此正则旨在更准确地识别帖子页面,同时允许 URL末尾有其他参数或路径段。
  1491. return /^\/t\/[^/]+\/\d+(?:\/.*|\?.*)?$/.test(window.location.pathname + window.location.search);
  1492. }
  1493.  
  1494. /**
  1495. * @function extractTopicIdFromUrl
  1496. * @description 从当前浏览器的 URL 中提取帖子的 ID。
  1497. * @returns {string|null} 如果成功从 URL (路径部分) 中提取到帖子 ID (一串数字),则返回该 ID 字符串。
  1498. * 如果 URL 不符合预期的帖子详情页格式或无法提取 ID,则返回 `null`。
  1499. */
  1500. function extractTopicIdFromUrl() {
  1501. // 正则表达式解析:
  1502. // `\/t\/` : 匹配路径中的 `/t/` 部分。
  1503. // `[^/]+` : 匹配帖子 slug (至少一个非斜杠字符)。
  1504. // `\/(\d+)`: 匹配一个斜杠,然后捕获 (`()`) 后面跟着的至少一个数字 (`\d+`),这就是帖子 ID。
  1505. const match = window.location.pathname.match(/\/t\/[^/]+\/(\d+)/);
  1506. // 如果匹配成功,`match` 是一个数组,其中 `match[1]` 包含捕获到的帖子 ID。
  1507. return match ? match[1] : null;
  1508. }
  1509.  
  1510. /**
  1511. * @function initializeScript
  1512. * @description 脚本的总入口和初始化函数。
  1513. * 它负责执行脚本启动时需要进行的所有设置和检查:
  1514. * 1. 加载用户配置(或默认配置)。
  1515. * 2. 在控制台打印脚本加载信息和当前生效的配置,方便用户调试。
  1516. * 3. 向页面注入 UI 所需的 CSS 样式。
  1517. * 4. 在页面头部(如果找到合适位置)创建并插入设置按钮。
  1518. * 5. 检查当前页面是否为帖子详情页:
  1519. * 如果是,并且“批量阅读”功能未在后台运行,则自动开始处理当前页面的帖子,将其标记为已读。
  1520. */
  1521. function initializeScript() {
  1522. // 步骤 1: 加载脚本配置
  1523. loadConfiguration();
  1524.  
  1525. // 步骤 2: 在控制台打印脚本加载信息和当前生效的各项配置值
  1526. console.log(`脚本 ${GM_info.script.name} 已加载,版本 ${GM_info.script.version}。下面是当前配置信息:`);
  1527. console.log(` 每轮基础延迟(ms):${currentScriptConfig.delayBase}`);
  1528. console.log(` 每轮随机延迟范围(ms):${currentScriptConfig.delayRandom}`);
  1529. console.log(` 每轮最小请求楼层数:${currentScriptConfig.minFloor}`);
  1530. console.log(` 每轮最大请求楼层数:${currentScriptConfig.maxFloor}`);
  1531. console.log(` 每篇帖子最小阅读时间(ms):${currentScriptConfig.minPostReadTime}`);
  1532. console.log(` 每篇帖子最大阅读时间(ms):${currentScriptConfig.maxPostReadTime}`);
  1533. console.log(` 每条评论最小阅读时间(ms):${currentScriptConfig.minCommentReadTime}`);
  1534. console.log(` 每条评论最大阅读时间(ms):${currentScriptConfig.maxCommentReadTime}`);
  1535. console.log(` 失败后额外重试次数:${currentScriptConfig.maxRetriesPerBatch} (总尝试次数为 1 + 重试次数)`);
  1536. console.log(` 网络请求超时(ms):${currentScriptConfig.requestTimeout}`);
  1537. console.log(` 批量阅读起始帖子ID${currentScriptConfig.bulkReadStartTopicId}`);
  1538. console.log(` 批量阅读读取方向:${currentScriptConfig.bulkReadDirection === 'forward' ? '正序 (ID递增)' : '倒序 (ID递减)'}`);
  1539. console.log("---"); // 日志分隔符,使配置信息与后续操作日志分开
  1540.  
  1541. // 步骤 3: 注入脚本 UI (设置面板等) 所需的 CSS 样式
  1542. UIManager.injectStyles();
  1543.  
  1544. // 步骤 4: 在页面上创建并插入用于打开设置面板的按钮
  1545. UIManager.insertSettingsButton();
  1546.  
  1547. // 步骤 5: 检查当前是否处于一个帖子详情页面,并据此决定是否自动开始标记
  1548. if (isTopicPage()) { // 判断当前页面是否为帖子详情页
  1549. const topicId = extractTopicIdFromUrl(); // 尝试从 URL 中提取帖子 ID
  1550. if (topicId) { // 如果成功提取到帖子 ID
  1551. // 如果“批量阅读”功能当前正在后台运行,则不应自动处理当前页面的帖子,以避免冲突或混乱。
  1552. if (isBulkReadingSessionActive) {
  1553. console.log("操作提示:“批量阅读”任务当前正在后台运行,脚本将暂时不自动标记当前打开的帖子页面,以避免冲突。");
  1554. } else {
  1555. // 如果“批量阅读”未运行,则开始处理当前页面的帖子
  1556. console.log("页面检测:检测到已进入帖子详情页面。"); // 明确指出进入了详情页
  1557. // `processSingleTopic` 函数内部会在开始处理时打印更详细的帖子信息(如总楼层数)
  1558. // 此处不再重复打印 "当前帖子 ID 为..."
  1559. processSingleTopic(topicId, false); // 调用核心处理函数,`isBulkMode` 参数为 `false` 表示非批量模式
  1560. }
  1561. } else {
  1562. // 虽然 `isTopicPage` 判断为真,但未能成功提取到帖子 ID,这通常不应发生,但作为健壮性考虑,打印警告。
  1563. console.warn("逻辑警告:当前页面被识别为帖子详情页,但未能从 URL 中成功提取帖子 ID。自动标记功能可能因此无法针对此页面启动。");
  1564. }
  1565. } else {
  1566. // 如果当前页面不是帖子详情页,则脚本不执行自动标记操作,仅提供设置入口。
  1567. console.log("页面检测:当前页面非帖子详情页,脚本不自动执行标记操作。您可以通过设置按钮进行配置或启动批量阅读。");
  1568. }
  1569. }
  1570.  
  1571. // 监听浏览器窗口或标签页即将被关闭或刷新的事件 (`beforeunload`)
  1572. // 这提供了一个机会,在用户离开页面前执行一些清理操作或给出提示。
  1573. window.addEventListener('beforeunload', () => {
  1574. // 如果“批量阅读”功能正在运行中,当用户尝试关闭页面时,
  1575. // 打印一条提示信息,告知用户其进度(即下一个要处理的帖子ID)通常已在每次处理帖子前被保存。
  1576. // 这是为了让用户放心,即使意外关闭页面,下次启动时通常也能从中断的地方继续。
  1577. if (isBulkReadingSessionActive) {
  1578. // `currentScriptConfig.bulkReadStartTopicId` 会在 `startBulkReadingSession` 循环中实时更新并保存到 LocalStorage。
  1579. console.log("操作提示:页面即将关闭或刷新。如果“批量阅读”功能正在运行,其进度(下一个待处理帖子ID)已在处理每个帖子前自动保存。");
  1580. }
  1581. });
  1582.  
  1583. // =================================================================================
  1584. // 脚本启动执行点 (Script Execution Start Point)
  1585. // =================================================================================
  1586. initializeScript(); // 调用初始化函数,启动脚本的全部功能
  1587.  
  1588. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址