// ==UserScript== // @name JetPunk Contextual Answer Explainer // @version 1.5.0 // @description Per-quiz explanation switch, Wikipedia/LLM enrichment, and contextual answer tooltips. // @author EZ // @match https://www.jetpunk.com/quizzes/* // @match https://www.jetpunk.com/user-quizzes/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect * // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; /** * ========================= 配置区 ========================= * * 免费方式:SOURCE_MODE 保持为 'wikipedia',无需配置任何 Key。 * 混合方式:改为 'hybrid',Wiki 先提供事实证据,大模型再整批解释题目关联。 * LLM 方式:改为 'llm';可直接把 API Key 填入 API_KEY, * 或保持 API_KEY 为空,让脚本用 GM_setValue 保存首次输入的 Key。 * * BASE_URL 必须是 OpenAI Chat Completions 兼容端点,例如: * - OpenAI: https://api.openai.com/v1/chat/completions * - DeepSeek: https://api.deepseek.com/chat/completions * - Gemini OpenAI 兼容层或其他代理:填写其完整 chat/completions 地址 */ const CONFIG = Object.freeze({ // ================= 来源开关 ================= // 初始默认值;在 Tampermonkey 菜单选择后,以菜单中保存的模式为准。 // 'wikipedia':完全免费;'hybrid':Wiki 提供证据、LLM 解释关联;'llm':全部交给 LLM。 SOURCE_MODE: 'wikipedia', // ================= LLM 子配置 ================= API_KEY: '', BASE_URL: 'https://api.deepseek.com/chat/completions', MODEL: 'deepseek-v4-flash', PROMPT_FOR_KEY: true, USE_JSON_MODE: true, REQUEST_TIMEOUT_MS: 90_000, TEMPERATURE: 0.2, // hybrid 默认将全部答案一次性打包给 LLM;Wiki 草稿负责减少幻觉和词义漂移。 HYBRID_SCOPE: 'all', HYBRID_MAX_LLM_ITEMS: 200, // ================= Wikipedia 子配置 ================= // LOOKUP_LANGUAGE 应与 JetPunk 答案语言一致;DISPLAY_LANGUAGE 决定摘要语言。 WIKIPEDIA_LOOKUP_LANGUAGE: 'en', WIKIPEDIA_DISPLAY_LANGUAGE: 'zh', WIKIPEDIA_CHINESE_VARIANT: 'zh-cn', WIKIPEDIA_REQUEST_TIMEOUT_MS: 45_000, WIKIPEDIA_TITLE_BATCH_SIZE: 50, WIKIPEDIA_EXTRACT_BATCH_SIZE: 20, // 仅对“精确标题与题意不匹配”的答案逐个搜索;覆盖大型 200 题测验。 WIKIPEDIA_MAX_CONTEXT_SEARCHES: 200, // 开关位于 Start Quiz 旁,玩家开始前即可决定,因此开启解释时仍可后台预取。 PREFETCH_ON_QUIZ_START: true, MAX_DESCRIPTION_CHARS: 1_000, MAX_CLUE_CHARS: 600, WIKIPEDIA_SUMMARY_MAX_CHARS: 220, TOOLTIP_MAX_WIDTH_PX: 460, TOOLTIP_HIDE_DELAY_MS: 700, }); const SYSTEM_PROMPT = "你是地理与通识测验的实体解释助手。结合测验标题、说明、每个答案对应的题目正文和答案,输出客观中文解释。每项依次包含:中文名;题目关联(必须具体解释该实体为什么或如何满足这条题干/线索,引用关键事实,严禁只复述“它在某测验中对应某题”);1-2项有助记忆的背景知识。每项约100-180字。严禁词义漂移和编造,无法确认时明确说明。若答案明确是地理地点,可在末尾另起一行输出“地图:https://www.google.com/maps/search/?api=1&query=编码后的地名”,不得编造坐标。返回纯JSON字典,Key严格保留答案原文,Value为解释文本。"; const HYBRID_SYSTEM_PROMPT = "你是测验解释校对助手。Wikipedia草稿是主要事实依据,并结合测验标题、说明和逐题正文进行语义推理。每项依次包含:中文名;题目关联(具体解释该答案为什么或如何满足该题干,指出关键事实,禁止只说“在某测验中对应某题”或机械复述题干);1-2项扩展知识。约100-180字。不得捏造草稿与常识都无法支持的细节,存疑时明确说明。不要输出来源或地图行,脚本会保留Wikipedia提供的可靠链接。返回纯JSON字典,Key严格保留答案原文,Value为解释文本。"; const SCRIPT_PREFIX = 'jpce'; const API_KEY_STORAGE_KEY = `${SCRIPT_PREFIX}:api-key`; const SOURCE_MODE_STORAGE_KEY = `${SCRIPT_PREFIX}:source-mode`; // v6 修复括号答案匹配,并加入整题解释开关。 const CACHE_SCHEMA_VERSION = 6; const CACHE_PREFIX = `${SCRIPT_PREFIX}:cache:v${CACHE_SCHEMA_VERSION}:`; const TOOLTIP_ID = `${SCRIPT_PREFIX}-tooltip`; const READY_CLASS = `${SCRIPT_PREFIX}-ready`; const QUIZ_TOGGLE_CLASS = `${SCRIPT_PREFIX}-quiz-toggle`; const OWN_UI_ATTRIBUTE = `data-${SCRIPT_PREFIX}-ui`; let runtimeSourceMode = null; // JetPunk 的 answer-* 类带有随机后缀;其余选择器用于兼容不同题型及旧版页面。 const BASE_ANSWER_SELECTOR = [ '.answer-cell', '.quiz-answer', 'td.answer', 'th.answer', 'td[class*="answer-"]', 'th[class*="answer-"]', '[data-answer]', ].join(','); const REVEALED_STATE_SELECTOR = [ '.correct', '.missed', '.incorrect', '.revealed', '[data-state="correct"]', '[data-state="missed"]', '[data-state="incorrect"]', '[data-state="revealed"]', ].join(','); const state = { processing: false, failed: false, observer: null, scanTimer: 0, apiKeyPromise: null, prefetchPromise: null, prefetchFingerprint: '', prefetchError: null, prefetchAttempted: false, backgroundPrefetching: false, explanationMap: null, explanationFingerprint: '', activeCell: null, tooltip: null, tooltipEventsInstalled: false, statusTimer: 0, tooltipHideTimer: 0, positionFrame: 0, sawRunningControl: false, boundCells: new WeakSet(), cellExplanations: new WeakMap(), quizExplanationEnabled: true, quizToggleButton: null, quizToggleLocked: false, quizStartControl: null, }; function cleanText(value, maxLength = Infinity) { const text = String(value ?? '') .replace(/[\u200B-\u200D\uFEFF]/g, '') .replace(/\s+/g, ' ') .trim(); return text.length > maxLength ? `${text.slice(0, maxLength - 1).trimEnd()}…` : text; } function normalizeSourceMode(value) { const mode = String(value || '').trim().toLowerCase(); return mode === 'llm' || mode === 'hybrid' ? mode : 'wikipedia'; } function selectedSourceMode() { return runtimeSourceMode || normalizeSourceMode(CONFIG.SOURCE_MODE); } function cleanMultilineText(value, maxLength = Infinity) { const text = String(value ?? '') .replace(/\r\n?/g, '\n') .replace(/[\u200B-\u200D\uFEFF]/g, '') .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) .filter(Boolean) .join('\n'); return text.length > maxLength ? `${text.slice(0, maxLength - 1).trimEnd()}…` : text; } function elementText(element, maxLength = Infinity) { if (!(element instanceof HTMLElement)) return ''; // innerText 只获取实际呈现的答案,避免读取 CSS 隐藏的预置文本。 const renderedText = typeof element.innerText === 'string' ? element.innerText : element.textContent || ''; return cleanText(renderedText, maxLength); } function storedElementText(element, maxLength = Infinity) { if (!(element instanceof HTMLElement)) return ''; return cleanText(element.textContent || '', maxLength); } function answerCellText(element, { includeHidden = false, maxLength = 300 } = {}) { if (!includeHidden) return elementText(element, maxLength); const storedText = storedElementText(element, maxLength); if (storedText) return storedText; // 少数题型把尚未揭晓的答案放在 data-* 中,而不是文本节点中。 return cleanText( element.getAttribute('data-answer') || element.getAttribute('data-correct-answer') || element.getAttribute('data-solution') || '', maxLength, ); } function isVisible(element) { if (!(element instanceof HTMLElement)) return false; const style = getComputedStyle(element); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { return false; } return element.getClientRects().length > 0; } function controlText(element) { if (element instanceof HTMLInputElement) { return cleanText(element.value || element.getAttribute('aria-label') || ''); } return elementText(element, 100); } function visibleControls() { return Array.from( document.querySelectorAll('button, a, [role="button"], input[type="button"], input[type="submit"]'), ).filter(isVisible); } function classLooksLikeJetPunkAnswer(element) { return Array.from(element.classList).some( (className) => className === 'answer' || className === 'answer-cell' || className === 'quiz-answer' || /^answer-[A-Za-z0-9_-]{4,}$/.test(className), ); } function isBaseAnswerCell(element) { return ( element instanceof HTMLElement && (element.hasAttribute('data-answer') || classLooksLikeJetPunkAnswer(element)) ); } function hasRevealedState(element) { if (!(element instanceof HTMLElement)) return false; if (element.matches(REVEALED_STATE_SELECTOR)) return true; return Array.from(element.classList).some((name) => /^(?:answer-|text-)?(?:correct|incorrect|missed|revealed)$/.test(name), ); } /** 返回真正的答案格子;若选择器命中内部 span,则提升到其 td/th。 */ function collectAnswerCells({ revealedOnly = false, includeHidden = false } = {}) { const directMatches = Array.from(document.querySelectorAll(BASE_ANSWER_SELECTOR)); const stateOnlyMatches = Array.from( document.querySelectorAll('td.correct, td.missed, td.incorrect, th.correct, th.missed, th.incorrect'), ); const unique = new Set(); for (const match of [...directMatches, ...stateOnlyMatches]) { if (!(match instanceof HTMLElement)) continue; const tableCell = match.closest('td, th'); const cell = tableCell && (isBaseAnswerCell(tableCell) || hasRevealedState(tableCell)) ? tableCell : match; if (!isBaseAnswerCell(cell) && !hasRevealedState(cell)) continue; if (revealedOnly && !hasRevealedState(cell)) continue; if (cell.closest('template')) continue; if (!includeHidden && cell.closest('[aria-hidden="true"]')) continue; if (!includeHidden && !isVisible(cell)) continue; const answer = answerCellText(cell, { includeHidden }); if (!answer || /^(?:answer|答案|—|-|\?|✓|✗)$/iu.test(answer)) continue; unique.add(cell); } return Array.from(unique); } function hasVisibleCompletionPanel() { const controls = visibleControls(); if (controls.some((node) => /^(?:retake|restart|take again)\b.*quiz/i.test(controlText(node)))) { return true; } const panels = document.querySelectorAll([ '#quiz-results', '#quiz-result', '#scoring', '.quiz-results', '.quiz-result', '.quiz-score', '.scoring', '[data-quiz-state="finished"]', '[data-state="finished"]', '[data-state="complete"]', ].join(',')); for (const panel of panels) { if (!isVisible(panel)) continue; const text = elementText(panel, 2_000); if (/\b(?:you scored|your score|scoring|quiz results?|retake quiz)\b/i.test(text)) { return true; } if (panel.matches('[data-quiz-state="finished"], [data-state="finished"], [data-state="complete"]')) { return true; } } return false; } function hasVisibleZeroTimer() { const timers = document.querySelectorAll([ '#timer', '.timer', '.quiz-timer', '[data-testid*="timer" i]', '[id*="countdown" i]', '[class*="countdown" i]', ].join(',')); return Array.from(timers).some( (timer) => isVisible(timer) && /^(?:0?0:)?0?0:00$/.test(elementText(timer, 20)), ); } function updateRunningSignal() { const runningControl = visibleControls().find((node) => /^give up\??$/i.test(controlText(node))); if (runningControl && !runningControl.hasAttribute('disabled')) { state.sawRunningControl = true; } return Boolean(runningControl); } function isQuizFinished() { const giveUpIsVisible = updateRunningSignal(); if (hasVisibleCompletionPanel()) return true; const allCells = collectAnswerCells(); const revealedCells = collectAnswerCells({ revealedOnly: true }); if (!revealedCells.length) return false; // 回退路径:兼容没有标准结算容器的旧题型。必须同时满足答案几乎全部揭晓, // 并且倒计时归零,或曾经可见的 Give Up 控件已经消失。 const revealRatio = revealedCells.length / Math.max(allCells.length, revealedCells.length); const runningControlStopped = state.sawRunningControl && !giveUpIsVisible; return revealRatio >= 0.98 && (hasVisibleZeroTimer() || runningControlStopped); } function isQuizRunning() { return updateRunningSignal() && !hasVisibleCompletionPanel(); } function findColumnHeader(cell, includeHidden = false) { if (!(cell instanceof HTMLTableCellElement)) return ''; const table = cell.closest('table'); const row = cell.closest('tr'); if (!table || !row) return ''; const targetIndex = cell.cellIndex; let bestHeader = ''; for (const headerRow of table.querySelectorAll('thead tr, tr')) { if (headerRow === row) break; let column = 0; for (const headerCell of headerRow.children) { if (!(headerCell instanceof HTMLTableCellElement)) continue; const span = Math.max(1, headerCell.colSpan || 1); if ( headerCell.tagName === 'TH' && targetIndex >= column && targetIndex < column + span ) { const candidate = includeHidden ? storedElementText(headerCell, 120) : elementText(headerCell, 120); if (candidate) bestHeader = candidate; } column += span; } } return bestHeader; } function isUnhelpfulHeader(text) { return /^(?:answer|answers|答案|réponse|antwort|respuesta|risposta)$/iu.test(text); } function extractClue(cell, answer, includeHidden = false) { const clueParts = []; const readText = (element, maxLength = CONFIG.MAX_CLUE_CHARS) => includeHidden ? storedElementText(element, maxLength) : elementText(element, maxLength); const addPart = (value, prefix = '') => { const text = cleanText(value, CONFIG.MAX_CLUE_CHARS); if (!text || text === answer) return; const labeled = prefix && !text.startsWith(prefix) ? `${prefix}${text}` : text; if (!clueParts.includes(labeled)) clueParts.push(labeled); }; const header = findColumnHeader(cell, includeHidden); if (header && header !== answer && !isUnhelpfulHeader(header)) { addPart(header, '列头:'); } const row = cell.closest('tr, [role="row"], .quiz-row, .answer-row'); if (row) { for (const sibling of row.children) { if (!(sibling instanceof HTMLElement) || sibling === cell || sibling.contains(cell)) continue; if (isBaseAnswerCell(sibling) || hasRevealedState(sibling)) continue; if (!includeHidden && !isVisible(sibling)) continue; addPart(readText(sibling)); } } // 非表格、卡片和图片题常把正文放在答案格祖先中的 question/prompt 节点。 const semanticRoot = cell.closest([ '.quiz-question', '.question-row', '.quiz-row', '.answer-row', '[data-question]', '[data-prompt]', '[role="row"]', 'li', ].join(',')); const roots = new Set([row, semanticRoot, cell.parentElement].filter(Boolean)); const questionSelector = [ '.hint', '.clue', '.question', '.question-text', '.quiz-question-text', '.prompt', '[data-hint]', '[data-clue]', '[data-question]', '[data-prompt]', '[class*="hint-"]', '[class*="clue-"]', ].join(','); for (const root of roots) { for (const attribute of ['data-hint', 'data-clue', 'data-question', 'data-prompt']) { addPart(root.getAttribute?.(attribute) || '', '题目:'); } for (const questionNode of Array.from(root.querySelectorAll?.(questionSelector) || []).slice(0, 12)) { if (!(questionNode instanceof HTMLElement) || questionNode === cell) continue; if (questionNode.contains(cell) || cell.contains(questionNode)) continue; if (isBaseAnswerCell(questionNode) || hasRevealedState(questionNode)) continue; if (!includeHidden && !isVisible(questionNode)) continue; addPart(readText(questionNode), '题目:'); } } // aria-labelledby 常用于把独立的题干与输入/答案格关联起来。 for (const id of cleanText(cell.getAttribute('aria-labelledby') || '').split(/\s+/)) { if (!id) continue; const label = document.getElementById(id); if (label instanceof HTMLElement) addPart(readText(label), '题目:'); } // 最后兼容“题干段落 + 下一节点答案”的简单结构,但不跨到其他答案容器。 const anchor = semanticRoot || cell.parentElement; const previous = anchor?.previousElementSibling; if ( previous instanceof HTMLElement && previous.matches('p, label, dt, .question, .prompt, [data-question], [data-prompt]') && !previous.querySelector(BASE_ANSWER_SELECTOR) && (includeHidden || isVisible(previous)) ) { addPart(readText(previous), '题目:'); } return cleanText(clueParts.join(' | '), CONFIG.MAX_CLUE_CHARS); } function extractDescription(title) { const selectors = [ '.quiz-description', '.quiz-description-text', '.quiz-desc', '.quiz-instructions', '.quizInstructions', '#quiz-description', '[data-testid="quiz-description"]', '[itemprop="description"]', ]; const parts = []; for (const selector of selectors) { for (const element of document.querySelectorAll(selector)) { if (!isVisible(element)) continue; const text = elementText(element, CONFIG.MAX_DESCRIPTION_CHARS); if (text && text !== title && !parts.includes(text)) parts.push(text); } } if (!parts.length) { const meta = document.querySelector('meta[name="description"], meta[property="og:description"]'); const metaText = cleanText(meta?.getAttribute('content') || '', CONFIG.MAX_DESCRIPTION_CHARS); if (metaText && metaText !== title) parts.push(metaText); } return cleanText(parts.join(' '), CONFIG.MAX_DESCRIPTION_CHARS); } function extractQuizData({ includeHiddenAnswers = false } = {}) { const title = elementText(document.querySelector('h1'), 300) || cleanText(document.title, 300); const description = extractDescription(title); const cells = collectAnswerCells({ includeHidden: includeHiddenAnswers }); const items = []; for (const cell of cells) { const answer = answerCellText(cell, { includeHidden: includeHiddenAnswers }); if (!answer) continue; const question = extractClue(cell, answer, includeHiddenAnswers); items.push({ answer, clue: question, question, cell, }); } return { title, description, items }; } function explanationEnabledQuizData(data) { return { ...data, items: state.quizExplanationEnabled ? data.items : [], }; } function expectedAnswerCount() { const quizRoot = document.querySelector('main') || document.body; const text = elementText(quizRoot, 12_000); const counts = []; for (const match of text.matchAll(/\b\d+\s*\/\s*(\d+)\s*(?:guessed|answers?|correct)\b/gi)) { counts.push(Number(match[1])); } return counts.length ? Math.max(...counts.filter(Number.isFinite)) : 0; } function rawAnswerCellCount() { const cells = new Set(); for (const match of document.querySelectorAll(BASE_ANSWER_SELECTOR)) { if (!(match instanceof HTMLElement) || match.closest('template')) continue; cells.add(match.closest('td, th') || match); } return cells.size; } function hasCompletePrefetchData(data) { const uniqueAnswers = new Set(data.items.map(({ answer }) => answer)).size; const expected = expectedAnswerCount(); // 宁可等到交卷后再请求,也不拿不完整答案提前生成,避免同一局重复付费。 if (expected > 0) return uniqueAnswers >= expected; const rawCellCount = rawAnswerCellCount(); return uniqueAnswers > 0 && data.items.length >= rawCellCount; } function publicQuizData(data) { const questionsByAnswer = new Map(); for (const { answer, question, clue } of data.items) { if (!questionsByAnswer.has(answer)) questionsByAnswer.set(answer, new Set()); const text = question || clue || ''; if (text) questionsByAnswer.get(answer).add(text); } return { quizId: location.pathname, topic: data.title, description: data.description, items: Array.from(questionsByAnswer, ([answer, questions]) => ({ answer, question: cleanText(Array.from(questions).join(' | '), CONFIG.MAX_CLUE_CHARS), })), }; } function canonicalQuizIdentity(data) { const cluesByAnswer = new Map(); for (const { answer, clue } of data.items) { if (!cluesByAnswer.has(answer)) cluesByAnswer.set(answer, new Set()); if (clue) cluesByAnswer.get(answer).add(clue); } const items = Array.from(cluesByAnswer, ([answer, clues]) => ({ answer, clues: Array.from(clues).sort(), })).sort((left, right) => left.answer.localeCompare(right.answer)); return { quizId: location.pathname, topic: data.title, description: data.description, items, }; } function fingerprintQuiz(data) { const source = JSON.stringify({ sourceMode: selectedSourceMode(), wikipediaLookupLanguage: CONFIG.WIKIPEDIA_LOOKUP_LANGUAGE, wikipediaDisplayLanguage: CONFIG.WIKIPEDIA_DISPLAY_LANGUAGE, wikipediaChineseVariant: CONFIG.WIKIPEDIA_CHINESE_VARIANT, wikipediaSummaryMaxChars: CONFIG.WIKIPEDIA_SUMMARY_MAX_CHARS, hybridScope: CONFIG.HYBRID_SCOPE, hybridMaxItems: CONFIG.HYBRID_MAX_LLM_ITEMS, quiz: canonicalQuizIdentity(data), }); // FNV-1a:只用于判断缓存是否对应当前 DOM,不承担加密用途。 let hash = 0x811c9dc5; for (let index = 0; index < source.length; index += 1) { hash ^= source.charCodeAt(index); hash = Math.imul(hash, 0x01000193); } return (hash >>> 0).toString(36); } function cacheKey() { return `${CACHE_PREFIX}${selectedSourceMode()}:${location.pathname}`; } function readCache(fingerprint) { try { const raw = sessionStorage.getItem(cacheKey()); if (!raw) return null; const cached = JSON.parse(raw); if ( cached?.schema !== CACHE_SCHEMA_VERSION || cached?.fingerprint !== fingerprint || !Array.isArray(cached?.entries) ) { return null; } const entries = cached.entries.filter( (entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === 'string' && typeof entry[1] === 'string', ); return entries.length ? new Map(entries) : null; } catch (error) { console.warn('[JetPunk Explainer] 无法读取 sessionStorage 缓存:', error); return null; } } function writeCache(fingerprint, explanationMap) { // hybrid 因未配置 Key 或补充失败而退回纯 Wiki 时不写 hybrid 缓存; // 用户下次刷新并配置 Key 后仍有机会补充,而不会被本次降级结果挡住。 if (selectedSourceMode() === 'hybrid' && explanationMap?.hybridCacheable === false) return; try { sessionStorage.setItem( cacheKey(), JSON.stringify({ schema: CACHE_SCHEMA_VERSION, fingerprint, savedAt: Date.now(), entries: Array.from(explanationMap.entries()), }), ); } catch (error) { console.warn('[JetPunk Explainer] 无法写入 sessionStorage 缓存:', error); } } async function gmGet(key, fallbackValue) { try { return await Promise.resolve(GM_getValue(key, fallbackValue)); } catch (error) { console.warn('[JetPunk Explainer] GM_getValue 失败:', error); return fallbackValue; } } async function gmSet(key, value) { try { await Promise.resolve(GM_setValue(key, value)); } catch (error) { console.warn('[JetPunk Explainer] GM_setValue 失败:', error); } } async function initializeSourceModeMenu() { runtimeSourceMode = normalizeSourceMode( await gmGet(SOURCE_MODE_STORAGE_KEY, normalizeSourceMode(CONFIG.SOURCE_MODE)), ); if (typeof GM_registerMenuCommand !== 'function') return; const modes = [ ['wikipedia', 'Wikipedia(免费)'], ['hybrid', 'Wikipedia + LLM(推荐)'], ['llm', 'LLM'], ]; for (const [mode, label] of modes) { const marker = runtimeSourceMode === mode ? '●' : '○'; GM_registerMenuCommand(`${marker} 解释来源:${label}`, async () => { if (runtimeSourceMode === mode) return; await gmSet(SOURCE_MODE_STORAGE_KEY, mode); location.reload(); }); } } async function getApiKey() { const configured = cleanText(CONFIG.API_KEY); if (configured) return configured; const stored = cleanText(await gmGet(API_KEY_STORAGE_KEY, '')); if (stored) return stored; if (!CONFIG.PROMPT_FOR_KEY) return ''; const endpointName = (() => { try { return new URL(CONFIG.BASE_URL).host; } catch (_) { return CONFIG.BASE_URL; } })(); const entered = cleanText( window.prompt( `JetPunk 答案解释器需要 API Key。\n端点:${endpointName}\n模型:${CONFIG.MODEL}\n\nKey 只会保存在 Tampermonkey 脚本存储中。`, '', ) || '', ); if (entered) await gmSet(API_KEY_STORAGE_KEY, entered); return entered; } function getApiKeyOnce() { if (!state.apiKeyPromise) state.apiKeyPromise = getApiKey(); return state.apiKeyPromise; } function gmRequest(options) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ ...options, onload: resolve, onerror: () => reject(new Error('网络请求失败,请检查 API 地址及网络连接。')), ontimeout: () => reject(new Error(`API 请求超过 ${CONFIG.REQUEST_TIMEOUT_MS / 1_000} 秒。`)), onabort: () => reject(new Error('API 请求已中止。')), }); }); } function safeWikiLanguage(value, fallback) { const language = String(value || '').trim().toLowerCase(); return /^[a-z][a-z0-9-]{0,14}$/.test(language) ? language : fallback; } function chunks(values, requestedSize, hardLimit) { const size = Math.max(1, Math.min(Number(requestedSize) || hardLimit, hardLimit)); const result = []; for (let index = 0; index < values.length; index += size) { result.push(values.slice(index, index + size)); } return result; } function wikiTitleKey(value) { return cleanText(value).replace(/_/g, ' ').normalize('NFKC').toLocaleLowerCase(); } function wikiPages(responseBody) { const pages = responseBody?.query?.pages; if (Array.isArray(pages)) return pages; return pages && typeof pages === 'object' ? Object.values(pages) : []; } /** * 将 API 的 normalized/converted/redirects 链还原到原始请求标题,避免 USA、 * Sao Tome 等重定向后无法重新对应 JetPunk 答案。 */ function mapRequestedWikiPages(requestedTitles, responseBody) { const edges = new Map(); const query = responseBody?.query || {}; for (const collection of [query.normalized, query.converted, query.redirects]) { if (!Array.isArray(collection)) continue; for (const item of collection) { if (item?.from && item?.to) edges.set(wikiTitleKey(item.from), wikiTitleKey(item.to)); } } const pagesByTitle = new Map( wikiPages(responseBody).map((page) => [wikiTitleKey(page.title), page]), ); const mapped = new Map(); for (const requestedTitle of requestedTitles) { let key = wikiTitleKey(requestedTitle); const visited = new Set(); while (edges.has(key) && !visited.has(key)) { visited.add(key); key = edges.get(key); } mapped.set(requestedTitle, pagesByTitle.get(key) || null); } return mapped; } function isMissingWikiPage(page) { return !page || Object.prototype.hasOwnProperty.call(page, 'missing') || page.invalid; } function isDisambiguationWikiPage(page) { return Boolean( page && (Object.prototype.hasOwnProperty.call(page.pageprops || {}, 'disambiguation') || /topics? referred to by the same term|disambiguation/i.test(page.description || '')), ); } function wikiLanguageLink(page, language) { if (!Array.isArray(page?.langlinks)) return ''; const link = page.langlinks.find((item) => item?.lang === language); return cleanText(link?.title || link?.['*'] || ''); } async function wikiApi(language, parameters) { const wikiLanguage = safeWikiLanguage(language, 'en'); const form = new URLSearchParams({ action: 'query', format: 'json', formatversion: '2', origin: '*', maxlag: '5', }); if (wikiLanguage === 'zh' && /^(?:zh|zh-cn|zh-hans|zh-tw|zh-hant|zh-hk)$/.test(CONFIG.WIKIPEDIA_CHINESE_VARIANT)) { form.set('variant', CONFIG.WIKIPEDIA_CHINESE_VARIANT); form.set('uselang', CONFIG.WIKIPEDIA_CHINESE_VARIANT); form.set('converttitles', '1'); } for (const [key, value] of Object.entries(parameters)) { if (value !== undefined && value !== null && value !== '') form.set(key, String(value)); } const response = await gmRequest({ method: 'POST', url: `https://${wikiLanguage}.wikipedia.org/w/api.php`, headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Api-User-Agent': 'JetPunkContextExplainer/1.5.0 (Tampermonkey userscript)', }, data: form.toString(), timeout: CONFIG.WIKIPEDIA_REQUEST_TIMEOUT_MS, }); let responseBody; try { responseBody = JSON.parse(response.responseText || '{}'); } catch (_) { throw new Error(`${wikiLanguage}.wikipedia.org 返回了无效 JSON。`); } if (response.status < 200 || response.status >= 300 || responseBody?.error) { const apiMessage = cleanText( responseBody?.error?.info || responseBody?.error?.code || response.statusText || '', 240, ); const error = new Error( `Wikipedia 请求失败(HTTP ${response.status || '未知'})${apiMessage ? `:${apiMessage}` : ''}`, ); error.status = response.status; throw error; } return responseBody; } function wikiQueryProperties(lookupLanguage, displayLanguage) { const properties = ['pageprops', 'description', 'coordinates']; if (lookupLanguage !== displayLanguage) properties.push('langlinks'); return properties.join('|'); } async function fetchExactWikiPages(answers, lookupLanguage, displayLanguage) { const result = new Map(); const batches = chunks(answers, CONFIG.WIKIPEDIA_TITLE_BATCH_SIZE, 50); for (let index = 0; index < batches.length; index += 1) { reportWikiProgress(`正在查询 Wikipedia 条目… ${index + 1}/${batches.length}`); const batch = batches[index]; const parameters = { redirects: '1', prop: wikiQueryProperties(lookupLanguage, displayLanguage), ppprop: 'disambiguation', colimit: 'max', titles: batch.join('|'), }; if (lookupLanguage !== displayLanguage) { parameters.lllang = displayLanguage; parameters.lllimit = 'max'; } const responseBody = await wikiApi(lookupLanguage, parameters); for (const [answer, page] of mapRequestedWikiPages(batch, responseBody)) { result.set(answer, page); } } return result; } function searchTokens(text) { const ignored = new Set([ 'the', 'a', 'an', 'of', 'in', 'on', 'at', 'to', 'for', 'from', 'by', 'and', 'or', 'quiz', 'name', 'answer', 'answers', 'clue', 'each', 'this', 'that', 'with', 'world', 'top', 'largest', 'longest', 'best', 'most', 'can', 'you', 'enter', 'guess', 'guessed', 'shown', 'according', 'wikipedia', 'map', 'rank', 'number', 'length', 'mile', 'km', ]); const normalized = cleanText(text) .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .toLocaleLowerCase(); return new Set( // NFKD 已将绝大多数拉丁重音拆成 ASCII;显式列出文字区间,避免把 // 弯引号等标点误当成关键词(例如把 World’s 整体当成一个词)。 (normalized.match(/[a-z0-9]+|[\u3400-\u9fff]+|[\u0400-\u04ff]+|[\u0370-\u03ff]+/gi) || []) .map((token) => { if (/^[a-z]+$/.test(token) && token.length > 4) { if (token.endsWith('ies')) return `${token.slice(0, -3)}y`; if (token.endsWith('s') && !/(?:ss|is|us)$/.test(token)) return token.slice(0, -1); } return token; }) .filter((token) => token.length > 1 && !/^\d+$/.test(token) && !ignored.has(token)), ); } function itemQuestionText(item) { return cleanText(item.question || item.clue || '', CONFIG.MAX_CLUE_CHARS); } function isWikiPageContextCompatible(page, item) { if (isMissingWikiPage(page) || isDisambiguationWikiPage(page)) return false; const contextTokens = searchTokens( `${itemQuestionText(item)} ${item.topic} ${item.description || ''}`, ); if (!contextTokens.size) return true; const pageTokens = searchTokens(`${page.title || ''} ${page.description || ''}`); return Array.from(contextTokens).some((token) => pageTokens.has(token)); } function wikiAnswerVariants(answer) { const original = cleanText(answer, 180).normalize('NFKC'); const bracketContents = Array.from( original.matchAll(/[\(\[\{【]([^\)\]\}】]+)[\)\]\}】]/gu), (match) => cleanText(match[1], 100), ); const withoutBrackets = cleanText( original.replace(/\s*[\(\[\{【][^\)\]\}】]*[\)\]\}】]\s*/gu, ' '), 180, ); const unwrapped = cleanText(original.replace(/[\(\)\[\]\{\}【】]/gu, ' '), 180); const candidates = [original, withoutBrackets, ...bracketContents, unwrapped]; const split = candidates.flatMap((candidate) => candidate .replace(/["“”]/gu, ' ') .split(/\s*(?:\/|;|\||、|\bor\b)\s*/iu) .map((part) => cleanText(part, 100)), ); return Array.from(new Set(split.filter((part) => part.length > 1))).slice(0, 8); } /** * 纯程序化提取“题目大意”:优先同行线索和列头,其次 h1,最后测验说明; * 去掉数字、单位和答题套话,只保留能表示实体类别/地域的少量关键词。 */ function wikiContextProfile(item) { const sanitizePhrase = (value, maxLength) => cleanText( String(value || '').replace(/(?:列头|题目):/g, '').replace(/["|()]/g, ' '), maxLength, ); const topicPhrase = sanitizePhrase(item.topic, 160); const questionText = itemQuestionText(item); const cluePhrase = sanitizePhrase(questionText, 220); const answerTokens = searchTokens(item.answer); const terms = []; const normalizedClue = questionText.replace(/(?:列头|题目):/g, ''); for (const source of [normalizedClue, item.topic, item.description || '']) { for (const token of searchTokens(source)) { if (!answerTokens.has(token) && !terms.includes(token)) terms.push(token); } } return { topicPhrase, cluePhrase: /[a-z\u00c0-\uffff]{2}/iu.test(cluePhrase) ? cluePhrase : '', terms: terms.slice(0, 8), }; } function contextualWikiSearchQuery(item) { const answerDisjunction = wikiAnswerVariants(item.answer) .map((term) => `"${term}"`) .join(' OR '); const context = wikiContextProfile(item); const contextDisjunction = [context.topicPhrase, context.cluePhrase, ...context.terms] .filter(Boolean) .map((term) => `"${term}"`) .join(' OR '); // 两组之间是 AND:页面必须同时命中答案和题意;组内用 OR 容忍标题措辞、 // 单复数和同行数值差异。不再用严格 intitle,以兼容简称、别名与合并答案。 return `(${answerDisjunction}) (${contextDisjunction || `"${context.topicPhrase}"`})`; } function scoreWikiCandidate(page, item, rank) { const answerTokens = searchTokens(item.answer); const contextTokens = searchTokens( `${itemQuestionText(item)} ${item.topic} ${item.description || ''}`, ); const candidateTokens = searchTokens(`${page.title || ''} ${page.description || ''}`); const candidateTitleTokens = searchTokens(page.title || ''); const candidateTitle = wikiTitleKey(page.title); const answerKeys = wikiAnswerVariants(item.answer).map(wikiTitleKey); let score = Math.max(0, 20 - rank * 2); let answerMatches = 0; for (const token of answerTokens) { if (candidateTitle.includes(token)) { score += 14; answerMatches += 1; } } for (const token of contextTokens) { if (candidateTokens.has(token)) score += candidateTitleTokens.has(token) ? 12 : 6; } const matchingAnswerKey = answerKeys.find((key) => candidateTitle.includes(key)); if (matchingAnswerKey) score += 18; if (answerKeys.some((key) => candidateTitle.startsWith(key))) score += 12; if (!answerMatches && !matchingAnswerKey) score -= 20; return score; } async function searchContextualWikiPage(item, lookupLanguage, displayLanguage) { const parameters = { generator: 'search', gsrsearch: contextualWikiSearchQuery(item), gsrnamespace: '0', gsrlimit: '10', prop: wikiQueryProperties(lookupLanguage, displayLanguage), ppprop: 'disambiguation', colimit: 'max', }; if (lookupLanguage !== displayLanguage) { parameters.lllang = displayLanguage; parameters.lllimit = 'max'; } const responseBody = await wikiApi(lookupLanguage, parameters); const candidates = wikiPages(responseBody) .filter((page) => !isMissingWikiPage(page) && !isDisambiguationWikiPage(page)) .sort((left, right) => (left.index ?? 999) - (right.index ?? 999)); if (!candidates.length) return null; const scored = candidates .map((page, rank) => ({ page, score: scoreWikiCandidate(page, item, rank) })) .sort((left, right) => right.score - left.score); return scored[0].score >= 12 ? scored[0].page : null; } async function fetchWikiExtracts(language, titles) { const result = new Map(); const uniqueTitles = Array.from(new Set(titles.filter(Boolean))); const batches = chunks(uniqueTitles, CONFIG.WIKIPEDIA_EXTRACT_BATCH_SIZE, 20); for (let index = 0; index < batches.length; index += 1) { reportWikiProgress(`正在读取 Wikipedia 摘要… ${index + 1}/${batches.length}`); const batch = batches[index]; const responseBody = await wikiApi(language, { redirects: '1', prop: 'extracts|description', exintro: '1', explaintext: '1', exsentences: '4', exlimit: 'max', titles: batch.join('|'), }); for (const [title, page] of mapRequestedWikiPages(batch, responseBody)) { result.set(title, page); } } return result; } function compactChineseExtract(text) { let source = cleanText(text, 1_200) .replace(/([^)]{1,180})/g, '') .replace(/\([^)]{1,180}\)/g, '') .trim(); if (!source) return ''; const maxLength = Math.max(80, Number(CONFIG.WIKIPEDIA_SUMMARY_MAX_CHARS) || 220); const sentences = source.match(/[^。!?]+[。!?]?/g) || [source]; let summary = ''; while (sentences.length && summary.length < Math.min(120, maxLength)) { summary += sentences.shift().trim(); } if (summary.length <= maxLength) return summary; let boundary = -1; for (const punctuation of ['。', ';', ',']) { boundary = Math.max(boundary, summary.lastIndexOf(punctuation, maxLength - 1)); } return boundary >= 80 ? `${summary.slice(0, boundary)}。` : `${summary.slice(0, maxLength - 1).trimEnd()}…`; } function compactEnglishExtract(text) { const source = cleanText(text, 1_200); if (!source) return ''; const firstSentence = source.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim() || source; const words = firstSentence.split(/\s+/); return words.length > 85 ? `${words.slice(0, 85).join(' ')}…` : firstSentence; } function googleMapsUrlForPage(page) { const coordinate = Array.isArray(page?.coordinates) ? page.coordinates.find((item) => item?.primary) || page.coordinates[0] : null; const latitude = Number(coordinate?.lat); const longitude = Number(coordinate?.lon); if ( !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180 ) { return ''; } const query = encodeURIComponent(`${latitude},${longitude}`); return `https://www.google.com/maps/search/?api=1&query=${query}`; } function formatWikiExplanation(page, language, item, sourcePage = page) { if (isMissingWikiPage(page) || isDisambiguationWikiPage(page)) return ''; const source = page.extract || page.description || ''; const summary = language.startsWith('zh') ? compactChineseExtract(source) : compactEnglishExtract(source); if (!summary) return ''; const projectName = language.startsWith('zh') ? '中文维基百科' : `${language}.wikipedia.org`; const displayTitle = cleanText(page.title, 120); const originalAnswer = cleanText(item?.answer || '', 100); const heading = language.startsWith('zh') && originalAnswer && normalizeKey(displayTitle) !== normalizeKey(originalAnswer) ? `【${displayTitle}(${originalAnswer})】` : `【${displayTitle}】`; const topic = cleanText(item?.topic || '', 120); const question = cleanText(itemQuestionText(item || {}), 180); // 纯 Wiki 只能可靠呈现原始线索,不能假装完成了语义推理;真正的 // “为何符合题干”由 hybrid/llm 模式的大模型基于下方百科草稿生成。 const relation = question ? `题目线索:${question}` : `测验主题:${topic}`; const lines = [ heading, relation, `百科扩展:${summary}`, `来源:${projectName}《${displayTitle}》(CC BY-SA)`, ]; const mapUrl = googleMapsUrlForPage(sourcePage) || googleMapsUrlForPage(page); if (mapUrl) lines.push(`地图:${mapUrl}`); return lines.join('\n'); } function uniqueWikipediaItems(data) { const result = new Map(); for (const item of data.items) { if (!result.has(item.answer)) { result.set(item.answer, { answer: item.answer, clue: item.clue, question: item.question || item.clue || '', topic: data.title, description: data.description, }); } else if (item.question || item.clue) { const existing = result.get(item.answer); const question = item.question || item.clue; existing.question = cleanText( `${existing.question || existing.clue || ''} | ${question}`, CONFIG.MAX_CLUE_CHARS, ); existing.clue = existing.question; } } return Array.from(result.values()); } async function requestWikipediaExplanations(data) { const lookupLanguage = safeWikiLanguage(CONFIG.WIKIPEDIA_LOOKUP_LANGUAGE, 'en'); const displayLanguage = safeWikiLanguage(CONFIG.WIKIPEDIA_DISPLAY_LANGUAGE, lookupLanguage); const items = uniqueWikipediaItems(data); const itemByAnswer = new Map(items.map((item) => [item.answer, item])); const answers = items.map(({ answer }) => answer); const exactPages = await fetchExactWikiPages(answers, lookupLanguage, displayLanguage); const resolvedPages = new Map(); const needsContextSearch = []; const unresolvedReasons = new Map(); for (const item of items) { const page = exactPages.get(item.answer); // 即使标题精确命中,也必须与题目/线索至少有一个有效语义词重合; // Amazon 公司之于“世界河流”会在这里被否决并进入带上下文的搜索。 if (!isWikiPageContextCompatible(page, item)) { needsContextSearch.push(item); unresolvedReasons.set( item.answer, isMissingWikiPage(page) ? '没有同名条目' : isDisambiguationWikiPage(page) ? '同名条目存在歧义' : '同名条目与题意不符', ); } else { resolvedPages.set(item.answer, page); } } const searchLimit = Math.max( 0, Math.min(Number(CONFIG.WIKIPEDIA_MAX_CONTEXT_SEARCHES) || 0, needsContextSearch.length), ); for (let index = 0; index < searchLimit; index += 1) { reportWikiProgress(`正在结合题目与线索校验… ${index + 1}/${searchLimit}`); const item = needsContextSearch[index]; try { const page = await searchContextualWikiPage(item, lookupLanguage, displayLanguage); if (page) { resolvedPages.set(item.answer, page); unresolvedReasons.delete(item.answer); } else { unresolvedReasons.set(item.answer, '答案与题意联合搜索没有可靠候选'); console.warn( `[JetPunk Explainer] “${item.answer}”无可靠候选。查询式:${contextualWikiSearchQuery(item)}`, ); } } catch (error) { unresolvedReasons.set(item.answer, 'Wikipedia 搜索请求失败'); console.warn(`[JetPunk Explainer] “${item.answer}”上下文检索失败:`, error); } } for (const item of needsContextSearch.slice(searchLimit)) { unresolvedReasons.set(item.answer, '超过当前上下文搜索数量上限'); } const displayTitleByAnswer = new Map(); for (const [answer, page] of resolvedPages) { const displayTitle = lookupLanguage === displayLanguage ? page.title : wikiLanguageLink(page, displayLanguage); if (displayTitle) displayTitleByAnswer.set(answer, displayTitle); } let displayExtracts = new Map(); try { displayExtracts = await fetchWikiExtracts( displayLanguage, Array.from(displayTitleByAnswer.values()), ); } catch (error) { console.warn('[JetPunk Explainer] 目标语言摘要读取失败,将回退原语言:', error); } const result = new Map(); const lowConfidenceAnswers = new Set(); const needsLookupExtract = []; for (const [answer, page] of resolvedPages) { const displayTitle = displayTitleByAnswer.get(answer); const displayPage = displayTitle ? displayExtracts.get(displayTitle) : null; const explanation = formatWikiExplanation( displayPage, displayLanguage, itemByAnswer.get(answer), page, ); if (explanation) result.set(answer, explanation); else { lowConfidenceAnswers.add(answer); needsLookupExtract.push({ answer, page }); } } if (needsLookupExtract.length) { let lookupExtracts = new Map(); try { lookupExtracts = await fetchWikiExtracts( lookupLanguage, needsLookupExtract.map(({ page }) => page.title), ); } catch (error) { console.warn('[JetPunk Explainer] 原语言摘要读取失败,将使用短描述:', error); } for (const { answer, page } of needsLookupExtract) { const lookupPage = lookupExtracts.get(page.title) || page; const explanation = formatWikiExplanation( lookupPage, lookupLanguage, itemByAnswer.get(answer), page, ); if (explanation) result.set(answer, explanation); else unresolvedReasons.set(answer, '条目存在,但没有可用的摘要或短描述'); } } for (const { answer } of items) { if (!result.has(answer)) { const reason = unresolvedReasons.get(answer) || '未知原因'; result.set(answer, `未找到与当前测验题意可靠匹配的 Wikipedia 条目(${reason})。`); lowConfidenceAnswers.add(answer); } } // Map 仍保持 string -> string,便于缓存;额外集合仅供同一次 hybrid 流程选题。 result.lowConfidenceAnswers = lowConfidenceAnswers; return result; } function parseJsonObject(text) { let source = cleanText(text); source = source.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim(); const attempts = [source]; const firstBrace = source.indexOf('{'); const lastBrace = source.lastIndexOf('}'); if (firstBrace >= 0 && lastBrace > firstBrace) { attempts.push(source.slice(firstBrace, lastBrace + 1)); } for (const attempt of attempts) { try { const parsed = JSON.parse(attempt); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch (_) { // 尝试下一个安全的 JSON 边界;绝不使用 eval。 } } throw new Error('模型返回内容不是有效的 JSON 字典。'); } function messageContent(message) { if (typeof message?.content === 'string') return message.content; if (Array.isArray(message?.content)) { return message.content .map((part) => (typeof part === 'string' ? part : part?.text || '')) .join(''); } return ''; } async function requestJsonDictionary(systemPrompt, userContent, apiKey) { let endpoint; try { endpoint = new URL(CONFIG.BASE_URL); } catch (_) { throw new Error('CONFIG.BASE_URL 不是有效 URL。'); } if (!/^https?:$/.test(endpoint.protocol)) { throw new Error('CONFIG.BASE_URL 仅支持 HTTP 或 HTTPS。'); } const payload = { model: CONFIG.MODEL, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userContent }, ], temperature: CONFIG.TEMPERATURE, stream: false, }; if (CONFIG.USE_JSON_MODE) payload.response_format = { type: 'json_object' }; const response = await gmRequest({ method: 'POST', url: endpoint.href, headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, data: JSON.stringify(payload), timeout: CONFIG.REQUEST_TIMEOUT_MS, }); let responseBody = null; try { responseBody = JSON.parse(response.responseText || '{}'); } catch (_) { // 状态码错误时在下方生成不含 Key 的精简报错。 } if (response.status < 200 || response.status >= 300) { const apiMessage = cleanText(responseBody?.error?.message || response.statusText || '', 240); const error = new Error( `API 请求失败(HTTP ${response.status || '未知'})${apiMessage ? `:${apiMessage}` : ''}`, ); error.status = response.status; throw error; } const content = messageContent(responseBody?.choices?.[0]?.message); if (!content) throw new Error('API 响应中没有 choices[0].message.content。'); return parseJsonObject(content); } function requestExplanations(data, apiKey) { return requestJsonDictionary( SYSTEM_PROMPT, '以下是当前测验的完整结构化上下文。请覆盖 items 中的每个答案,并严格保留 answer 原文作为 JSON Key:\n' + JSON.stringify(publicQuizData(data)), apiKey, ); } function normalizeKey(value) { return cleanText(value).normalize('NFKC').toLocaleLowerCase(); } /** * LLM 偶尔会把 `Name (qualifier)` 的 JSON Key 写成 `Name`、`qualifier` 或 * `Name qualifier`。生成多个候选键,但只在当前题集中唯一时才允许模糊对应, * 避免把 Congo (Brazzaville) 与 Congo (Kinshasa) 之类的答案串错。 */ function answerAliasKeys(value) { const original = cleanText(value).normalize('NFKC'); const bracketContents = Array.from( original.matchAll(/[\(\[\{【]([^\)\]\}】]+)[\)\]\}】]/gu), (match) => cleanText(match[1]), ); const withoutBrackets = cleanText( original.replace(/\s*[\(\[\{【][^\)\]\}】]*[\)\]\}】]\s*/gu, ' '), ); const unwrapped = cleanText(original.replace(/[\(\)\[\]\{\}【】]/gu, ' ')); const values = [original, withoutBrackets, unwrapped, ...bracketContents].filter(Boolean); const aliases = new Set(); for (const candidate of values) { aliases.add(normalizeKey(candidate)); const wordsOnly = normalizeKey(candidate.replace(/[\p{P}\p{S}]+/gu, ' ')); if (wordsOnly) aliases.add(wordsOnly); } return aliases; } function toExplanationMap(rawResult, items) { // 少数兼容端点会额外包一层 explanations;在不产生歧义时安全解包。 let dictionary = rawResult; const keys = Object.keys(rawResult); if ( keys.length === 1 && keys[0] === 'explanations' && rawResult.explanations && typeof rawResult.explanations === 'object' && !Array.isArray(rawResult.explanations) ) { dictionary = rawResult.explanations; } const exact = new Map(); const normalized = new Map(); const rawAliases = new Map(); for (const [key, value] of Object.entries(dictionary)) { if (typeof value !== 'string') continue; const explanation = cleanMultilineText(value, 900); if (!explanation) continue; exact.set(cleanText(key), explanation); const normalizedKey = normalizeKey(key); if (!normalized.has(normalizedKey)) normalized.set(normalizedKey, explanation); for (const alias of answerAliasKeys(key)) { if (!rawAliases.has(alias)) rawAliases.set(alias, new Set()); rawAliases.get(alias).add(explanation); } } const itemAliasOwners = new Map(); for (const { answer } of items) { const owner = normalizeKey(answer); for (const alias of answerAliasKeys(answer)) { if (!itemAliasOwners.has(alias)) itemAliasOwners.set(alias, new Set()); itemAliasOwners.get(alias).add(owner); } } const result = new Map(); for (const { answer } of items) { let explanation = exact.get(answer) || normalized.get(normalizeKey(answer)); if (!explanation) { for (const alias of answerAliasKeys(answer)) { if (itemAliasOwners.get(alias)?.size !== 1) continue; const candidates = rawAliases.get(alias); if (candidates?.size === 1) { explanation = candidates.values().next().value; break; } } } if (explanation) result.set(answer, explanation); } if (!result.size) throw new Error('模型 JSON 中没有可匹配当前答案的键。'); return result; } function hybridTargetItems(data, wikipediaMap) { const lowConfidence = wikipediaMap.lowConfidenceAnswers instanceof Set ? wikipediaMap.lowConfidenceAnswers : new Set( Array.from(wikipediaMap, ([answer, explanation]) => /^未找到|en\.wikipedia\.org/u.test(explanation) ? answer : null, ).filter(Boolean), ); const scopeAll = String(CONFIG.HYBRID_SCOPE).trim().toLowerCase() === 'all'; const seen = new Set(); const candidates = data.items.filter(({ answer }) => { if (seen.has(answer) || (!scopeAll && !lowConfidence.has(answer))) return false; seen.add(answer); return true; }); const limit = Math.max(0, Number(CONFIG.HYBRID_MAX_LLM_ITEMS) || 0); return candidates.slice(0, limit); } function wikiAttributionLines(explanation) { return cleanMultilineText(explanation, 1_400) .split('\n') .filter((line) => /^(?:来源|地图):/u.test(line)); } function wikiEvidenceForLlm(explanation) { const lines = cleanMultilineText(explanation, 1_400) .split('\n') .filter((line) => !/^(?:题目线索|测验主题|来源|地图):/u.test(line)); return cleanMultilineText(lines.join('\n'), 360); } async function requestHybridEnhancements(data, wikipediaMap, targets, apiKey) { const questionByAnswer = new Map( publicQuizData(data).items.map(({ answer, question }) => [answer, question]), ); const hybridPayload = { quizId: location.pathname, title: data.title, description: data.description, items: targets.map(({ answer }) => ({ answer, question: cleanText(questionByAnswer.get(answer) || '', 300), wikipediaDraft: wikiEvidenceForLlm(wikipediaMap.get(answer) || ''), })), }; const rawResult = await requestJsonDictionary( HYBRID_SYSTEM_PROMPT, '请为以下答案生成真正解释题干关系的中文说明。Wikipedia草稿是主要事实边界;若草稿明确未找到,可谨慎消歧但不得猜测:\n' + JSON.stringify(hybridPayload), apiKey, ); const enhanced = toExplanationMap(rawResult, targets); const merged = new Map(wikipediaMap); for (const { answer } of targets) { const modelText = enhanced.get(answer); if (!modelText) continue; const footers = wikiAttributionLines(wikipediaMap.get(answer) || ''); merged.set(answer, [modelText, ...footers].join('\n')); } merged.hybridCacheable = true; return merged; } async function generateExplanationMap(data, sourceMode = selectedSourceMode()) { if (sourceMode === 'wikipedia') return requestWikipediaExplanations(data); if (sourceMode === 'hybrid') { const wikipediaMap = await requestWikipediaExplanations(data); const targets = hybridTargetItems(data, wikipediaMap); if (!targets.length) { wikipediaMap.hybridCacheable = true; console.info('[JetPunk Explainer] hybrid 没有需要提交给大模型的答案,本局未调用。'); return wikipediaMap; } const apiKey = await getApiKeyOnce(); if (!apiKey) { wikipediaMap.hybridCacheable = false; console.info('[JetPunk Explainer] 未配置 Key,hybrid 保留 Wikipedia 结果。'); return wikipediaMap; } try { console.info( `[JetPunk Explainer] hybrid 一次性批量解释 ${targets.length}/${new Set(data.items.map(({ answer }) => answer)).size} 个答案。`, ); return await requestHybridEnhancements(data, wikipediaMap, targets, apiKey); } catch (error) { wikipediaMap.hybridCacheable = false; if ((error?.status === 401 || error?.status === 403) && !CONFIG.API_KEY) { await gmSet(API_KEY_STORAGE_KEY, ''); state.apiKeyPromise = null; } console.warn('[JetPunk Explainer] hybrid 补充失败,已安全保留 Wikipedia 结果:', error); return wikipediaMap; } } const apiKey = await getApiKeyOnce(); if (!apiKey) throw new Error('未配置 API Key,本次未发送任何请求。'); // LLM 模式始终是一次整批请求,不会按答案拆分并发。 const rawResult = await requestExplanations(data, apiKey); return toExplanationMap(rawResult, data.items); } function mapCoversQuizData(explanationMap, data) { if (!(explanationMap instanceof Map) || !explanationMap.size) return false; const answers = new Set(data.items.map(({ answer }) => answer)); return Array.from(answers).every( (answer) => explanationMap.has(answer) || explanationMap.has(cleanText(answer)), ); } function closeActiveTooltipImmediately(cell) { if (state.activeCell !== cell) return; cancelTooltipHide(); state.activeCell = null; if (!state.tooltip || state.tooltip.dataset.mode !== 'tooltip') return; state.tooltip.dataset.open = 'false'; state.tooltip.setAttribute('aria-hidden', 'true'); } function suspendCellExplanation(cell) { if (!(cell instanceof HTMLElement)) return; closeActiveTooltipImmediately(cell); state.cellExplanations.delete(cell); cell.classList.remove(READY_CLASS); if (cell.getAttribute('aria-describedby') === TOOLTIP_ID) { cell.removeAttribute('aria-describedby'); } } function updateQuizToggleButton(button) { if (!(button instanceof HTMLButtonElement)) return; const enabled = state.quizExplanationEnabled; button.dataset.enabled = enabled ? 'true' : 'false'; button.setAttribute('aria-checked', enabled ? 'true' : 'false'); button.textContent = enabled ? '是否解释:开' : '是否解释:关'; button.setAttribute('aria-label', enabled ? '本题答案解释已开启' : '本题答案解释已关闭'); button.title = state.quizToggleLocked ? enabled ? '本题将生成答案解释(选择已锁定)' : '本题不生成答案解释(选择已锁定)' : enabled ? '点击关闭本题的答案解释' : '点击开启本题的答案解释'; button.disabled = state.quizToggleLocked; } function setQuizToggleLocked(locked) { state.quizToggleLocked = locked; updateQuizToggleButton(state.quizToggleButton); } function ensureQuizExplanationToggle(startControl) { if (!(startControl instanceof HTMLElement)) return null; installTooltipStyles(); let button = state.quizToggleButton; if (!(button instanceof HTMLButtonElement)) { button = document.createElement('button'); button.type = 'button'; button.className = QUIZ_TOGGLE_CLASS; button.setAttribute(OWN_UI_ATTRIBUTE, 'quiz-toggle'); button.setAttribute('role', 'switch'); button.addEventListener('mousedown', (event) => event.stopPropagation()); button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); if (state.quizToggleLocked) return; state.quizExplanationEnabled = !state.quizExplanationEnabled; updateQuizToggleButton(button); button.blur(); }); state.quizToggleButton = button; } if (button.previousElementSibling !== startControl) { startControl.insertAdjacentElement?.('afterend', button); if (!button.isConnected) startControl.parentElement?.appendChild(button); } state.quizStartControl = startControl; updateQuizToggleButton(button); return button; } function prepareQuizExplanationToggle() { const startControl = visibleControls().find((node) => /^start quiz$/i.test(controlText(node))); if (startControl) { // Retake 回到开始界面时,新一局恢复默认开启,并清除上局挂在答案格上的提示。 if (state.quizToggleLocked) { state.quizExplanationEnabled = true; state.prefetchAttempted = false; state.prefetchPromise = null; state.prefetchFingerprint = ''; state.prefetchError = null; state.quizToggleLocked = false; const oldData = extractQuizData({ includeHiddenAnswers: true }); for (const { cell } of oldData.items) suspendCellExplanation(cell); } const processedRoot = document.querySelector('main') || document.body; processedRoot.dataset.geoProcessed = 'false'; ensureQuizExplanationToggle(startControl); setQuizToggleLocked(false); return; } if (isQuizRunning() || isQuizFinished()) setQuizToggleLocked(true); } function installTooltipStyles() { if (document.getElementById(`${SCRIPT_PREFIX}-style`)) return; const style = document.createElement('style'); style.id = `${SCRIPT_PREFIX}-style`; style.textContent = ` .${READY_CLASS} { cursor: help !important; text-decoration-line: underline !important; text-decoration-style: dashed !important; text-decoration-color: rgba(92, 174, 255, 0.72) !important; text-decoration-thickness: 1px !important; text-underline-offset: 0.2em !important; } .${QUIZ_TOGGLE_CLASS} { position: relative; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 96px; min-height: 34px; margin-inline-start: 10px; padding: 6px 12px; border: 1px solid rgba(20, 112, 57, 0.88); border-radius: 999px; background: rgba(33, 137, 71, 0.92); color: #fff; font: 600 13px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; white-space: nowrap; vertical-align: middle; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important; cursor: pointer; } .${QUIZ_TOGGLE_CLASS}[data-enabled="false"] { border-color: rgba(86, 92, 101, 0.88); background: rgba(100, 107, 116, 0.9); color: #fff; } .${QUIZ_TOGGLE_CLASS}:focus-visible { outline: 2px solid rgba(52, 133, 232, 0.82); outline-offset: 2px; } .${QUIZ_TOGGLE_CLASS}:disabled { cursor: default; opacity: 0.72 !important; } #${TOOLTIP_ID} { position: fixed; left: -10000px; top: -10000px; box-sizing: border-box; width: max-content; max-width: min(${CONFIG.TOOLTIP_MAX_WIDTH_PX}px, calc(100vw - 16px)); max-height: min(60vh, 520px); overflow-y: auto; padding: 10px 12px; border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 9px; background: rgba(20, 23, 29, 0.94); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28); color: #f4f7fb; font: 13px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0.01em; overflow-wrap: anywhere; white-space: pre-line; pointer-events: none; opacity: 0; visibility: hidden; transform: translateY(4px); transition: opacity 140ms ease, transform 140ms ease, visibility 140ms; z-index: 99999; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } #${TOOLTIP_ID}[data-open="true"] { opacity: 1; visibility: visible; transform: translateY(0); } #${TOOLTIP_ID}[data-mode="status"] { border-color: rgba(92, 174, 255, 0.38); color: #eaf5ff; } #${TOOLTIP_ID}[data-mode="tooltip"] { pointer-events: auto; } #${TOOLTIP_ID}[data-mode="tooltip"]::after { content: ""; position: absolute; left: 0; right: 0; height: 14px; pointer-events: auto; } #${TOOLTIP_ID}[data-mode="tooltip"][data-placement="top"]::after { bottom: -14px; } #${TOOLTIP_ID}[data-mode="tooltip"][data-placement="bottom"]::after { top: -14px; } #${TOOLTIP_ID} a { color: #8ecbff; text-decoration: underline; text-underline-offset: 0.16em; } #${TOOLTIP_ID} a:hover { color: #c5e6ff; } `; document.head.appendChild(style); } function ensureTooltip() { if (state.tooltip?.isConnected) return state.tooltip; installTooltipStyles(); const tooltip = document.createElement('div'); tooltip.id = TOOLTIP_ID; tooltip.setAttribute('role', 'tooltip'); tooltip.setAttribute('aria-hidden', 'true'); tooltip.dataset.open = 'false'; tooltip.addEventListener('mouseenter', cancelTooltipHide, { passive: true }); tooltip.addEventListener('mouseleave', (event) => { if (state.activeCell?.contains?.(event.relatedTarget)) return; scheduleTooltipHide(state.activeCell); }, { passive: true }); document.body.appendChild(tooltip); state.tooltip = tooltip; if (!state.tooltipEventsInstalled) { window.addEventListener('resize', scheduleActiveTooltipPosition, { passive: true }); window.addEventListener('scroll', scheduleActiveTooltipPosition, { passive: true, capture: true }); state.tooltipEventsInstalled = true; } return tooltip; } function hideStatus() { const tooltip = state.tooltip; if (!tooltip || tooltip.dataset.mode !== 'status' || state.activeCell) return; tooltip.dataset.open = 'false'; tooltip.setAttribute('aria-hidden', 'true'); } /** 使用同一个 Tooltip 显示非阻塞进度,不额外创建页面浮层。 */ function showStatus(message, hideAfterMs = 0) { const tooltip = ensureTooltip(); window.clearTimeout(state.statusTimer); cancelTooltipHide(); state.activeCell = null; tooltip.textContent = cleanText(message, 240); tooltip.dataset.mode = 'status'; tooltip.dataset.open = 'true'; tooltip.setAttribute('aria-hidden', 'false'); tooltip.style.left = 'auto'; tooltip.style.right = '12px'; tooltip.style.top = '12px'; if (hideAfterMs > 0) { state.statusTimer = window.setTimeout(hideStatus, hideAfterMs); } } function reportWikiProgress(message) { // 开局预取期间不创建状态浮层,避免提示位置或样式间接泄露隐藏答案。 if (!state.backgroundPrefetching) showStatus(message); } function positionTooltip(cell) { const tooltip = state.tooltip; if (!tooltip || !cell?.isConnected || tooltip.dataset.open !== 'true') return; const gap = 10; const margin = 8; const anchorRect = cell.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); const viewportWidth = document.documentElement.clientWidth; const viewportHeight = document.documentElement.clientHeight; let left = anchorRect.left + anchorRect.width / 2 - tooltipRect.width / 2; left = Math.max(margin, Math.min(left, viewportWidth - tooltipRect.width - margin)); let placement = 'top'; let top = anchorRect.top - tooltipRect.height - gap; if (top < margin) { placement = 'bottom'; top = anchorRect.bottom + gap; } top = Math.max(margin, Math.min(top, viewportHeight - tooltipRect.height - margin)); tooltip.dataset.placement = placement; tooltip.style.left = `${Math.round(left)}px`; tooltip.style.top = `${Math.round(top)}px`; } function scheduleActiveTooltipPosition() { if (!state.activeCell || state.positionFrame) return; state.positionFrame = requestAnimationFrame(() => { state.positionFrame = 0; positionTooltip(state.activeCell); }); } function cancelTooltipHide() { window.clearTimeout(state.tooltipHideTimer); state.tooltipHideTimer = 0; } function scheduleTooltipHide(cell) { cancelTooltipHide(); state.tooltipHideTimer = window.setTimeout(() => { if (cell && state.activeCell !== cell) return; state.activeCell = null; if (!state.tooltip || state.tooltip.dataset.mode !== 'tooltip') return; state.tooltip.dataset.open = 'false'; state.tooltip.setAttribute('aria-hidden', 'true'); }, Math.max(250, Number(CONFIG.TOOLTIP_HIDE_DELAY_MS) || 700)); } function renderExplanationInTooltip(tooltip, explanation) { tooltip.replaceChildren(); const lines = cleanMultilineText(explanation, 1_600).split('\n'); lines.forEach((line, index) => { const mapMatch = line.match(/^地图:(https:\/\/www\.google\.com\/maps\/[^\s]+)$/u); if (mapMatch) { tooltip.append(document.createTextNode('地图:')); const link = document.createElement('a'); link.href = mapMatch[1]; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = '在 Google 地图中查看位置'; tooltip.append(link); } else { tooltip.append(document.createTextNode(line)); } if (index < lines.length - 1) tooltip.append(document.createElement('br')); }); } function showTooltip(cell) { const explanation = state.cellExplanations.get(cell); if (!explanation) return; const tooltip = ensureTooltip(); window.clearTimeout(state.statusTimer); cancelTooltipHide(); state.activeCell = cell; renderExplanationInTooltip(tooltip, explanation); tooltip.dataset.mode = 'tooltip'; tooltip.dataset.open = 'true'; tooltip.setAttribute('aria-hidden', 'false'); tooltip.style.right = 'auto'; tooltip.style.left = '-10000px'; tooltip.style.top = '-10000px'; positionTooltip(cell); } function hideTooltip(cell) { if (state.activeCell !== cell || !state.tooltip) return; scheduleTooltipHide(cell); } function onCellEnter(event) { showTooltip(event.currentTarget); } function onCellLeave(event) { if (state.tooltip?.contains?.(event.relatedTarget)) { cancelTooltipHide(); return; } hideTooltip(event.currentTarget); } function attachExplanations(data, explanationMap) { let attached = 0; for (const { answer, cell } of data.items) { const explanation = explanationMap.get(answer) || explanationMap.get(cleanText(answer)); if (!explanation || !(cell instanceof HTMLElement)) continue; state.cellExplanations.set(cell, explanation); if (!cell.classList.contains(READY_CLASS)) cell.classList.add(READY_CLASS); if (cell.getAttribute('aria-describedby') !== TOOLTIP_ID) { cell.setAttribute('aria-describedby', TOOLTIP_ID); } if (!state.boundCells.has(cell)) { cell.addEventListener('mouseenter', onCellEnter, { passive: true }); cell.addEventListener('mouseleave', onCellLeave, { passive: true }); state.boundCells.add(cell); } attached += 1; } if (attached) ensureTooltip(); return attached; } function currentExplanationCellsAreAttached(data, explanationMap) { let attachable = 0; for (const { answer, cell } of data.items) { const explanation = explanationMap.get(answer) || explanationMap.get(cleanText(answer)); if (!explanation || !(cell instanceof HTMLElement)) continue; attachable += 1; if ( state.cellExplanations.get(cell) !== explanation || !cell.classList.contains(READY_CLASS) ) { return false; } } return attachable > 0; } function maybeStartPrefetch() { if ( !CONFIG.PREFETCH_ON_QUIZ_START || !state.quizExplanationEnabled || state.prefetchAttempted || state.prefetchPromise || state.failed || !isQuizRunning() ) { return; } // 只从页面自身已经预置、但尚未显示的 DOM/data-* 中读取。若当前题型并未 // 预载完整答案,这里保持静默,交卷后仍走原来的提取和请求路径。 const allData = extractQuizData({ includeHiddenAnswers: true }); if (!allData.items.length || !hasCompletePrefetchData(allData)) return; const data = explanationEnabledQuizData(allData); if (!data.items.length) return; const fingerprint = fingerprintQuiz(data); state.prefetchAttempted = true; state.prefetchFingerprint = fingerprint; const cached = readCache(fingerprint); if (cached) { state.explanationMap = cached; state.explanationFingerprint = fingerprint; state.prefetchPromise = Promise.resolve(cached); console.info(`[JetPunk Explainer] 已在答题阶段预载 ${cached.size} 条会话缓存。`); return; } state.backgroundPrefetching = true; const sourceMode = selectedSourceMode(); console.info( `[JetPunk Explainer] 已识别 ${new Set(data.items.map(({ answer }) => answer)).size} 个预置答案,开始后台预取。`, ); state.prefetchPromise = (async () => { try { const explanationMap = await generateExplanationMap(data, sourceMode); state.explanationMap = explanationMap; state.explanationFingerprint = fingerprint; writeCache(fingerprint, explanationMap); console.info( `[JetPunk Explainer] 已在答题阶段后台生成 ${explanationMap.size} 条解释;交卷前不会挂载。`, ); return explanationMap; } catch (error) { state.prefetchError = error; console.warn('[JetPunk Explainer] 开局后台预取失败,将在交卷时报告:', error); return null; } finally { state.backgroundPrefetching = false; } })(); } async function processFinishedQuiz() { if (state.processing || state.failed || !isQuizFinished()) return; const sourceMode = selectedSourceMode(); state.processing = true; try { // 给 JetPunk 一小段时间完成 Give Up 后的批量 DOM 写入。processing 在等待前 // 已锁定,避免多次 MutationObserver 回调并行越过防重检查。 await new Promise((resolve) => window.setTimeout(resolve, 180)); const allData = extractQuizData(); if (!allData.items.length) { state.failed = true; showStatus('已检测到测验结束,但没有识别到答案格。请查看控制台诊断信息。', 5_000); console.warn('[JetPunk Explainer] 结束状态已命中,但答案选择器未匹配当前题型。'); return; } setQuizToggleLocked(true); const data = explanationEnabledQuizData(allData); const fingerprint = fingerprintQuiz(data); const processedRoot = document.querySelector('main') || document.body; const wasAlreadyProcessed = processedRoot.dataset.geoProcessed === 'true'; if (!data.items.length) { state.explanationMap = new Map(); state.explanationFingerprint = fingerprint; processedRoot.dataset.geoProcessed = 'true'; showStatus('本题已关闭答案解释,未发送任何解释请求。', 3_200); return; } // Tooltip 的定位/显隐、READY_CLASS 挂载等也可能引发 DOM 变化。若当前这批 // 格子已经绑定完成,必须在任何 showStatus 之前返回,不能覆盖正在看的解释。 if ( wasAlreadyProcessed && state.explanationMap && currentExplanationCellsAreAttached(allData, state.explanationMap) ) { return; } // Retake 可能替换整张答案表。此时只复用内存/会话缓存并重新挂载,绝不再请求 API。 if ( state.explanationMap && (state.explanationFingerprint === fingerprint || mapCoversQuizData(state.explanationMap, data)) ) { const wasPrefetched = Boolean(state.prefetchFingerprint); state.explanationFingerprint = fingerprint; processedRoot.dataset.geoProcessed = 'true'; writeCache(fingerprint, state.explanationMap); const attached = attachExplanations(allData, state.explanationMap); if (wasPrefetched && !wasAlreadyProcessed) { showStatus(`开局预取已就绪:${attached} 个答案可悬停查看。`, 3_000); } return; } const cached = readCache(fingerprint); if (cached) { state.explanationMap = cached; state.explanationFingerprint = fingerprint; processedRoot.dataset.geoProcessed = 'true'; const attached = attachExplanations(allData, cached); showStatus(`已从会话缓存加载 ${attached} 个答案解释。`, 2_600); return; } // 若玩家很快交卷而后台任务尚未结束,只等待同一个 Promise;不启动第二批请求。 if (state.prefetchPromise) { state.backgroundPrefetching = false; showStatus('正在完成开局后台预取…'); const prefetched = await state.prefetchPromise; if (prefetched instanceof Map && prefetched.size) { state.explanationMap = prefetched; state.explanationFingerprint = fingerprint; processedRoot.dataset.geoProcessed = 'true'; writeCache(fingerprint, prefetched); const attached = attachExplanations(allData, prefetched); showStatus(`开局预取已就绪:${attached} 个答案可悬停查看。`, 3_000); const missing = new Set(data.items.map(({ answer }) => answer)); for (const answer of prefetched.keys()) missing.delete(answer); if (missing.size) { console.warn(`[JetPunk Explainer] 预取来源遗漏了 ${missing.size} 个答案;未重复请求。`); } return; } if (state.prefetchError) throw state.prefetchError; } if (processedRoot.dataset.geoProcessed === 'true') return; processedRoot.dataset.geoProcessed = 'true'; showStatus( sourceMode === 'wikipedia' ? '正在从 Wikipedia 获取答案解释…' : sourceMode === 'hybrid' ? '正在用 Wikipedia 生成解释,并按需补充低置信度答案…' : '正在调用大模型生成答案解释…', ); const explanationMap = await generateExplanationMap(data, sourceMode); state.explanationMap = explanationMap; state.explanationFingerprint = fingerprint; writeCache(fingerprint, explanationMap); const attached = attachExplanations(allData, explanationMap); const sourceLabel = sourceMode === 'wikipedia' ? 'Wikipedia' : sourceMode === 'hybrid' ? 'Wikipedia + 轻量大模型' : '大模型'; showStatus(`${sourceLabel} 解释已就绪:${attached} 个答案可悬停查看。`, 3_000); const missing = new Set(data.items.map(({ answer }) => answer)); for (const answer of explanationMap.keys()) missing.delete(answer); if (missing.size) { console.warn(`[JetPunk Explainer] 来源遗漏了 ${missing.size} 个答案;已挂载其余解释。`); } } catch (error) { state.failed = true; // 防止 MutationObserver 在失败后形成重试风暴;刷新页面可重试。 console.error('[JetPunk Explainer]', error); let guidance = ''; if ( sourceMode === 'llm' && (error?.status === 401 || error?.status === 403) && !CONFIG.API_KEY ) { await gmSet(API_KEY_STORAGE_KEY, ''); state.apiKeyPromise = null; guidance = '\n已清除失效的脚本存储 Key;刷新页面后可重新输入。'; } showStatus(`答案解释生成失败:${error?.message || String(error)}`, 5_000); window.alert(`JetPunk 答案解释器:${error?.message || String(error)}${guidance}`); } finally { state.processing = false; } } function scheduleScan(delay = 260) { window.clearTimeout(state.scanTimer); state.scanTimer = window.setTimeout(() => { prepareQuizExplanationToggle(); maybeStartPrefetch(); void processFinishedQuiz(); }, delay); } function looksLikeQuizPage() { if (!document.querySelector('h1')) return false; return Array.from( document.querySelectorAll('button, input[type="button"], input[type="submit"]'), ).some((node) => /^(?:start quiz|give up\??)$/i.test(controlText(node))); } function isOwnUiMutation(mutation) { const target = mutation.target; if (target === state.tooltip) return true; const element = target instanceof HTMLElement ? target : target?.parentElement; if (element && (element.id === TOOLTIP_ID || element.closest?.(`#${TOOLTIP_ID}`))) { return true; } if (element?.hasAttribute?.(OWN_UI_ATTRIBUTE) || element?.closest?.(`[${OWN_UI_ATTRIBUTE}]`)) { return true; } if (mutation.type !== 'childList') return false; const changedNodes = [...mutation.addedNodes, ...mutation.removedNodes]; return changedNodes.length > 0 && changedNodes.every((node) => { const changed = node instanceof HTMLElement ? node : node?.parentElement; return Boolean( changed?.hasAttribute?.(OWN_UI_ATTRIBUTE) || changed?.closest?.(`[${OWN_UI_ATTRIBUTE}]`), ); }); } async function start() { await initializeSourceModeMenu(); if (!document.body) return; state.observer = new MutationObserver((mutations) => { // Tooltip 与逐题开关自己的 DOM 变化不代表测验状态变化,避免观察器自触发。 if (mutations.length && mutations.every(isOwnUiMutation)) return; scheduleScan(); }); state.observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'disabled', 'aria-hidden', 'data-state'], }); scheduleScan(500); // 仅在真正的测验页预热 Key;user-quizzes 下的用户目录页不会弹窗。 window.setTimeout(() => { if ( selectedSourceMode() === 'llm' && CONFIG.PROMPT_FOR_KEY && !CONFIG.API_KEY && looksLikeQuizPage() ) { void getApiKeyOnce(); } }, 900); } void start(); })();