// ==UserScript== // @name 粉笔一键折叠解析和答案 // @namespace http://tampermonkey.net/ // @version 2.1.0 // @description 材料分栏题组已展平:材料区整体移除,每 5 题一组的分栏题拆成一道道独立单题(与前面的普通题同宽同距);点击题号折叠/展开题干并把该题顶到页首;点击「解析」折叠/展开解析;悬浮题号显示正确答案与全站正确率;题号按正确率着色;↑/↓ 切题,Enter/右键 开关解析;卷名可点击跳 B 站搜索;隐藏考点/来源/笔记/箭头/题型标识等无关项 // @author You // @match *://spa.fenbi.com/* // @match *://www.fenbi.com/* // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const TIP_ID = 'fenbi-fold-tip'; const BTN_ID = 'fenbi-fold-toggle-btn'; const VERIFY_LOW = 25; // <=25% 紫色 const RED_LIMIT = 80; // <80% 红色 // 每题中"内容区"的几块(题干 / 选项 / 统计信息),点题号统一显示或隐藏 const SEL_BODY = ['app-question-choice', 'app-choice-radio', 'app-solution-overall']; /* ---------- 题目集合 ---------- */ function getItems() { let items = Array.from(document.querySelectorAll('app-ti.question-multiple')); if (!items.length) items = Array.from(document.querySelectorAll('app-ti')); if (!items.length) items = Array.from(document.querySelectorAll('.ti-container')); return items; } /* ---------- 题号集合 ---------- */ function getAllNos() { return Array.from(document.querySelectorAll('app-ti .title-index')); } /* ---------- 数据读取(按标题文本定位,不依赖通用类名) ---------- */ function readOverall(item, key, cls) { const boxes = item.querySelectorAll('.overall-item'); for (let i = 0; i < boxes.length; i++) { const t = boxes[i].querySelector('.overall-item-title'); if (!t || t.textContent.indexOf(key) === -1) continue; const v = boxes[i].querySelector(cls); if (!v) continue; return v.textContent.trim(); } return ''; } function getRate(item) { let t = readOverall(item, '全站正确率', '.correct-rate').replace(/\s+/g, ''); if (t && !/%$/.test(t)) t += '%'; return t || '无数据'; } function getAnswer(item) { return readOverall(item, '正确答案', '.correct-answer') || '无数据'; } function toNum(s) { const m = String(s || '').match(/\d+(\.\d+)?/); return m ? parseFloat(m[0]) : NaN; } /* ---------- 悬浮提示 ---------- */ var _tipAnchor = null; function removeTip() { const t = document.getElementById(TIP_ID); if (t) t.remove(); _tipAnchor = null; } // 把提示定位到锚点元素下方(滚动过程中会被反复调用,实现"跟随") function placeTip() { const tip = document.getElementById(TIP_ID); if (!tip || !_tipAnchor) return; const r = _tipAnchor.getBoundingClientRect(); const tw = tip.offsetWidth; const th = tip.offsetHeight; let left = r.left; let top = r.bottom + 6; if (left + tw > window.innerWidth - 8) left = window.innerWidth - tw - 8; if (top + th > window.innerHeight - 8) top = r.top - th - 6; tip.style.left = Math.max(8, left) + 'px'; tip.style.top = Math.max(8, top) + 'px'; } function showTip(anchor, rate, answer) { removeTip(); const tip = document.createElement('div'); tip.id = TIP_ID; tip.style.cssText = 'position:fixed;z-index:2147483647;background:rgba(30,30,30,.96);' + 'color:#fff;font-size:13px;line-height:1.8;padding:8px 12px;border-radius:6px;' + 'font-family:Menlo,Consolas,monospace;white-space:nowrap;pointer-events:none;' + 'box-shadow:0 4px 16px rgba(0,0,0,.25);'; const n = toNum(rate); const l1 = document.createElement('div'); l1.textContent = '全站正确率:' + rate; l1.style.fontWeight = 'bold'; if (!isNaN(n) && n <= VERIFY_LOW) l1.style.color = '#d8a0ff'; else if (!isNaN(n) && n < RED_LIMIT) l1.style.color = '#ff8080'; tip.appendChild(l1); const l2 = document.createElement('div'); l2.textContent = '正确答案:' + answer; tip.appendChild(l2); document.body.appendChild(tip); _tipAnchor = anchor; placeTip(); } /* ---------- 显隐控制(只改 display,不动 DOM 结构) ---------- */ function bodyEls(item) { return SEL_BODY.map(function (s) { return item.querySelector(s); }).filter(Boolean); } // 解析可能被拆在多个容器里(不同题型结构不一样), // 必须把所有命中的容器都收进来一起控制,只认第一个会漏掉半截 var RES_SELECTORS = ['app-result-common', 'app-solution-content', '.solution-container', 'app-result']; function resEls(item) { var out = []; for (var i = 0; i < RES_SELECTORS.length; i++) { var nodes = item.querySelectorAll(RES_SELECTORS[i]); for (var j = 0; j < nodes.length; j++) { if (out.indexOf(nodes[j]) === -1) out.push(nodes[j]); } } return out; } function resEl(item) { var els = resEls(item); return els.length ? els[0] : null; } function isBodyOpen(item) { const els = bodyEls(item); if (!els.length) return true; return els[0].style.display !== 'none'; } function isResOpen(item) { // 优先用脚本自己记的状态:解析区可能是懒创建的, // 此时 DOM 查不到容器,不能据此判定"已展开" if (typeof item.__fenbiResOpen === 'boolean') return item.__fenbiResOpen; const el = resEl(item); item.__fenbiResOpen = el ? el.style.display !== 'none' : true; return item.__fenbiResOpen; } function setBody(item, open) { bodyEls(item).forEach(function (el) { el.style.display = open ? '' : 'none'; }); hideExtras(item); } function setRes(item, open) { open = !!open; item.__fenbiResOpen = open; // 解析区可能是懒创建的:展开时容器还不存在、或内容是空的, // 先替用户点一下原生的解析开关,把它唤出来 if (open) ensureResRendered(item); // 展开:所有解析容器一起显示;收起:一起隐藏 var els = resEls(item); for (var i = 0; i < els.length; i++) { els[i].style.display = open ? '' : 'none'; } if (item.__fenbiResBtn) { item.__fenbiResBtn.textContent = open ? '解析 ▾' : '解析 ▸'; } if (open) hideExtras(item); } /* ---------- 隐藏无关项:考点 / 来源 / 笔记 / 题型标识 ---------- */ // 需要整块隐藏的栏目(按标题文字匹配,不依赖具体 class) var HIDE_SECTIONS = ['考点', '来源', '笔记', '考察能力', '考察知识点']; // class 里的"独立词"匹配:resource 不会命中 source,避免误伤解析容器 function hasClassToken(el, token) { var cn = el.getAttribute ? (el.getAttribute('class') || '') : ''; if (!cn) return false; var parts = cn.toLowerCase().split(/\s+/); for (var i = 0; i < parts.length; i++) { var p = parts[i]; if (!p) continue; if (p === token) return true; if (p.indexOf(token + '-') === 0 || p.indexOf(token + '_') === 0) return true; if (p.indexOf('-' + token) !== -1 || p.indexOf('_' + token) !== -1) return true; } return false; } // 元素(含其内部)是否装着解析正文——这类块任何情况下都不许隐藏 function isSolutionCore(el) { if (!el) return false; var tag = (el.tagName || '').toUpperCase(); if (tag === 'APP-RESULT-COMMON' || tag === 'APP-SOLUTION-CONTENT' || tag === 'APP-RESULT') return true; if (hasClassToken(el, 'solution-container')) return true; if (!el.querySelector) return false; return !!el.querySelector('app-result-common, app-solution-content, app-result, ' + '.solution-container, .solution-text, .solution-body, .answer-text, ' + '.analysis-content, .solution-content'); } // 往上层扩块时的刹车:父容器里除 keep 之外还有别的实质内容,就别再往上吞 function hasForeignContent(parent, keep) { var kids = parent.children; for (var i = 0; i < kids.length; i++) { var k = kids[i]; if (k === keep) continue; if (k.id === TIP_ID || k.id === BTN_ID) continue; if (isSolutionCore(k)) return true; if ((k.textContent || '').replace(/\s+/g, '').length > 20) return true; } return false; } // 解析可能是懒渲染:展开那一刻容器还不存在或内容为空, // 就替用户点一下原生的解析开关,把它唤出来(每题只自动点一次) function ensureResRendered(item) { var el = resEl(item); if (el && (el.textContent || '').replace(/\s+/g, '').length > 20) return; // 已有内容 if (item.__fenbiNativeTried) return; var btns = item.querySelectorAll('button, [role="button"], a'); for (var i = 0; i < btns.length; i++) { var t = (btns[i].textContent || '').replace(/\s+/g, ''); if (t.indexOf('解析') !== -1 && t.length <= 10) { item.__fenbiNativeTried = true; try { btns[i].click(); } catch (err) { /* 忽略 */ } return; } } } function hideExtras(item) { // 1) 题型标识("单选题"等) var typeNames = item.querySelectorAll('.title-type-name, .questions-single-container'); for (var i = 0; i < typeNames.length; i++) { typeNames[i].style.display = 'none'; } // 2) "添加笔记"输入框 var note = item.querySelector('textarea[placeholder="添加笔记"]'); if (note) note.style.display = 'none'; // 3) 解析标题旁的折叠箭头按钮 var resTitle = item.querySelector('.solution-title-container, app-solution-title'); if (resTitle) { var btns = resTitle.querySelectorAll('button, span[role="button"], [role="button"], [aria-expanded]'); for (var b = 0; b < btns.length; b++) { var btxt = (btns[b].textContent || '').trim(); if (btxt.indexOf('解析') === -1 && btxt.length < 10) { btns[b].style.display = 'none'; } } } // 4) 按标题文字定位「考点 / 来源 / 笔记」三个大项,整块隐藏。 // 只认"标题型"元素(自身文字很短),最多向上扩 2 层; // 一旦上层混入了别的实质内容就立刻停手,绝不碰解析正文所在块。 var cands = item.querySelectorAll('div, span, p, h1, h2, h3, h4, dt, section'); for (var c = 0; c < cands.length; c++) { var el = cands[c]; var txt = (el.textContent || '').replace(/\s+/g, ''); if (!txt || txt.length > 8) continue; var isLabel = false; for (var k = 0; k < HIDE_SECTIONS.length; k++) { if (txt === HIDE_SECTIONS[k]) { isLabel = true; break; } } if (!isLabel) continue; var box = el; for (var up = 0; up < 2; up++) { var p = box.parentElement; if (!p || p === item) break; var pTag = (p.tagName || '').toUpperCase(); if (pTag === 'APP-RESULT-COMMON' || pTag === 'APP-TI') break; if (hasForeignContent(p, box)) break; box = p; } // 最终护栏:只要这块里装着解析正文,一律不动 if (isSolutionCore(box)) continue; box.style.display = 'none'; box.__fenbiHidden = true; } // 5) 按 class 词精确隐藏 来源 / 笔记 / 考点 相关块 // 注意用"独立词"匹配,避免 resource 被 source 误命中(这是解析被吞的元凶) var byClass = item.querySelectorAll('[class]'); for (var s = 0; s < byClass.length; s++) { var e2 = byClass[s]; if (e2.id === TIP_ID || e2.id === BTN_ID) continue; if (isSolutionCore(e2)) continue; // 装着题干/选项的块也不许动 if (e2.querySelector && e2.querySelector( '.solution-choice-container, .choice-item, .question-stem')) continue; // 只认"考点/来源/笔记"的精确 class 词: // 裸 point 会命中 question-point / key-point 之类,把题干整块吞掉 if (hasClassToken(e2, 'source') || hasClassToken(e2, 'note') || hasClassToken(e2, 'exam-point') || hasClassToken(e2, 'knowledge-point') || hasClassToken(e2, 'test-point')) { e2.style.display = 'none'; e2.__fenbiHidden = true; } } // 5b) 删除来源盒子:每道解析下面那一行"2026下半年省考第二十八季行测模考大赛(上海卷)第xxx题" var srcBoxes = item.querySelectorAll('section.result-common-section'); for (var si = 0; si < srcBoxes.length; si++) { var srcBox = srcBoxes[si]; var srcDiv = srcBox.querySelector('.content > app-format-html > div'); if (!srcDiv) continue; var srcText = (srcDiv.textContent || '').replace(/\s+/g, ''); if (srcText.indexOf('省考') !== -1 || srcText.indexOf('模考大赛') !== -1) { srcBox.remove(); } } } function processItem(item) { // Angular 重建过这块 DOM 的话,之前挂在旧节点上的标记会失效, // 表现为"按钮没了 / 点了没反应",所以发现按钮已不在树里就重做一遍 if (item.__fenbiFoldDone && item.__fenbiResBtn && !item.contains(item.__fenbiResBtn)) { item.__fenbiFoldDone = false; item.__fenbiResBtn = null; } if (item.__fenbiFoldDone) return; const noEl = item.querySelector('.title-index'); if (!noEl) return; // 还没渲染出来时先跳过,等下一轮轮询; // 只要有题干区或解析区其一,就认为这一题可以绑定了 // (尾部题目结构可能与前面不同,不能被单一选择器挡住) if (!item.querySelector('.solution-choice-container') && !resEl(item)) return; item.__fenbiFoldDone = true; const rate = getRate(item); const answer = getAnswer(item); const n = toNum(rate); // 题号着色 if (!isNaN(n) && n <= VERIFY_LOW) { noEl.style.color = '#e5e500'; // ≤25% → 黄色 noEl.style.fontWeight = 'bold'; } else if (!isNaN(n) && n < RED_LIMIT) { noEl.style.color = '#d93025'; noEl.style.fontWeight = 'bold'; } // 该题数据挂到题号上,供键盘切换时复用 noEl.__fenbiRate = rate; noEl.__fenbiAnswer = answer; // 题号上的监听器只绑一次:DOM 被重建后重入时不能再叠加, // 否则点一下会翻两次,等于"点了没反应" if (!noEl.__fenbiBound) { noEl.__fenbiBound = true; // 点题号:展开/收起题干、选项、统计信息,并把这道题顶到页面最上方 noEl.style.cursor = 'pointer'; noEl.addEventListener('click', function () { setBody(item, !isBodyOpen(item)); scrollItemToTop(item); }); // 悬浮题号:显示正确率与正确答案 noEl.addEventListener('mouseenter', function () { // 键盘切题的滚动过程中,静止的鼠标会"掠过"题号而误触发本事件, // 必须忽略,否则选中项被拽回去,表现为高亮框上下跳 if (_fenbiByKeyboard) return; // 键盘刚切过题(1.5s 内)同样忽略鼠标:页面滚动会让指针下方的 // 题号不断变化并触发本事件,锚点被抢走后再按 ↓ 就会"隔一题跳" if (Date.now() - _kbLastKeyTime < 1500) return; highlightActive(noEl); _fenbiActiveIndex = getNavNos().indexOf(noEl); showTip(noEl, rate, answer); }); noEl.addEventListener('mouseleave', removeTip); } // 题号右侧的「解析」开关(独立于题号点击) if (item.__fenbiResBtn && item.contains(item.__fenbiResBtn)) { // 重建后按钮还在,只同步文字状态 item.__fenbiResBtn.textContent = isResOpen(item) ? '解析 ▾' : '解析 ▸'; } else { const resBtn = document.createElement('span'); resBtn.textContent = isResOpen(item) ? '解析 ▾' : '解析 ▸'; resBtn.style.cssText = 'margin-left:10px;font-size:12px;color:#0066cc;' + 'cursor:pointer;user-select:none;'; resBtn.addEventListener('click', function (e) { e.stopPropagation(); setRes(item, !isResOpen(item)); }); noEl.insertAdjacentElement('afterend', resBtn); item.__fenbiResBtn = resBtn; } /* ---------- 隐藏无关项:让页面紧凑 ---------- */ hideExtras(item); } /* ---------- 键盘浏览:↑/↓ 切题,Enter / 右键 打开解析 ---------- */ var _fenbiActiveIndex = -1; var _fenbiActiveNo = null; var _fenbiByKeyboard = false; // 键盘切题期间的滚动抑制窗口 var _kbHoverTimer = null; var _kbCorrectTimer = null; // 兜底校正定时器(只保留最新一次) var _kbLastKeyTime = 0; // 上次切题时间,用于判断是否连按 // 高亮"当前题号",让键盘浏览时一眼看到自己在哪道题 function highlightActive(no) { if (_fenbiActiveNo && _fenbiActiveNo !== no) { _fenbiActiveNo.style.outline = ''; _fenbiActiveNo.style.outlineOffset = ''; } _fenbiActiveNo = no; if (no) { no.style.outline = '2px solid #0066cc'; no.style.outlineOffset = '3px'; no.style.borderRadius = '3px'; } } // 题号是否真的能被看到(隐藏的套题组 / 折叠块里的题号不算) function isVisibleNo(no) { if (!no || !no.getClientRects || !no.getClientRects().length) return false; var cs = getComputedStyle(no); if (cs.display === 'none' || cs.visibility === 'hidden') return false; var r = no.getBoundingClientRect(); return r.width > 0 && r.height > 0; } // 参与键盘浏览的题号:按文档顺序,剔除看不见的 function getNavNos() { var all = getAllNos(); var vis = all.filter(isVisibleNo); return vis.length ? vis : all; // 兜底:全被判为隐藏时不做过滤 } // 鼠标最近是否真的动过。键盘操作时鼠标常静止停在某个旧题号上, // 若直接把":hover"当作用户意图,锚点会被那个静止题号抢走, // 表现就是"按了不动"或"隔一个往后跳" var _lastMouseMove = 0; document.addEventListener('mousemove', function () { _lastMouseMove = Date.now(); }, true); function mouseIsFresh() { return (Date.now() - _lastMouseMove) < 1500; } // 当前锚点题号(键盘推进的起点): // 0) 键盘连续浏览期间(1.5s 内刚切过题)完全忽略鼠标:鼠标停在某道题号上 // 不动时,若还让鼠标当锚点,键盘每前进一步都会被拽回鼠标那道题, // 表现就是"按不动"或来回跳 // 1) 鼠标确实指在某题号上、且最近真的动过鼠标、且键盘已停手 → 用鼠标那道 // 2) 脚本上次切到的题号(还在 DOM 里、仍可见、没被滚出视野太远)→ 最稳 // 3) 用户手动滚过页面 → 取视口里最上面那道看得见的题 function anchorNo() { var nos = getNavNos(); if (!nos.length) return null; var kbIdle = (Date.now() - _kbLastKeyTime) > 1500; if (kbIdle && mouseIsFresh()) { for (var i = 0; i < nos.length; i++) { if (nos[i].matches(':hover')) return nos[i]; } } if (_fenbiActiveNo && document.contains(_fenbiActiveNo) && isVisibleNo(_fenbiActiveNo)) { var r = _fenbiActiveNo.getBoundingClientRect(); var vh = window.innerHeight || document.documentElement.clientHeight; if (r.bottom > -vh * 0.5 && r.top < vh * 1.5) return _fenbiActiveNo; } for (var j = 0; j < nos.length; j++) { var rr = nos[j].getBoundingClientRect(); if (rr.bottom > 0 && rr.top < window.innerHeight) return nos[j]; } return nos[0]; } // 按文档顺序取相邻题号;已经在首/尾就返回自己(停住,不回环乱跳) function neighborNo(cur, dir) { var nos = getNavNos(); if (!nos.length || !cur) return null; var i = nos.indexOf(cur); if (i === -1) { // 锚点不在可视集合里(被隐藏或已被回收):按文档位置就近取一个 for (var k = 0; k < nos.length; k++) { if (cur.compareDocumentPosition(nos[k]) & Node.DOCUMENT_POSITION_FOLLOWING) { return dir > 0 ? nos[k] : (k > 0 ? nos[k - 1] : nos[0]); } } return dir > 0 ? nos[nos.length - 1] : nos[0]; } var ni = i + dir; if (ni < 0) return nos[0]; if (ni >= nos.length) return nos[nos.length - 1]; return nos[ni]; } // 顶部固定栏(导航/标题条)的高度,滚动时要给它让位,否则题号会被压在栏下 function getTopOffset() { var off = 0; var cands = document.querySelectorAll( 'header, nav, [class*="navbar"], [class*="header"], [class*="sticky"], [class*="fixed"]'); for (var i = 0; i < cands.length; i++) { var el = cands[i]; if (el.id === TIP_ID || el.id === BTN_ID) continue; var cs = getComputedStyle(el); if (cs.position !== 'fixed' && cs.position !== 'sticky') continue; var r = el.getBoundingClientRect(); if (r.height <= 0 || r.height > 200) continue; if (r.top > 40) continue; // 必须贴在页面顶端 off = Math.max(off, r.bottom); } return off + 8; } // 找出元素外侧最近的"真正能滚动的容器"。 // 粉笔按每 5 题一组,组内往往有独立的滚动区, // 只滚 window 的话那个滚轮根本不动,切题就会"不知道跑哪去了" function getScrollParent(el) { var p = el && el.parentElement; while (p && p !== document.body && p !== document.documentElement) { var oy = getComputedStyle(p).overflowY; if ((oy === 'auto' || oy === 'scroll' || oy === 'overlay') && p.scrollHeight - p.clientHeight > 2) { return p; } p = p.parentElement; } return null; } // 把某道题的整个容器顶到页面最上方(顶部固定栏以下), // 让这道题的题干、选项、解析尽量在一屏里完整露出。 // instant=true 时不做平滑动画,用于兜底校正。 function scrollItemToTop(item, instant) { if (!item) return; // 1) 先把所有内部滚动容器对齐(逐层向外,含题目所在的套题组) var sp = getScrollParent(item); var guard = 0; while (sp && guard++ < 10) { var rect = item.getBoundingClientRect(); var spRect = sp.getBoundingClientRect(); sp.scrollTop = Math.max(0, sp.scrollTop + (rect.top - spRect.top) - 4); sp = getScrollParent(sp.parentElement); } // 2) 再让整页把这道题顶到固定栏下方 var y = item.getBoundingClientRect().top + window.pageYOffset - getTopOffset(); window.scrollTo({ top: Math.max(0, y), behavior: instant ? 'auto' : 'smooth' }); } // 切到指定题号:把该题滚到页面顶部(让整道题连同解析完整露出) // + 高亮题号 + 显示悬浮提示。 // 浏览器不允许脚本移动真实鼠标指针,所以改用"滚动 + 提示跟随 + 高亮", // 保证键盘切题时目标题一定出现在眼前。 function activateNoEl(no) { if (!no) return; var nos = getNavNos(); var i = nos.indexOf(no); if (i >= 0) _fenbiActiveIndex = i; highlightActive(no); showTip(no, no.__fenbiRate || '无数据', no.__fenbiAnswer || '无数据'); // 滚动会让静止的鼠标"掠过"新题号并触发 mouseenter,把选中项拽回去。 // 切题后短暂屏蔽鼠标悬停,等滚动落定再交还控制权。 _fenbiByKeyboard = true; clearTimeout(_kbHoverTimer); _kbHoverTimer = setTimeout(function () { _fenbiByKeyboard = false; }, 900); // 题目容器整块的顶端(不是题号本身),这样题干从顶部开始完整显示 var box = no.closest('app-ti') || no; // 连续按键(700ms 内)不做平滑动画:多段滚动动画互相叠加会让页面 // 停在旧题上,表现就是"按了没反应"或"隔一题跳" var nowTs = Date.now(); var rapid = (nowTs - _kbLastKeyTime) < 700; _kbLastKeyTime = nowTs; scrollItemToTop(box, rapid); // 兜底校正:只保留最后一次切题的定时器。旧定时器若也去滚自己的题目, // 会把刚跳到的题又甩回旧位置,表现为"按不动""隔一题往后跳" clearTimeout(_kbCorrectTimer); _kbCorrectTimer = setTimeout(function () { if (_fenbiActiveNo !== no) return; // 期间又切题了,交给新的校正 var r = no.getBoundingClientRect(); if (r.top < -2 || r.bottom > window.innerHeight + 2) { scrollItemToTop(box, true); var r2 = no.getBoundingClientRect(); if (r2.top < -2 || r2.bottom > window.innerHeight + 2) { try { no.scrollIntoView({ block: 'start', behavior: 'auto' }); } catch (err) { /* 忽略 */ } } } }, 380); } document.addEventListener('keydown', function (e) { var isNext = (e.key === 'ArrowDown' || e.keyCode === 40); var isPrev = (e.key === 'ArrowUp' || e.keyCode === 38); var isOpen = (e.key === 'Enter' || e.keyCode === 13 || e.key === 'ArrowRight' || e.keyCode === 39); if (!isNext && !isPrev && !isOpen) return; var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return; var cur = anchorNo(); if (!cur) return; // 拦住默认行为:避免 Enter 被浏览器当成"再点一次当前聚焦的按钮", // 也避免 ↑/↓ 把页面自己滚走(滚动统一由脚本控制) e.preventDefault(); e.stopPropagation(); if (isNext || isPrev) { // 以"锚点题号"为准按文档顺序走一格:不用序号取题, // 就不会因题目增删 / 可见性变化而错位或跳题 var target = neighborNo(cur, isNext ? 1 : -1); if (target && target !== cur) activateNoEl(target); } else { // Enter / 右键:切换当前这道题的解析(打开或关闭) var item = cur.closest('app-ti'); if (item) setRes(item, !isResOpen(item)); } }, true); /* ---------- 右键:在题号上点右键,直接打开该题解析 ---------- */ document.addEventListener('contextmenu', function (e) { var no = e.target && e.target.closest ? e.target.closest('.title-index') : null; if (!no) return; var item = no.closest('app-ti'); if (!item) return; e.preventDefault(); e.stopPropagation(); highlightActive(no); _fenbiActiveIndex = getNavNos().indexOf(no); _kbLastKeyTime = Date.now(); // 右键后键盘从这道题接着走,别被鼠标抢走 setRes(item, !isResOpen(item)); }, true); // 平滑滚动过程中,提示实时跟随题号位置 window.addEventListener('scroll', placeTip, true); /* ---------- 卷名标题 → 点击跳 B 站搜这套模考 ---------- */ // 在页面顶部找到含「模考」的最内层短标题,绑点击事件去 B 站搜索 // 同时在标题右边追加一个 B站搜索引导胶囊标签 function linkExamTitle() { var prev = document.__fenbiTitleEl; if (prev && document.body.contains(prev) && prev.__fenbiTitleLinked) return; var cands = document.querySelectorAll( 'h1, h2, h3, h4, a, span, div, [class*="title"], [class*="exam"]'); var best = null; for (var i = 0; i < cands.length; i++) { var el = cands[i]; if (el.id === TIP_ID || el.id === BTN_ID || el.__fenbiTitleLinked) continue; var txt = (el.textContent || '').replace(/\s+/g, ''); if (txt.indexOf('模考') === -1) continue; if (txt.length > 40) continue; // 太长说明是整块容器,不是标题 var inner = el.querySelector('h1, h2, h3, h4, a, span, div'); if (inner && (inner.textContent || '').replace(/\s+/g, '') === txt) continue; // 取最内层 var r = el.getBoundingClientRect(); if (!best || r.top < best.getBoundingClientRect().top) best = el; } if (!best) return; document.__fenbiTitleEl = best; /* ---- 标题右边加 B 站搜索引导胶囊标签 ---- */ if (document.getElementById('fenbi-bilibili-tag')) return; var kw = (best.textContent || '').replace(/\s+/g, ' ').trim(); var tag = document.createElement('span'); tag.id = 'fenbi-bilibili-tag'; tag.textContent = '点我去B站搜这套'; tag.style.cssText = 'display:inline-block;border-radius:16px;background:#f0f6ff;color:#0066cc;font-size:12px;' + 'padding:3px 10px;border:1px solid #c9e0ff;cursor:pointer;margin-left:8px;user-select:none;' + 'vertical-align:middle;'; tag.title = '点击到 B 站搜索这套模考'; tag.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); window.open('https://search.bilibili.com/all?keyword=' + encodeURIComponent(kw), '_blank'); }); best.appendChild(tag); } /* ---------- 每 5 题一大题:把题组内部空间放大到一屏,少滚滚轮 ---------- */ var GROUP_ROOM_ON = false; // 一屏高方案已停用(改用材料折叠);需要时改回 true function isScrollBox(el) { var oy = getComputedStyle(el).overflowY; if (oy !== 'auto' && oy !== 'scroll' && oy !== 'overlay') return false; return el.scrollHeight - el.clientHeight > 2; } function nearestCommonAncestor(a, b) { var chain = []; var p = a.parentElement; while (p) { chain.push(p); p = p.parentElement; } var q = b.parentElement; while (q) { if (chain.indexOf(q) >= 0) return q; q = q.parentElement; } return null; } function commonAncestorOfAll(items) { var anc = items[0]; for (var i = 1; i < items.length && anc; i++) { if (anc === items[i] || anc.contains(items[i])) continue; // 已收敛就别再往上找 anc = nearestCommonAncestor(anc, items[i]); } return anc; } // 每 5 题一组的容器:取相邻两题的最近公共祖先,去掉"包住整份卷子"的那层 function groupBoxes(items) { if (items.length < 2) return []; var whole = commonAncestorOfAll(items); var out = []; for (var i = 0; i + 1 < items.length; i++) { var anc = nearestCommonAncestor(items[i], items[i + 1]); if (!anc || anc === whole) continue; if (anc === document.body || anc === document.documentElement) continue; if (anc.querySelectorAll('app-ti').length > 12) continue; // 太宽,不是一组 if (out.indexOf(anc) === -1) out.push(anc); } return out; } // 题组里"自己带滚动条"的最内层容器:粉笔每组内部各有一个滚动区, // 高度被压得很矮,解析一展开就得反复滑滚轮 function groupScrollBoxes(items) { var cands = []; for (var i = 0; i < items.length; i++) { var p = items[i].parentElement; while (p && p !== document.body && p !== document.documentElement) { var cnt = p.querySelectorAll('app-ti').length; if (cnt >= 2 && cnt <= 12 && isScrollBox(p) && cands.indexOf(p) === -1) { cands.push(p); } p = p.parentElement; } } // 只留最内层,避免把外层大容器也撑成一屏、套出两个滚动条 return cands.filter(function (c) { return !cands.some(function (o) { return o !== c && c.contains(o); }); }); } // 抬下限 + 解上限:不再被粉笔压成矮窗口,展开解析后能占满一屏 function loosenHeight(el, h) { if (el.__fenbiRoomH === h) return; el.__fenbiRoomH = h; el.style.setProperty('min-height', h + 'px', 'important'); el.style.setProperty('max-height', 'none', 'important'); el.style.setProperty('overflow-y', 'auto', 'important'); } function enlargeGroups() { if (!GROUP_ROOM_ON) return; var items = getItems(); if (items.length < 2) return; var off = getTopOffset(); var vh = window.innerHeight || document.documentElement.clientHeight; // 顶栏高度让出来,剩下的整块给一组题,基本就是"一屏一组" var room = Math.max(360, Math.round(vh - off - 16)); groupScrollBoxes(items).forEach(function (box) { loosenHeight(box, room); }); groupBoxes(items).forEach(function (box) { loosenHeight(box, room); }); } /* ---------- 大题材料折叠:以页面自带的「材料」标签为开关 ---------- */ var MATERIAL_FOLD_ON = false; // 材料区已整体移除(见下方「材料分栏展平」模块),折叠不再需要;要恢复材料功能时改回 true var MAT_MIN_LEN = 20; // 材料正文最小字数,低于此值不当材料看 var MAT_IMG_MIN = 60; // 材料图的最小边长(px),防把图标当材料 function inItem(el) { return !!(el.closest && el.closest('app-ti')); } function plainLen(el) { return (el.textContent || '').replace(/\s+/g, '').length; } // 材料里的大图(图片材料没有文字,不能只看字数) function hasBigMedia(el) { var media = []; if (el.tagName === 'IMG' || el.tagName === 'VIDEO' || el.tagName === 'CANVAS') media.push(el); if (el.querySelectorAll) { var inner = el.querySelectorAll('img, video, canvas, svg'); for (var i = 0; i < inner.length; i++) media.push(inner[i]); } for (var j = 0; j < media.length; j++) { var m = media[j]; var w = m.naturalWidth || m.width || m.clientWidth || 0; var h = m.naturalHeight || m.height || m.clientHeight || 0; if (Math.max(w, h) >= MAT_IMG_MIN) return true; } // 背景图也算(取有尺寸的) var bg = getComputedStyle(el).backgroundImage; if (bg && bg !== 'none' && bg.indexOf('url(') >= 0) { var bw = el.clientWidth || el.offsetWidth || 0; if (bw >= MAT_IMG_MIN) return true; } return false; } // 材料块:文字够多,或者里面有一张像样的图 function isMaterialLike(el) { if (plainLen(el) >= MAT_MIN_LEN) return true; return hasBigMedia(el); } // 文档序判断:b 相对 a 的位置 function afterDoc(a, b) { return !!(a.compareDocumentPosition(b) & 2); } // a 在 b 之后 function beforeDoc(a, b) { return !!(a.compareDocumentPosition(b) & 4); } // a 在 b 之前 function ensureMatStyle() { if (document.getElementById('fenbi-mat-style')) return; var st = document.createElement('style'); st.id = 'fenbi-mat-style'; st.textContent = '.fenbi-mat-anchor::after{content:" ▸";color:#2b6cd4;}' + '.fenbi-mat-open::after{content:" ▾";}'; (document.head || document.documentElement).appendChild(st); } // 材料开关 = 页面自带 材料 function findMaterialAnchors() { var out = []; var cands = document.querySelectorAll('.label-tab'); var i, t; for (i = 0; i < cands.length; i++) { var el = cands[i]; if (inItem(el)) continue; if (el.__fenbiMatAnchor) { out.push(el); continue; } t = (el.textContent || '').replace(/\s+/g, ''); if (t.length <= 6 && t.indexOf('材料') === 0) out.push(el); } if (out.length) return out; // 兜底:没有 label-tab 时,找只有「材料」二字的短标签 var all = document.querySelectorAll('a, span, h3, h4, p'); for (i = 0; i < all.length; i++) { var e2 = all[i]; if (inItem(e2) || e2.children.length) continue; t = (e2.textContent || '').replace(/\s+/g, ''); if (/^材料[一二三四五六七八九十]?$/.test(t)) out.push(e2); } return out; } // 开关之后最近的一道题(材料正文的右边界) function nextItemAfter(anchor) { var items = getItems(), best = null; for (var i = 0; i < items.length; i++) { if (!afterDoc(items[i], anchor)) continue; if (!best || beforeDoc(items[i], best)) best = items[i]; } return best; } // 材料正文范围:本开关之后 → 下一道题(或下一个「材料」开关)之前 function materialBound(anchor, anchors) { var best = nextItemAfter(anchor); for (var i = 0; i < anchors.length; i++) { var o = anchors[i]; if (o === anchor || !afterDoc(o, anchor)) continue; if (!best || beforeDoc(o, best)) best = o; } return best; } function materialContentOf(anchor, anchors) { var bound = materialBound(anchor, anchors); var all = document.querySelectorAll('*'); var out = []; for (var i = 0; i < all.length; i++) { var el = all[i]; if (el === anchor || el.contains(anchor)) continue; // 不能把开关自己折进去 if (inItem(el) || el.querySelector('app-ti')) continue; if (!isMaterialLike(el)) continue; if (!afterDoc(el, anchor)) continue; if (bound && !beforeDoc(el, bound)) continue; var covered = false; for (var m = 0; m < out.length; m++) { if (out[m].contains(el)) { covered = true; break; } } if (covered) continue; out = out.filter(function (x) { return !el.contains(x); }); out.push(el); } return out; } function bindMaterialAnchor(anchor, blocks) { ensureMatStyle(); anchor.__fenbiMatAnchor = true; anchor.classList.add('fenbi-mat-anchor'); if (!anchor.__fenbiMatOn) { anchor.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); if (e.stopImmediatePropagation) e.stopImmediatePropagation(); var st = anchor.__fenbiMatState; if (!st) return; st.open = !st.open; st.render(); }, true); // 捕获阶段,先于页面自身逻辑 anchor.__fenbiMatOn = true; } var state = { open: false, blocks: blocks }; state.render = function () { blocks.forEach(function (b) { if (state.open) b.style.removeProperty('display'); else b.style.setProperty('display', 'none', 'important'); }); anchor.classList.toggle('fenbi-mat-open', !!state.open); if (state.open) homeSplitLeft(); // 材料展开后左栏要恢复宽度 }; anchor.__fenbiMatState = state; anchor.__fenbiMatBound = blocks; state.render(); } function foldMaterials() { if (!MATERIAL_FOLD_ON) return; if (!getItems().length) return; var anchors = findMaterialAnchors(); if (!anchors.length) return; anchors.forEach(function (a) { var bound = a.__fenbiMatBound; if (bound && bound.length && bound.every(function (b) { return b.isConnected; })) { return; // 已绑好,保持用户当前开合状态 } var blocks = materialContentOf(a, anchors); if (!blocks.length) return; bindMaterialAnchor(a, blocks); }); } /* ---------- 材料分栏展平:把每 5 题一组的分栏题组拆成一道道独立单题 ---------- */ // 材料区整体移除,组内题目与前面的普通题完全一致:全宽、无分栏、无拖拽分隔条。 // 只改样式 / 加类名,不动原 DOM 结构;要恢复材料与分栏时把 FLATTEN_ON 改回 false var FLATTEN_ON = true; var FLAT_GAP = 48; // 展平后组内相邻题的间距,对齐普通题的 48px function ensureFlatStyle() { if (document.getElementById('fenbi-flat-style')) return; var st = document.createElement('style'); st.id = 'fenbi-flat-style'; st.textContent = // 分栏容器:取消 flex 与固定高度(原 70vh),否则内容撑不出容器、各组会互相压叠 '.fenbi-flat{display:block !important;height:auto !important;min-height:0 !important;' + 'max-height:none !important;overflow:visible !important;}' + // 左栏(材料区)与拖拽手柄:整体不要了 '.fenbi-flat > .nz-resizable.left,.fenbi-flat .nz-resizable-handle{display:none !important;}' + // 右栏(题目区):吃满整宽,去掉原来的 4px 左边距与滚动限制 '.fenbi-flat > .right{width:100% !important;max-width:none !important;margin-left:0 !important;' + 'height:auto !important;max-height:none !important;overflow:visible !important;}' + '.fenbi-flat .right-section{width:100% !important;max-width:none !important;}' + // 组内题目:全宽 + 补上普通题才有的题间距 '.fenbi-flat app-ti.question-multiple{width:100% !important;max-width:none !important;' + 'margin-bottom:' + FLAT_GAP + 'px !important;}' + '.fenbi-flat app-ti.question-multiple:last-of-type{margin-bottom:0 !important;}' + // 题组自带的「组内题号导航条」(106题 107题…):材料题组专属,展平后不要了,普通题没有这个 '.fenbi-flat .questions-anchors{display:none !important;}' + // 组内首题上方补 12px,使「上一题 → 本组首题」的间距与普通题的 48px 对齐 '.fenbi-flat .questions-container{padding-top:12px !important;}' + // 上层包裹:app-resizable 原为 inline(不认 width),必须块化 '.fenbi-flat-up{display:block !important;width:100% !important;max-width:none !important;}'; (document.head || document.documentElement).appendChild(st); } function flattenGroups() { if (!FLATTEN_ON) return; var boxes = document.querySelectorAll('div.resizable-container'); if (!boxes.length) return; ensureFlatStyle(); for (var i = 0; i < boxes.length; i++) { var box = boxes[i]; box.classList.add('fenbi-flat'); // 往上两层:app-resizable(原 inline)与 div.ti 也要放开宽度 var p1 = box.parentElement; if (p1 && (p1.tagName || '').toUpperCase() === 'APP-RESIZABLE') { p1.classList.add('fenbi-flat-up'); var p2 = p1.parentElement; if (p2 && (p2.tagName || '').toUpperCase() === 'DIV') p2.classList.add('fenbi-flat-up'); } // 旧的分隔条逻辑可能给左栏压过宽度,清掉,免得残留 var left = box.querySelector('.nz-resizable.left'); if (left) { left.style.removeProperty('width'); left.style.removeProperty('display'); } } } /* ---------- 全局按钮:开/关所有解析答案 ---------- */ function applyAll(open) { getItems().forEach(function (item) { processItem(item); setBody(item, open); setRes(item, open); }); } // SPA 重渲染后,DOM 上的显示状态可能被还原成默认值, // 需要按脚本记录的状态重新贴一次,否则表现为"点了没反应/打不开" function syncResState(item) { if (typeof item.__fenbiResOpen !== 'boolean') return; var els = resEls(item); if (!els.length) return; var shouldShow = item.__fenbiResOpen; for (var i = 0; i < els.length; i++) { var isShown = els[i].style.display !== 'none'; if (shouldShow !== isShown) { els[i].style.display = shouldShow ? '' : 'none'; } } if (item.__fenbiResBtn) { item.__fenbiResBtn.textContent = shouldShow ? '解析 ▾' : '解析 ▸'; } } function createToggleBtn() { if (document.getElementById(BTN_ID)) return; const btn = document.createElement('button'); btn.id = BTN_ID; btn.textContent = '开/关所有解析答案'; btn.style.cssText = 'position:fixed;top:10px;right:10px;z-index:2147483647;' + 'border-radius:16px;background:#f0f6ff;color:#0066cc;font-size:12px;' + 'padding:3px 10px;border:1px solid #c9e0ff;cursor:pointer;user-select:none;' + 'box-shadow:0 1px 3px rgba(0,0,0,0.08);line-height:1.4;'; btn.addEventListener('click', function () { const anyOpen = getItems().some(function (item) { return isBodyOpen(item) || isResOpen(item); }); const target = !anyOpen; applyAll(target); btn.blur(); }); document.body.appendChild(btn); } /* ---------- 初始化(SPA 轮询兜底) ---------- */ function init() { createToggleBtn(); linkExamTitle(); getItems().forEach(function (item) { processItem(item); syncResState(item); }); enlargeGroups(); foldMaterials(); homeSplitLeft(); flattenGroups(); } /* ---------- 组内拖拽分隔条:默认顶到最左边 ---------- */ var HSCROLL_HOME_ON = false; // 分栏已展平、分隔条已移除,不再需要摆位置;要恢复分栏时改回 true var SPLIT_LEFT_MIN = 8; // 材料收起时,左栏压到的最小宽度(px) var SPLIT_LEFT_OPEN = 830; // 材料展开时,左栏恢复的宽度(px) // 沿 DOM 上溯,找到这个元素所属的「左栏」(nz-resizable left) function leftOf(node) { var el = node; while (el && el.nodeType === 1) { if (el.classList && el.classList.contains('nz-resizable') && el.classList.contains('left')) return el; el = el.parentElement; } return null; } // 全页所有左栏(每个材料题组一个) function leftPanes() { var out = []; function push(el) { if (el && el.nodeType === 1 && out.indexOf(el) === -1) out.push(el); } Array.prototype.slice.call(document.querySelectorAll('.nz-resizable.left')).forEach(push); // 兜底:类名变了就取 resizable-container 的第一个可拖拽子元素 Array.prototype.slice.call(document.querySelectorAll('div.resizable-container')).forEach(function (box) { var first = box.firstElementChild; if (first && first.classList && first.classList.contains('nz-resizable')) push(first); }); return out; } // 该左栏所在的组里,材料是否正被展开 function materialOpened(left) { var scope = (left.closest ? left.closest('div.resizable-container') : null) || left.parentElement || left; var anchors = scope.querySelectorAll('.fenbi-mat-anchor'); for (var i = 0; i < anchors.length; i++) { var st = anchors[i].__fenbiMatState; if (st && st.open) return true; } return false; } function setLeftWidth(left, w) { var cur = parseFloat(left.style.width); if (!isNaN(cur) && Math.abs(cur - w) < 0.5) return; left.style.width = w + 'px'; } // 用户一旦自己拖过分隔条,这一组就不再被自动摆正 function guardSplitDrag() { if (guardSplitDrag.__on) return; guardSplitDrag.__on = true; function mark(e) { var t = e.target; while (t && t.nodeType === 1) { if (t.classList && t.classList.contains('nz-resizable-handle')) { var left = leftOf(t); if (left) left.__fenbiUserResized = true; return; } t = t.parentElement; } } document.addEventListener('pointerdown', mark, true); document.addEventListener('mousedown', mark, true); } // 把每组的分隔条摆到最左边:左栏压窄;材料一旦展开则恢复原宽 function homeSplitLeft() { if (!HSCROLL_HOME_ON) return; guardSplitDrag(); leftPanes().forEach(function (left) { if (left.__fenbiUserResized) return; setLeftWidth(left, materialOpened(left) ? SPLIT_LEFT_OPEN : SPLIT_LEFT_MIN); }); } init(); setTimeout(init, 1200); setTimeout(init, 3000); setInterval(function () { linkExamTitle(); getItems().forEach(function (item) { processItem(item); syncResState(item); }); enlargeGroups(); foldMaterials(); homeSplitLeft(); flattenGroups(); }, 3000); })();