From e26598317062520150e2279d5d8df658b40c75bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E6=98=9F=E5=B9=BB=E6=B5=B7?= <30327324256@qq.com> Date: Sat, 10 Oct 2026 02:03:40 +0800 Subject: [PATCH] feat: add automatic practice question bank export --- .gitignore | 6 + README.md | 59 +++++ build-userscript.mjs | 19 ++ package.json | 14 ++ practice-exporter.js | 173 +++++++++++++++ question-bank-core.js | 290 +++++++++++++++++++++++++ tests/verify.cjs | 195 +++++++++++++++++ 离线导出题库.html | 92 ++++++++ 谊安学题库导出.user.js | 477 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1325 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build-userscript.mjs create mode 100644 package.json create mode 100644 practice-exporter.js create mode 100644 question-bank-core.js create mode 100644 tests/verify.cjs create mode 100644 离线导出题库.html create mode 100644 谊安学题库导出.user.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..163f4b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# 原始下载网页、压缩包和题库不提交到源代码仓库。 +网页样本/ +导出结果/ +*.zip +node_modules/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1cadba --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# 谊安学练习题库导出 + +进入谊安学“练习”页面后自动下载 CSV,包含题干、全部选项、参考答案、答案对应选项、题目解析及图片链接。也可手动导出 JSON、TXT。 + +脚本读取网页中已加载的参考答案,包括隐藏答案,不点击选项或“查看答案”,不提交练习。样本中的“查看答案”会记录错题,因此采用直接读取的方式。 + +## 在线自动导出 + +1. 在 Chrome / Edge 浏览器的油猴(Tampermonkey)中新建脚本。 +2. 将 `谊安学题库导出.user.js` 的全部内容粘贴进去,保存并启用。 +3. 刷新谊安学网页,正常登录,点击进入“练习”。 +4. 等待题目和答案加载完毕,CSV 会自动下载至浏览器下载目录,右下角显示导出状态。也可点击 CSV / JSON / TXT 按钮重新导出。 + +脚本匹配 `http://10.119.67.7/clientcn/*` 和对应 HTTPS 地址,仅在 `/exam//questions?flag=exercise` 页面自动导出;考试页面不会触发。站点地址变化时,修改脚本顶部的 `@match` 地址。 + +如果脚本未运行,请检查油猴是否启用、浏览器是否允许扩展运行用户脚本。如果提示已发起下载但没有文件,点击右下角导出按钮;浏览器若提示下载权限,允许本页面下载。 + +## 从已下载网页导出 + +1. 解压原始网页压缩包。 +2. 双击本项目的 `离线导出题库.html`,保持它与 `question-bank-core.js` 放在同一目录。 +3. 选择解压得到的 `谊安学.html`,查看题目数和答案完整性,点击所需导出格式。 + +本地工具只解析 HTML 文本,不运行原网站脚本、不连接原网站。不需要登录或安装 Node.js。输入的是解压后的 HTML 文件。 + +油猴脚本也支持原始保存网页;需要在浏览器扩展设置中开启“允许访问文件网址”。通常优先使用上面的离线工具,避免原网页脚本重新启动后清空已保存内容。 + +## 导出内容和限制 + +- CSV 使用 UTF-8 BOM,可用 Excel 打开;JSON 保留结构化选项和图片地址;TXT 便于阅读。 +- 参考答案与用户已选择的答案分开处理,导出的是网页提供的参考答案。缺失答案保留为空并提示,不推测答案。 +- 自动导出等待题目内容稳定,并核对答题卡数量;存在缺题或缺答案时不会自动下载,手动导出会标记不完整。没有答题卡数量时,只能核对当前 DOM 中的题目,不能保证包含网站未加载的题目。 +- 本版本针对提供的桌面练习页。分页或滚动后才加载的题目,需要先加载全部内容;未加载的内容无法从 DOM 读取。 +- 图片以链接导出,不会自动打包图片文件。保存网页中的相对图片地址按原始网页地址解析,离线时这些地址可能不可访问。 +- 提供的样本为“安全领导力-1”,有 120 题,单选、多选、判断各 40 题;全部参考答案存在,其中 112 题答案被隐藏。样本的 120 处解析文本均为空,导出时也为空。 +- 同一次页面运行中,相同练习内容只自动下载一次;重新刷新或手动点击导出仍可下载。 + +## 开发和验证 + +`question-bank-core.js` 是 DOM 提取与文件导出核心;`practice-exporter.js` 负责练习页面识别、加载等待、状态面板和自动下载。发布用油猴脚本已构建,可直接安装。 + +修改源文件后运行: + +```powershell +npm run build +``` + +浏览器验证依赖 Playwright 和本机 Chrome,可执行: + +```powershell +npm install +npm test +``` + +测试使用合成题目模拟隐藏答案、错误选项、延迟加载、页面切换、缺题、缺答案和下载,且不会连接谊安学服务器。若本地存在 `网页样本/考试答案/谊安学.html`,还会核对该样本的 120 道题。 + +原始压缩包、解压网页和实际题库文件已在 `.gitignore` 中排除,不会随源代码提交。 + +远程仓库:`ssh://git@192.168.10.31:2222/qishizhe/yianxue-shauti.git`。 diff --git a/build-userscript.mjs b/build-userscript.mjs new file mode 100644 index 0000000..095ee5d --- /dev/null +++ b/build-userscript.mjs @@ -0,0 +1,19 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +const metadata = `// ==UserScript== +// @name 谊安学练习题库自动导出 +// @namespace yianxue-question-bank +// @version 1.0.0 +// @description 进入练习后自动导出题干、选项、参考答案和解析;读取隐藏答案。 +// @match http://10.119.67.7/clientcn/* +// @match https://10.119.67.7/clientcn/* +// @match file:///* +// @run-at document-idle +// @grant none +// @noframes +// ==/UserScript== +`; +const core = readFileSync(new URL('./question-bank-core.js', import.meta.url), 'utf8'); +const automation = readFileSync(new URL('./practice-exporter.js', import.meta.url), 'utf8'); +writeFileSync(new URL('./谊安学题库导出.user.js', import.meta.url), metadata + '\n' + core + '\n' + automation, 'utf8'); +console.log('已生成 谊安学题库导出.user.js'); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d8230e1 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "yianxue-question-bank-exporter", + "version": "1.0.0", + "private": true, + "description": "谊安学练习题库自动导出与保存网页离线解析", + "scripts": { + "build": "node build-userscript.mjs", + "pretest": "node build-userscript.mjs", + "test": "node tests/verify.cjs" + }, + "devDependencies": { + "playwright": "1.62.1" + } +} diff --git a/practice-exporter.js b/practice-exporter.js new file mode 100644 index 0000000..2aed96e --- /dev/null +++ b/practice-exporter.js @@ -0,0 +1,173 @@ +(function () { + 'use strict'; + + // 源代码:构建后连同 question-bank-core.js 一起装入单个油猴脚本。 + if (window.YianQuestionBankExporter) return; + const core = window.QuestionBankCore; + if (!core) throw new Error('题库提取核心未加载'); + const config = { autoExport: true, format: 'csv', settleMs: 1800 }; + const completed = new Set(); + let state = null; + let panel = null; + let statusNode = null; + let debounceTimer = null; + let previousPractice = false; + let previousRoot = null; + let previousSignature = ''; + + function routeInfo() { + const url = new URL(location.href); + let params = url.searchParams; + let path = url.pathname; + if (url.hash.startsWith('#/')) { + const hashUrl = new URL(url.hash.slice(1), url.origin); + params = hashUrl.searchParams; + path = hashUrl.pathname; + } + const root = document.querySelector('app-examtest, .pc_examtest'); + const local = url.protocol === 'file:'; + const practice = !!root && (local + ? /[?&]flag=exercise(?:&|$)/.test(core.savedSourceURL(document)) + : params.get('flag') === 'exercise' && /\/exam\/[^/]+\/questions\/?$/.test(path)); + return { practice, root, title: params.get('title') || '', key: path + '|' + (params.get('title') || ''), local }; + } + + function fingerprint(bank) { + return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(q => + [q.number, q.type, q.question, q.options, q.answer, q.explanation, q.images])]); + } + + function setStatus(message) { + if (statusNode && statusNode.textContent !== message) statusNode.textContent = message; + } + + function createPanel() { + if (panel) return; + panel = document.createElement('div'); + panel.id = 'yian-question-bank-exporter'; + panel.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483600;max-width:min(330px,calc(100vw - 32px));padding:14px;border:1px solid #d5dde5;border-radius:12px;background:#fff;color:#233447;box-shadow:0 4px 20px #0002;font:13px/1.6 system-ui,sans-serif;text-align:left'; + const heading = document.createElement('strong'); + heading.textContent = '练习题库导出'; + statusNode = document.createElement('div'); + statusNode.setAttribute('role', 'status'); + statusNode.style.cssText = 'margin:6px 0;white-space:pre-line'; + const actions = document.createElement('div'); + actions.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap'; + for (const format of ['csv', 'json', 'txt']) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = '导出 ' + format.toUpperCase(); + button.style.cssText = 'cursor:pointer;border:1px solid #d5dde5;border-radius:6px;background:#f5f8fa;padding:5px 9px;color:#233447;font:inherit'; + button.addEventListener('click', () => { + try { exportCurrent(format); } + catch (error) { setStatus('导出失败:' + error.message); } + }); + actions.append(button); + } + const label = document.createElement('label'); + label.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:8px;cursor:pointer'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = config.autoExport; + checkbox.addEventListener('change', () => { + config.autoExport = checkbox.checked; + tick(); + }); + label.append(checkbox, document.createTextNode('进入练习后自动导出 CSV')); + panel.append(heading, statusNode, actions, label); + document.body.append(panel); + } + + function exportCurrent(format = config.format) { + if (!routeInfo().practice) throw new Error('请先进入练习页面'); + const bank = core.extract(document, location.href); + if (!bank.total) throw new Error('页面还没有题目,请稍后重试'); + // 手动导出可保存不完整内容,文件及提示都会标记缺失。 + core.download(bank, format); + setStatus((bank.complete ? '已发起下载:' : '已发起不完整题库下载:') + bank.title + '\n' + + bank.total + ' 题,' + bank.answered + ' 个答案' + + (bank.complete ? '。' : ',请检查缺失内容。') + + '\n若未出现文件,请点击上方导出按钮。'); + return bank; + } + + function tick() { + const route = routeInfo(); + if (!route.practice) { + if (panel) panel.remove(); + panel = statusNode = null; + state = null; + previousPractice = false; + previousRoot = route.root; + return; + } + createPanel(); + const bank = core.extract(document, location.href); + const signature = fingerprint(bank); + const now = Date.now(); + if (!state || state.key !== route.key) { + // SPA 改 URL 后旧题目可能仍留在 DOM,等待题目或组件更新。 + const transitioning = !!previousRoot && !!(previousPractice || previousSignature) + && previousRoot === route.root && previousSignature === signature; + state = { key: route.key, signature, since: now, initialRoot: route.root, + initialSignature: signature, waitingForReplacement: transitioning }; + } + if (state.waitingForReplacement && (route.root !== state.initialRoot || signature !== state.initialSignature)) { + state.waitingForReplacement = false; + state.since = now; + } + previousPractice = true; + previousRoot = route.root; + previousSignature = signature; + if (state.signature !== signature) { + state.signature = signature; + state.since = now; + } + if (state.waitingForReplacement || (route.title && route.title.trim() !== bank.title.trim())) { + setStatus('等待本次练习的题目加载…'); + return; + } + if (!bank.total || now - state.since < config.settleMs) { + setStatus('正在读取题目与隐藏答案…'); + return; + } + if (bank.expectedTotal !== null && bank.expectedTotal !== bank.total) { + setStatus('已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。\n若页面分页,请逐页导出;手动导出会标记不完整。'); + return; + } + if (!bank.complete) { + setStatus('已读取 ' + bank.total + ' 题,缺少 ' + bank.missingAnswers.length + ' 个答案。\n自动导出等待答案加载;可手动保存现有内容。'); + return; + } + const exportKey = state.key + '|' + signature; + if (completed.has(exportKey)) return; + if (!config.autoExport) { + setStatus(bank.title + '\n' + bank.total + ' 题,答案齐全,可点击导出。'); + return; + } + try { + exportCurrent(config.format); + completed.add(exportKey); + } catch (error) { + config.autoExport = false; + const checkbox = panel.querySelector('input'); + if (checkbox) checkbox.checked = false; + setStatus('自动下载失败:' + error.message + '\n请点击导出按钮重试。'); + console.error('[谊安学题库导出]', error); + } + } + + const observer = new MutationObserver(records => { + if (records.every(record => panel && (record.target === panel || panel.contains(record.target)))) return; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(tick, 300); + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + const timer = setInterval(tick, 750); // 同时覆盖 Angular 路由和返回/前进。 + window.YianQuestionBankExporter = Object.freeze({ + exportCurrent, + collectCurrent: () => core.extract(document, location.href), + stop: () => { observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer); if (panel) panel.remove(); } + }); + tick(); +})(); diff --git a/question-bank-core.js b/question-bank-core.js new file mode 100644 index 0000000..4377955 --- /dev/null +++ b/question-bank-core.js @@ -0,0 +1,290 @@ +/* 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); diff --git a/tests/verify.cjs b/tests/verify.cjs new file mode 100644 index 0000000..4a003ba --- /dev/null +++ b/tests/verify.cjs @@ -0,0 +1,195 @@ +/* 真实浏览器验证:只启动本机测试服务,不请求原网站。 */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const http = require('node:http'); +const { chromium } = require('playwright'); + +const root = path.resolve(__dirname, '..'); +const coreSource = fs.readFileSync(path.join(root, 'question-bank-core.js'), 'utf8'); +const scriptSource = fs.readFileSync(path.join(root, '谊安学题库导出.user.js'), 'utf8'); +const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-export-qa'); +fs.mkdirSync(qaDir, { recursive: true }); +const samplePath = path.join(root, '网页样本', '考试答案', '谊安学.html'); + +function option(label, text, selected = false) { + return `
${label}${text}
`; +} +function fixture(title = '模拟练习', { missingAnswer = false, cardCount = 3 } = {}) { + const content = [ + { type: '单选', stem: '包含引号的项目 "甲,乙"?', options: [option('A', '错误选择', true), option('B', '正确选项')], answer: 'B', explanation: '请依据参考答案。' }, + { type: '多选', stem: '选择符合条件的项目。', options: ['A', 'B', 'C', 'D', 'E'].map(x => option(x, '项目' + x)), answer: 'AE', explanation: '' }, + { type: '判断', stem: '隐藏答案应能读取。', options: [option('A', '对'), option('B', '错')], answer: missingAnswer ? '' : 'B', explanation: '' } + ].map((q, i) => `

