// ==UserScript== // @name 学习通万能题目 提取器 // @namespace http://tampermonkey.net/ // @version 5.0.1 // @description 自动提取学习通作业/考试/章节测验题目并一键导出Word。加强章节识别、标题去噪与去重、修复填空题排版、去掉题干重复编号并输出显式章节标题。) // @author kkkxfr // @match *://*.chaoxing.com/exam-ans/exam/test/reVersionPaperMarkContentNew* // @match *://*.chaoxing.com/exam-ans/exam/test/look* // @match *://*.chaoxing.com/mooc-ans/work/selectWorkQuestionYiPiYue* // @match *://*.mooc1.chaoxing.com/mooc-ans/mooc2/work/view?* // @match *://*.chaoxing.com/work/doHomeWorkNew* // @match *://*.mooc-ans/work/doHomeWorkNew* // @match *://*.mooc1-api.chaoxing.com/mooc-ans/mooc2/work* // @match *://*.chaoxing.com/api/selectWorkQuestionYiPiYue* // @match *://*mooc2-ans.chaoxing.com/mooc2-ans/mycourse* // @grant GM_setClipboard // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // ==/UserScript== (function(){ 'use strict'; const AUTO_EXTRACT_KEY = 'chaoxing_auto_extract_enabled'; /* ---------- 基础辅助 ---------- */ function normalizeText(s){ if(!s) return ''; return String(s).replace(/\u00A0/g,' ').replace(/\r?\n/g,' ').replace(/\s+/g,' ').trim(); } function getCleanText(el){ if(!el) return ''; 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 = document.createElement('div'); tmp.innerHTML = html; return normalizeText(tmp.textContent || tmp.innerText || ''); } function xmlEscape(s){ if(!s) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').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(){ 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 = document.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(document.title||''); if(docTitle && docTitle.length > 2 && !/查看已批阅|作业详情/i.test(docTitle)) return docTitle; return '试卷'; } /* ---------- 题目/选项定位 ---------- */ function findOptionContainers(qNode){ const sels = ['.mark_letter','.Cy_ulTop','.Zy_ulTop','.options','.option-list','.optionsList']; const found = []; for(const s of sels){ const el = qNode.querySelector(s); if(el) found.push(el); } const uls = Array.from(qNode.querySelectorAll('ul,ol')); uls.forEach(u=>{ if((u.className && /ul|options|Cy_ulTop|Zy_ulTop|mark_letter/.test(u.className)) || (u.querySelector('li') && !u.textContent.includes('我的答案'))) { if(!found.includes(u)) found.push(u); } }); return found; } /* ---------- 答案提取 ---------- */ function extractCorrectAnswerFromNode(qNode, qType){ if(!qNode) return null; const isFill = !!(qType && /填空|简答|问答|填充|主观|主观题/i.test(qType)); const strictAnsRe = /(?:正确答案|参考答案|答案)[::\s]*([A-Da-d对错√×]+)/i; const strictMyRe = /(?:我的答案|My Answer)[::\s]*([A-Da-d对错√×]+)/i; const looseAnsRe = /(?:正确答案|参考答案|答案)[::\s]*([\s\S]{1,200})/i; const looseMyRe = /(?:我的答案|My Answer)[::\s]*([\s\S]{1,200})/i; const rightEls = Array.from(qNode.querySelectorAll('.rightAnswerContent')); if(rightEls.length){ const texts = rightEls.map(el => getCleanText(el)).filter(Boolean); if(texts.length) return texts.join('\n'); } const selectors = ['.mark_key','.mark_answer','.right_answer','.answerRight','.Py_answer','.answer-content','.answer']; const collected = []; for(const s of selectors){ const els = Array.from(qNode.querySelectorAll(s)); for(const el of els){ const t = getCleanText(el); if(!t || /未提取|未作答|暂无/.test(t)) continue; if(isFill){ const m = t.match(looseAnsRe); if(m && m[1]) { collected.push(m[1].trim()); continue; } collected.push(t.trim()); } else { const m = t.match(strictAnsRe); if(m && m[1]) { collected.push(m[1].trim()); continue; } const short = t.replace(/\s+/g,''); if(/^[A-D]+$/i.test(short)) collected.push(short); } } } if(collected.length) return collected.join('\n'); let clone = qNode.cloneNode(true); clone.querySelectorAll('ul, ol, .mark_letter, .Cy_ulTop, .options, .option-list').forEach(el => el.remove()); clone.querySelectorAll('.mark_name, .Cy_TItle, .Qtitle, h3').forEach(el => el.remove()); const remainingText = normalizeText(clone.innerText || clone.textContent || ''); if(remainingText){ if(isFill){ const exactMatch = remainingText.match(looseAnsRe); if(exactMatch && exactMatch[1]) return exactMatch[1].trim(); } else { const exactMatch = remainingText.match(strictAnsRe); if(exactMatch && exactMatch[1]) return exactMatch[1].trim(); } } if(isFill){ const myMatch = remainingText.match(looseMyRe); if(myMatch && myMatch[1]) return myMatch[1].trim(); } else { const myMatch = remainingText.match(strictMyRe); if(myMatch && myMatch[1]) return myMatch[1].trim(); } try { const inputs = Array.from(qNode.querySelectorAll('input[type="text"], input[type="hidden"], textarea, input[type="search"]')); const inputVals = inputs.map(inp => { return (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(); }).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 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 findNearestSectionTitle(node, root){ try{ 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 === document.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(document.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 titleEl = node.querySelector('.mark_name, .Cy_TItle, .Qtitle, .question-title, .marking-title') || 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 = normalizeText(stemRaw); stem = stem.replace(/(我的答案|My Answer)[::\s]*.*$/i, ''); stem = stem.replace(/^\d+[\.\、\s]*【.*?】\s*/, ''); stem = stem.replace(/^\d+[\.\、]\s*/, ''); // 移除开头处各种括号内的题型标注(支持多次连续的括号) stem = stem.replace(/^(?:\s*[\(\(\【\[]\s*(?:单选题|单选|选择题|选择|多选题|多选|判断题|判断|填空题|填空|论述题|论述|简答题|简答|问答题|问答|主观题|主观|材料题|操作题)\s*[\)\)\】\]]\s*)+/i, ''); stem = stem.trim(); (function stripLeadingNums(){ let changed = true; while(changed){ changed = false; const m = stem.match(/^\s*(?:\(*\s*\d+\s*[\)\.\、\)]|\(\s*\d+\s*)|\d+[\.\、])\s*/); if(m){ stem = stem.slice(m[0].length); changed = true; } const m2 = stem.match(/^\s*\d+[\.\、]\s*/); if(m2){ stem = stem.slice(m2[0].length); changed = true; } } stem = stem.replace(/^\s+/, ''); })(); const wholeTxt = (node.innerText||node.textContent||'').toLowerCase(); // 优先识别论述/简答/主观类题型 let type = '单选题'; if(/论述|简答|问答|主观|论述题|简答题|问答题|主观题/i.test(wholeTxt)) { type = '简答题'; } else if(/多选|multiple|multi/i.test(wholeTxt)) { type = '多选题'; } else if(/判断|是非|对错|true|false/i.test(wholeTxt)) { type = '判断题'; } else if(/填空|空格|填充|填空题/i.test(wholeTxt)) { type = '填空题'; } else { const dt = node.getAttribute && (node.getAttribute('data-type') || node.getAttribute('qtype') || ''); if(dt && /multi|checkbox|多选/i.test(dt)) type = '多选题'; } const isFill = /填空|简答|问答|填充|主观|主观题/i.test(type); const options = []; if(!isFill){ const containers = findOptionContainers(node); if(containers.length > 0){ let chosen = containers.find(c => c.querySelector && c.querySelector('li')); if(!chosen) chosen = containers[0]; if(chosen){ const lis = Array.from(chosen.querySelectorAll('li')); const items = lis.length ? lis : Array.from(chosen.children); items.forEach((li, idx)=>{ let label = (li.querySelector('.mark_letter_span')?.innerText || li.querySelector('i.fl')?.innerText || '').replace(/[^A-Za-z0-9]/g,'').trim(); 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(); let text = normalizeText(clone.textContent || clone.innerText || ''); const m = text.match(/^[\(\[]?\s*([A-Da-d])[\.\、\)\]\s]+/); if(!label && m){ label = m[1].toUpperCase(); text = text.replace(/^[\(\[]?\s*([A-Da-d])[\.\、\)\]\s]+/,'').trim(); } if(!label) label = String.fromCharCode(65 + (idx % 26)); if(text.length > 0 && text !== '查看解析') options.push({ key: label.toUpperCase(), text: text }); }); } } } let answerRaw = extractCorrectAnswerFromNode(node, type) || ''; let answer = normalizeAnswerString(answerRaw, type); answer = stripLeadingLabels(answer); let answerSplit = splitNumberedBlanks(answer); let answerInline = renderAnswerInline(answerSplit); 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) || ''; return { type, stem, options, answer: answerSplit, answerInline, analysis, sectionTitle }; } catch(e){ console.error('parseQuestionNode error', e); return null; } } /* ---------- 构建试卷对象 ---------- */ function buildStructuredPaperFromDOM(){ const root = document.querySelector('.fanyaMarking') || document.querySelector('.mark_table') || document.body; let explicitSectionTitle = ''; (function(){ try{ const candidateSelectors = '.type_tit, .Cy_TItle1, .typeTitle, .mark_section, h2, h3'; const pageTitle = getPageTitle(); let explicitEl = root.querySelector('.mark_table .type_tit, .mark_item .type_tit, ' + candidateSelectors); if(explicitEl && normalizeText(explicitEl.innerText || explicitEl.textContent || '') === normalizeText(pageTitle)){ const rightCard = document.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 = document.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=> document.querySelectorAll(sel).forEach(n=> { if(n && root.contains(n)) nodeSet.add(n); })); if(nodeSet.size === 0){ 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, .qtContent, h3'); if(hasStem || (len>12 && n.querySelector('ul') && n.innerText.includes('A'))) nodeSet.add(n); }catch(e){} }); } const arr = Array.from(nodeSet).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.answerInline||'') ).slice(0,2000); if(seenFp.has(fp)) continue; seenFp.add(fp); parsedQuestions.push(q); } const sectionsMap = new Map(); const pageTitle = getPageTitle(); 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; } /* ---------- 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)}`; } } 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*\d+\s*分)?\s*)/g, ''); t = t.replace(/\(\s*共\s*\d+\s*题(?:[,,]\s*\d+\s*分)?\s*\)/g, ''); t = t.replace(/(\s*\d+\s*分\s*)/g, ''); t = t.replace(/\(\s*\d+\s*分\s*\)/g, ''); // remove inline occurrences like "共 20 题" or "100 分" t = t.replace(/共\s*\d+\s*题/g, ''); t = t.replace(/[\d0-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 rawAns = q.answerInline ? stripLeadingLabels(q.answerInline) : ''; const ansClean = rawAns.replace(/^[::\s]+/, ''); if(ansClean) out += `正确答案:${ansClean}\n`; const rawAnal = q.analysis ? stripLeadingLabels(q.analysis) : ''; const analClean = rawAnal.replace(/^[::\s]+/, ''); if(analClean) out += `答案解析:${analClean}\n`; out += `\n`; } } return out; } function exportPaperToDocx(paper){ 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 a=document.createElement('a'); const safe = (paper.title || getPageTitle()).replace(/[\\\/:*?"<>|]+/g,'_'); a.href = URL.createObjectURL(blob); a.download = safe + '.docx'; document.body.appendChild(a); a.click(); setTimeout(()=>{ document.body.removeChild(a); URL.revokeObjectURL(a.href); }, 3000); } function extractAndShow(isManual=false){ const paper = buildStructuredPaperFromDOM(); const total = paper.sections.reduce((s,sec)=>s + (sec.questions||[]).length, 0); if(total === 0){ if(isManual) showNotification('未找到题目 — 请确认页面已加载或切换到答题/查看页', true); return; } const text = structuredPaperToText(paper); GM_setClipboard(text); showUI(text, paper); showNotification(`已提取 ${total} 道题(复制到剪贴板)`); } /* ---------- 交互与 UI ---------- */ let notificationElement = null; function showNotification(msg, isError=false){ if(!notificationElement){ notificationElement = document.createElement('div'); notificationElement.id='cx-notification-gm'; document.body.appendChild(notificationElement); } notificationElement.innerHTML = `${isError?'⚠️':'✅'} ${msg}`; notificationElement.className = isError ? 'error' : 'success'; notificationElement.style.display = 'flex'; notificationElement.style.animation = 'none'; notificationElement.offsetHeight; notificationElement.style.animation = 'cx-slide-in 0.3s forwards'; setTimeout(()=>{ notificationElement.style.animation = 'cx-slide-out 0.3s forwards'; setTimeout(()=>{ notificationElement.style.display='none'; },300); }, 2500); } let uiContainer = null; let uiOverlay = null; function showUI(text, paper){ if(!uiContainer){ uiOverlay = document.createElement('div'); uiOverlay.id = 'cx-ui-overlay'; document.body.appendChild(uiOverlay); uiOverlay.onclick = closeUI; uiContainer = document.createElement('div'); uiContainer.id = 'cx-extractor-ui'; uiContainer.innerHTML = `
📝 提取结果预览
×
`; document.body.appendChild(uiContainer); document.getElementById('cx-close-icon').onclick = closeUI; document.getElementById('cx-close').onclick = closeUI; document.getElementById('cx-copy').onclick = () => { const ta = document.getElementById('cx-extractor-output'); ta.select(); GM_setClipboard(ta.value); showNotification('已复制到剪贴板'); }; document.getElementById('cx-export').onclick = () => exportPaperToDocx(paper); } const ta = document.getElementById('cx-extractor-output'); if(ta) ta.value = text; uiOverlay.style.display = 'block'; requestAnimationFrame(() => { uiOverlay.style.opacity = '1'; uiContainer.classList.add('active'); }); } function closeUI(){ if(!uiContainer) return; uiOverlay.style.opacity = '0'; uiContainer.classList.remove('active'); setTimeout(() => { uiOverlay.style.display = 'none'; }, 300); } function createFloatingButton(){ if(document.getElementById('cx-float-btn')) return; const btn = document.createElement('button'); btn.id = 'cx-float-btn'; btn.innerHTML = '📝'; btn.title = '提取题目'; btn.onclick = () => extractAndShow(true); document.body.appendChild(btn); } function toggleAuto(){ const v = !GM_getValue(AUTO_EXTRACT_KEY,false); GM_setValue(AUTO_EXTRACT_KEY,v); alert(`自动提取已${v?'开启':'关闭'},刷新页面生效。`); updateMenu(); } function updateMenu(){ const isAuto = GM_getValue(AUTO_EXTRACT_KEY,false); try{ GM_registerMenuCommand(`${isAuto?'✅':'❌'} 自动提取开关`, toggleAuto); GM_registerMenuCommand('🖐 手动提取题目', ()=> extractAndShow(true)); }catch(e){ console.warn('GM_registerMenuCommand 可能不可用', e); } } GM_addStyle(` #cx-ui-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 99997; backdrop-filter: blur(2px); display: none; opacity: 0; transition: opacity 0.3s; } #cx-extractor-ui { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.95); width: 600px; max-width: 90vw; max-height: 85vh; background: #fff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.2); z-index: 99998; display: flex; flex-direction: column; opacity: 0; pointer-events: none; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); font-family: -apple-system, sans-serif; } #cx-extractor-ui.active { opacity: 1; pointer-events: auto; transform: translate(-50%, -50%) scale(1); } .cx-header { padding: 16px 24px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; background: #fff; border-radius: 16px 16px 0 0; } .cx-title { font-size: 18px; font-weight: 600; color: #333; display: flex; align-items: center; gap: 8px; } .cx-close-icon { font-size: 24px; cursor: pointer; color: #999; transition: color 0.2s; } .cx-close-icon:hover { color: #333; } .cx-body { flex: 1; padding: 0; overflow: hidden; display: flex; flex-direction: column; background: #fafafa; } #cx-extractor-output { width: 100%; height: 400px; padding: 20px; border: none; font-family: 'Consolas', monospace; font-size: 13px; color: #444; background: #fafafa; line-height: 1.6; resize: none; outline: none; box-sizing: border-box; } .cx-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; gap: 12px; background: #fff; border-radius: 0 0 16px 16px; } .cx-btn { flex: 1; padding: 12px 0; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: transform 0.1s, filter 0.2s; display: flex; align-items: center; justify-content: center; gap: 6px; } .cx-btn:active { transform: scale(0.98); } .cx-btn-primary { background: #1890ff; color: #fff; } .cx-btn-success { background: #52c41a; color: #fff; } .cx-btn-secondary { background: #f5f5f5; color: #666; border: 1px solid #e8e8e8; } #cx-float-btn { position: fixed; top: 180px; right: 20px; z-index: 99999; width: 56px; height: 56px; border-radius: 50%; background: linear-gradient(135deg, #1890ff, #096dd9); color: #fff; border: none; font-size: 24px; box-shadow: 0 6px 20px rgba(24,144,255,0.4); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.3s; } #cx-float-btn:hover { transform: scale(1.1) rotate(10deg); } #cx-notification-gm { position: fixed; top: 30px; left: 50%; transform: translateX(-50%); padding: 12px 24px; border-radius: 30px; display: flex; align-items: center; gap: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); z-index: 100000; font-family: sans-serif; font-size: 14px; font-weight: 600; pointer-events: none; background: #fff; color: #333; border: 1px solid #eee; } #cx-notification-gm.success span { color: #52c41a; } #cx-notification-gm.error span { color: #ff4d4f; } @keyframes cx-slide-in { from { transform: translate(-50%, -20px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } } @keyframes cx-slide-out { from { transform: translate(-50%, 0); opacity: 1; } to { transform: translate(-50%, -20px); opacity: 0; } } `); updateMenu(); createFloatingButton(); window.addEventListener('load', ()=> { if(GM_getValue(AUTO_EXTRACT_KEY,false)) setTimeout(()=> extractAndShow(false), 1500); }); })();