// ==UserScript== // @name 超星题目提取器 // @namespace https://chaoxing.com/ // @version 5.1.0 // @author kkkxfr // @description 提取超星章节测验、作业、考试题目,支持章节混淆字体自动解码。 // @match *://*.chaoxing.com/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_setClipboard // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect cdn.jsdelivr.net // @connect raw.githubusercontent.com // @run-at document-end // ==/UserScript== (function(){ 'use strict'; const IS_TOP_CONTEXT = (() => { try{ return window.top === window.self; }catch(e){ return true; } })(); if(!IS_TOP_CONTEXT) return; const AUTO_EXTRACT_KEY = 'chaoxing_auto_extract_enabled'; const EXTRACTION_HISTORY_KEY = 'chaoxing_extract_history_v1'; const EXTRACTION_HISTORY_LIMIT = 12; const CXSECRET_FONT_MAP_STORAGE_PREFIX = 'cxsecret_font_map_v2_'; const CXSECRET_REFERENCE_FONT_URLS = Object.freeze([ 'https://cdn.jsdelivr.net/gh/adobe-fonts/source-han-sans@release/OTF/SimplifiedChinese/SourceHanSansSC-Normal.otf', 'https://raw.githubusercontent.com/adobe-fonts/source-han-sans/release/OTF/SimplifiedChinese/SourceHanSansSC-Normal.otf' ]); const CXSECRET_REFERENCE_FONT_FAMILY = '__cx_ref_source_han__'; const CXSECRET_RENDER_CANVAS_SIZE = 160; const CXSECRET_COARSE_RENDER_SIZE = 32; const CXSECRET_FINE_RENDER_SIZE = 64; const CXSECRET_COARSE_THRESHOLD = 220; const CXSECRET_FINE_THRESHOLD = 230; const CXSECRET_TOP_CANDIDATES = 8; const CXSECRET_FONT_MAPS = { '6fa68352': Object.freeze({ '\u5591':'\u6e90','\u5592':'\u653f','\u5593':'\u4e8e','\u5594':'\u6211','\u5595':'\u5217','\u5596':'\u662f', '\u5597':'\u5c5e','\u5599':'\u884c','\u559b':'\u4e0b','\u559e':'\u5bf9','\u559f':'\u80fd','\u55a0':'\u7684', '\u55a1':'\u53ef','\u55a2':'\u76f8','\u55a3':'\u800c','\u55a4':'\u7cfb','\u55a5':'\u5404','\u55a6':'\u5f8b', '\u55a8':'\u6e0a','\u55a9':'\u5173','\u55aa':'\u6b63','\u55ac':'\u4f53','\u55ad':'\u6709','\u55af':'\u8bf4', '\u55b0':'\u5305','\u55b1':'\u4e3b','\u55b2':'\u4f1a','\u55b3':'\u76d1','\u55b4':'\u5458','\u55b5':'\u59d4', '\u55b6':'\u4eba','\u55b8':'\u7b2c','\u55b9':'\u5185','\u55ba':'\u79c1','\u55bc':'\u4e00','\u55bf':'\u4e0e', '\u55c0':'\u4e2d','\u55c1':'\u5e76','\u55c2':'\u7ec7','\u55c3':'\u7a0b','\u55c4':'\u4ea4','\u55c8':'\u516c', '\u55c9':'\u89c4','\u55cb':'\u548c','\u55cc':'\u6c11','\u55cd':'\u6838','\u55cf':'\u8bef','\u55d0':'\u5f81', '\u55d2':'\u91cf','\u55d5':'\u81ea','\u55d6':'\u5177','\u55d7':'\u6027','\u55d8':'\u76ee','\u55d9':'\u76ca', '\u55db':'\u88ab','\u55dc':'\u52a8','\u55dd':'\u6548','\u55de':'\u7387','\u55df':'\u7279','\u55e0':'\u4e0d', '\u55e2':'\u5b9a','\u55e7':'\u4e0a','\u55e8':'\u65b9','\u55ea':'\u8868','\u55ec':'\u5fd7','\u55ed':'\u610f', '\u55ee':'\u5bb9','\u55ef':'\u62e9','\u55f0':'\u9009','\u55f1':'\u534e','\u55f2':'\u4fee','\u55f4':'\u5904', '\u55f5':'\u6708','\u55f8':'\u4f8b','\u55f9':'\u53f7','\u55fa':'\u56fd','\u55fb':'\u5171','\u55fc':'\u529e', '\u55fd':'\u4ee4','\u55fe':'\u7406','\u55ff':'\u5e74','\u5600':'\u7ba1','\u560d':'\u7edf','\u566f':'\u65e5', '\u56b3':'\u5b8c','\u56c1':'\u793a','\u6eb4':'\u63a7','\u6ec3':'\u6052','\u6fc6':'\u4e09','\u939f':'\u88c1', '\u9623':'\u6cd5' }) }; const CXSECRET_CHAR_RE = /[\u5591-\u55a6\u55a8-\u55aa\u55ac-\u55b6\u55b8-\u55b9\u55ba\u55bc\u55bf-\u55c4\u55c8\u55c9\u55cb-\u55d2\u55d5-\u55d9\u55db-\u55e0\u55e2\u55e7\u55e8\u55ea\u55ec-\u55f2\u55f4\u55f5\u55f8-\u5600\u560d\u566f\u56b3\u56c1\u6eb4\u6ec3\u6fc6\u939f\u9623]/g; const cxSecretDecoderCache = new WeakMap(); const cxSecretDecoderPromiseCache = new WeakMap(); const cxSecretFontBase64Cache = new WeakMap(); const cxSecretLoadedFontFamilies = new Map(); const cxSecretRenderCache = new Map(); let cxSecretReferenceCandidatesPromise = null; const BIT_COUNTS = (() => { const table = new Uint8Array(256); for(let i = 0; i < 256; i++) table[i] = (i & 1) + table[i >> 1]; return table; })(); const gm = { addStyle(css){ if(typeof GM_addStyle === 'function') return GM_addStyle(css); const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); return style; }, getValue(key, defaultValue){ if(typeof GM_getValue === 'function'){ try{ return GM_getValue(key, defaultValue); }catch(e){} } try{ const raw = localStorage.getItem(key); return raw == null ? defaultValue : JSON.parse(raw); }catch(e){ return defaultValue; } }, setValue(key, value){ if(typeof GM_setValue === 'function'){ try{ return GM_setValue(key, value); }catch(e){} } try{ localStorage.setItem(key, JSON.stringify(value)); }catch(e){} return value; }, setClipboard(text){ if(typeof GM_setClipboard === 'function'){ try{ GM_setClipboard(text); return; }catch(e){} } if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(String(text)).catch(()=>{}); } }, registerMenuCommand(title, fn){ if(typeof GM_registerMenuCommand === 'function'){ try{ return GM_registerMenuCommand(title, fn); }catch(e){} } return null; }, xmlHttpRequest(options){ if(typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest(options); throw new Error('GM_xmlhttpRequest unavailable'); } }; /* ---------- 基础辅助 ---------- */ function normalizeText(s){ if(!s) return ''; return String(s).replace(/\u00A0/g,' ').replace(/\r?\n/g,' ').replace(/\s+/g,' ').trim(); } function hashDjb2(str){ let h = 5381; for(let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0; return ('00000000' + h.toString(16)).slice(-8); } function base64ToArrayBuffer(base64){ const binary = atob(base64); const bytes = new Uint8Array(binary.length); for(let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } function getCxSecretRenderSurface(){ if(getCxSecretRenderSurface.surface) return getCxSecretRenderSurface.surface; const canvas = document.createElement('canvas'); canvas.width = CXSECRET_RENDER_CANVAS_SIZE; canvas.height = CXSECRET_RENDER_CANVAS_SIZE; const ctx = canvas.getContext('2d', { willReadFrequently: true }); getCxSecretRenderSurface.surface = { canvas, ctx }; return getCxSecretRenderSurface.surface; } function getCxSecretBitDistance(a, b){ let count = 0; for(let i = 0; i < a.length; i += 2){ count += BIT_COUNTS[parseInt(a.slice(i, i + 2), 16) ^ parseInt(b.slice(i, i + 2), 16)]; } return count; } function getCxSecretGrayDistance(a, b){ let total = 0; for(let i = 0; i < a.length; i++){ const diff = a[i] - b[i]; total += diff * diff; } return total / a.length; } function findCxSecretFontBase64(doc=document){ if(!doc) return ''; if(cxSecretFontBase64Cache.has(doc)) return cxSecretFontBase64Cache.get(doc) || ''; let base64 = ''; try{ for(const ss of Array.from(doc.styleSheets || [])){ try{ for(const rule of Array.from(ss.cssRules || [])){ const css = String(rule.cssText || ''); if(!css.includes('font-cxsecret') || !css.includes('base64,')) continue; const match = css.match(/base64,([A-Za-z0-9+/=]+)/); if(match && match[1]) { base64 = match[1]; break; } } }catch(e){} if(base64) break; } }catch(e){} cxSecretFontBase64Cache.set(doc, base64 || ''); return base64 || ''; } function normalizeStoredCxSecretMap(raw){ if(!raw) return null; let data = raw; if(typeof data === 'string'){ try{ data = JSON.parse(data); }catch(e){ return null; } } if(!data || typeof data !== 'object') return null; const map = {}; for(const [fakeChar, realChar] of Object.entries(data)){ if(typeof fakeChar !== 'string' || typeof realChar !== 'string' || fakeChar.length !== 1 || realChar.length !== 1) continue; map[fakeChar] = realChar; } return Object.keys(map).length ? Object.freeze(map) : null; } function getStoredCxSecretMap(fontHash){ return normalizeStoredCxSecretMap(gm.getValue(CXSECRET_FONT_MAP_STORAGE_PREFIX + fontHash, null)); } function storeCxSecretMap(fontHash, map){ if(!fontHash || !map) return; gm.setValue(CXSECRET_FONT_MAP_STORAGE_PREFIX + fontHash, map); } function collectObservedCxSecretChars(doc=document){ if(!doc || !doc.body) return []; const text = doc.body.innerText || doc.body.textContent || ''; const matches = text.match(CXSECRET_CHAR_RE); return matches ? Array.from(new Set(matches)) : []; } function renderCxSecretGlyph(ch, family, outSize, threshold){ const key = `${family}|${outSize}|${threshold}|${ch}`; if(cxSecretRenderCache.has(key)) return cxSecretRenderCache.get(key); const { canvas, ctx } = getCxSecretRenderSurface(); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#000'; ctx.textBaseline = 'alphabetic'; ctx.font = `120px "${family}"`; const metrics = ctx.measureText(ch); const left = metrics.actualBoundingBoxLeft || 0; const right = metrics.actualBoundingBoxRight || 0; const ascent = metrics.actualBoundingBoxAscent || 0; const descent = metrics.actualBoundingBoxDescent || 0; const width = left + right; const height = ascent + descent; const x = ((canvas.width - width) / 2) + left; const y = ((canvas.height - height) / 2) + ascent; ctx.fillText(ch, x, y); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data; let minX = canvas.width; let minY = canvas.height; let maxX = -1; let maxY = -1; for(let yy = 0; yy < canvas.height; yy++){ for(let xx = 0; xx < canvas.width; xx++){ const idx = (yy * canvas.width + xx) * 4; if(imageData[idx] < 240){ if(xx < minX) minX = xx; if(yy < minY) minY = yy; if(xx > maxX) maxX = xx; if(yy > maxY) maxY = yy; } } } if(maxX < minX || maxY < minY){ cxSecretRenderCache.set(key, null); return null; } const pad = 2; minX = Math.max(0, minX - pad); minY = Math.max(0, minY - pad); maxX = Math.min(canvas.width - 1, maxX + pad); maxY = Math.min(canvas.height - 1, maxY + pad); const tmpCanvas = document.createElement('canvas'); tmpCanvas.width = outSize; tmpCanvas.height = outSize; const tmpCtx = tmpCanvas.getContext('2d', { willReadFrequently: true }); tmpCtx.fillStyle = '#fff'; tmpCtx.fillRect(0, 0, outSize, outSize); tmpCtx.drawImage(canvas, minX, minY, maxX - minX + 1, maxY - minY + 1, 0, 0, outSize, outSize); const out = tmpCtx.getImageData(0, 0, outSize, outSize).data; const gray = new Uint8Array(outSize * outSize); let grayIndex = 0; let bits = ''; for(let i = 0; i < out.length; i += 4){ const value = out[i]; gray[grayIndex++] = value; bits += value < threshold ? '1' : '0'; } let bitsHex = ''; for(let i = 0; i < bits.length; i += 4){ bitsHex += parseInt(bits.slice(i, i + 4), 2).toString(16); } const result = { gray, bitsHex }; cxSecretRenderCache.set(key, result); return result; } async function requestArrayBuffer(urlOrUrls){ const urls = Array.isArray(urlOrUrls) ? urlOrUrls : [urlOrUrls]; let lastError = null; for(const url of urls){ try{ return await new Promise((resolve, reject) => { gm.xmlHttpRequest({ method: 'GET', url, responseType: 'arraybuffer', onload(response){ if(response.status >= 200 && response.status < 300 && response.response){ resolve(response.response); return; } reject(new Error(`HTTP ${response.status || 0} @ ${url}`)); }, onerror(error){ reject(error || new Error(`GM_xmlhttpRequest failed @ ${url}`)); }, ontimeout(){ reject(new Error(`GM_xmlhttpRequest timeout @ ${url}`)); } }); }); }catch(gmError){ try{ const response = await fetch(url); if(!response.ok) throw new Error(`HTTP ${response.status} @ ${url}`); return await response.arrayBuffer(); }catch(fetchError){ lastError = fetchError || gmError; } } } throw lastError || new Error('Unable to fetch reference font'); } async function loadCxSecretFontFamily(family, sourceBuffer){ if(cxSecretLoadedFontFamilies.has(family)) return cxSecretLoadedFontFamilies.get(family); const promise = (async () => { const font = new FontFace(family, sourceBuffer); await font.load(); document.fonts.add(font); await document.fonts.ready; return family; })(); cxSecretLoadedFontFamilies.set(family, promise); try{ return await promise; }catch(e){ cxSecretLoadedFontFamilies.delete(family); throw e; } } async function getCxSecretReferenceCandidates(){ if(cxSecretReferenceCandidatesPromise) return cxSecretReferenceCandidatesPromise; cxSecretReferenceCandidatesPromise = (async () => { const buffer = await requestArrayBuffer(CXSECRET_REFERENCE_FONT_URLS); await loadCxSecretFontFamily(CXSECRET_REFERENCE_FONT_FAMILY, buffer); const candidates = []; for(let cp = 0x3000; cp <= 0x303f; cp++){ const rendered = renderCxSecretGlyph(String.fromCharCode(cp), CXSECRET_REFERENCE_FONT_FAMILY, CXSECRET_COARSE_RENDER_SIZE, CXSECRET_COARSE_THRESHOLD); if(rendered) candidates.push({ cp, bitsHex: rendered.bitsHex }); } for(let cp = 0xff00; cp <= 0xffef; cp++){ const rendered = renderCxSecretGlyph(String.fromCharCode(cp), CXSECRET_REFERENCE_FONT_FAMILY, CXSECRET_COARSE_RENDER_SIZE, CXSECRET_COARSE_THRESHOLD); if(rendered) candidates.push({ cp, bitsHex: rendered.bitsHex }); } for(let cp = 0x4e00; cp <= 0x9fff; cp++){ const rendered = renderCxSecretGlyph(String.fromCharCode(cp), CXSECRET_REFERENCE_FONT_FAMILY, CXSECRET_COARSE_RENDER_SIZE, CXSECRET_COARSE_THRESHOLD); if(rendered) candidates.push({ cp, bitsHex: rendered.bitsHex }); } return candidates; })(); try{ return await cxSecretReferenceCandidatesPromise; }catch(e){ cxSecretReferenceCandidatesPromise = null; throw e; } } async function buildCxSecretCharMap(base64, fontHash, observedChars){ if(!base64 || !observedChars || observedChars.length === 0) return null; const observedFamily = `__cx_obs_${fontHash}__`; await loadCxSecretFontFamily(observedFamily, base64ToArrayBuffer(base64)); const candidates = await getCxSecretReferenceCandidates(); const map = {}; for(const fakeChar of observedChars){ const coarse = renderCxSecretGlyph(fakeChar, observedFamily, CXSECRET_COARSE_RENDER_SIZE, CXSECRET_COARSE_THRESHOLD); if(!coarse) continue; const topCandidates = Array.from({ length: CXSECRET_TOP_CANDIDATES }, () => [Number.POSITIVE_INFINITY, 0]); for(const candidate of candidates){ const distance = getCxSecretBitDistance(coarse.bitsHex, candidate.bitsHex); if(distance >= topCandidates[topCandidates.length - 1][0]) continue; topCandidates[topCandidates.length - 1] = [distance, candidate.cp]; topCandidates.sort((a, b) => a[0] - b[0]); } const fine = renderCxSecretGlyph(fakeChar, observedFamily, CXSECRET_FINE_RENDER_SIZE, CXSECRET_FINE_THRESHOLD); if(!fine) continue; let bestCp = 0; let bestScore = Number.POSITIVE_INFINITY; for(const [, candidateCp] of topCandidates){ if(!candidateCp) continue; const candidateFine = renderCxSecretGlyph(String.fromCharCode(candidateCp), CXSECRET_REFERENCE_FONT_FAMILY, CXSECRET_FINE_RENDER_SIZE, CXSECRET_FINE_THRESHOLD); if(!candidateFine) continue; const score = getCxSecretGrayDistance(fine.gray, candidateFine.gray); if(score < bestScore){ bestScore = score; bestCp = candidateCp; } } if(bestCp) map[fakeChar] = String.fromCharCode(bestCp); } return Object.keys(map).length ? Object.freeze(map) : null; } function getCxSecretCharMap(doc=document){ if(!doc) return null; if(cxSecretDecoderCache.has(doc)) return cxSecretDecoderCache.get(doc); const base64 = findCxSecretFontBase64(doc); if(!base64){ cxSecretDecoderCache.set(doc, null); return null; } const fontHash = hashDjb2(base64); const map = CXSECRET_FONT_MAPS[fontHash] || getStoredCxSecretMap(fontHash) || null; if(map) cxSecretDecoderCache.set(doc, map); return map; } async function ensureCxSecretCharMap(doc=document){ if(!doc) return null; const cached = getCxSecretCharMap(doc); if(cached) return cached; if(cxSecretDecoderPromiseCache.has(doc)) return cxSecretDecoderPromiseCache.get(doc); const promise = (async () => { const base64 = findCxSecretFontBase64(doc); if(!base64){ cxSecretDecoderCache.set(doc, null); return null; } const fontHash = hashDjb2(base64); const stored = getStoredCxSecretMap(fontHash); const known = stored || CXSECRET_FONT_MAPS[fontHash] || null; const observedChars = collectObservedCxSecretChars(doc); const missingChars = observedChars.filter(ch => !known || !known[ch]); if(missingChars.length === 0){ cxSecretDecoderCache.set(doc, known); return known; } const built = await buildCxSecretCharMap(base64, fontHash, missingChars); const merged = Object.freeze({ ...(known || {}), ...(built || {}) }); if(Object.keys(merged).length){ storeCxSecretMap(fontHash, merged); cxSecretDecoderCache.set(doc, merged); return merged; } cxSecretDecoderCache.set(doc, null); return null; })(); cxSecretDecoderPromiseCache.set(doc, promise); try{ return await promise; }finally{ cxSecretDecoderPromiseCache.delete(doc); } } async function ensureCxSecretMapsForDocuments(docs, isManual=false){ const targets = []; let needsAutoDecode = false; for(const doc of (docs || [])){ const base64 = findCxSecretFontBase64(doc); if(!base64) continue; const observedChars = collectObservedCxSecretChars(doc); if(observedChars.length === 0) continue; const fontHash = hashDjb2(base64); const known = CXSECRET_FONT_MAPS[fontHash] || getStoredCxSecretMap(fontHash) || null; if(observedChars.some(ch => !known || !known[ch])) needsAutoDecode = true; targets.push(doc); } if(targets.length === 0) return; if(needsAutoDecode && isManual) showNotification('首次解析章节混淆字体,可能需要 10-20 秒……'); await Promise.all(targets.map(doc => ensureCxSecretCharMap(doc))); } function decodeCxSecretText(text, doc=document){ if(!text) return ''; const map = getCxSecretCharMap(doc); if(!map) return String(text); return String(text).replace(CXSECRET_CHAR_RE, ch => map[ch] || ch); } function getCleanText(el){ if(!el) return ''; const currentDoc = el.ownerDocument || document; const c = el.cloneNode(true); c.querySelectorAll('script, style, .mark_letter, .Cy_ulTop').forEach(x => x.remove()); let html = c.innerHTML || ''; html = html.replace(/]*>/gi, '\n').replace(/]*>/gi, '\n'); const tmp = currentDoc.createElement('div'); tmp.innerHTML = html; return normalizeText(decodeCxSecretText(tmp.textContent || tmp.innerText || '', currentDoc)); } function xmlEscape(s){ if(!s) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function htmlEscape(s){ return xmlEscape(s).replace(/'/g, '''); } function stripLeadingLabels(s){ if(!s) return ''; let t = normalizeText(s); while(/^(?:答案解析|正确答案|参考答案|答案|解析)[::\s]*/i.test(t)){ t = t.replace(/^(?:答案解析|正确答案|参考答案|答案|解析)[::\s]*/i, ''); } return t.trim(); } function pullLeadingScore(stem){ if(!stem) return {score:'', rest: ''}; const s = stem.trim(); const m = s.match(/^[,,]?\s*[\((]?\s*([0-9]+(?:\.[0-9]+)?)\s*分\s*[\)\)]?\s*(.*)$/); if(m){ return { score: m[1] + ' 分', rest: (m[2]||'').trim() }; } return { score: '', rest: s }; } /* ---------- 标题清洗(去噪、标准化) ---------- */ function sanitizeSectionTitleRaw(title){ if(!title) return ''; let t = normalizeText(title); // 去掉前导分数字样: "0分 "、"(100分)"、"(0分)" 等 t = t.replace(/^[((]?\s*[\d0-9]+\s*分[))]?\s*/i, ''); // 去掉答题卡里可能带的前缀(如 "未评分 "、"已批改") t = t.replace(/^(?:未评分|未批改|已批改|已做|0分|0分:)\s*/i, ''); // 全角数字 -> 半角 t = t.replace(/[\uFF10-\uFF19]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFF10 + 48)); // 规范化括号和空白(把各种形式的 "共 N 题" 统一) t = t.replace(/(\s*共\s*(\d+)\s*题\s*)/g, '(共 $1 题)'); t = t.replace(/\(\s*共\s*(\d+)\s*题\s*\)/g, '(共 $1 题)'); // 去掉重复的括号片段(保留第一次) t = t.replace(/(([^)]+))\s*\1+/g, '$1'); // 移除行首与行尾多余的符号 t = t.replace(/^[#\-\:\s ]+|[#\-\:\s ]+$/g, ''); t = t.trim(); return t; } /* ---------- 页面标题 ---------- */ function getPageTitle(doc=document){ const selectors=[ '#workTitle', '.work_title', 'h3.mark_title', '.courseName', '.course-title', '.mark_title', '.title', '.detailsHead', 'h1', 'h2', 'h3', '.fanyaMarking_left.whiteBg' ]; for(const sel of selectors){ try{ const els = doc.querySelectorAll(sel); for(const el of els){ const t = normalizeText(el.innerText||el.textContent||''); if(t && t.length > 2 && !/^(?:error|404|登录|未找到|提示|查看已批阅|作业详情)$/i.test(t) && !/[((]\s*(?:单选|多选|判断|填空)/.test(t) && !/^[0-9]+[\.、\s]/.test(t) ){ return t.split(/\r?\n/)[0]; } } }catch(e){} } const docTitle = normalizeText(doc.title||''); if(docTitle && docTitle.length > 2 && !/查看已批阅|作业详情/i.test(docTitle)) return docTitle; return '试卷'; } function collectAccessibleDocuments(rootDoc=document, out=[], seen=new Set()){ if(!rootDoc || seen.has(rootDoc)) return out; seen.add(rootDoc); out.push(rootDoc); try{ Array.from(rootDoc.querySelectorAll('iframe')).forEach(frame => { try{ const childDoc = frame.contentDocument; if(childDoc && childDoc.body) collectAccessibleDocuments(childDoc, out, seen); }catch(e){} }); }catch(e){} return out; } function countQuestionsInPaper(paper){ if(!paper || !paper.sections) return 0; return paper.sections.reduce((sum, sec) => sum + ((sec && sec.questions) ? sec.questions.length : 0), 0); } function detectPageKind(url=location.href){ const href = String(url || ''); if(/\/exam-ans\//i.test(href)) return '考试'; if(/\/work\/view|\/mooc2\/work\//i.test(href)) return '作业'; if(/studentstudy|doHomeWorkNew|chapter/i.test(href)) return '章节测验'; return '题目页面'; } function formatDateTime(value){ if(!value) return ''; try{ return new Date(value).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); }catch(e){ return String(value); } } function normalizeHistoryRecord(record){ if(!record || typeof record !== 'object') return null; const text = typeof record.text === 'string' ? record.text : ''; const paper = record.paper && typeof record.paper === 'object' ? record.paper : null; return { id: String(record.id || `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`), title: String(record.title || (paper && paper.title) || '未命名试卷'), pageKind: String(record.pageKind || detectPageKind(record.pageUrl || '')), pageUrl: String(record.pageUrl || ''), createdAt: String(record.createdAt || new Date().toISOString()), total: Number(record.total || countQuestionsInPaper(paper) || 0), text, paper }; } function getExtractionHistory(){ const raw = gm.getValue(EXTRACTION_HISTORY_KEY, []); if(!Array.isArray(raw)) return []; return raw.map(normalizeHistoryRecord).filter(Boolean); } function setExtractionHistory(records){ gm.setValue(EXTRACTION_HISTORY_KEY, records.slice(0, EXTRACTION_HISTORY_LIMIT)); } function upsertExtractionRecord(record){ const normalized = normalizeHistoryRecord(record); if(!normalized) return []; const history = getExtractionHistory().filter(item => { if(!item) return false; if(item.id === normalized.id) return false; if(item.pageUrl && normalized.pageUrl && item.pageUrl === normalized.pageUrl) return false; return true; }); history.unshift(normalized); setExtractionHistory(history); return history.slice(0, EXTRACTION_HISTORY_LIMIT); } function deleteExtractionRecord(recordId){ const history = getExtractionHistory().filter(item => item && item.id !== recordId); setExtractionHistory(history); return history; } function clearExtractionHistory(){ setExtractionHistory([]); return []; } function createExtractionRecord(paper, text){ return normalizeHistoryRecord({ id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, title: (paper && paper.title) || getPageTitle(document), pageKind: detectPageKind(), pageUrl: location.href, createdAt: new Date().toISOString(), total: countQuestionsInPaper(paper), text: String(text || ''), paper: paper || null }); } function buildRecordFileName(record, extension){ const title = (record && record.title) || '超星题目'; const stamp = formatDateTime(record && record.createdAt).replace(/[\\/: ]+/g, '-'); const safeTitle = String(title).replace(/[\\/:*?"<>|]+/g, '_'); return `${safeTitle}_${stamp || 'export'}.${extension}`; } function mergeStructuredPapers(papers){ const usable = (papers || []).filter(p => countQuestionsInPaper(p) > 0); if(usable.length === 0) return { title: getPageTitle(document), sections: [], explicitSectionTitle: '' }; usable.sort((a,b)=> countQuestionsInPaper(b) - countQuestionsInPaper(a)); const merged = { title: usable[0].title || getPageTitle(document), sections: [], explicitSectionTitle: usable[0].explicitSectionTitle || '' }; const sectionMap = new Map(); const seenFp = new Set(); let no = 1; for(const paper of usable){ for(const sec of (paper.sections || [])){ const secTitle = (sec && sec.title) ? sec.title : (paper.title || '未分组'); if(!sectionMap.has(secTitle)) sectionMap.set(secTitle, { title: secTitle, questions: [] }); const target = sectionMap.get(secTitle); for(const q of (sec.questions || [])){ const fp = ((q.stem || '') + '||' + ((q.options || []).map(o => o.text).join('||')) + '||' + (q.correctAnswerInline || '') + '||' + (q.myAnswerInline || '')).slice(0, 2000); if(seenFp.has(fp)) continue; seenFp.add(fp); target.questions.push({ ...q, no: no++ }); } } } merged.sections = Array.from(sectionMap.values()); return merged; } /* ---------- 题目/选项定位 ---------- */ function findOptionContainers(qNode){ const sels = ['.mark_letter','.Cy_ulTop','.Zy_ulTop','.options','.option-list','.optionsList']; const found = []; for(const s of sels){ const el = qNode.querySelector(s); if(el) found.push(el); } const uls = Array.from(qNode.querySelectorAll('ul,ol')); uls.forEach(u=>{ if((u.className && /ul|options|Cy_ulTop|Zy_ulTop|mark_letter/.test(u.className)) || (u.querySelector('li') && !u.textContent.includes('我的答案'))) { if(!found.includes(u)) found.push(u); } }); return found; } /* ---------- 答案提取 ---------- */ function extractCorrectAnswerFromNode(qNode, qType){ if(!qNode) return null; const isFill = !!(qType && /填空|简答|问答|填充|主观|主观题/i.test(qType)); const strictAnsRe = /(?:正确答案|参考答案|答案)[::\s]*([A-Da-d对错√×]+)/i; const strictMyRe = /(?:我的答案|My Answer)[::\s]*([A-Da-d对错√×]+)/i; const looseAnsRe = /(?:正确答案|参考答案|答案)[::\s]*([\s\S]{1,200})/i; const looseMyRe = /(?:我的答案|My Answer)[::\s]*([\s\S]{1,200})/i; const rightEls = Array.from(qNode.querySelectorAll('.rightAnswerContent')); if(rightEls.length){ const texts = rightEls.map(el => getCleanText(el)).filter(Boolean); if(texts.length) return texts.join('\n'); } const selectors = ['.mark_key','.mark_answer','.right_answer','.answerRight','.Py_answer','.answer-content','.answer']; const collected = []; for(const s of selectors){ const els = Array.from(qNode.querySelectorAll(s)); for(const el of els){ const t = getCleanText(el); if(!t || /未提取|未作答|暂无/.test(t)) continue; if(isFill){ const m = t.match(looseAnsRe); if(m && m[1]) { collected.push(m[1].trim()); continue; } collected.push(t.trim()); } else { const m = t.match(strictAnsRe); if(m && m[1]) { collected.push(m[1].trim()); continue; } const short = t.replace(/\s+/g,''); if(/^[A-D]+$/i.test(short)) collected.push(short); } } } if(collected.length) return collected.join('\n'); let clone = qNode.cloneNode(true); clone.querySelectorAll('ul, ol, .mark_letter, .Cy_ulTop, .options, .option-list').forEach(el => el.remove()); clone.querySelectorAll('.mark_name, .Cy_TItle, .Qtitle, h3').forEach(el => el.remove()); const remainingText = normalizeText(clone.innerText || clone.textContent || ''); if(remainingText){ if(isFill){ const exactMatch = remainingText.match(looseAnsRe); if(exactMatch && exactMatch[1]) return exactMatch[1].trim(); } else { const exactMatch = remainingText.match(strictAnsRe); if(exactMatch && exactMatch[1]) return exactMatch[1].trim(); } } if(isFill){ const myMatch = remainingText.match(looseMyRe); if(myMatch && myMatch[1]) return myMatch[1].trim(); } else { const myMatch = remainingText.match(strictMyRe); if(myMatch && myMatch[1]) return myMatch[1].trim(); } try { const inputs = Array.from(qNode.querySelectorAll( 'input[type="text"], textarea, input[type="search"], ' + 'input[type="hidden"][data-answer], input[type="hidden"][data-right], input[type="hidden"][data-true-answer], input[type="hidden"][data-key], ' + 'input[type="hidden"][name*="answer"]:not([name*="answertype"]):not([id*="answertype"]), ' + 'input[type="hidden"][id*="answer"]:not([name*="answertype"]):not([id*="answertype"])' )); const inputVals = inputs.map(inp => { const raw = (inp.value || inp.getAttribute('value') || (inp.dataset && (inp.dataset.answer || inp.dataset.right)) || inp.getAttribute('data-answer') || inp.getAttribute('data-right') || inp.getAttribute('placeholder') || '').trim(); const nameLike = `${inp.name || ''} ${inp.id || ''}`; if(/answertype|qtype/i.test(nameLike)) return ''; return raw; }).filter(v => v); if(inputVals.length) return inputVals.join(' / '); } catch(e){} const blankSelectors = [ '.fill-blank', '.fillblank', '.blank-input', '.blank', '.filling_answer', '.fill-answer', '.blank-list', '.answerBlank', '.answer_blank', '.textAnswer', '.answer-input', '.answerValue' ]; for(const bs of blankSelectors){ const els = qNode.querySelectorAll(bs); if(els && els.length){ const vals = Array.from(els).map(e => { if(e.tagName === 'INPUT' || e.tagName === 'TEXTAREA') { return (e.value || e.getAttribute('value') || '').trim(); } const txt = getCleanText(e) || (e.dataset && (e.dataset.answer || e.dataset.right)) || ''; return String(txt).trim(); }).filter(Boolean); if(vals.length) return vals.join(' / '); } } const dataAnswerEl = qNode.querySelector('[data-answer], [data-right], [data-true-answer], [data-key]'); if(dataAnswerEl){ const v = (dataAnswerEl.getAttribute('data-answer') || dataAnswerEl.getAttribute('data-right') || dataAnswerEl.getAttribute('data-true-answer') || dataAnswerEl.getAttribute('data-key') || '').trim(); if(v) return v; } try { const candidates = qNode.querySelectorAll('span, label'); for(const c of candidates){ const txt = getCleanText(c); if(!txt) continue; const m = txt.match(/^(?:答案|正确答案|参考答案|\:)\s*([\s\S]{1,200})/i); if(m && m[1]) return m[1].trim(); } } catch(e){} return null; } /* ---------- 规范化 ---------- */ function isFillLikeQuestion(qType){ return !!(qType && /填空|简答|问答|填充|主观|主观题/i.test(qType)); } function extractAnswerByMode(qNode, qType, mode){ if(!qNode) return null; const isFill = isFillLikeQuestion(qType); const labels = mode === 'correct' ? ['正确答案', '参考答案', '姝g‘绛旀', '鍙傝€冪瓟妗?'] : ['我的答案', 'My Answer', '学生答案', '鎴戠殑绛旀']; const normalizedLabels = mode === 'correct' ? ['正确答案', '参考答案', '标准答案', '答案'] : ['我的答案', 'My Answer', '学生答案', '你的答案']; const escapedLabels = normalizedLabels.map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const looseRe = new RegExp(`(?:${escapedLabels.join('|')})[::\\s]*([\\s\\S]{1,200})`, 'i'); const strictRe = new RegExp(`(?:${escapedLabels.join('|')})[::\\s]*([A-Da-d对错√×+-]+)`, 'i'); const selectors = mode === 'correct' ? ['.rightAnswerContent', '.right_answer', '.answerRight', '.mark_key', '.Py_answer', '.answer.correct', '.answer-right'] : ['.stuAnswerContent', '.myAnswer', '.userAnswer', '.user-answer', '.answer.mine', '.answer-user', '.mark_fill', '.stu_answer', '.answer-current']; const collected = []; for(const selector of selectors){ for(const el of Array.from(qNode.querySelectorAll(selector))){ const text = getCleanText(el); if(!text || /未提交|未作答|暂无|空/.test(text)) continue; if(isFill){ const match = text.match(looseRe); collected.push((match && match[1] ? match[1] : text).trim()); } else { const match = text.match(strictRe) || text.match(looseRe); if(match && match[1]) collected.push(match[1].trim()); else if(/^[A-D]+$/i.test(text.replace(/\s+/g, ''))) collected.push(text.replace(/\s+/g, '')); } } } if(collected.length) return collected.join('\n'); const clone = qNode.cloneNode(true); clone.querySelectorAll('ul, ol, .mark_letter, .Cy_ulTop, .options, .option-list').forEach(el => el.remove()); clone.querySelectorAll('.mark_name, .Cy_TItle, .Qtitle, h3').forEach(el => el.remove()); const remainingText = normalizeText(clone.innerText || clone.textContent || ''); if(remainingText){ const match = isFill ? remainingText.match(looseRe) : (remainingText.match(strictRe) || remainingText.match(looseRe)); if(match && match[1]) return match[1].trim(); } if(mode === 'correct'){ const dataAnswerEl = qNode.querySelector('[data-right], [data-true-answer], [data-key], [data-correct]'); if(dataAnswerEl){ const value = ( dataAnswerEl.getAttribute('data-right') || dataAnswerEl.getAttribute('data-true-answer') || dataAnswerEl.getAttribute('data-key') || dataAnswerEl.getAttribute('data-correct') || '' ).trim(); if(value) return value; } } else { const selectedInputs = Array.from(qNode.querySelectorAll('input[type="radio"]:checked, input[type="checkbox"]:checked')); if(selectedInputs.length){ const labelsOut = selectedInputs.map(input => { const li = input.closest('li'); if(!li) return ''; return normalizeText( li.querySelector('.num_option_dx, .num_option')?.getAttribute('data') || li.querySelector('.mark_letter_span, i.fl, .num_option_dx, .num_option')?.innerText || '' ).replace(/[^A-Za-z0-9]/g, '').trim().toUpperCase(); }).filter(Boolean); if(labelsOut.length) return Array.from(new Set(labelsOut)).join(''); } const inputVals = Array.from(qNode.querySelectorAll('input[type="text"], textarea')) .map(inp => (inp.value || inp.getAttribute('value') || '').trim()) .filter(Boolean); if(inputVals.length) return inputVals.join(' / '); } try{ const candidates = Array.from(qNode.querySelectorAll('span, label, div, p')); for(const candidate of candidates){ const text = getCleanText(candidate); if(!text) continue; const match = isFill ? text.match(looseRe) : (text.match(strictRe) || text.match(looseRe)); if(match && match[1]) return match[1].trim(); } }catch(e){} return null; } function extractMyAnswerFromNode(qNode, qType){ return extractAnswerByMode(qNode, qType, 'my'); } function extractOnlyCorrectAnswerFromNode(qNode, qType){ return extractAnswerByMode(qNode, qType, 'correct'); } function normalizeAnswerString(s, qType){ if(!s) return ''; s = normalizeText(s); const blanks = s.match(/(?:\(\s*\d+\s*\)|(\s*\d+\s*))[\s\S]*?(?=(?:\(\s*\d+\s*\)|(\s*\d+\s*)|$))/g); if(blanks && blanks.length > 1) return blanks.map(b => normalizeText(b)).join(' '); const letters = s.match(/[A-D]/gi); if(letters){ const upper = letters.map(x => x.toUpperCase()); const uniq = Array.from(new Set(upper)); if((qType === '单选题' || qType === '单选') && uniq.length > 1) { const strictMatch = s.match(/(?:正确答案|答案)[::\s]*([A-D])/i); if(strictMatch) return strictMatch[1].toUpperCase(); if(uniq.length > 2) return ''; return uniq.sort().join(''); } return uniq.sort().join(''); } if(/^(对|正确|true|√)$/i.test(s)) return '对'; if(/^(错|错误|false|×)$/i.test(s)) return '错'; return s; } /* ---------- 拆分多空 ---------- */ function splitNumberedBlanks(s){ if(!s) return s; s = String(s).trim(); s = s.replace(/\u00A0/g,' ').replace(/\r?\n/g,'\n').replace(/[ \t\v\f]+/g,' '); if(/\(\s*\d+\s*\)/.test(s)){ const regex = /\(\s*(\d+)\s*\)\s*([\s\S]*?)(?=(?:\(\s*\d+\s*\)|$))/g; let m; const parts = []; while((m = regex.exec(s)) !== null){ const idx = parseInt(m[1], 10); let content = String(m[2] || '').trim(); content = content.replace(/^\s+|\s+$/g,'').replace(/\r?\n+/g,' '); content = content.replace(/\s{2,}/g,' '); parts.push({ idx, content }); } if(parts.length){ parts.sort((a,b)=> a.idx - b.idx); return parts.map(p => `(${p.idx}) ${p.content}`).join('\n'); } } const m1 = s.match(/^\(?\s*1\s*\)?\s*([\s\S]+)$/); if(m1){ let rest = m1[1].trim(); if(rest){ let parts = rest.split(/\s{2,}|\/|;|;|,|,|\|/).map(p => p.trim()).filter(Boolean); if(parts.length <= 1){ const tokens = rest.split(/\s+/).map(t => t.trim()).filter(Boolean); if(tokens.length > 1 && tokens.every(t => /^[A-Za-z0-9\-_]+$/.test(t)) ){ parts = tokens; } } if(parts.length > 1){ return parts.map((p, idx) => `(${idx+1}) ${p.trim()}`).join('\n'); } } } return s; } /* ---------- 合并为单行显示 ---------- */ function renderAnswerInline(ans){ if(!ans) return ''; return String(ans).replace(/\r?\n+/g,' ').replace(/\s{2,}/g,' ').trim(); } function getQuestionAnswerSummary(q){ const correct = q && q.correctAnswerInline ? stripLeadingLabels(q.correctAnswerInline) : ''; const mine = q && q.myAnswerInline ? stripLeadingLabels(q.myAnswerInline) : ''; if(correct && mine) return `正:${correct} 我:${mine}`; if(correct) return correct; if(mine) return `我:${mine}`; return q && q.answerInline ? stripLeadingLabels(q.answerInline) : ''; } /* ---------- 更鲁棒章节识别 ---------- */ function findNearestSectionTitle(node, root, doc){ try{ const currentDoc = doc || (node && node.ownerDocument) || document; const headerSels = [ '.type_tit', '.Cy_TItle1', 'h1', 'h2', 'h3', 'h4', '.markTitle', '.typeTitle', '.mark_section', '.section-title', '.question-type-title', '.headline' ]; let cur = node; for(let i=0;i<20;i++){ if(!cur || cur === currentDoc.body) break; let ps = cur.previousElementSibling; while(ps){ for(const sel of headerSels){ if(ps.matches && ps.matches(sel)){ const t = normalizeText(ps.innerText||ps.textContent||''); if(t && !/^\d+[.、]/.test(t) && !/^[((]\s*\d+\s*分\s*[))]$/.test(t) && t.length < 200) return t; } } if(ps.querySelector){ for(const sel of headerSels){ const sub = ps.querySelector(sel); if(sub){ const t = normalizeText(sub.innerText||sub.textContent||''); if(t && !/^\d+[.、]/.test(t) && !/^[((]\s*\d+\s*分\s*[))]$/.test(t) && t.length < 200) return t; } } } ps = ps.previousElementSibling; } cur = cur.parentElement; } const containerCandidates = ['.mark_item', '.mark_item_box', '.mark_table', '.fanyaMarking_left', '.mark_table .mark_item']; for(const cand of containerCandidates){ const container = node.closest && node.closest(cand); if(container){ for(const sel of headerSels){ const hd = container.querySelector(sel); if(hd){ const t = normalizeText(hd.innerText||hd.textContent||''); if(t && !/^[((]\s*\d+\s*分\s*[))]$/.test(t) && t.length < 200) return t; } } } } if(root){ const allHeaders = Array.from(root.querySelectorAll(headerSels.join(','))); let candidate = ''; for(const h of allHeaders){ if(h.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING){ const t = normalizeText(h.innerText||h.textContent||''); if(t && !/^\d+[.、]/.test(t) && !/^[((]\s*\d+\s*分\s*[))]$/.test(t) && t.length < 200){ candidate = t; } } } if(candidate) return candidate; } try{ const allPossible = Array.from(currentDoc.querySelectorAll(headerSels.join(','))); const nodeRect = node.getBoundingClientRect ? node.getBoundingClientRect() : null; if(nodeRect && allPossible.length){ let best = null; let bestTop = -Infinity; for(const h of allPossible){ const r = h.getBoundingClientRect ? h.getBoundingClientRect() : null; if(!r) continue; if(r.top < nodeRect.top && r.top > bestTop){ const t = normalizeText(h.innerText||h.textContent||''); if(t && t.length < 300) { best = t; bestTop = r.top; } } } if(best) return best; } }catch(e){} }catch(e){} return ''; } /* ---------- 题目解析(含清理题干开头序号) ---------- */ function parseQuestionNode(node, root){ try{ const currentDoc = node.ownerDocument || document; const titleEl = node.querySelector('.mark_name, .Cy_TItle, .Zy_TItle, .Qtitle, .question-title, .marking-title, .qtContent') || node; let stemRaw = ''; if(titleEl === node) { let clone = node.cloneNode(true); clone.querySelectorAll('.mark_letter, .Cy_ulTop, .Zy_ulTop, .options, ul, ol, .Py_answer, .mark_fill, .mark_answer, .rightAnswerContent, .right_answer, .mark_key, .answer, .answer-content, .fill-blank, .stuAnswerContent').forEach(el => el.remove()); stemRaw = getCleanText(clone); } else { stemRaw = getCleanText(titleEl); } let stem = decodeCxSecretText(normalizeText(stemRaw), currentDoc); stem = stem.replace(/(我的答案|My Answer)[::\s]*.*$/i, ''); stem = stem.replace(/^\d+[\.\、\s]*【.*?】\s*/, ''); stem = stem.replace(/^\d+[\.\、]\s*/, ''); // 移除开头处各种括号内的题型标注(支持多次连续的括号) stem = stem.replace(/^(?:\s*[\(\(\【\[]\s*(?:单选题|单选|选择题|选择|多选题|多选|判断题|判断|填空题|填空|论述题|论述|简答题|简答|问答题|问答|主观题|主观|材料题|操作题)\s*[\)\)\】\]]\s*)+/i, ''); stem = stem.trim(); (function stripLeadingNums(){ let changed = true; while(changed){ changed = false; const m = stem.match(/^\s*(?:\(*\s*\d+\s*[\)\.\、\)]|\(\s*\d+\s*)|\d+[\.\、])\s*/); if(m){ stem = stem.slice(m[0].length); changed = true; } const m2 = stem.match(/^\s*\d+[\.\、]\s*/); if(m2){ stem = stem.slice(m2[0].length); changed = true; } } stem = stem.replace(/^\s+/, ''); })(); const wholeTxt = (node.innerText||node.textContent||'').toLowerCase(); // 优先识别论述/简答/主观类题型 let type = '单选题'; if(/论述|简答|问答|主观|论述题|简答题|问答题|主观题/i.test(wholeTxt)) { type = '简答题'; } else if(/多选|multiple|multi/i.test(wholeTxt)) { type = '多选题'; } else if(/判断|是非|对错|true|false/i.test(wholeTxt)) { type = '判断题'; } else if(/填空|空格|填充|填空题/i.test(wholeTxt)) { type = '填空题'; } else { const dt = node.getAttribute && (node.getAttribute('data-type') || node.getAttribute('qtype') || ''); if(dt && /multi|checkbox|多选/i.test(dt)) type = '多选题'; } const isFill = /填空|简答|问答|填充|主观|主观题/i.test(type); const options = []; if(!isFill){ const containers = findOptionContainers(node); if(containers.length > 0){ let chosen = containers.find(c => c.querySelector && c.querySelector('li')); if(!chosen) chosen = containers[0]; if(chosen){ const lis = Array.from(chosen.querySelectorAll('li')); const items = lis.length ? lis : Array.from(chosen.children); items.forEach((li, idx)=>{ const visibleLabel = normalizeText(li.querySelector('.mark_letter_span')?.innerText || li.querySelector('i.fl')?.innerText || li.querySelector('.num_option_dx, .num_option')?.innerText || '').replace(/[^A-Za-z0-9对错×√]/g,'').trim(); const dataLabel = normalizeText( li.querySelector('.num_option_dx, .num_option')?.getAttribute('data') || li.querySelector('[data-option]')?.getAttribute('data-option') || li.querySelector('[data-choice]')?.getAttribute('data-choice') || li.getAttribute('data-option') || li.getAttribute('data-choice') || '' ).replace(/[^A-Za-z0-9对错×√]/g,'').trim(); const ariaLabel = normalizeText(li.getAttribute('aria-label') || ''); const ariaMatch = ariaLabel.match(/^([A-Da-d对错×√])(?:[\.\、\s]|$)/); let label = dataLabel || (ariaMatch ? ariaMatch[1] : '') || visibleLabel; let clone = li.cloneNode(true); if(clone.querySelector('.mark_letter_span')) clone.querySelector('.mark_letter_span').remove(); if(clone.querySelector('i.fl')) clone.querySelector('i.fl').remove(); clone.querySelectorAll('.num_option_dx, .num_option, .before').forEach(el => el.remove()); let text = decodeCxSecretText(normalizeText(clone.textContent || clone.innerText || ''), currentDoc); const m = text.match(/^[\(\[]?\s*([A-Da-d])[\.\、\)\]\s]+/); if(!label && m){ label = m[1].toUpperCase(); } const prefixCandidates = Array.from(new Set([visibleLabel, label, m ? m[1] : ''].filter(Boolean))); prefixCandidates.forEach(prefix => { const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); text = text.replace(new RegExp('^[\\(\\[]?\\s*' + escaped + '[\\.、\\)\\]\\s]+', 'i'), '').trim(); }); if(!label) label = String.fromCharCode(65 + (idx % 26)); if(text.length > 0 && text !== '查看解析') options.push({ key: label.toUpperCase(), text: text }); }); } } } const answerSource = (function(){ const rawText = node.innerText || node.textContent || ''; if(/正确答案|参考答案/.test(rawText) || node.querySelector('.rightAnswerContent, .right_answer, .answerRight')) return 'correct'; if(/我的答案|My Answer/.test(rawText)) return 'my'; return ''; })(); let answerRaw = extractCorrectAnswerFromNode(node, type) || ''; let answer = normalizeAnswerString(answerRaw, type); answer = stripLeadingLabels(answer); let answerSplit = splitNumberedBlanks(answer); let answerInline = renderAnswerInline(answerSplit); let correctAnswerRaw = extractOnlyCorrectAnswerFromNode(node, type) || ''; let correctAnswer = normalizeAnswerString(correctAnswerRaw, type); correctAnswer = stripLeadingLabels(correctAnswer); let correctAnswerSplit = splitNumberedBlanks(correctAnswer); let correctAnswerInline = renderAnswerInline(correctAnswerSplit); let myAnswerRaw = extractMyAnswerFromNode(node, type) || ''; let myAnswer = normalizeAnswerString(myAnswerRaw, type); myAnswer = stripLeadingLabels(myAnswer); let myAnswerSplit = splitNumberedBlanks(myAnswer); let myAnswerInline = renderAnswerInline(myAnswerSplit); let mergedAnswer = correctAnswerSplit || myAnswerSplit || answerSplit || ''; let mergedAnswerInline = correctAnswerInline || myAnswerInline || answerInline || ''; let mergedAnswerSource = correctAnswerInline ? 'correct' : (myAnswerInline ? 'my' : answerSource); const analysisEl = node.querySelector('.analysisDiv, .analysis, .py_analyse, .Py_addpy .pingyu, .explain, .analysisTxt'); let analysis = analysisEl ? getCleanText(analysisEl) : ''; analysis = stripLeadingLabels(analysis); const sectionTitle = findNearestSectionTitle(node, root, node.ownerDocument || document) || ''; return { type, stem, options, answer: mergedAnswer, answerInline: mergedAnswerInline, answerSource: mergedAnswerSource, correctAnswer: correctAnswerSplit, correctAnswerInline, myAnswer: myAnswerSplit, myAnswerInline, analysis, sectionTitle }; } catch(e){ console.error('parseQuestionNode error', e); return null; } } /* ---------- 构建试卷对象 ---------- */ function buildStructuredPaperFromDOM(doc=document){ const root = doc.querySelector('.fanyaMarking') || doc.querySelector('.mark_table') || doc.body; let explicitSectionTitle = ''; (function(){ try{ const candidateSelectors = '.type_tit, .Cy_TItle1, .typeTitle, .mark_section, h2, h3'; const pageTitle = getPageTitle(doc); let explicitEl = root.querySelector('.mark_table .type_tit, .mark_item .type_tit, ' + candidateSelectors); if(explicitEl && normalizeText(explicitEl.innerText || explicitEl.textContent || '') === normalizeText(pageTitle)){ const rightCard = doc.querySelector('.topicNumber_checkbox, .topicNumber .topicNumber_checkbox, #topicNumberScroll .topicNumber_checkbox'); if(rightCard && normalizeText(rightCard.innerText || rightCard.textContent || '')){ explicitEl = rightCard; } else { const alt = root.querySelector('.mark_item .type_tit, .mark_table h2, .mark_item h2, ' + candidateSelectors); if(alt) explicitEl = alt; } } if(!explicitEl){ explicitEl = root.querySelector(candidateSelectors); if(!explicitEl){ const rn = doc.querySelector('.topicNumber_checkbox, .topicNumber .topicNumber_checkbox, #topicNumberScroll .topicNumber_checkbox'); if(rn) explicitEl = rn; } } if(explicitEl){ const t = normalizeText(explicitEl.innerText || explicitEl.textContent || ''); const cleaned = sanitizeSectionTitleRaw(t); if(cleaned && cleaned !== sanitizeSectionTitleRaw(pageTitle)) explicitSectionTitle = cleaned; } }catch(e){} })(); const selectors = ['.questionLi','.TiMu','.question-item','.mark_question','.exam-question','.paper-question','.Ques','.questionBox','.marBom60','.singleQuesId']; const nodeSet = new Set(); selectors.forEach(sel=> doc.querySelectorAll(sel).forEach(n=> { if(n && root.contains(n)) nodeSet.add(n); })); if(nodeSet.size === 0){ const hasNestedFrame = !!root.querySelector('iframe'); if(!hasNestedFrame){ Array.from(root.querySelectorAll('*')).forEach(n=>{ try{ if(!(n instanceof HTMLElement)) return; const len = (n.innerText||'').length; const hasStem = !!n.querySelector('.mark_name, .Cy_TItle, .Zy_TItle, .Qtitle, .qtContent, h3'); const hasOptions = !!n.querySelector('.mark_letter, .Cy_ulTop, .Zy_ulTop, ul, ol'); const hasAnswerish = /我的答案|My Answer|正确答案|参考答案|答案解析/.test(n.innerText || ''); if((hasStem && hasOptions) || (len > 24 && hasOptions && hasAnswerish)) nodeSet.add(n); }catch(e){} }); } } const rawNodes = Array.from(nodeSet); const arr = rawNodes .filter(node => !rawNodes.some(other => other !== node && node.contains && node.contains(other))) .sort((a,b)=> (a.compareDocumentPosition(b) & 2) ? 1 : -1); const parsedQuestions = []; const seenFp = new Set(); for(const node of arr){ const q = parseQuestionNode(node, root); if(!q) continue; const fp = ( (q.stem||'') + '||' + ((q.options||[]).map(o=>o.text).join('||')) + '||' + (q.correctAnswerInline||'') + '||' + (q.myAnswerInline||'') ).slice(0,2000); if(seenFp.has(fp)) continue; seenFp.add(fp); parsedQuestions.push(q); } const sectionsMap = new Map(); const pageTitle = getPageTitle(doc); for(const q of parsedQuestions){ const key = q.sectionTitle && q.sectionTitle.trim() ? q.sectionTitle.trim() : '未分组'; if(!sectionsMap.has(key)) sectionsMap.set(key, []); sectionsMap.get(key).push(q); } if(sectionsMap.size === 1 && sectionsMap.has('未分组')){ if(explicitSectionTitle){ sectionsMap.set(explicitSectionTitle, sectionsMap.get('未分组')); } else { const localHeader = root.querySelector('.type_tit, .Cy_TItle1, .typeTitle, .markTitle, h2, h3'); const headerText = localHeader ? sanitizeSectionTitleRaw(normalizeText(localHeader.innerText || localHeader.textContent || '')) : ''; if(headerText && headerText.length > 0 && headerText !== sanitizeSectionTitleRaw(pageTitle)){ sectionsMap.set(headerText, sectionsMap.get('未分组')); } else { sectionsMap.set(pageTitle, sectionsMap.get('未分组')); } } sectionsMap.delete('未分组'); } const entries = Array.from(sectionsMap.entries()); if(entries.length > 1 && entries[0][0] === '未分组'){ const ungroupList = entries[0][1] || []; const nextList = entries[1][1] || []; entries[1][1] = ungroupList.concat(nextList); entries.splice(0, 1); } const sections = []; for(const [title, qlist] of entries){ sections.push({ title, qlist }); } let counter = 1; const paper = { title: pageTitle, sections: [] }; for(const s of sections){ const sec = { title: s.title, questions: [] }; for(const q of s.qlist){ q.no = counter++; sec.questions.push(q); } paper.sections.push(sec); } paper.explicitSectionTitle = explicitSectionTitle || ''; return paper; } function buildStructuredPaper(){ const docs = collectAccessibleDocuments(document); const papers = docs.map(doc => { try{ return buildStructuredPaperFromDOM(doc); }catch(e){ console.warn('buildStructuredPaperFromDOM failed on document', e); return null; } }).filter(Boolean); return mergeStructuredPapers(papers); } /* ---------- DOCX / 文本输出 ---------- */ function buildContentTypes(){ return ''; } function buildRels(){ return ''; } function buildStyles(){ return ''; } function buildDocumentXmlFromPaper(paper){ const questionsFlat = []; for(const s of paper.sections) for(const q of s.questions) questionsFlat.push(q); const totalCount = questionsFlat.length; let xml=''; xml += `${xmlEscape(paper.title)}`; xml += `${xmlEscape('(共 ' + totalCount + ' 题)')}`; xml += ``; for(const sec of paper.sections){ if(normalizeText(sec.title) !== normalizeText(paper.title) && !/未分组|题目|^$|^\d+[.、]/.test(sec.title)){ xml += `${xmlEscape(sec.title)}`; } for(const q of sec.questions){ const scoreInfo = pullLeadingScore(q.stem); let stemBody = scoreInfo.score ? scoreInfo.rest : q.stem; stemBody = (stemBody||'').trim(); const heading = scoreInfo.score ? `${q.no}. (${q.type} , ${scoreInfo.score}) ${stemBody}` : `${q.no}. (${q.type}) ${stemBody || q.stem}`; xml += `${xmlEscape(heading)}`; if(q.options && q.options.length){ for(let i=0;i0) ? op.key : String.fromCharCode(65 + (i % 26)); const text = op.text || ''; xml += `${xmlEscape(label + '. ' + text)}`; } } const correctOutput = q.correctAnswerInline ? stripLeadingLabels(q.correctAnswerInline) : ''; if(correctOutput){ xml += `${xmlEscape('正确答案:' + correctOutput)}`; } const mineOutput = q.myAnswerInline ? stripLeadingLabels(q.myAnswerInline) : ''; if(mineOutput){ xml += `${xmlEscape('我的答案:' + mineOutput)}`; } if(q.analysis){ xml += `${xmlEscape('答案解析:' + stripLeadingLabels(q.analysis))}`; } xml += ``; } } xml += `答案汇总`; const cols=8; const rows=Math.ceil(Math.max(questionsFlat.length,1)/cols); xml += ``; let idx=0; for(let r=0;r`; for(let c=0;c${xmlEscape(txt)}`; idx++; } xml += ``; } xml += ``; const analyses = questionsFlat.filter(q=> q.analysis && q.analysis.length > 1); if(analyses.length > 0){ xml += `答案解析汇总`; for(const q of analyses){ xml += `${xmlEscape('第' + q.no + '题:')}${xmlEscape(stripLeadingLabels(q.analysis))}`; xml += ``; } } xml += ``; xml += ``; return xml; } /* ---------- 打包导出 ---------- */ function strToUint8(s){ return new TextEncoder().encode(s); } function uint32ToLE(n){ return [n & 0xff, (n>>>8)&0xff, (n>>>16)&0xff, (n>>>24)&0xff]; } function uint16ToLE(n){ return [n & 0xff, (n>>>8)&0xff]; } function crc32(buf){ const table = crc32.table || (crc32.table=(function(){ const t=new Uint32Array(256); for(let n=0;n<256;n++){ let c=n; for(let k=0;k<8;k++){ c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1); } t[n]=c>>>0; } return t; })()); let crc=0^(-1); for(let i=0;i>>8) ^ table[(crc ^ buf[i]) & 0xff]; return (crc ^ (-1))>>>0; } function buildZip(files){ const localEntries=[]; let offset=0; for(const f of files){ const nameBuf=strToUint8(f.name); const data=f.data; const crc=crc32(data); const compSize=data.length; const uncompSize=data.length; const localHeader=[...uint32ToLE(0x04034b50), ...uint16ToLE(20), ...uint16ToLE(0), ...uint16ToLE(0), ...uint16ToLE(0), ...uint16ToLE(0), ...uint32ToLE(crc), ...uint32ToLE(compSize), ...uint32ToLE(uncompSize), ...uint16ToLE(nameBuf.length), ...uint16ToLE(0)]; const headerBuf=new Uint8Array(localHeader.length + nameBuf.length + data.length); headerBuf.set(new Uint8Array(localHeader),0); headerBuf.set(nameBuf, localHeader.length); headerBuf.set(data, localHeader.length + nameBuf.length); localEntries.push({ name: f.name, headerBuf, crc, compSize, uncompSize, offset }); offset += headerBuf.length; } const cdParts=[]; let cdSize=0; for(const e of localEntries){ const nameBuf=strToUint8(e.name); const cdr=[...uint32ToLE(0x02014b50), ...uint16ToLE(0x0314), ...uint16ToLE(20), ...uint16ToLE(0), ...uint16ToLE(0), ...uint16ToLE(0), ...uint16ToLE(0), ...uint32ToLE(e.crc), ...uint32ToLE(e.compSize), ...uint32ToLE(e.uncompSize), ...uint16ToLE(nameBuf.length), ...uint16ToLE(0), ...uint16ToLE(0), ...uint16ToLE(0), ...uint16ToLE(0), ...uint32ToLE(0), ...uint32ToLE(e.offset)]; const cdbuf=new Uint8Array(cdr.length + nameBuf.length); cdbuf.set(new Uint8Array(cdr),0); cdbuf.set(nameBuf, cdr.length); cdParts.push(cdbuf); cdSize += cdbuf.length; } const total = offset + cdSize + 22; const out=new Uint8Array(total); let pos=0; for(const e of localEntries){ out.set(e.headerBuf, pos); pos += e.headerBuf.length; } for(const c of cdParts){ out.set(c, pos); pos += c.length; } const eocd=[...uint32ToLE(0x06054b50), ...uint16ToLE(0), ...uint16ToLE(0), ...uint16ToLE(localEntries.length), ...uint16ToLE(localEntries.length), ...uint32ToLE(cdSize), ...uint32ToLE(offset), ...uint16ToLE(0)]; out.set(new Uint8Array(eocd), pos); return out; } function structuredPaperToText(paper){ // helper to remove counts/scores from a title for comparison function stripCountsAndScores(title){ if(!title) return ''; let t = title; // remove parenthetical groups like (共 20 题), (共 20 题,100分), (100分), (100分) t = t.replace(/(\s*共\s*\d+\s*题(?:[,,]\s*[0-9]+(?:\.[0-9]+)?\s*分)?\s*)/g, ''); t = t.replace(/\(\s*共\s*\d+\s*题(?:[,,]\s*[0-9]+(?:\.[0-9]+)?\s*分)?\s*\)/g, ''); t = t.replace(/(\s*[0-9]+(?:\.[0-9]+)?\s*分\s*)/g, ''); t = t.replace(/\(\s*[0-9]+(?:\.[0-9]+)?\s*分\s*\)/g, ''); // remove inline occurrences like "共 20 题" or "100 分" t = t.replace(/共\s*\d+\s*题/g, ''); t = t.replace(/[0-90-9]+(?:\.[0-9]+)?\s*分/g, ''); // normalize punctuation and whitespace t = t.replace(/[::]+/g, ' ').replace(/[\s ]+/g, ' ').trim(); return t; } const explicitRaw = paper.explicitSectionTitle || ''; const explicitClean = sanitizeSectionTitleRaw(explicitRaw); const pageTitleClean = sanitizeSectionTitleRaw(paper.title || ''); let out = `# ${paper.title}\n\n`; if(explicitClean && explicitClean !== pageTitleClean){ out += `## ${explicitClean}\n\n`; } // printedKeys stores normalized keys (without counts/scores) to avoid duplicates const printedKeys = new Set(); if(explicitClean) printedKeys.add(stripCountsAndScores(explicitClean)); for(const sec of paper.sections){ const secRaw = sec.title || ''; const secClean = sanitizeSectionTitleRaw(secRaw) || ''; if(!secClean) { // still print questions under blank-titled sections (no heading) } // compute normalized key without counts/scores const secKey = stripCountsAndScores(secClean); // decide whether to print this section title let shouldPrintTitle = true; if(!secClean) shouldPrintTitle = false; if(secKey && secKey === stripCountsAndScores(pageTitleClean)) shouldPrintTitle = false; // if any already-printed key includes this key (or vice-versa), treat as duplicate for(const k of printedKeys){ if(!k) continue; if(k === secKey || k.includes(secKey) || secKey.includes(k)){ shouldPrintTitle = false; break; } } if(shouldPrintTitle){ if(sec.title && !/未分组|题目|^$|^\d+[.、]/.test(sec.title)){ // determine whether secClean already carries a count/score fragment const hasCountOrScore = /(\s*共\s*\d+\s*题\s*)|共\s*\d+\s*题|[\d0-9]+\s*分/.test(secClean); const displayTitle = hasCountOrScore ? secClean : `${secClean}${sec.questions ? `(共 ${sec.questions.length} 题)` : ''}`; out += `## ${displayTitle}\n\n`; // store normalized key (without counts/scores) printedKeys.add(secKey || secClean); } } // print questions for(const q of sec.questions){ const scoreInfo = pullLeadingScore(q.stem); if(scoreInfo.score){ const rest = scoreInfo.rest || ''; out += `${q.no}. (${q.type} , ${scoreInfo.score}) ${rest}\n`; } else { out += `${q.no}. (${q.type}) ${q.stem}\n`; } if(q.options && q.options.length) for(let i=0;i0) ? op.key : String.fromCharCode(65 + (i % 26)); out += ` ${label}. ${op.text}\n`; } const rawCorrect = q.correctAnswerInline ? stripLeadingLabels(q.correctAnswerInline) : ''; const correctOutput = rawCorrect.replace(/^[::\s]+/, ''); if(correctOutput) out += `正确答案:${correctOutput}\n`; const rawMine = q.myAnswerInline ? stripLeadingLabels(q.myAnswerInline) : ''; const mineOutput = rawMine.replace(/^[::\s]+/, ''); if(mineOutput) out += `我的答案:${mineOutput}\n`; const rawAns = ''; const ansClean = rawAns.replace(/^[::\s]+/, ''); if(ansClean){ const answerLabel = q.answerSource === 'my' ? '我的答案' : '正确答案'; out += `${answerLabel}:${ansClean}\n`; } const rawAnal = q.analysis ? stripLeadingLabels(q.analysis) : ''; const analClean = rawAnal.replace(/^[::\s]+/, ''); if(analClean) out += `答案解析:${analClean}\n`; out += `\n`; } } return out; } function downloadBlob(blob, filename){ const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; document.body.appendChild(link); link.click(); setTimeout(() => { document.body.removeChild(link); URL.revokeObjectURL(link.href); }, 3000); } function exportTextRecord(record){ if(!record || !record.text) return; const blob = new Blob([`\uFEFF${record.text}`], { type:'text/plain;charset=utf-8' }); downloadBlob(blob, buildRecordFileName(record, 'txt')); } function exportPaperToDocx(paper, filename){ let n=1; for(const s of paper.sections) for(const q of s.questions) if(!q.no) q.no = n++; const docXml = buildDocumentXmlFromPaper(paper); const files=[ { name:'[Content_Types].xml', data: strToUint8(buildContentTypes()) }, { name:'_rels/.rels', data: strToUint8(buildRels()) }, { name:'word/document.xml', data: strToUint8(docXml) }, { name:'word/styles.xml', data: strToUint8(buildStyles()) } ]; const zipBuf = buildZip(files); const blob = new Blob([zipBuf], { type:'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); const safe = filename || `${String(paper.title || getPageTitle()).replace(/[\\\/:*?"<>|]+/g,'_')}.docx`; downloadBlob(blob, safe); } async function extractAndShow(isManual=false){ try{ const docs = collectAccessibleDocuments(document); await ensureCxSecretMapsForDocuments(docs, isManual); const paper = buildStructuredPaper(); const total = paper.sections.reduce((sum, sec) => sum + (sec.questions || []).length, 0); if(total === 0){ if(isManual) showNotification('\u672a\u627e\u5230\u9898\u76ee\uff0c\u8bf7\u786e\u8ba4\u9875\u9762\u5df2\u52a0\u8f7d\u5b8c\u6210\u6216\u5df2\u5207\u6362\u5230\u7b54\u9898/\u67e5\u770b\u9875\u9762\u3002', true); return; } const text = structuredPaperToText(paper); gm.setClipboard(text); showUI(text, paper, isManual); showNotification(`\u5df2\u63d0\u53d6 ${total} \u9053\u9898\uff0c\u7ed3\u679c\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f\u3002`); }catch(e){ console.error('extractAndShow failed', e); if(isManual) showNotification(`\u63d0\u53d6\u5931\u8d25\uff1a${e && e.message ? e.message : '\u672a\u77e5\u9519\u8bef'}`, true); } } /* ---------- UI ---------- */ let notificationElement = null; let uiContainer = null; let uiOverlay = null; let historyListElement = null; let previewTitleElement = null; let previewMetaElement = null; let previewScrollElement = null; let previewContentElement = null; let previewEmptyElement = null; const managerState = { history: getExtractionHistory(), selectedId: '', lastOpenedId: '' }; function showNotification(msg, isError=false){ if(!notificationElement){ notificationElement = document.createElement('div'); notificationElement.id = 'cx-notification-gm'; document.body.appendChild(notificationElement); } notificationElement.innerHTML = `${isError ? '⚠️' : '✅'} ${msg}`; notificationElement.className = isError ? 'error' : 'success'; notificationElement.style.display = 'flex'; notificationElement.style.animation = 'none'; notificationElement.offsetHeight; notificationElement.style.animation = 'cx-slide-in 0.3s forwards'; setTimeout(() => { notificationElement.style.animation = 'cx-slide-out 0.3s forwards'; setTimeout(() => { notificationElement.style.display = 'none'; }, 300); }, 2500); } function getSelectedRecord(){ return managerState.history.find(item => item && item.id === managerState.selectedId) || null; } function syncSelectedRecord(){ if(managerState.selectedId && managerState.history.some(item => item && item.id === managerState.selectedId)) return; managerState.selectedId = managerState.history[0] ? managerState.history[0].id : ''; } function renderHistoryList(){ if(!historyListElement) return; const selected = getSelectedRecord(); if(!managerState.history.length){ historyListElement.innerHTML = `
\u6682\u65e0\u63d0\u53d6\u8bb0\u5f55
`; return; } historyListElement.innerHTML = managerState.history.map(item => `
${htmlEscape(item.pageKind)}
${htmlEscape(item.title)}
${item.total} \u9898 \u00b7 ${htmlEscape(formatDateTime(item.createdAt))}
`).join(''); } function renderPreview(){ if(!previewTitleElement || !previewMetaElement || !previewContentElement || !previewEmptyElement) return; const record = getSelectedRecord(); const copyButton = document.getElementById('cx-copy'); const txtButton = document.getElementById('cx-export-txt'); const docxButton = document.getElementById('cx-export-docx'); const deleteButton = document.getElementById('cx-delete-record'); if(!record){ previewTitleElement.textContent = '\u63d0\u53d6\u7ba1\u7406\u9762\u677f'; previewMetaElement.innerHTML = `\u7b49\u5f85\u63d0\u53d6\u652f\u6301 TXT / DOCX / \u9884\u89c8`; previewContentElement.textContent = ''; previewEmptyElement.style.display = 'flex'; if(copyButton) copyButton.disabled = true; if(txtButton) txtButton.disabled = true; if(docxButton) docxButton.disabled = true; if(deleteButton) deleteButton.disabled = true; return; } previewTitleElement.textContent = record.title; previewMetaElement.innerHTML = [ `${htmlEscape(record.pageKind)}`, `${record.total} \u9898`, `${htmlEscape(formatDateTime(record.createdAt))}`, record.pageUrl ? `\u6253\u5f00\u6765\u6e90` : '' ].join(''); previewContentElement.textContent = record.text || ''; if(previewScrollElement) previewScrollElement.scrollTop = 0; previewEmptyElement.style.display = 'none'; if(copyButton) copyButton.disabled = !record.text; if(txtButton) txtButton.disabled = !record.text; if(docxButton) docxButton.disabled = !(record.paper && record.paper.sections); if(deleteButton) deleteButton.disabled = false; } function renderManager(){ syncSelectedRecord(); renderHistoryList(); renderPreview(); } function ensureManagerUI(){ if(uiContainer) return; uiOverlay = document.createElement('div'); uiOverlay.id = 'cx-ui-overlay'; document.body.appendChild(uiOverlay); uiContainer = document.createElement('div'); uiContainer.id = 'cx-extractor-ui'; uiContainer.innerHTML = `
当前预览

