// ==UserScript== // @name 51外语课堂 · 题目答案查询助手 // @name:en 51Learning Question / Answer Lookup // @namespace workbuddy/kt-answer-lookup // @version 1.0.0 // @description 在 51外语课堂 页面内输入或识别题目,自动到题库/作业/试卷页面检索并整理正确答案;处理未找到、答案不唯一、登录失效与网络异常。 // @author WorkBuddy // @match https://ketang.51learning.com.cn/* // @icon https://ketang.51learning.com.cn/img/favicon-kt.ico // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_notification // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @connect ketang.51learning.com.cn // @connect cdn.jsdelivr.net // @connect tessdata.projectnaptha.com // @run-at document-idle // ==/UserScript== /* * 使用说明(也可在页面右下角面板里操作) * 1. 安装 ScriptCat(脚本猫)扩展,新建脚本并粘贴本文件,保存后刷新 51外语课堂 页面。 * 2. 前提:当前浏览器已登录 ketang.51learning.com.cn,且账号能看到「题库管理 / 作业批改 / 试卷管理」。 * 脚本只复用浏览器 Cookie,不索取、不保存账号密码;SSO / 扫码登录同样适用。 * 3. 首次使用建议点「探测接口」:脚本会记录题库页真实发出的 XHR/fetch 地址与 JSON 字段, * 并自动回填「接口查询」配置。之后批量查询走接口,稳定且快。 * 不想配置也可用「页面查询」:脚本自动在隐藏 iframe 里操作搜索框并解析结果表格。 * 4. 输入:文本框内一行一题;「识别本页题目」自动抓取当前页题目;「图片识别」可选 OCR。 * 5. 输出:面板表格 + 复制结果 + 导出 JSON/CSV;结果含 命中 / 未找到 / 答案不唯一 / 异常 四类状态。 * * 依赖:ScriptCat 扩展;无构建、无 Node/Python 运行时。 * 「图片识别」为可选功能,首次使用会从 jsDelivr 加载 Tesseract.js(chi_sim+eng),需要外网。 * 注意:请仅用于本人有权访问的题库、练习与复习,勿用于在线正式考试或违反平台使用条款的场景。 */ (function () { 'use strict'; /* ===================================================================== * 第 1 部分:纯函数核心(无 DOM / GM 依赖,可单独做单元测试) * ===================================================================== */ const STRONG_Q = ['question', 'questionname', 'questiontitle', 'questioncontent', 'questiontext', 'questionstem', 'stem', 'tigan', 'timu', 'shiti', '题目', '题干', '试题内容', '题目内容']; const WEAK_Q = ['title', 'content', 'name', 'subject', 'topic', 'text', '标题', '内容']; const STRONG_A = ['correctanswer', 'rightanswer', 'trueanswer', 'standardanswer', 'answercontent', 'answertext', 'answerkey', 'answerkeys', 'answerlist', 'answer', 'answers', 'daan', '正确答案', '参考答案', '标准答案', '答案', '答案内容']; const WEAK_A = ['solution', 'analysis', 'correct', 'correctoption', 'rightoption', 'result', 'key', '解析', '正确选项']; const BAD_KEY = /(code|msg|message|status|count|total|page|pagesize|size|flag|tip|error|time|date|score|num|^id|id$)/; class AuthError extends Error { constructor(m) { super(m); this.name = 'AuthError'; } } class NetworkError extends Error { constructor(m) { super(m); this.name = 'NetworkError'; } } class ParseError extends Error { constructor(m) { super(m); this.name = 'ParseError'; } } function stripHtml(s) { return String(s == null ? '' : s) .replace(//gi, ' ') .replace(//gi, ' ') .replace(/<[^>]*>/g, ' ') .replace(/ | /gi, ' ') .replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>') .replace(/&#\d+;/g, ' '); } /** 归一化题干:NFKC + 小写 + 去编号 + 只保留字母/数字/汉字 */ function normalize(s) { if (s == null) return ''; let t = stripHtml(s); try { t = t.normalize('NFKC'); } catch (e) { /* 老引擎忽略 */ } t = t.toLowerCase() .replace(/^\s*(第?\s*\d+\s*[题\.、::]|question\s*\d+\s*[.、::]?)\s*/i, '') .replace(/[\r\n\t]+/g, ' '); t = t.replace(/[^\p{L}\p{N}]/gu, ''); return t; } /** 二元组 Dice 相似度 + 包含关系加权,返回 0~1 */ function similarity(a, b) { const x = normalize(a), y = normalize(b); if (!x || !y) return 0; if (x === y) return 1; if (x.length > 3 && y.length > 3 && (x.includes(y) || y.includes(x))) return 0.96; const bg = (s) => { const m = new Map(); for (let i = 0; i < s.length - 1; i++) { const g = s.slice(i, i + 2); m.set(g, (m.get(g) || 0) + 1); } return m; }; const A = bg(x), B = bg(y); let total = 0, inter = 0; A.forEach(v => { total += v; }); B.forEach(v => { total += v; }); A.forEach((v, g) => { if (B.has(g)) inter += Math.min(v, B.get(g)); }); return total ? Math.round((2 * inter / total) * 1000) / 1000 : 0; } const keyOf = (k) => String(k).toLowerCase().replace(/[^a-z\u4e00-\u9fa5]/g, ''); function isQuestionKey(k, strongOnly) { const s = keyOf(k); if (!s || BAD_KEY.test(s)) return false; if (STRONG_Q.some(t => s === t || s.includes(t))) return true; return !strongOnly && WEAK_Q.some(t => s === t || s.includes(t)); } function isAnswerKey(k, strongOnly) { const s = keyOf(k); if (!s || BAD_KEY.test(s)) return false; if (STRONG_A.some(t => s === t || s.includes(t))) return true; return !strongOnly && WEAK_A.some(t => s === t || s.includes(t)); } function pickField(obj, test) { for (const k of Object.keys(obj)) { if (!test(k)) continue; const v = obj[k]; if (typeof v === 'string' || typeof v === 'number') { const t = stripHtml(v).replace(/\s+/g, ' ').trim(); if (t) return t; } } return ''; } const textOf = (v) => stripHtml(v == null ? '' : v).replace(/\s+/g, ' ').trim(); /** 从容器里找出 A/B/C/D 选项文本,用于把 "A" 展开成 "A(xxx)" */ function optionsMap(container) { const map = {}; if (!container || typeof container !== 'object') return map; for (const k of Object.keys(container)) { const s = keyOf(k); if (!/option|choice|item|select|选项/.test(s)) continue; const v = container[k]; if (Array.isArray(v)) { v.forEach((o, i) => { const letter = String.fromCharCode(65 + i); if (typeof o === 'string') map[letter] = textOf(o); else if (o && typeof o === 'object') { const no = o.optionNo || o.optionKey || o.key || o.label || o.no || o.letter || letter; map[String(no).toUpperCase().slice(0, 1)] = textOf(o.optionText || o.optionContent || o.content || o.text || o.value || o.label || o.name); } }); } else if (v && typeof v === 'object') { Object.keys(v).forEach(letter => { map[letter.toUpperCase().slice(0, 1)] = textOf(v[letter]); }); } } return map; } function formatAnswer(v, container) { if (v == null || v === '') return ''; if (typeof v === 'string') { let t = textOf(v); const compact = t.replace(/[\s、,,/]/g, ''); if (/^[A-Da-d]{1,7}$/.test(compact)) { const om = optionsMap(container); const expanded = compact.toUpperCase().split('').map(ch => om[ch] ? ch + '(' + om[ch] + ')' : ch).join(' '); return expanded || t; } return t; } if (typeof v === 'number' || typeof v === 'boolean') return String(v); if (Array.isArray(v)) { const correct = v.filter(o => o && typeof o === 'object' && (o.isCorrect || o.correct || o.isRight || o.right || o.isAnswer || o.answer === true)); if (correct.length) { return correct.map(o => textOf(o.optionText || o.optionContent || o.content || o.text || o.value || o.label || o.name)).filter(Boolean).join(' / '); } return v.map(x => formatAnswer(x, container)).filter(Boolean).join(' / '); } if (typeof v === 'object') { const t = textOf(v.content || v.text || v.value || v.answer || v.label || v.name || v.optionText || v.optionContent || v.title); return t || ''; } return ''; } function pickAnswer(obj, hasQuestionField) { let t = ''; for (const k of Object.keys(obj)) { if (!isAnswerKey(k, true)) continue; t = formatAnswer(obj[k], obj); if (t) return t; } if (hasQuestionField) { for (const k of Object.keys(obj)) { if (!isAnswerKey(k, false) || isAnswerKey(k, true)) continue; t = formatAnswer(obj[k], obj); if (t) return t; } } return ''; } /** 递归遍历接口 JSON,抽取 {question, answer} 命中项 */ function collectHits(node, inheritedQ, out, depth) { depth = depth || 0; if (!node || depth > 9) return out; if (Array.isArray(node)) { node.forEach(n => collectHits(n, inheritedQ, out, depth + 1)); return out; } if (typeof node !== 'object') return out; const qField = pickField(node, k => isQuestionKey(k, true)) || pickField(node, k => isQuestionKey(k, false)); const q = qField || inheritedQ || ''; const a = pickAnswer(node, !!qField); if (a) out.push({ question: String(q), answer: a, source: 'json' }); for (const k of Object.keys(node)) { const v = node[k]; if (v && typeof v === 'object') collectHits(v, q || inheritedQ, out, depth + 1); } return out; } function dedupeHits(hits) { const seen = new Set(), out = []; for (const h of hits) { const k = normalize(h.question) + '|' + normalize(h.answer); if (!k || seen.has(k)) continue; seen.add(k); out.push(h); } return out; } const STATUS_TEXT = { ok: '命中', not_found: '未找到', ambiguous: '答案不唯一', answer_missing: '题目有匹配但无答案字段', auth_expired: '登录失效', network_error: '网络异常', parse_error: '解析失败', error: '异常', empty: '空题目' }; /** 匹配决策:唯一答案 -> ok;多答案 -> ambiguous;无匹配 -> not_found */ function decide(query, hits, threshold) { const th = typeof threshold === 'number' ? threshold : 0.82; const all = dedupeHits(hits || []).map(h => Object.assign({}, h, { score: similarity(query, h.question) })).sort((a, b) => b.score - a.score); const matched = all.filter(h => h.question && h.score >= th); const brief = (arr) => arr.slice(0, 5).map(h => ({ question: String(h.question).slice(0, 160), answer: String(h.answer || '').slice(0, 200), score: h.score, source: h.source || '' })); if (!matched.length) { return { status: 'not_found', answer: '', candidates: brief(all), message: all.length ? '无匹配题目,最高相似度 ' + all[0].score + '(阈值 ' + th + ');可尝试降低阈值或换模块' : '题库页未返回任何题目数据' }; } const best = matched[0].score; const top = matched.filter(h => best - h.score <= 0.03); const byAnswer = new Map(); top.forEach(h => { const k = normalize(h.answer); if (!k) return; if (!byAnswer.has(k)) byAnswer.set(k, { answer: h.answer, score: h.score, items: [] }); byAnswer.get(k).items.push(h); }); if (byAnswer.size === 0) { return { status: 'answer_missing', answer: '', candidates: brief(top), message: '匹配到题目但未解析出答案字段,请用「探测接口」校准' }; } if (byAnswer.size > 1) { return { status: 'ambiguous', answer: '', answers: [...byAnswer.values()].map(v => v.answer), candidates: brief(top), message: '同一题目出现 ' + byAnswer.size + ' 个不同答案,需人工确认;脚本不会自动选取' }; } const only = [...byAnswer.values()][0]; return { status: 'ok', answer: only.answer, candidates: brief(top), message: '相似度 ' + best + ',匹配 ' + top.length + ' 条记录' }; } /** 识别登录拦截页 / 401 / 5xx */ function classifyResponse(status, text) { const t = String(text || ''); if (status === 401 || status === 403) return 'AUTH'; if (/window\.open\s*\(\s*['"]\/index\.html/i.test(t) || /window\.top\.location\.href\s*=\s*['"]index/i.test(t)) return 'AUTH'; try { const j = JSON.parse(t); if (j && (j.code === 401 || j.code === '401')) return 'AUTH'; } catch (e) { /* 非 JSON */ } if (status === 0 || status >= 500) return 'SERVER'; if (status >= 400) return 'HTTP'; return 'OK'; } const Core = { stripHtml, normalize, similarity, isQuestionKey, isAnswerKey, pickField, formatAnswer, pickAnswer, collectHits, dedupeHits, decide, classifyResponse, AuthError, NetworkError, ParseError, STATUS_TEXT }; if (typeof module !== 'undefined' && module.exports) module.exports = Core; /* ===================================================================== * 第 2 部分:运行时守卫 —— 不是用户脚本宿主(如 Node 测试)就到此为止 * ===================================================================== */ const IS_USERSCRIPT = (typeof GM_getValue === 'function') || (typeof GM_registerMenuCommand === 'function'); if (!IS_USERSCRIPT || typeof document === 'undefined' || !document.documentElement) return; const ORIGIN = location.origin; const IS_TOP = (() => { try { return window.top === window; } catch (e) { return false; } })(); const GMX = { get(k, d) { try { if (typeof GM_getValue === 'function') { const v = GM_getValue(k, d); if (v !== undefined && v !== null) return v; } } catch (e) { /* ignore */ } try { const v = localStorage.getItem(k); return v === null ? d : v; } catch (e) { return d; } }, set(k, v) { try { if (typeof GM_setValue === 'function') { GM_setValue(k, v); return; } } catch (e) { /* ignore */ } try { localStorage.setItem(k, v); } catch (e) { /* ignore */ } }, style(css) { try { if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; } } catch (e) { /* ignore */ } const s = document.createElement('style'); s.textContent = css; (document.head || document.documentElement).appendChild(s); }, notify(msg) { try { if (typeof GM_notification === 'function') { GM_notification({ title: '题目查询助手', text: msg, timeout: 5000 }); return; } } catch (e) { /* ignore */ } console.info('[KT]', msg); }, clipboard(text) { try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text/plain'); return; } } catch (e) { /* ignore */ } try { navigator.clipboard.writeText(text); } catch (e) { /* ignore */ } }, menu(name, fn) { try { if (typeof GM_registerMenuCommand === 'function') GM_registerMenuCommand(name, fn); } catch (e) { /* ignore */ } } }; const DEFAULTS = { modulePath: 'bankQuestionBank/bankQuestionBankList.html', // 602 题库管理 apiPath: '', // 探测后自动回填,如 bankQuestionBank/questionList.html apiMethod: 'GET', apiParamQuestion: 'keyword', apiExtraParams: 'page=1&rows=20', apiBodyType: 'form', // form | json(POST 时生效) searchInputSelector: '', // 留空=自动识别 searchButtonSelector: '', threshold: 0.82, retries: 3, timeoutMs: 12000, settleMs: 1200, useCache: true }; const MODULES = { '题库管理': 'bankQuestionBank/bankQuestionBankList.html', '试卷管理': 'ksExam/ksExamList.html', '作业批改': 'unitSubject.htm', '考试管理': 'ksInfo/ksInfoList.html' }; function loadCfg() { try { return Object.assign({}, DEFAULTS, JSON.parse(GMX.get('kt_cfg', '{}'))); } catch (e) { return Object.assign({}, DEFAULTS); } } let cfg = loadCfg(); function saveCfg() { GMX.set('kt_cfg', JSON.stringify(cfg)); } const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const now = () => Date.now(); function log() { console.log.apply(console, ['[KT答题助手]'].concat([].slice.call(arguments))); } /* 离线缓存:网络异常 / 登录失效时作为兜底,最多 500 条 */ function cacheGet(q) { if (!cfg.useCache) return null; try { const c = JSON.parse(GMX.get('kt_cache', '{}')); return c[normalize(q)] || null; } catch (e) { return null; } } function cachePut(q, r) { if (!cfg.useCache || r.status !== 'ok') return; try { const c = JSON.parse(GMX.get('kt_cache', '{}')); c[normalize(q)] = { question: q, answer: r.answer, ts: now() }; const keys = Object.keys(c); if (keys.length > 500) keys.slice(0, keys.length - 500).forEach(k => delete c[k]); GMX.set('kt_cache', JSON.stringify(c)); } catch (e) { /* ignore */ } } /* ===================================================================== * 第 3 部分:XHR / fetch 抓包钩子(注入页面自身 JS 上下文) * —— 目的:无需预知接口地址,也能拿到题库页返回的 JSON 结构 * ===================================================================== */ function pageHook() { var TAG = '__KT_HOOK_INSTALLED__'; if (window[TAG]) return; window[TAG] = true; function emit(url, method, body, data) { try { (window.parent || window).postMessage({ __kt: 'response', url: String(url), method: method || 'GET', body: typeof body === 'string' ? body.slice(0, 2000) : '', data: data }, location.origin); } catch (e) { /* 结构化克隆失败时忽略 */ } } var of = window.fetch; if (typeof of === 'function') { window.fetch = function () { var args = arguments; var input = args[0], init = args[1] || {}; var url = typeof input === 'string' ? input : (input && input.url); var method = init.method || (input && input.method) || 'GET'; return of.apply(this, args).then(function (res) { try { var ct = res.headers && res.headers.get('content-type') || ''; if (ct.indexOf('json') >= 0) { res.clone().json().then(function (d) { emit(url, method, init.body, d); }).catch(function () {}); } } catch (e) { /* ignore */ } return res; }); }; } try { var oo = XMLHttpRequest.prototype.open, os = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (m, u) { this.__ktMeta = { m: m, u: u }; return oo.apply(this, arguments); }; XMLHttpRequest.prototype.send = function (body) { var self = this; try { this.addEventListener('load', function () { try { var ct = self.getResponseHeader('content-type') || ''; if (ct.indexOf('json') >= 0 && self.responseText) { emit(self.__ktMeta.u, self.__ktMeta.m, body, JSON.parse(self.responseText)); } } catch (e) { /* ignore */ } }); } catch (e) { /* ignore */ } return os.apply(this, arguments); }; } catch (e) { /* ignore */ } } const captures = []; let captureOn = true; window.addEventListener('message', function (ev) { if (!captureOn || ev.origin !== ORIGIN) return; const d = ev.data; if (!d || d.__kt !== 'response') return; captures.push({ url: d.url, method: d.method, body: d.body, data: d.data, ts: now() }); if (captures.length > 120) captures.shift(); try { if (d.data && (d.data.code === 401 || d.data.code === '401')) authLost = true; } catch (e) { /* ignore */ } }); let authLost = false; function clearCaptures() { captures.length = 0; } function takeCaptures(since) { return captures.filter(c => !since || c.ts >= since); } function injectHook(doc) { try { if (!doc || !doc.head || doc.querySelector('script[data-kt-hook]')) return false; const s = doc.createElement('script'); s.setAttribute('data-kt-hook', '1'); s.textContent = '(' + pageHook.toString() + ')();'; doc.head.appendChild(s); return true; } catch (e) { return false; } } injectHook(document); // 顶层页面也挂一份 /* ===================================================================== * 第 4 部分:隐藏工作 iframe(不打扰用户当前页面) * ===================================================================== */ function workFrame() { let f = document.getElementById('__kt_work_frame__'); if (f) return f; f = document.createElement('iframe'); f.id = '__kt_work_frame__'; f.setAttribute('data-kt', '1'); f.style.cssText = 'position:fixed;left:-10000px;top:0;width:1280px;height:900px;opacity:0;pointer-events:none;border:0;z-index:-1;'; (document.body || document.documentElement).appendChild(f); return f; } function moduleFullUrl() { const p = String(cfg.modulePath || '').replace(/^\/+/, ''); return p.startsWith('http') ? p : ORIGIN + '/' + p; } function frameDoc(f) { try { const d = f.contentDocument || (f.contentWindow && f.contentWindow.document); return d && d.body ? d : null; } catch (e) { return null; } } async function ensureWorkFrame(force) { const f = workFrame(); const url = moduleFullUrl(); if (force || !f.src || f.src.indexOf(url.replace(ORIGIN, '')) < 0 || f.dataset.ktUrl !== url) { f.dataset.ktUrl = url; const ready = new Promise(resolve => { const done = () => { f.removeEventListener('load', done); resolve(); }; f.addEventListener('load', done); setTimeout(done, cfg.timeoutMs); }); f.src = url; await ready; } const d = frameDoc(f); if (d) injectHook(d); if (!d) throw new NetworkError('无法读取题库 iframe(可能被 CSP 或跨域策略拦截)'); return d; } /* ===================================================================== * 第 5 部分:网络请求 / 两种查询后端 * ===================================================================== */ function gmFetch(url, opt) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') return reject(new NetworkError('当前环境不支持 GM_xmlhttpRequest')); GM_xmlhttpRequest({ method: opt.method || 'GET', url, data: opt.body || undefined, headers: Object.assign({ Accept: 'application/json, text/plain, */*' }, opt.headers || {}), timeout: opt.timeoutMs || 12000, onload: r => resolve({ status: r.status, text: () => Promise.resolve(r.responseText || '') }), onerror: () => reject(new NetworkError('网络连接失败')), ontimeout: () => reject(new NetworkError('请求超时')) }); }); } async function httpJSON(url, opt) { opt = opt || {}; const ctrl = typeof AbortController === 'function' ? new AbortController() : null; const timer = setTimeout(() => { if (ctrl) ctrl.abort(); }, opt.timeoutMs || cfg.timeoutMs); let res, text; try { if (typeof fetch === 'function') { res = await fetch(url, { method: opt.method || 'GET', credentials: 'include', headers: Object.assign({ Accept: 'application/json, text/plain, */*' }, opt.headers || {}), body: opt.body || undefined, signal: ctrl ? ctrl.signal : undefined }); text = await res.text(); } else { res = await gmFetch(url, opt); text = await res.text(); } } catch (e) { if (e && (e.name === 'AbortError' || /timeout|超时/i.test(String(e.message)))) throw new NetworkError('请求超时(可增大超时时间)'); if (e instanceof NetworkError || e instanceof AuthError) throw e; throw new NetworkError('网络异常:' + (e && e.message ? e.message : '连接失败')); } finally { clearTimeout(timer); } let kind = Core.classifyResponse(res.status, text); // 某些脚本管理器沙箱里的 fetch 不带 Cookie:改用 GM_xmlhttpRequest 复核一次 if (kind === 'AUTH' && typeof GM_xmlhttpRequest === 'function') { try { const alt = await gmFetch(url, opt); const altText = await alt.text(); if (Core.classifyResponse(alt.status, altText) === 'OK') { res = alt; text = altText; kind = 'OK'; } } catch (e) { /* 仍按登录失效处理 */ } } if (kind === 'AUTH') { authLost = true; throw new AuthError('登录状态已失效,请在浏览器重新登录 51外语课堂'); } if (kind === 'SERVER') throw new NetworkError('服务端错误 HTTP ' + res.status); if (kind === 'HTTP') throw new NetworkError('请求被拒绝 HTTP ' + res.status); try { return JSON.parse(text); } catch (e) { throw new ParseError('返回内容不是 JSON(可能是登录跳转页),请重新登录或检查接口配置'); } } function extraParams(q) { const out = []; try { new URLSearchParams(String(cfg.apiExtraParams || '')).forEach((v, k) => { out.push([k, String(v).replace(/\{question\}/g, q)]); }); } catch (e) { /* ignore */ } return out; } function buildApiRequest(q) { const raw = String(cfg.apiPath || '').trim(); if (!raw) throw new Error('未配置接口地址,请先点「探测接口」或改用「页面查询」'); let u; try { u = new URL(raw.startsWith('http') ? raw : ORIGIN + '/' + raw.replace(/^\/+/, ''), ORIGIN); } catch (e) { throw new Error('接口地址格式不正确:' + raw); } const param = cfg.apiParamQuestion || 'keyword'; if (String(cfg.apiMethod).toUpperCase() === 'POST') { const body = {}; extraParams(q).forEach(([k, v]) => { body[k] = v; }); body[param] = q; if (cfg.apiBodyType === 'json') { return { url: u.toString(), method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json;charset=UTF-8' } }; } const p = new URLSearchParams(); Object.keys(body).forEach(k => p.set(k, body[k])); return { url: u.toString(), method: 'POST', body: p.toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' } }; } extraParams(q).forEach(([k, v]) => u.searchParams.set(k, v)); u.searchParams.set(param, q); return { url: u.toString(), method: 'GET', body: null, headers: {} }; } async function searchViaApi(q) { const req = buildApiRequest(q); const data = await httpJSON(req.url, req); const hits = collectHits(data, '', [], 0); return hits; } /* ---- 页面模式:自动找搜索框 → 输入 → 点击 → 解析 DOM ---- */ function findInput(doc, c) { const sel = (c && c.searchInputSelector || '').trim(); if (sel) { try { return doc.querySelector(sel); } catch (e) { /* ignore */ } } const list = [].slice.call(doc.querySelectorAll('input, textarea')).filter(el => { if (!el || el.disabled || el.readOnly) return false; const type = (el.type || 'text').toLowerCase(); if (['hidden', 'checkbox', 'radio', 'button', 'submit', 'file', 'password'].includes(type)) return false; const r = el.getBoundingClientRect ? el.getBoundingClientRect() : null; if (r && r.width === 0 && r.height === 0) return false; return true; }); const score = (el) => { const s = [el.placeholder, el.name, el.id, el.getAttribute('aria-label'), el.className].join(' ').toLowerCase(); let n = 0; if (/题|搜索|查询|检索|关键词|关键|keyword|search|query|content/.test(s)) n += 10; if (/题|question|stem/.test(s)) n += 4; if (el.tagName === 'TEXTAREA') n += 1; return n; }; list.sort((a, b) => score(b) - score(a)); if (!list.length) return null; // 多个输入框且都不像搜索框时,不猜,交给用户用选择器/探测来指定 if (list.length > 1 && score(list[0]) === 0) return null; return list[0]; } function findButton(doc, c) { const sel = (c && c.searchButtonSelector || '').trim(); if (sel) { try { return doc.querySelector(sel); } catch (e) { /* ignore */ } } const els = [].slice.call(doc.querySelectorAll('button, input[type=submit], a, [role=button], span, div')) .filter(el => { const t = (el.innerText || el.value || el.textContent || '').trim(); return t && t.length <= 12 && /搜索|查询|查找|检索|筛选|search|query|确定/.test(t); }); return els[0] || null; } function setInputValue(input, q, doc) { const win = doc.defaultView || window; try { const proto = input.tagName === 'TEXTAREA' ? win.HTMLTextAreaElement.prototype : win.HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; setter.call(input, q); } catch (e) { input.value = q; } input.dispatchEvent(new win.Event('input', { bubbles: true })); input.dispatchEvent(new win.Event('change', { bubbles: true })); } function waitSettle(doc, softMs, hardMs) { softMs = softMs || cfg.settleMs; hardMs = hardMs || cfg.timeoutMs; return new Promise(resolve => { let done = false, quiet = null; const finish = () => { if (done) return; done = true; try { obs.disconnect(); } catch (e) {} resolve(); }; let obs; try { obs = new MutationObserver(() => { clearTimeout(quiet); quiet = setTimeout(finish, 350); }); obs.observe(doc.body || doc.documentElement, { childList: true, subtree: true, characterData: true }); } catch (e) { obs = { disconnect() {} }; } quiet = setTimeout(finish, softMs); setTimeout(finish, hardMs); }); } function parseDomHits(doc) { const out = []; let all = []; try { all = [].slice.call(doc.querySelectorAll('tr, li, div, section, article, p, td')).filter(el => { const t = (el.innerText || el.textContent || '').trim(); return t.length > 3 && t.length < 1600 && /答案|正确答案|参考答案|answer\s*[::]/i.test(t); }); } catch (e) { return out; } const inner = all.filter(el => !all.some(o => o !== el && el.contains(o))); inner.forEach(el => { const t = (el.innerText || el.textContent || '').trim(); const lines = t.split(/[\r\n]+/).map(s => s.trim()).filter(Boolean); const idx = lines.findIndex(l => /(正确答案|参考答案|标准答案|答案|answer)\s*[::]/i.test(l)); let answer = '', question = ''; if (idx >= 0) { const m = lines[idx].match(/(?:正确答案|参考答案|标准答案|答案|answer)\s*[::]\s*(.+)$/i); answer = m ? m[1].trim() : (lines[idx + 1] || '').trim(); question = lines.slice(0, idx).join(' ').trim(); } else { const m = t.match(/(.{4,300}?)\s*(?:正确答案|参考答案|标准答案|答案|answer)\s*[::]?\s*([^\n]{1,160})/i); if (m) { question = m[1].trim(); answer = m[2].trim(); } } if (question && answer) out.push({ question, answer, source: 'dom' }); }); return out; } async function searchViaPage(q, doc, c) { c = c || cfg; injectHook(doc); const input = findInput(doc, c); if (!input) throw new Error('未找到搜索框:请在「设置」里填写搜索框选择器,或先执行「探测接口」'); setInputValue(input, q, doc); const btn = findButton(doc, c); if (btn) btn.click(); else { const win = doc.defaultView || window; input.dispatchEvent(new win.KeyboardEvent('keydown', { bubbles: true, key: 'Enter', keyCode: 13, which: 13 })); } await waitSettle(doc, c.settleMs, c.timeoutMs); return parseDomHits(doc); } /** 页面模式入口:顶层直接读 iframe DOM,失败则让 iframe 内的脚本实例代跑 */ async function searchOnPage(q) { const doc = await ensureWorkFrame(false); const since = now() - 100; let domHits; try { domHits = await searchViaPage(q, doc, cfg); } catch (e) { if (String(e.message).indexOf('未找到搜索框') < 0) throw e; domHits = await askFrameAgent(q); } const hits = domHits.slice(); takeCaptures(since).forEach(c => { if (c.data && (c.data.code === 401 || c.data.code === '401')) authLost = true; collectHits(c.data, '', hits, 0); }); if (authLost) throw new AuthError('登录状态已失效,请在浏览器重新登录 51外语课堂'); return hits; } function askFrameAgent(q) { const f = workFrame(); if (!f.contentWindow) return Promise.reject(new NetworkError('iframe 不可访问')); return new Promise((resolve, reject) => { const id = 'kt_' + now() + '_' + Math.random().toString(36).slice(2); const timer = setTimeout(() => { window.removeEventListener('message', h); reject(new NetworkError('iframe 内脚本未响应(请确认 ScriptCat 允许在框架中运行)')); }, cfg.timeoutMs); function h(ev) { if (ev.origin !== ORIGIN || !ev.data || ev.data.__kt !== 'ack' || ev.data.id !== id) return; clearTimeout(timer); window.removeEventListener('message', h); if (ev.data.error) reject(new Error(ev.data.error)); else resolve(ev.data.hits || []); } window.addEventListener('message', h); f.contentWindow.postMessage({ __kt: 'cmd', id, action: 'search', payload: { q, cfg } }, ORIGIN); }); } /** 带退避重试的单题查询;登录失效不重试,结果状态异常不重试 */ async function lookup(q) { const started = now(); let lastErr; for (let attempt = 1; attempt <= Math.max(1, cfg.retries); attempt++) { try { authLost = false; if (!navigator.onLine) throw new NetworkError('浏览器当前处于离线状态'); const hits = cfg.apiPath ? await searchViaApi(q) : await searchOnPage(q); const r = decide(q, hits, Number(cfg.threshold)); cachePut(q, r); r.elapsedMs = now() - started; r.attempts = attempt; return r; } catch (e) { lastErr = e; if (e instanceof AuthError || e instanceof ParseError) break; if (attempt < Math.max(1, cfg.retries)) { const wait = Math.min(4000, 500 * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 200); log('第 ' + attempt + ' 次失败,' + wait + 'ms 后重试:' + e.message); await sleep(wait); } } } const cached = cacheGet(q); let status = 'network_error', message = lastErr ? lastErr.message : '未知错误'; if (lastErr instanceof AuthError) status = 'auth_expired'; else if (lastErr instanceof ParseError) status = 'parse_error'; else if (!(lastErr instanceof NetworkError)) { status = 'error'; } return { status, answer: '', candidates: [], attempts: Math.max(1, cfg.retries), elapsedMs: now() - started, message, fallback: cached ? { answer: cached.answer, from: '本地缓存 ' + new Date(cached.ts).toLocaleString() } : null }; } /* ===================================================================== * 第 6 部分:题目识别(DOM 抓取 / 可选 OCR) * ===================================================================== */ function extractQuestionsFromDom(doc) { const found = [], seen = new Set(); const add = (t) => { const s = String(t || '').replace(/\s+/g, ' ').trim(); if (s.length < 6 || s.length > 600) return; const k = normalize(s).slice(0, 120); if (!k || seen.has(k)) return; seen.add(k); found.push(s); }; try { const els = [].slice.call(doc.querySelectorAll('*')).filter(el => { const cn = ((typeof el.className === 'string' ? el.className : '') + ' ' + (el.id || '')).toLowerCase(); return /(question|stem|tigan|timu|topic|subject|题目|题干)/.test(cn); }); els.forEach(el => { const t = (el.innerText || el.textContent || '').trim(); if (t.length < 1200) add(t); }); } catch (e) { /* ignore */ } if (found.length < 1) { try { const text = (doc.body && (doc.body.innerText || doc.body.textContent)) || ''; text.split(/[\r\n]+/).forEach(line => { if (/[??]|_{3,}|(\s*)|\(\s*\)|[A-D][\.、]/.test(line)) add(line); }); } catch (e) { /* ignore */ } } return found.slice(0, 30); } function loadScript(url) { return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = url; s.onload = resolve; s.onerror = () => reject(new Error('OCR 组件加载失败(需要访问 cdn.jsdelivr.net,或页面 CSP 拦截)')); (document.head || document.documentElement).appendChild(s); }); } async function ocrImage(file, onProgress) { onProgress && onProgress('正在加载 OCR 组件…'); if (!window.Tesseract) await loadScript('https://cdn.jsdelivr.net/npm/tesseract.js@5.1.1/dist/tesseract.min.js'); if (!window.Tesseract) throw new Error('Tesseract.js 未就绪'); onProgress && onProgress('加载中文语言包(首次约 10~20MB)…'); const worker = await window.Tesseract.createWorker('chi_sim+eng', 1, { logger: m => { if (m && m.status === 'recognizing text') onProgress && onProgress('识别中 ' + Math.round((m.progress || 0) * 100) + '%'); } }); try { const ret = await worker.recognize(file); return (ret && ret.data && ret.data.text) || ''; } finally { try { await worker.terminate(); } catch (e) { /* ignore */ } } } /* ===================================================================== * 第 7 部分:接口探测(校准配置,解决「接口未知」问题) * ===================================================================== */ function keyTree(v, depth, prefix, out) { out = out || new Set(); if (depth <= 0 || !v || typeof v !== 'object') return out; if (Array.isArray(v)) { if (v.length) keyTree(v[0], depth - 1, (prefix || '') + '[]', out); return out; } Object.keys(v).slice(0, 40).forEach(k => { const p = prefix ? prefix + '.' + k : k; out.add(p); if (v[k] && typeof v[k] === 'object') keyTree(v[k], depth - 1, p, out); }); return out; } function domInventory(doc) { const inputs = [], buttons = []; try { [].slice.call(doc.querySelectorAll('input, textarea, select')).forEach(el => { inputs.push({ tag: el.tagName, id: el.id, name: el.name, type: el.type, placeholder: el.placeholder, cls: String(el.className || '').slice(0, 80), selector: el.id ? '#' + el.id : (el.name ? el.tagName.toLowerCase() + '[name="' + el.name + '"]' : '') }); }); [].slice.call(doc.querySelectorAll('button, a, input[type=submit], [role=button]')).forEach(el => { const t = (el.innerText || el.value || '').trim(); if (t && t.length < 20) buttons.push({ text: t, selector: el.id ? '#' + el.id : '' }); }); } catch (e) { /* ignore */ } return { inputs: inputs.slice(0, 25), buttons: buttons.slice(0, 25) }; } function guessApi(caps, q) { const KEYWORDISH = /(keyword|key|search|query|question|content|name|title|stem|题目|题干|关键词)/i; let weak = null; for (const c of caps) { let u; try { u = new URL(c.url, ORIGIN); } catch (e) { continue; } let param = ''; for (const [k, v] of u.searchParams) if (v === q) { param = k; break; } if (!param && c.body) { try { for (const [k, v] of new URLSearchParams(c.body)) if (v === q) { param = k; break; } } catch (e) { /* ignore */ } } if (!param && c.body && String(c.body).trim().startsWith('{')) { try { const j = JSON.parse(c.body); Object.keys(j).forEach(k => { if (j[k] === q) param = k; }); } catch (e) { /* ignore */ } } const path = u.pathname.replace(/^\/+/, ''); if (/\/user\/|login|message|info\.do|getClassInfo/.test(path)) continue; // 排除非题目接口 if (param) return { path, method: String(c.method || 'GET').toUpperCase(), param }; if (!weak && KEYWORDISH.test(u.search) && [...keyTree(c.data, 3)].some(k => /question|stem|题目|题干|content|title/i.test(k))) { weak = { path, method: String(c.method || 'GET').toUpperCase(), param: cfg.apiParamQuestion, guessed: true }; } } return weak; } async function probe(q) { const doc = await ensureWorkFrame(true); injectHook(doc); const inv = domInventory(doc); clearCaptures(); const since = now(); const kw = (q || '').trim() || ('ktprobe' + String(now()).slice(-4)); let domErr = ''; try { await searchViaPage(kw, doc, cfg); } catch (e) { domErr = e.message; } await sleep(600); const caps = takeCaptures(since).map(c => ({ url: c.url, method: c.method, body: c.body, jsonKeys: [...keyTree(c.data, 3)].slice(0, 40) })); const guess = guessApi(caps, kw); return { moduleUrl: moduleFullUrl(), keyword: kw, domInventory: inv, responses: caps, suggestion: guess, domSearchError: domErr, hint: guess ? '已自动回填接口配置:' + guess.path + '(参数 ' + guess.param + ')。若字段解析不到答案,可把 JSON 字段结构发给开发者调整关键字表。' : '未自动识别到查询接口。可手工把上面 responses 里的地址填入「设置 → 接口地址」,或继续使用「页面查询」模式。' }; } /* ===================================================================== * 第 8 部分:iframe 内实例(脚本猫默认会在框架中运行,作为兜底代理) * ===================================================================== */ if (!IS_TOP) { window.addEventListener('message', async function (ev) { if (ev.origin !== ORIGIN || !ev.data || ev.data.__kt !== 'cmd') return; const { id, action, payload } = ev.data; const reply = (data) => { try { (ev.source || window.parent).postMessage(Object.assign({ __kt: 'ack', id }, data), ORIGIN); } catch (e) {} }; try { if (action === 'search') { injectHook(document); const hits = await searchViaPage(payload.q, document, payload.cfg || cfg); reply({ hits }); } else reply({ error: '未知指令' }); } catch (e) { reply({ error: String(e && e.message || e) }); } }); return; } /* ===================================================================== * 第 9 部分:顶层面板 UI * ===================================================================== */ GMX.style(` #kt-panel{position:fixed;right:18px;bottom:18px;width:480px;max-width:calc(100vw - 36px);z-index:2147483647; background:#fff;color:#243044;border:1px solid #dfe4ec;border-radius:12px; box-shadow:0 8px 30px rgba(24,39,75,.18);font:13px/1.5 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;overflow:hidden} #kt-panel *{box-sizing:border-box} .kt-head{display:flex;align-items:center;gap:8px;padding:9px 12px;background:#f5f7fb;border-bottom:1px solid #e6eaf1;cursor:move;user-select:none} .kt-title{font-weight:600;font-size:13px;flex:1;color:#1f2d3d} .kt-badge{font-size:11px;color:#6b7a90;background:#e9eef6;padding:1px 7px;border-radius:10px} .kt-btn{border:1px solid #cfd8e6;background:#fff;color:#2b3a52;border-radius:6px;padding:4px 9px;cursor:pointer;font-size:12px} .kt-btn:hover{background:#f0f4fa} .kt-btn.primary{background:#2b6cb0;border-color:#2b6cb0;color:#fff} .kt-btn.primary:hover{background:#245a94} .kt-btn:disabled{opacity:.5;cursor:not-allowed} .kt-body{padding:10px 12px 12px;max-height:min(72vh,660px);overflow:auto} #kt-panel.kt-min .kt-body{display:none} #kt-q{width:100%;min-height:88px;resize:vertical;border:1px solid #d5dce8;border-radius:7px;padding:8px;font:12px/1.6 inherit;color:#243044;background:#fcfdff} .kt-row{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0} .kt-status{font-size:12px;color:#5b6b82;min-height:18px} .kt-table{width:100%;border-collapse:collapse;margin-top:8px;font-size:12px} .kt-table th{background:#f5f7fb;text-align:left;padding:6px;border-bottom:1px solid #e6eaf1;color:#5b6b82;font-weight:600} .kt-table td{padding:6px;border-bottom:1px solid #eef1f6;vertical-align:top;word-break:break-word} .kt-s-ok{color:#137a45;font-weight:600}.kt-s-ambiguous{color:#b7791f;font-weight:600} .kt-s-not_found{color:#6b7a90}.kt-s-auth_expired,.kt-s-network_error,.kt-s-error,.kt-s-parse_error,.kt-s-answer_missing{color:#c53030;font-weight:600} .kt-answer{color:#137a45;font-weight:600} .kt-note{color:#8792a3;font-size:11px;display:block;margin-top:2px} .kt-details{margin-top:10px;border-top:1px solid #eef1f6;padding-top:8px} .kt-details summary{cursor:pointer;color:#4a5b73;font-size:12px} .kt-grid{display:grid;grid-template-columns:130px 1fr;gap:6px 8px;margin-top:8px;align-items:center;font-size:12px} .kt-grid input,.kt-grid select{width:100%;border:1px solid #d5dce8;border-radius:5px;padding:4px 6px;font-size:12px;color:#243044;background:#fff} .kt-foot{font-size:11px;color:#97a1b0;margin-top:8px;line-height:1.5} .kt-probe{margin-top:8px;background:#f7f9fc;border:1px solid #e6eaf1;border-radius:6px;padding:8px; font:11px/1.5 Consolas,Monaco,monospace;white-space:pre-wrap;max-height:230px;overflow:auto;color:#334155} .kt-hidden{display:none} `); const root = document.createElement('div'); root.id = 'kt-panel'; root.innerHTML = `
题目答案查询助手 页面查询
就绪 · 请先确认已登录且账号有题库权限

      
#题目 状态答案 / 说明
设置(接口、选择器、阈值、重试)
仅用于本人有权访问的题库/练习复习,勿用于在线正式考试。脚本不读取或保存账号密码,只复用当前浏览器会话。
`; (document.body || document.documentElement).appendChild(root); const $ = (id) => document.getElementById(id); const state = { results: [], running: false }; function setStatus(t) { $('kt-status').textContent = t; } function refreshMode() { $('kt-mode').textContent = cfg.apiPath ? '接口查询' : '页面查询'; $('c-mode').value = cfg.apiPath ? 'api' : 'page'; } function fillSettings() { const sel = $('c-module'); sel.innerHTML = ''; Object.keys(MODULES).forEach(k => { const o = document.createElement('option'); o.value = MODULES[k]; o.textContent = k; if (MODULES[k] === cfg.modulePath) o.selected = true; sel.appendChild(o); }); $('c-api').value = cfg.apiPath || ''; $('c-method').value = cfg.apiMethod || 'GET'; $('c-param').value = cfg.apiParamQuestion || 'keyword'; $('c-extra').value = cfg.apiExtraParams || ''; $('c-input').value = cfg.searchInputSelector || ''; $('c-button').value = cfg.searchButtonSelector || ''; $('c-th').value = cfg.threshold; $('c-retry').value = cfg.retries; $('c-timeout').value = cfg.timeoutMs; $('c-settle').value = cfg.settleMs; $('c-cache').value = cfg.useCache ? '1' : '0'; refreshMode(); } function readSettings() { cfg.modulePath = $('c-module').value || DEFAULTS.modulePath; const mode = $('c-mode').value; cfg.apiPath = mode === 'api' ? $('c-api').value.trim() : ''; cfg.apiMethod = $('c-method').value; cfg.apiParamQuestion = $('c-param').value.trim() || 'keyword'; cfg.apiExtraParams = $('c-extra').value.trim(); cfg.searchInputSelector = $('c-input').value.trim(); cfg.searchButtonSelector = $('c-button').value.trim(); cfg.threshold = Math.min(1, Math.max(0, Number($('c-th').value) || 0.82)); cfg.retries = Math.min(6, Math.max(1, Number($('c-retry').value) || 3)); cfg.timeoutMs = Math.max(2000, Number($('c-timeout').value) || 12000); cfg.settleMs = Math.max(300, Number($('c-settle').value) || 1200); cfg.useCache = $('c-cache').value === '1'; saveCfg(); refreshMode(); } fillSettings(); function parseQuestions(text) { return String(text || '').split(/[\r\n]+/) .map(s => s.replace(/^\s*(?:\d+[\.、\)]|第\d+题)\s*/, '').trim()) .filter(s => s && !s.startsWith('#')); } function short(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n) + '…' : s; } function renderRow(r, i) { const tr = document.createElement('tr'); const td = (txt, cls) => { const c = document.createElement('td'); c.textContent = txt; if (cls) c.className = cls; return c; }; tr.appendChild(td(String(i + 1))); tr.appendChild(td(short(r.query, 70))); tr.appendChild(td(STATUS_TEXT[r.status] || r.status, 'kt-s-' + r.status)); const last = document.createElement('td'); if (r.status === 'ok') { const a = document.createElement('span'); a.className = 'kt-answer'; a.textContent = r.answer; last.appendChild(a); } else if (r.status === 'ambiguous') { const a = document.createElement('span'); a.className = 'kt-answer'; a.textContent = (r.answers || []).join(' / '); last.appendChild(a); const note = document.createElement('span'); note.className = 'kt-note'; note.textContent = '以下答案均出现,需人工确认,脚本不自动选取'; last.appendChild(note); } else if (r.fallback) { const a = document.createElement('span'); a.className = 'kt-answer'; a.textContent = r.fallback.answer; last.appendChild(a); const note = document.createElement('span'); note.className = 'kt-note'; note.textContent = '来自' + r.fallback.from; last.appendChild(note); } else { last.textContent = r.message || STATUS_TEXT[r.status] || r.status; } if (r.candidates && r.candidates.length && r.status !== 'ok') { const note = document.createElement('span'); note.className = 'kt-note'; note.textContent = '候选:' + r.candidates.map(c => short(c.question, 26) + '(' + c.score + ')').join(';'); last.appendChild(note); } const meta = document.createElement('span'); meta.className = 'kt-note'; meta.textContent = (r.elapsedMs || 0) + 'ms · 重试 ' + (r.attempts || 1) + ' 次'; last.appendChild(meta); tr.appendChild(last); $('kt-tbody').appendChild(tr); return tr; } function setRunning(on) { state.running = on; ['kt-run', 'kt-probe', 'kt-grab'].forEach(id => { $(id).disabled = on; }); } async function runBatch() { if (state.running) return; const qs = parseQuestions($('kt-q').value); if (!qs.length) { setStatus('请先在文本框输入题目(一行一题)'); return; } setRunning(true); state.results = []; $('kt-tbody').innerHTML = ''; let ok = 0, nf = 0, amb = 0, err = 0; for (let i = 0; i < qs.length; i++) { setStatus('(' + (i + 1) + '/' + qs.length + ') 查询中:' + short(qs[i], 28)); let r; try { r = await lookup(qs[i]); } catch (e) { r = { status: 'error', answer: '', candidates: [], elapsedMs: 0, attempts: 1, message: '未捕获异常:' + (e && e.message || e) }; } r.query = qs[i]; state.results.push(r); renderRow(r, i); if (r.status === 'ok') ok++; else if (r.status === 'not_found' || r.status === 'answer_missing') nf++; else if (r.status === 'ambiguous') amb++; else err++; await sleep(120); // 轻微限速,避免给平台造成压力 } const summary = '完成:命中 ' + ok + ' · 未找到 ' + nf + ' · 需确认 ' + amb + ' · 异常 ' + err; setStatus(summary); GMX.notify(summary); setRunning(false); } function resultText() { return state.results.map((r, i) => [ i + 1, r.query, STATUS_TEXT[r.status] || r.status, r.status === 'ok' ? r.answer : (r.answers || []).join(' / ') || (r.fallback && r.fallback.answer) || r.message || '' ].join('\t')).join('\n'); } function exportJSON() { const data = JSON.stringify({ site: ORIGIN, generatedAt: new Date().toISOString(), results: state.results }, null, 2); const blob = new Blob([data], { type: 'application/json;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'kt-answers-' + new Date().toISOString().slice(0, 10) + '.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 3000); setStatus('已导出 ' + state.results.length + ' 条结果'); } $('kt-run').onclick = runBatch; $('kt-min').onclick = () => root.classList.toggle('kt-min'); $('kt-clear').onclick = () => { $('kt-q').value = ''; $('kt-tbody').innerHTML = ''; state.results = []; setStatus('已清空'); }; $('kt-copy').onclick = () => { if (!state.results.length) return setStatus('暂无结果'); GMX.clipboard(resultText()); setStatus('结果已复制(制表符分隔,可直接粘贴到表格)'); }; $('kt-export').onclick = exportJSON; $('kt-save').onclick = () => { readSettings(); setStatus('设置已保存;切换模块后会自动重建查询 iframe'); }; $('kt-cache-clear').onclick = () => { GMX.set('kt_cache', '{}'); setStatus('离线缓存已清空'); }; $('c-mode').onchange = () => { if ($('c-mode').value === 'api' && !$('c-api').value.trim()) setStatus('接口地址为空:建议先点「探测接口」自动回填'); }; $('kt-grab').onclick = () => { const docs = [document]; const f = document.getElementById('__kt_work_frame__'); if (f) { const d = frameDoc(f); if (d) docs.push(d); } const all = []; docs.forEach(d => extractQuestionsFromDom(d).forEach(q => { if (!all.includes(q)) all.push(q); })); if (!all.length) return setStatus('本页未识别到题目,请手工粘贴,或先打开含题目的页面'); const cur = $('kt-q').value.trim(); $('kt-q').value = (cur ? cur + '\n' : '') + all.join('\n'); setStatus('已从页面识别 ' + all.length + ' 道题,请检查后点「批量查询」'); }; $('kt-probe').onclick = async () => { if (state.running) return; setRunning(true); setStatus('正在打开题库模块并嗅探接口…(首次可能需要几秒)'); const box = $('kt-probe-out'); box.classList.remove('kt-hidden'); try { const first = parseQuestions($('kt-q').value)[0] || 'a'; const info = await probe(first); box.textContent = JSON.stringify(info, null, 2); if (info.suggestion) { cfg.apiPath = info.suggestion.path; cfg.apiMethod = info.suggestion.method; cfg.apiParamQuestion = info.suggestion.param; saveCfg(); fillSettings(); setStatus('探测完成:' + info.hint); } else setStatus('探测完成:' + info.hint); setStatus($('kt-status').textContent + '(详情见下方,可全选复制)'); log('探测结果', info); } catch (e) { box.textContent = '探测失败:' + (e && e.message || e); setStatus('探测失败,详见下方输出'); } finally { setRunning(false); } }; $('kt-file').onchange = async (ev) => { const file = ev.target.files && ev.target.files[0]; if (!file) return; setStatus('OCR 识别中…'); try { const text = await ocrImage(file, s => setStatus(s)); if (!text.trim()) return setStatus('图片中未识别到文字'); const cur = $('kt-q').value.trim(); $('kt-q').value = (cur ? cur + '\n' : '') + text.trim(); setStatus('OCR 完成,请校对文本后查询(中文识别可能有误)'); } catch (e) { setStatus('OCR 失败:' + (e && e.message || e) + ';可改用手工粘贴'); } ev.target.value = ''; }; // 面板拖动 (function () { const head = $('kt-drag'); let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false; head.addEventListener('mousedown', e => { dragging = true; sx = e.clientX; sy = e.clientY; const r = root.getBoundingClientRect(); ox = r.left; oy = r.top; root.style.right = 'auto'; root.style.bottom = 'auto'; root.style.left = ox + 'px'; root.style.top = oy + 'px'; e.preventDefault(); }); window.addEventListener('mousemove', e => { if (!dragging) return; root.style.left = Math.max(0, ox + e.clientX - sx) + 'px'; root.style.top = Math.max(0, oy + e.clientY - sy) + 'px'; }); window.addEventListener('mouseup', () => { dragging = false; }); })(); GMX.menu('打开/隐藏 题目查询面板', () => root.classList.toggle('kt-min')); GMX.menu('探测题库接口并自动配置', () => $('kt-probe').click()); GMX.menu('清空离线缓存', () => { GMX.set('kt_cache', '{}'); GMX.notify('缓存已清空'); }); window.addEventListener('keydown', e => { if (e.altKey && (e.key === 'k' || e.key === 'K')) root.classList.toggle('kt-min'); }); log('已加载。模块=' + cfg.modulePath + ',模式=' + (cfg.apiPath ? '接口' : '页面') + ',Alt+K 折叠面板'); })();