// ==UserScript== // @name 上岸村机考助手 // @namespace http://tampermonkey.net/ // @version 2.0.1 // @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* // @match https://pub.xdtech.top/mingshi/wxpage/tiku/gongan/zhuanxiang.html // @match https://pub.xdtech.top/*/wxpage/tiku/gongan/zhuanxiang.html* // @include *://*.xdtech.top/*/wxpage/tiku/gongan/zhuanxiang.html* // @match https://pub.xdtech.top/mingshi/wxpage/tiku/gongan/practice.html // @match https://pub.xdtech.top/*/wxpage/tiku/gongan/practice.html* // @include *://*.xdtech.top/*/wxpage/tiku/gongan/practice.html* // @connect pub.xdapi.top // @grant GM_xmlhttpRequest // @grant GM_addStyle // @run-at document-idle // ==/UserScript== // ===== 计时暂停:闸门与阀门 ===== (function () { 'use strict'; try { var CSS = [ '.remain-time{width:auto!important}', '.gth-timer-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;', 'height:30px;line-height:30px;padding:0 30px;margin-left:8px;border:0;border-radius:5px;', 'background:#fff;color:#2178db;font-size:16px;cursor:pointer;vertical-align:middle;user-select:none}', '.gth-timer-btn .gth-timer-ic{width:18px;height:18px;flex:0 0 auto}', '.gth-timer-btn .gth-timer-ic svg{width:100%;height:100%;display:block}', '.gth-timer-btn:hover{background:#d8e9fa}', '.gth-timer-btn.paused{background:#f59e0b;color:#fff}', '.gth-timer-btn.paused:hover{background:#d97706}', '.remain-time.gth-paused a{color:#fde68a}', '.remain-time.gth-paused a::after{content:"(已暂停)";font-size:13px}', '.remain-time.gth-timer-no a{text-decoration:underline dotted #dc2626 2px;text-underline-offset:4px}' ].join(''); var TIMER_ICONS = { pause: '', play: '' }; function timerIcon(name) { var d = TIMER_ICONS[name]; if (!d) return ''; return ''; } var paused = false; var noFreeze = ''; var givenUp = false; var scope = null; var restored = false; var ngSrc = ''; var ngErr = ''; var scopeCount = 0; var valveOn = 0; var valveWanted = 0; var holdSeen = null; var warned = ''; var storeErr = ''; var lastSig = ''; var frozenText = ''; var domTimer = null; var running = false; var lastTotal = -1; var SID = ''; try { SID = new URLSearchParams(location.search).get('submit_id') || ''; } catch (e) { SID = ''; } var LS_KEY = 'gth_timer_paused_' + (SID || location.pathname); var CTX = location.pathname.split('/')[1] || 'mingshi'; var QTIME_KEY = 'gth_qtime_' + CTX; var EXAM = ''; var rec = null; var recTick = 0; var recErr = ''; function errName(e) { return e && e.name ? e.name : String(e); } function hashQuery() { var h = String(location.hash || ''); var i = h.indexOf('?'); return i < 0 ? '' : h.slice(i + 1); } function siteParam(name) { var out = ''; try { out = new URLSearchParams(location.search).get(name) || ''; var fromHash = new URLSearchParams(hashQuery()).get(name); if (fromHash) out = fromHash; } catch (e) { out = ''; } return out || ''; } function freezablePage() { var path = String(location.pathname || ''); if (/\/zhuanxiang\.html$/.test(path)) return true; if (/\/practice\.html$/.test(path)) return siteParam('page_type') === 'random'; return false; } function timerStore(v) { try { if (v) localStorage.setItem(LS_KEY, '1'); else localStorage.removeItem(LS_KEY); storeErr = ''; } catch (e) { storeErr = '本地存储写不进(暂停状态刷新后不保留):' + errName(e); } } function stored() { try { return localStorage.getItem(LS_KEY) === '1'; } catch (e) { storeErr = '本地存储读不出(当作未暂停):' + errName(e); return false; } } function timerPad2(x) { return (x < 10 ? '0' : '') + x; } function hms(n) { if (typeof n !== 'number' || !(n >= 0)) return '--:--:--'; return timerPad2(Math.floor(n / 3600)) + ':' + timerPad2(Math.floor(n % 3600 / 60)) + ':' + timerPad2(n % 60); } function pickNg(w) { return (w && w.angular && typeof w.angular.element === 'function') ? w.angular : null; } function pageNg() { var wins = [ ['window', typeof window === 'undefined' ? null : window], ['unsafeWindow', typeof unsafeWindow === 'undefined' ? null : unsafeWindow] ]; var i, ng = null; for (i = 0; i < wins.length; i++) { ng = pickNg(wins[i][1]); if (ng) { ngSrc = wins[i][0]; ngErr = ''; return ng; } } if (typeof angular !== 'undefined' && angular && typeof angular.element === 'function') { ngSrc = 'angular'; ngErr = ''; return angular; } ngSrc = ''; return null; } function eachTimerScope(fn) { var ng = pageNg(); scopeCount = 0; if (!ng) { ngErr = 'window / unsafeWindow / 裸 angular 三条路上都没有 angular'; return false; } var root; try { root = ng.element(document.body).injector().get('$rootScope'); } catch (e) { ngErr = '拿不到 $rootScope:' + (e && e.message ? e.message : String(e)); return false; } if (!root) { ngErr = '$rootScope 是空的'; return false; } var seen = new WeakSet(); (function walk(s) { if (!s || seen.has(s)) return; seen.add(s); if (Object.prototype.hasOwnProperty.call(s, 'localStorage') && s.localStorage && typeof s.localStorage === 'object' && ('allInterval' in s || 'partInterval' in s)) { fn(s); scopeCount++; } for (var c = s.$$childHead; c; c = c.$$nextSibling) walk(c); })(root); if (!scopeCount) ngErr = '页面上没有 localStorage + allInterval 形状的计时器 scope'; return scopeCount > 0; } function guard(obj, key) { if (!obj || typeof obj !== 'object' || !(key in obj)) return ''; var d = Object.getOwnPropertyDescriptor(obj, key); if (!d) return ''; if (d.get) return 'on'; if (!d.configurable) return ''; var cur = obj[key]; Object.defineProperty(obj, key, { configurable: true, enumerable: true, get: function () { return cur; }, set: function (v) { if (!paused) cur = v; } }); return 'new'; } function mainValve() { var ls = scope && scope.localStorage; if (!ls) return false; var d = Object.getOwnPropertyDescriptor(ls, 'totalTime'); return !!(d && d.get); } function installGuards() { var scopes = []; if (scope) scopes.push(scope); eachTimerScope(function (s) { scopes.push(s); }); var seenS = new WeakSet(); valveOn = 0; valveWanted = 0; scopes.forEach(function (s) { if (!s || seenS.has(s)) return; seenS.add(s); var ls = s.localStorage || {}; var pairs = [[ls, 'totalTime']]; (ls.partData || []).forEach(function (p) { pairs.push([p, 'remainTime']); }); pairs.forEach(function (pair) { valveWanted++; if (guard(pair[0], pair[1])) valveOn++; }); }); return valveOn; } function holdReason() { var ls = scope && scope.localStorage; if (!ls) return '页面计时器 scope 不在了'; if (!mainValve()) return '阀门没挂在 totalTime 上'; var v = ls.totalTime; if (typeof v !== 'number') return 'totalTime 不是数字'; if (holdSeen !== null && v !== holdSeen) return '暂停期间 totalTime 仍在变,还有第二条时钟'; holdSeen = v; return ''; } function markHost(why) { var old = btn(); if (old) old.remove(); var host = timerHost(); if (!host) return; host.classList.remove('gth-paused'); host.classList.add('gth-timer-no'); host.title = '这一页停不住:' + why + ' · ' + diag(); } function clearHost() { var host = timerHost(); if (!host) return; host.classList.remove('gth-timer-no'); host.classList.remove('gth-paused'); if (String(host.title || '').indexOf('这一页停不住') === 0) host.title = ''; } function failHold(why) { var foundTimer = scopeCount > 0; release(); if (warned !== why) { warned = why; console.warn('[计时暂停] ' + why + (foundTimer ? '(按钮已摘掉,这一页不再尝试)' : '')); } if (!foundTimer) { lastSig = ''; render(); return; } givenUp = true; noFreeze = why; markHost(noFreeze); } function release() { paused = false; holdSeen = null; freezeDom(false); timerStore(false); } function applyPause(on) { if (!on) { release(); render(); return; } paused = true; holdSeen = null; if (!scope) scope = findScope(); installGuards(); noFreeze = holdReason(); if (noFreeze) { failHold(noFreeze); return; } freezeDom(true); timerStore(true); flushRec(); render(); } function btn() { return document.querySelector('.gth-timer-btn'); } function timerHost() { return document.querySelector('.operation-dati .remain-time') || document.querySelector('.remain-time'); } function usedText() { var ls = scope && scope.localStorage; var e = ls ? ls.elapsed_time : -1; return hms(typeof e === 'number' ? e : -1); } function recText() { if (!timerIsZyPage()) return ''; if (!EXAM) return '分场:URL 上没有 exam_id(本场不记用时)'; return '本场已记 ' + hms(recSum(rec)) + (rec && rec.paused ? '(其中暂停 ' + hms(rec.paused) + ',已算在各题头上)' : '') + (rec && rec.lost ? ' · 未归属 ' + rec.lost + ' 秒' : '') + (rec && rec.merged ? ' · 已并入别的标签 ' + rec.merged + ' 秒' : '') + (recErr ? ' · ' + recErr : ''); } function diag() { var s = '取页:' + (ngSrc || '没拿到'); if (ngErr) s += '(' + ngErr + ')'; s += ' · 计时器' + scopeCount + '台 · 阀门' + valveOn + '/' + valveWanted; s += ' · 已用 ' + usedText(); if (paused) s += ' · 冻在 ' + hms(holdSeen); if (storeErr) s += ' · ' + storeErr; var r = recText(); if (r) s += ' · ' + r; return s; } function label() { return paused ? timerIcon('play') : timerIcon('pause'); } function tip() { return (paused ? '已冻结,可以放心离开' : '点这里冻结计时') + ' · ' + diag(); } function pausedClass() { return !!(paused && !noFreeze); } function render() { var b = btn(); if (!b) return; var host = timerHost(); var sig = [paused ? '1' : '0', noFreeze, host ? 'h' : '-', pausedClass() ? 'p' : '-', diag()].join('|'); if (sig === lastSig) return; lastSig = sig; b.className = 'gth-timer-btn' + (paused ? ' paused' : ''); b.innerHTML = label(); b.title = tip(); if (host) host.classList.toggle('gth-paused', pausedClass()); } function ensureBtn() { var host = timerHost(); if (!host) return; var b = btn(); if (b) { if (b.parentNode !== host) host.appendChild(b); return; } b = document.createElement('button'); b.type = 'button'; b.className = 'gth-timer-btn'; b.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); applyPause(!paused); }); host.appendChild(b); lastSig = ''; } function remainAnchor() { return document.querySelector('.remain-time a'); } function remainText() { var a = remainAnchor(); return a ? (a.textContent || '') : ''; } function timeTextNode() { var a = remainAnchor(); if (!a) return null; var i, c; for (i = 0; i < a.childNodes.length; i++) { c = a.childNodes[i]; if (c.nodeType === 3 && /\d{1,2}:\d{2}:\d{2}/.test(c.nodeValue)) return c; } for (i = 0; i < a.childNodes.length; i++) { c = a.childNodes[i]; if (c.nodeType === 3) return c; } return null; } function freezeDom(on) { if (domTimer) { clearInterval(domTimer); domTimer = null; } frozenText = ''; if (!on) return; var n = timeTextNode(); if (!n) return; frozenText = n.nodeValue; domTimer = setInterval(function () { var nn = timeTextNode(); if (nn && frozenText && nn.nodeValue !== frozenText) nn.nodeValue = frozenText; }, 150); } function domSeconds() { var m = remainText().match(/(\d{1,2}):(\d{2}):(\d{2})/); if (!m) return -1; return (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]); } function findScope() { var want = domSeconds(); var best = null; var bestScore = -1; eachTimerScope(function (s) { var t = s.localStorage.totalTime; var score = 0; if (typeof t === 'number') { if (want >= 0 && Math.abs(t - want) <= 2) score = 1e6; else if (want >= 0) score = 1e5 - Math.min(Math.abs(t - want), 9999); else score = t; } if (score > bestScore) { best = s; bestScore = score; } }); return best; } function live() { var s = scope; var t = (s && s.localStorage) ? s.localStorage.totalTime : -1; if (s && lastTotal >= 0 && typeof t === 'number' && t < lastTotal) running = true; lastTotal = typeof t === 'number' ? t : -1; var parentAllTime = s && s.$parent && typeof s.$parent.allTime === 'string' ? s.$parent.allTime : ''; var segs = remainText().match(/(\d{1,2}:\d{2}:\d{2})/g) || []; var domLive = segs.some(function (seg) { return seg !== '00:00:00'; }); return running || !!(s && s.allInterval) || (typeof t === 'number' && t > 60) || (parentAllTime.length > 0 && !/00:00:00/.test(parentAllTime)) || domLive; } function timerIsZyPage() { return /\/zhuanxiang\.html$/.test(String(location.pathname || '')); } function newRec() { return { q: {}, paused: 0, lost: 0, merged: 0 }; } function recSum(r) { if (!r) return 0; // 暂停的秒数已经记在各题头上了(那是墙上时间),paused 只作展示——再加一次就是双计 var n = r.lost || 0; Object.keys(r.q || {}).forEach(function (k) { n += r.q[k] || 0; }); return n; } function readRec() { var all = {}; try { all = JSON.parse(localStorage.getItem(QTIME_KEY) || '{}') || {}; } catch (e) { recErr = '用时记录读不出(这一场从头记):' + errName(e); } var r = all[EXAM]; if (!r || typeof r !== 'object') return newRec(); if (!r.q || typeof r.q !== 'object') r.q = {}; if (typeof r.paused !== 'number') r.paused = 0; if (typeof r.lost !== 'number') r.lost = 0; if (typeof r.merged !== 'number') r.merged = 0; return r; } function mergeUp(target, key, val) { if (!(typeof val === 'number' && val > (target[key] || 0))) return 0; var d = val - (target[key] || 0); target[key] = val; return d; } function flushRec() { if (!EXAM || !rec) return; var all = {}, raised = 0; try { all = JSON.parse(localStorage.getItem(QTIME_KEY) || '{}') || {}; } catch (e) { all = {}; } var disk = all[EXAM]; if (disk && typeof disk === 'object') { if (!rec.q || typeof rec.q !== 'object') rec.q = {}; Object.keys(disk.q || {}).forEach(function (k) { raised += mergeUp(rec.q, k, disk.q[k]); }); raised += mergeUp(rec, 'paused', disk.paused); raised += mergeUp(rec, 'lost', disk.lost); } if (raised) rec.merged = (rec.merged || 0) + raised; all[EXAM] = rec; try { localStorage.setItem(QTIME_KEY, JSON.stringify(all)); recErr = ''; } catch (e) { recErr = '用时记不进本地存储(复盘会有缺口):' + errName(e); } } function currentQid() { var ls = scope && scope.localStorage; if (!ls || !ls.currentPartData) return ''; var list = ls.currentPartData.subjectList || []; var i, it; for (i = 0; i < list.length; i++) { it = list[i]; if (it && it.index === ls.currentIndex && (it.id || it.id === 0)) return String(it.id); } return ''; } // 一秒落到哪一个记账单位上:认得出当前题就记在它头上,认不出就落 lost(不许猜给上一题) function tallySecond(r, id) { if (id) r.q[id] = (r.q[id] || 0) + 1; else r.lost = (r.lost || 0) + 1; } function recordSecond() { if (!EXAM || !timerIsZyPage() || !scope || !scope.allInterval || scope.isAlreadySubmit) return; if (!rec) rec = readRec(); // 暂停期间的那一秒照样属于「这一屏这道题」:用户要的是真实用时,离开两分钟就是两分钟。 // paused 那只表仍然走,但只用于展示「其中暂停了多久」,不再从各题头上扣出去(ADR 0007 决策 2) if (paused && !noFreeze) rec.paused++; tallySecond(rec, currentQid()); recTick++; if (recTick % 5 === 0) flushRec(); } function tick() { var s = findScope(); if (s !== scope) { scope = s; restored = false; running = false; lastTotal = -1; holdSeen = null; } if (scope && scope.$$destroyed) { scope = null; restored = false; } if (scope && scope.isAlreadySubmit) { if (paused || stored()) release(); noFreeze = ''; clearHost(); flushRec(); var old = btn(); if (old) old.remove(); return; } recordSecond(); if (!timerHost()) return; if (givenUp) { markHost(noFreeze); return; } ensureBtn(); var isLive = live(); render(); if (!isLive) return; if (!restored) { restored = true; if (stored()) applyPause(true); } if (paused) { installGuards(); noFreeze = holdReason(); if (noFreeze) failHold(noFreeze); } render(); } if (!freezablePage()) return; EXAM = siteParam('exam_id'); var st = document.createElement('style'); st.textContent = CSS; document.head.appendChild(st); tick(); setInterval(tick, 1000); } catch (e) { console.warn('[计时暂停] 初始化失败:' + (e && e.message ? e.message : String(e))); } })(); // ===== 机考助手:错题 · 笔记 · 补充题本 · 模考 ===== (function () { 'use strict'; try { // 助手这半只在自己这几条路上动手:站点两个列表(含模考 SPA)与专项页。 // 其余被 @match 到的页面(今天是 practice.html)归计时那半,助手一个节点都不建—— // body 上那只 400ms 观察器就挂在下面,漏到答题页上等于考试中多跑几千行(ADR 0008 决策 7)。 function helperOwnPage() { var p = String(location.pathname || ''); if (/\/index\.html$/.test(p)) return true; if (/\/zhuanxiang\.html$/.test(p)) return true; return false; } if (!helperOwnPage()) return; 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/'; 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 SITE_LIST_CAP = 4800; var MASTER_STREAK = 2; var KEYACT = '.gth-menu-item,.gth-chip,.gth-badge,.gth-qhist-h,.gth-aside-hl-t,.gth-aside-empty,.gth-balloon,' + '.gth-hlp-item .t,.gth-hlp-item .n,.gth-hlp-item .rm,.gthq-cell,.gth-choice,.gth-btext,.gthq-btn,' + 'mark.gth-hl,#gth-mock-btn'; var EXPORT_HEADERS = ['序号', '科目', '来源', '材料', '题干', '选项', '你的答案', '正确答案', '答错次数', '解析', '笔记', '划线摘录', '掌握状态', '题目ID']; // `store.exported` 与 `store.exportAt` 的那五档桶,一处出处:少一档就是底册静默漏并一批题号 var EXPORT_BUCKETS = ['error', 'favorite', 'both', 'mock', 'book']; var XLSX_WIDTHS = [6, 16, 12, 30, 60, 50, 10, 10, 10, 60, 40, 40, 14, 12]; function $(sel, root) { return (root || document).querySelector(sel); } function $$(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); } // 找不到站点宿主就静默不干活,是这次「脚本明明在跑、界面整块没了」最难查的一半:界面上不放 // 解释性文案,但控制台得说一次。同一条只说一次——这些路径在 400ms 观察器的射程里。 var said = {}; function sayOnce(key, msg) { if (said[key] === msg) return; said[key] = msg; console.warn('[错题助手] ' + msg); } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } // 行内记号一次扫过去,替换出来的标签不会再被自己吃进去——所以「不嵌套」是这套写法的自然结果, // 不是还没做完。斜体那条要求星号前面是行首或空白:数量关系里 3*5*7 太常见,误判会把数字推成斜体。 // 进来的文本已经过 esc,所以协议白名单是这里唯一还要设防的地方(esc 挡得住