// ==UserScript== // @name 超星可见题目本地题库助手 // @namespace local.chaoxing.visible-question-bank // @version 0.1.1 // @description 采集当前超星页面中已经可见的题目、答案和解析,保存为本地复习题库,支持导出 JSON/Markdown/CSV。 // @author Codex // @match *://*.chaoxing.com/exam-ans/exam/test/* // @match *://*.chaoxing.com/exam/test/* // @match file:///*/test1.html // @grant GM_setClipboard // @run-at document-idle // @license MIT // ==/UserScript== ;(function () { 'use strict' const STORE_KEY = 'cx_visible_question_bank_v1' const PANEL_ID = 'cx-qb-panel' // 发布到网上后,把这里改成你的练习网站地址,例如: // https://your-name.github.io/chaoxing-question-bank/question-bank-practice.html const PRACTICE_SITE_URL = 'https://lshfx.github.io/chaoxing-question-bank/' const state = { lastScan: [], } function normalizeText(text) { return String(text || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .trim() } function normalizeMultiline(text) { return String(text || '') .replace(/\u00a0/g, ' ') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim() } function textOf(el) { return el ? normalizeText(el.innerText || el.textContent || '') : '' } function htmlOf(el) { return el ? el.innerHTML.trim() : '' } function isVisible(el) { if (!el || !el.ownerDocument) return false const win = el.ownerDocument.defaultView let node = el while (node && node.nodeType === 1) { const style = win.getComputedStyle(node) if ( style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0' ) { return false } node = node.parentElement } return true } function visibleTextList(root, selector) { return Array.from(root.querySelectorAll(selector)) .filter(isVisible) .map(textOf) .filter(Boolean) } function getPaperTitle() { return ( textOf(document.querySelector('.mark_title')) || document.title || '超星题库' ) } function getSectionTitle(questionEl) { let node = questionEl.previousElementSibling while (node) { if (node.matches && node.matches('.type_tit')) return textOf(node) node = node.previousElementSibling } const parentTitle = questionEl .closest('.mark_item') ?.querySelector('.type_tit') return textOf(parentTitle) } function parseMeta(questionEl) { const metaText = textOf( questionEl.querySelector('.mark_name .colorShallow'), ) const match = metaText.match( /[((]\s*([^,,))]+)\s*[,,]\s*([\d.]+)\s*分\s*[))]/, ) return { type: match ? match[1] : metaText.replace(/[()()]/g, ''), score: match ? Number(match[2]) : null, } } function parseQuestionNo(questionEl) { const h3 = questionEl.querySelector('.mark_name') if (!h3) return '' const firstText = Array.from(h3.childNodes) .filter((node) => node.nodeType === Node.TEXT_NODE) .map((node) => node.textContent) .join(' ') const match = firstText.match(/(\d+)\s*[..、]/) return match ? match[1] : '' } function parseOptions(questionEl) { return Array.from(questionEl.querySelectorAll('.qtDetail li')) .filter(isVisible) .map((li, index) => { const raw = textOf(li) const match = raw.match(/^([A-Z])\s*[..、]\s*(.*)$/i) return { key: match ? match[1].toUpperCase() : String.fromCharCode(65 + index), text: match ? normalizeText(match[2]) : raw, raw, html: htmlOf(li), } }) } function getImageUrls(questionEl) { return Array.from(questionEl.querySelectorAll('img')) .filter(isVisible) .map( (img) => img.getAttribute('data-original') || img.currentSrc || img.src || img.getAttribute('src'), ) .filter(Boolean) .map((src) => new URL(src, location.href).href) } function getStableHash(input) { let hash = 2166136261 for (let i = 0; i < input.length; i += 1) { hash ^= input.charCodeAt(i) hash = Math.imul(hash, 16777619) } return `q_${(hash >>> 0).toString(16).padStart(8, '0')}` } function parseQuestion(questionEl) { const meta = parseMeta(questionEl) const qtContent = questionEl.querySelector('.qtContent') const options = parseOptions(questionEl) const studentAnswers = visibleTextList(questionEl, '.stuAnswerContent') const rightAnswers = visibleTextList(questionEl, '.rightAnswerContent') const analysis = visibleTextList(questionEl, '.qtAnalysis').join('\n') const questionText = normalizeMultiline( qtContent?.innerText || qtContent?.textContent || '', ) const rawId = questionEl.getAttribute('data') || questionEl.id || '' const section = getSectionTitle(questionEl) const source = getPaperTitle() const hashInput = [ meta.type, questionText, options.map((item) => item.raw).join('|'), ].join('\n') return { id: getStableHash(hashInput), platformId: rawId, source, section, number: parseQuestionNo(questionEl), type: meta.type, score: meta.score, question: questionText, questionHtml: htmlOf(qtContent), options, myAnswer: studentAnswers.join('; '), correctAnswer: rightAnswers.join('; '), analysis: normalizeMultiline(analysis), imageUrls: getImageUrls(questionEl), pageUrl: location.href, capturedAt: new Date().toISOString(), } } function scanVisibleQuestions() { return Array.from(document.querySelectorAll('.questionLi')) .filter(isVisible) .map(parseQuestion) .filter( (item) => item.question || item.options.length || item.correctAnswer || item.myAnswer, ) } function loadBank() { try { const data = JSON.parse(localStorage.getItem(STORE_KEY) || '[]') return Array.isArray(data) ? data : [] } catch (error) { console.warn('[超星题库助手] 读取本地题库失败', error) return [] } } function saveBank(items) { localStorage.setItem(STORE_KEY, JSON.stringify(items)) } function mergeQuestions(existing, incoming) { const map = new Map(existing.map((item) => [item.id, item])) let added = 0 let updated = 0 incoming.forEach((item) => { const old = map.get(item.id) if (!old) { map.set(item.id, item) added += 1 return } const merged = { ...old, ...item, myAnswer: item.myAnswer || old.myAnswer, correctAnswer: item.correctAnswer || old.correctAnswer, analysis: item.analysis || old.analysis, questionHtml: item.questionHtml || old.questionHtml, imageUrls: Array.from( new Set([...(old.imageUrls || []), ...(item.imageUrls || [])]), ), capturedAt: item.capturedAt, } map.set(item.id, merged) updated += 1 }) return { items: Array.from(map.values()), added, updated, } } function escapeMarkdown(text) { return String(text || '').replace(/\|/g, '\\|') } function toMarkdown(items) { return items .map((item, index) => { const lines = [] lines.push(`## ${index + 1}. [${item.type || '题目'}] ${item.question}`) lines.push('') if (item.options?.length) { item.options.forEach((option) => lines.push(`- ${option.key}. ${option.text}`), ) lines.push('') } if (item.myAnswer) lines.push(`我的答案:${item.myAnswer}`) if (item.correctAnswer) lines.push(`正确答案:${item.correctAnswer}`) if (item.analysis) lines.push(`解析:${item.analysis}`) if (item.source) lines.push(`来源:${item.source}`) if (item.section) lines.push(`分组:${item.section}`) if (item.imageUrls?.length) { lines.push('图片:') item.imageUrls.forEach((url) => lines.push(`- ${url}`)) } return lines.join('\n') }) .join('\n\n---\n\n') } function toCsv(items) { const headers = [ 'id', 'source', 'section', 'number', 'type', 'score', 'question', 'options', 'myAnswer', 'correctAnswer', 'analysis', 'pageUrl', 'capturedAt', ] const rows = items.map((item) => headers .map((key) => { const value = key === 'options' ? item.options .map((option) => `${option.key}. ${option.text}`) .join('\n') : item[key] return `"${String(value ?? '').replace(/"/g, '""')}"` }) .join(','), ) return [`\uFEFF${headers.join(',')}`, ...rows].join('\n') } function download(filename, content, type) { const blob = new Blob([content], { type }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() a.remove() setTimeout(() => URL.revokeObjectURL(url), 1000) } function copyText(text) { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text') return Promise.resolve() } return navigator.clipboard.writeText(text) } function buildPracticePayload(items) { return { type: 'cx-question-bank-import', version: 1, source: 'chaoxing-visible-question-bank.user.js', sentAt: new Date().toISOString(), bank: items, } } function openPracticeSite(items) { if (!items.length) { setStatus('没有可发送的题目,请先扫描或保存。') return } if (!PRACTICE_SITE_URL || PRACTICE_SITE_URL.includes('example.com')) { setStatus('请先在脚本中配置 PRACTICE_SITE_URL 为你的练习网站地址。') return } const payload = buildPracticePayload(items) const target = window.open(PRACTICE_SITE_URL, '_blank') if (!target) { setStatus('浏览器拦截了弹窗,请允许打开练习网站。') return } let attempts = 0 const timer = window.setInterval(() => { attempts += 1 target.postMessage(payload, '*') if (attempts >= 20) window.clearInterval(timer) }, 500) setStatus(`已打开练习网站,正在发送 ${items.length} 题。`) } function filename(ext) { const title = getPaperTitle() .replace(/[\\/:*?"<>|]/g, '_') .slice(0, 50) || 'chaoxing-question-bank' const date = new Date().toISOString().slice(0, 10) return `${title}_${date}.${ext}` } function setStatus(text) { const status = document.querySelector(`#${PANEL_ID} .cx-qb-status`) if (status) status.textContent = text } function renderPreview(items) { const preview = document.querySelector(`#${PANEL_ID} .cx-qb-preview`) if (!preview) return preview.innerHTML = '' const shown = items.slice(0, 5) shown.forEach((item) => { const row = document.createElement('div') row.className = 'cx-qb-item' row.innerHTML = `