// ==UserScript== // @name 粉笔题库获取评论区 // @namespace http://tampermonkey.net/ // @version 1.5 // @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 // @require https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js // ==/UserScript== GM_addStyle(` .analysis, .parse, .explanation, .video-item, video, .video-container, .specific-unwanted-element, div.video-wrapper, div.member-container.ng-star-inserted { display: none !important; } app-result-common section[id^="section-keypoint-"] { display: none !important; } app-result-common section[id^="section-source-"] { display: none !important; } .floating-ad, .vip-promotion { opacity: 0 !important; height: 0 !important; padding: 0 !important; } app-solution-overall { display: none !important; } `); /** * 主应用类 - FenbiPaperTool * 封装了所有功能和状态管理 */ class FenbiPaperTool { /** * 构造函数 - 初始化状态和绑定方法 */ constructor() { // 状态管理 - 集中存储所有应用状态 this.state = { // 存储是否全部展开状态 expandAll: false, // 存储需要点击展开的按钮 buttonsWithActive: [], // 获取当前页面的问题ID key是新的id,value是旧的id questionIds: {}, // 存储评论所需前置数据 episodeMap: {}, // 获取当前页面的评论合集 comments: {}, // 存储评论区展开状态,key为questionKey commentExpanded: {} } // 设置请求钩子,用于拦截特定请求获取数据 this.hookRequest(); // 绑定方法的this上下文,确保在事件回调中this指向当前实例 this.bindMethods(); // 初始化应用 this.init(); } /** * 绑定方法的this上下文 * 确保所有类方法在作为回调函数时,this仍然指向类实例 */ bindMethods() { this.log = this.log.bind(this); this.logAllSections = this.logAllSections.bind(this); this.hookRequest = this.hookRequest.bind(this); this.pollForValue = this.pollForValue.bind(this); this.processSolutionSection = this.processSolutionSection.bind(this); this.createSvgButton = this.createSvgButton.bind(this); this.renderComments = this.renderComments.bind(this); this.fetchTargetSections = this.fetchTargetSections.bind(this); this.toggleComments = this.toggleComments.bind(this); this.handleExpandAllClick = this.handleExpandAllClick.bind(this); } /** * 初始化应用 * 设置请求钩子,页面加载完成后初始化UI */ init() { // 延迟3秒执行,确保完全加载 setTimeout(() => { // 处理拦截得到的数据 this.pollForValue(); }, 3000); } /** * HTTP请求封装 * @param {Object} options - 请求选项 * @returns {Promise} 返回Promise对象 */ httpRequest(options) { return new Promise((resolve, reject) => { // 使用GM_xmlhttpRequest发送请求,支持跨域 GM_xmlhttpRequest({ ...options, // 请求成功回调 onload: (response) => { if (response.status >= 200 && response.status < 300) { // 成功状态,返回响应内容 resolve(response.responseText); } else { // 错误状态,返回错误信息 reject(new Error(`HTTP错误: ${response.status}`)); } }, // 请求错误回调 onerror: (error) => reject(error), // 请求中止回调 onabort: () => reject(new Error('请求被中止')), // 超时时间15秒 timeout: 15000 }); }); } /** * 钩子请求,拦截特定请求获取数据 */ hookRequest() { // 保存当前实例的引用 const self = this; // 保存原始的open和send方法 const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; // 重写open方法,保存请求URL XMLHttpRequest.prototype.open = function (method, url) { this._requestUrl = url; return originalOpen.apply(this, arguments); }; // 重写send方法,添加事件监听 XMLHttpRequest.prototype.send = function (body) { const url = this._requestUrl; const that = this; // 定义所有需要匹配的前缀 const TARGET_PATH_PREFIXES = ["/episodes/tiku_episodes_with_multi_type", "/combine/exercise/getSolution", "combine/static/solution"]; // 判断是否是目标页面 const isTargetPage = TARGET_PATH_PREFIXES.some(prefix => url && url.indexOf(prefix) !== -1); // 检查是否是目标请求 if (isTargetPage) { const splitUrl = url.split('?')[0] console.log('🎯 拦截到目标请求 URL:', splitUrl); // 监听请求状态变化 this.addEventListener('readystatechange', () => { // 请求完成 if (that.readyState === 4) { // 成功状态 if (that.status >= 200 && that.status < 300) { try { const responseText = that.responseText; const data = JSON.parse(responseText); if (data) { // 根据URL关键词处理不同数据 const handlers = { solution: () => { const resultObj = {}; if (data.solutions) { Object.entries(data.solutions).forEach(([key, { globalId, id, tikuPrefix }]) => { resultObj[globalId] = { globalId, id, tikuPrefix }; }); } self.state.questionIds = resultObj; }, getSolution: () => { const resultObj = {}; if (data.data?.userAnswers) { Object.entries(data.data.userAnswers).forEach(([prop, { key, id, prefix }]) => { resultObj[prop] = { key, id, prefix }; }); } self.state.questionIds = resultObj; }, episodes: () => { self.state.episodeMap = data.data; } }; // 查找并执行对应的处理器 const handlerKey = Object.keys(handlers).find(key => splitUrl.includes(key)); if (handlerKey) { handlers[handlerKey](); } } else { console.error('❌ 解析返回内容失败:', data); } } catch (e) { console.error('❌ 解析返回内容失败:', e); } } else { console.error('❌ 请求失败,状态码:', that.status); } } }); } // 调用原始的send方法 return originalSend.apply(this, arguments); }; } /** * 轮询检查特定页面并获取数据 */ async pollForValue() { if (this.state.episodeMap && this.state.questionIds) { // 定义所有需要匹配的前缀 const TARGET_PATH_PREFIXES = ["/ti/exam/solution",]; // 当前页面路径 const currentPath = window.location.pathname; // 判断是否是目标页面 const isTargetPage = TARGET_PATH_PREFIXES.some(prefix => currentPath.startsWith(prefix)); if (!isTargetPage) { console.log(`❌ 当前页面路径(${currentPath})不是目标页面,跳过请求`); return; } // 检查目标section const checkInterval = setInterval(() => { const targetSections = this.fetchTargetSections(); if (targetSections.length > 0) { clearInterval(checkInterval); // 处理所有找到的section targetSections.forEach(section => { // 获取包含"toggle-btn"类的元素 this.processSolutionSection(section); }); } else { console.log('暂未找到目标section,继续等待...'); } }, 500); // 新增:按钮唯一性校验(先检查是否已存在,避免重复创建) if (!document.getElementById('fenbi-expand-all-btn')) { // 创建按钮元素 const button = document.createElement('button'); button.id = 'fenbi-expand-all-btn'; // 给按钮加唯一ID,用于校验 button.textContent = this.state.expandAll ? '全部收起' : '全部展开'; // 按状态显示文本 // 设置按钮样式,使其固定在右上角 button.style.position = 'fixed'; button.style.top = '50px'; button.style.right = '50px'; button.style.zIndex = '9999'; button.style.padding = '8px 16px'; button.style.backgroundColor = '#4CAF50'; button.style.color = 'white'; button.style.border = 'none'; button.style.borderRadius = '4px'; button.style.cursor = 'pointer'; button.style.fontSize = '14px'; button.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'; button.style.transition = 'background-color 0.2s'; // 加过渡效果,更流畅 // 关键修改:绑定类实例的点击事件(而非匿名函数) button.addEventListener('click', this.handleExpandAllClick); // 将按钮添加到页面中 document.body.appendChild(button); } else { // 若按钮已存在,更新按钮文本(同步状态) const existingBtn = document.getElementById('fenbi-expand-all-btn'); existingBtn.textContent = this.state.expandAll ? '全部收起' : '全部展开'; } } } /** * 延迟函数 * @param {number} ms - 延迟毫秒数 * @returns {Promise} 返回Promise对象 */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // 打印调试信息 log(message) { console.log(`[解析复制脚本]: ${message}`); } // 查找所有匹配的section,用于调试 logAllSections() { const allSections = document.querySelectorAll('section.result-common-section'); this.log(`页面中找到${allSections.length}个result-common-section:`); } // 创建SVG样式按钮,添加展开/收起状态 createSvgButton(expanded = false) { // 创建按钮容器,使用更合理的尺寸 const svgContainer = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svgContainer.setAttribute("width", "40"); svgContainer.setAttribute("height", "40"); svgContainer.setAttribute("viewBox", "0 0 40 40"); svgContainer.style.cursor = "pointer"; // 添加鼠标指针效果 // 添加点击区域 const clickArea = document.createElementNS("http://www.w3.org/2000/svg", "rect"); clickArea.setAttribute("width", "40"); clickArea.setAttribute("height", "40"); clickArea.setAttribute("fill", "transparent"); svgContainer.appendChild(clickArea); // 创建背景(使用简单的矩形代替复杂路径,更可靠) const bgRect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); bgRect.setAttribute("x", "2"); bgRect.setAttribute("y", "2"); bgRect.setAttribute("width", "36"); bgRect.setAttribute("height", "36"); bgRect.setAttribute("rx", "6"); // 圆角 bgRect.setAttribute("ry", "6"); bgRect.setAttribute("class", "toggle-btn-bg-1"); bgRect.style.fill = "#ffffffff"; bgRect.style.stroke = "#0056b3"; bgRect.style.strokeWidth = "1"; // 创建箭头(调整坐标使其居中显示) const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); // 重新计算箭头路径,使其在40x40的视图中居中显示 const d = expanded ? "M12 22l8-8 8 8" // 向上箭头 : "M12 18l8 8 8-8"; // 向下箭头 arrowPath.setAttribute("d", d); arrowPath.setAttribute("stroke", "black"); // 白色箭头在蓝色背景上更明显 arrowPath.setAttribute("stroke-width", "2"); arrowPath.setAttribute("stroke-linecap", "round"); arrowPath.setAttribute("stroke-linejoin", "round"); arrowPath.setAttribute("fill", "none"); arrowPath.setAttribute("class", "toggle-btn-arrow toggle-btn-bg-2"); // 组合元素 svgContainer.appendChild(bgRect); svgContainer.appendChild(arrowPath); // 添加点击切换功能 svgContainer.addEventListener('click', () => { const newState = !expanded; const newD = newState ? "M12 18l8 8 8-8" : "M12 22l8-8 8 8"; arrowPath.setAttribute("d", newD); // 可以在这里添加其他状态变化逻辑 }); return svgContainer; } // 处理单个解析节点 processSolutionSection(solutionSection) { const sectionId = solutionSection.id; const questionKey = sectionId.split('-')[2]; // 获取所有id名以 "section-video-" 开头的元素 const videoElements = solutionSection.parentNode.querySelector(`[id="section-video-${questionKey}"]`); if (!videoElements) { return; } // 初始化展开状态为false this.state.commentExpanded[questionKey] = false; // 复制原始section const newSection = solutionSection.cloneNode(true); newSection.id = `section-comments-${questionKey}`; // 修改标题 const titleElement = newSection.querySelector('.solution-title'); if (titleElement) { titleElement.textContent = '评论区'; titleElement.parentNode.style.display = 'flex'; titleElement.style.flex = 1; // 创建按钮容器 const buttonContainer = document.createElement('div'); buttonContainer.style.cursor = 'pointer'; buttonContainer.title = '展开评论'; buttonContainer.dataset.questionKey = questionKey; // 添加SVG按钮 const svgButton = this.createSvgButton(false); buttonContainer.appendChild(svgButton); // 添加点击事件,使用新的toggle方法 buttonContainer.addEventListener('click', (e) => { const contentContainer = newSection.querySelector('.content'); this.toggleComments(questionKey, contentContainer, buttonContainer); }); titleElement.parentNode.insertBefore(buttonContainer, titleElement.nextSibling); } // 清空并重新构建内容容器 const contentContainer = newSection.querySelector('.content'); if (contentContainer) { // 初始状态为空 contentContainer.innerHTML = ''; } // 插入新节点到原节点后面 solutionSection.parentNode.insertBefore(newSection, solutionSection.nextSibling); } /** * 新增:全部展开按钮点击事件处理 * 切换expandAll状态,并重新处理所有section(触发展开/收起) */ handleExpandAllClick() { // 切换全部展开状态(true ↔ false) this.state.expandAll = !this.state.expandAll; console.log(`全部展开状态切换为: ${this.state.expandAll ? '展开' : '收起'}`); // 重新获取所有目标section,处理展开/收起 const targetSections = this.fetchTargetSections(); if (targetSections.length > 0) { targetSections.forEach(section => { const questionKey = section.id.split('-')[2]; // 查找当前带有active类的按钮 let currentActiveBtn = section.parentNode.querySelector('.toggle-btn-active'); if (currentActiveBtn) { // 检查是否已记录,避免重复存储 if (!this.state.buttonsWithActive.includes(currentActiveBtn)) { this.state.buttonsWithActive[questionKey] = currentActiveBtn; // 保存DOM引用 } } else { // 检查是否已记录,直接获取 if (this.state.buttonsWithActive[questionKey]) { currentActiveBtn = this.state.buttonsWithActive[questionKey]; // 获取DOM引用 } } if (currentActiveBtn) { currentActiveBtn.click(); // 点击按钮 } // 额外处理:如果已创建评论区,直接触发其展开/收起按钮 const commentSection = document.getElementById(`section-comments-${questionKey}`); if (commentSection) { const toggleBtn = commentSection.querySelector('.solution-title + div'); // 评论区展开按钮 if (toggleBtn) { toggleBtn.click(); // 全部展开时,触发评论区展开 } } }); } // 更新右上角按钮文字 const expandAllBtn = document.getElementById('fenbi-expand-all-btn'); if (expandAllBtn) { expandAllBtn.textContent = this.state.expandAll ? '全部收起' : '全部展开'; } } /** * 切换评论区展开/收起状态 * @param {string} questionKey - 问题标识 * @param {HTMLElement} contentContainer - 内容容器 * @param {HTMLElement} buttonContainer - 按钮容器 */ async toggleComments(questionKey, contentContainer, buttonContainer) { // 获取当前展开状态 const isExpanded = this.state.commentExpanded[questionKey]; if (!isExpanded) { // 如果未展开,先加载评论再展开 contentContainer.innerHTML = '