提取管理面板

📝
还没有可预览的提取结果
点击“重新提取当前页”或右侧悬浮按钮,将当前页面题目加入到管理列表。

          
`; document.body.appendChild(uiContainer); historyListElement = document.getElementById('cx-history-list'); previewTitleElement = document.getElementById('cx-preview-title'); previewMetaElement = document.getElementById('cx-preview-meta'); previewScrollElement = document.getElementById('cx-preview-scroll'); previewContentElement = document.getElementById('cx-preview-content'); previewEmptyElement = document.getElementById('cx-preview-empty'); document.getElementById('cx-close-icon').onclick = closeUI; document.getElementById('cx-refresh-page').onclick = () => { extractAndShow(true).catch(()=>{}); }; document.getElementById('cx-copy').onclick = () => { const record = getSelectedRecord(); if(!record || !record.text) return; gm.setClipboard(record.text); showNotification('已复制选中记录'); }; document.getElementById('cx-export-txt').onclick = () => { const record = getSelectedRecord(); if(!record) return; exportTextRecord(record); showNotification('已导出 TXT'); }; document.getElementById('cx-export-docx').onclick = () => { const record = getSelectedRecord(); if(!record || !record.paper) return; exportPaperToDocx(record.paper, buildRecordFileName(record, 'docx')); showNotification('已导出 DOCX'); }; document.getElementById('cx-delete-record').onclick = () => { const record = getSelectedRecord(); if(!record) return; managerState.history = deleteExtractionRecord(record.id); managerState.selectedId = managerState.history[0] ? managerState.history[0].id : ''; renderManager(); showNotification('已删除记录'); }; document.getElementById('cx-clear-history').onclick = () => { if(!managerState.history.length) return; if(!window.confirm('确定清空所有提取记录?')) return; managerState.history = clearExtractionHistory(); managerState.selectedId = ''; renderManager(); showNotification('已清空提取记录'); }; historyListElement.addEventListener('click', event => { const deleteTrigger = event.target.closest('[data-delete-id]'); if(deleteTrigger){ event.stopPropagation(); const recordId = deleteTrigger.getAttribute('data-delete-id') || ''; managerState.history = deleteExtractionRecord(recordId); if(managerState.selectedId === recordId){ managerState.selectedId = managerState.history[0] ? managerState.history[0].id : ''; } renderManager(); showNotification('已删除记录'); return; } const item = event.target.closest('[data-record-id]'); if(!item) return; managerState.selectedId = item.getAttribute('data-record-id') || ''; renderManager(); }); } function openManager(){ ensureManagerUI(); managerState.history = getExtractionHistory(); renderManager(); uiOverlay.style.display = 'block'; const floatBtn = document.getElementById('cx-float-btn'); if(floatBtn) floatBtn.classList.add('panel-open'); requestAnimationFrame(() => { uiContainer.classList.add('active'); }); } function showUI(text, paper, shouldOpen=true){ const record = createExtractionRecord(paper, text); managerState.history = upsertExtractionRecord(record); managerState.selectedId = record.id; managerState.lastOpenedId = record.id; ensureManagerUI(); renderManager(); if(shouldOpen) openManager(); } function closeUI(){ if(!uiContainer) return; uiContainer.classList.remove('active'); const floatBtn = document.getElementById('cx-float-btn'); if(floatBtn) floatBtn.classList.remove('panel-open'); setTimeout(() => { if(!uiContainer.classList.contains('active')) uiOverlay.style.display = 'none'; }, 260); } function createFloatingButton(){ if(document.getElementById('cx-float-btn')) return; const btn = document.createElement('button'); btn.id = 'cx-float-btn'; btn.innerHTML = `📝提取`; btn.title = '提取题目并展开管理面板'; btn.onclick = () => { if(uiContainer && uiContainer.classList.contains('active')){ closeUI(); return; } extractAndShow(true).catch(()=>{}); }; document.body.appendChild(btn); } function toggleAuto(){ const enabled = !gm.getValue(AUTO_EXTRACT_KEY, false); gm.setValue(AUTO_EXTRACT_KEY, enabled); alert(`\u81ea\u52a8\u63d0\u53d6\u5df2${enabled ? '\u5f00\u542f' : '\u5173\u95ed'}\uff0c\u5237\u65b0\u9875\u9762\u751f\u6548\u3002`); updateMenu(); } function updateMenu(){ const enabled = gm.getValue(AUTO_EXTRACT_KEY, false); try{ gm.registerMenuCommand(`${enabled ? '\u2705' : '\u274c'} \u81ea\u52a8\u63d0\u53d6\u5f00\u5173`, toggleAuto); gm.registerMenuCommand('\ud83d\udd90 \u624b\u52a8\u63d0\u53d6\u9898\u76ee', () => { extractAndShow(true).catch(()=>{}); }); gm.registerMenuCommand('\ud83d\udcc2 \u6253\u5f00\u63d0\u53d6\u7ba1\u7406', () => { openManager(); }); }catch(e){ console.warn('GM_registerMenuCommand unavailable', e); } } gm.addStyle(` #cx-ui-overlay { position: fixed; inset: 0; background: transparent; z-index: 99997; display: none; pointer-events: none; } #cx-extractor-ui { position: fixed; top: 20px; right: 20px; width: min(680px, calc(100vw - 28px)); height: min(78vh, calc(100vh - 32px)); background: #ffffff; border: 1px solid #e2e8f0; border-radius: 20px; overflow: hidden; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12); z-index: 99998; display: grid; grid-template-columns: 208px minmax(0, 1fr); opacity: 0; pointer-events: none; transform-origin: calc(100% - 40px) 180px; transform: translateX(18px) scale(0.985); transition: transform 0.2s ease, opacity 0.2s ease; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } #cx-extractor-ui.active { opacity: 1; pointer-events: auto; transform: translateX(0) scale(1); } .cx-sidebar { display: flex; flex-direction: column; min-height: 0; padding: 18px 14px 14px; background: #f8fafc; border-right: 1px solid #e8edf3; color: #233246; } .cx-sidebar-top { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } .cx-brand-mark { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: #eef4ff; border: 1px solid #d9e4fb; font-size: 20px; font-weight: 800; color: #1d4ed8; } .cx-brand-eyebrow { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #7a889c; } .cx-brand-title { font-size: 22px; line-height: 1.1; font-weight: 800; color: #16263a; margin-top: 2px; } .cx-brand-sub { font-size: 12px; color: #7c8a9f; margin-top: 6px; } .cx-sidebar-actions { display: grid; gap: 8px; margin-bottom: 16px; } .cx-history-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: #66768c; margin-bottom: 8px; } .cx-history-tip { font-size: 12px; color: #97a4b6; } .cx-history-list { flex: 1; min-height: 0; overflow: auto; padding-right: 2px; } .cx-history-list::-webkit-scrollbar, .cx-preview-scroll::-webkit-scrollbar { width: 8px; height: 8px; } .cx-history-list::-webkit-scrollbar-thumb, .cx-preview-scroll::-webkit-scrollbar-thumb { background: rgba(135, 148, 166, 0.42); border-radius: 999px; } .cx-history-item { padding: 12px; border-radius: 16px; background: #ffffff; border: 1px solid #e5eaf0; cursor: pointer; transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; margin-bottom: 8px; } .cx-history-item:hover { transform: translateY(-1px); border-color: #cfd8e3; box-shadow: 0 8px 18px rgba(15, 23, 42, 0.05); } .cx-history-item.active { border-color: #9dbcf2; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08); } .cx-history-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; } .cx-history-kind { display: inline-flex; align-items: center; justify-content: center; min-height: 22px; padding: 0 9px; border-radius: 999px; background: #edf2f7; color: #4e6179; font-size: 12px; } .cx-history-delete { width: 24px; height: 24px; border: none; border-radius: 50%; background: #f1f4f8; color: #7b8898; cursor: pointer; } .cx-history-title { font-size: 14px; line-height: 1.45; font-weight: 700; color: #1b2b3d; } .cx-history-sub { margin-top: 6px; font-size: 12px; color: #7f8ca0; } .cx-history-empty { min-height: 100%; display: flex; align-items: center; justify-content: center; text-align: center; padding: 30px 12px; border: 1px dashed #d8e0ea; border-radius: 16px; color: #90a0b4; background: #fbfcfd; } .cx-workspace { display: grid; grid-template-rows: auto auto minmax(0, 1fr); min-width: 0; min-height: 0; overflow: hidden; padding: 18px; background: #ffffff; } .cx-workspace-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } .cx-panel-eyebrow { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8897aa; } .cx-panel-title { margin: 6px 0 0; font-size: clamp(24px, 1.8vw, 30px); line-height: 1.12; font-weight: 800; color: #152638; } .cx-preview-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .cx-meta-pill, .cx-meta-link { display: inline-flex; align-items: center; min-height: 30px; padding: 0 12px; border-radius: 999px; background: #f1f4f8; color: #506276; font-size: 12px; font-weight: 600; text-decoration: none; border: 1px solid #e3e8ef; } .cx-meta-pill.muted { color: #73839a; } .cx-meta-link { background: #ffffff; color: #1f4b7a; } .cx-icon-btn { width: 38px; height: 38px; border-radius: 12px; border: 1px solid #e1e7ef; background: #ffffff; color: #4f6175; font-size: 24px; cursor: pointer; transition: background 0.16s ease, border-color 0.16s ease; } .cx-icon-btn:hover { background: #f7f9fb; border-color: #ced6e2; } .cx-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; } .cx-btn { min-height: 40px; padding: 0 14px; border: 1px solid #dbe3ec; border-radius: 12px; font-size: 13px; font-weight: 700; cursor: pointer; transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease, opacity 0.16s ease; display: inline-flex; align-items: center; justify-content: center; gap: 8px; } .cx-btn:active { transform: scale(0.99); } .cx-btn:disabled { opacity: 0.46; cursor: not-allowed; } .cx-btn-primary { background: #1d4ed8; border-color: #1d4ed8; color: #fff; } .cx-btn-success { background: #eff6ff; border-color: #c7dbff; color: #1d4ed8; } .cx-btn-secondary { background: #f7f9fb; color: #516377; } .cx-btn-ghost { background: #ffffff; color: #31465c; } .cx-preview-stage { position: relative; min-height: 0; height: 100%; overflow: hidden; border-radius: 18px; background: #fbfcfd; border: 1px solid #e4e9ef; display: flex; flex-direction: column; } .cx-preview-scroll { flex: 1; min-height: 0; height: 100%; overflow: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; } .cx-preview-content { margin: 0; padding: 22px; white-space: pre-wrap; word-break: break-word; font-family: "Cascadia Code", "Consolas", monospace; font-size: 13px; line-height: 1.72; color: #26384c; min-height: auto; box-sizing: border-box; } .cx-preview-empty { position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center; gap: 10px; text-align: center; padding: 24px; background: rgba(251,252,253,0.96); pointer-events: none; } .cx-preview-empty-mark { font-size: 40px; } .cx-preview-empty-title { font-size: 17px; font-weight: 800; color: #203245; } .cx-preview-empty-sub { max-width: 380px; font-size: 13px; line-height: 1.7; color: #7a889a; } #cx-float-btn { position: fixed; top: 180px; right: 20px; z-index: 99999; min-width: 68px; height: 46px; padding: 0 15px; border-radius: 999px; background: #1d4ed8; color: #fff; border: none; box-shadow: 0 8px 18px rgba(29, 78, 216, 0.2); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px; transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; } #cx-float-btn:hover { transform: translateY(-1px); box-shadow: 0 12px 22px rgba(29, 78, 216, 0.22); background: #1e40af; } #cx-float-btn.panel-open { opacity: 0; transform: translateX(12px) scale(0.96); pointer-events: none; } .cx-float-icon { font-size: 22px; line-height: 1; } .cx-float-label { font-size: 13px; font-weight: 800; letter-spacing: 0.02em; } #cx-notification-gm { position: fixed; top: 24px; left: 50%; transform: translateX(-50%); padding: 10px 18px; border-radius: 999px; display: flex; align-items: center; gap: 8px; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12); z-index: 100000; font-family: sans-serif; font-size: 13px; font-weight: 700; pointer-events: none; background: #fff; color: #23354c; border: 1px solid #e1e7ef; } #cx-notification-gm.success span { color: #52c41a; } #cx-notification-gm.error span { color: #ff4d4f; } @keyframes cx-slide-in { from { transform: translate(-50%, -20px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } } @keyframes cx-slide-out { from { transform: translate(-50%, 0); opacity: 1; } to { transform: translate(-50%, -20px); opacity: 0; } } @media (max-width: 980px) { #cx-extractor-ui { width: min(92vw, 640px); grid-template-columns: 1fr; grid-template-rows: 220px minmax(0, 1fr); } .cx-sidebar { padding-bottom: 14px; } .cx-workspace { padding: 18px; } } @media (max-width: 640px) { #cx-extractor-ui { top: 12px; right: 12px; width: calc(100vw - 24px); height: calc(100vh - 24px); border-radius: 18px; transform-origin: right bottom; } .cx-sidebar { padding: 16px 14px 12px; } .cx-workspace { padding: 14px; } .cx-panel-title { font-size: 22px; } .cx-toolbar { gap: 10px; } .cx-btn { width: calc(50% - 5px); } .cx-sidebar-actions .cx-btn { width: 100%; } #cx-float-btn { top: auto; bottom: 18px; right: 14px; height: 48px; min-width: 66px; padding: 0 14px; } } `); updateMenu(); createFloatingButton(); window.addEventListener('load', ()=> { if(gm.getValue(AUTO_EXTRACT_KEY, false)) setTimeout(() => { extractAndShow(false).catch(()=>{}); }, 1500); }); })();