[${q.type}] ${i + 1}. ${q.stem}查看答案

${q.options.join('')}
`).join(''); + return `模拟谊安学练习
${title}
${content}
${Array.from({ length: cardCount }, (_, i) => `
${i + 1}
`).join('')}
`; +} + +function parseCSV(text) { + text = text.replace(/^\uFEFF/, ''); + const rows = []; let row = []; let cell = ''; let quoted = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + if (quoted && text[i + 1] === '"') { cell += '"'; i++; } + else quoted = !quoted; + } else if (!quoted && ch === ',') { row.push(cell); cell = ''; } + else if (!quoted && (ch === '\r' || ch === '\n')) { + if (ch === '\r' && text[i + 1] === '\n') i++; + row.push(cell); rows.push(row); row = []; cell = ''; + } else cell += ch; + } + if (cell || row.length) { row.push(cell); rows.push(row); } + assert.equal(quoted, false, 'CSV 引号需成对'); + return rows; +} + +(async function main() { + const server = http.createServer((req, res) => { + const requestURL = new URL(req.url, 'http://localhost'); + if (requestURL.pathname.endsWith('/question-bank-core.js')) { + res.writeHead(200, { 'Content-Type': 'text/javascript;charset=utf-8' }); res.end(coreSource); + } else if (requestURL.pathname.startsWith('/tool/')) { + res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(fs.readFileSync(path.join(root, '离线导出题库.html'))); + } else { + res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(fixture(requestURL.searchParams.get('title') || '模拟练习')); + } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + let browser; + try { + browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); + const context = await browser.newContext({ acceptDownloads: true, viewport: { width: 1366, height: 900 } }); + const page = await context.newPage(); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); }); + const downloads = []; page.on('download', download => downloads.push(download)); + const practiceURL = title => origin + '/clientcn/exam/' + encodeURIComponent(title) + '/questions?title=' + encodeURIComponent(title) + '&flag=exercise'; + await page.goto(practiceURL('模拟练习')); + await page.evaluate(() => { window.answerClicks = 0; }); + const firstDownload = page.waitForEvent('download'); + await page.addScriptTag({ content: scriptSource }); + const downloaded = await firstDownload; + await downloaded.saveAs(path.join(qaDir, 'synthetic.csv')); + const bank = await page.evaluate(() => QuestionBankCore.extract(document)); + assert.equal(bank.total, 3); assert.equal(bank.answered, 3); assert.equal(bank.complete, true); + assert.equal(bank.questions[0].answer, 'B'); assert.equal(bank.questions[0].options[0].text, '错误选择'); + assert.equal(bank.questions[1].answer, 'AE'); assert.equal(bank.questions[1].options.length, 5); + assert.equal(bank.questions[2].answerText, 'B. 错'); assert.equal(bank.questions[0].explanation, '请依据参考答案。'); + assert(bank.questions.every(q => !q.question.includes('查看答案'))); + assert.equal(await page.evaluate(() => window.answerClicks), 0); + const csv = fs.readFileSync(path.join(qaDir, 'synthetic.csv'), 'utf8'); + assert.equal(csv.charCodeAt(0), 0xFEFF); + const rows = parseCSV(csv); assert.equal(rows.length, 4); + assert(rows.every(row => row.length === rows[0].length)); + assert.equal(rows[1][rows[0].indexOf('题目')], '包含引号的项目 "甲,乙"?'); + assert(rows[0].includes('选项E')); + const formulaCSV = await page.evaluate(() => { + const b = QuestionBankCore.extract(document); b.questions[0].question = '=HYPERLINK("http://invalid")'; return QuestionBankCore.toCSV(b); + }); + assert(parseCSV(formulaCSV)[1][2].startsWith("'=")); + await page.waitForTimeout(2400); assert.equal(downloads.length, 1, '同一题库不应重复自动下载'); + await page.screenshot({ path: path.join(qaDir, 'practice-desktop.png') }); + console.log('PASS 隐藏答案、错误已选项隔离、多选、判断、完整CSV与单次自动下载'); + + // 新 URL 与新 DOM 在下一次扫描前一同更新,仍应成功自动导出。 + const nextDownload = page.waitForEvent('download'); + await page.evaluate(({ url, html }) => { + history.pushState({}, '', url); + const doc = new DOMParser().parseFromString(html, 'text/html'); + document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); + }, { url: practiceURL('第二份练习'), html: fixture('第二份练习') }); + assert.equal((await nextDownload).suggestedFilename(), '第二份练习_题库_3题.csv'); + console.log('PASS SPA 页面与地址同步切换自动导出'); + + // URL 先改变、旧内容仍在时不得导出旧题;题目延后到达再导出。 + const beforeDelayed = downloads.length; + await page.evaluate(url => history.pushState({}, '', url), practiceURL('延迟练习')); + await page.waitForTimeout(2600); assert.equal(downloads.length, beforeDelayed); + const delayedDownload = page.waitForEvent('download'); + await page.evaluate(html => { + const doc = new DOMParser().parseFromString(html, 'text/html'); + document.querySelector('app-examtest').innerHTML = doc.querySelector('app-examtest').innerHTML; + }, fixture('延迟练习')); + assert.equal((await delayedDownload).suggestedFilename(), '延迟练习_题库_3题.csv'); + console.log('PASS SPA 旧页面残留与延迟加载等待'); + + const beforeMissing = downloads.length; + await page.evaluate(({ url, html }) => { + history.pushState({}, '', url); + document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML; + }, { url: practiceURL('缺答案练习'), html: fixture('缺答案练习', { missingAnswer: true }) }); + await page.waitForTimeout(2600); assert.equal(downloads.length, beforeMissing); + assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('缺少 1 个答案')); + const manual = page.waitForEvent('download'); await page.getByRole('button', { name: '导出 CSV', exact: true }).click(); + assert((await manual).suggestedFilename().includes('_不完整')); + const missing = await page.evaluate(() => QuestionBankCore.extract(document)); + assert.equal(missing.complete, false); assert.deepEqual(missing.missingAnswers, [3]); + console.log('PASS 缺答案不自动下载,手动导出标记不完整'); + + await page.evaluate(({ url, html }) => { + history.pushState({}, '', url); + document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML; + }, { url: practiceURL('缺题练习'), html: fixture('缺题练习', { cardCount: 4 }) }); + const beforeCount = downloads.length; + await page.waitForTimeout(2600); assert.equal(downloads.length, beforeCount); + assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('3 / 4')); + await page.evaluate(() => history.pushState({}, '', location.href.replace('flag=exercise', 'flag=exam'))); + await page.waitForTimeout(1100); assert.equal(await page.locator('#yian-question-bank-exporter').count(), 0); + assert.equal(await page.evaluate(() => window.answerClicks), 0); + console.log('PASS 缺题等待、考试页面不触发、不改变答题状态'); + + const sampleHTML = fs.existsSync(samplePath) ? fs.readFileSync(samplePath, 'utf8') : fixture(); + const tool = await context.newPage(); + tool.on('pageerror', error => errors.push(error.message)); + tool.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); }); + const toolURL = require('node:url').pathToFileURL(path.join(root, '离线导出题库.html')).href; + await tool.goto(toolURL); + assert.equal(tool.url(), toolURL); + assert.equal(await tool.title(), '离线导出题库'); + assert(await tool.getByRole('heading', { name: '从已保存的练习网页导出题库' }).isVisible()); + const sourceRequests = []; + tool.on('request', request => { if (!request.url().startsWith(origin)) sourceRequests.push(request.url()); }); + await tool.locator('#file').setInputFiles({ name: '谊安学.html', mimeType: 'text/html', buffer: Buffer.from(sampleHTML) }); + await tool.locator('#csv').waitFor({ state: 'visible' }); + await tool.waitForFunction(() => !document.getElementById('csv').disabled); + assert.equal(sourceRequests.length, 0, '解析网页时不应请求原网站资源'); + const realBank = await tool.evaluate(html => QuestionBankCore.extract(QuestionBankCore.parseHTML(html)), sampleHTML); + if (fs.existsSync(samplePath)) { + assert.equal(realBank.total, 120); assert.equal(realBank.answered, 120); assert.equal(realBank.expectedTotal, 120); + assert.equal(realBank.complete, true); + assert.deepEqual(realBank.questions.reduce((o, q) => (o[q.type] = (o[q.type] || 0) + 1, o), {}), { 单选: 40, 多选: 40, 判断: 40 }); + assert.equal(realBank.questions[0].answer, 'C'); assert.equal(realBank.questions[0].options[0].text, '2种'); + assert.equal(realBank.questions[20].answer, 'AB'); assert.equal(realBank.questions[40].answerText, 'A. 对'); + assert.equal(realBank.questions[100].answerText, 'B. 错'); + assert(realBank.questions.every(q => q.explanation === '')); + assert.deepEqual(realBank.questions.map(q => q.number), Array.from({ length: 120 }, (_, i) => i + 1)); + console.log('PASS 真实样本120题120答案,三种题型各40题、全部解析为空'); + } + for (const format of ['csv', 'json', 'txt']) { + const promise = tool.waitForEvent('download'); await tool.locator('#' + format).click(); + const file = await promise; await file.saveAs(path.join(qaDir, file.suggestedFilename())); + assert((await file.failure()) === null); + } + await tool.screenshot({ path: path.join(qaDir, 'offline-desktop.png'), fullPage: true }); + await tool.setViewportSize({ width: 390, height: 844 }); + assert(await tool.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)); + await tool.screenshot({ path: path.join(qaDir, 'offline-mobile.png'), fullPage: true }); + console.log('PASS 离线工具文件导入、三格式下载、无联网、桌面与手机布局'); + + // 验证原网页的文档级保存注释可以激活 file: 模式。 + const local = await context.newPage(); + const localHTML = '' + fixture('本地练习'); + const localPath = path.join(qaDir, 'local-practice.html'); fs.writeFileSync(localPath, localHTML); + await local.goto(require('node:url').pathToFileURL(localPath).href); + const localDownload = local.waitForEvent('download'); await local.addScriptTag({ content: scriptSource }); + assert.equal((await localDownload).suggestedFilename(), '本地练习_题库_3题.csv'); + assert.equal(errors.length, 0, '浏览器不应有控制台/运行时错误:' + errors.join('\n')); + console.log('PASS 本地保存网页自动识别与浏览器错误检查'); + console.log('验证截图和下载文件:' + qaDir); + } finally { + if (browser) await browser.close(); + await new Promise(resolve => server.close(resolve)); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/离线导出题库.html b/离线导出题库.html new file mode 100644 index 0000000..c432a49 --- /dev/null +++ b/离线导出题库.html @@ -0,0 +1,92 @@ + + + + + + + 离线导出题库 + + + +
+
LOCAL QUESTION BANK EXPORTER
+

从已保存的练习网页导出题库

+

先解压下载的压缩包,再选择其中的 HTML 文件。隐藏的参考答案也能读取,无需逐题点击“查看答案”。

+
+ +

保留网页旁的资源文件夹,方便后续查看图片。此工具会导出图片链接,不会加载或上传图片。

+
等待选择文件。
+
+
+ + +
+ + + + diff --git a/谊安学题库导出.user.js b/谊安学题库导出.user.js new file mode 100644 index 0000000..4a3f46f --- /dev/null +++ b/谊安学题库导出.user.js @@ -0,0 +1,477 @@ +// ==UserScript== +// @name 谊安学练习题库自动导出 +// @namespace yianxue-question-bank +// @version 1.0.0 +// @description 进入练习后自动导出题干、选项、参考答案和解析;读取隐藏答案。 +// @match http://10.119.67.7/clientcn/* +// @match https://10.119.67.7/clientcn/* +// @match file:///* +// @run-at document-idle +// @grant none +// @noframes +// ==/UserScript== + +/* 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); + +(function () { + 'use strict'; + + // 源代码:构建后连同 question-bank-core.js 一起装入单个油猴脚本。 + if (window.YianQuestionBankExporter) return; + const core = window.QuestionBankCore; + if (!core) throw new Error('题库提取核心未加载'); + const config = { autoExport: true, format: 'csv', settleMs: 1800 }; + const completed = new Set(); + let state = null; + let panel = null; + let statusNode = null; + let debounceTimer = null; + let previousPractice = false; + let previousRoot = null; + let previousSignature = ''; + + function routeInfo() { + const url = new URL(location.href); + let params = url.searchParams; + let path = url.pathname; + if (url.hash.startsWith('#/')) { + const hashUrl = new URL(url.hash.slice(1), url.origin); + params = hashUrl.searchParams; + path = hashUrl.pathname; + } + const root = document.querySelector('app-examtest, .pc_examtest'); + const local = url.protocol === 'file:'; + const practice = !!root && (local + ? /[?&]flag=exercise(?:&|$)/.test(core.savedSourceURL(document)) + : params.get('flag') === 'exercise' && /\/exam\/[^/]+\/questions\/?$/.test(path)); + return { practice, root, title: params.get('title') || '', key: path + '|' + (params.get('title') || ''), local }; + } + + function fingerprint(bank) { + return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(q => + [q.number, q.type, q.question, q.options, q.answer, q.explanation, q.images])]); + } + + function setStatus(message) { + if (statusNode && statusNode.textContent !== message) statusNode.textContent = message; + } + + function createPanel() { + if (panel) return; + panel = document.createElement('div'); + panel.id = 'yian-question-bank-exporter'; + panel.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483600;max-width:min(330px,calc(100vw - 32px));padding:14px;border:1px solid #d5dde5;border-radius:12px;background:#fff;color:#233447;box-shadow:0 4px 20px #0002;font:13px/1.6 system-ui,sans-serif;text-align:left'; + const heading = document.createElement('strong'); + heading.textContent = '练习题库导出'; + statusNode = document.createElement('div'); + statusNode.setAttribute('role', 'status'); + statusNode.style.cssText = 'margin:6px 0;white-space:pre-line'; + const actions = document.createElement('div'); + actions.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap'; + for (const format of ['csv', 'json', 'txt']) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = '导出 ' + format.toUpperCase(); + button.style.cssText = 'cursor:pointer;border:1px solid #d5dde5;border-radius:6px;background:#f5f8fa;padding:5px 9px;color:#233447;font:inherit'; + button.addEventListener('click', () => { + try { exportCurrent(format); } + catch (error) { setStatus('导出失败:' + error.message); } + }); + actions.append(button); + } + const label = document.createElement('label'); + label.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:8px;cursor:pointer'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = config.autoExport; + checkbox.addEventListener('change', () => { + config.autoExport = checkbox.checked; + tick(); + }); + label.append(checkbox, document.createTextNode('进入练习后自动导出 CSV')); + panel.append(heading, statusNode, actions, label); + document.body.append(panel); + } + + function exportCurrent(format = config.format) { + if (!routeInfo().practice) throw new Error('请先进入练习页面'); + const bank = core.extract(document, location.href); + if (!bank.total) throw new Error('页面还没有题目,请稍后重试'); + // 手动导出可保存不完整内容,文件及提示都会标记缺失。 + core.download(bank, format); + setStatus((bank.complete ? '已发起下载:' : '已发起不完整题库下载:') + bank.title + '\n' + + bank.total + ' 题,' + bank.answered + ' 个答案' + + (bank.complete ? '。' : ',请检查缺失内容。') + + '\n若未出现文件,请点击上方导出按钮。'); + return bank; + } + + function tick() { + const route = routeInfo(); + if (!route.practice) { + if (panel) panel.remove(); + panel = statusNode = null; + state = null; + previousPractice = false; + previousRoot = route.root; + return; + } + createPanel(); + const bank = core.extract(document, location.href); + const signature = fingerprint(bank); + const now = Date.now(); + if (!state || state.key !== route.key) { + // SPA 改 URL 后旧题目可能仍留在 DOM,等待题目或组件更新。 + const transitioning = !!previousRoot && !!(previousPractice || previousSignature) + && previousRoot === route.root && previousSignature === signature; + state = { key: route.key, signature, since: now, initialRoot: route.root, + initialSignature: signature, waitingForReplacement: transitioning }; + } + if (state.waitingForReplacement && (route.root !== state.initialRoot || signature !== state.initialSignature)) { + state.waitingForReplacement = false; + state.since = now; + } + previousPractice = true; + previousRoot = route.root; + previousSignature = signature; + if (state.signature !== signature) { + state.signature = signature; + state.since = now; + } + if (state.waitingForReplacement || (route.title && route.title.trim() !== bank.title.trim())) { + setStatus('等待本次练习的题目加载…'); + return; + } + if (!bank.total || now - state.since < config.settleMs) { + setStatus('正在读取题目与隐藏答案…'); + return; + } + if (bank.expectedTotal !== null && bank.expectedTotal !== bank.total) { + setStatus('已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。\n若页面分页,请逐页导出;手动导出会标记不完整。'); + return; + } + if (!bank.complete) { + setStatus('已读取 ' + bank.total + ' 题,缺少 ' + bank.missingAnswers.length + ' 个答案。\n自动导出等待答案加载;可手动保存现有内容。'); + return; + } + const exportKey = state.key + '|' + signature; + if (completed.has(exportKey)) return; + if (!config.autoExport) { + setStatus(bank.title + '\n' + bank.total + ' 题,答案齐全,可点击导出。'); + return; + } + try { + exportCurrent(config.format); + completed.add(exportKey); + } catch (error) { + config.autoExport = false; + const checkbox = panel.querySelector('input'); + if (checkbox) checkbox.checked = false; + setStatus('自动下载失败:' + error.message + '\n请点击导出按钮重试。'); + console.error('[谊安学题库导出]', error); + } + } + + const observer = new MutationObserver(records => { + if (records.every(record => panel && (record.target === panel || panel.contains(record.target)))) return; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(tick, 300); + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + const timer = setInterval(tick, 750); // 同时覆盖 Angular 路由和返回/前进。 + window.YianQuestionBankExporter = Object.freeze({ + exportCurrent, + collectCurrent: () => core.extract(document, location.href), + stop: () => { observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer); if (panel) panel.remove(); } + }); + tick(); +})();