// ==UserScript== // @name 粉笔错题评论区 // @namespace http://tampermonkey.net/ // @version 4.0 // @description 优化版粉笔错题-兼容多选题,支持背题页面自动添加评论区 // @author Mythical creature // @match https://*.fenbi.com/* // @connect tiku.fenbi.com // @connect ke.fenbi.com // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @run-at document-idle // ==/UserScript== class FenbiCommentTool { constructor() { // 状态管理 this.state = { episodesMap: [], // 存储/api/gwy/v3/episodes/tiku_episodes_with_multi_type的数据 questionIds: [], // 存储/api/xingce/questionIds数据 lastAnalysisInserted: null, // 避免重复插入评论区 allowCheckAnalysis: false, // 是否允许检查解析 hasSubmitButton: null, // 是否存在提交按钮(初始为null,未检查状态) isRecitationPageChecked: false, // 标记背诵背诵是否是否已检查过背诵页面 lastUrl: null // 初始化URL为null,避免首次与undefined对比 }; // 选择器常量 this.selectors = { OPTION_LI_SELECTOR: 'li', QUESTION_ROOT_SELECTOR: 'fb-local-question, fb-recitatation-question', ANALYSIS_ITEM_SELECTOR: 'fb-ng-solution-detail-item[name="解析"]', RADIO_INPUT_SELECTOR: 'input[type="radio"]', SUBMIT_BUTTON_SELECTOR: '.multi-submit', VIDEO_ITEM_SELECTOR: 'li.video-item', OPTIONS_TITLE_SELECTOR: '.options-title', SOLUTION_DETAIL_CONTENT: 'fb-ng-solution-detail-content' }; // 配置参数 this.config = { DUPLICATE_CLICK_THRESHOLD: 800, // 重复点击阈值(ms) ANALYSIS_CHECK_DELAY: 200, // 解析检查延迟(ms) COMMENTS_LOADING_TIMEOUT: 15000, // 评论加载超时(ms) MAX_COMMENTS_DISPLAYED: 10, // 最大显示评论数 MIN_COMMENT_LENGTH: 2, // 最小评论长度 MIN_LIKE_COUNT: 1, // 最小点赞数 RECITATION_PAGE_URL: 'https://www.fenbi.com/spa/tiku/exam/recitation' // 背诵页面URL }; // 绑定方法上下文 this.handleOptionClick = this.handleOptionClick.bind(this); this.handleClick = this.handleClick.bind(this); this.hookRequest = this.hookRequest.bind(this); this.checkAnalysis = this.checkAnalysis.bind(this); this.checkSubmitButtonExists = this.checkSubmitButtonExists.bind(this); this.checkRecitationPage = this.checkRecitationPage.bind(this); this.log = this.log.bind(this); // 初始化URL变化监听 this.initUrlChangeDetection(); // 初始化 this.init(); } /** * 初始化URL变化检测 * 修复Illegal invocation错误,确保原生方法上下文正确 */ initUrlChangeDetection() { // 防抖计时器 let urlChangeTimer = null; const self = this; // 保存当前实例的引用 // 处理URL变化的核心函数 const handleUrlChangeCore = () => { const currentUrl = window.location.href; // 仅当URL确实发生变化时才执行操作 if (currentUrl !== self.state.lastUrl) { this.log(`🔍 URL变化: ${self.state.lastUrl} → ${currentUrl}`); self.state.lastUrl = currentUrl; // 只有当URL切换到背诵页面时才检查(减少不必要调用) const isRecitationNow = currentUrl.startsWith(self.config.RECITATION_PAGE_URL); const wasRecitationBefore = self.state.lastUrl ? self.state.lastUrl.startsWith(self.config.RECITATION_PAGE_URL) : false; // 仅在"非背诵 → 是背诵"时才触发检查(避免同页面刷新重复触发) if (isRecitationNow && !wasRecitationBefore) { self.checkRecitationPage(); } // 重置部分状态,避免页面切换后状态残留 self.state.lastAnalysisInserted = null; self.state.hasSubmitButton = null; self.state.allowCheckAnalysis = false; } }; // 重写history.pushState方法(使用普通函数保留原生this) const originalPushState = history.pushState; history.pushState = function (...args) { // 用原生history对象作为上下文调用原始方法 const result = originalPushState.apply(this, args); // 防抖处理,使用保存的self引用访问类实例 clearTimeout(urlChangeTimer); urlChangeTimer = setTimeout(handleUrlChangeCore, self.config.URL_CHANGE_DELAY); return result; // 保持原生方法的返回值 }; // 重写history.replaceState方法(同上) const originalReplaceState = history.replaceState; history.replaceState = function (...args) { // 用原生history对象作为上下文调用原始方法 const result = originalReplaceState.apply(this, args); // 防抖处理 clearTimeout(urlChangeTimer); urlChangeTimer = setTimeout(handleUrlChangeCore, self.config.URL_CHANGE_DELAY); return result; // 保持原生方法的返回值 }; // 监听popstate事件(处理浏览器前进/后退按钮) window.addEventListener('popstate', () => { clearTimeout(urlChangeTimer); urlChangeTimer = setTimeout(handleUrlChangeCore, self.config.URL_CHANGE_DELAY); }); this.log('✅ URL变化检测器已初始化'); } /** * 初始化函数,设置事件监听和钩子 */ init() { try { document.addEventListener('click', this.handleClick, { capture: true, passive: true // 提升滚动性能 }); this.hookRequest(); this.injectStyles(); // 集中注入样式 // 初始检查是否在背诵页面(移到最后,确保状态初始化完成) setTimeout(() => { this.checkRecitationPage(); }, 0); // 用微任务延迟,避免与URL变化检测竞争 this.log('已启动'); } catch (error) { this.log('❌ 初始化失败:', 'error', error); } } /** * 打印日志,方便定位问题 * @param {string} message - 日志消息 * @param {string} [type='log'] - 日志类型(error/warn/log) * @param {any} [data] - 可选的附加数据 */ log(message, type = 'log', data) { const prefix = '[错题评论区工具]'; const args = [`${prefix} ${message}`]; // 只有data存在时才添加到参数列表 if (data != null) args.push(data); // 根据类型调用对应console方法,动态传递参数 console[type](...args); } /** * 检查当前是否在背诵页面,如果是则为已有解析的题目添加评论区 */ checkRecitationPage() { // 检查当前URL是否匹配背诵页面 if (!window.location.href.startsWith(this.config.RECITATION_PAGE_URL)) { // 不是背诵页面时重置标记,确保下次进入能正常执行 this.state.isRecitationPageChecked = false; return; } // 核心优化:如果已经检查过,直接返回(避免重复执行) if (this.state.isRecitationPageChecked) { this.log('📌 已检查过背诵页面,跳过重复执行'); return; } // 标记为已检查(在执行核心逻辑前就标记,防止异步过程中重复触发) this.state.isRecitationPageChecked = true; // 标记是否已执行过扫描,避免重复执行 let hasScanned = false; // 核心扫描逻辑(封装为函数) const executeScan = () => { if (hasScanned) return; // 避免重复执行 this.log('📌 页面所有资源加载完成,开始扫描题目'); this.scanAndAddCommentsToExistingAnalyses(); hasScanned = true; }; // 检查是否有动态加载的内容(如异步渲染的题目) const observeDynamicContent = () => { // 监听题目容器的变化(针对异步加载的内容) const questionContainer = document.body; // 可根据实际DOM结构优化选择器 if (!questionContainer) return; // 观察容器内的DOM变化(子元素增减、属性变化) const dynamicObserver = new MutationObserver((mutations) => { // 当检测到题目相关元素被添加时,尝试扫描 const hasQuestionElements = document.querySelector(this.selectors.QUESTION_ROOT_SELECTOR) !== null; if (hasQuestionElements) { executeScan(); dynamicObserver.disconnect(); // 扫描后停止观察,避免性能消耗 } }); // 配置观察选项:监听子元素变化和属性变化 dynamicObserver.observe(questionContainer, { childList: true, subtree: true, attributes: false, characterData: false }); // 超时保护:如果10秒内未检测到动态内容,强制执行一次扫描 setTimeout(() => { dynamicObserver.disconnect(); if (!hasScanned) { this.log('⏰ 动态内容加载超时,执行强制扫描'); executeScan(); } }, 10000); }; // 1. 先等待window.load事件(确保所有资源加载完成) if (document.readyState === 'complete') { observeDynamicContent(); } else { // 关键:使用once: true确保load事件只触发一次 window.addEventListener('load', () => { this.log('✅ 页面所有资源加载完成'); observeDynamicContent(); }, { once: true }); } } /** * 扫描页面上已有解析的题目并添加评论区 */ scanAndAddCommentsToExistingAnalyses() { try { // 获取所有题目根元素 const questionRoots = document.querySelectorAll(this.selectors.QUESTION_ROOT_SELECTOR); if (questionRoots.length === 0) { this.log('⚠️ 未找到题目元素'); return; } this.log(`📌 找到 ${questionRoots.length} 个题目,开始检查解析`); questionRoots.forEach((questionRoot, index) => { // 检查视频元素是否显示 const videoElement = questionRoot.querySelector(this.selectors.VIDEO_ITEM_SELECTOR); if (!videoElement) return; // 检查是否已有解析 const analysisItem = questionRoot.querySelector(this.selectors.ANALYSIS_ITEM_SELECTOR); if (analysisItem) { // 检查是否已经添加过评论区,避免重复添加 const existingComment = questionRoot.querySelector('[data-custom-comments="true"]'); if (!existingComment) { this.log(`📝 为第 ${index + 1} 题添加评论区`); this.createAndInsertCommentSection(analysisItem, index); } } }); } catch (error) { this.log('❌ 扫描并添加评论区失败:', 'error', error); } } /** * 检查提交按钮是否存在(仅在需要时调用) */ checkSubmitButtonExists() { // 如果已经检查过,直接返回缓存结果 if (this.state.hasSubmitButton !== null) { return this.state.hasSubmitButton; } try { this.state.hasSubmitButton = document.querySelector(this.selectors.SUBMIT_BUTTON_SELECTOR) !== null; this.log(`📌 检测到"提交答案"按钮: ${this.state.hasSubmitButton ? '存在' : '不存在'}`); // 如果不存在提交按钮,直接允许检查解析 if (!this.state.hasSubmitButton) { this.state.allowCheckAnalysis = true; } return this.state.hasSubmitButton; } catch (error) { this.log('❌ 检查提交按钮失败:', 'error', error); this.state.hasSubmitButton = false; this.state.allowCheckAnalysis = true; return false; } } /** * 统一处理点击事件 * @param {Event} event - 点击事件对象 */ handleClick(event) { const target = event.target; // 检查是否点击了提交按钮 const submitButton = target.closest(this.selectors.SUBMIT_BUTTON_SELECTOR); if (submitButton) { this.log('✅ 检测到"提交答案"按钮被点击'); this.state.allowCheckAnalysis = true; return; } // 处理选项点击 this.handleOptionClick(event); } /** * 处理选项点击事件 * @param {Event} event - 点击事件对象 */ handleOptionClick(event) { const target = event.target; const optionLi = target.closest(this.selectors.OPTION_LI_SELECTOR); if (!optionLi) return; // 【修改】加脚本专属前缀,避免属性污染 const lastClickKey = '__commentTool_lastClick'; const now = Date.now(); // 用自定义key访问属性 if (optionLi[lastClickKey] && now - optionLi[lastClickKey] < this.config.DUPLICATE_CLICK_THRESHOLD) { return; } optionLi[lastClickKey] = now; // 存储时也用自定义key // 获取题目根元素 const questionRoot = optionLi.closest(this.selectors.QUESTION_ROOT_SELECTOR); if (!questionRoot) { this.log('⚠️ 未找到最外层题目容器 '); return; } // 提取选项信息 const { letter, optionText } = this.extractOptionInfo(optionLi); // 提取题目内容 const questionContent = this.extractQuestionContent(questionRoot); // 输出日志 this.logQuestionInfo(questionContent, letter, optionText); // 点击选项时才检查是否存在提交按钮 const hasSubmitBtn = this.checkSubmitButtonExists(); // 根据检查结果决定是否检查解析 if (!hasSubmitBtn || this.state.allowCheckAnalysis) { setTimeout(() => this.checkAnalysis(questionRoot), this.config.ANALYSIS_CHECK_DELAY); } } /** * 提取选项信息 * @param {HTMLElement} optionLi - 选项元素 * @returns {Object} 包含选项字母和文本的对象 */ extractOptionInfo(optionLi) { const letterEl = optionLi.querySelector('p.fb-radioInput span') || optionLi.querySelector('span.option-letter'); const letter = letterEl?.textContent.trim() || '?'; const optionTextEl = optionLi.querySelector('p.options-material') || optionLi.querySelector('p.option-content'); const optionText = optionTextEl?.textContent.trim() || '无内容'; return { letter, optionText }; } /** * 提取题目内容 * @param {HTMLElement} questionRoot - 题目根元素 * @returns {string} 题目内容文本 */ extractQuestionContent(questionRoot) { const questionContentEl = questionRoot.querySelector('.question-content'); return questionContentEl?.textContent.trim() || '无题目内容'; } /** * 输出题目信息到控制台 * @param {string} questionContent - 题目内容 * @param {string} letter - 选项字母 * @param {string} optionText - 选项文本 */ logQuestionInfo(questionContent, letter, optionText) { const truncateLength = 60; const truncatedContent = questionContent.length > truncateLength ? `${questionContent.substring(0, truncateLength)}...` : questionContent; this.log(`📋 题目: ${truncatedContent}`); this.log(`🔤 选项 ${letter}: ${optionText}`); } hookRequest() { // 【关键】保存类实例引用(闭包捕获,不影响XHR的this) const self = this; const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url) { this._requestMethod = method; this._requestUrl = url; return originalOpen.apply(this, arguments); // this → XHR实例 }; XMLHttpRequest.prototype.send = function (body) { const url = this._requestUrl; const that = this; // 保存XHR实例引用 const TARGET_PATHS = new Set([ '/api/xingce/exercises', '/api/xingce/questionIds', '/api/gwy/v3/episodes/tiku_episodes_with_multi_type' ]); if (url && [...TARGET_PATHS].some(path => url.includes(path))) { self.log(`🎯 拦截到目标请求 URL: ${url}`); this.addEventListener('readystatechange', () => { if (that.readyState === 4 && that.status >= 200 && that.status < 300) { try { const responseText = that.responseText; const data = JSON.parse(responseText); if (url.includes('/api/xingce/exercises') && data) { const usefulData = data.sheet?.questionIds; if (Array.isArray(usefulData) && usefulData.every(item => !isNaN(Number(item)))) { // 满足“是数组,且所有元素都是数字/可转数字的字符串”时,执行这里的逻辑 self.state.questionIds = usefulData; } } if (url.includes('/api/xingce/questionIds') && data) { // 1. 提取有效数据 data.data 并检查(需为对象) const usefulData = data.results; if (Array.isArray(usefulData) && usefulData.every(item => !isNaN(Number(item)))) { // 满足“是数组,且所有元素都是数字/可转数字的字符串”时,执行这里的逻辑 self.state.questionIds = usefulData; } } if (url.includes('/api/gwy/v3/episodes/tiku_episodes_with_multi_type') && data) { // 1. 提取有效数据 data.data 并检查(需为对象) const usefulData = data.data; if (usefulData && typeof usefulData === 'object') { // 2. 遍历 usefulData 的所有顶层键,直接合并到 episodesMap Object.keys(usefulData).forEach(key => { self.state.episodesMap[key] = usefulData[key]; // 直接用 usefulData 的键存储 }); } } } catch (e) { self.log('❌ 解析返回内容失败:', 'error', e); self.log('错误响应文本:', 'error', that.responseText); } } }); } // 【关键】this → XHR实例,确保原生send调用上下文正确 return originalSend.apply(this, arguments); }; } /** * HTTP请求封装 * @param {Object} options - 请求选项 * @returns {Promise} 返回Promise对象 */ httpRequest(options) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error('请求超时')); }, this.config.COMMENTS_LOADING_TIMEOUT); GM_xmlhttpRequest({ ...options, onload: (response) => { clearTimeout(timeoutId); if (response.status >= 200 && response.status < 300) { resolve(response.responseText); } else { reject(new Error(`HTTP错误: ${response.status}`)); } }, onerror: (error) => { clearTimeout(timeoutId); reject(error); }, onabort: () => { clearTimeout(timeoutId); reject(new Error('请求被中止')); } }); }); } /** * 切换评论区展开/收起状态 * @param {string} questionIndex - 问题索引 * @param {HTMLElement} contentContainer - 内容容器 */ async toggleComments(questionIndex, contentContainer) { if (questionIndex < 0) { return; } // 显示加载状态 contentContainer.innerHTML = '
加载中...
'; try { // 获取评论所需ID const questionId = this.state.questionIds[questionIndex]; if (!questionId) { throw new Error(`未找到索引为 ${questionIndex} 的题目ID`); } // 轮询检查:当episodesMap是空数组时,每200ms检查一次,直到非空 if (Array.isArray(this.state.episodesMap) && this.state.episodesMap.length === 0) { this.log("⏳ episodesMap为空,开始轮询检查(每200ms一次)"); // 设置最大轮询次数(避免无限等待,可根据需求调整) const maxPollCount = 15; // 15*200ms=3秒 let pollCount = 0; // 轮询逻辑 while (Array.isArray(this.state.episodesMap) && this.state.episodesMap.length === 0) { // 检查是否超时 if (pollCount >= maxPollCount) { throw new Error("轮询超时,仍未获取到数据"); } // 等待200ms await new Promise(resolve => setTimeout(resolve, 200)); pollCount++; this.log(`🔍 第${pollCount}次轮询:仍为空数组,继续等待...`); } // 跳出循环说明已不满足空数组条件 this.log("✅ 轮询结束:episodesMap已存在数据"); } // 获取最新数据 const value = this.state.episodesMap[questionId] ?? {}; const episodeId = value[0]?.id ?? 0; if (episodeId === 0) { //throw new Error("未获取到有效的评论关联ID"); contentContainer.innerHTML = '
此题无评论
'; return; } // 获取并渲染评论 const comments = await this.getComments(episodeId); this.renderComments(contentContainer, comments); } catch (error) { contentContainer.innerHTML = `
${error.message || '加载评论失败'}
`; this.log('加载评论失败:', 'error', error); } } /** * 获取题目评论 * @param {string} episodeId - 评论所需前置数据ID * @returns {Array} 评论列表 */ async getComments(episodeId) { try { if (episodeId === 0) { return []; } const response = await this.httpRequest({ url: `https://ke.fenbi.com/ipad/gwy/v3/comments/episodes/${episodeId}?system=12.4.7&inhouse=0&app=gwy&ua=iPad&av=44&version=6.11.3&kav=22&kav=1&len=600`, method: "GET", headers: { 'Cookie': document.cookie, 'Referer': `https://tiku.fenbi.com/xingce/`, 'User-Agent': navigator.userAgent } }); return this.processCommentResponse(response); } catch (e) { this.log('获取评论失败:', 'error', e); return []; } } /** * 处理评论响应数据 * @param {string} response - 响应文本 * @returns {Array} 处理后的评论列表 */ processCommentResponse(response) { try { const obj = JSON.parse(response); let processedDatas = []; if (Array.isArray(obj.datas)) { processedDatas.push(...obj.datas); } // 过滤、排序和限制评论 const filteredComments = processedDatas.filter(comment => this.isValidComment(comment)); // 如果过滤后没有数据,则使用原数据,否则使用过滤后的数据 const commentsToProcess = filteredComments.length > 0 ? filteredComments : processedDatas; return commentsToProcess .sort((a, b) => b.likeCount - a.likeCount) .slice(0, this.config.MAX_COMMENTS_DISPLAYED); } catch (error) { this.log('JSON解析失败:', 'error', error); return []; } } /** * 检查评论是否有效 * @param {Object} comment - 评论对象 * @returns {boolean} 是否有效 */ isValidComment(comment) { return comment.likeCount > this.config.MIN_LIKE_COUNT && comment.comment.length > this.config.MIN_COMMENT_LENGTH && !['?', '?'].some(char => comment.comment.includes(char)); } /** * 注入样式表 */ injectStyles() { // 检查样式是否已注入 if (document.getElementById('fenbi-question-listener-styles')) { return; } const style = document.createElement('style'); style.id = 'fenbi-question-listener-styles'; style.textContent = ` .comments-container { margin: 15px 0; padding: 10px; } .no-comments { color: #666; padding: 20px; text-align: center; background-color: #f9f9f9; border-radius: 8px; font-size: 14px; } .comments-list { display: flex; flex-direction: column; gap: 12px; } .comment-item { background: #fff; display: flex; align-items: center; justify-content: space-between; padding: 10px 15px; border-radius: 8px; border: 1px solid #eee; transition: all 0.2s ease; } .comment-item:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); } .comment-content { color: #444; line-height: 1.5; margin-bottom: 10px; font-size: 18px; flex: 1; margin-right: 10px; } .comment-like { display: flex; align-items: center; gap: 5px; cursor: pointer; transition: color 0.2s ease; } .comment-like:hover { color: #007bff; } .like-icon .like-count{ font-size: 14px; } .like-icon{ color: #d0d0d0; /* 红色 */ } `; document.head.appendChild(style); } /** * 渲染评论内容 * @param {HTMLElement} container - 容器元素 * @param {Array} comments - 评论列表 */ renderComments(container, comments) { if (!comments || comments.length === 0) { container.innerHTML = '
暂无评论
'; return; } let html = '
'; comments.forEach(comment => { html += `
${comment.comment}
`; }); html += '
'; container.innerHTML = html; } /** * 检查并显示解析内容 * @param {HTMLElement} questionRoot - 题目根元素 */ checkAnalysis(questionRoot) { try { const analysisItem = questionRoot.querySelector(this.selectors.ANALYSIS_ITEM_SELECTOR); if (!analysisItem) { this.log('⚠️ 未找到解析元素 '); return; } // 检查视频元素是否显示 const videoElement = document.querySelector(this.selectors.VIDEO_ITEM_SELECTOR); if (!videoElement) return; // 避免重复插入评论区 if (this.state.lastAnalysisInserted === analysisItem) return; this.state.lastAnalysisInserted = analysisItem; // 获取题目索引 const optionsTitle = questionRoot.querySelector(this.selectors.OPTIONS_TITLE_SELECTOR); const questionIndex = optionsTitle?.textContent.trim().split('.')[0] || 0; // 创建并插入评论区 this.createAndInsertCommentSection(analysisItem, questionIndex - 1); } catch (error) { this.log('❌ 检查解析失败:', 'error', error); } } /** * 创建并插入评论区 * @param {HTMLElement} analysisItem - 解析元素 * @param {number} questionIndex - 题目索引 */ createAndInsertCommentSection(analysisItem, questionIndex) { if (questionIndex < 0) { return; } // 克隆解析元素作为评论区容器 const newSection = analysisItem.cloneNode(true); newSection.setAttribute('name', '评论区'); newSection.setAttribute('data-custom-comments', 'true'); // 标记为自定义评论区 // 修改标题 const h4Element = newSection.querySelector('h4'); if (h4Element) { h4Element.textContent = '评论区'; } // 清空并填充内容 const contentElement = newSection.querySelector(this.selectors.SOLUTION_DETAIL_CONTENT); if (contentElement) { contentElement.innerHTML = ''; this.toggleComments(questionIndex, contentElement); } // 插入到解析区后面 analysisItem.parentNode.insertBefore(newSection, analysisItem.nextSibling); } } (function () { 'use strict'; // 创建应用实例,启动工具 new FenbiCommentTool(); })();