// ==UserScript== // @name 超星题目导出工具 // @namespace 自测考试作业题目采集 // @version 2.7.3 // @description 一键解析题目到本地。 // @author Jason7187 // @match *://*.chaoxing.com/mooc-ans/mooc2/work/view* // @match *://*.chaoxing.com/mooc-ans/mooc2/work/* // @match *://*.chaoxing.com/mooc-ans/work/* // @match *://*.chaoxing.com/exam-ans/exam/* // @match *://*.chaoxing.com/mycourse/studentstudy* // @match *://*.chaoxing.com/mooc-ans/knowledge/cards* // @match *://*.chaoxing.com/ananas/* // @match *://*.chaoxing.com/mooc-ans/api/* // @grant GM_registerMenuCommand // @grant GM_notification // @grant GM_xmlhttpRequest // @connect api.awk618.cn // @connect tk.awk618.icu // @source https://github.com/Jason7187/chaoxing // @icon https://free.picui.cn/free/2025/10/11/68e9b26f92cc7.jpg // @require https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.4/xlsx.full.min.js // ==/UserScript== /* global XLSX, unsafeWindow */ (function () { 'use strict'; const CONFIG = { CLOUD_API: ['http://api.awk618.cn/upload', 'https://tk.awk618.icu/upload'], API_KEY: '7G2hP9sKq3wT6yL4mN8vB5cR1xZ0FjA2dQeW7sU3pH', DELAY_INIT: 2000, ANSWER_SPLITTER: '###', OPTION_SPLITTER: '|', PREVIEW_LIMIT: 100, AUTO_UPLOAD: true, UPLOAD_RETRY: [3000, 10000, 20000], EXTRA_CHAPTER_COLUMN: false, // true 时在 CSV/Excel 末尾追加一列章节名 DIAGNOSTIC: false, // 诊断模式:默认关闭;开启后解析失败会打印框架诊断并导出诊断包 QUIET_WAIT: 200, // 收到跨框架应答后再等这么久就收尾(提前返回的关键) COLLECT_TIMEOUT: 1200, // 跨框架收集的兜底上限 DUMP_TIMEOUT: 900, PONG_WAIT: 600, // 每层等待自己子框架的兜底上限 HOST_WAIT: 2500, HOST_STEP: 400, PROBE_TIMEOUT: 900, HOTKEYS: { SHOW: 'ArrowRight', HIDE: 'ArrowLeft' } }; const IDS = { MARKER: 'cx-script-marker', TOOLBAR: 'cx-toolbar', TOOLBAR_STYLE: 'cx-toolbar-style', PREVIEW: 'cx-preview', PREVIEW_STYLE: 'cx-preview-style' }; // 跨域 iframe 读不到 document,只能靠 postMessage 互传 const MSG = { PING: 'CX_EXPORT_PING', PONG: 'CX_EXPORT_PONG', CLAIM: 'CX_EXPORT_HOST_CLAIM', HELLO: 'CX_EXPORT_ASK_UP', HOST: 'CX_EXPORT_HOST_HERE', DUMP: 'CX_EXPORT_DUMP', DUMP_DATA: 'CX_EXPORT_DUMP_DATA' }; const Q_SELECTOR = '.aiArea, .questionLi, .TiMu, #ZyBottom, h3.newTestType'; const CONTAINER_CANDIDATES = ['.questionLi', '.aiArea', '.TiMu']; const RAW = (() => { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow && unsafeWindow.document) return unsafeWindow; } catch (e) {} return window; })(); let currentData = []; let suppressed = false; let claimed = false; function notify(text) { try { if (typeof GM_notification === 'function') { GM_notification({ title: '超星题目导出工具', text, timeout: 4000 }); } } catch (e) { console.log('[超星题目导出]', text); } } function guard(label, fn) { try { return fn(); } catch (err) { console.error('[超星题目导出] ' + label + ' 出错:', err); notify(label + ' 出错:' + ((err && err.message) || err)); } } function rawFrames(win) { const list = []; let frames; try { frames = win.frames; } catch (e) { return list; } if (!frames) return list; let len = 0; try { len = frames.length; } catch (e) { return list; } for (let i = 0; i < len; i++) { try { list.push(frames[i]); } catch (e) {} } return list; } function childWindows(win) { const out = []; const add = list => list.forEach(w => { if (out.indexOf(w) === -1) out.push(w); }); if (win === RAW || win === window) { add(rawFrames(RAW)); if (RAW !== window) add(rawFrames(window)); } else { add(rawFrames(win)); } return out; } function shortUrl(url) { const clean = String(url || '').replace(/[?#].*$/, ''); const seg = clean.split('/').filter(Boolean).pop() || clean; return seg.length > 26 ? seg.slice(0, 26) + '…' : seg; } const util = { text(doc, sel) { try { const el = doc.querySelector(sel); if (!el) return ''; return (el.textContent || '').replace(/\u00A0/g, ' ').replace(/\s+/g, ' ').trim(); } catch (e) { return ''; } }, value(doc, sel) { try { const el = doc.querySelector(sel); return el && 'value' in el ? String(el.value || '').trim() : ''; } catch (e) { return ''; } }, urlParam(url, key) { const m = new RegExp('[?&]' + key + '=([^]*)', 'i').exec(url || ''); if (!m) return ''; try { return decodeURIComponent(m[1].replace(/\+/g, ' ')); } catch (e) { return m[1]; } }, scriptVar(doc, key) { try { const re = new RegExp('["\']' + key + '["\']\\s*:\\s*["\']([^"\']{1,200})["\']'); const scripts = doc.querySelectorAll('script:not([src])'); for (const s of scripts) { const m = re.exec(s.textContent || ''); if (!m) continue; let v = m[1]; try { v = decodeURIComponent(v.replace(/\+/g, ' ')); } catch (e) {} return v.replace(/\s+/g, ' ').trim(); } } catch (e) {} return ''; }, escapeHtml(str) { return String(str == null ? '' : str) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } }; const Frame = { topDoc() { try { return RAW.top.document; } catch (e) { return null; } }, topHasScript() { const doc = this.topDoc(); try { return !!(doc && doc.getElementById(IDS.MARKER)); } catch (e) { return false; } }, localContainers() { return Frame.containerCount(document); }, containerCount(doc) { for (const selector of CONTAINER_CANDIDATES) { try { const count = doc.querySelectorAll(selector).length; if (count) return count; } catch (e) { /* ignore */ } } return 0; }, hasQuestionsDeep(win) { try { if (win.document.querySelector(Q_SELECTOR)) return true; } catch (e) {} for (const w of childWindows(win)) { try { if (!w.document) continue; } catch (e) { continue; } if (this.hasQuestionsDeep(w)) return true; } return false; }, docs(start) { const list = []; const seen = new Set(); const walk = win => { let doc; try { doc = win.document; } catch (e) { return; } if (!doc || seen.has(doc)) return; seen.add(doc); list.push(doc); childWindows(win).forEach(w => { try { if (w.document) walk(w); } catch (e) {} }); }; walk(start || RAW); return list; }, localInfo() { let url = ''; try { url = RAW.location ? RAW.location.href : ''; } catch (e) {} return { url, containers: this.localContainers() }; }, collectReport(infos) { const rows = []; const seen = new Set(); const push = row => { const key = row.url + '#' + row.containers + '#' + (row.remote ? 'r' : 'l'); if (seen.has(key)) return; seen.add(key); rows.push(row); }; const walk = win => { let doc = null; try { doc = win.document; } catch (e) { doc = null; } if (!doc) { push({ url: '', short: '跨域框架', containers: 0, remote: false, blocked: true }); return; } let url = ''; try { url = doc.location ? doc.location.href : ''; } catch (e) {} let containers = 0; containers = this.containerCount(doc); push({ url, short: shortUrl(url), containers, remote: false, blocked: false }); childWindows(win).forEach(walk); }; walk(RAW); (infos || []).forEach(info => { if (!info) return; push({ url: info.url || '', short: shortUrl(info.url || ''), containers: info.containers || 0, remote: true, blocked: false }); }); return rows; }, probe() { return this.docs().map(doc => { let url = ''; try { url = doc.location ? doc.location.href : ''; } catch (e) {} const count = sel => { try { return doc.querySelectorAll(sel).length; } catch (e) { return 0; } }; let sample = ''; let first = null; try { first = doc.querySelector('.questionLi, .aiArea, .TiMu'); } catch (e) {} if (first) { try { sample = String(first.outerHTML || '').replace(/\s+/g, ' ').slice(0, 400); } catch (e) {} } return { short: shortUrl(url), aiArea: count('.aiArea'), questionLi: count('.questionLi'), tiMu: count('.TiMu'), optionList: count('.mark_letter li'), sample }; }); }, markPresence() { try { if (document.getElementById(IDS.MARKER)) return; const el = document.createElement('div'); el.id = IDS.MARKER; el.style.display = 'none'; (document.head || document.documentElement).appendChild(el); } catch (e) {} }, docDump(doc) { let url = ''; try { url = doc.location ? doc.location.href : ''; } catch (e) {} const counts = {}; ['.aiArea', '.questionLi', '.TiMu', '.qtContent', '.mark_name', '.mark_letter li', '.colorShallow', '.colorGreen', '.rightAnswerContent', '.answerCon', 'iframe'].forEach(sel => { try { counts[sel] = doc.querySelectorAll(sel).length; } catch (e) { counts[sel] = -1; } }); let shadow = 0; try { doc.querySelectorAll('*').forEach(el => { if (el.shadowRoot) shadow++; }); } catch (e) {} let html = ''; try { html = doc.documentElement ? doc.documentElement.outerHTML : ''; } catch (e) {} return { url, counts, shadow, html: html.slice(0, 300000) }; } }; const Bus = { _pending: {}, token() { return 'cx' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }, post(win, payload) { try { if (win) win.postMessage(payload, '*'); } catch (e) {} }, broadcastDown(payload) { childWindows(RAW).forEach(w => this.post(w, payload)); }, listen() { const handler = ev => { const data = ev.data; if (!data || typeof data !== 'object') return; if (data.type === MSG.PING) this.onPing(data, ev.source); else if ((data.type === MSG.PONG || data.type === MSG.DUMP_DATA) && this._pending[data.token]) this._pending[data.token](data); else if (data.type === MSG.CLAIM) this.onClaim(); else if (data.type === MSG.HELLO) this.onHello(data, ev.source); else if (data.type === MSG.HOST) this.onHost(data); else if (data.type === MSG.DUMP) this.onDump(data, ev.source); }; const targets = [RAW]; if (RAW !== window) targets.push(window); targets.forEach(t => { try { t.addEventListener('message', handler, false); } catch (e) {} }); }, parentWindow() { try { const parent = RAW.parent; if (parent && parent !== RAW) return parent; } catch (e) {} return null; }, askUp(token) { const parent = this.parentWindow(); if (parent) this.post(parent, { type: MSG.HELLO, token }); }, onHello(data, source) { if (!source || (data.hop || 0) > 8) return; if (claimed) { this.post(source, { type: MSG.HOST, token: data.token }); return; } const parent = this.parentWindow(); if (!parent) return; this._parentAsk[data.token] = source; this.post(parent, { type: MSG.HELLO, token: data.token, hop: (data.hop || 0) + 1 }); }, onHost(data) { suppressed = true; this.removeToolbar(); const asker = this._parentAsk[data.token]; if (asker) { delete this._parentAsk[data.token]; this.post(asker, { type: MSG.HOST, token: data.token }); } }, onClaim() { suppressed = true; this.removeToolbar(); childWindows(RAW).forEach(w => this.post(w, { type: MSG.CLAIM })); }, removeToolbar() { const el = document.getElementById(IDS.TOOLBAR); if (el) el.remove(); }, onPing(data, source) { if (!source || (data.hop || 0) > 8) return; const token = data.token; const info = Frame.localInfo(); let items = []; try { items = CXParser.parseAll(); } catch (e) { items = []; } if (!childWindows(RAW).length) { this.post(source, { type: MSG.PONG, token, items, infos: [info] }); return; } const childItems = []; const childInfos = []; let finished = false; let quietTimer = null; let capTimer = null; const finish = () => { if (finished) return; finished = true; clearTimeout(quietTimer); clearTimeout(capTimer); delete this._pending[token]; this.post(source, { type: MSG.PONG, token, items: items.concat(childItems), infos: [info].concat(childInfos) }); }; this._pending[token] = res => { if (Array.isArray(res.items)) childItems.push(...res.items); if (Array.isArray(res.infos)) childInfos.push(...res.infos); clearTimeout(quietTimer); quietTimer = setTimeout(finish, CONFIG.QUIET_WAIT); }; childWindows(RAW).forEach(w => this.post(w, { type: MSG.PING, token, hop: (data.hop || 0) + 1 })); quietTimer = setTimeout(finish, CONFIG.QUIET_WAIT); capTimer = setTimeout(finish, CONFIG.PONG_WAIT); }, onDump(data, source) { if (!source || (data.hop || 0) > 8) return; const token = data.token; const mine = Frame.docDump(document); const kids = childWindows(RAW); if (!kids.length) { this.post(source, { type: MSG.DUMP_DATA, token, list: [mine] }); return; } const list = []; this._pending[token] = res => { if (Array.isArray(res.list)) list.push(...res.list); }; kids.forEach(w => this.post(w, { type: MSG.DUMP, token, hop: (data.hop || 0) + 1 })); setTimeout(() => { delete this._pending[token]; this.post(source, { type: MSG.DUMP_DATA, token, list: [mine].concat(list) }); }, CONFIG.PONG_WAIT); } }; Bus._parentAsk = {}; const Collector = { collect(timeoutMs) { let direct = []; try { direct = CXParser.parseAll(); } catch (e) { direct = []; } const extra = []; const infos = []; const token = Bus.token(); return new Promise(resolve => { let finished = false; let quietTimer = null; let capTimer = null; const finish = () => { if (finished) return; finished = true; clearTimeout(quietTimer); clearTimeout(capTimer); delete Bus._pending[token]; resolve({ items: CXParser.dedupe(direct.concat(extra)), infos }); }; Bus._pending[token] = res => { if (Array.isArray(res.items)) extra.push(...res.items); if (Array.isArray(res.infos)) infos.push(...res.infos); clearTimeout(quietTimer); quietTimer = setTimeout(finish, CONFIG.QUIET_WAIT); }; if (!childWindows(RAW).length) { finish(); return; } Bus.broadcastDown({ type: MSG.PING, token, hop: 0 }); quietTimer = setTimeout(finish, CONFIG.QUIET_WAIT); capTimer = setTimeout(finish, timeoutMs || CONFIG.COLLECT_TIMEOUT); }); } }; const Meta = { catalogParts(doc) { let active = null; try { active = doc.querySelector('.posCatalog_active'); } catch (e) { return []; } if (!active) return []; const parts = []; let li = active.closest('li'); let guard = 0; while (li && guard++ < 8) { const own = li.querySelector( ':scope > .posCatalog_select > .posCatalog_name,' + ':scope > .posCatalog_select > .posCatalog_title,' + ':scope > .posCatalog_title,:scope > .posCatalog_name' ); if (own) { const em = own.querySelector('.posCatalog_sbar'); const num = em ? em.textContent.trim() : ''; let name = (own.getAttribute('title') || '').trim(); if (!name) name = (own.textContent || '').replace(em ? em.textContent : '', '').replace(/\s+/g, ' ').trim(); if (name) parts.unshift((!num || name.includes(num)) ? name : `${num} ${name}`); } const up = li.parentElement ? li.parentElement.closest('li') : null; li = (up && up !== li) ? up : null; } return parts.filter(Boolean); }, catalogPath(doc) { return this.catalogParts(doc).join(' / '); }, catalogNode(doc) { const parts = this.catalogParts(doc); return parts.length ? parts[parts.length - 1] : ''; }, buildExportName(meta) { const node = meta.node || ''; if (!node) return meta.title || meta.courseName || ''; if (!meta.courseName || node.includes(meta.courseName)) return node; return `${meta.courseName} ${node}`; }, resolve(docs) { const list = docs && docs.length ? docs : [document]; const meta = { title: '', chapter: '', node: '', courseName: '', courseId: '', workId: '', exportName: '' }; list.forEach(doc => { if (!meta.title) { meta.title = util.text(doc, 'h2.mark_title') || util.text(doc, '.mark_title') || util.text(doc, '#workTitle') || util.text(doc, '.workTitle') || util.text(doc, '.ceyan_name h3') || util.text(doc, '.TestTitle_name') || this.catalogNode(doc); } if (!meta.node) meta.node = this.catalogNode(doc); if (!meta.chapter) meta.chapter = this.catalogPath(doc) || util.scriptVar(doc, 'chapterName'); if (!meta.courseName) { meta.courseName = util.scriptVar(doc, 'courseName') || util.text(doc, '#courseName') || util.text(doc, '.courseName'); } if (!meta.courseId) { let search = ''; try { search = doc.location ? doc.location.search : ''; } catch (e) {} meta.courseId = util.value(doc, '#courseId') || util.urlParam(search, 'courseId'); } if (!meta.workId) meta.workId = util.value(doc, '#workId'); }); if (!meta.title) meta.title = meta.chapter || meta.courseName || (list[0] && list[0].title) || ''; if (!meta.chapter) meta.chapter = meta.title; meta.exportName = this.buildExportName(meta); return meta; } }; class CXParser { static parseAll() { const docs = Frame.docs(); const meta = Meta.resolve(docs); this._meta = meta; let items = []; docs.forEach(doc => { items = items.concat(this.parseDoc(doc)); }); items.forEach(item => { item.courseName = meta.exportName || meta.title || ''; item.courseId = meta.courseId || ''; item.chapterName = meta.chapter || ''; item.courseRealName = meta.courseName || ''; }); return this.dedupe(items); } static getCourseInfo() { if (!this._meta) this._meta = Meta.resolve(Frame.docs()); return { exportName: this._meta.exportName || '', courseName: this._meta.title || '', taskName: this._meta.node || this._meta.title || '', courseId: this._meta.courseId || '', chapterName: this._meta.chapter || '', realCourseName: this._meta.courseName || '' }; } static parseDoc(doc) { return this.questionRoots(doc) .map(el => this.parseContainer(el)) .filter(item => item && item.answer); } static questionRoots(doc) { let all = []; let selector = ''; for (const candidate of CONTAINER_CANDIDATES) { try { const found = doc.querySelectorAll(candidate); if (found.length) { all = Array.from(found); selector = candidate; break; } } catch (e) { /* ignore */ } } if (!all.length) return []; const roots = all.filter(el => !(el.parentElement && el.parentElement.closest && el.parentElement.closest(selector))); const stems = this.countStems(doc); if (stems > 1 && roots.length < 2) return this.rootsFromStems(doc); return roots; } static countStems(doc) { try { return doc.querySelectorAll('.qtContent').length; } catch (e) { return 0; } } static rootsFromStems(doc) { const roots = []; let stems = []; try { stems = Array.from(doc.querySelectorAll('.qtContent')); } catch (e) { return roots; } stems.forEach(stem => { let node = stem; while (node.parentElement) { const parent = node.parentElement; let count = 0; try { count = parent.querySelectorAll('.qtContent').length; } catch (e) { break; } if (count !== 1) break; node = parent; } if (roots.indexOf(node) === -1) roots.push(node); }); return roots; } static stripOptionLabel(text) { return String(text == null ? '' : text) .replace(/^\s*[A-Za-z]\s*[..。、,,::))]\s*/, '') .replace(/^\s*[A-Za-z][\s\u3000]+/, ''); } static parseContainer(container) { const type = this.parseType(container); return { type, question: this.parseQuestion(container), options: this.parseOptions(container), answer: this.parseAnswer(container, type), score: this.parseScore(container) }; } static dedupe(items) { const map = new Map(); items.forEach(item => { const key = [item.type, item.question, item.options].join('\u0001'); const prev = map.get(key); if (!prev) map.set(key, item); else if (!prev.answer && item.answer) map.set(key, item); }); return Array.from(map.values()); } static parseType(container) { const candidates = []; const shallow = container.querySelector('.colorShallow'); if (shallow) candidates.push(shallow.textContent); const tag = container.querySelector('.qtContent .newZy_TItle') || container.querySelector('.newZy_TItle'); if (tag) candidates.push(tag.textContent); const section = this.findSectionTitle(container); if (section) candidates.push(section); for (const raw of candidates) { const type = this.normalizeType(raw); if (type) return type; } return ''; } static findSectionTitle(container) { const doc = container.ownerDocument; const body = doc.body || doc.documentElement; let el = container; while (el && el !== body && el !== doc.documentElement) { let prev = el.previousElementSibling; while (prev) { if (prev.matches && prev.matches('h3.newTestType')) return prev.textContent; const inner = prev.querySelector && prev.querySelector('h3.newTestType'); if (inner) return inner.textContent; prev = prev.previousElementSibling; } el = el.parentElement; } return ''; } static normalizeType(raw) { const src = String(raw == null ? '' : raw).replace(/\u00A0/g, ' ').trim(); if (!src) return ''; const clean = src .replace(/[([(【{<]/g, '').replace(/[\]))】}>]/g, '') .replace(/([.·。:,,]?\s*)(共|满分)?\s*\d+\.?\d*\s*分/g, '') .replace(/[.·。:,,]\s*分/g, '').replace(/\s*分/g, '').trim(); if (/^(?:A\d+|B\d+)+$/.test(clean)) return clean; const compact = clean.replace(/[\s\u3000.,。·:,、]/g, ''); const map = [ ['单选', '单选题'], ['单项选择', '单选题'], ['选择题A型', '单选题'], ['多选', '多选题'], ['多项选择', '多选题'], ['共用选项', '共用选项题'], ['共用备选答案', '共用选项题'], ['共用题干', '共用选项题'], ['配伍', '共用选项题'], ['判断', '判断题'], ['填空', '填空题'], ['简答', '简答题'], ['名词解释', '名词解释'], ['分析', '分析题'], ['问答', '问答题'], ['综合', '综合题'] ]; for (const [keyword, name] of map) { if (compact.includes(keyword)) return name; } if (/^[一二三四五六七八九十百\d.、,,::]+$/.test(compact)) return ''; return compact; } static parseQuestion(container) { const content = container.querySelector('.qtContent') || container.querySelector('.mark_name') || container.querySelector('.Zy_TItle'); if (!content) return ''; return this.extractTextWithImgs(content) .replace(/^\s*【[^】]{1,12}】\s*/, '') .replace(/^\s*[\[((]\s*(?:单选题|多选题|判断题|填空题|简答题|名词解释|分析题|问答题|综合题)\s*[\]))]\s*/, '') .trim(); } static parseOptions(container) { const optionElements = container.querySelectorAll( '.mark_letter li, .stem_answer > div, ul.Zy_ulTop.qtDetail > li, ul.qtDetail > li' ); return Array.from(optionElements).map((optionDiv, idx) => { const letter = String.fromCharCode(65 + idx); const text = this.stripOptionLabel(this.extractTextWithImgs(optionDiv)); return `${letter}.${text}`; }).join(' | '); } static isCorrectByUI(container) { if (container.querySelector('.marking_dui')) return true; if (container.querySelector('[aria-label*="正确"]')) return true; return false; } static isWrongByUI(container) { if (container.querySelector('.marking_cuo')) return true; if (container.querySelector('[aria-label*="错误"]')) return true; return false; } static isHalfCorrect(container) { if (container.querySelector('.marking_bandui')) return true; if (container.querySelector('[aria-label*="半对"]')) return true; return false; } static parseScore(container) { const el = container.querySelector('.totalScore i, .mark_score i, .newAnswerScore .scoreNum, .scoreNum'); return el ? parseFloat(el.textContent.trim()) : null; } static parseAnswer(container, type) { if (type === '填空题') { const rightAnswerElements = container.querySelectorAll( '.rightAnswerContent, .correctAnswerBx .correctAnswer .answerCon, .correctAnswer .answerCon' ); if (rightAnswerElements.length) { const parts = Array.from(rightAnswerElements) .map(el => this.cleanAnswerText(el.textContent)).filter(Boolean); if (parts.length) return parts.join(CONFIG.ANSWER_SPLITTER); } const score = this.parseScore(container) || 0; if (score > 0 && !this.isHalfCorrect(container)) return this.extractStudentAnswer(container); return ''; } let answer = this.extractRightAnswer(container); if (!answer && this.isMultipleChoice(container)) answer = this.extractMultipleChoiceAnswer(container); if (!answer) { const score = this.parseScore(container) || 0; if (score > 0 && !this.isHalfCorrect(container)) answer = this.extractStudentAnswer(container); } if (!answer && this.isCorrectByUI(container) && !this.isWrongByUI(container) && !this.isHalfCorrect(container)) { answer = this.extractStudentAnswer(container); } if (!answer) return ''; answer = this.normalizeJudgmentSymbol(answer); if (this.isOptionLetter(answer)) answer = this.mapAnswerToOptions(answer, this.buildOptionsMap(container)); return this.cleanAnswerText(answer); } static extractMultipleChoiceAnswer(container) { const el = container.querySelector('.rightAnswerContent, .correctAnswer .answerCon'); return el ? el.textContent.trim() : ''; } static isOptionLetter(answer) { return /^[A-Za-z]+$/.test(answer); } static normalizeJudgmentSymbol(text) { if (/^[√✓]$/.test(text)) return '正确'; if (/^[×✗]$/.test(text)) return '错误'; return text; } static buildOptionsMap(container) { const optionElements = container.querySelectorAll( '.mark_letter li, .stem_answer > div, ul.Zy_ulTop.qtDetail > li, ul.qtDetail > li' ); return Array.from(optionElements).reduce((map, el, idx) => { const letter = String.fromCharCode(65 + idx); let text = this.stripOptionLabel(this.extractTextWithImgs(el)).trim(); if (text.startsWith('。')) text = text.substring(1).trim(); map[letter] = text; return map; }, {}); } static mapAnswerToOptions(answer, optionsMap) { return answer.split('').map(l => optionsMap[l.toUpperCase()] || l).join(CONFIG.ANSWER_SPLITTER); } static cleanAnswerText(answer) { if (!answer) return ''; return String(answer) .replace(/\u00A0/g, ' ') .split(CONFIG.ANSWER_SPLITTER) .map(part => part.replace(/^\s*(正确答案|参考答案|标准答案|我的答案|学生答案|作答)\s*[::]\s*/, '').replace(/\s+/g, ' ').trim()) .filter(Boolean) .join(CONFIG.ANSWER_SPLITTER); } static extractRightAnswer(container) { const selectors = [ '.correctAnswerBx .correctAnswer .answerCon', '.correctAnswer .answerCon', '.rightAnswerContent', '.colorGreen dd', // 新版作业页:
| 课程名 | 课程ID | 题型 | 题目 | 选项 | 答案 |
|---|---|---|---|---|---|
| ${esc(item.courseName)} | ${esc(item.courseId)} | ${esc(item.type)} | ${esc(item.question)} | ${esc(item.options).replace(/\|/g, ' | ')} | ${this.renderAnswer(item.answer, item.hiddenAnswer)} |