// ==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 '提取管理面板