// ==UserScript== // @name 超星题目提取器 // @namespace https://chaoxing.com/ // @version 5.1.2 // @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 PANEL_SIZE_STORAGE_KEY = 'chaoxing_extract_panel_size_v1'; const PANEL_POSITION_STORAGE_KEY = 'chaoxing_extract_panel_position_v1'; 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_REFERENCE_RENDER_BATCH = 96; const CXSECRET_MATCH_BATCH = 192; const CXSECRET_YIELD_DELAY = 0; const CXSECRET_PROGRESS_THROTTLE_MS = 120; const CXSECRET_MAX_COARSE_INK_DELTA = 72; const CXSECRET_MAX_COARSE_INK_RATIO = 0.28; const CXSECRET_MAX_COARSE_ASPECT_DELTA = 0.6; 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 cxSecretFontCodePointCache = new Map(); const cxSecretLoadedFontFamilies = new Map(); const cxSecretRenderCache = new Map(); const cxSecretResizeSurfaceCache = 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 getSfntTag(view, offset){ if(offset + 4 > view.byteLength) return ''; return String.fromCharCode( view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3) ); } function parseCmapFormat4CodePoints(view, offset, out){ if(offset + 14 > view.byteLength) return; const length = view.getUint16(offset + 2); if(length < 24 || offset + length > view.byteLength) return; const segCount = view.getUint16(offset + 6) / 2; if(!segCount) return; const endCodeOffset = offset + 14; const startCodeOffset = endCodeOffset + (segCount * 2) + 2; const idDeltaOffset = startCodeOffset + (segCount * 2); const idRangeOffsetOffset = idDeltaOffset + (segCount * 2); if(idRangeOffsetOffset + (segCount * 2) > offset + length) return; for(let i = 0; i < segCount; i++){ const endCode = view.getUint16(endCodeOffset + (i * 2)); const startCode = view.getUint16(startCodeOffset + (i * 2)); const idDelta = view.getInt16(idDeltaOffset + (i * 2)); const idRangeOffset = view.getUint16(idRangeOffsetOffset + (i * 2)); if(endCode === 0xFFFF && startCode === 0xFFFF) continue; for(let cp = startCode; cp <= endCode; cp++){ let glyphId = 0; if(idRangeOffset === 0){ glyphId = (cp + idDelta) & 0xFFFF; }else{ const glyphOffset = idRangeOffsetOffset + (i * 2) + idRangeOffset + ((cp - startCode) * 2); if(glyphOffset + 2 > offset + length) continue; glyphId = view.getUint16(glyphOffset); if(glyphId !== 0) glyphId = (glyphId + idDelta) & 0xFFFF; } if(glyphId !== 0) out.add(cp); } } } function parseCmapFormat6CodePoints(view, offset, out){ if(offset + 10 > view.byteLength) return; const length = view.getUint16(offset + 2); if(length < 10 || offset + length > view.byteLength) return; const firstCode = view.getUint16(offset + 6); const entryCount = view.getUint16(offset + 8); for(let i = 0; i < entryCount; i++){ const glyphIdOffset = offset + 10 + (i * 2); if(glyphIdOffset + 2 > offset + length) break; const glyphId = view.getUint16(glyphIdOffset); if(glyphId !== 0) out.add(firstCode + i); } } function parseCmapFormat12CodePoints(view, offset, out){ if(offset + 16 > view.byteLength) return; const length = view.getUint32(offset + 4); if(length < 16 || offset + length > view.byteLength) return; const nGroups = view.getUint32(offset + 12); for(let i = 0; i < nGroups; i++){ const groupOffset = offset + 16 + (i * 12); if(groupOffset + 12 > offset + length) break; const startCharCode = view.getUint32(groupOffset); const endCharCode = view.getUint32(groupOffset + 4); for(let cp = startCharCode; cp <= endCharCode; cp++) out.add(cp); } } function parseCxSecretFontCodePoints(buffer){ try{ const view = new DataView(buffer); const signature = getSfntTag(view, 0); if(signature === 'wOFF' || signature === 'wOF2') return null; const numTables = view.getUint16(4); let cmapOffset = 0; let cmapLength = 0; for(let i = 0; i < numTables; i++){ const tableOffset = 12 + (i * 16); if(tableOffset + 16 > view.byteLength) break; const tag = getSfntTag(view, tableOffset); const offset = view.getUint32(tableOffset + 8); const length = view.getUint32(tableOffset + 12); if(tag === 'cmap'){ cmapOffset = offset; cmapLength = length; break; } } if(!cmapOffset || cmapOffset + Math.max(4, cmapLength) > view.byteLength) return null; const numSubtables = view.getUint16(cmapOffset + 2); const out = new Set(); for(let i = 0; i < numSubtables; i++){ const recordOffset = cmapOffset + 4 + (i * 8); if(recordOffset + 8 > view.byteLength) break; const subtableOffset = cmapOffset + view.getUint32(recordOffset + 4); if(subtableOffset + 2 > view.byteLength) continue; const format = view.getUint16(subtableOffset); if(format === 4){ parseCmapFormat4CodePoints(view, subtableOffset, out); }else if(format === 6){ parseCmapFormat6CodePoints(view, subtableOffset, out); }else if(format === 12 || format === 13){ parseCmapFormat12CodePoints(view, subtableOffset, out); } } return out.size ? out : null; }catch(e){ return null; } } function getCxSecretFontCodePoints(base64){ if(!base64) return null; const fontHash = hashDjb2(base64); if(cxSecretFontCodePointCache.has(fontHash)) return cxSecretFontCodePointCache.get(fontHash) || null; const parsed = parseCxSecretFontCodePoints(base64ToArrayBuffer(base64)); cxSecretFontCodePointCache.set(fontHash, parsed || null); return parsed || null; } 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 getCxSecretResizeSurface(outSize){ if(cxSecretResizeSurfaceCache.has(outSize)) return cxSecretResizeSurfaceCache.get(outSize); const canvas = document.createElement('canvas'); canvas.width = outSize; canvas.height = outSize; const ctx = canvas.getContext('2d', { willReadFrequently: true }); const surface = { canvas, ctx }; cxSecretResizeSurfaceCache.set(outSize, surface); return surface; } function cxSecretYieldToMainThread(){ return new Promise(resolve => setTimeout(resolve, CXSECRET_YIELD_DELAY)); } function formatCxSecretPercent(current, total){ if(!total) return '0%'; const safeCurrent = Math.max(0, Math.min(total, current)); return `${Math.round((safeCurrent / total) * 100)}%`; } function getCxSecretDocLabel(docIndex, totalDocs){ return totalDocs > 1 ? `\u6587\u6863 ${docIndex}/${totalDocs}` : '\u5f53\u524d\u9875\u9762'; } function reportCxSecretProgress(progressReporter, message, label){ if(typeof progressReporter !== 'function' || !message) return; progressReporter({ message, label: label || '\u89e3\u6790\u4e2d' }); } function buildCxSecretReferenceProgress(current, total){ return `\u6b63\u5728\u5efa\u7acb\u6df7\u6dc6\u5b57\u4f53\u53c2\u8003\u5b57\u5f62 ${current}/${total}\uff08${formatCxSecretPercent(current, total)}\uff09`; } function buildCxSecretPrepareProgress(docLabel, current, total){ return `${docLabel}\uff1a\u5df2\u51c6\u5907\u89e3\u6790 ${current}/${total} \u4e2a\u5f85\u5339\u914d\u5b57\u7b26`; } function buildCxSecretMatchProgress(docLabel, charIndex, totalChars, candidateCurrent, candidateTotal){ return `${docLabel}\uff1a\u6b63\u5728\u5339\u914d\u7b2c ${charIndex}/${totalChars} \u4e2a\u5b57\u7b26\uff0c\u5019\u9009 ${candidateCurrent}/${candidateTotal}\uff08${formatCxSecretPercent(candidateCurrent, candidateTotal)}\uff09`; } function getCxSecretBitDistance(a, b){ let count = 0; const length = Math.min(a.length, b.length); for(let i = 0; i < length; i++){ count += BIT_COUNTS[a[i] ^ b[i]]; } return count + (Math.abs(a.length - b.length) * 8); } 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 base64 = findCxSecretFontBase64(doc); const supportedCodePoints = getCxSecretFontCodePoints(base64); if(supportedCodePoints && supportedCodePoints.size){ const chars = new Set(); const fontUsageCache = new WeakMap(); const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while(node){ const parent = node.parentElement; let usesCxSecretFont = false; if(parent){ if(fontUsageCache.has(parent)){ usesCxSecretFont = fontUsageCache.get(parent); }else{ try{ usesCxSecretFont = String(getComputedStyle(parent).fontFamily || '').toLowerCase().includes('font-cxsecret'); }catch(e){} fontUsageCache.set(parent, usesCxSecretFont); } } if(usesCxSecretFont){ const text = node.nodeValue || ''; for(let i = 0; i < text.length;){ const cp = text.codePointAt(i); const ch = String.fromCodePoint(cp); if(supportedCodePoints.has(cp)) chars.add(ch); i += ch.length; } } node = walker.nextNode(); } if(chars.size) return Array.from(chars); } 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 sourceWidth = maxX - minX + 1; const sourceHeight = maxY - minY + 1; const { canvas: tmpCanvas, ctx: tmpCtx } = getCxSecretResizeSurface(outSize); tmpCtx.fillStyle = '#fff'; tmpCtx.fillRect(0, 0, outSize, outSize); tmpCtx.drawImage(canvas, minX, minY, sourceWidth, sourceHeight, 0, 0, outSize, outSize); const out = tmpCtx.getImageData(0, 0, outSize, outSize).data; const gray = new Uint8Array(outSize * outSize); const bits = new Uint8Array(Math.ceil(gray.length / 8)); let grayIndex = 0; let bitIndex = 0; let bitMask = 0x80; let inkCount = 0; for(let i = 0; i < out.length; i += 4){ const value = out[i]; gray[grayIndex++] = value; if(value < threshold){ bits[bitIndex] |= bitMask; inkCount++; } if(bitMask === 1){ bitMask = 0x80; bitIndex++; }else{ bitMask >>= 1; } } const result = { gray, bits, inkCount, aspectRatio: sourceWidth / Math.max(1, sourceHeight) }; 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(progressReporter){ if(cxSecretReferenceCandidatesPromise){ reportCxSecretProgress(progressReporter, '\u6b63\u5728\u590d\u7528\u5df2\u7f13\u5b58\u7684\u53c2\u8003\u5b57\u5f62\u2026'); return cxSecretReferenceCandidatesPromise; } cxSecretReferenceCandidatesPromise = (async () => { const buffer = await requestArrayBuffer(CXSECRET_REFERENCE_FONT_URLS); await loadCxSecretFontFamily(CXSECRET_REFERENCE_FONT_FAMILY, buffer); const candidates = []; let batchCount = 0; const ranges = [ [0x3000, 0x303f], [0xff00, 0xffef], [0x4e00, 0x9fff] ]; const totalGlyphs = ranges.reduce((sum, [start, end]) => sum + (end - start + 1), 0); reportCxSecretProgress(progressReporter, buildCxSecretReferenceProgress(0, totalGlyphs)); for(const [start, end] of ranges){ for(let cp = start; cp <= end; cp++){ const rendered = renderCxSecretGlyph(String.fromCharCode(cp), CXSECRET_REFERENCE_FONT_FAMILY, CXSECRET_COARSE_RENDER_SIZE, CXSECRET_COARSE_THRESHOLD); if(rendered){ candidates.push({ cp, bits: rendered.bits, inkCount: rendered.inkCount, aspectRatio: rendered.aspectRatio }); } batchCount++; if(batchCount % CXSECRET_REFERENCE_RENDER_BATCH === 0){ reportCxSecretProgress(progressReporter, buildCxSecretReferenceProgress(batchCount, totalGlyphs)); await cxSecretYieldToMainThread(); } } } reportCxSecretProgress(progressReporter, buildCxSecretReferenceProgress(totalGlyphs, totalGlyphs)); return candidates; })(); try{ return await cxSecretReferenceCandidatesPromise; }catch(e){ cxSecretReferenceCandidatesPromise = null; throw e; } } async function collectCxSecretTopCandidates(coarse, candidates, progressReporter, progressMeta){ const candidateTotal = candidates.length; const makeTopCandidates = () => Array.from({ length: CXSECRET_TOP_CANDIDATES }, () => [Number.POSITIVE_INFINITY, 0]); const maxInkDelta = Math.max(CXSECRET_MAX_COARSE_INK_DELTA, Math.round(coarse.inkCount * CXSECRET_MAX_COARSE_INK_RATIO)); const scanCandidates = async (usePruning) => { const topCandidates = makeTopCandidates(); let compared = 0; for(let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++){ const candidate = candidates[candidateIndex]; if(usePruning){ if(Math.abs(candidate.inkCount - coarse.inkCount) > maxInkDelta) continue; if(Math.abs(candidate.aspectRatio - coarse.aspectRatio) > CXSECRET_MAX_COARSE_ASPECT_DELTA) continue; } compared++; const distance = getCxSecretBitDistance(coarse.bits, candidate.bits); if(distance < topCandidates[topCandidates.length - 1][0]){ topCandidates[topCandidates.length - 1] = [distance, candidate.cp]; topCandidates.sort((a, b) => a[0] - b[0]); } if((candidateIndex + 1) % CXSECRET_MATCH_BATCH === 0){ reportCxSecretProgress( progressReporter, buildCxSecretMatchProgress(progressMeta.docLabel, progressMeta.charIndex, progressMeta.totalChars, candidateIndex + 1, candidateTotal) ); await cxSecretYieldToMainThread(); } } reportCxSecretProgress( progressReporter, buildCxSecretMatchProgress(progressMeta.docLabel, progressMeta.charIndex, progressMeta.totalChars, candidateTotal, candidateTotal) ); return { topCandidates, compared }; }; const pruned = await scanCandidates(true); if(pruned.compared > 0) return pruned.topCandidates; return (await scanCandidates(false)).topCandidates; } async function buildCxSecretCharMap(base64, fontHash, observedChars, progressReporter, progressMeta){ if(!base64 || !observedChars || observedChars.length === 0) return null; const observedFamily = `__cx_obs_${fontHash}__`; await loadCxSecretFontFamily(observedFamily, base64ToArrayBuffer(base64)); const docLabel = progressMeta && progressMeta.docLabel ? progressMeta.docLabel : '\u5f53\u524d\u9875\u9762'; reportCxSecretProgress(progressReporter, buildCxSecretPrepareProgress(docLabel, 0, observedChars.length)); const candidates = await getCxSecretReferenceCandidates(progressReporter); const map = {}; for(let charIndex = 0; charIndex < observedChars.length; charIndex++){ const fakeChar = observedChars[charIndex]; const coarse = renderCxSecretGlyph(fakeChar, observedFamily, CXSECRET_COARSE_RENDER_SIZE, CXSECRET_COARSE_THRESHOLD); if(!coarse) continue; reportCxSecretProgress( progressReporter, buildCxSecretMatchProgress(docLabel, charIndex + 1, observedChars.length, 0, candidates.length) ); const topCandidates = await collectCxSecretTopCandidates( coarse, candidates, progressReporter, { docLabel, charIndex: charIndex + 1, totalChars: observedChars.length } ); 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); reportCxSecretProgress(progressReporter, buildCxSecretPrepareProgress(docLabel, charIndex + 1, observedChars.length)); if((charIndex + 1) % 2 === 0) await cxSecretYieldToMainThread(); } 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, options={}){ 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 = options.base64 || findCxSecretFontBase64(doc); if(!base64){ cxSecretDecoderCache.set(doc, null); return null; } const fontHash = options.fontHash || hashDjb2(base64); const known = options.known || getStoredCxSecretMap(fontHash) || CXSECRET_FONT_MAPS[fontHash] || null; const observedChars = Array.isArray(options.observedChars) ? options.observedChars : collectObservedCxSecretChars(doc); const missingChars = Array.isArray(options.missingChars) ? options.missingChars : observedChars.filter(ch => !known || !known[ch]); if(missingChars.length === 0){ cxSecretDecoderCache.set(doc, known); return known; } const built = await buildCxSecretCharMap(base64, fontHash, missingChars, options.progressReporter, { docLabel: getCxSecretDocLabel(options.docIndex || 1, options.totalDocs || 1) }); 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, progressReporter=null){ const targets = []; 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; const missingChars = observedChars.filter(ch => !known || !known[ch]); if(missingChars.length === 0) continue; targets.push({ doc, base64, fontHash, known, observedChars, missingChars }); } if(targets.length === 0) return; const totalDocs = targets.length; const totalChars = targets.reduce((sum, target) => sum + target.missingChars.length, 0); if(isManual){ reportCxSecretProgress( progressReporter, `\u6b63\u5728\u89e3\u6790\u6df7\u6dc6\u5b57\u4f53\uff0c\u5171 ${totalDocs} \u4e2a\u6587\u6863\uff0c${totalChars} \u4e2a\u5f85\u5339\u914d\u5b57\u7b26` ); await cxSecretYieldToMainThread(); } for(let index = 0; index < targets.length; index++){ const target = targets[index]; await ensureCxSecretCharMap(target.doc, { base64: target.base64, fontHash: target.fontHash, known: target.known, observedChars: target.observedChars, missingChars: target.missingChars, progressReporter, docIndex: index + 1, totalDocs }); } } function decodeCxSecretText(text, doc=document){ if(!text) return ''; const map = getCxSecretCharMap(doc); if(!map) return String(text); let output = ''; const source = String(text); for(let i = 0; i < source.length;){ const cp = source.codePointAt(i); const ch = String.fromCodePoint(cp); output += map[ch] || ch; i += ch.length; } return output; } 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 findExtendedOptionContainers(qNode){ if(!qNode || !qNode.querySelectorAll) return []; const found = []; const seen = new Set(); const push = el => { if(!el || seen.has(el)) return; seen.add(el); found.push(el); }; findOptionContainers(qNode).forEach(push); [ '.stem_answer', '.answerList', '.answer-list', '.radioList', '.checkList', '.checkboxList', '.questionOptions', '.question-options', '.topicOption', '.topic-option', '.optionList', '.answerBg', '.answer_p' ].forEach(selector => { qNode.querySelectorAll(selector).forEach(push); }); Array.from(qNode.querySelectorAll('ul,ol,div')).forEach(el => { const className = typeof el.className === 'string' ? el.className : ''; const hasLikelyItems = !!el.querySelector('li, .answerBg, .answer_p, .after, .option-item, .optionItem'); if( (className && /(?:ul|options|Cy_ulTop|Zy_ulTop|mark_letter|stem_answer|answerList|answer-list|radioList|checkList|checkboxList|optionList|question-options|questionOptions|topicOption|topic-option|answerBg|answer_p)/i.test(className)) || hasLikelyItems ){ push(el); } }); return found; } function getOptionItemNodes(container){ if(!container) return []; const items = []; const seen = new Set(); const push = el => { if(!el || seen.has(el)) return; seen.add(el); items.push(el); }; Array.from(container.children || []).forEach(el => { const className = typeof el.className === 'string' ? el.className : ''; if(/^(LI|LABEL)$/.test(el.tagName)) return push(el); if( /(?:answerBg|answer_p|after|option-item|optionItem|radioItem|checkItem|choice-item|option)/i.test(className) || !!el.querySelector('.answer_p, .after, .option-content, .mark_letter_span, .num_option, .num_option_dx, .before') ){ push(el); } }); if(items.length === 0) Array.from(container.querySelectorAll('li')).forEach(push); if(items.length === 0) Array.from(container.querySelectorAll('.answerBg, .answer_p, .after, .option-item, .optionItem')).forEach(push); return items; } function extractOptionsFromContainer(container, currentDoc){ const items = getOptionItemNodes(container); const options = []; const seen = new Set(); items.forEach((item, idx) => { const visibleLabel = normalizeText( item.querySelector('.mark_letter_span')?.innerText || item.querySelector('.num_option_dx, .num_option')?.innerText || item.querySelector('.fl.before, .before, i.fl')?.innerText || '' ).replace(/[^A-Za-z0-9]/g, '').trim().toUpperCase(); const dataLabel = normalizeText( item.querySelector('.num_option_dx, .num_option')?.getAttribute('data') || item.querySelector('[data-option]')?.getAttribute('data-option') || item.querySelector('[data-choice]')?.getAttribute('data-choice') || item.getAttribute('data-option') || item.getAttribute('data-choice') || item.getAttribute('option') || item.getAttribute('choice') || '' ).replace(/[^A-Za-z0-9]/g, '').trim().toUpperCase(); const ariaLabel = normalizeText(item.getAttribute('aria-label') || ''); const ariaMatch = ariaLabel.match(/^([A-Da-d])(?:[\.\s]|$)/); let label = visibleLabel || dataLabel || (ariaMatch ? ariaMatch[1].toUpperCase() : ''); const preferredTextNode = item.querySelector('.answer_p, .after, .option-content, .content, .label, .optionCnt, .option-cont, .ans-attach-ct'); let text = preferredTextNode ? getCleanText(preferredTextNode) : ''; const clone = item.cloneNode(true); clone.querySelectorAll('.mark_letter_span, .num_option_dx, .num_option, .fl.before, .before, i.fl, input, .option-check, .check, .score, .ans-checkbox, .ans-radio').forEach(el => el.remove()); if(!text) text = decodeCxSecretText(normalizeText(clone.textContent || clone.innerText || ''), currentDoc); const leadingMatch = text.match(/^[\(\[]?\s*([A-Da-d])[\.\s\u3001\)\]]+/); if(!label && leadingMatch) label = leadingMatch[1].toUpperCase(); Array.from(new Set([visibleLabel, label, leadingMatch ? leadingMatch[1].toUpperCase() : ''].filter(Boolean))).forEach(prefix => { const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); text = text.replace(new RegExp('^[\\(\\[]?\\s*' + escaped + '[\\.\\s\\u3001\\)\\]]+', 'i'), '').trim(); }); text = normalizeText(text); if(!label) label = String.fromCharCode(65 + (idx % 26)); if(!text || text === label || text.length < 2 || text === '鏌ョ湅瑙f瀽') return; const fingerprint = `${label}::${text}`; if(seen.has(fingerprint)) return; seen.add(fingerprint); options.push({ key: label, text }); }); return options; } function collectOptionScopeCandidates(node, root){ const scopes = []; const seen = new Set(); let current = node; let depth = 0; while(current && current.nodeType === 1){ if(!seen.has(current)){ seen.add(current); scopes.push(current); } if(current === root || depth >= 6) break; const parent = current.parentElement; if(!parent) break; const nestedQuestionCount = parent.querySelectorAll('.questionLi, .TiMu, .question-item, .mark_question, .exam-question, .paper-question, .Ques, .questionBox, .marBom60, .singleQuesId').length; if(depth > 0 && nestedQuestionCount > 1) break; current = parent; depth += 1; } return scopes; } function extractOptionsForQuestion(node, root, currentDoc){ let bestScore = -1; let bestOptions = []; const scopes = collectOptionScopeCandidates(node, root); for(const scope of scopes){ const containers = findExtendedOptionContainers(scope); const candidates = containers.length ? containers : [scope]; for(const container of candidates){ const options = extractOptionsFromContainer(container, currentDoc); if(!options.length) continue; const uniqueTextCount = new Set(options.map(option => option.text)).size; const totalLength = options.reduce((sum, option) => sum + (option.text || '').length, 0); const hasLi = !!(container.querySelector && container.querySelector('li')); const score = (uniqueTextCount * 1000) + totalLength + (hasLi ? 20 : 0) + (scope === node ? 10 : 0); if(score > bestScore){ bestScore = score; bestOptions = options; } } } return bestOptions; } 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); let 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 = visibleLabel || dataLabel || (ariaMatch ? ariaMatch[1] : ''); 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 }); }); } } } if(!isFill && options.length === 0){ options = extractOptionsForQuestion(node, root, currentDoc); } 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){ if(extractionInProgress){ if(isManual) showNotification('\u6b63\u5728\u63d0\u53d6\uff0c\u8bf7\u7a0d\u5019\u2026', false, { tone: 'progress', icon: '\u21bb' }); return; } extractionInProgress = true; const progress = createExtractionProgressController(isManual); try{ progress.update({ message: '\u6b63\u5728\u626b\u63cf\u9875\u9762\u4e0e\u6df7\u6dc6\u5b57\u4f53\u2026' }); await cxSecretYieldToMainThread(); const docs = collectAccessibleDocuments(document); await ensureCxSecretMapsForDocuments(docs, isManual, payload => progress.update(payload)); progress.update({ message: '\u6df7\u6dc6\u5b57\u4f53\u89e3\u6790\u5b8c\u6210\uff0c\u6b63\u5728\u6574\u7406\u9898\u76ee\u2026' }); await cxSecretYieldToMainThread(); const paper = buildStructuredPaper(); const total = paper.sections.reduce((sum, sec) => sum + (sec.questions || []).length, 0); if(total === 0){ progress.clear(); 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){ progress.clear(); console.error('extractAndShow failed', e); if(isManual) showNotification(`\u63d0\u53d6\u5931\u8d25\uff1a${e && e.message ? e.message : '\u672a\u77e5\u9519\u8bef'}`, true); }finally{ extractionInProgress = false; progress.clear(); } } /* ---------- UI ---------- */ let notificationElement = null; let notificationHideTimer = 0; let uiContainer = null; let uiOverlay = null; let historyListElement = null; let previewTitleElement = null; let previewMetaElement = null; let previewScrollElement = null; let previewContentElement = null; let previewEmptyElement = null; let panelHeaderDragElement = null; let sidebarTopElement = null; let panelDimensionsElement = null; let resizeLeftElement = null; let resizeBottomElement = null; let resizeHandleElement = null; let panelInteractionState = null; let floatingButtonElement = null; let floatingButtonLabelElement = null; let floatingButtonIconElement = null; let extractionInProgress = false; const managerState = { history: getExtractionHistory(), selectedId: '', lastOpenedId: '', panelSize: gm.getValue(PANEL_SIZE_STORAGE_KEY, null), panelPosition: gm.getValue(PANEL_POSITION_STORAGE_KEY, null) }; function clampValue(value, min, max){ return Math.min(max, Math.max(min, value)); } function getViewportSize(){ const root = document.documentElement || document.body; return { width: Math.max(root ? root.clientWidth : 0, window.innerWidth || 0), height: Math.max(root ? root.clientHeight : 0, window.innerHeight || 0) }; } function getPanelSizeBounds(){ const viewport = getViewportSize(); const maxWidth = Math.max(360, viewport.width - 24); const maxHeight = Math.max(360, viewport.height - 24); return { maxWidth, maxHeight, minWidth: Math.min(520, maxWidth), minHeight: Math.min(420, maxHeight) }; } function getDefaultPanelSize(){ const viewport = getViewportSize(); const bounds = getPanelSizeBounds(); return { width: clampValue(Math.round(viewport.width * 0.42), bounds.minWidth, Math.min(bounds.maxWidth, 680)), height: clampValue(Math.round(viewport.height * 0.78), bounds.minHeight, Math.min(bounds.maxHeight, 760)) }; } function normalizePanelSize(raw){ const defaults = getDefaultPanelSize(); const bounds = getPanelSizeBounds(); const width = raw && Number.isFinite(Number(raw.width)) ? Number(raw.width) : defaults.width; const height = raw && Number.isFinite(Number(raw.height)) ? Number(raw.height) : defaults.height; return { width: Math.round(clampValue(width, bounds.minWidth, bounds.maxWidth)), height: Math.round(clampValue(height, bounds.minHeight, bounds.maxHeight)) }; } function getDefaultPanelPosition(sizeInput){ const size = normalizePanelSize(sizeInput || managerState.panelSize); const viewport = getViewportSize(); return { left: Math.max(8, viewport.width - size.width - 20), top: 20 }; } function normalizePanelPosition(raw, sizeInput){ const size = normalizePanelSize(sizeInput || managerState.panelSize); const viewport = getViewportSize(); const maxLeft = Math.max(8, viewport.width - size.width - 8); const maxTop = Math.max(8, viewport.height - size.height - 8); const defaults = getDefaultPanelPosition(size); const left = raw && Number.isFinite(Number(raw.left)) ? Number(raw.left) : defaults.left; const top = raw && Number.isFinite(Number(raw.top)) ? Number(raw.top) : defaults.top; return { left: Math.round(clampValue(left, 8, maxLeft)), top: Math.round(clampValue(top, 8, maxTop)) }; } function renderPanelDimensions(sizeInput){ if(!panelDimensionsElement) return; const size = normalizePanelSize(sizeInput || managerState.panelSize); panelDimensionsElement.textContent = `${size.width} × ${size.height}`; } function applyPanelPosition(rawPosition, persist=false, sizeInput){ const size = normalizePanelSize(sizeInput || managerState.panelSize); const position = normalizePanelPosition(rawPosition, size); managerState.panelPosition = position; if(uiContainer){ uiContainer.style.left = `${position.left}px`; uiContainer.style.top = `${position.top}px`; uiContainer.style.right = 'auto'; } if(persist) gm.setValue(PANEL_POSITION_STORAGE_KEY, position); return position; } function applyPanelSize(rawSize, persist=false){ const size = normalizePanelSize(rawSize); managerState.panelSize = size; if(uiContainer){ uiContainer.style.width = `${size.width}px`; uiContainer.style.height = `${size.height}px`; const compact = size.width <= 580 || size.height <= 520; const roomy = size.width >= 760 && size.height >= 680; uiContainer.style.setProperty('--cx-btn-font-size', compact ? '12px' : (roomy ? '14px' : '13px')); uiContainer.style.setProperty('--cx-btn-padding-x', compact ? '11px' : (roomy ? '16px' : '14px')); uiContainer.style.setProperty('--cx-btn-min-height', compact ? '36px' : (roomy ? '42px' : '40px')); uiContainer.style.setProperty('--cx-size-btn-font-size', compact ? '11px' : '12px'); uiContainer.style.setProperty('--cx-size-btn-min-height', compact ? '32px' : '34px'); uiContainer.style.setProperty('--cx-icon-btn-size', compact ? '34px' : '38px'); uiContainer.style.setProperty('--cx-icon-btn-font-size', compact ? '20px' : '24px'); uiContainer.style.setProperty('--cx-toolbar-gap', compact ? '8px' : '10px'); uiContainer.style.setProperty('--cx-brand-title-size', compact ? '18px' : (roomy ? '24px' : '22px')); uiContainer.style.setProperty('--cx-brand-sub-size', compact ? '11px' : '12px'); uiContainer.style.setProperty('--cx-brand-mark-size', compact ? '36px' : '40px'); uiContainer.style.setProperty('--cx-brand-mark-font-size', compact ? '18px' : '20px'); uiContainer.style.setProperty('--cx-panel-title-size', compact ? '22px' : (roomy ? '32px' : '28px')); uiContainer.style.setProperty('--cx-panel-eyebrow-size', compact ? '10px' : '11px'); uiContainer.style.setProperty('--cx-meta-font-size', compact ? '11px' : '12px'); uiContainer.style.setProperty('--cx-meta-height', compact ? '28px' : '30px'); uiContainer.style.setProperty('--cx-meta-padding-x', compact ? '10px' : '12px'); uiContainer.style.setProperty('--cx-history-head-font-size', compact ? '11px' : '12px'); uiContainer.style.setProperty('--cx-history-card-padding', compact ? '10px' : '12px'); uiContainer.style.setProperty('--cx-history-kind-font-size', compact ? '11px' : '12px'); uiContainer.style.setProperty('--cx-history-kind-height', compact ? '20px' : '22px'); uiContainer.style.setProperty('--cx-history-title-size', compact ? '13px' : (roomy ? '15px' : '14px')); uiContainer.style.setProperty('--cx-history-sub-size', compact ? '11px' : '12px'); uiContainer.style.setProperty('--cx-dimensions-font-size', compact ? '11px' : '12px'); } renderPanelDimensions(size); if(persist) gm.setValue(PANEL_SIZE_STORAGE_KEY, size); return size; } function resetPanelSize(showTip=false){ const size = applyPanelSize(getDefaultPanelSize(), true); applyPanelPosition(managerState.panelPosition, true, size); if(showTip) showNotification('\u5df2\u6062\u590d\u9ed8\u8ba4\u5c3a\u5bf8'); } function finishPanelInteraction(persist=true){ if(!panelInteractionState) return; if(uiOverlay){ uiOverlay.style.pointerEvents = 'none'; uiOverlay.style.cursor = ''; } document.body.classList.remove('cx-panel-interacting', 'cx-panel-resize-left', 'cx-panel-resize-bottom', 'cx-panel-resize-corner', 'cx-panel-moving'); const state = panelInteractionState; panelInteractionState = null; if(!persist) return; gm.setValue(PANEL_SIZE_STORAGE_KEY, managerState.panelSize); gm.setValue(PANEL_POSITION_STORAGE_KEY, managerState.panelPosition); } function handlePanelInteractionEnd(){ finishPanelInteraction(true); } function handlePanelInteractionMove(event){ if(!panelInteractionState) return; if(event && typeof event.preventDefault === 'function') event.preventDefault(); const state = panelInteractionState; if(state.mode === 'move'){ applyPanelPosition({ left: state.startLeft + (event.clientX - state.startX), top: state.startTop + (event.clientY - state.startY) }, false, managerState.panelSize); return; } const nextSize = applyPanelSize({ width: state.mode === 'bottom' ? state.startWidth : (state.startWidth + (state.startX - event.clientX)), height: state.mode === 'left' ? state.startHeight : (state.startHeight + (event.clientY - state.startY)) }, false); if(state.mode === 'bottom'){ applyPanelPosition({ left: state.startLeft, top: state.startTop }, false, nextSize); return; } const rightEdge = state.startLeft + state.startWidth; applyPanelPosition({ left: rightEdge - nextSize.width, top: state.startTop }, false, nextSize); } function beginPanelInteraction(mode, event){ if(!uiContainer) return; if(event.button != null && event.button !== 0) return; event.preventDefault(); event.stopPropagation(); const rect = uiContainer.getBoundingClientRect(); finishPanelInteraction(false); panelInteractionState = { mode, startX: event.clientX, startY: event.clientY, startWidth: rect.width, startHeight: rect.height, startLeft: rect.left, startTop: rect.top }; if(uiOverlay){ uiOverlay.style.pointerEvents = 'auto'; uiOverlay.style.cursor = mode === 'move' ? 'move' : (mode === 'left' ? 'ew-resize' : (mode === 'bottom' ? 'ns-resize' : 'nesw-resize')); } document.body.classList.add('cx-panel-interacting', mode === 'move' ? 'cx-panel-moving' : `cx-panel-resize-${mode}`); } function startPanelResize(event, mode='corner'){ beginPanelInteraction(mode, event); } function startPanelMove(event){ if(event.target && event.target.closest('button, a, input, textarea, select, label, [data-no-drag]')) return; beginPanelInteraction('move', event); } function legacyShowNotification(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 clearNotificationTimer(){ if(notificationHideTimer){ clearTimeout(notificationHideTimer); notificationHideTimer = 0; } } function hideNotification(immediate=false){ if(!notificationElement) return; clearNotificationTimer(); if(immediate){ notificationElement.style.display = 'none'; notificationElement.dataset.persistent = '0'; return; } notificationElement.style.animation = 'cx-slide-out 0.3s forwards'; setTimeout(() => { if(notificationElement) notificationElement.style.display = 'none'; }, 300); } function setFloatingButtonState(isBusy=false, label='提取'){ if(!floatingButtonElement) return; floatingButtonElement.classList.toggle('busy', !!isBusy); floatingButtonElement.setAttribute('aria-busy', isBusy ? 'true' : 'false'); if(floatingButtonIconElement) floatingButtonIconElement.textContent = isBusy ? '\u21bb' : '\ud83d\udcdd'; if(floatingButtonLabelElement) floatingButtonLabelElement.textContent = isBusy ? (label || '\u89e3\u6790\u4e2d') : '\u63d0\u53d6'; } function createExtractionProgressController(isManual=false){ let lastMessage = ''; let lastAt = 0; return { update(payload){ const info = payload && typeof payload === 'object' ? payload : { message: String(payload || '') }; if(!info.message) return; setFloatingButtonState(true, info.label || '\u89e3\u6790\u4e2d'); if(!isManual) return; const now = Date.now(); if(info.message === lastMessage && now - lastAt < CXSECRET_PROGRESS_THROTTLE_MS) return; lastMessage = info.message; lastAt = now; showNotification(info.message, false, { persistent: true, tone: 'progress', icon: '\u21bb' }); }, clear(){ setFloatingButtonState(false); if(notificationElement && notificationElement.dataset && notificationElement.dataset.persistent === '1'){ hideNotification(true); } } }; } function showNotification(msg, isError=false, options={}){ if(!notificationElement){ notificationElement = document.createElement('div'); notificationElement.id = 'cx-notification-gm'; document.body.appendChild(notificationElement); } const persistent = !!options.persistent; const duration = Number.isFinite(options.duration) ? Number(options.duration) : 2500; const tone = options.tone || (isError ? 'error' : 'success'); const icon = options.icon || (tone === 'progress' ? '\u21bb' : (isError ? '\u26a0\ufe0f' : '\u2705')); clearNotificationTimer(); notificationElement.innerHTML = `${icon} ${msg}`; notificationElement.className = tone; notificationElement.dataset.persistent = persistent ? '1' : '0'; notificationElement.style.display = 'flex'; notificationElement.style.animation = 'none'; notificationElement.offsetHeight; notificationElement.style.animation = 'cx-slide-in 0.3s forwards'; if(persistent) return; notificationHideTimer = setTimeout(() => { hideNotification(false); }, duration); } 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'; uiOverlay.addEventListener('mousemove', handlePanelInteractionMove, true); uiOverlay.addEventListener('mouseup', handlePanelInteractionEnd, true); 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'); panelHeaderDragElement = uiContainer.querySelector('.cx-title-row'); sidebarTopElement = uiContainer.querySelector('.cx-sidebar-top'); panelDimensionsElement = document.getElementById('cx-panel-dimensions'); resizeLeftElement = document.getElementById('cx-resize-left'); resizeBottomElement = document.getElementById('cx-resize-bottom'); resizeHandleElement = document.getElementById('cx-resize-handle'); document.getElementById('cx-close-icon').onclick = closeUI; document.getElementById('cx-reset-size').onclick = () => { resetPanelSize(true); }; 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(); }); if(panelHeaderDragElement){ panelHeaderDragElement.addEventListener('mousedown', startPanelMove); } if(sidebarTopElement){ sidebarTopElement.addEventListener('mousedown', startPanelMove); } if(resizeLeftElement){ resizeLeftElement.addEventListener('mousedown', event => { startPanelResize(event, 'left'); }); } if(resizeBottomElement){ resizeBottomElement.addEventListener('mousedown', event => { startPanelResize(event, 'bottom'); }); } if(resizeHandleElement){ resizeHandleElement.addEventListener('mousedown', event => { startPanelResize(event, 'corner'); }); resizeHandleElement.addEventListener('dblclick', () => { resetPanelSize(true); }); } applyPanelSize(managerState.panelSize, false); applyPanelPosition(managerState.panelPosition, false, managerState.panelSize); } function openManager(){ ensureManagerUI(); managerState.history = getExtractionHistory(); renderManager(); const size = applyPanelSize(managerState.panelSize, false); applyPanelPosition(managerState.panelPosition, false, size); 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; finishPanelInteraction(false); 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(){ const existing = document.getElementById('cx-float-btn'); if(existing){ floatingButtonElement = existing; floatingButtonIconElement = existing.querySelector('.cx-float-icon'); floatingButtonLabelElement = existing.querySelector('.cx-float-label'); return; } const btn = document.createElement('button'); btn.id = 'cx-float-btn'; btn.innerHTML = `📝提取`; btn.title = '提取题目并展开管理面板'; btn.onclick = () => { if(extractionInProgress){ showNotification('\u6b63\u5728\u63d0\u53d6\uff0c\u8bf7\u7a0d\u5019\u2026', false, { tone: 'progress', icon: '\u21bb' }); return; } if(uiContainer && uiContainer.classList.contains('active')){ closeUI(); return; } extractAndShow(true).catch(()=>{}); }; document.body.appendChild(btn); floatingButtonElement = btn; floatingButtonIconElement = btn.querySelector('.cx-float-icon'); floatingButtonLabelElement = btn.querySelector('.cx-float-label'); } 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; cursor: move; } .cx-brand-mark { width: var(--cx-brand-mark-size, 40px); height: var(--cx-brand-mark-size, 40px); border-radius: 12px; display: flex; align-items: center; justify-content: center; background: #eef4ff; border: 1px solid #d9e4fb; font-size: var(--cx-brand-mark-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: var(--cx-brand-title-size, 22px); line-height: 1.1; font-weight: 800; color: #16263a; margin-top: 2px; } .cx-brand-sub { font-size: var(--cx-brand-sub-size, 12px); color: #7c8a9f; margin-top: 6px; } .cx-sidebar-actions { display: grid; gap: var(--cx-toolbar-gap, 10px); margin-bottom: 16px; } .cx-history-head { display: flex; align-items: center; justify-content: space-between; font-size: var(--cx-history-head-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: var(--cx-history-card-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: var(--cx-history-kind-height, 22px); padding: 0 9px; border-radius: 999px; background: #edf2f7; color: #4e6179; font-size: var(--cx-history-kind-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: var(--cx-history-title-size, 14px); line-height: 1.45; font-weight: 700; color: #1b2b3d; } .cx-history-sub { margin-top: 6px; font-size: var(--cx-history-sub-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: grid; gap: 10px; margin-bottom: 14px; min-width: 0; } .cx-title-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: flex-start; min-width: 0; cursor: move; } .cx-panel-heading { min-width: 0; } .cx-panel-actions { display: inline-flex; align-items: center; gap: 8px; flex-shrink: 0; margin-left: auto; } .cx-dimensions-pill { display: inline-flex; align-items: center; min-height: 30px; padding: 0 12px; border-radius: 999px; border: 1px solid #dbe4ef; background: #f8fafc; color: #516377; font-size: var(--cx-dimensions-font-size, 12px); font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; } .cx-panel-eyebrow { font-size: var(--cx-panel-eyebrow-size, 11px); letter-spacing: 0.08em; text-transform: uppercase; color: #8897aa; } .cx-panel-title { margin: 6px 0 0; font-size: var(--cx-panel-title-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: 0; width: 100%; align-items: flex-start; } .cx-meta-pill, .cx-meta-link { display: inline-flex; align-items: center; min-height: var(--cx-meta-height, 30px); padding: 0 var(--cx-meta-padding-x, 12px); border-radius: 999px; background: #f1f4f8; color: #506276; font-size: var(--cx-meta-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: var(--cx-icon-btn-size, 38px); height: var(--cx-icon-btn-size, 38px); border-radius: 12px; border: 1px solid #e1e7ef; background: #ffffff; color: #4f6175; font-size: var(--cx-icon-btn-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-size-btn { min-height: var(--cx-size-btn-min-height, 34px); padding: 0 12px; border-radius: 10px; border: 1px solid #dbe4ef; background: #f8fafc; color: #4f6278; font-size: var(--cx-size-btn-font-size, 12px); font-weight: 700; cursor: pointer; white-space: nowrap; } .cx-size-btn:hover { background: #f1f5f9; border-color: #cdd8e5; } .cx-toolbar { display: flex; flex-wrap: wrap; gap: var(--cx-toolbar-gap, 10px); margin-bottom: 14px; } .cx-btn { min-height: var(--cx-btn-min-height, 40px); padding: 0 var(--cx-btn-padding-x, 14px); border: 1px solid #dbe3ec; border-radius: 12px; font-size: var(--cx-btn-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; white-space: nowrap; } .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-resize-edge { position: absolute; z-index: 2; touch-action: none; } .cx-resize-left { top: 16px; bottom: 16px; left: 0; width: 10px; cursor: ew-resize; } .cx-resize-bottom { left: 18px; right: 18px; bottom: 0; height: 10px; cursor: ns-resize; } .cx-resize-handle { position: absolute; left: 10px; bottom: 10px; width: 18px; height: 18px; border-radius: 9px; cursor: nesw-resize; opacity: 0.72; touch-action: none; z-index: 3; } .cx-resize-handle::before { content: ''; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(135deg, transparent 38%, rgba(148,163,184,0.9) 38%, rgba(148,163,184,0.9) 46%, transparent 46%, transparent 56%, rgba(148,163,184,0.84) 56%, rgba(148,163,184,0.84) 64%, transparent 64%, transparent 74%, rgba(148,163,184,0.78) 74%, rgba(148,163,184,0.78) 82%, transparent 82%); } #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-btn.busy { background: #2563eb; box-shadow: 0 12px 24px rgba(37, 99, 235, 0.24); } body.cx-panel-interacting, body.cx-panel-interacting * { user-select: none !important; } body.cx-panel-resize-left, body.cx-panel-resize-left * { cursor: ew-resize !important; } body.cx-panel-resize-bottom, body.cx-panel-resize-bottom * { cursor: ns-resize !important; } body.cx-panel-resize-corner, body.cx-panel-resize-corner * { cursor: nesw-resize !important; } .cx-float-icon { font-size: 22px; line-height: 1; } #cx-float-btn.busy .cx-float-icon { animation: cx-spin 1s linear infinite; } .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; } #cx-notification-gm.progress span { color: #1d4ed8; animation: cx-spin 1s linear infinite; } @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; } } @keyframes cx-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @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-title-row { grid-template-columns: 1fr; gap: 10px; } .cx-panel-actions { margin-left: 0; } .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; } } `); /* function recoverOptionsFromContainer(container, currentDoc){ const items = getRecoveredOptionItems(container); const options = []; const seen = new Set(); items.forEach((item, idx) => { const visibleLabel = normalizeText( item.querySelector('.mark_letter_span')?.innerText || item.querySelector('.num_option_dx, .num_option')?.innerText || item.querySelector('.fl.before, .before, i.fl')?.innerText || '' ).replace(/[^A-Za-z0-9]/g, '').trim().toUpperCase(); const dataLabel = normalizeText( item.querySelector('.num_option_dx, .num_option')?.getAttribute('data') || item.querySelector('[data-option]')?.getAttribute('data-option') || item.querySelector('[data-choice]')?.getAttribute('data-choice') || item.getAttribute('data-option') || item.getAttribute('data-choice') || item.getAttribute('option') || item.getAttribute('choice') || '' ).replace(/[^A-Za-z0-9]/g, '').trim().toUpperCase(); const ariaLabel = normalizeText(item.getAttribute('aria-label') || ''); const ariaMatch = ariaLabel.match(/^([A-Da-d])(?:[\.\s、]|$)/); let label = visibleLabel || dataLabel || (ariaMatch ? ariaMatch[1].toUpperCase() : ''); const preferredTextNode = item.querySelector('.answer_p, .after, .option-content, .content, .label, .optionCnt, .option-cont, .ans-attach-ct'); let text = preferredTextNode ? getCleanText(preferredTextNode) : ''; const clone = item.cloneNode(true); clone.querySelectorAll('.mark_letter_span, .num_option_dx, .num_option, .fl.before, .before, i.fl, input, .option-check, .check, .score, .ans-checkbox, .ans-radio').forEach(el => el.remove()); if(!text) text = decodeCxSecretText(normalizeText(clone.textContent || clone.innerText || ''), currentDoc); const leadingMatch = text.match(/^[\(\[]?\s*([A-Da-d])[\.\s、\)\]]+/); if(!label && leadingMatch) label = leadingMatch[1].toUpperCase(); Array.from(new Set([visibleLabel, label, leadingMatch ? leadingMatch[1].toUpperCase() : ''].filter(Boolean))).forEach(prefix => { const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); text = text.replace(new RegExp('^[\\(\\[]?\\s*' + escaped + '[\\.\\s、\\)\\]]+', 'i'), '').trim(); }); text = normalizeText(text); if(!label) label = String.fromCharCode(65 + (idx % 26)); if(!text || text === label || text.length < 2) return; const fingerprint = `${label}::${text}`; if(seen.has(fingerprint)) return; seen.add(fingerprint); options.push({ key: label, text }); }); return options; } } */ updateMenu(); createFloatingButton(); window.addEventListener('mousemove', handlePanelInteractionMove, true); window.addEventListener('mouseup', handlePanelInteractionEnd, true); window.addEventListener('blur', handlePanelInteractionEnd, true); document.addEventListener('mousemove', handlePanelInteractionMove, true); document.addEventListener('mouseup', handlePanelInteractionEnd, true); window.addEventListener('resize', () => { if(!uiContainer) return; const size = applyPanelSize(managerState.panelSize, false); applyPanelPosition(managerState.panelPosition, false, size); }); window.addEventListener('load', ()=> { if(gm.getValue(AUTO_EXTRACT_KEY, false)) setTimeout(() => { extractAndShow(false).catch(()=>{}); }, 1500); }); })();