// ==UserScript== // @name 粉笔题库获取评论区 // @namespace http://tampermonkey.net/ // @version 2.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 // @require https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js // ==/UserScript== GM_addStyle(` .analysis, .parse, .explanation, .specific-unwanted-element, div.member-container.ng-star-inserted { display: none !important; } app-result-common section[id^="section-video-"] { 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: [], // 存储评论区展开状态,key为questionKey commentExpanded: {} } // 绑定方法的this上下文,确保在事件回调中this指向当前实例 this.bindMethods(); // 初始化应用 this.init(); } /** * 绑定方法的this上下文 * 确保所有类方法在作为回调函数时,this仍然指向类实例 */ bindMethods() { this.log = this.log.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); this.handleToggleButtonClick = this.handleToggleButtonClick.bind(this); this.hookRequest = this.hookRequest.bind(this); } /** * 初始化应用 * 设置请求钩子,页面加载完成后初始化UI */ init() { // 调用方式 this.hookRequest(); // 添加全局事件监听器监控toggle-btn-active按钮点击 this.setupToggleButtonMonitor(); this.log('已启动'); } /** * 设置监控器来跟踪toggle-btn-active按钮的点击 */ setupToggleButtonMonitor() { // 使用事件委托监控所有.toggle-btn-active按钮的点击 document.addEventListener('click', (e) => { const toggleButton = e.target.closest('.toggle-btn-active'); if (toggleButton) { this.handleToggleButtonClick(toggleButton); } }); } /** * 打印日志,方便定位问题 * @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); } /** * 钩子请求,拦截特定请求获取exerciseId */ hookRequest() { // 避免重复重写XMLHttpRequest方法(防止多次调用导致的逻辑叠加) if (XMLHttpRequest.prototype._isHooked) return; XMLHttpRequest.prototype._isHooked = true; const self = this; // 保存实例引用 const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; // 重写open方法,记录请求方法和URL XMLHttpRequest.prototype.open = function (method, url) { this._requestMethod = method; this._requestUrl = url; return originalOpen.apply(this, arguments); }; // 目标拦截路径(使用Set提高查找效率) const TARGET_PATHS = new Set([ '/combine/static/solution', '/api/gwy/v3/episodes/tiku_episodes_with_multi_type' ]); // 重写send方法,添加请求拦截逻辑 XMLHttpRequest.prototype.send = function (body) { const xhr = this; // 明确指向当前XHR实例 const { _requestUrl: url } = xhr; // 检查是否为目标请求 const isTargetRequest = url && Array.from(TARGET_PATHS).some(path => url.includes(path) ); if (isTargetRequest) { self.log('🎯 拦截到目标请求 URL:', 'log', url); // 使用self调用log,避免上下文问题 // 定义请求完成后的处理函数(单独提取,增强可读性) const handleResponse = () => { if (xhr.readyState !== 4) return; // 非完成状态不处理 // 处理请求成功 if (xhr.status >= 200 && xhr.status < 300) { try { const data = JSON.parse(xhr.responseText); if (!data) { self.log('❌ 响应数据为空', 'log'); return; } // 根据URL特征执行对应处理逻辑 if (url.includes('solution')) { // 处理solution类型数据 const questionIds = {}; if (data.solutions) { Object.entries(data.solutions).forEach(([key, { globalId, id, tikuPrefix }]) => { questionIds[globalId] = { globalId, id, tikuPrefix }; }); } self.state.questionIds = questionIds; } else if (url.includes('episodes')) { // 处理episodes类型数据 self.state.episodeMap = data.data; self.pollForValue(); } } catch (e) { self.log('❌ 解析响应失败', 'error', e); } } else { // 处理请求失败(非2xx状态码) self.log('❌ 请求失败,状态码:', 'error', xhr.status); } }; // 添加响应监听(使用命名函数,方便后续移除) xhr.addEventListener('readystatechange', handleResponse); // 可选:添加请求完成后的清理逻辑(如移除监听) xhr.addEventListener('loadend', () => { xhr.removeEventListener('readystatechange', handleResponse); }); } // 调用原始send方法 return originalSend.apply(xhr, arguments); }; } /** * 处理toggle-btn-active按钮的点击事件 * @param {HTMLElement} button - 被点击的按钮元素 */ handleToggleButtonClick(button) { // 查找最近的问题section以获取questionKey const section = button.parentNode.querySelector('section[id^="section-solution-"]'); if (section) { const questionKey = section.id.split('-')[2]; const commentSection = document.getElementById(`section-comments-${questionKey}`); if (commentSection) { const toggleBtn = commentSection.querySelector('.solution-title + div'); if (toggleBtn && !this.state.commentExpanded[questionKey]) { toggleBtn.click(); // 自动展开评论区 } } } } /** * 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 }); }); } /** * 轮询检查特定页面并获取数据 */ async pollForValue() { // 定义所有需要匹配的前缀 const TARGET_PATH_PREFIXES = ["/ti/exam/solution",]; // 当前页面路径 const currentPath = window.location.pathname; // 判断是否是目标页面 const isTargetPage = TARGET_PATH_PREFIXES.some(prefix => currentPath.startsWith(prefix)); if (!isTargetPage) { this.log(`❌ 当前页面路径(${currentPath})不是目标页面,跳过请求`); return; } const exerciseKey = currentPath.split('/')[4]; // 只有当questionIds是空数组时才请求 if (Array.isArray(this.state.questionIds) && this.state.questionIds.length === 0) { this.state.questionIds = await this.getSolution(exerciseKey); } // 只有当episodeMap是空数组时才请求 if (Array.isArray(this.state.episodeMap) && this.state.episodeMap.length === 0) { this.state.episodeMap = await this.getEpisodesByIds(); } // 检查目标section const checkInterval = setInterval(() => { const targetSections = this.fetchTargetSections(); if (targetSections.length > 0) { clearInterval(checkInterval); // 处理所有找到的section targetSections.forEach(section => { // 获取包含"toggle-btn"类的元素 this.processSolutionSection(section); }); } else { this.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 = '80px'; button.style.right = '20px'; button.style.zIndex = '9999'; button.style.padding = '10px 20px'; 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)); } // 创建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; //this.log(`处理解析section: ${sectionId}`); 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', () => { 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); //this.log(`已添加评论区: ${newSection.id}`); // 查找所有带有toggle-btn类但没有toggle-btn-active类的按钮 const button = solutionSection.parentNode.querySelector('.toggle-btn:not(.toggle-btn-active)'); if (button) { const commentSection = document.getElementById(`section-comments-${questionKey}`); if (commentSection) { const toggleBtn = commentSection.querySelector('.solution-title + div'); if (toggleBtn && !this.state.commentExpanded[questionKey]) { toggleBtn.click(); // 自动展开评论区 } } } } /** * 新增:全部展开按钮点击事件处理 * 切换expandAll状态,并重新处理所有section(触发展开/收起) */ handleExpandAllClick() { // 切换全部展开状态(true ↔ false) this.state.expandAll = !this.state.expandAll; this.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引用 } else { currentActiveBtn = this.state.expandAll == false && section.parentNode.querySelector('.toggle-btn:not(.toggle-btn-active)'); } } if (currentActiveBtn) { currentActiveBtn.click(); // 点击按钮 } // 额外处理:如果已创建评论区,直接触发其展开/收起按钮 const commentSection = document.getElementById(`section-comments-${questionKey}`); if (commentSection && this.state.expandAll != this.state.commentExpanded[questionKey]) { 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 ? '全部收起' : '全部展开'; } } /** * 获取评论前置数据相关 * @returns {Object} 数据 */ async getEpisodesByIds() { // 提取所有id的方法 function extractIds(obj) { // 使用Object.values获取对象的所有值 // 过滤掉空对象 // 提取每个对象的id属性 return Object.values(obj) .filter(item => Object.keys(item).length > 0) // 排除空对象 .map(item => item.id); // 提取id } // 调用函数获取所有id const questionIds = extractIds(this.state.questionIds); try { // 发送请求获取剧集数据 let result = await this.httpRequest({ url: `https://ke.fenbi.com/api/gwy/v3/episodes/tiku_episodes_with_multi_type?tiku_ids=${questionIds.join(',')}&tiku_prefix=xingce&tiku_type=5`, method: "GET", headers: { 'Cookie': document.cookie, 'Referer': 'https://ke.fenbi.com/gwy/', 'User-Agent': navigator.userAgent } }); return JSON.parse(result).data; } catch (error) { this.log(`❌ 获取评论前置数据失败:${error.message}`, 'error'); return {}; } } /** * 获取练习中的题目 * @param {string} exerciseKey - 练习key */ async getSolution(exerciseKey) { try { // 发送请求获取练习详情 const response = await this.httpRequest({ url: `https://tiku.fenbi.com/combine/exercise/getSolution?format=html&key=${exerciseKey}&routecs=xingce&kav=121&av=121&hav=121&app=web`, method: 'GET', headers: { 'Cookie': document.cookie, 'Referer': `https://tiku.fenbi.com/xingce/`, 'User-Agent': navigator.userAgent } }); const data = JSON.parse(response); const resultObj = {}; if (data.data?.userAnswers) { Object.entries(data.data.userAnswers).forEach(([prop, { key, id, prefix }]) => { resultObj[prop] = { key, id, prefix }; }); } return resultObj; } catch (error) { this.log(`❌ 获取题目失败:${error.message}`, 'error'); } } /** * 切换评论区展开/收起状态 * @param {string} questionKey - 问题标识 * @param {HTMLElement} contentContainer - 内容容器 * @param {HTMLElement} buttonContainer - 按钮容器 */ async toggleComments(questionKey, contentContainer, buttonContainer) { // 获取当前展开状态 const isExpanded = this.state.commentExpanded[questionKey]; if (!isExpanded) { // 如果未展开,先加载评论再展开 contentContainer.innerHTML = '
加载中...
'; const questionId = this.state.questionIds[questionKey]?.id; const value = this.state.episodeMap[questionId] ?? {}; let comments = []; try { const commentRawDatas = await this.getComments(value[0]?.id ?? 0); for (const prop in commentRawDatas) { if (Object.hasOwn(commentRawDatas, prop)) { const { comment, likeCount } = commentRawDatas[prop]; comments.push({ comment, likeCount }); } } // 渲染评论 this.renderComments(contentContainer, comments); // 更新状态为展开 this.state.commentExpanded[questionKey] = true; // 更新按钮图标 buttonContainer.innerHTML = ''; buttonContainer.appendChild(this.createSvgButton(true)); buttonContainer.title = '收起评论'; } catch (error) { contentContainer.innerHTML = '
加载评论失败
'; this.log('加载评论失败:', 'error', error); } } else { // 如果已展开,直接收起 contentContainer.innerHTML = ''; // 更新状态为收起 this.state.commentExpanded[questionKey] = false; // 更新按钮图标 buttonContainer.innerHTML = ''; buttonContainer.appendChild(this.createSvgButton(false)); buttonContainer.title = '展开评论'; } } /** * 获取题目评论 * @param {string} episodeId - 评论所需前置数据ID * @returns {Array} 评论列表 */ async getComments(episodeId) { try { if (episodeId === 0) { return []; } const str = 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 } }); let processedDatas = []; // 处理评论数据 try { const obj = JSON.parse(str); if (Array.isArray(obj.datas)) { processedDatas.push(...obj.datas); } } catch (error) { this.log('JSON解析失败:', 'error', error); } const data = processedDatas .filter(i => i.likeCount > 1 && !['?', '?'].some(t => i.comment.includes(t)) && i.comment.length > 8 ) .sort((a, b) => b.likeCount - a.likeCount) // 按点赞数降序排序 .slice(0, 10); // 取前10条数据 return data; } catch (e) { this.log('获取评论失败:', 'error', e); return []; } } // 渲染评论内容(带美观样式) renderComments(container, comments) { // 添加评论区全局样式 const style = document.createElement('style'); 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; } `; document.head.appendChild(style); if (!comments || comments.length === 0) { container.innerHTML = '
暂无评论
'; return; } let html = '
'; comments.forEach(comment => { html += `
${comment.comment}
`; }); html += '
'; container.innerHTML = html; } fetchTargetSections() { // 直接在整个文档中查找所有section元素 const allSections = Array.from(document.querySelectorAll('section')); // 过滤元素:只保留目标section,跳过chapter-container const targetSections = allSections.filter(el => { // 保留:符合条件的section const isTargetSection = el.classList.contains('result-common-section') && el.id.startsWith('section-solution-') && el.classList.contains('ng-star-inserted'); // 排除:chapter-container元素 const isChapterContainer = el.classList.contains('chapter-container'); // 只保留目标section且不是chapter-container return isTargetSection && !isChapterContainer; }); this.log(`找到${targetSections.length}个目标section(已跳过chapter-container)`); return targetSections; } } (function () { 'use strict'; // 创建应用实例,启动工具 new FenbiPaperTool(); })();