// ==UserScript== // @name 上岸村机考系统错题助手 // @namespace http://tampermonkey.net/ // @version 1.10.3 // @description 上岸村机考系统错题整理增强,支持笔记、划线标注、错题重练、一键复制、错题与笔记导出等功能。 // @author 烨笙 // @license MIT // @match https://pub.xdtech.top/mingshi/wxpage/tiku/gongan/index.html // @match https://pub.xdtech.top/*/wxpage/tiku/gongan/index.html // @match https://pub.xdtech.top/*/wxpage/tiku/gongan/index.html* // @include *://*.xdtech.top/*/wxpage/tiku/gongan/index.html* // @match https://pub.xdtech.top/mingshi/wxpage/tiku/mocks/index.html // @match https://pub.xdtech.top/*/wxpage/tiku/mocks/index.html // @match https://pub.xdtech.top/*/wxpage/tiku/mocks/index.html* // @include *://*.xdtech.top/*/wxpage/tiku/mocks/index.html* // @connect pub.xdapi.top // @grant GM_xmlhttpRequest // @grant GM_addStyle // @run-at document-idle // ==/UserScript== (function () { 'use strict'; console.log('[错题助手] 脚本已注入:' + location.href); var CTX = location.pathname.split('/')[1] || 'mingshi'; var API_BASE = 'https://pub.xdapi.top/' + CTX + '/api/v1/tiku/gongan/'; var API_V2 = 'https://pub.xdapi.top/' + CTX + '/api/v2/'; // 模考补题面走 v2(与站点同一接口) var LS_STORE = 'gongan_tiku_helper_' + CTX; var LS_TOKEN = 'token_gongan_' + CTX; var LS_LOGIN = 'login_status_' + CTX; var SUBJ_ZY = 1; var SUBJ_XC = 0; var SUBJECT_NAME = {}; SUBJECT_NAME[SUBJ_ZY] = '公安专业知识'; SUBJECT_NAME[SUBJ_XC] = '行政职业能力测试'; var DAY_RANGES = [['0', '当天'], ['1', '本周'], ['2', '本月'], ['3', '近三月'], ['4', '全部']]; var MASTER_STREAK = 2; /* 带 click 的 div / span:键盘本来完全到不了它们。这一份是唯一出处——下面的焦点环 CSS 由它生成, 判据 T24 也拿它回脚本里核对类名,为的是「界面类名改了、这份没跟着改」那种界面上看不出的漂移。 名单里不放真 ', ' ', ' ', ' ', ' ', '
', // ---- 共用筛选区(导出 / 重练两个 pane 的;笔记 pane 用自己那一排)---- '
', '
', ' ', ' ', '
', '
', ' ', ' ', ' ', ' ', ' ', ' ', '
', ' ', ' ', '
', // ---- 导出 ---- '
', '
', ' ', ' ', ' ', '
', '
', ' ', ' ', ' ', '
', '
首次导出会自动按当前来源与筛选载入题目
', '
', // ---- 重练 ---- ' ', // ---- 笔记 ---- ' ', '
', '
本地数据
', '
', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '
', '
这些数据只存在本机浏览器(localStorage)里,不上传站点。清理浏览器数据、换设备或重装系统之前,先点「备份全部数据」存一份 JSON;要还回来就用「恢复备份」选回那个文件。
', '
', ' ', '
' ].join(''); document.body.appendChild(viewEl); // 批注轨道:脱离题目滚动容器,靠 JS 镜像其位置并跟随 scrollTop var railEl = document.createElement('div'); railEl.id = 'gth-rail'; railEl.className = 'gth-rail'; railEl.hidden = true; railEl.innerHTML = '
'; document.body.appendChild(railEl); var statusEl = $('#gth-status'); function setStatus(msg, kind) { if (!msg) { statusEl.hidden = true; statusEl.textContent = ''; statusEl.className = ''; return; } statusEl.hidden = false; statusEl.textContent = msg; statusEl.className = kind || ''; } /* 拉题 / 组卷是几十秒级的异步活。按钮不置灰的话,用户看着界面没动就再点一次, 同一批题就被并发拉两遍(模考那条路有 mockCollecting 挡着,导出与组卷这两条原来没有)。 用两段 then 而不是 finally:这份脚本通篇不依赖新式 API。 */ function busy(btn, p) { if (!btn) return p; btn.disabled = true; return p.then(function (v) { btn.disabled = false; return v; }, function (e) { btn.disabled = false; throw e; }); } // 选中的行测模块名;空数组表示「全部模块」 var selectedModules = []; var moduleList = []; /* 行测模块表的取题门:modulesLoading 挡住同一次里的并发,modulesLoaded 挡住「站点返回空表」 时反复重发。失败不置 loaded,所以下一次用户动作(切科目、开面板)还会重试—— 这两个门是必要的,因为观察器一度每次站点重绘都跑一遍 syncFilterUI。 */ var modulesLoading = false; var modulesLoaded = false; // 重练面板勾选的来源(导出面板仍用 #gth-src 单选),以及模考模块勾选 var practiceSrcs = ['error']; var practiceSrcTouched = false; var selectedMockModules = []; var SRC_KEYS = ['error', 'favorite', 'mock']; // both 不在其中:它只是导出单选里的历史值,由 srcList 展开 var SRC_NAME = { error: '错题本', favorite: '收藏夹', both: '错题+收藏', mock: '模考收录' }; // 来源写进描述里:刷题进度 resumeKey 与组卷历史都靠这串字区分条件 function filterDesc(f) { var srcs = srcList(f); var parts = []; var site = srcs.filter(function (s) { return s !== 'mock'; }); if (site.length) { parts.push(srcLabel(site) + ' · ' + (f.mode === 'date' ? '日期:' + dayLabel(f.dayRange) : subjectDesc(f))); } // 模考题没有科目也没有日期(站点接口不提供),唯一能筛的维度是模块 if (srcs.indexOf('mock') >= 0) { var mods = f.mock_modules || []; parts.push(SRC_NAME.mock + (mods.length ? '(' + mods.join('、') + ')' : '(全部模考题)')); } return parts.join(' + '); } function dayLabel(v) { var d = DAY_RANGES.filter(function (x) { return x[0] === String(v); })[0]; return d ? d[1] : v; } function subjectDesc(f) { var s = SUBJECT_NAME[f.subject] || f.subject; if (f.subject === SUBJ_XC) { var names = f.module_names || []; s += names.length ? '(' + names.join('、') + ')' : '(全部模块)'; } return s; } function readFilter(pane) { var mode = $('#gth-mode').value; var srcs = pane === 'practice' ? practiceSrcs.slice() : [currentSrc()]; var f = { mode: mode, srcs: srcs }; if (mode === 'date') { f.dayRange = $('#gth-day').value; } else { f.subject = Number($('#gth-subject').value); if (f.subject === SUBJ_XC) f.module_names = selectedModules.slice(); } if (srcs.indexOf('mock') >= 0) f.mock_modules = selectedMockModules.slice(); return f; } // 两组模块 chips(行测考点来自站点接口,模考考点来自本地收录)共用同一套渲染与交互 function chipsHtml(items, sel) { var allOn = sel.length === 0; var html = '
' + (allOn ? icon('checkCircle') : '') + '全部
'; return html + items.map(function (m, i) { var on = sel.indexOf(m.name) >= 0; return '
' + (on ? icon('checkCircle') : '') + esc(m.name) + (m.n == null ? '' : '' + m.n + '') + '
'; }).join(''); } function bindChips(box, getItems, sel, redraw) { $$('.gth-chip', box).forEach(function (chip) { chip.addEventListener('click', function () { var items = getItems(); var idx = Number(chip.dataset.idx); if (idx < 0) sel.length = 0; else { var name = items[idx] && items[idx].name; var i = sel.indexOf(name); if (i >= 0) sel.splice(i, 1); else if (name) sel.push(name); } invalidateLoaded(); redraw(); }); }); } function renderModChips() { var box = $('#gth-mod-chips'); if (!box) return; if (!moduleList.length) { box.innerHTML = '
未获取到行测模块,可直接开始(将按全部错题处理)
'; return; } box.innerHTML = chipsHtml(moduleList, selectedModules); bindChips(box, function () { return moduleList; }, selectedModules, renderModChips); } function renderMockChips() { var box = $('#gth-mock-chips'); if (!box) return; var items = mockModuleOptions(); // 先剪掉不再存在的勾选(收录被清空时也要剪),否则残留的模块名会变成看不见的筛选 var names = items.map(function (m) { return m.name; }); for (var i = selectedMockModules.length - 1; i >= 0; i--) { if (names.indexOf(selectedMockModules[i]) < 0) selectedMockModules.splice(i, 1); } if (!items.length) { box.innerHTML = '
还没有收录过模考题:先去模考报告解析页用「收齐全场答错题」收集。
'; return; } box.innerHTML = chipsHtml(items, selectedMockModules); bindChips(box, function () { return mockModuleOptions(); }, selectedMockModules, renderMockChips); } function activePane() { var on = $('.gth-tabs button.on'); return on ? on.dataset.tab : 'export'; } function syncFilterUI() { var mode = $('#gth-mode').value; var pane = activePane(); $('#gth-shared').hidden = pane === 'notes'; $('#gth-src-row').hidden = pane === 'practice'; var srcs = srcList({ srcs: pane === 'practice' ? practiceSrcs : [currentSrc()] }); var siteOn = hasSiteSrc(pane === 'practice' ? practiceSrcs : [currentSrc()]); // 只勾模考收录时,日期 / 科目 / 行测模块都不参与取题,收起来——摆着能点却不生效就是「看着能筛其实无效」 $('#gth-scope-row').hidden = !siteOn; $('.gth-date-only').hidden = !(siteOn && mode === 'date'); $('.gth-subject-only').hidden = !(siteOn && mode === 'subject'); var xc = siteOn && mode === 'subject' && Number($('#gth-subject').value) === SUBJ_XC; $('.gth-xc-only').hidden = !xc; if (xc && !moduleList.length && !modulesLoading && !modulesLoaded) loadModules(); $('#gth-mock-chiprow').hidden = srcs.indexOf('mock') < 0; if (srcs.indexOf('mock') >= 0) renderMockChips(); renderPracticeHint(); } function loadModules() { modulesLoading = true; return fetchSubcategory().then(function (list) { modulesLoading = false; modulesLoaded = true; moduleList = buildModuleOptions(list); // 结构诊断:若模块 chips 仍非预期,可据此定位真实层级 console.log('[错题助手] 行测模块:', moduleList.map(function (m) { return m.name; }), '|原始 subcategory_list:', list.map(function (c) { return c.name + ' × ' + (c.exampoint_list || []).length; }).join(' / ')); renderModChips(); }).catch(function (e) { modulesLoading = false; // 失败不置 modulesLoaded:下一次用户动作还会再取 setStatus('行测模块加载失败:' + e.message, 'err'); }); } /* ---------- 重练面板的来源多选:勾选式下拉,选中态存在 practiceSrcs ---------- */ function renderPracticeSrc() { var label = $('#gth-src-dd-label'); if (!label) return; label.textContent = practiceSrcs.length ? practiceSrcs.map(function (s) { return SRC_NAME[s]; }).join('+') : '选择来源'; $$('#gth-src-dd-menu input').forEach(function (cb) { cb.checked = practiceSrcs.indexOf(cb.dataset.src) >= 0; }); } function checkedPracticeSrcs() { return $$('#gth-src-dd-menu input').filter(function (cb) { return cb.checked; }) .map(function (cb) { return cb.dataset.src; }); } $('#gth-src-dd-btn').addEventListener('click', function (e) { e.stopPropagation(); var menu = $('#gth-src-dd-menu'); menu.hidden = !menu.hidden; }); // 点下拉外面就收起:菜单是绝对定位的浮层,不收会压住下面的组卷历史 document.addEventListener('click', function (e) { var dd = $('#gth-src-dd'), menu = $('#gth-src-dd-menu'); if (dd && menu && !menu.hidden && !dd.contains(e.target)) menu.hidden = true; }); document.addEventListener('keydown', function (e) { var menu = $('#gth-src-dd-menu'); if (e.key === 'Escape' && menu && !menu.hidden) menu.hidden = true; }); $$('#gth-src-dd-menu input').forEach(function (cb) { cb.addEventListener('change', function () { practiceSrcTouched = true; practiceSrcs = checkedPracticeSrcs(); invalidateLoaded(); renderPracticeSrc(); syncFilterUI(); }); }); /* ---------- 笔记 tab 自己的筛选条:数据全在本地,套不上站点那套来源/范围 ---------- */ var notesFilter = { subj: '', mod: '', src: '', mast: '', cont: '' }; var notesShownIds = []; // 上一次渲染实际列出的题目 id,「一键整理 / 导出笔记」跟着它走 // 列表被收窄过没有——关键词框也算,否则「搜一个不匹配的词再点一键整理」会去整理全部 function notesNarrowed() { return !!(notesFilter.subj || notesFilter.mod || notesFilter.src || notesFilter.mast || notesFilter.cont || notesSearchKey.trim()); } function readNotesFilter() { notesFilter.subj = $('#gth-nf-subj').value; notesFilter.mod = $('#gth-nf-mod').value; notesFilter.src = $('#gth-nf-src').value; notesFilter.mast = $('#gth-nf-mast').value; notesFilter.cont = $('#gth-nf-cont').value; } function masteryKey(id) { var l = masteredLabel(id); return l === '已掌握' ? 'done' : (l.indexOf('待巩固') === 0 ? 'dyn' : 'un'); } // 一条笔记(或一组划线)在本地能查到的全部筛选维度,缺的都落到「未记录」 function noteFacets(id) { var n = store.notes[id] || {}; var hls = getHighlights(id); var subj = n.subject != null ? n.subject : (hls[0] && hls[0].subject); return { subj: subj == null ? 'x' : String(subj), mod: moduleOf(id) || MOCK_UNCLS, src: store.mockQs[id] ? 'mock' : 'site', mast: masteryKey(id), hasNote: !!n.text, hasHl: hls.length > 0 }; } function noteMatch(r, nf) { if (nf.subj && r.subj !== nf.subj) return false; if (nf.mod && r.mod !== nf.mod) return false; if (nf.src && r.src !== nf.src) return false; if (nf.mast && r.mast !== nf.mast) return false; if (nf.cont === 'note' && !r.hasNote) return false; if (nf.cont === 'hl' && !r.hasHl) return false; if (nf.cont === 'both' && !(r.hasNote && r.hasHl)) return false; return true; } // 模块下拉的候选从这批笔记题的模块里来,而不是站点的考点表:只列真筛得出东西的值 function renderNoteFilterMods(ids) { var sel = $('#gth-nf-mod'); if (!sel) return; var names = {}, hasUnknown = false; ids.forEach(function (id) { var m = moduleOf(id); if (m) names[m] = 1; else hasUnknown = true; }); sel.innerHTML = '' + Object.keys(names).sort().map(function (n) { return ''; }).join('') + (hasUnknown ? '' : ''); sel.value = notesFilter.mod; notesFilter.mod = sel.value; // 这一维要靠「按科目 + 行测」拉过一次题才登记得上。有笔记却一条模块都没有时, // 下拉里只有「全部模块 / 模块未记录」两项——不说清就是「看着能筛,其实筛不出东西」 setIf($('#gth-nf-mod-hint'), 'hidden', !ids.length || Object.keys(names).length > 0); } ['gth-nf-subj', 'gth-nf-mod', 'gth-nf-src', 'gth-nf-mast', 'gth-nf-cont'].forEach(function (id) { $('#' + id).addEventListener('change', function () { readNotesFilter(); renderNotesList(); }); }); /* ---------- 入口:注入到左侧菜单「公安专业知识」下方,并作为原生视图切换 ---------- */ var viewOn = false; var panelRev = -1; // 面板内容最后一次画的是哪个 storeRev;-1 = 还没画过 // 助手生效的路由:错题页(#/error)与收藏页(#/shoucang)。两页模板同构, // 左侧菜单同样是「日期 / 行政职业能力测试 / 公安专业知识」三个二级标签。 function routeKind() { var h = location.hash || ''; if (h.indexOf('#/error') === 0) return 'error'; if (h.indexOf('#/shoucang') === 0) return 'collect'; return ''; } function isCollectRoute() { return routeKind() === 'collect'; } function isListRoute() { return !!routeKind(); } // 切换助手视图:与站点原生右侧内容互斥显示,左菜单同步高亮。 // 可重复调用(AngularJS 重绘后需重新压住原生内容),因此不做「状态未变就返回」的短路。 /* 面板的「壳」:站点重绘之后必须重贴的东西——原生二级菜单与内容的隐藏类、被 AngularJS 换掉的 right-content 里重新安家、入口高亮。这部分只跟 DOM 有关,每次观察器跑都要重做。 */ function applyViewChrome(on) { var rc = $('.gongan2-container .right-content') || $('.right-content'); if (rc) { // .inner-content 带 zero-flex-* 会设置 display,必须用 !important 类隐藏 $$('.second-menu, .inner-content', rc).forEach(function (el) { el.classList.toggle('gth-hide', on); }); if (viewEl.parentNode !== rc) rc.appendChild(viewEl); } viewEl.hidden = !on; document.body.classList.toggle('gth-view-on', on); var entry = $('.gth-menu-item'); if (entry) entry.classList.toggle('active', on); } /* 面板的「内容」:只读 store 的那两处(组卷历史、增量导出提示)。 打开面板必须画一次;之后只有 store 真变过才画——见 storeRev。 */ function renderPanel() { panelRev = storeRev; renderHistory(); updateExportHint(); } function setView(on) { viewOn = on; applyViewChrome(on); if (on) { renderPanel(); syncFilterUI(); // 面板刚打开时按当前 tab 决定筛选行显示哪几排 } syncRail(); } function injectMenuEntry() { var menu = $('.gongan2-container .left-menu') || $('.left-menu'); var existing = $('.gth-menu-item'); var kind = routeKind(); // 非错题页 / 收藏页不注入入口,并清掉遗留的入口 if (!kind) { if (existing && existing.parentNode) existing.parentNode.removeChild(existing); return; } if (!menu) return; // 两页共用同一个入口,名字也统一:这里不再按路由换标题,切回来什么都不用重画 if (existing && menu.contains(existing)) return; var items = $$('.item', menu); var anchor = null; for (var i = 0; i < items.length; i++) { if ((items[i].textContent || '').indexOf('公安专业知识') >= 0) { anchor = items[i]; break; } } if (!anchor) return; var btn = document.createElement('div'); btn.className = 'item zero-flex-ver-center gth-menu-item'; btn.innerHTML = '
' + icon('sparkles') + '机考助手
'; btn.title = '笔记 / 导出 / 重练 / 掌握状态'; btn.addEventListener('click', function () { setView(!viewOn); }); if (anchor.nextSibling) menu.insertBefore(btn, anchor.nextSibling); else menu.appendChild(btn); if (viewOn) btn.classList.add('active'); } // 点击原生二级标签(日期 / 行政职业能力测试 / 公安专业知识)时交还原生内容。 // 必须显式关闭:这些标签切换时不改变路由,仅靠 hashchange 感知不到; // 而 viewOn 若保持为 true,观察器会持续重新隐藏原生内容,导致二级标签打不开。 document.addEventListener('click', function (e) { if (!viewOn) return; var it = e.target && e.target.closest && e.target.closest('.left-menu .item'); if (it && !it.classList.contains('gth-menu-item')) setView(false); }); $$('.gth-tabs button').forEach(function (b) { b.addEventListener('click', function () { $$('.gth-tabs button').forEach(function (x) { x.classList.remove('on'); }); b.classList.add('on'); $$('[data-pane]').forEach(function (p) { p.hidden = p.getAttribute('data-pane') !== b.dataset.tab; }); if (b.dataset.tab === 'notes') renderNotesList(); if (b.dataset.tab === 'practice') renderHistory(); syncFilterUI(); // 筛选行按 pane 变 }); }); // 筛选条件一变,之前缓存的题目就作废,下次导出 / 备份会按新条件重新拉 function invalidateLoaded() { loaded.filter = null; loaded.list = []; } $('#gth-mode').addEventListener('change', function () { invalidateLoaded(); syncFilterUI(); }); $('#gth-subject').addEventListener('change', function () { invalidateLoaded(); syncFilterUI(); }); $('#gth-day').addEventListener('change', invalidateLoaded); $('#gth-order').addEventListener('change', renderPracticeHint); $('#gth-src').addEventListener('change', function () { $('#gth-src').dataset.touched = '1'; invalidateLoaded(); updateExportHint(); syncFilterUI(); // 选到「模考收录」时要把模考模块那一排 chips 露出来 }); // 默认来源跟随当前页面:错题页默认错题本、收藏页默认收藏夹(用户手动改过就不再自动切) function syncSourceDefault() { var el = $('#gth-src'); if (!el || el.dataset.touched) return; var want = isCollectRoute() ? 'favorite' : 'error'; // 来源跟着页面换了就得自己把提示语换掉:观察器那一路现在只管 store 变了才重画面板 if (el.value !== want) { el.value = want; invalidateLoaded(); updateExportHint(); } if (!practiceSrcTouched && practiceSrcs.join() !== want) { practiceSrcs = [want]; invalidateLoaded(); renderPracticeSrc(); } } var loaded = { filter: null, list: [] }; // ---- 增量导出:本地按来源记录「已导出过的题目 id」,只导出从未导出过的题 ---- function currentSrc() { var el = $('#gth-src'); return (el && el.value) || 'error'; } function exIds(src) { return (store.exported && store.exported[src]) || {}; } function exAt(src) { return (store.exportAt && store.exportAt[src]) || 0; } function newOnes(list) { var ex = exIds(currentSrc()); return list.filter(function (q) { return !ex[q.id]; }); } function markExported(list) { var src = currentSrc(); if (!store.exported[src]) store.exported[src] = {}; list.forEach(function (q) { store.exported[src][q.id] = 1; }); store.exportAt[src] = Date.now(); saveStore(); updateExportHint(); } function updateExportHint() { var el = $('#gth-export-hint'); if (!el) return; var src = currentSrc(); var n = Object.keys(exIds(src)).length; el.textContent = n ? SRC_NAME[src] + ' 已导出 ' + n + ' 题 · 上次 ' + fmtAgo(exAt(src)) : SRC_NAME[src] + ' 尚未导出过任何题目'; } /* 每个来源都要有一项:少了它 runExport 会静默落到默认前缀上,用户在下载目录里 看不出这批是不是模考那一批——只有表格里的「来源」列能区分。名字跟着导出面板上 那个勾选项(SRC_NAME.mock)走,不另起一套叫法。 */ var EXPORT_PREFIX = { error: '上岸村错题_', favorite: '上岸村收藏_', both: '上岸村错题收藏_', mock: '上岸村模考收录_' }; function runExport(list, tag) { var fmt = $('#gth-fmt').value; var name = (EXPORT_PREFIX[currentSrc()] || '上岸村错题_') + tag + '_' + stamp(); if (fmt === 'md') download(name + '.md', exportMarkdown(list)); else if (fmt === 'csv') download(name + '.csv', exportCsv(list)); else download(name + '.xlsx', exportXlsx(list)); } // 首次导出时按当前筛选自动载入题目,省掉单独的「加载」按钮 function ensureLoaded() { if (loaded.list.length) return Promise.resolve(loaded.list); var f = readFilter('export'); setStatus('正在载入 ' + (srcLabel(f.srcs) || '题目') + '…'); return fetchByFilter(f).then(function (list) { loaded.filter = f; loaded.list = list; $('#gth-count').textContent = srcLabel(f.srcs) + ' · 共 ' + list.length + ' 题 · 新增 ' + newOnes(list).length + ' 题'; updateExportHint(); return list; }); } $('#gth-export-new').addEventListener('click', function () { var btn = this; busy(btn, ensureLoaded().then(function () { var list = newOnes(loaded.list); if (!list.length) { setStatus('没有新增题目(' + loaded.list.length + ' 题此前均已导出过)', ''); return; } try { runExport(list, '新增'); var src = currentSrc(), tag = SRC_NAME[src]; markExported(list); setStatus('已导出新增 ' + list.length + ' 题(' + tag + ' 累计已导出 ' + Object.keys(exIds(src)).length + ' 题)', 'ok'); } catch (e) { setStatus('导出失败:' + e.message, 'err'); } }).catch(function (e) { setStatus('载入失败:' + e.message, 'err'); })); }); $('#gth-export-all').addEventListener('click', function () { var btn = this; busy(btn, ensureLoaded().then(function () { try { runExport(loaded.list, '全量'); markExported(loaded.list); setStatus('已导出全部 ' + loaded.list.length + ' 题', 'ok'); } catch (e) { setStatus('导出失败:' + e.message, 'err'); } }).catch(function (e) { setStatus('载入失败:' + e.message, 'err'); })); }); $('#gth-reset-base').addEventListener('click', function () { var src = currentSrc(); if (!Object.keys(exIds(src)).length) { setStatus(SRC_NAME[src] + ' 的增量基线本就是空的', ''); return; } if (!confirm('重置后,下次「导出新增」会把' + SRC_NAME[src] + '当前全部题目视为新增。确定继续?')) return; store.exported[src] = {}; store.exportAt[src] = 0; saveStore(); updateExportHint(); setStatus(SRC_NAME[src] + ' 增量基线已重置', 'ok'); }); $('#gth-backup').addEventListener('click', function () { download('上岸村错题_备份全部数据_' + stamp() + '.json', exportJson(loaded.list)); setStatus('已备份全部数据(' + (loaded.list.length || '0') + ' 题 + 本地笔记与掌握记录)', 'ok'); }); $('#gth-export-notes-md').addEventListener('click', function () { // 列表被收窄时只导出看得见的题,否则「筛完再导出」会把被筛掉的也带上 var ids = notesNarrowed() ? notesShownIds.slice() : null; if (ids && !ids.length) { setStatus('当前条件下没有笔记可导出', 'err'); return; } if (!ids && !Object.keys(store.notes).length) { setStatus('暂无笔记可导出', 'err'); return; } download('上岸村错题_笔记_' + stamp() + '.md', exportNotesMarkdown(ids)); }); $('#gth-collect-open').addEventListener('click', function () { if (!notesNarrowed()) { openCollect(null); return; } // null = 不限题,整理全部 if (!notesShownIds.length) { setStatus('当前条件下没有可整理的内容', 'err'); return; } openCollect(notesShownIds.slice()); }); $('#gth-restore').addEventListener('click', function () { $('#gth-file').click(); }); $('#gth-file').addEventListener('change', function (e) { var file = e.target.files[0]; if (!file) return; var fr = new FileReader(); fr.onload = function () { try { setStatus(restoreJson(String(fr.result)), 'ok'); } catch (err) { setStatus('恢复失败:' + err.message, 'err'); } }; fr.readAsText(file); e.target.value = ''; }); // 一键清空本脚本写入浏览器 localStorage 的全部数据 $('#gth-wipe').addEventListener('click', function () { var nNote = Object.keys(store.notes).length; var nStat = Object.keys(store.mastered).length; var nExp = ['error', 'favorite', 'both', 'mock'].reduce(function (a, k) { return a + Object.keys(exIds(k)).length; }, 0); var nHis = (store.history || []).length; var nRes = Object.keys(store.resume || {}).length; var nMock = Object.keys(store.mockQs || {}).length; var nAux = Object.keys(store.practiced || {}).length + Object.keys(store.qModule || {}).length; if (!nNote && !nStat && !nExp && !nHis && !nRes && !nMock && !nAux) { setStatus('本地数据已为空', ''); return; } if (!confirm( '将删除本脚本存在此浏览器中的全部数据:\n' + '· 笔记 ' + nNote + ' 条\n' + '· 掌握记录 ' + nStat + ' 条\n' + '· 答错次数统计\n' + '· 增量导出记录 ' + nExp + ' 题\n' + '· 组卷历史 ' + nHis + ' 条\n' + '· 刷题进度 ' + nRes + ' 处\n' + '· 模考收录 ' + nMock + ' 题(连带重练登记与模块记录)\n\n' + '此操作不可恢复。建议先点「备份全部数据」再清空。\n\n确定继续?' )) return; store.notes = {}; store.mastered = {}; store.wrongCount = {}; store.exported = { _v: 2, error: {}, favorite: {}, both: {}, mock: {} }; store.exportAt = { error: 0, favorite: 0, both: 0, mock: 0 }; store.history = []; store.resume = {}; store.mockQs = {}; store.mocks = {}; store.practiced = {}; store.qModule = {}; selectedMockModules.length = 0; renderMockChips(); saveStore(); refreshBadges(); renderPracticeHint(); renderNotesList(); renderHistory(); updateExportHint(); setStatus('已清空本地数据', 'ok'); }); /* 这两件事的耗时与不可逆程度差很远——重扫只往库里加登记、要联网几秒,清理是删了取不回来—— 所以分成两次点击,不合成一个「一键整理」。 */ $('#gth-mod-scan').addEventListener('click', function () { setStatus('正在按日期载入错题本与收藏夹…'); busy(this, fetchByFilter({ mode: 'date', dayRange: '4', srcs: ['error', 'favorite'] }, 0)) .then(function (list) { var gap = noteIdsWithHl().filter(function (id) { return !moduleOf(id); }).length; setStatus('这一趟过了 ' + list.length + ' 题。' + (gap ? '笔记里仍差 ' + gap + ' 题:这些题已不在错题本与收藏夹里,接口取不到它们的考点。' : '笔记里的题目模块已齐。'), 'ok'); renderNotesList(); // 模块下拉与那条提示按新登记的重画 }) .catch(function (e) { setStatus('重扫失败:' + e.message, 'err'); }); }); $('#gth-stray-clean').addEventListener('click', function () { var names = []; for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); if (k) names.push(k); } var keys = strayStorageKeys(names), old = staleResumeKeys(store.resume); if (!keys.length && !old.length) { setStatus('没有可清理的残留记录', 'ok'); return; } var kb = keys.reduce(function (a, x) { return a + (localStorage.getItem(x) || '').length; }, 0) / 1024; var detail = keys.map(function (x) { return '· 存储键 ' + x; }) .concat(old.map(function (x) { return '· 旧刷题进度 ' + x; })).join('\n'); if (!confirm('将删掉 ' + (keys.length + old.length) + ' 项不再被读取的记录' + (keys.length ? '(存储键约 ' + kb.toFixed(1) + ' KB)' : '') + ',不可恢复:\n\n' + detail + '\n\n确定继续?')) return; keys.forEach(function (x) { localStorage.removeItem(x); }); old.forEach(function (x) { delete store.resume[x]; }); saveStore(); setStatus('已清掉 ' + (keys.length + old.length) + ' 项残留记录', 'ok'); }); var notesSearchKey = ''; var gthNoteEditing = false; // 编辑器中途打开时,任何 renderNotesList 都跳过,避免被观察器/其他保存冲掉 var openNoteBox = null; // 当前打开的笔记编辑器(单编辑器约束,防止多个框叠加) $('#gth-note-search').addEventListener('input', debounce(function (e) { notesSearchKey = e.target.value; renderNotesList(); }, 150)); function renderNotesList() { if (gthNoteEditing) return; // 编辑中途不重绘,防止正在输入的 textarea 被冲掉 var listEl = $('#gth-notes-list'); var countEl = $('#gth-note-count'); if (!listEl) return; var ids = noteIdsWithHl(); var lastAct = function (id) { var t = (store.notes[id] && store.notes[id].updated) || 0; getHighlights(id).forEach(function (h) { if ((h.at || 0) > t) t = h.at; }); return t; }; renderNoteFilterMods(ids); var q = notesSearchKey.trim().toLowerCase(); var filtered = ids.filter(function (id) { if (!noteMatch(noteFacets(id), notesFilter)) return false; if (!q) return true; var n = store.notes[id] || {}; // 用户不认识题目 ID,搜索只针对笔记内容、题干快照与划线原文 if (((n.text || '') + ' ' + (n.snapshot || '')).toLowerCase().indexOf(q) >= 0) return true; return getHighlights(id).some(function (h) { return ((h.quote || '') + ' ' + (h.snap || '')).toLowerCase().indexOf(q) >= 0; }); }); notesShownIds = filtered.slice(); var lim = notesNarrowed() ? filtered.length + ' / ' : ''; countEl.textContent = lim + ids.length + ' 题'; if (!ids.length) { listEl.innerHTML = '
' + icon('bookOpen') + '
暂无笔记与划线。在错题页或答题报告里选中解析文字即可划线。
'; return; } if (!filtered.length) { listEl.innerHTML = '
' + icon('search') + '
' + (q ? '没有匹配的内容' : '当前筛选条件下没有笔记题') + '
'; return; } listEl.innerHTML = filtered.sort(function (a, b) { return lastAct(b) - lastAct(a); }).map(function (id) { var n = store.notes[id] || {}; var hls = getHighlights(id); var f = noteFacets(id); var subj = SUBJECT_NAME[n.subject != null ? n.subject : (hls[0] && hls[0].subject)] || '未知科目'; var dyn = masteredLabel(id); var dynCls = dyn === '已掌握' ? 'done' : (dyn.indexOf('待巩固') === 0 ? 'dyn' : ''); return '' + '
' + '
' + '' + esc(subj) + '' + (f.src === 'mock' ? '' + icon('bookOpen') + '模考收录' : '') + (f.mod && f.mod !== MOCK_UNCLS ? '' + esc(f.mod) + '' : '') + '' + esc(dyn) + '' + (hls.length ? '' + icon('highlighter') + esc(String(hls.length)) + ' 划' : '') + '' + fmtAgo(lastAct(id)) + '' + '' + '' + '' + '
' + ((n.snapshot || (hls[0] && hls[0].snap)) ? '
' + esc(n.snapshot || hls[0].snap) + '
' : '') + '
' + esc(n.text || (hls.length ? '' : '(无笔记内容)')) + '
' + (hls.length ? '
' + hls.map(function (h, i) { return hlRowHtml(h, i, ''); }).join('') + '
' : '') + '
'; }).join(''); $$('.gth-note-item', listEl).forEach(function (item) { var id = item.dataset.id; $('[data-act="edit"]', item).addEventListener('click', function () { openNoteEditor(id, item); }); $('[data-act="del"]', item).addEventListener('click', function () { var hasNote = !!store.notes[id], hasHl = countHighlights(id) > 0; var what = hasNote ? (hasHl ? '笔记和划线' : '笔记') : '划线'; if (!confirm('确定删除这道题的' + what + '?此操作不可恢复。')) return; delete store.notes[id]; delete store.highlights[id]; saveStore(); repaintHighlights(); refreshBadges(); renderNotesList(); }); $$('.gth-hlp-item .rm', item).forEach(function (rm) { rm.addEventListener('click', function () { hlRemove(id, Number(rm.closest('.gth-hlp-item').dataset.hl)); toast('已取消划线'); afterHlChange(); }); }); $$('.gth-hlp-item .t, .gth-hlp-item .n', item).forEach(function (node) { node.addEventListener('click', function () { openHlNote(id, Number(node.closest('.gth-hlp-item').dataset.hl), node.getBoundingClientRect()); }); }); }); } // 单编辑器约束:任何时候只保留一个编辑框 function closeNoteEditor(s) { if (!s) return; s.box.remove(); if (s.textEl && s.textEl.isConnected) s.textEl.style.display = ''; gthNoteEditing = false; } function openNoteEditor(id, itemEl) { // 同一道题再次点击「编辑」:聚焦已有框,不重复创建 if (openNoteBox && openNoteBox.itemEl === itemEl) { var t0 = $('textarea', openNoteBox.box); if (t0) t0.focus(); return; } closeNoteEditor(openNoteBox); // 切换到别的笔记前,先关掉上一个,避免叠加 blurEditors('panel'); // 同时收掉批注栏 / 划线的编辑器(焦点切换) gthNoteEditing = true; var n = store.notes[id] || { text: '' }; var box = document.createElement('div'); box.className = 'gth-note-edit'; box.innerHTML = '
' + '' + '
'; var ta = $('textarea', box); ta.value = n.text || ''; var textEl = $('.gth-note-text', itemEl); textEl.style.display = 'none'; itemEl.insertBefore(box, textEl.nextSibling); ta.focus(); openNoteBox = { box: box, itemEl: itemEl, textEl: textEl }; $('[data-act="cancel"]', box).addEventListener('click', function () { closeNoteEditor(openNoteBox); openNoteBox = null; }); $('[data-act="save"]', box).addEventListener('click', function () { openNoteBox = null; gthNoteEditing = false; // 解除重绘保护,否则 renderNotesList 会被拦截、列表不刷新 var newText = ta.value.trim(); if (newText) { setNote(id, newText); } else { delete store.notes[id]; saveStore(); } refreshBadges(); renderNotesList(); }); } function getAngular() { try { return window.angular || (typeof unsafeWindow !== 'undefined' && unsafeWindow.angular); } catch (e) { return null; } } // ---- 笔记批注栏:吸附在题目右侧页边距,默认只读,点击可二次编辑 ---- var itemsById = {}; // 恢复 / 清空后据此重绘批注 function renderAside(el, item) { var text = getNote(item.id); var wasEditing = el.dataset.editing; el.dataset.editing = ''; var head, body; if (text) { head = '
' + icon('pencil') + '笔记' + '
'; body = '
' + esc(text) + '
'; } else { head = '
' + icon('pencil') + '笔记
'; body = '
+ 添加笔记
'; } var html = head + body + asideHlHtml(item.id); // 幂等:内容没变就一个字都不写。观察器每次唤醒都会走到这里,而重写 innerHTML // 既会喂给 MutationObserver 形成 400ms 往复循环,又会把展开的划线清单、 // 正在编辑的批注框「一瞬间收回」。只在真的变了(或刚从编辑态退出)时才重绘 if (el.dataset.gthHtml === html && !wasEditing) return; el.dataset.gthHtml = html; var keepOpen = el.dataset.hlOpen === '1'; // 展开状态不在 HTML 里,重绘后要还原 el.innerHTML = html; if (keepOpen) { var hlList = $('.gth-aside-hl-list', el); if (hlList) hlList.classList.add('on'); // 箭头朝向由 CSS 跟着 .on 转,这里不再写文案 } var ed = $('[data-act="edit"]', el), ad = $('[data-act="add"]', el); if (ed) ed.addEventListener('click', function () { editAside(el, item); }); if (ad) ad.addEventListener('click', function () { editAside(el, item); }); bindAsideHl(el, item); } // 批注栏里的划线清单:内联展开,因为轨道容器 overflow:hidden 会裁掉浮层 function asideHlHtml(id) { var list = getHighlights(id); if (!list.length) return ''; // 收起态只报条数。轨道压到 clamp 下限 160px 时「(黄 x · 红 y)」会把这行挤成两行, // 而颜色在展开后的清单里每条前面都有圆点——收起态要回答的只是「要不要点开」 var desc = list.length + ' 条划线'; var rows = list.map(function (h, i) { return hlRowHtml(h, i, '点击改批注'); }).join(''); var lost = list.filter(function (h) { return h.lost; }).length; return '
' + '
' + icon('highlighter') + esc(desc) + (lost ? ' · ' + lost + ' 条已失效' : '') + '' + icon('chevronDown') + '
' + '
' + rows + '
' + '
' + '
'; } function bindAsideHl(el, item) { var t = $('.gth-aside-hl-t', el), list = $('.gth-aside-hl-list', el); if (!t || !list) return; t.addEventListener('click', function () { var on = list.classList.toggle('on'); el.dataset.hlOpen = on ? '1' : ''; // 记住展开状态,重绘后由 renderAside 还原 syncRail(); }); $$('.gth-hlp-item .t, .gth-hlp-item .n', list).forEach(function (node) { node.addEventListener('click', function () { openHlNote(item.id, Number(node.closest('.gth-hlp-item').dataset.hl), node.getBoundingClientRect()); }); }); $$('.gth-hlp-item .rm', list).forEach(function (rm) { rm.addEventListener('click', function (e) { e.stopPropagation(); hlRemove(item.id, Number(rm.closest('.gth-hlp-item').dataset.hl)); toast('已取消划线'); afterHlChange(); }); }); var cb = $('[data-act="collect"]', list); if (cb) cb.addEventListener('click', function () { openCollect([String(item.id)]); }); } function insertAtCursor(ta, text) { var s = ta.selectionStart == null ? ta.value.length : ta.selectionStart; var e = ta.selectionEnd == null ? ta.value.length : ta.selectionEnd; ta.value = ta.value.slice(0, s) + text + ta.value.slice(e); var p = s + text.length; try { ta.setSelectionRange(p, p); } catch (err) { /* 某些输入类型不支持 */ } ta.focus(); } function closeAsideEditor() { $$('.gth-aside[data-editing="1"]').forEach(function (a) { var it = itemsById[a.dataset.id]; if (it) renderAside(a, it); else a.dataset.editing = ''; }); } /* 焦点切换:三处笔记编辑器(题目批注栏 / 划线的批注浮层 / 笔记列表里的编辑框) 同一时刻只保留一个。否则划线的批注浮层会压住批注栏,两个 textarea 还会抢输入焦点。 keep 传本次要留下的那一个,其余全部收起 */ function blurEditors(keep) { if (keep !== 'aside') closeAsideEditor(); if (keep !== 'hlnote') closeHlNote(); if (keep !== 'hlmenu') closeHlMenu(); if (keep !== 'panel') { closeNoteEditor(openNoteBox); openNoteBox = null; } } function editAside(el, item) { blurEditors('aside'); el.dataset.editing = '1'; el.innerHTML = '
' + icon('pencil') + '编辑笔记
' + '' + '
' + '' + '' + '
' + '
'; var ta = $('textarea', el); ta.value = getNote(item.id); ta.focus(); var picker = $('[data-ins="1"]', el); $('[data-act="ins"]', el).addEventListener('click', function () { var list = getHighlights(item.id); picker.classList.toggle('on'); if (!picker.classList.contains('on')) { picker.innerHTML = ''; return; } if (!list.length) { picker.innerHTML = '
本题还没有划线
'; return; } picker.innerHTML = list.map(function (h, i) { return '
' + '' + '' + esc(h.quote || '') + '
'; }).join(''); $$('.gth-hlp-item .t', picker).forEach(function (node) { node.addEventListener('click', function () { var h = getHighlights(item.id)[Number(node.closest('.gth-hlp-item').dataset.hl)]; if (!h) return; var pre = ta.value && !/\n$/.test(ta.value) ? '\n' : ''; insertAtCursor(ta, pre + '> ' + h.quote + '\n'); }); }); }); $('[data-act="save"]', el).addEventListener('click', function () { setNote(item.id, ta.value.trim(), { snapshot: stripHtml(item.content).slice(0, 240), subject: item.content_type }); renderAside(el, item); refreshBadges(); renderNotesList(); }); $('[data-act="cancel"]', el).addEventListener('click', function () { renderAside(el, item); }); } // 只在真的变了才写 DOM。徽章的文案/图标每次都是同一份,无脑重写会不停惊动 // MutationObserver(→ refreshPageUI → 又一轮重绘) function setIf(el, prop, val) { if (!el) return; if (prop === 'hidden') { if (el.hidden !== !!val) el.hidden = !!val; return; } if (el[prop] !== val) el[prop] = val; } function refreshBadges() { $$('.gth-qbar').forEach(function (bar) { var id = bar.dataset.id; if (!id) return; var badge = $('.gth-badge', bar); var errEl = $('.gth-err', bar); var rec = store.mastered[id]; var done = isMastered(id); // 没做过也没标记过的题,未掌握没有信息量,弱化成幽灵样式;但它仍可点(点一下就标记已掌握) setIf(badge, 'className', 'gth-badge' + (done ? ' done' : (!rec ? ' ghost' : (getNote(id) ? ' has' : '')))); setIf(badge, 'textContent', done && rec.manual ? '已掌握 · 手动' : masteredLabel(id)); setIf(badge, 'title', done ? '点一下取消掌握标记' : '点一下标记为已掌握'); var n = errCountFor(id, bar.dataset.serverErr, bar.dataset.kind); if (n) { setIf(errEl, 'hidden', false); var t = errTag(n); setIf(errEl, 'className', t.cls); setIf(errEl, 'innerHTML', t.html); } else { // 收藏页里从没做过的题:不谎报「答错 1 次」。错题本里的题必然错过一次,所以那边照旧从 1 起算 setIf(errEl, 'hidden', true); setIf(errEl, 'className', 'gth-err'); setIf(errEl, 'innerHTML', ''); } }); // 恢复 / 清空本地数据后同步刷新已渲染的批注 rerenderAsides(); } function errCountFor(id, serverVal, kind) { var s = Number(serverVal) || 0; if (s) return s; var local = (store.wrongCount && store.wrongCount[id]) || 0; if (kind === 'collect') return local ? local + WRONG_BASE : 0; return local + WRONG_BASE; } // 手动掌握开关:与自动判分共用同一份 mastered 记录,只是直接把连对次数顶到阈值。 // 标记时留 manual 记号,日后重练交卷仍按自动规则走(答错清零、答对递增) function toggleMastery(id) { var rec = store.mastered[id]; if (rec && rec.streak >= MASTER_STREAK) { delete store.mastered[id]; toast('已取消掌握标记'); } else { store.mastered[id] = { streak: MASTER_STREAK, updated: Date.now(), manual: 1 }; toast('已标记为已掌握'); } saveStore(); refreshBadges(); renderNotesList(); } // 操作条的「笔记」按钮:滚到这道题,并把右侧批注栏切到编辑态 function focusNote(item) { if (viewOn) setView(false); var id = String(item.id); var aside = $('#gth-rail-in .gth-aside[data-id="' + id + '"]'); if (!aside) { toast('批注栏还没就绪,稍后再点一次'); return; } var content = railContent(), node = qNodes[id]; if (node && node.isConnected) { // 以「可见切片顶部」为基准滚动,错题页的内滚动盒与收藏页的页面滚动都适用 var base = railFrame ? railFrame.top : (content ? content.getBoundingClientRect().top : 0); var delta = node.getBoundingClientRect().top - base - 8; var sc = railScroller(content); if (sc) sc.scrollTop += delta; else window.scrollBy(0, delta); } aside.hidden = false; editAside(aside, item); var ta = aside.querySelector('textarea'); if (ta) { try { ta.focus(); } catch (e) {} } syncRail(); toast('笔记栏在题目右侧'); } /* ---------- 批注轨道:镜像题目滚动容器的位置,跟随其 scrollTop ---------- */ // 必须用子选择器:题目区里也有 .content(题干那一层),用后代选择器会在收藏页 // 误命中第一道题的题干。错题页的滚动层是 .inner-content 的直接子节点 var CONTENT_SEL = '.gongan2-container .right-content .inner-content > .content'; var qNodes = {}; // 题目 id -> ng-repeat 节点 // 错题页的列表包在 .content(height:600px;overflow:scroll)里,收藏页没有这层包裹, // 所以滚动容器要动态解析,不能写死选择器 function railContent() { return $(CONTENT_SEL) || $('.gongan2-container .right-content .inner-content') || $('.right-content .inner-content'); } // 往上找真正在滚动的那个祖先;返回 null 表示列表不自己滚、跟着页面整体滚动 function railScroller(content) { var el = content; while (el && el !== document.body && el !== document.documentElement) { if (el.scrollHeight - el.clientHeight > 4) { var ov = ''; try { ov = getComputedStyle(el).overflowY; } catch (e) {} if (ov === 'auto' || ov === 'scroll' || ov === 'overlay') return el; } el = el.parentElement; } return null; } /* 轨道的坐标模型:不猜「谁在滚」,只算「列表当前露出哪一段视口」。 错题页的列表在 600px 的内部滚动盒里,收藏页没有那层包裹、跟着页面滚, 两种情况下用同一套算法都能对齐: 可见切片 top = max(列表盒子 top, 0),bottom = min(盒子 bottom, 视口高) 批注框 top = 题目矩形 top − 可见切片 top */ var railFrame = null; // {rect, top, height} var railRaf = 0; function railVisible() { var content = railContent(); if (!content) return null; var r = content.getBoundingClientRect(); if (!r.height) return null; // 切到助手视图时原生内容被隐藏 var top = Math.max(r.top, 0); var bottom = Math.min(r.bottom, window.innerHeight); if (bottom - top < 40) return null; // 只剩一条缝时不摆东西 return { rect: r, top: top, height: bottom - top }; } // 只读布局 + 写 top,按 rAF 节流,可以挂在 scroll 上 function placeRailItems() { var inner = $('#gth-rail-in'); if (railEl.hidden || !railFrame || !inner) return; $$('.gth-aside', inner).forEach(function (a) { var node = qNodes[a.dataset.id]; if (node && node.isConnected) { a.hidden = false; a.style.top = Math.round(node.getBoundingClientRect().top - railFrame.top) + 'px'; } else { a.hidden = true; // 题目已从列表移除 / 节点还没就绪,别在轨道里留无主的「鬼影」框 } }); syncBalloons(); } function onRailScroll() { if (railEl.hidden || railRaf) return; railRaf = requestAnimationFrame(function () { railRaf = 0; var f = railVisible(); if (!f) { railEl.hidden = true; railFrame = null; return; } if (!railFrame || f.top !== railFrame.top || f.height !== railFrame.height) { // 页面整体滚动时列表盒子的 top 会变,轨道几何要跟着走 railEl.style.top = Math.round(f.top) + 'px'; railEl.style.height = Math.round(f.height) + 'px'; } railFrame = f; placeRailItems(); }); } function syncRail() { var rail = railEl, inner = $('#gth-rail-in'); if (!isListRoute() || !inner) { rail.hidden = true; railFrame = null; return; } // 滚动可能发生在内部容器(错题页的 .content),也可能发生在页面本身(收藏页)。 // capture 阶段的监听能同时收到两者,不必再猜哪个元素在滚 if (!document.body.dataset.gthRailScroll) { document.body.dataset.gthRailScroll = '1'; document.addEventListener('scroll', onRailScroll, { capture: true, passive: true }); } var content = railContent(); if (content && !content.dataset.gthRailLoad) { content.dataset.gthRailLoad = '1'; content.addEventListener('load', syncRail, true); // 图片加载后行高变化需重新对齐 } var f = railVisible(); if (!f) { rail.hidden = true; railFrame = null; return; } railFrame = f; /* 宽度按「可视区右缘 − 落点」现算。CSS 里那个 clamp(160px, (100vw-1000px)/2 - 24px, 280px) 把可用空间当成「容器右缘到视口右缘」,可落点其实是内容区右缘 + 24(内容在容器里还要减去 左菜单与内边距),窄视口下限 160px 会把整张卡推到屏幕外——实测卡片右缘 1166 越过了 clientWidth,elementsFromPoint 在那个点上取不到任何元素。 挤不下就不摆:宁可没有批注栏,也不要一张切在屏幕边上的。 (注:这一条修的不是「箭头看着像两个叠在一起」——那个是类名与 bootstrap 全局 .caret 撞车,见样式表里 .gth-caret 上面的说明。我当时把两件事当成了一件,白改了两轮。) */ var railLeft = Math.round(f.rect.right + 24); var avail = (document.documentElement.clientWidth || window.innerWidth) - railLeft - 8; if (avail < 120) { rail.hidden = true; railFrame = null; return; } rail.hidden = false; rail.style.left = railLeft + 'px'; rail.style.width = Math.min(280, avail) + 'px'; rail.style.top = Math.round(f.top) + 'px'; rail.style.height = Math.round(f.height) + 'px'; inner.style.transform = 'none'; // 老版本用 translate 跟随滚动,现在改按可视区算绝对坐标 placeRailItems(); } function injectListUI() { if (!isListRoute()) return; var ang = getAngular(); if (!ang) return; var railInner = $('#gth-rail-in'); if (!railInner) return; $$('[ng-repeat="item in subjectList"]').forEach(function (node) { if (node.dataset.gth) return; var item = null; try { item = ang.element(node).scope().item; } catch (e) { return; } if (!item || !item.id) return; // 防同一道题在轨道里出现多个批注栏: // 节点被站点重渲染替换(带图题目常见,图片加载/布局变化触发 digest)时,旧的 aside 会残留在轨道里。 // 用 item.id 作为稳定键去重。 railInner.querySelectorAll('.gth-aside[data-id="' + item.id + '"]').forEach(function (a) { a.remove(); }); node.dataset.gth = '1'; // 划线的定位根:paintRoot 靠它把题目 id 和 DOM 子树对上 node.setAttribute('data-gth-qid', item.id); itemsById[item.id] = item; qNodes[item.id] = node; if (!probed) { probed = true; probeFields([item]); } var bar = document.createElement('div'); bar.className = 'gth-qbar'; bar.dataset.id = item.id; bar.dataset.kind = routeKind(); bar.dataset.serverErr = serverErrCount(item); // 节点被站点重渲染替换时,旧的 qbar 还挂在原节点上。重新挂之前先清掉同题旧 qbar,避免重复 $$('.gth-qbar[data-id="' + item.id + '"]').forEach(function (b) { b.remove(); }); bar.innerHTML = '' + '' + '' + '' + ''; bar.querySelector('.gth-qbar-copy').addEventListener('click', function (e) { e.stopPropagation(); // 防止站点原有点击展开/收起等行为被误触 var btn = e.currentTarget; var it = itemsById[btn.dataset.id]; if (!it) return; var ok = copyText(copyQuestion(it)); btn.classList.add('copied'); btn.innerHTML = (ok ? icon('check') : icon('xCircle')) + (ok ? '已复制' : '复制失败'); setTimeout(function () { btn.classList.remove('copied'); btn.innerHTML = icon('copy') + '复制题目'; }, 1200); }); // 掌握状态:手动开关。站点的掌握度只由重练交卷驱动,而收藏页根本没有交卷场景, // 没有手动入口的话两处徽标就只是装饰,收藏题永远停在「未掌握」 bar.querySelector('.gth-badge').addEventListener('click', function (e) { e.stopPropagation(); toggleMastery(item.id); }); // 笔记入口:滚到这道题,并直接展开右侧批注栏的编辑器 bar.querySelector('.gth-qbar-note').addEventListener('click', function (e) { e.stopPropagation(); focusNote(item); }); var aside = document.createElement('div'); aside.className = 'gth-aside'; aside.dataset.id = item.id; renderAside(aside, item); // 操作条挂在 ng-repeat 节点的第一个子节点位置,也就是 .question-box(材料 + 题干)之前。 // 之前是 appendChild,落在解析之后,等于「整道题看完才看见控件」 node.insertBefore(bar, node.firstChild); railInner.appendChild(aside); }); refreshBadges(); } // 只写属性不惊动观察器:body 上那个 MutationObserver 只 observe childList,属性不在它眼里 function syncKeyTargets() { $$(KEYACT).forEach(function (el) { if (el.getAttribute('tabindex') === null) el.setAttribute('tabindex', '0'); if (!el.getAttribute('role')) el.setAttribute('role', 'button'); }); } document.addEventListener('keydown', function (e) { if (e.key !== 'Enter' && e.key !== ' ') return; var t = e.target && e.target.closest ? e.target.closest(KEYACT) : null; if (!t) return; e.preventDefault(); // 空格落在这些元素上不该把页面滚走 t.click(); }); // 错题列表与左侧菜单均由 AngularJS 异步渲染,用观察器在重绘后补回 var refreshPageUI = debounce(function () { if (isMocksPage()) { mocksScanTick(); return; } // mocks 页只做模考收录扫描,下面是 gongan 页的活 injectListUI(); injectMenuEntry(); /* 原生内容被 AngularJS 重绘后会丢掉隐藏类,壳要重贴;面板内容只读 store,所以按 storeRev 决定要不要重画。原来这里走的是面板的完整开启流程:站点每变一次就把组卷历史 整段 innerHTML 重写、监听重绑,展开的下拉和刚聚焦的控件一起被抹掉,还顺带每次重发一遍 行测模块请求。批注栏对位本来就在下一行,这里不再重复。 */ if (viewOn) { applyViewChrome(true); if (storeRev !== panelRev) renderPanel(); } syncRail(); repaintHighlights(); // 站点重绘后按锚点把划线重新落笔(自带幂等签名,不会往复触发) syncBalloons(); // 划线重画后,右侧页边距的批注气球要跟着重新对位 syncKeyTargets(); // 上面这些重绘会新建出带 click 的节点,键盘可达要补回来 }, 400); new MutationObserver(refreshPageUI).observe(document.body, { childList: true, subtree: true }); window.addEventListener('resize', syncRail); // 首次注入不在这里跑:那时后面还有一批 `var X = {}` 没赋值(syncBalloons 读 balloonEls // 就是在这一步 Object.keys(undefined) 抛错,把整个脚本的后半段——含划线条——一起带走)。 // 统一挪到 IIFE 末尾,见文件最后。 /* ================= 划线交互:选中即划 ================= */ var hlBar = document.createElement('div'); hlBar.id = 'gth-hlbar'; hlBar.innerHTML = '' + '' + '' + '' + ''; document.body.appendChild(hlBar); var pendingSel = null; var toastTimer = null; function toast(msg) { var t = $('#gth-toast'); if (!t) { t = document.createElement('div'); t.id = 'gth-toast'; document.body.appendChild(t); } t.textContent = msg; t.classList.add('on'); clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.classList.remove('on'); }, 1600); } // 这些区域不允许划线:插件自己的 UI、输入控件、答题页的导航与答题卡 var HL_BLOCK_SEL = '#gth-hlbar,#gth-collect,#gth-toast,#gth-rail,.gth-aside,.gth-view,' + '#gth-quiz .gthq-top,#gth-quiz .gthq-sheet,#gth-quiz .gthq-nav,textarea,input,select'; function contextOfRange(range) { var node = range.commonAncestorContainer; if (node.nodeType === 3) node = node.parentNode; var el = node && node.nodeType === 1 ? node : null; if (el && el.closest && el.closest(HL_BLOCK_SEL)) return null; while (el) { if (el.getAttribute && el.getAttribute('data-gth-qid')) { return { root: el, qid: el.getAttribute('data-gth-qid') }; } el = el.parentElement; } return null; } // 选区必须在这里就被抓成快照:一点工具条按钮,浏览器选区就没了 function captureSelection() { var sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) return null; var range = sel.getRangeAt(0); var quote = String(sel.toString()); if (!quote.trim()) return null; var ctx = contextOfRange(range); if (!ctx) return null; var start = offsetInRoot(ctx.root, range.startContainer, range.startOffset); if (start < 0) return null; var end = offsetInRoot(ctx.root, range.endContainer, range.endOffset); if (end < 0 || end <= start) return null; return { quote: quote, start: start, end: end, ctx: ctx, rect: range.getBoundingClientRect() }; } // 取(必要时先建)选区对应的那条划线。已有一条完整覆盖选区的就复用,不重复建 function ensureHl(p, color) { var hits = hlOverlap(p.ctx.root, p.start, p.end, null); if (hits.length === 1) return { qid: p.ctx.qid, idx: hits[0], reused: true }; var rec = addHighlight(p.ctx.qid, p.quote, p.start, color || 'yellow'); if (!rec) return null; var list = store.highlights[p.ctx.qid] || []; return { qid: p.ctx.qid, idx: list.length - 1, reused: false }; } /* Word 式切换:对选区再点一次「同色」= 取消这段的划线;点别的颜色 = 改色 */ function toggleHl(p, color) { var same = hlOverlap(p.ctx.root, p.start, p.end, color); if (same.length) { same.sort(function (a, b) { return b - a; }).forEach(function (i) { hlRemove(p.ctx.qid, i); }); return { removed: same.length }; } var other = hlOverlap(p.ctx.root, p.start, p.end, null); if (other.length === 1) { setHighlightColor(p.ctx.qid, other[0], color); return { recolored: 1 }; } var rec = addHighlight(p.ctx.qid, p.quote, p.start, color); return rec ? { added: 1 } : null; } function hideBar() { hlBar.classList.remove('on'); pendingSel = null; } function showBar() { var p = captureSelection(); pendingSel = p; if (!p) { hlBar.classList.remove('on'); return; } hlBar.classList.add('on'); var r = p.rect, bw = hlBar.offsetWidth, bh = hlBar.offsetHeight; var top = r.top - bh - 8; if (top < 8) top = r.bottom + 8; // 顶部放不下就翻到选区下方 var left = Math.max(8, Math.min(r.left + r.width / 2 - bw / 2, window.innerWidth - bw - 8)); hlBar.style.left = left + 'px'; hlBar.style.top = top + 'px'; } hlBar.addEventListener('mousedown', function (e) { e.preventDefault(); }); // 保住选区 /* 划线动作的唯一入口:选中后点工具条、或按 Alt+1/2/3,走的都是这条。 act = 'note'(挂批注)或颜色。 */ function applyHl(act, p) { if (act === 'note') { var h = ensureHl(p, 'yellow'); hideBar(); if (!h) { toast('批注失败:没能在题目里定位到这段文字'); return; } openHlNote(h.qid, h.idx, p.rect); return; } var r = toggleHl(p, act); hideBar(); if (!r) { toast('划线失败:没能在题目里定位到这段文字'); return; } if (r.removed) toast('已取消 ' + r.removed + ' 条划线'); else if (r.recolored) toast('已改为' + HL_LABEL[act]); else toast('已标为' + HL_LABEL[act]); afterHlChange(); } hlBar.addEventListener('click', function (e) { var btn = e.target.closest && e.target.closest('button'); if (!btn || !pendingSel) { hideBar(); return; } var p = pendingSel; if (btn.dataset.act === 'copy') { copyText(p.quote); toast('已复制'); hideBar(); return; } applyHl(btn.dataset.act === 'note' ? 'note' : (btn.dataset.c === 'red' ? 'red' : 'yellow'), p); }); document.addEventListener('mouseup', function (e) { if (e.target && e.target.closest && e.target.closest('#gth-hlbar')) return; setTimeout(showBar, 10); }); document.addEventListener('mousedown', function (e) { if (e.target && e.target.closest && e.target.closest('#gth-hlbar')) return; hideBar(); }); window.addEventListener('scroll', hideBar, true); window.addEventListener('resize', hideBar); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { hideBar(); closeHlMenu(); closeHlNote(); closeCollect(); return; } if (!e.altKey || !/^[123]$/.test(e.key)) return; var p = captureSelection(); if (!p) return; e.preventDefault(); applyHl(e.key === '3' ? 'note' : (e.key === '2' ? 'red' : 'yellow'), p); }); /* 点已划线的文字(无选区)→ 弹出这条划线的菜单:批注 / 改色 / 取消划线 */ document.addEventListener('mouseup', function (e) { var sel = window.getSelection(); if (sel && !sel.isCollapsed) return; // 有选区时归工具条处理 var mk = e.target && e.target.closest && e.target.closest('mark.gth-hl'); if (!mk) { closeHlMenu(); return; } var root = mk.closest('[data-gth-qid]'); if (!root || mk.dataset.gthI == null) return; openHlMenu(root.getAttribute('data-gth-qid'), Number(mk.dataset.gthI), mk.getBoundingClientRect()); }); /* ================= 划线的批注与取消(Word 式) ================= */ var hlMenuEl = null, hlMenuAnchor = null; var hlNoteEl = null, hlNoteCtx = null; function placeFloating(el, rect) { el.hidden = false; var bw = el.offsetWidth, bh = el.offsetHeight; var top = rect.bottom + 8; if (top + bh > window.innerHeight - 8) top = Math.max(8, rect.top - bh - 8); var left = Math.max(8, Math.min(rect.left || 0, window.innerWidth - bw - 8)); el.style.left = left + 'px'; el.style.top = top + 'px'; } function flashMark(qid, idx) { var root = qRoot(qid); if (!root) return; $$('mark.gth-hl[data-gth-i="' + idx + '"]', root).forEach(function (m) { m.classList.add('gth-hl-flash'); setTimeout(function () { m.classList.remove('gth-hl-flash'); }, 900); }); } function buildHlMenu() { hlMenuEl = document.createElement('div'); hlMenuEl.id = 'gth-hlmenu'; hlMenuEl.hidden = true; document.body.appendChild(hlMenuEl); hlMenuEl.addEventListener('mousedown', function (e) { e.preventDefault(); }); hlMenuEl.addEventListener('click', function (e) { var btn = e.target.closest && e.target.closest('button'); if (!btn || !hlMenuEl.dataset.q) return; var qid = hlMenuEl.dataset.q, idx = Number(hlMenuEl.dataset.i); var rect = hlMenuAnchor || { left: 0, top: 0, bottom: 0 }; if (btn.dataset.act === 'note') { closeHlMenu(); openHlNote(qid, idx, rect); return; } if (btn.dataset.act === 'del') { closeHlMenu(); hlRemove(qid, idx); toast('已取消划线'); afterHlChange(); return; } if (btn.dataset.c) { setHighlightColor(qid, idx, btn.dataset.c); closeHlMenu(); toast('已改为' + HL_LABEL[btn.dataset.c]); afterHlChange(); } }); } function openHlMenu(qid, idx, rect) { var rec = (store.highlights[qid] || [])[idx]; if (!rec) return; blurEditors('hlmenu'); // 焦点切换:先收起别的编辑器 if (!hlMenuEl) buildHlMenu(); hlMenuAnchor = rect; hlMenuEl.dataset.q = qid; hlMenuEl.dataset.i = String(idx); var txt = rec.quote || ''; if (txt.length > 26) txt = txt.slice(0, 26) + '…'; hlMenuEl.innerHTML = '
' + esc(txt) + '
' + '' + '
' + '' + '' + '
' + ''; placeFloating(hlMenuEl, rect); flashMark(qid, idx); } function closeHlMenu() { if (hlMenuEl) hlMenuEl.hidden = true; } function buildHlNote() { hlNoteEl = document.createElement('div'); hlNoteEl.id = 'gth-hlnote'; hlNoteEl.hidden = true; hlNoteEl.innerHTML = [ '
', '
' + icon('quote') + '批注', '
', '
', '', '
', '', '', '', '', '
' ].join(''); document.body.appendChild(hlNoteEl); hlNoteEl.addEventListener('click', function (e) { var btn = e.target.closest && e.target.closest('button[data-act]'); if (!btn || !hlNoteCtx) return; var act = btn.dataset.act; if (act === 'close' || act === 'cancel') { closeHlNote(); return; } var ta = $('textarea', hlNoteEl); if (act === 'save') { hlNoteSet(hlNoteCtx.qid, hlNoteCtx.idx, ta ? ta.value.trim() : ''); closeHlNote(); toast('批注已保存'); afterHlChange(); return; } if (act === 'del-note') { hlNoteSet(hlNoteCtx.qid, hlNoteCtx.idx, ''); closeHlNote(); toast('批注已删除'); afterHlChange(); } }); } function openHlNote(qid, idx, rect) { var rec = (store.highlights[qid] || [])[idx]; if (!rec) return; blurEditors('hlnote'); // 焦点切换:批注栏的笔记框同时只留一个 if (!hlNoteEl) buildHlNote(); hlNoteCtx = { qid: qid, idx: idx }; $('.gth-hln-q', hlNoteEl).innerHTML = '' + esc(rec.quote || ''); var ta = $('textarea', hlNoteEl); ta.value = rec.note || ''; placeFloating(hlNoteEl, rect || { left: 0, top: 0, bottom: 0 }); ta.focus(); flashMark(qid, idx); } function closeHlNote() { if (hlNoteEl) { hlNoteEl.hidden = true; hlNoteCtx = null; } } // 一处划线变动后,把所有「看到划线」的界面一起刷新 function afterHlChange() { refreshBadges(); renderNotesList(); rerenderAsides(); refreshQuizHl(); syncBalloons(); } function quizHlHtml(qid) { var list = getHighlights(qid); if (!list.length) return ''; return '
' + icon('highlighter') + list.length + ' 条划线
' + list.map(function (h, i) { return hlRowHtml(h, i, '点击改批注'); }).join(''); } function refreshQuizHl() { $$('#gth-quiz [data-hls]').forEach(function (box) { box.innerHTML = quizHlHtml(box.getAttribute('data-hls')); }); } // 答题报告页里的划线清单:点文字写批注,点 ✕ 取消划线 document.addEventListener('click', function (e) { if (!e.target || !e.target.closest) return; var box = e.target.closest('#gth-quiz [data-hls]'); if (!box) return; var item = e.target.closest('.gth-hlp-item'); if (!item) return; var qid = box.getAttribute('data-hls'), idx = Number(item.dataset.hl); if (e.target.closest('.rm')) { hlRemove(qid, idx); toast('已取消划线'); afterHlChange(); return; } if (e.target.closest('.t') || e.target.closest('.n')) { openHlNote(qid, idx, item.getBoundingClientRect()); } }); /* ---- 右侧页边距的批注气球:只在错题页,锚定到划线所在的那一行 ---- */ /* 气球上一直挂着 cursor:pointer 却没有任何处理器,点它没反应。按 Word 的模型, 页边那个框就是这条批注的化身,点它当然该改这条批注——所以接上,而不是把 pointer 摘掉。 走的是页边清单里点一条时同一个 openHlNote。 */ document.addEventListener('click', function (e) { var el = e.target && e.target.closest ? e.target.closest('.gth-balloon') : null; if (!el) return; var k = el.dataset.k || '', c = k.lastIndexOf(':'); // k 是 qid + ':' + 下标,qid 自己可能带冒号 if (c <= 0) return; openHlNote(k.slice(0, c), Number(k.slice(c + 1)), el.getBoundingClientRect()); }); var balloonEls = {}; function syncBalloons() { var inner = $('#gth-rail-in'); // 助手视图打开 / 不在列表页时轨道没有可见切片,批注气球一并收起 if (!inner || !isListRoute() || !railFrame) return; var want = {}; Object.keys(store.highlights).forEach(function (qid) { if (!qNodes[qid]) return; // 这道题不在当前列表里,没必要摆气球(也让滚动时的开销只跟当前列表有关) (store.highlights[qid] || []).forEach(function (rec, i) { if (rec.note) want[qid + ':' + i] = { qid: qid, idx: i, rec: rec }; }); }); Object.keys(balloonEls).forEach(function (k) { if (!want[k]) { balloonEls[k].remove(); delete balloonEls[k]; } }); var rows = []; Object.keys(want).forEach(function (k) { var w = want[k], el = balloonEls[k]; if (!el) { el = document.createElement('div'); el.className = 'gth-balloon'; el.dataset.k = k; el.title = '点击改这条批注'; inner.appendChild(el); balloonEls[k] = el; } // 只在内容真的变了才写 innerHTML,否则会不停触发观察器造成往复重绘 var q = w.rec.quote || ''; if (q.length > 40) q = q.slice(0, 40) + '…'; var sig = (w.rec.color || 'yellow') + '|' + q + '|' + (w.rec.note || ''); if (el.dataset.sig !== sig) { el.dataset.sig = sig; el.innerHTML = '
' + '' + esc(q) + '
' + '
' + esc(w.rec.note || '') + '
'; } rows.push({ el: el, qid: w.qid, idx: w.idx }); }); if (!rows.length) return; // 用 rect 相减得到「划线相对可见切片顶部」的偏移,与滚动位置无关 rows.forEach(function (row) { row.top = null; var node = qNodes[row.qid]; if (!node || !node.isConnected) return; var mk = $('mark.gth-hl[data-gth-i="' + row.idx + '"]', node); if (!mk) return; row.top = mk.getBoundingClientRect().top - railFrame.top; row.h = row.el.offsetHeight || 60; // 先量高度,避免写 top 再读高度来回触发重排 }); rows.sort(function (a, b) { return (a.top == null ? 1e9 : a.top) - (b.top == null ? 1e9 : b.top); }); var last = -1e9; rows.forEach(function (row) { if (row.top == null) { row.el.hidden = true; return; } // 题目不在当前列表里 row.el.hidden = false; var t = Math.max(row.top, last + 8); // 简单纵向避让:紧跟上一条,不互相压住 row.el.style.top = Math.round(t) + 'px'; last = t + row.h; }); } /* ================= 一键整理为笔记 ================= */ var collectEl = null; var collectState = { qids: null, src: 'both', onlyAnalysis: true, group: 'q' }; function collectIds() { var ids = noteIdsWithHl(); if (collectState.qids) { var want = collectState.qids.map(String); ids = ids.filter(function (id) { return want.indexOf(String(id)) >= 0; }); } return ids; } function collectItems() { var out = []; collectIds().forEach(function (id) { var note = store.notes[id]; var hls = []; if (collectState.src !== 'note') { hls = getHighlights(id).filter(function (h) { return !collectState.onlyAnalysis || h.block === 'analysis'; }); } var noteText = (collectState.src === 'hl' || !note) ? '' : (note.text || ''); if (!noteText && !hls.length) return; var at = (note && note.updated) || 0; hls.forEach(function (h) { if ((h.at || 0) > at) at = h.at; }); out.push({ id: id, subject: (note && note.subject != null) ? note.subject : (hls[0] ? hls[0].subject : null), snap: (note && note.snapshot) || (hls[0] && hls[0].snap) || '', note: noteText, hls: hls, at: at }); }); out.sort(function (a, b) { return b.at - a.at; }); return out; } function tagOf(it) { return '[' + (SUBJECT_NAME[it.subject] || '题目') + ' #' + it.id + ']'; } function collectMarkdown() { var items = collectItems(); var nHl = items.reduce(function (s, it) { return s + it.hls.length; }, 0); var L = ['# 上岸村划线笔记', '', '> 生成时间:' + new Date().toLocaleString() + ' 共 ' + items.length + ' 题 ' + nHl + ' 条划线', '']; if (!items.length) { L.push('(没有可整理的内容)'); return L.join('\n'); } // 按颜色归拢:把散在各题里的红色易错点收成一节,这才是划线真正的复习价值 if (collectState.group === 'color') { var red = [], yel = [], notes = []; items.forEach(function (it) { it.hls.forEach(function (h) { (h.color === 'red' ? red : yel).push(tagOf(it) + ' ' + (h.quote || '') + (h.note ? ' (批注:' + h.note.replace(/\s*\n\s*/g, ' ') + ')' : '')); }); if (it.note) notes.push(tagOf(it) + ' ' + it.note.replace(/\s*\n\s*/g, ' ')); }); if (red.length) { L.push('## 易错(' + red.length + ' 条)'); L.push(''); red.forEach(function (s) { L.push('- ' + s); }); L.push(''); } if (yel.length) { L.push('## 重点(' + yel.length + ' 条)'); L.push(''); yel.forEach(function (s) { L.push('- ' + s); }); L.push(''); } if (notes.length) { L.push('## 我的笔记(' + notes.length + ' 条)'); L.push(''); notes.forEach(function (s) { L.push('- ' + s); }); L.push(''); } return L.join('\n'); } items.forEach(function (it, i) { L.push('## ' + (i + 1) + '. ' + tagOf(it)); L.push(''); if (it.snap) { L.push('> ' + it.snap); L.push(''); } if (it.note) { L.push('**我的笔记**'); L.push(''); L.push(it.note); L.push(''); } if (it.hls.length) { L.push('**划线摘录**'); L.push(''); it.hls.forEach(function (h) { L.push('- [' + (HL_LABEL[h.color] || '重点') + '] ' + (h.quote || '') + (h.lost ? ' (原文已变更,未能重新定位)' : '')); if (h.note) L.push(' - 批注:' + h.note.replace(/\s*\n\s*/g, ' ')); }); L.push(''); } L.push('---'); L.push(''); }); return L.join('\n'); } function collectPlain() { return collectMarkdown() .replace(/^> /gm, '') .replace(/^#{1,6} /gm, '') .replace(/\*\*/g, '') .replace(/^- \[(重点|易错)\] /gm, '$1:'); } function buildCollectEl() { collectEl = document.createElement('div'); collectEl.id = 'gth-collect'; collectEl.hidden = true; collectEl.innerHTML = [ '
', '
' + icon('highlighter') + '整理为笔记', '
', '
', '
内容
', '
仅划线
', '
仅笔记
', '
划线 + 笔记
', '
', '
分组
', '
按题目
', '
按颜色归拢
', '
', '
', '', '
', '
', '
', '
', '', '', '', '
', '
' ].join(''); document.body.appendChild(collectEl); collectEl.addEventListener('click', function (e) { if (e.target === collectEl) { closeCollect(); return; } var chip = e.target.closest && e.target.closest('.gth-chip'); if (chip) { collectState[chip.parentNode.dataset.g] = chip.dataset.v; renderCollect(); return; } var btn = e.target.closest && e.target.closest('button[data-act]'); if (!btn) return; var act = btn.dataset.act; if (act === 'close') { closeCollect(); return; } if (act === 'copy') { copyText($('#gthc-pre').textContent); toast('已复制到剪贴板'); return; } if (act === 'txt') { download('上岸村划线笔记_' + stamp() + '.txt', new Blob([collectPlain()], { type: 'text/plain;charset=utf-8' })); return; } if (act === 'md') { download('上岸村划线笔记_' + stamp() + '.md', new Blob([collectMarkdown()], { type: 'text/markdown;charset=utf-8' })); } }); $('#gthc-only-analysis').addEventListener('change', function (e) { collectState.onlyAnalysis = e.target.checked; renderCollect(); }); } function renderCollect() { if (!collectEl) return; $$('.gth-chip', collectEl).forEach(function (c) { c.classList.toggle('on', c.dataset.v === collectState[c.parentNode.dataset.g]); }); var cb = $('#gthc-only-analysis'); if (cb) { cb.checked = collectState.onlyAnalysis; cb.disabled = collectState.src === 'note'; } $('#gthc-pre').textContent = collectMarkdown(); var items = collectItems(); var nh = items.reduce(function (s, it) { return s + it.hls.length; }, 0); var nn = items.filter(function (it) { return it.note; }).length; $('#gthc-meta').textContent = items.length + ' 题 · 划线 ' + nh + ' 条' + (nn ? ' · 笔记 ' + nn + ' 条' : ''); } function openCollect(qids) { collectState.qids = (qids && qids.length) ? qids : null; if (!collectEl) buildCollectEl(); renderCollect(); collectEl.hidden = false; } function closeCollect() { if (collectEl) collectEl.hidden = true; } var quizEl = document.createElement('div'); quizEl.id = 'gth-quiz'; quizEl.hidden = true; document.body.appendChild(quizEl); var quiz = null; // 随机组卷先分层再抽(层内按答错次数加权,Efraimidis-Spirakis):顽固错题 → 尚未重练 → 练过但没掌握 → 已掌握 // 已掌握(含收藏页手动打勾的)排最后:用户说过「会了」的题不该再来占题量。 function tierOf(q) { if (isMastered(q.id)) return 3; if (errCountOf(q) >= STUBBORN_MIN) return 0; if (!isPracticed(q.id)) return 1; return 2; } // 层内加权:key = 随机值^(1/errCountOf),次数越大 key 越接近 1、排得越前。errCountOf 已含 WRONG_BASE,就是界面上显示的那个次数 function weightedShuffle(arr) { return arr.map(function (q) { return { q: q, key: Math.pow(Math.random(), 1 / errCountOf(q)) }; }).sort(function (a, b) { return b.key - a.key; }) .map(function (x) { return x.q; }); } function weightedPick(arr, k) { var n = (k && k < arr.length) ? k : arr.length; // k=0 视为「全部」,仍按加权顺序打乱 var tiers = [[], [], [], []]; arr.forEach(function (q) { tiers[tierOf(q)].push(q); }); return tiers.reduce(function (out, t) { return out.concat(weightedShuffle(t)); }, []).slice(0, n); } // ---- 顺序刷题进度(从上次继续)---- // 键 = 筛选描述 + 顺序,值 = 该题在「完整有序列表」中的下标(0 起) function resumeKey(f, order) { return filterDesc(f) + '|' + order; } /* 续刷的键 = filterDesc + 顺序,而 filterDesc 里的来源标签早先写的是 SRC_NAME.both (「错题+收藏」),后来改成逐项拼(「错题本+收藏夹」)。老库里那条按旧标签算出的键 从此命不中:既接不上进度,「从头开始」也删不掉它。按旧写法再试一次,命中就搬成新键—— 用户「刷到第几题」保住,库里也不留一条永不命中的记录。 约束:这两个名字(srcLabel 的拼法 / SRC_NAME.both)再变,这条自愈就够不着老键了。 */ function resumeKeyOld(key) { return key.replace(srcLabel(['error', 'favorite']), SRC_NAME.both); } function readResume(key) { if (!key) return null; var r = store.resume[key]; if (r) return r; var old = resumeKeyOld(key); if (old === key || !store.resume[old]) return null; store.resume[key] = store.resume[old]; delete store.resume[old]; saveStore(); return store.resume[key]; } function clearResume(key) { if (!key) return; var old = resumeKeyOld(key); if (!store.resume[key] && !store.resume[old]) return; delete store.resume[key]; delete store.resume[old]; saveStore(); } /* 现版本算不出来的续刷键。filterDesc 现在拼出的键一定带来源段「 · 」(按日期与按科目都拼), 或者整串以「模考收录」开头(只勾模考时没有站点来源段)——两种都不满足的,是「来源还没进 描述」那一代留下的:键里没记来源,搬到错题本 / 收藏夹 / 合并哪一种都是猜,所以不自动搬, 只交给「清理残留」删。注意可自愈的那代(「错题+收藏 · …」)含「 · 」,不在这里命中。 */ function staleResumeKeys(resume) { return Object.keys(resume || {}).filter(function (k) { return k && k.indexOf(' · ') < 0 && k.indexOf(SRC_NAME.mock) !== 0; }); } /* 不再被任何脚本读取的存储键:已停用的机考助手留下的整库,以及排查时产生的历史快照。 在用的库(gongan_tiku_helper_,没有 __bak_ 段)与别的 ctx 的在用的库都落不进这两个模式。 */ function strayStorageKeys(names) { return (names || []).filter(function (k) { return /^gongan_exam_helper_/.test(k) || /^gongan_tiku_helper_[\w-]+__bak_/.test(k); }); } function saveResume(key, idx, id, answered) { if (!key) return; store.resume[key] = { idx: idx, id: id || '', at: Date.now(), answered: answered || {} }; saveStore(); } // 优先按题目 id 定位(列表变动时下标会漂移),找不到才退回记录的下标 function resumeOffset(key, list) { var r = readResume(key); if (!r) return 0; for (var i = 0; i < list.length; i++) { if (String(list[i].id) === String(r.id)) return i; } return Math.min(Math.max(r.idx || 0, 0), list.length); } $('#gth-start').addEventListener('click', function () { var f = readFilter('practice'); if (!f.srcs.length) { setStatus('至少勾一个来源(错题本 / 收藏夹 / 模考收录)', 'err'); return; } var num = Math.max(0, parseInt($('#gth-num').value, 10) || 0); var order = $('#gth-order').value; var seq = order !== 'random'; // 只有顺序刷题记录 / 续用进度 var key = seq ? resumeKey(f, order) : ''; var r = seq ? readResume(key) : null; var desc0 = filterDesc(f); // 按下这一刻的条件:取题期间面板上的筛选还能改,返回时要对得上 // 续刷时要拉到「上次位置 + 题量」,否则只取到第一页会拿不到后面的题 var want = !num ? 0 : ((r && r.idx) ? r.idx + num + 1 : num); setStatus('正在组卷…'); busy(this, fetchByFilter(f, want).then(function (all) { if (!all.length) { setStatus('该条件下没有题目(来源:' + srcLabel(f.srcs) + ')', 'err'); return; } var ordered = order === 'desc' ? all.slice().reverse() : all; var offset = seq ? resumeOffset(key, ordered) : 0; var restarted = false; if (offset >= ordered.length) { // 已刷到末尾,从头再来 offset = 0; clearResume(key); restarted = true; } var picked = order === 'random' ? weightedPick(ordered, num) : (num ? ordered.slice(offset, offset + num) : ordered.slice(offset)); if (!picked.length) { setStatus('没有可练习的题目', 'err'); return; } loaded.filter = f; // 供组卷历史复用同一筛选 // 顺序刷题:若上次有「未作答」的题,这次先跳到第一道未作答的题接着做 var startIdx = 0; if (seq && r && r.answered && picked.length) { for (var fu = 0; fu < picked.length; fu++) { if (!r.answered[String(picked[fu].id)]) { startIdx = fu; break; } } } var note = restarted ? '(已刷完,从头开始)' : ((offset + startIdx) ? '(从第 ' + (offset + startIdx + 1) + ' 题继续)' : ''); setStatus('组卷完成,共 ' + picked.length + ' 题' + note, 'ok'); /* 取题请求在飞的这段时间里筛选控件仍可改,而卷子是按按下那一刻的条件生成的。 不把这一点说出来,重练界面顶上的条件就成了面板当前值的影子——看着是这次的,其实不是。 */ if (filterDesc(readFilter('practice')) !== desc0) { toast('筛选在取题期间改过了:这份卷子按「' + desc0 + '」生成'); } startQuiz(picked, filterDesc(f), false, seq ? (r && r.answered) : null); if (seq) { quiz.resumeKey = key; quiz.resumeBase = offset; quiz.idx = startIdx; // 立刻记录起始位置;沿用上次已作答记录,作为本次续刷的基准 saveResume(key, offset + startIdx, picked[startIdx] && picked[startIdx].id, quiz.answeredIds); } renderQuiz(); // 用更新后的 idx 重新渲染,确保直接显示第一道未作答的题 renderPracticeHint(); }).catch(function (e) { setStatus('组卷失败:' + e.message, 'err'); })); }); // ---- 组卷历史:滚动保留最近三次 ---- function pushHistory(desc, list) { store.history.unshift({ at: Date.now(), desc: desc, n: list.length, filter: loaded.filter, ids: list.map(function (q) { return q.id; }) }); store.history = store.history.slice(0, 3); saveStore(); renderHistory(); } function renderHistory() { var box = $('#gth-history'); if (!box) return; var h = store.history || []; if (!h.length) { box.innerHTML = '
' + icon('play') + '
还没有组卷记录。设置题量后点「开始重练」即可。
'; return; } box.innerHTML = h.map(function (it, i) { return '
' + '' + esc(it.desc) + '' + '' + it.n + ' 题 · ' + fmtAgo(it.at) + '' + '' + '
'; }).join(''); $$('[data-his]', box).forEach(function (b) { b.addEventListener('click', function () { replayHistory(store.history[Number(b.dataset.his)]); }); }); } // 重练面板的引导文案:随机说明分层规则,顺序提示续刷位置并可重置 function renderPracticeHint() { var el = $('#gth-practice-hint'), btn = $('#gth-resume-reset'); if (!el) return; var order = $('#gth-order').value; if (order === 'random') { el.textContent = '随机组卷按「顽固错题 → 尚未重练 → 练过未掌握 → 已掌握」分层,同层内按答错次数加权'; if (btn) btn.hidden = true; return; } // r.idx 是「下次从第几题开始」的下标:中途退出=重做该题,交卷后=接着下一题 var r = readResume(resumeKey(readFilter('practice'), order)); el.textContent = r ? '下次从第 ' + (r.idx + 1) + ' 题继续' : '顺序刷题会自动记录进度,下次可从上次继续'; if (btn) btn.hidden = !r; } $('#gth-resume-reset').addEventListener('click', function () { clearResume(resumeKey(readFilter('practice'), $('#gth-order').value)); renderPracticeHint(); setStatus('已清除该条件下的刷题进度,下次从头开始', 'ok'); }); // 按历史记录的筛选条件重新拉题,再与当时的题目 id 快照取交集 function replayHistory(h) { if (!h || !h.filter) return; setStatus('正在按历史条件重新拉取题目…'); fetchByFilter(h.filter).then(function (list) { var want = {}; (h.ids || []).forEach(function (id) { want[id] = 1; }); var picked = list.filter(function (q) { return want[q.id]; }); if (!picked.length) { setStatus('这套卷的题目已不在当前来源中,无法重练', 'err'); return; } var miss = h.ids.length - picked.length; if (miss) setStatus('已找回 ' + picked.length + '/' + h.ids.length + ' 题,其余已移出列表', 'err'); else setStatus(''); startQuiz(picked, h.desc, true); }).catch(function (e) { setStatus('重练失败:' + e.message, 'err'); }); } function startQuiz(list, desc, fromHistory, seedAnswered) { quiz = { list: list, desc: desc, idx: 0, answers: {}, submitted: false, answeredIds: seedAnswered || {}, // 续刷时沿用上次已作答记录,避免被首次渲染清空 startAt: Date.now(), timer: null }; if (!fromHistory) pushHistory(desc, list); quizEl.hidden = false; document.body.style.overflow = 'hidden'; quiz.timer = setInterval(function () { var el = $('#gth-timer'); if (el && quiz && !quiz.submitted) el.textContent = fmtTime(Date.now() - quiz.startAt); }, 1000); renderQuiz(); } function closeQuiz() { if (quiz && quiz.timer) clearInterval(quiz.timer); quiz = null; quizEl.hidden = true; quizEl.innerHTML = ''; document.body.style.overflow = ''; refreshBadges(); renderPracticeHint(); // 退出重练后刷新「上次刷到第几题」 } function toggleAnswer(id, label, multi) { var cur = quiz.answers[id] || []; if (!multi) { quiz.answers[id] = [label]; } else { var i = cur.indexOf(label); if (i >= 0) cur.splice(i, 1); else cur.push(label); quiz.answers[id] = cur.slice().sort(); } if ((quiz.answers[id] || []).length) quiz.answeredIds[id] = true; // 只要作答过就记一笔 renderQuiz(); } function answeredCount() { return Object.keys(quiz.answers).filter(function (k) { return (quiz.answers[k] || []).length > 0; }).length; } function renderQuiz() { if (!quiz) return; // 顺序刷题:实时记录刷到第几题 + 已作答集合,中途退出后可从「第一道未作答」继续 if (quiz.resumeKey && !quiz.submitted && quiz.list[quiz.idx]) { saveResume(quiz.resumeKey, quiz.resumeBase + quiz.idx, quiz.list[quiz.idx].id, quiz.answeredIds); } quizEl.innerHTML = quiz.submitted ? reportHtml() : doingHtml(); bindQuiz(); repaintHighlights(); // 报告页每道题都是新 DOM,渲染完立刻把划线画回去 } function doingHtml() { var q = quiz.list[quiz.idx]; var multi = ansKey(q.correct_answer).length > 1; var sel = quiz.answers[q.id] || []; var opts = (q.opt || []).map(function (o) { return '
' + '' + esc(o.label) + '.' + '' + wakeImgs(o.content || '') + '
'; }).join(''); var cells = quiz.list.map(function (item, i) { var cls = 'gthq-cell'; if (i === quiz.idx) cls += ' cur'; else if ((quiz.answers[item.id] || []).length) cls += ' answered'; return '
' + (i + 1) + '
'; }).join(''); return '' + '
' + '' + icon('listChecks') + '第 ' + (quiz.idx + 1) + ' / ' + quiz.list.length + ' 题' + '已答 ' + answeredCount() + '' + '' + icon('clock') + fmtTime(Date.now() - quiz.startAt) + '' + '' + '' + '' + '
' + '
' + '
' + '
' + icon('hash') + '题目ID ' + esc(q.id) + ' ' + esc(quiz.desc) + '
' + (q.material ? '
' + wakeImgs(q.material) + '
' : '') + '
' + wakeImgs(q.content || '') + '
' + '
' + (opts || '
该题型无选项
') + '
' + (multi ? '
多选题,共 ' + ansKey(q.correct_answer).length + ' 个正确选项
' : '') + '
' + '
' + cells + '
' + '
' + '' + '' + '
' + '
'; } function reportHtml() { var right = 0; var items = quiz.list.map(function (q, i) { var ans = quiz.answers[q.id] || []; var answered = ans.length > 0; var mine = ansKey(ans); var ok = answered && mine === ansKey(q.correct_answer); if (ok) right++; var opts = (q.opt || []).map(function (o) { var isRight = ansKey(q.correct_answer).indexOf(o.label) >= 0; var isMine = (quiz.answers[q.id] || []).indexOf(o.label) >= 0; var mark = isRight ? '(正确)' : (isMine ? '(你选的)' : ''); var style = isRight ? ' style="color:#16a34a;font-weight:600"' : (isMine ? ' style="color:var(--gth-destructive)"' : ''); return '' + esc(o.label) + '. ' + wakeImgs(o.content || '') + mark + ''; }).join(''); return '' + '
' + '
' + '' + (ok ? icon('checkCircle') : (answered ? icon('xCircle') : icon('warn'))) + (ok ? '正确' : (answered ? '错误' : '未作答')) + '' + '第 ' + (i + 1) + ' 题' + '·' + esc(SUBJECT_NAME[q.content_type] || '') + '' + '·#' + esc(q.id) + '' + (function () { var t = errTag(errCountOf(q)); return '' + t.html + ''; })() + '
' + (q.material ? '
' + wakeImgs(q.material) + '
' : '') + '
' + wakeImgs(q.content || '') + '
' + '
' + opts + '
' + '
你的答案:' + (mine || '未作答') + '
' + '
正确答案:' + ansKey(q.correct_answer) + '
' + (analysisOf(q) ? '
解析
' + wakeImgs(analysisOf(q)) + '
' : '') + '
' + '
' + '' + '
' + '
' + '
' + '
'; }).join(''); var pct = quiz.list.length ? Math.round(right / quiz.list.length * 100) : 0; // 错题数只统计「答过且答错」的,未作答不计入错题 var wrongCount = quiz.list.filter(function (q) { var a = quiz.answers[q.id] || []; return a.length > 0 && ansKey(a) !== ansKey(q.correct_answer); }).length; return '' + '
' + '' + icon('checkCircle') + '得分 ' + right + ' / ' + quiz.list.length + '' + '正确率 ' + pct + '%' + '' + icon('clock') + '用时 ' + fmtTime(Date.now() - quiz.startAt) + '' + '' + '' + '' + '' + '
' + '
' + (wrongCount ? '
' + icon('warn') + '错题 ' + wrongCount + ' 道
' : '') + '
' + items + '
' + '
'; } function bindQuiz() { var q = quiz.list[quiz.idx]; $$('.gthq-opt', quizEl).forEach(function (el) { el.addEventListener('click', function () { toggleAnswer(q.id, el.dataset.label, ansKey(q.correct_answer).length > 1); }); }); $$('.gthq-cell', quizEl).forEach(function (el) { el.addEventListener('click', function () { quiz.idx = Number(el.dataset.i); renderQuiz(); }); }); var prev = $('#gth-prev', quizEl); if (prev) prev.addEventListener('click', function () { if (quiz.idx > 0) { quiz.idx--; renderQuiz(); } }); var next = $('#gth-next', quizEl); if (next) next.addEventListener('click', function () { if (quiz.idx < quiz.list.length - 1) { quiz.idx++; renderQuiz(); } }); var submit = $('#gth-submit', quizEl); if (submit) submit.addEventListener('click', function () { var un = quiz.list.length - answeredCount(); if (un > 0 && !confirm('还有 ' + un + ' 题未作答,确定交卷吗?')) return; clearInterval(quiz.timer); quiz.timer = null; applyMastery(); quiz.submitted = true; // 交卷:未作答的不算「做过」。全部作答 -> 整套完成(下次从头开始); // 否则下次从「第一道未作答」继续,而不是径直跳到这套之后 if (quiz.resumeKey) { var fu = 0; for (; fu < quiz.list.length; fu++) { if (!quiz.answeredIds[String(quiz.list[fu].id)]) break; } if (fu >= quiz.list.length) saveResume(quiz.resumeKey, quiz.resumeBase + quiz.list.length, '', {}); else saveResume(quiz.resumeKey, quiz.resumeBase + fu, quiz.list[fu].id, quiz.answeredIds); } renderQuiz(); quizEl.querySelector('.gthq-main').scrollTop = 0; }); var ow = $('#gth-onlywrong', quizEl); if (ow) ow.addEventListener('click', function () { $$('#gth-report .gthq-r-item').forEach(function (el) { el.hidden = !el.classList.contains('wrong'); }); }); var al = $('#gth-all', quizEl); if (al) al.addEventListener('click', function () { $$('#gth-report .gthq-r-item').forEach(function (el) { el.hidden = false; }); }); var exit = $('#gth-exit', quizEl); if (exit) exit.addEventListener('click', closeQuiz); $$('textarea[data-note]', quizEl).forEach(function (ta) { ta.addEventListener('input', debounce(function () { var q2 = null; for (var i = 0; i < quiz.list.length; i++) if (String(quiz.list[i].id) === ta.dataset.note) { q2 = quiz.list[i]; break; } setNote(ta.dataset.note, ta.value.trim(), { snapshot: q2 ? stripHtml(q2.content || '').slice(0, 240) : '', subject: q2 ? q2.content_type : null }); }, 400)); }); // 报告页:划线清单(点文字写批注 / 点 ✕ 取消)由 document 上的委托统一处理 var cb = $('#gth-report'); if (cb) refreshQuizHl(); $$('.gthq-r-item[data-gth-qid] [data-act="collect"]', quizEl).forEach(function (btn) { var item = btn.closest('.gthq-r-item'); btn.addEventListener('click', function () { openCollect([String(item.getAttribute('data-gth-qid'))]); }); }); } // 交卷时统计对错:未作答的题目既不计入对错,也不累计答错次数、不改变掌握度 function applyMastery() { quiz.list.forEach(function (q) { var ans = quiz.answers[q.id]; if (!ans || !ans.length) return; // 未作答:直接跳过,当作「没做过」处理 var ok = ansKey(ans) === ansKey(q.correct_answer); var prev = (store.mastered[q.id] && store.mastered[q.id].streak) || 0; store.mastered[q.id] = { streak: ok ? prev + 1 : 0, updated: Date.now() }; bumpWrongCount(q, ok); markPracticed(q.id); // 「尚未重练」层只认这里的登记 }); saveStore(); } /* ==================== 全真模考:解析页题目收录 ==================== */ // mocks 是独立 SPA,模考成绩不进站点错题 API。这里做被动抓取: // 主通路从 Angular ng-repeat 节点的 scope 抠题目对象;兜底通路嗅探 XHR 响应。 // 收录进 store.mockQs;答错的并入错题本(fetchByFilter),全部题经「模考收录」来源导出/重练。 // // 实测过的解析页题目对象(场次 5158 的 [ng-repeat="item in jiexieSubjects"]): // 列表态 11 键(subId/result/examPointId/userAnswers/index/id/examPointName/root_id/ // root_name/isPlaying/$$hashKey),题面与 correct_answer 由站点的 initJiexi(id) // 逐题请求 tiku_v2.subject_analysis 才补上(补完 52 键)。 // 所以答对题的 correct_answer 是填的(实测 result=1 的 2455010:correct_answer // 与 userAnswers 都是 ["A"]),它不能拿来区分做没做。 // 作答没有下划线 user_answer 这个键——只以驼峰 userAnswers 存在。 // result 是站点的权威判分,实测三态:0=答错(48) / 1=答对(99) / 2=未做(33)。 var MOCK_QID_KEYS = ['id', 'question_id', 'subject_id', 'qid', 'content_id']; var MOCK_STEM_KEYS = ['content', 'stem', 'question', 'title', 'topic']; var MOCK_MAT_KEYS = ['material', 'materials']; var MOCK_OPT_KEYS = ['opt', 'option', 'options', 'opt_list', 'option_list']; var MOCK_USER_KEYS = ['userAnswers', 'user_answer', 'my_answer', 'self_answer', 'user_ans', 'answer']; var MOCK_CORRECT_KEYS = ['correct_answer', 'right_answer', 'true_answer', 'correct', 'answer_right']; var MOCK_ANALYSIS_KEYS = ['analysis', 'analysis_shadow', 'explain', 'explanation']; // 科目只认明确的科目字段。站点题目里的 `type` 是题型(type_name 是「单选题」), // 不是科目,拿它兜底会把所有模考题的科目列写成行测。取不到就留 null。 var MOCK_SUBJECT_KEYS = ['content_type', 'subject_type', 'subject']; var MOCK_RESULT_KEYS = ['result']; // 模块名:解析页列表态的 examPointName 最可靠(批量收题时列表项就在手,零额外请求), // 题面接口的 exam_point / check_point 兜底。root_name 是卷名不是模块,不参与。 var MOCK_MODULE_KEYS = ['examPointName', 'exam_point', 'check_point']; function mockModuleOf(raw) { var v = pickKey(raw, MOCK_MODULE_KEYS); return v == null ? '' : String(v).trim(); } // 站点判分归一化:返回 0(答错)/ 1(答对)/ 2(未做),取不到返回 null 交回比对通路 function mockResultOf(raw) { var r = pickKey(raw, MOCK_RESULT_KEYS); if (typeof r === 'string' && /^-?\d+$/.test(r)) r = Number(r); return (r === 0 || r === 1 || r === 2) ? r : (typeof r === 'number' && isFinite(r) ? r : null); } function isMocksPage() { return /\/wxpage\/tiku\/mocks\/index\.html/.test(location.pathname); } // 场次 id:hash 形如 #/mocks/baogao/5158/detailv3;防御性取首个 ≥3 位数字段 function mockExamIdOf() { var m = /(\d{3,})/.exec(location.hash || ''); return m ? m[1] : ''; } // 候选 key 依序取首个非空值('' / null / undefined 视为空) function pickKey(obj, keys) { for (var i = 0; i < keys.length; i++) { var v = obj[keys[i]]; if (v != null && v !== '') return v; } return undefined; } // 题目形状判据:scope 提取与 XHR 嗅探共用,宁缺毋滥 function looksLikeQuestion(o) { if (!o || typeof o !== 'object' || Array.isArray(o)) return false; var id = pickKey(o, MOCK_QID_KEYS); if (id === undefined || !/^\d+$/.test(String(id))) return false; var stem = pickKey(o, MOCK_STEM_KEYS); if (!stem || !String(stem).trim()) return false; var ans = pickKey(o, MOCK_CORRECT_KEYS); var ana = pickKey(o, MOCK_ANALYSIS_KEYS); return (ans !== undefined && ans !== '') || (ana !== undefined && ana !== ''); } // 答案归一化:数组原样;JSON 串解析;裸字符串 "A"/"AC" 保留 function mockParseAns(v) { if (v == null || v === '') return ''; if (Array.isArray(v)) return v; if (typeof v === 'string' && /^[\[{"]/.test(v)) { try { return JSON.parse(v); } catch (e) {} } return v; } // 选项归一化:容忍 JSON 串 / 数组 / 对象映射 / "A. xxx" 字符串等形态 function mockParseOpt(v) { if (v == null || v === '') return []; if (typeof v === 'string') { if (!/^[\[{]/.test(v)) return []; try { v = JSON.parse(v); } catch (e) { return []; } } if (!Array.isArray(v)) { if (typeof v === 'object') { // {"A":"xxx","B":"yyy"} 形态 return Object.keys(v).map(function (k) { return { label: String(k).toUpperCase(), content: v[k] == null ? '' : String(v[k]) }; }); } return []; } return v.map(function (o) { if (typeof o === 'string') { var m = /^([A-Za-z])[.、.::\s]\s*(.*)$/.exec(o.trim()); return m ? { label: m[1].toUpperCase(), content: m[2] } : { label: '', content: o }; } if (o && typeof o === 'object') { var label = pickKey(o, ['label', 'name', 'key', 'option', 'id']); var content = pickKey(o, ['content', 'value', 'text', 'title', 'desc']); return { label: label == null ? '' : String(label).toUpperCase(), content: content == null ? '' : String(content) }; } return { label: '', content: String(o) }; }); } // 把 mocks 页拿到的原始题目对象映射成与站点 API 一致的形状(复用后续导出/重练全链路) function mockFieldMap(raw) { if (!looksLikeQuestion(raw)) return null; var q = { id: pickKey(raw, MOCK_QID_KEYS), content: String(pickKey(raw, MOCK_STEM_KEYS) || ''), material: String(pickKey(raw, MOCK_MAT_KEYS) || ''), opt: mockParseOpt(pickKey(raw, MOCK_OPT_KEYS)), correct_answer: mockParseAns(pickKey(raw, MOCK_CORRECT_KEYS)), user_answer: mockParseAns(pickKey(raw, MOCK_USER_KEYS)), analysis: String(pickKey(raw, MOCK_ANALYSIS_KEYS) || ''), content_type: null, module: mockModuleOf(raw), result: mockResultOf(raw) }; var ct = pickKey(raw, MOCK_SUBJECT_KEYS); if (typeof ct === 'number' || (typeof ct === 'string' && /^\d+$/.test(ct))) q.content_type = Number(ct); return q; } // 首个抓到的原始样本只打一次日志:字段清单 + 截断 JSON,便于按真实字段收敛候选表 var mockSampleLogged = false; function logMockSample(raw) { if (mockSampleLogged) return; mockSampleLogged = true; try { console.log('[错题助手] mocks 题目字段:' + Object.keys(raw).join(', ')); console.log('[错题助手] mocks 样本:' + JSON.stringify(raw).slice(0, 1500)); } catch (e) {} } function dedupeById(list) { var seen = {}, out = []; list.forEach(function (q) { if (!q || q.id == null) return; var k = String(q.id); if (seen[k]) return; seen[k] = 1; out.push(q); }); return out; } // 主通路:扫描 ng-repeat 节点,从 scope 抠出被迭代对象再识别题目 function harvestFromScope(root) { var ng = getAngular(); if (!ng) return []; var out = []; $$('[ng-repeat],[data-ng-repeat]', root).forEach(function (node) { var attr = node.getAttribute('ng-repeat') || node.getAttribute('data-ng-repeat') || ''; var m = /([A-Za-z_$][\w$]*)\s+in\s+/.exec(attr); var it = null; try { var sc = ng.element(node).scope(); if (!sc) return; if (m && sc[m[1]]) it = sc[m[1]]; if (!it) it = sc.item || sc.q || sc.question || null; } catch (e) { return; } if (!it || typeof it !== 'object') return; var q = mockFieldMap(it); if (q) { out.push(q); logMockSample(it); } }); return dedupeById(out); } // 兜底通路:嗅探 XHR 响应里的题目数组(AngularJS $http 走 XHR,可覆盖站点全部数据请求)。 // 注意 @run-at document-idle 之前的请求抓不到——解析页数据是点击后才加载的,实际够用。 var mockSniffCache = {}; // 题目 id -> 归一化题目 var mockSniffOrder = []; // 写入顺序,超限淘汰最旧 var mockSniffHooked = false; function mockSniffRemember(q) { var k = String(q.id); if (!mockSniffCache[k]) { mockSniffOrder.push(k); if (mockSniffOrder.length > 3000) delete mockSniffCache[mockSniffOrder.shift()]; } mockSniffCache[k] = q; } function mockSniffed() { return mockSniffOrder.map(function (k) { return mockSniffCache[k]; }); } function sniffJson(j, depth) { if (!j || typeof j !== 'object' || depth > 6) return; if (Array.isArray(j)) { var hit = 0; for (var i = 0; i < j.length; i++) { if (j[i] && typeof j[i] === 'object' && looksLikeQuestion(j[i])) hit++; } if (j.length && hit / j.length >= 0.6) { // 题目数组:整组收录,不再下钻 j.forEach(function (o) { var q = mockFieldMap(o); if (q) { mockSniffRemember(q); logMockSample(o); } }); } else { j.forEach(function (o) { sniffJson(o, depth + 1); }); } return; } if (looksLikeQuestion(j)) { var q = mockFieldMap(j); if (q) { mockSniffRemember(q); logMockSample(j); } return; } Object.keys(j).forEach(function (k) { sniffJson(j[k], depth + 1); }); } function hookXhrSniffer() { try { var w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; var proto = w.XMLHttpRequest && w.XMLHttpRequest.prototype; if (!proto || proto.__gthSniffed) return; proto.__gthSniffed = true; var origOpen = proto.open; proto.open = function (method, url) { this.__gthUrl = url; return origOpen.apply(this, arguments); }; var origSend = proto.send; proto.send = function () { var self = this; this.addEventListener('load', function () { try { var t = self.responseText || ''; if (t.length > 200 && t.length < 3000000 && /analysis|subject|question|correct_answer|opt/i.test(t)) { sniffJson(JSON.parse(t), 0); } } catch (e) {} }); return origSend.apply(this, arguments); }; mockSniffHooked = true; } catch (e) { /* 沙箱拦截失败时静默降级:仅靠 scope 提取 */ } } // 收录合并:新题写入;已有题仅在「从未作答 → 拿到作答信息」时补 done/ok。 // 答错计数幂等:同场次同一题只计一次(processed 登记在 store.mocks[场次].processed)。 // 注:模考题 id 与练习题 id 是否同源未证实——若撞键且内容不同,需改用合成 id //(如 'm'+examId+'_'+qid,配合内容指纹比对),现阶段只 warn 不自动改写。 function mergeMockQs(list, examId) { if (!examId) examId = 'unknown'; if (!store.mocks[examId]) store.mocks[examId] = { at: Date.now(), processed: {} }; if (!store.mocks[examId].processed) store.mocks[examId].processed = {}; var reg = store.mocks[examId]; var added = 0, wrongNew = 0, changed = false; list.forEach(function (q) { var k = String(q.id); var done, ok; if (q.result !== null) { // 未做(2)不算答错:既不进错题本也不计次,只在「模考收录」里留着 done = q.result === 2 ? 0 : 1; ok = (q.result === 0 || q.result === 2) ? 0 : 1; } else { done = (ansKey(q.correct_answer) && ansKey(q.user_answer)) ? 1 : 0; ok = (done && ansKey(q.user_answer) === ansKey(q.correct_answer)) ? 1 : 0; } var cur = store.mockQs[k]; if (!cur) { if (store.notes[k] || store.mastered[k]) { console.warn('[错题助手] 模考题 id 与已有练习题记录撞键:' + k + '(请核对内容是否同一题)'); } store.mockQs[k] = { id: q.id, content: q.content, material: q.material, opt: q.opt, correct_answer: q.correct_answer, user_answer: done ? q.user_answer : '', analysis: q.analysis, content_type: q.content_type, module: q.module || '', examId: examId, done: done, ok: ok, at: Date.now() }; cur = store.mockQs[k]; added++; changed = true; } else { // 已有记录只补两处:未作答 → 拿到作答、无模块 → 拿到模块;题面一律不动 var touched = false; if (!cur.done && done) { cur.user_answer = q.user_answer; cur.done = done; cur.ok = ok; touched = true; } if (!cur.module && q.module) { cur.module = q.module; touched = true; } if (touched) { cur.at = Date.now(); changed = true; } } if (done && !ok && !reg.processed[k]) { reg.processed[k] = 1; bumpWrongCount({ id: q.id }, false); wrongNew++; changed = true; } }); reg.at = Date.now(); if (changed) saveStore(); return { added: added, wrongNew: wrongNew }; } // mods = 面板上勾的模考模块名,空数组 = 全部 function mockMatchModule(m, mods) { return !mods || !mods.length || mods.indexOf(m.module || MOCK_UNCLS) >= 0; } // store.mockQs -> 导出/重练用的题目形状(附 _mock = 场次 id,导出时显示来源) function mockToList(onlyWrong, mods) { var out = []; Object.keys(store.mockQs).forEach(function (k) { var m = store.mockQs[k]; if (onlyWrong && !(m.done && !m.ok)) return; if (!mockMatchModule(m, mods)) return; out.push({ id: m.id, content: m.content, material: m.material, opt: m.opt, correct_answer: m.correct_answer, user_answer: m.user_answer, analysis: m.analysis, content_type: m.content_type, module: m.module || '', _mock: m.examId }); }); return out; } function mockAllList(mods) { return mockToList(false, mods); } function mockWrongList(mods) { return mockToList(true, mods); } // 模考模块 chips 的候选:只看本地真收到的题,不去对站点的行测考点表(两套名字未必同源) function mockModuleOptions() { var by = {}, out = []; Object.keys(store.mockQs).forEach(function (k) { var name = store.mockQs[k].module || MOCK_UNCLS; if (!by[name]) { by[name] = { name: name, n: 0 }; out.push(by[name]); } by[name].n++; }); out.sort(function (a, b) { return b.n - a.n || (a.name < b.name ? -1 : 1); }); return out; } // ---- 批量收题:列表态只有判分,题面得逐题问站点接口 ---- // 站点自己也是这么补的(mocks 控制器的 initJiexi 打 api/v2/tiku/{examType}/{id}/analysis)。 var MOCK_COLLECT_CAP = 300; var mockCollecting = false; function mockExamType() { var m = /exam_type=(\d+)/.exec(location.search || '') || /exam_type=(\d+)/.exec(location.hash || ''); return m ? m[1] : '1'; } function mockStatus(html) { var st = $('#gth-mock-status'); if (st) st.innerHTML = html; } // 解析页列表里的题目:列表态 11 个键里就有 id / result / userAnswers,够挑出答错的 function mockSheetItems() { var ng = getAngular(); if (!ng) return []; var out = [], seen = {}; $$('[ng-repeat="item in jiexieSubjects"]').forEach(function (node) { var it; try { it = ng.element(node).scope().item; } catch (e) { return; } if (!it || typeof it !== 'object' || !/^\d+$/.test(String(it.id || ''))) return; if (seen[String(it.id)]) return; seen[String(it.id)] = 1; out.push(it); }); return out; } function fetchMockSubject(id) { var t = mockExamType(); return apiGet(API_V2 + 'tiku/' + t + '/' + id + '/analysis', { id: id, exam_type: t }); } function collectMockWrong() { // 原来这里静默 return:再点一下像没反应,用户只会接着点。要说一句为什么不动 if (mockCollecting) { mockStatus('这一轮还在补题面,等它跑完再点。'); return; } var examId = mockExamIdOf(); var items = mockSheetItems(); if (!items.length) { mockStatus('这一页还没有题目列表:先进「全部解析」或「错题解析」再收。'); return; } var wrongAll = items.filter(function (it) { return it.result === 0; }); var need = wrongAll.filter(function (it) { var cur = store.mockQs[String(it.id)]; return !(cur && cur.content); // 已经翻到过、题面在册的就不重复抓 }).slice(0, MOCK_COLLECT_CAP); if (!need.length) { mockStatus('本场答错 ' + wrongAll.length + ' 题,题面都已收录,不用补。'); return; } mockCollecting = true; var allBtn = $('#gth-mock-all'); if (allBtn) allBtn.disabled = true; var doneN = 0, okN = 0, failN = 0, i = 0, batch = [], firstErr = ''; function flush() { if (batch.length) { mergeMockQs(batch, examId); batch = []; } } function step() { if (i >= need.length) { flush(); mockCollecting = false; if (allBtn) allBtn.disabled = false; mocksScanTick(); mockStatus('补题面结束:成功 ' + okN + ' 题,失败 ' + failN + ' 题。' + (firstErr ? '
首个失败原因:' + esc(firstErr) : '答错的已并入错题本,回错题页即可导出 / 重练。')); return; } var it = need[i++]; fetchMockSubject(it.id).then(function (data) { // 判分与模块名取自列表项(题面接口不带这两样),题面取自接口 var raw = Object.assign({}, data || {}, { result: it.result, userAnswers: it.userAnswers, examPointName: it.examPointName }); var q = mockFieldMap(raw); if (q) { batch.push(q); okN++; } else { failN++; if (!firstErr) firstErr = '题目形状不符(缺题干或答案)'; } })['catch'](function (e) { failN++; if (!firstErr) firstErr = (e && e.message) || String(e); }).then(function () { doneN++; if (batch.length >= 10) flush(); mockStatus('正在补题面 ' + doneN + ' / ' + need.length + '(失败 ' + failN + ')…'); setTimeout(step, 180); }); } step(); } // ---- mocks 页浮动 UI:右下角按钮 + 收录面板 ---- // 「不依赖站点布局」是没得选:报告页实测没有 .question-box / .question-outer / .inner-content // (计数 0),错题页与收藏页那套容器选择器在这页一条都命中不到,能挂的只有 jiexieSubjects 的节点本身。 var mockPanelBuilt = false; function buildMocksUI() { if (mockPanelBuilt) return; // 页面生命周期内只建一次,避免站点重绘导致重复节点 mockPanelBuilt = true; GM_addStyle([ // 深色浮标的底色走 --gth-primary,别再引 --gth-fg:同值不同令牌,改一处就分叉 '#gth-mock-btn{position:fixed;right:18px;bottom:18px;z-index:100000;display:flex;align-items:center;gap:7px;', 'height:var(--gth-ctl-h,38px);padding:0 16px;border-radius:999px;', 'background:var(--gth-primary,#0f172a);color:var(--gth-primary-fg,#f8fafc);font-size:13px;font-weight:600;', 'cursor:pointer;box-shadow:0 4px 16px rgba(15,23,42,.25);user-select:none;}', '#gth-mock-btn:hover{background:var(--gth-primary-hover,#1e293b);}', '#gth-mock-btn .gth-ic svg{width:15px;height:15px;}', '#gth-mock-panel{position:fixed;right:18px;bottom:66px;z-index:100001;width:268px;background:var(--gth-bg,#fff);', 'color:var(--gth-fg,#0f172a);border:1px solid var(--gth-border,#e2e8f0);border-radius:12px;', 'box-shadow:0 12px 40px rgba(15,23,42,.18);padding:14px;font-size:13px;line-height:1.6;}', '#gth-mock-panel .gthm-head{display:flex;align-items:center;gap:6px;font-weight:600;margin-bottom:8px;}', '#gth-mock-panel .gthm-head .gth-ic svg{width:16px;height:16px;}', '#gth-mock-panel #gth-mock-close{margin-left:auto;border:0;background:none;cursor:pointer;', 'color:var(--gth-muted,#64748b);display:flex;padding:2px;border-radius:6px;}', '#gth-mock-panel #gth-mock-close:hover{background:var(--gth-hover,#f1f5f9);}', '#gth-mock-panel #gth-mock-close .gth-ic svg{width:14px;height:14px;}', '#gth-mock-panel .gthm-status{background:var(--gth-subtle,#f8fafc);border:1px solid var(--gth-border,#e2e8f0);', 'border-radius:8px;padding:8px 10px;margin-bottom:10px;}', '#gth-mock-panel .gthm-status b{font-weight:600;}', '#gth-mock-panel .gthm-btn{width:100%;height:var(--gth-ctl-h,38px);display:flex;align-items:center;justify-content:center;', 'gap:6px;border:1px solid var(--gth-border-strong,#cbd5e1);background:var(--gth-bg,#fff);color:var(--gth-fg,#0f172a);', 'border-radius:var(--gth-ctl-r,10px);cursor:pointer;font-size:13px;}', '#gth-mock-panel .gthm-btn:hover{background:var(--gth-hover,#f1f5f9);}', '#gth-mock-panel .gthm-btn:disabled{opacity:.5;cursor:not-allowed;background:var(--gth-bg,#fff);}', '#gth-mock-panel .gthm-btn .gth-ic svg{width:14px;height:14px;}', '#gth-mock-panel .gthm-hint{margin-top:10px;font-size:12px;color:var(--gth-muted,#64748b);}' ].join('\n')); var btn = document.createElement('div'); btn.id = 'gth-mock-btn'; btn.innerHTML = icon('bookOpen') + '模考收录'; var panel = document.createElement('div'); panel.id = 'gth-mock-panel'; panel.hidden = true; panel.innerHTML = [ '
' + icon('bookOpen') + '全真模考收录', '
', '
正在检测页面…
', '', '', '
站点的解析页只把「当前这一题」的题面加载出来,答错的题要靠上面那个按钮逐题补齐(走站点自己的题目解析接口)。不点的话,翻到哪题收哪题。收录后回错题页即可导出 / 重练。
' ].join(''); document.body.appendChild(btn); document.body.appendChild(panel); btn.addEventListener('click', function () { panel.hidden = !panel.hidden; if (!panel.hidden) mocksScanTick(); }); $('#gth-mock-close', panel).addEventListener('click', function () { panel.hidden = true; }); $('#gth-mock-scan', panel).addEventListener('click', mocksScanTick); $('#gth-mock-all', panel).addEventListener('click', collectMockWrong); } // 观察器回调(refreshPageUI 已 debounce 400ms):扫描 -> 合并 -> 刷新面板 function mocksScanTick() { if (!isMocksPage()) return; buildMocksUI(); var examId = mockExamIdOf(); var list = harvestFromScope(document); if (!list.length) list = mockSniffed(); var r = list.length ? mergeMockQs(list, examId) : null; var total = 0, wrong = 0; Object.keys(store.mockQs).forEach(function (k) { total++; if (store.mockQs[k].done && !store.mockQs[k].ok) wrong++; }); var st = $('#gth-mock-status'); if (st) { st.innerHTML = (examId ? '场次 #' + esc(examId) : '未识别场次(仍可收录)') + '
已收录 ' + total + ' 题 · 错题 ' + wrong + ' 题' + (r && r.added ? '
本次新增 ' + r.added + ' 题(错题 ' + r.wrongNew + ')' : '') + (list.length ? '' : '
本页暂未检测到题目,进入解析页后再试'); } } function initMocksUI() { hookXhrSniffer(); buildMocksUI(); syncKeyTargets(); // 浮标是 div,mocks 页不走下面那批 gongan 页初始化 mocksScanTick(); console.log('[错题助手] 模考助手已注入' + (mockSniffHooked ? '(嗅探已挂)' : '') + ':' + location.href); } document.addEventListener('keydown', function (e) { if (quizEl.hidden) return; if (e.key === 'Escape') { closeQuiz(); return; } if (quiz && !quiz.submitted) { if (e.key === 'ArrowLeft' && quiz.idx > 0) { quiz.idx--; renderQuiz(); } if (e.key === 'ArrowRight' && quiz.idx < quiz.list.length - 1) { quiz.idx++; renderQuiz(); } } }); // 批注栏在错题页与收藏页都生效,用 body 上的标记类限定样式作用域 // (类名沿用 gth-error,改名会牵动一批 CSS 选择器,没必要) function syncRouteClass() { document.body.classList.toggle('gth-error', isListRoute()); syncSourceDefault(); } window.addEventListener('hashchange', function () { syncRouteClass(); invalidateLoaded(); // 换了路由,缓存的题目列表跟着作废 // 离开错题页 / 收藏页时收起助手视图,避免它跟着显示到别的界面上 if (!isListRoute() && viewOn) setView(false); refreshPageUI(); }); if (isMocksPage()) { // mocks 页:不进错题面板初始化,只做模考收录 initMocksUI(); return; } if (!getToken()) { setStatus('未检测到登录 token,请先登录站点后再使用。', 'err'); } // 首次注入要放在所有定义与 var 之后(早跑会读到还没赋值的 balloonEls,见上面 resize 那行), // 但又排在尾部那几个初始化之前:它们里有 `$('#gth-mode').value` 这种不判空的读法, // 一旦哪天站点改了名,抛错也不该把划线的入口一起带走。 injectListUI(); injectMenuEntry(); syncRail(); syncKeyTargets(); syncRouteClass(); renderPracticeSrc(); syncFilterUI(); renderHistory(); updateExportHint(); })();