/* QuestionBankCore: 从已保存或当前练习页面读取题目,始终以“参考答案”为准。 */ (function (root) { 'use strict'; const SELECTORS = { question: '.pc_examtest_content', stem: '.pc_examtest_content_question', option: '.pc_examtest_content_list_des', label: '.examtest_content_list_des_optionsort', reference: '.examtest_content_answer_left', explanation: '.pc_examtest_content_resolve', card: '.examtestcard_count_defult' }; const CONTROL = '.pc_examtest_content_question_showanswer,script,style,button,input,select,textarea,iframe,object,embed,link'; const TYPE_RE = /^\s*[\[【](单选题?|多选题?|判断题?|填空题?|简答题?|问答题?)[\]】]\s*/; const NUMBER_RE = /^\s*(\d+)\s*[..、]\s*/; function normalize(value) { return String(value == null ? '' : value).replace(/\u00a0/g, ' ').replace(/[\t\n\r ]+/g, ' ').trim(); } function cleanClone(node) { if (!node) return null; const copy = node.cloneNode(true); copy.querySelectorAll(CONTROL).forEach(function (item) { item.remove(); }); return copy; } function textOf(node) { const copy = cleanClone(node); return copy ? normalize(copy.textContent) : ''; } function savedSourceURL(doc) { const walker = doc.createTreeWalker(doc, 128); let comment; while ((comment = walker.nextNode())) { const match = comment.nodeValue.match(/saved from url=\(\d+\)([\s\S]+?)\s*$/i); if (match) return match[1].trim(); } return ''; } function usableURL(value) { return /^(https?:|file:|blob:)/i.test(value || '') ? value : ''; } function resolveURL(raw, base) { if (!raw) return ''; raw = raw.trim(); if (/^(javascript|vbscript):/i.test(raw) || /^data:(?!image\/)/i.test(raw)) return ''; try { return new URL(raw, base || undefined).href; } catch (_) { return raw; } } function originalAttribute(node, name) { return node.getAttribute('data-qb-original-' + name) || node.getAttribute(name) || ''; } function baseURL(doc, sourceUrl) { const base = doc.querySelector('base'); return base ? resolveURL(originalAttribute(base, 'href'), sourceUrl) || sourceUrl : sourceUrl; } function imageResources(node, base) { if (!node) return []; return Array.from(node.querySelectorAll('img')).map(function (img) { const originalSrc = originalAttribute(img, 'src') || img.getAttribute('data-src') || img.getAttribute('data-original') || ''; return { src: originalSrc, url: resolveURL(originalSrc, base), alt: normalize(img.getAttribute('alt')) }; }).filter(function (item) { return item.url; }); } function uniqueImages(resources) { return Array.from(new Set(resources.map(function (item) { return item.url; }))); } // HTML 仅作为附加数据导出。预览界面只使用 textContent,不插入这些 HTML。 function staticHTML(node, base) { const copy = cleanClone(node); if (!copy) return ''; const linkURLs = Array.from(copy.querySelectorAll('a')).map(function (link) { return resolveURL(originalAttribute(link, 'href'), base); }); copy.querySelectorAll('*').forEach(function (element) { Array.from(element.attributes).forEach(function (attr) { if (/^on|^_ng|^ng-|^style$|^data-qb-original-/i.test(attr.name)) element.removeAttribute(attr.name); }); }); const sourceImages = node.querySelectorAll('img'); copy.querySelectorAll('img').forEach(function (img, index) { const source = sourceImages[index]; const raw = source && (originalAttribute(source, 'src') || source.getAttribute('data-src') || source.getAttribute('data-original')); const url = resolveURL(raw, base); if (url) img.setAttribute('src', url); else img.removeAttribute('src'); img.removeAttribute('srcset'); }); copy.querySelectorAll('a').forEach(function (link, index) { const href = linkURLs[index]; if (href) link.setAttribute('href', href); else link.removeAttribute('href'); }); return copy.innerHTML.trim(); } function inferType(stem, options) { const match = stem.match(TYPE_RE); if (match) return match[1].replace(/题$/, ''); if (options.some(function (option) { return option.node.querySelector('.examtest_content_list_des_multiplesort'); })) return '多选'; const texts = options.map(function (option) { return option.text; }); if (texts.length === 2 && texts.every(function (value) { return /^(正确|错误|对|错|是|否)$/.test(value); })) return '判断'; return '未知'; } function readAnswer(box) { const reference = box.querySelector(SELECTORS.reference); if (!reference) return ''; const pieces = Array.from(reference.querySelectorAll('.answer')).map(textOf).filter(Boolean); if (pieces.length) { // 多选可保存为一个 ABC span,也可分别保存为 A、B、C 多个 span。 if (pieces.every(function (piece) { return /^[A-Z\s,,、;;]+$/i.test(piece); })) return pieces.join('').replace(/[\s,,、;;]+/g, '').toUpperCase(); return pieces.join(';'); } return textOf(reference).replace(/^\s*参考答案\s*[::]\s*/, '').trim(); } function answerOptions(answer, options) { if (!answer) return []; const normalizedAnswer = answer.replace(/[\s,,、;;]+/g, '').toUpperCase(); const letterLabels = options.every(function (option) { return /^[A-Z]$/i.test(option.label); }); if (letterLabels && /^[A-Z]+$/.test(normalizedAnswer)) { return options.filter(function (option) { return normalizedAnswer.includes(option.label.toUpperCase()); }); } const pieces = answer.split(/[\s,,、;;]+/).filter(Boolean); return options.filter(function (option) { return pieces.includes(option.label) || pieces.includes(option.text); }); } function extract(doc, sourceUrl) { if (!doc || typeof doc.querySelectorAll !== 'function') throw new TypeError('extract 需要 Document 对象。'); const source = usableURL(sourceUrl) || usableURL(doc.__questionBankSourceURL) || savedSourceURL(doc) || usableURL(doc.URL) || ''; const resourceBase = baseURL(doc, source); const title = textOf(doc.querySelector('.pc_examtest_title')) || normalize(doc.title) || '练习题库'; const questions = Array.from(doc.querySelectorAll(SELECTORS.question)).map(function (box, index) { const stemNode = box.querySelector(SELECTORS.stem); const rawStem = textOf(stemNode); const optionItems = Array.from(box.querySelectorAll(SELECTORS.option)).map(function (node, optionIndex) { const label = textOf(node.querySelector(SELECTORS.label)) || String.fromCharCode(65 + optionIndex); const copy = cleanClone(node); copy.querySelectorAll(SELECTORS.label).forEach(function (item) { item.remove(); }); // 保存网页把选项图片放在与文字相邻的 _list_img 内。 const container = node.parentElement; const resources = imageResources(container || node, resourceBase); return { node: node, label: label, text: normalize(copy.textContent), images: uniqueImages(resources), imageResources: resources, html: staticHTML(copy, resourceBase) }; }); const type = inferType(rawStem, optionItems); const withoutType = rawStem.replace(TYPE_RE, ''); const numberMatch = withoutType.match(NUMBER_RE); const number = numberMatch ? Number(numberMatch[1]) : index + 1; const question = withoutType.replace(NUMBER_RE, '').trim(); const options = optionItems.map(function (option) { return { label: option.label, text: option.text, images: option.images, imageResources: option.imageResources, html: option.html }; }); const answer = readAnswer(box); const matchedOptions = answerOptions(answer, options); const resolveNode = box.querySelector(SELECTORS.explanation); const explanation = textOf(resolveNode).replace(/^\s*(?:题目解析|答案解析|解析)\s*[::]\s*/, '').trim(); const questionResources = imageResources(stemNode, resourceBase); const explanationResources = imageResources(resolveNode, resourceBase); const allResources = questionResources.concat(options.flatMap(function (option) { return option.imageResources; }), explanationResources); return { number: number, type: type, question: question, options: options, answer: answer, answerText: matchedOptions.map(function (option) { return option.label + '. ' + (option.text || '[图片选项]'); }).join(';'), explanation: explanation, images: uniqueImages(allResources), questionImages: uniqueImages(questionResources), explanationImages: uniqueImages(explanationResources), imageResources: allResources, questionHTML: staticHTML(stemNode, resourceBase), explanationHTML: staticHTML(resolveNode, resourceBase) }; }); const cardCount = doc.querySelectorAll(SELECTORS.card).length; const expectedTotal = cardCount || null; const missingAnswers = questions.filter(function (question) { return !question.answer; }).map(function (question) { return question.number; }); return { title: title, sourceUrl: source, resourceBaseUrl: resourceBase, exportedAt: new Date().toISOString(), total: questions.length, answered: questions.length - missingAnswers.length, missingAnswers: missingAnswers, expectedTotal: expectedTotal, complete: questions.length > 0 && missingAnswers.length === 0 && (expectedTotal === null || expectedTotal === questions.length), questions: questions }; } // 中和可能加载外部资源的属性后才交给 DOMParser;原值用于生成资源链接。 // 此函数不会把解析结果插入当前文档,也不执行保存网页中的任何脚本。 function parseHTML(html, sourceUrl) { if (typeof DOMParser === 'undefined') throw new Error('此功能需要浏览器 DOMParser。'); let inert = String(html).replace(/]*>[\s\S]*?<\/script\s*>/gi, '').replace(/]*>[\s\S]*?<\/style\s*>/gi, ''); inert = inert.replace(/<[a-z][^>]*>/gi, function (tag) { return tag.replace(/\s(src|srcset|href|poster|data|style|background|action|formaction)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, function (_, name, value) { return ' data-qb-original-' + name.toLowerCase() + '=' + value; }).replace(/\s(on[a-z]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, ''); }); const doc = new DOMParser().parseFromString(inert, 'text/html'); doc.__questionBankSourceURL = sourceUrl || ''; return doc; } function csvCell(value) { let text = String(value == null ? '' : value); // Excel 会在引号包裹的 CSV 单元格中依旧识别公式;前置单引号使其按文本打开。 if (/^[\s\uFEFF]*[=+\-@]/.test(text)) text = "'" + text; return '"' + text.replace(/"/g, '""') + '"'; } function toCSV(bank) { const labels = []; bank.questions.forEach(function (question) { question.options.forEach(function (option) { if (!labels.includes(option.label)) labels.push(option.label); }); }); const header = ['题号', '题型', '题目'].concat(labels.map(function (label) { return '选项' + label; }), ['全部选项', '参考答案', '答案对应选项', '题目解析', '图片链接', '来源网址', '题库名称', '完整性', '网页题数', '缺失答案题号']); const rows = bank.questions.map(function (question) { return [question.number, question.type, question.question].concat(labels.map(function (label) { const option = question.options.find(function (item) { return item.label === label; }); return option ? [option.text].concat(option.images).filter(Boolean).join('\n') : ''; }), [question.options.map(function (option) { return option.label + '. ' + option.text + (option.images.length ? '\n' + option.images.join('\n') : ''); }).join('\n'), question.answer, question.answerText, question.explanation, question.images.join('\n'), bank.sourceUrl, bank.title, bank.complete ? (bank.expectedTotal === null ? '当前页面完整(网页题数未知)' : '完整') : '不完整', bank.expectedTotal === null ? '未知' : bank.expectedTotal, bank.missingAnswers.join('、')]); }); return '\uFEFF' + [header].concat(rows).map(function (row) { return row.map(csvCell).join(','); }).join('\r\n') + '\r\n'; } function toJSON(bank) { return JSON.stringify(bank, null, 2); } function toText(bank) { const completeness = bank.complete ? (bank.expectedTotal === null ? '当前页面完整(网页题数未知)' : '完整') : '不完整'; const lines = [bank.title, '来源:' + (bank.sourceUrl || '本地网页'), '完整性:' + completeness, '题目:' + bank.total + ';有参考答案:' + bank.answered, '网页答题卡题数:' + (bank.expectedTotal === null ? '未知,只能核对当前页面' : bank.expectedTotal), '缺失答案题号:' + (bank.missingAnswers.length ? bank.missingAnswers.join('、') : '无')]; if (bank.expectedTotal !== null && bank.total < bank.expectedTotal) lines.push('缺失题目数:' + (bank.expectedTotal - bank.total)); lines.push(''); bank.questions.forEach(function (question) { lines.push(question.number + '. [' + question.type + '] ' + question.question); question.questionImages.forEach(function (url) { lines.push('题目图片:' + url); }); question.options.forEach(function (option) { lines.push(option.label + '. ' + option.text); option.images.forEach(function (url) { lines.push('选项图片:' + url); }); }); lines.push('参考答案:' + (question.answer || '网页未提供')); if (question.answerText) lines.push('对应选项:' + question.answerText); lines.push('题目解析:' + (question.explanation || '网页未提供')); question.explanationImages.forEach(function (url) { lines.push('解析图片:' + url); }); lines.push(''); }); return lines.join('\r\n'); } function typeCounts(bank) { return bank.questions.reduce(function (counts, question) { counts[question.type] = (counts[question.type] || 0) + 1; return counts; }, {}); } function filenameFor(bank, format) { const name = (bank.title || '练习题库').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').replace(/[. ]+$/, '').slice(0, 100) || '练习题库'; return name + '_题库_' + bank.total + '题' + (bank.complete ? '' : '_不完整') + '.' + format; } function download(bank, format) { format = String(format || 'csv').toLowerCase(); const formats = { csv: { encode: toCSV, mime: 'text/csv;charset=utf-8' }, json: { encode: toJSON, mime: 'application/json;charset=utf-8' }, txt: { encode: toText, mime: 'text/plain;charset=utf-8' } }; if (!formats[format]) throw new Error('导出格式仅支持 csv、json、txt。'); const filename = filenameFor(bank, format); const blob = new Blob([formats[format].encode(bank)], { type: formats[format].mime }); const url = URL.createObjectURL(blob); const link = root.document.createElement('a'); link.href = url; link.download = filename; root.document.body.appendChild(link); link.click(); link.remove(); // 留出时间让浏览器接管下载后再回收 URL。 root.setTimeout(function () { URL.revokeObjectURL(url); }, 30000); return filename; } root.QuestionBankCore = { version: '1.0.0', selectors: SELECTORS, extract: extract, parseHTML: parseHTML, savedSourceURL: savedSourceURL, resolveURL: resolveURL, toCSV: toCSV, toJSON: toJSON, toText: toText, typeCounts: typeCounts, filenameFor: filenameFor, download: download }; })(typeof window !== 'undefined' ? window : globalThis);