// ==UserScript== // @name 学习通万能题目 提取器 // @namespace http://tampermonkey.net/ // @version 5.0.0 // @description 自动提取学习通作业/考试/章节测验题目并一键导出Word。智能识别试卷标题,支持参考答案表格与解析汇总生成。 // @author kkkxfr // @match *://*.chaoxing.com/exam-ans/exam/test/reVersionPaperMarkContentNew* // @match *://*.chaoxing.com/exam-ans/exam/test/look* // @match *://*.chaoxing.com/mooc-ans/work/selectWorkQuestionYiPiYue* // @match *://*.mooc1.chaoxing.com/mooc-ans/mooc2/work/view?* // @match *://*.chaoxing.com/work/doHomeWorkNew* // @match *://*.mooc-ans/work/doHomeWorkNew* // @match *://*.chaoxing.com/api/selectWorkQuestionYiPiYue* // @grant GM_setClipboard // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // ==/UserScript== (function(){ 'use strict'; const AUTO_EXTRACT_KEY = 'chaoxing_auto_extract_enabled'; /* ---------- 1. 基础辅助函数 ---------- */ function normalizeText(s){ if(!s) return ''; return String(s).replace(/\u00A0/g,' ').replace(/\r?\n/g,' ').replace(/\s+/g,' ').trim(); } // 【核心修复】:将 .rightAnswerContent 从移除列表中剔除! // 之前的版本这里误删了答案容器,导致第一步提取失败 function getCleanText(el){ if(!el) return ''; const c = el.cloneNode(true); // 仅移除无关元素,保留答案类名 c.querySelectorAll('script, style, .mark_letter, .Cy_ulTop').forEach(x => x.remove()); let html = c.innerHTML || ''; html = html.replace(/
]*>/gi, '\n').replace(/
]*>/gi, '\n');
const tmp = document.createElement('div');
tmp.innerHTML = html;
return normalizeText(tmp.textContent || tmp.innerText || '');
}
function xmlEscape(s){ if(!s) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }
function stripLeadingLabels(s){
if(!s) return '';
let t = normalizeText(s);
while(/^(?:答案解析|正确答案|参考答案|答案|解析)[::\s]*/i.test(t)){
t = t.replace(/^(?:答案解析|正确答案|参考答案|答案|解析)[::\s]*/i, '');
}
return t.trim();
}
function pullLeadingScore(stem){
if(!stem) return {score:'', rest: ''};
const s = stem.trim();
const m = s.match(/^[,,]?\s*[\((]?\s*([0-9]+(?:\.[0-9]+)?)\s*分\s*[\)\)]?\s*(.*)$/);
if(m){
return { score: m[1] + ' 分', rest: (m[2]||'').trim() };
}
return { score: '', rest: s };
}
/* ---------- 2. 页面标题提取 ---------- */
function getPageTitle(){
const selectors=[
'#workTitle', '.work_title', 'h3.mark_title', '.courseName', '.course-title',
'.mark_title', '.title', '.detailsHead', 'h1', 'h2', 'h3', '.fanyaMarking_left.whiteBg'
];
for(const sel of selectors){
try{
const els = document.querySelectorAll(sel);
for (const el of els) {
const t = normalizeText(el.innerText||el.textContent||'');
if(t && t.length > 2 &&
!/^(?:error|404|登录|未找到|提示|查看已批阅|作业详情)$/i.test(t) &&
!/[((]\s*(?:单选|多选|判断|填空)/.test(t) &&
!/^[0-9]+[\.、]\s/.test(t)
) {
return t.split(/\r?\n/)[0];
}
}
} catch(e){}
}
const docTitle = normalizeText(document.title||'');
if(docTitle && docTitle.length > 2 && !/查看已批阅|作业详情/i.test(docTitle)) return docTitle;
return '试卷';
}
/* ---------- 3. 题目与选项定位 ---------- */
function findOptionContainers(qNode){
const sels = ['.mark_letter','.Cy_ulTop','.Zy_ulTop','.options','.option-list','.optionsList'];
const found = [];
for(const s of sels){
const el = qNode.querySelector(s);
if(el) found.push(el);
}
const uls = Array.from(qNode.querySelectorAll('ul,ol'));
uls.forEach(u=>{
if((u.className && /ul|options|Cy_ulTop|Zy_ulTop|mark_letter/.test(u.className)) || (u.querySelector('li') && !u.textContent.includes('我的答案'))) {
if(!found.includes(u)) found.push(u);
}
});
return found;
}
/* ---------- 4. 答案提取逻辑 (修复核心 v3.7.2) ---------- */
function extractCorrectAnswerFromNode(qNode){
if(!qNode) return null;
// A. 优先尝试标准答案容器
// 新增 .mark_key 和 .mark_answer 以匹配你提供的 HTML 结构
const selectors=[
'.rightAnswerContent', // 最精准,直接就是答案
'.mark_key', // 包含 "我的答案:A 正确答案:C"
'.mark_answer', // 更外层
'.right_answer',
'.answerRight',
'.Py_answer',
'.answer-content',
'.answer'
];
for (const s of selectors) {
const els = qNode.querySelectorAll(s);
for (const el of els) {
// 策略 A1: 如果元素本身就是 .rightAnswerContent,直接取值
if (el.classList.contains('rightAnswerContent') || el.querySelector('.rightAnswerContent')) {
// 如果内部还有 .rightAnswerContent,优先取内部的
const inner = el.querySelector('.rightAnswerContent') || el;
const t = getCleanText(inner);
if(t && /[A-D对错√×]/.test(t)) return t.replace(/\s+/g,'');
}
// 策略 A2: 分析容器文本 (例如 .mark_key)
const t = getCleanText(el);
if (t && !/未提取|未作答|暂无/.test(t)) {
// 尝试从文本中解析 "正确答案: C"
const match = t.match(/(?:正确答案|参考答案|答案)[::\s]*([A-Da-d对错√×]+)/i);
if (match) return match[1].trim();
// 如果文本很短且像答案,直接返回
if (/^[A-D]+$/.test(t.replace(/\s/g,''))) return t.replace(/\s/g,'');
}
}
}
// B. “手术刀”策略:兜底提取
let clone = qNode.cloneNode(true);
clone.querySelectorAll('ul, ol, .mark_letter, .Cy_ulTop, .options, .option-list').forEach(el => el.remove());
clone.querySelectorAll('.mark_name, .Cy_TItle, .Qtitle, h3').forEach(el => el.remove());
const remainingText = normalizeText(clone.innerText || clone.textContent || '');
// 正则:优先找“正确答案”,找不到再找“我的答案”
// 你的 HTML 里正确答案在我的答案后面,所以这个正则应该能匹配到
const exactMatch = remainingText.match(/(?:正确答案|参考答案|答案)[::\s]*([A-Da-d对错√×]+)(?![^A-D\.])/i);
if(exactMatch) return exactMatch[1].trim();
// 找不到正确答案,退而求其次找“我的答案”
const myMatch = remainingText.match(/(?:我的答案|My Answer)[::\s]*([A-Da-d对错√×]+)/i);
if(myMatch) return myMatch[1].trim();
return null;
}
function normalizeAnswerString(s, qType){
if(!s) return '';
s = normalizeText(s);
const blanks = s.match(/(?:\(\s*\d+\s*\)|(\s*\d+\s*))[\s\S]*?(?=(?:\(\s*\d+\s*\)|(\s*\d+\s*)|$))/g);
if(blanks && blanks.length > 1) return blanks.map(b => normalizeText(b)).join(' ');
const letters = s.match(/[A-D]/gi);
if(letters){
const upper = letters.map(x => x.toUpperCase());
const uniq = Array.from(new Set(upper));
// 单选题防误判
if((qType === '单选题' || qType === '单选') && uniq.length > 1) {
const strictMatch = s.match(/(?:正确答案|答案)[::\s]*([A-D])/i);
if(strictMatch) return strictMatch[1].toUpperCase();
// 如果无法确定,且有3个以上选项,返回空;否则(如AB)保留
if(uniq.length > 2) return '';
return uniq.sort().join('');
}
return uniq.sort().join('');
}
if(/^(对|正确|true|√)$/i.test(s)) return '对';
if(/^(错|错误|false|×)$/i.test(s)) return '错';
return s;
}
/* ---------- 5. 题目解析 ---------- */
function parseQuestionNode(node){
try{
const titleEl = node.querySelector('.mark_name, .Cy_TItle, .Qtitle, .question-title, .marking-title') || node;
let stemRaw = '';
if(titleEl === node) {
let clone = node.cloneNode(true);
clone.querySelectorAll('.mark_letter, .Cy_ulTop, .Zy_ulTop, .options, ul, ol, .Py_answer').forEach(el => el.remove());
stemRaw = getCleanText(clone);
} else {
stemRaw = getCleanText(titleEl);
}
let stem = normalizeText(stemRaw);
stem = stem.replace(/(我的答案|My Answer)[::\s]*.*$/i, '');
stem = stem.replace(/^\d+[\.\、\s]*【.*?】\s*/, '');
stem = stem.replace(/^\d+[\.\、]\s*/, '');
stem = stem.replace(/^[((【\[]\s*(?:单选题|多选题|判断题|填空题|单选|多选|判断|填空)[^))】\]]*[))】\]]\s*/, '');
stem = stem.trim();
const wholeTxt = (node.innerText||node.textContent||'').toLowerCase();
let type = '单选题';
if(/多选|multiple|multi/i.test(wholeTxt)) type = '多选题';
else if(/判断|是非|对错|true|false/i.test(wholeTxt)) type = '判断题';
else if(/填空|空格|空/i.test(wholeTxt)) type = '填空题';
else {
const dt = node.getAttribute && (node.getAttribute('data-type') || node.getAttribute('qtype') || '');
if(dt && /multi|checkbox|多选/i.test(dt)) type = '多选题';
}
const options = [];
const containers = findOptionContainers(node);
if(containers.length > 0){
let chosen = containers.find(c => c.querySelector && c.querySelector('li'));
if(!chosen) chosen = containers[0];
if(chosen){
const lis = Array.from(chosen.querySelectorAll('li'));
const items = lis.length ? lis : Array.from(chosen.children);
items.forEach((li, idx)=>{
let label = (li.querySelector('.mark_letter_span')?.innerText || li.querySelector('i.fl')?.innerText || '').replace(/[^A-Za-z0-9]/g,'').trim();
let clone = li.cloneNode(true);
if(clone.querySelector('.mark_letter_span')) clone.querySelector('.mark_letter_span').remove();
if(clone.querySelector('i.fl')) clone.querySelector('i.fl').remove();
let text = normalizeText(clone.textContent || clone.innerText || '');
const m = text.match(/^[\(\[]?\s*([A-Da-d])[\.\、\)\]\s]+/);
if(!label && m){
label = m[1].toUpperCase();
text = text.replace(/^[\(\[]?\s*([A-Da-d])[\.\、\)\]\s]+/,'').trim();
}
if(!label) label = String.fromCharCode(65 + (idx % 26));
if(text.length > 0 && text !== '查看解析') {
options.push({ key: label.toUpperCase(), text: text });
}
});
}
}
let answerRaw = extractCorrectAnswerFromNode(node) || '';
let answer = normalizeAnswerString(answerRaw, type);
answer = stripLeadingLabels(answer);
const analysisEl = node.querySelector('.analysisDiv, .analysis, .py_analyse, .Py_addpy .pingyu, .explain, .analysisTxt');
let analysis = analysisEl ? getCleanText(analysisEl) : '';
analysis = stripLeadingLabels(analysis);
const sectionTitle = findPrevSectionTitle(node) || '';
return { type, stem, options, answer, analysis, sectionTitle };
} catch(e){
console.error('parseQuestionNode error', e);
return null;
}
}
/* ---------- 6. 章节标题查找 ---------- */
function findPrevSectionTitle(node){
const headerSels=['.type_tit','.Cy_TItle1','h2','.markTitle','.typeTitle','.mark_section','.section-title'];
let cur=node;
for(let i=0;i<15;i++){
if(!cur) break;
let ps=cur.previousElementSibling;
for(let k=0;k<30 && ps;k++){
for(const sel of headerSels) {
if(ps.matches && ps.matches(sel)) {
const t = normalizeText(ps.innerText||ps.textContent||'');
if(t && !/^\d+[.、]/.test(t) && !/(\d+.*分)/.test(t) && !/\(\d+.*分\)/.test(t)) return t;
}
}
ps=ps.previousElementSibling;
}
cur=cur.parentElement;
}
const headers = Array.from(document.querySelectorAll('h1,h2,h3,.type_tit,.Cy_TItle1,.markTitle'));
let candidate = null;
for(const h of headers){
if(h.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) break;
const t = normalizeText(h.innerText||h.textContent||'');
if(t && !/^\d+[.、]/.test(t)) candidate = t;
}
if(candidate) return candidate;
return '';
}
/* ---------- 7. 构建试卷对象 ---------- */
function buildStructuredPaperFromDOM(){
const root = document.querySelector('.fanyaMarking') || document.querySelector('.mark_table') || document.body;
const selectors = ['.questionLi','.TiMu','.question-item','.mark_question','.exam-question','.paper-question','.Ques','.questionBox'];
const nodeSet = new Set();
selectors.forEach(sel=> document.querySelectorAll(sel).forEach(n=> { if(n && root.contains(n)) nodeSet.add(n); }));
if(nodeSet.size === 0){
Array.from(root.querySelectorAll('*')).forEach(n=>{
try{
if(!(n instanceof HTMLElement)) return;
const len = (n.innerText||'').length;
const hasStem = !!n.querySelector('.mark_name, .Cy_TItle');
if(hasStem || (len>12 && n.querySelector('ul') && n.innerText.includes('A'))) nodeSet.add(n);
}catch(e){}
});
}
const arr = Array.from(nodeSet).sort((a,b)=> (a.compareDocumentPosition(b) & 2) ? 1 : -1);
const parsedQuestions = [];
const seenFp = new Set();
for(const node of arr){
const q = parseQuestionNode(node);
if(!q) continue;
const fp = (q.stem+'||'+ (q.options||[]).map(o=>o.text).join('||')).slice(0,2000);
if(seenFp.has(fp)) continue;
seenFp.add(fp);
parsedQuestions.push(q);
}
const sectionsMap = new Map();
const pageTitle = getPageTitle();
for(const q of parsedQuestions){
const key = q.sectionTitle && q.sectionTitle.trim() ? q.sectionTitle.trim() : '未分组';
if(!sectionsMap.has(key)) sectionsMap.set(key, []);
sectionsMap.get(key).push(q);
}
if(sectionsMap.size === 1 && sectionsMap.has('未分组')){
sectionsMap.set(pageTitle, sectionsMap.get('未分组'));
sectionsMap.delete('未分组');
}
const sections = [];
for(const [title, qlist] of sectionsMap.entries()){
sections.push({ title, qlist });
}
let counter = 1;
const paper = { title: pageTitle, sections: [] };
for(const s of sections){
const sec = { title: s.title, questions: [] };
for(const q of s.qlist){ q.no = counter++; sec.questions.push(q); }
paper.sections.push(sec);
}
return paper;
}
/* ---------- 8. DOCX 生成核心 ---------- */
function buildContentTypes(){ return '