// ==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 挡得住