// ==UserScript==
// @name 超星作业题库提取器
// @namespace http://tampermonkey.net/
// @version 2.2
// @description 提取超星作业答案,升级为个人题库管理器。全面突破自建题库抓取;修复大题库遗漏与分页问题;支持双模式TXT导出;支持反反调试、未考题目强制抓取。兼容新旧版UI。仍有少量bug,修累了
// @author 毫厘
// @match *://*.chaoxing.com/*
// @match *://*.edu.cn/*
// @grant GM_setClipboard
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// 保持数据库 KEY 不变以免清空老用户的已有数据
const DB_KEY = 'cx_db_v2.0';
const AUTO_EXTRACT_KEY = 'cx_auto_ext_v2.0';
const FETCH_ALL_KEY = 'cx_fetch_all_v2.0';
const ANTI_DEBUG_KEY = 'cx_anti_debug_v2.0';
const COM_NOTIFY = 'cx_com_notify_v2';
const COM_RENDER = 'cx_com_render_v2';
const isTopWindow = (window === window.top);
// =========================================================
// 0. 网页反调试拦截 (必须最先执行)
// =========================================================
if (GM_getValue(ANTI_DEBUG_KEY, false)) {
try {
const _window = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
console.log("[反反调试脚本] 已在最底层注入,准备拦截 debugger...");
const originalConstructor = _window.Function.prototype.constructor;
_window.Function.prototype.constructor = function(...args) {
if (args && args.length > 0) {
const code = args[args.length - 1];
if (typeof code === 'string' && code.includes('debugger')) {
return function() {};
}
}
return originalConstructor.apply(this, args);
};
const originalEval = _window.eval;
_window.eval = function(code) {
if (typeof code === 'string' && code.includes('debugger')) {
code = code.replace(/debugger/ig, '');
}
return originalEval.call(this, code);
};
const originalSetInterval = _window.setInterval;
_window.setInterval = function(func, delay, ...args) {
if (typeof func === 'string' && func.includes('debugger')) {
func = func.replace(/debugger/ig, '');
} else if (typeof func === 'function' && func.toString().includes('debugger')) {
func = function() {};
}
return originalSetInterval.call(this, func, delay, ...args);
};
const originalSetTimeout = _window.setTimeout;
_window.setTimeout = function(func, delay, ...args) {
if (typeof func === 'string' && func.includes('debugger')) {
func = func.replace(/debugger/ig, '');
} else if (typeof func === 'function' && func.toString().includes('debugger')) {
func = function() {};
}
return originalSetTimeout.call(this, func, delay, ...args);
};
} catch (e) {
console.error("Anti-Debug Inject Failed: ", e);
}
}
// =========================================================
// 1. 样式注入 (原生按钮样式 + 主面板样式)
// =========================================================
const injectStyles = setInterval(() => {
if (document.head) {
clearInterval(injectStyles);
GM_addStyle(`
#cx-export-btn { display: inline-block; padding: 0 15px; background: #6f42c1; border-radius: 35px; line-height: 32px; color: #fff; font-size: 14px; cursor: pointer; margin-left: 10px; margin-right: 10px; border: none; font-weight: bold; transition: all 0.3s; box-shadow: 0 2px 6px rgba(111,66,193,0.3); vertical-align: middle; z-index: 999; }
#cx-export-btn:hover:not(:disabled) { background: #59339d; transform: translateY(-1px); }
#cx-export-btn:disabled { background: #adb5bd; cursor: not-allowed; box-shadow: none; transform: none; }
#extractQuizButtonGM_merged { position: fixed; top: 160px; right: 20px; z-index: 2147483647; padding: 10px 15px; background-color: #28a745; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; box-shadow: 0 2px 5px rgba(40,167,69,0.4); transition: all 0.3s; }
#extractQuizButtonGM_merged:hover:not(:disabled) { background-color: #218838; }
#extractQuizButtonGM_merged:disabled { background-color: #5a6268; cursor: not-allowed; }
#cx-qb-float-btn { position: fixed; right: 20px; top: 100px; z-index: 2147483647; background: #007bff; color: white; border: none; padding: 12px 20px; border-radius: 50px; cursor: pointer; font-size: 14px; font-weight: bold; box-shadow: 0 4px 12px rgba(0,123,255,0.4); transition: all 0.3s; display: flex; align-items: center; gap: 8px; }
#cx-qb-float-btn:hover { background: #0056b3; transform: scale(1.05); }
#cx-qb-main-panel { position: fixed; top: 80px; right: 20px; width: 420px; max-height: 85vh; background: #f8f9fa; z-index: 2147483647; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.25); display: none; flex-direction: column; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; border: 1px solid #dee2e6; }
.cx-qb-header { background: #fff; padding: 12px 20px; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; cursor: move; user-select: none; }
.cx-qb-title { margin: 0; font-size: 16px; color: #212529; font-weight: bold; display: flex; align-items: center; gap: 8px; }
.cx-qb-controls { display: flex; gap: 10px; }
.cx-qb-icon-btn { background: transparent; border: none; font-size: 16px; color: #6c757d; cursor: pointer; padding: 4px; border-radius: 4px; transition: background 0.2s; display: flex; align-items: center; justify-content: center;}
.cx-qb-icon-btn:hover { background: #e9ecef; color: #212529; }
.cx-qb-content { flex: 1; overflow-y: auto; background: #f8f9fa; position: relative; min-height: 300px; }
.cx-qb-view-list { padding: 15px; }
.cx-qb-task-item { background: #fff; border: 1px solid #e9ecef; border-radius: 8px; padding: 15px; margin-bottom: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); transition: box-shadow 0.2s; }
.cx-qb-task-item:hover { box-shadow: 0 4px 10px rgba(0,0,0,0.08); border-color: #dee2e6; }
.cx-qb-task-info { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.cx-qb-task-info h4 { margin: 0; color: #343a40; font-size: 15px; flex: 1; word-break: break-all; line-height: 1.4; }
.cx-qb-task-info span { font-size: 12px; background: #e9ecef; color: #495057; padding: 2px 8px; border-radius: 20px; margin-left: 10px; white-space: nowrap; }
.cx-qb-task-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.cx-qb-task-actions button { flex: 1; padding: 6px 0; font-size: 12px; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; transition: background 0.2s; }
.btn-view { background: #e2eafc; color: #3b5bdb; } .btn-view:hover { background: #c5d5fc; }
.btn-exp-txt { background: #e6fcf5; color: #0ca678; } .btn-exp-txt:hover { background: #c3fae8; }
.btn-exp-json { background: #fff3bf; color: #f59f00; } .btn-exp-json:hover { background: #ffec99; }
.btn-del { background: #ffe3e3; color: #c92a2a; flex: 0.5; } .btn-del:hover { background: #ffc9c9; }
.cx-qb-view-detail { padding: 15px; display: none; background: #fff; min-height: 100%; }
.cx-detail-header { display: flex; align-items: center; gap: 12px; margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px dashed #e9ecef; }
.cx-detail-header button { padding: 6px 12px; border: none; background: #f1f3f5; border-radius: 6px; cursor: pointer; font-weight: bold; color: #495057; }
.cx-detail-header button:hover { background: #e9ecef; }
.cx-detail-header h3 { margin: 0; font-size: 15px; color: #212529; flex: 1; word-break: break-all; }
.cx-q-item { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 15px; border: 1px solid #e9ecef; }
.cx-q-raw { white-space: pre-wrap; font-size: 13px; color: #343a40; line-height: 1.6; font-family: Consolas, "Courier New", monospace; }
.cx-qb-view-settings { padding: 20px; display: none; background: #fff; min-height: 100%; }
.cx-setting-item { display: flex; justify-content: space-between; align-items: center; padding: 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef; margin-bottom: 15px; }
.cx-setting-info h4 { margin: 0 0 5px 0; color: #212529; font-size: 15px; }
.cx-setting-info p { margin: 0; color: #868e96; font-size: 12px; }
.cx-switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0;}
.cx-switch input { opacity: 0; width: 0; height: 0; }
.cx-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 24px; }
.cx-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .cx-slider { background-color: #28a745; }
input:checked + .cx-slider:before { transform: translateX(20px); }
.cx-qb-footer { background: #fff; padding: 12px 15px; border-top: 1px solid #e9ecef; display: flex; justify-content: space-between; }
.cx-qb-footer button { padding: 6px 10px; font-size: 12px; border: none; border-radius: 4px; cursor: pointer; color: #495057; background: #f1f3f5; font-weight: bold; }
.cx-qb-footer button:hover { background: #e9ecef; }
.btn-g-clear { color: #c92a2a !important; background: #ffe3e3 !important; } .btn-g-clear:hover { background: #ffc9c9 !important; }
.cx-empty-state { text-align: center; color: #adb5bd; padding: 50px 20px; font-size: 14px; line-height: 1.6; }
#cx-top-notification { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); color: white; padding: 12px 25px; border-radius: 6px; box-shadow: 0 4px 10px rgba(0,0,0,0.15); z-index: 2147483647; font-size: 14px; display: none; text-align: center; font-weight: bold; transition: opacity 0.3s; pointer-events:none; }
`);
}
}, 50);
// =========================================================
// 2. 本地数据库逻辑
// =========================================================
function getDB() {
let defaultDB = { version: "2.0", tasks: {} };
let data = GM_getValue(DB_KEY, null);
if (data) { try { return JSON.parse(data); } catch(e) { return defaultDB; } }
return defaultDB;
}
function saveDB(db) { GM_setValue(DB_KEY, JSON.stringify(db)); }
function mergeQuestions(oldArr, newArr) {
let map = new Map();
oldArr.forEach(q => map.set(q.pureQText, q));
newArr.forEach(q => {
let key = q.pureQText;
if (!map.has(key)) map.set(key, q);
else {
let existing = map.get(key);
if (existing.answer.includes("未能提取") || existing.answer.includes("暂未找到")) {
if (!q.answer.includes("未能提取") && !q.answer.includes("暂未找到")) map.set(key, q);
}
}
});
return Array.from(map.values());
}
function addTaskToDB(taskTitle, questions) {
if (!questions || questions.length === 0) return 0;
let db = getDB();
let safeTitle = taskTitle || `未命名提取_${new Date().toLocaleString()}`;
if (!db.tasks[safeTitle]) db.tasks[safeTitle] =[];
let oldLen = db.tasks[safeTitle].length;
db.tasks[safeTitle] = mergeQuestions(db.tasks[safeTitle], questions);
saveDB(db);
return db.tasks[safeTitle].length - oldLen;
}
function deleteTaskFromDB(taskTitle) {
let db = getDB();
if (db.tasks[taskTitle]) { delete db.tasks[taskTitle]; saveDB(db); }
}
function notifyTopWindow(message, isError = false) {
GM_setValue(COM_NOTIFY, { msg: message, isError: isError, t: Date.now() + Math.random() });
}
function requestTopRender() {
GM_setValue(COM_RENDER, Date.now() + Math.random());
}
// =========================================================
// 3. 强力抓取解析引擎
// =========================================================
function normalizeText(text) { return (typeof text !== 'string') ? "" : text.replace(/\s+/g, ' ').trim(); }
function normalizeQuestionText(rawText) {
if (!rawText) return "";
let text = rawText.replace(/^【.*?题】\s*/, '').trim();
text = text.replace(/\s*[(\(]\s*\d+(\.\d+)?\s*分\s*[)\)]\s*$/, '').trim();
text = text.replace(/\(\s*( |\s)*\)/g, '( )').replace(/(\s*( |\s)*)/g, '( )');
return normalizeText(text);
}
function getCleanTextFromElement(element) {
if (!element) return "";
const clone = element.cloneNode(true);
clone.querySelectorAll('script, style').forEach(el => el.remove());
let html = clone.innerHTML.replace(/
]*>/gi, '\n').replace(/<\/div>/gi, '\n');
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
return normalizeText(tempDiv.textContent || tempDiv.innerText || "").replace(/\n\s*\n/g, '\n').trim();
}
function coreExtract(context) {
let questions =[];
let seenTexts = new Set();
function addQ(qObj) {
if (!qObj || !qObj.pureQText || qObj.pureQText === "未知题目") return;
if (!seenTexts.has(qObj.pureQText)) {
seenTexts.add(qObj.pureQText);
questions.push(qObj);
}
}
let markItems = context.querySelectorAll('.mark_item');
if (markItems.length > 0) {
markItems.forEach(item => {
let typeName = "未知题型";
let typeHeader = item.querySelector('.type_tit, .mark_name h3, .question_title');
if (typeHeader) typeName = detectTypeName(normalizeText(typeHeader.textContent).replace(/^[一二三四五六七八九十]+[、.]\s*/, '').replace(/\(.*?\)$/, '').trim());
item.querySelectorAll('.questionLi').forEach(q => addQ(processFanyaQuestion(q, typeName)));
});
}
if (questions.length === 0) {
context.querySelectorAll('.questionLi').forEach(q => addQ(processFanyaQuestion(q, "未知题型")));
}
let zyContainers = context.querySelectorAll('.CyBottom, .ZyBottom, #ZyBottom');
if (zyContainers.length > 0) {
zyContainers.forEach(element => {
let currentType = "未知题型";
Array.from(element.children).forEach(child => {
const isHeader = child.classList.contains('Cy_TItle1') || child.classList.contains('newTestType') || (child.tagName === 'H3' && child.className.includes('TestType')) || child.querySelector('.newTestType');
if (isHeader) {
currentType = detectTypeName(normalizeText(child.textContent).replace(/^[一二三四五六七八九十]+[、.]\s*/, '').replace(/\(.*?\)$/, '').replace(/\(.*\)$/, '').trim());
} else if (child.classList.contains('TiMu')) {
addQ(processZyBottomQuestion(child, currentType));
} else {
child.querySelectorAll('.TiMu').forEach(q => addQ(processZyBottomQuestion(q, currentType)));
}
});
});
}
if (questions.length === 0) {
context.querySelectorAll('.TiMu').forEach(q => addQ(processZyBottomQuestion(q, "未知题型")));
}
return questions;
}
function processZyBottomQuestion(qEl, typeName) {
let qOutput = "";
let pureQText = "未知题目";
let finalAns = "未能提取答案";
try {
const titleDiv = qEl.querySelector('.Cy_TItle, .Zy_TItle, .question_title, h3');
let qText = "题干未能提取";
if (titleDiv) {
const contentEl = titleDiv.querySelector('.qtContent, div.clearfix, .question_text') || titleDiv;
if(contentEl) qText = normalizeQuestionText(getCleanTextFromElement(contentEl));
}
pureQText = qText.replace(/^\d+[\.、]\s*/, '');
qOutput += qText + "\n";
qEl.querySelectorAll('.Cy_ulTop li, .Zy_ulTop li, .mark_letter li, .options li, .stem_answer li').forEach(opt => {
const label = opt.querySelector('i.fl, i.num')?.textContent.trim() || "";
let text = "";
const anchor = opt.querySelector('a');
if (anchor) text = normalizeText(anchor.textContent);
else {
let clone = opt.cloneNode(true);
clone.querySelector('i.fl, i.num')?.remove();
text = normalizeText(clone.textContent);
}
qOutput += label + " " + text + "\n";
});
let found = false;
const newBx = qEl.querySelector('.newAnswerBx');
if (newBx) {
const rightNode = newBx.querySelector('.correctAnswerBx .answerCon, .correctAnswer .answerCon');
if (rightNode) { finalAns = normalizeText(rightNode.innerText); found = true; }
if (!found) {
const multiBlankBx = newBx.querySelector('.myAllAnswerBx');
if (multiBlankBx) {
let tempAnswers =[], hasWrong = false;
multiBlankBx.querySelectorAll('.myAnswerBx').forEach(sub => {
const ansContent = sub.querySelector('.myAnswer');
if (ansContent) {
let t = normalizeText(ansContent.innerText).replace(/第[一二三四五六七八九十\d]+空[::]/g, '').trim();
if (sub.querySelector('.marking_cuo')) hasWrong = true;
tempAnswers.push(t);
}
});
if (tempAnswers.length > 0) { finalAns = hasWrong ? "暂未找到正确答案" : tempAnswers.join("; "); found = true; }
} else {
const myNode = newBx.querySelector('.myAnswerBx .answerCon, .myAnswer .answerCon, .myAnswer');
const statusSpan = newBx.querySelector('.answerScore .CorrectOrNot span');
if (myNode) {
let myText = normalizeText(myNode.innerText).replace(/^我的答案[::]/, '').trim();
if (statusSpan && statusSpan.classList.contains('marking_dui')) finalAns = myText;
else if (statusSpan && statusSpan.classList.contains('marking_cuo')) finalAns = "暂未找到正确答案";
else finalAns = "(仅供参考) " + myText;
}
}
}
} else {
const rightSpan = Array.from(qEl.querySelectorAll('.Py_answer span, .answerCon, .rightAnswer')).find(s => s.textContent.includes('正确答案') || s.textContent.includes('我的答案'));
if(rightSpan) finalAns = rightSpan.textContent.replace(/正确答案[::]|我的答案[::]/,'').trim();
}
if (finalAns === '√' || finalAns === 'true' || finalAns === '对') finalAns = '对';
if (finalAns === '×' || finalAns === 'false' || finalAns === '错') finalAns = '错';
qOutput += "答案:" + finalAns + "\n";
const analysis = qEl.querySelector('.answerKeyBx .answerCon, .Py_addpy .pingyu');
if(analysis) qOutput += "解析:" + getCleanTextFromElement(analysis) + "\n";
} catch (e) { console.error("Error parsing ZyBottom Q:", e); }
return { pureQText, rawText: qOutput, answer: finalAns, type: typeName };
}
function processFanyaQuestion(qEl, typeName) {
let qOutput = "";
let pureQText = "未知题目";
let finalAns = "未能提取答案";
try {
const nameEl = qEl.querySelector('h3.mark_name, .mark_title, .question_title, h3');
let qText = "题干未能提取";
if (nameEl) {
const contentEl = nameEl.querySelector('.qtContent, .question_text') || nameEl;
qText = normalizeQuestionText(getCleanTextFromElement(contentEl));
}
pureQText = qText.replace(/^\d+[\.、]\s*/, '');
qOutput += qText + "\n";
qEl.querySelectorAll('.options li, .stem_answer li, .mark_letter li').forEach(opt => qOutput += normalizeText(opt.textContent) + "\n");
let answerBlock = qEl.querySelector('.mark_answer');
if (answerBlock) {
const rightAnsEl = answerBlock.querySelector('.rightAnswerContent');
if (rightAnsEl) finalAns = normalizeText(rightAnsEl.textContent);
else {
const correctMatch = normalizeText(answerBlock.textContent).match(/正确答案[::]\s*([A-Za-z0-9\u4e00-\u9fa5]+)/);
if (correctMatch) finalAns = correctMatch[1];
}
if (!finalAns || finalAns === "未能提取答案") {
const myAnsEl = answerBlock.querySelector('.stuAnswerContent') || answerBlock.querySelector('.mark_fill dd') || answerBlock.querySelector('.mark_fill');
if (myAnsEl) {
let myText = getCleanTextFromElement(myAnsEl).replace(/^我的答案[::]/, '').trim();
if (qEl.querySelector('.marking_dui') || answerBlock.querySelector('.marking_dui') || answerBlock.querySelector('.icon_ok')) finalAns = myText;
else if (qEl.querySelector('.marking_cuo') || answerBlock.querySelector('.marking_cuo')) finalAns = "暂未找到正确答案";
else finalAns = "(我的答案) " + myText;
}
}
}
qOutput += "答案:" + finalAns + "\n";
} catch (e) { console.error("Error parsing Fanya Q:", e); }
return { pureQText, rawText: qOutput, answer: finalAns, type: typeName };
}
function detectTypeName(text) {
if (text.includes("单选题")) return "单选题";
if (text.includes("多选题")) return "多选题";
if (text.includes("判断题")) return "判断题";
if (text.includes("填空题")) return "填空题";
if (text.includes("简答题")) return "简答题";
if (text.includes("资料题") || text.includes("论述题")) return "资料题/论述题";
return text || "未知题型";
}
// =========================================================
// 4. 页面原生按钮注入引擎
// =========================================================
function isWorkListPage() {
const path = window.location.pathname;
return document.querySelector('.bottomList') !== null ||
document.querySelector('.ulDiv') !== null || // 兼容旧版作业与考试UI
path.includes('exam-list') ||
path.includes('getAllWork') ||
path.includes('/exam/test');
}
function isDetailPage() {
return document.querySelector('#ZyBottom') || document.querySelector('#fanyaMarking') || document.querySelector('.questionLi');
}
function getPageTitle() {
let node = document.querySelector('.task-title') || document.querySelector('.mark_title') || document.querySelector('.Cy_TItle1');
return node ? node.textContent.trim() : (document.title.split('-')[0].trim() || "未命名作业");
}
function tryAddBatchButton() {
if (!document.body) return;
if (!isWorkListPage()) return;
if (document.getElementById('cx-export-btn')) return;
// 兼容新版和老版不同的工具栏容器
const target = document.querySelector('.bnt_group') || document.querySelector('.filter') || document.querySelector('.top-back') || document.querySelector('.CyTop .ul01') || document.querySelector('.CyTop');
if (target) {
const btn = document.createElement('button');
btn.id = 'cx-export-btn';
btn.innerHTML = '📥 一键全自动抓取入库';
btn.onclick = startBatchSpider;
if (target.tagName.toLowerCase() === 'ul') {
const li = document.createElement('li');
li.style.display = 'inline-block';
li.style.float = 'right';
li.style.marginTop = '4px';
li.appendChild(btn);
target.appendChild(li);
} else {
target.insertBefore(btn, target.firstChild);
}
}
}
async function startBatchSpider() {
const btn = document.getElementById('cx-export-btn');
// 兼容新旧版列表选择器
const listItems = document.querySelectorAll('.bottomList ul li, .ulDiv ul li');
let tasks =[];
let courseId = document.getElementById('courseId')?.value || '';
let classId = document.getElementById('classId')?.value || '';
let cpi = document.getElementById('cpi')?.value || '';
let openc = document.getElementById('openc')?.value || '';
let fetchAll = GM_getValue(FETCH_ALL_KEY, false);
listItems.forEach(li => {
let titleNode = li.querySelector('.overHidden2') || li.querySelector('.titTxt a') || li.querySelector('a[title]');
let title = titleNode ? (titleNode.getAttribute('title') || titleNode.innerText.trim()) : "未命名作业";
let url = null;
let liText = li.innerText || "";
// “预览”用于匹配新版的题库集(本身无完成状态)
let isCompleted = liText.includes('已完成') || liText.includes('已批阅') || liText.includes('查看') || liText.includes('重做') || liText.includes('预览') || /得分/.test(liText);
if (!fetchAll && !isCompleted) {
return;
}
// 策略1:直接从已知的按钮上获取 href 或 data
let aNode = li.querySelector('a.viewUrl, a.viewBtn, a.Btn_blue_1, a.selfTestPreview');
if (aNode) {
let tempUrl = aNode.getAttribute('data') || aNode.getAttribute('href');
if (tempUrl && !tempUrl.includes('javascript:')) {
url = tempUrl;
} else {
let oc = aNode.getAttribute('onclick');
if (oc) {
let locMatch = oc.match(/location\.href=['"](.*?)['"]/);
if (locMatch) url = locMatch[1];
}
}
}
// 策略2:如果标题本身是个带链接的 a 标签
if (!url && titleNode && titleNode.tagName === 'A') {
let tempUrl = titleNode.getAttribute('href');
if (tempUrl && !tempUrl.includes('javascript:')) {
url = tempUrl;
}
}
// 策略3:强力提取 onclick 代码中的隐藏参数 (兼容所有旧版和题库版)
if (!url) {
let clickNodes = li.querySelectorAll('[onclick]');
for (let node of clickNodes) {
let oc = node.getAttribute('onclick') || "";
let locMatch = oc.match(/location\.href=['"](.*?)['"]/);
if (locMatch) { url = locMatch[1]; break; }
let viewMatch = oc.match(/viewPaper\('(\d+)'\)/);
if (viewMatch) {
url = `/exam-ans/exam/test/reVersionPaperMarkContentNew?courseId=${courseId}&classId=${classId}&p=1&id=${viewMatch[1]}&ut=s&cpi=${cpi}&newMooc=true&openc=${openc}&pageSize=2000`;
break;
}
// 抓取 goTest('courseId', examId, paperId, ...) 中的 paperId
let goMatch = oc.match(/goTest\(['"][^'"]*['"]\s*,\s*\d+\s*,\s*(\d+)/);
if (goMatch && goMatch[1] !== '0') {
let paperId = goMatch[1];
url = `/exam-ans/exam/test/reVersionPaperMarkContentNew?courseId=${courseId}&classId=${classId}&p=1&id=${paperId}&ut=s&cpi=${cpi}&newMooc=true&openc=${openc}&pageSize=2000`;
break;
}
}
}
if (url && !url.includes('javascript:')) {
if (url.startsWith('/')) url = window.location.origin + url;
tasks.push({ title, url });
}
});
if (tasks.length === 0) {
return notifyTopWindow(`⚠️ 当前页面没有${fetchAll ? '任何' : '已完成的'}试卷/作业可抓取!(若要抓自测题,请开启设置中功能)`, true);
}
btn.disabled = true;
notifyTopWindow(`🚀 启动爬虫!将静默爬取 ${tasks.length} 个作业及自动翻页,可能需要稍等片刻...`);
let totalAdded = 0;
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
btn.innerHTML = `⏳ 爬取中 (${i + 1}/${tasks.length})`;
try {
let pageNum = 1;
let hasMore = true;
let currentUrl = task.url;
if (!currentUrl.includes('pageSize')) {
currentUrl += (currentUrl.includes('?') ? '&' : '?') + 'pageSize=2000';
}
while(hasMore && pageNum <= 30) {
let fetchUrl = currentUrl;
if (pageNum > 1) {
fetchUrl += `&pageNum=${pageNum}&page=${pageNum}`;
}
const html = await new Promise((res, rej) => {
GM_xmlhttpRequest({ method: "GET", url: fetchUrl, onload: (r) => r.status===200 ? res(r.responseText) : rej(r.status), onerror: rej });
});
const doc = new DOMParser().parseFromString(html, 'text/html');
const qs = coreExtract(doc);
if (qs && qs.length > 0) {
totalAdded += addTaskToDB(task.title, qs);
requestTopRender();
} else {
hasMore = false;
break;
}
let hasNext = false;
doc.querySelectorAll('a').forEach(a => {
let t = a.textContent.trim();
if (t === '下一页' || t === '加载更多' || t.includes('下一页')) hasNext = true;
});
if (doc.querySelector('.nextPage, .page-next, .next')) hasNext = true;
if (hasNext) {
pageNum++;
await new Promise(r => setTimeout(r, 600));
} else {
hasMore = false;
}
}
} catch (e) { console.error("Spider error:", e); }
await new Promise(r => setTimeout(r, 600));
}
notifyTopWindow(`✅ 批量爬取圆满完成!本次题库净增长 ${totalAdded} 题。`);
btn.innerHTML = '📥 一键全自动抓取入库'; btn.disabled = false;
requestTopRender();
}
function createSinglePageExtractButton() {
if (!document.body) return;
if (isWorkListPage()) return;
if (!isDetailPage()) return;
if (document.getElementById('extractQuizButtonGM_merged')) return;
const btn = document.createElement('button');
btn.id = 'extractQuizButtonGM_merged';
btn.innerHTML = '⚡ 提取本页入库';
btn.onclick = function() {
this.innerHTML = '⏳ 正在提取...';
this.disabled = true;
setTimeout(() => {
const qs = coreExtract(document);
if (qs && qs.length > 0) {
let addedCount = addTaskToDB(getPageTitle(), qs);
notifyTopWindow(`✅ 成功抓取 ${qs.length} 题,新收录/更新 ${addedCount} 题!`);
requestTopRender();
} else {
notifyTopWindow('未能找到题目容器', true);
}
setTimeout(() => {
this.innerHTML = '⚡ 提取本页入库';
this.disabled = false;
}, 1000);
}, 300);
};
document.body.appendChild(btn);
}
// =========================================================
// 5. 最外层主面板 UI 与事件
// =========================================================
function showToastTopWindow(message, isError = false) {
let el = document.getElementById('cx-top-notification');
if (!el) { el = document.createElement('div'); el.id = 'cx-top-notification'; document.body.appendChild(el); }
el.textContent = message; el.style.backgroundColor = isError ? '#dc3545' : '#28a745'; el.style.display = 'block'; el.style.opacity = '1';
if(el.hideTimer) clearTimeout(el.hideTimer);
el.hideTimer = setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => { if (el.style.opacity === '0') el.style.display='none'; }, 300);
}, 3000);
}
function createTopPanelUI() {
if (!isTopWindow || !document.body) return;
if (document.getElementById('cx-qb-float-btn')) return;
const floatBtn = document.createElement('button');
floatBtn.id = 'cx-qb-float-btn';
floatBtn.innerHTML = '📚
题库管理';
floatBtn.onclick = () => {
document.getElementById('cx-qb-main-panel').style.display = 'flex';
floatBtn.style.display = 'none';
switchViewTop('list');
};
document.body.appendChild(floatBtn);
const panel = document.createElement('div');
panel.id = 'cx-qb-main-panel';
panel.innerHTML = `
`;
document.body.appendChild(panel);
bindEventsTop();
}
function switchViewTop(viewName) {
document.getElementById('cx-view-list').style.display = 'none';
document.getElementById('cx-view-detail').style.display = 'none';
document.getElementById('cx-view-settings').style.display = 'none';
document.getElementById('cx-qb-footer').style.display = (viewName === 'list') ? 'flex' : 'none';
if (viewName === 'list') {
document.getElementById('cx-view-list').style.display = 'block';
renderListTop();
} else if (viewName === 'detail') {
document.getElementById('cx-view-detail').style.display = 'block';
} else if (viewName === 'settings') {
document.getElementById('cx-view-settings').style.display = 'block';
document.getElementById('cx-toggle-auto').checked = GM_getValue(AUTO_EXTRACT_KEY, false);
document.getElementById('cx-toggle-fetchall').checked = GM_getValue(FETCH_ALL_KEY, false);
document.getElementById('cx-toggle-antidebug').checked = GM_getValue(ANTI_DEBUG_KEY, false);
}
}
function bindEventsTop() {
const header = document.getElementById('cx-qb-header');
const panel = document.getElementById('cx-qb-main-panel');
let isDragging = false, offset =[0,0];
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.cx-qb-controls')) return;
isDragging = true;
offset =[panel.offsetLeft - e.clientX, panel.offsetTop - e.clientY];
document.addEventListener('mousemove', dragPanel);
document.addEventListener('mouseup', stopDragPanel);
});
function dragPanel(e) {
if (!isDragging) return;
panel.style.left = (e.clientX + offset[0]) + 'px';
panel.style.top = (e.clientY + offset[1]) + 'px';
panel.style.right = 'auto';
}
function stopDragPanel() {
isDragging = false;
document.removeEventListener('mousemove', dragPanel);
document.removeEventListener('mouseup', stopDragPanel);
}
document.getElementById('cx-btn-nav-min').onclick = () => {
panel.style.display = 'none';
document.getElementById('cx-qb-float-btn').style.display = 'flex';
};
document.getElementById('cx-btn-nav-settings').onclick = () => switchViewTop('settings');
document.getElementById('cx-btn-back-list').onclick = () => switchViewTop('list');
document.getElementById('cx-btn-back-list2').onclick = () => switchViewTop('list');
document.getElementById('cx-toggle-auto').addEventListener('change', (e) => {
GM_setValue(AUTO_EXTRACT_KEY, e.target.checked);
showToastTopWindow(`自动提取已 ${e.target.checked ? '开启' : '关闭'}`);
});
document.getElementById('cx-toggle-fetchall').addEventListener('change', (e) => {
GM_setValue(FETCH_ALL_KEY, e.target.checked);
if (e.target.checked) {
showToastTopWindow('⚠️ 警告:已允许抓取所有!将强行开启所有未交作业和测验!', true);
} else {
showToastTopWindow('已关闭抓取所有,后续将严格过滤,仅抓取已完成(有得分/批阅)的作业。');
}
});
document.getElementById('cx-toggle-antidebug').addEventListener('change', (e) => {
GM_setValue(ANTI_DEBUG_KEY, e.target.checked);
showToastTopWindow('🔄 反调试设置已保存,请手动刷新页面生效!', true);
});
document.getElementById('cx-btn-g-txt').onclick = () => exportGlobalTop('txt');
document.getElementById('cx-btn-g-json').onclick = () => exportGlobalTop('json');
const clearBtn = document.getElementById('cx-btn-g-clear');
clearBtn.onclick = () => {
if (clearBtn.dataset.confirm === '1') {
saveDB({ version: "2.0", tasks: {} });
renderListTop();
showToastTopWindow("✅ 题库已成功清空");
clearBtn.dataset.confirm = '0';
clearBtn.textContent = '清空';
} else {
clearBtn.dataset.confirm = '1';
clearBtn.textContent = '确定清空?';
setTimeout(() => {
clearBtn.dataset.confirm = '0';
clearBtn.textContent = '清空';
}, 3000);
}
};
document.getElementById('cx-btn-g-import').onclick = importJSONTop;
}
function renderListTop() {
const container = document.getElementById('cx-view-list');
if (!container) return;
const db = getDB();
const tasks = db.tasks;
container.innerHTML = '';
const keys = Object.keys(tasks);
if (keys.length === 0) {
container.innerHTML = '
📦 题库空空如也~
页面中会出现原生的【提取】按钮,
点击后数据会自动同步到此处。
';
return;
}
keys.reverse().forEach(title => {
const list = tasks[title];
const item = document.createElement('div');
item.className = 'cx-qb-task-item';
item.innerHTML = `
${title}
${list.length} 题
`;
container.appendChild(item);
});
container.querySelectorAll('.btn-view').forEach(btn => btn.onclick = (e) => viewDetailTop(e.target.dataset.title));
container.querySelectorAll('.btn-exp-txt').forEach(btn => btn.onclick = (e) => exportSingleTop(e.target.dataset.title, 'txt'));
container.querySelectorAll('.btn-exp-json').forEach(btn => btn.onclick = (e) => exportSingleTop(e.target.dataset.title, 'json'));
container.querySelectorAll('.btn-del').forEach(btn => {
btn.onclick = (e) => {
const title = e.target.dataset.title;
if (btn.dataset.confirm === '1') {
deleteTaskFromDB(title);
renderListTop();
showToastTopWindow(`已删除: ${title}`);
} else {
btn.dataset.confirm = '1';
let originalText = btn.textContent;
btn.textContent = '确定?';
setTimeout(() => {
if(btn) { btn.dataset.confirm = '0'; btn.textContent = originalText; }
}, 3000);
}
};
});
}
function viewDetailTop(title) {
const db = getDB();
const qs = db.tasks[title];
if (!qs) return;
document.getElementById('cx-detail-title').textContent = title;
const body = document.getElementById('cx-detail-body');
let html = '';
qs.forEach((q, idx) => {
let rawStr = q.rawText.replace(/^\d+[\.、]\s*/, '').replace(/\n/g, '
');
html += `
${idx + 1}.[${q.type}]
${rawStr}
`;
});
body.innerHTML = html;
switchViewTop('detail');
}
// --- 导出 / 导入 逻辑 ---
function formatQuestionsToTXT(title, questionsArray) {
if (questionsArray.length === 0) return `# ${title}\n\n[提示:暂无题目数据]\n`;
let groups = {};
questionsArray.forEach(q => { if (!groups[q.type]) groups[q.type] = []; groups[q.type].push(q); });
let output = `# ${title}\n\n`;
for (let type in groups) {
output += `## ${type}\n\n`;
groups[type].forEach((q, idx) => {
let body = q.rawText.replace(/^\d+[\.、]\s*/, '');
output += `${idx + 1}. ${body}\n\n`;
});
output += `\n`;
}
return output;
}
function downloadFile(content, filename, type='text/plain') {
const blob = new Blob([content], { type: type + ';charset=utf-8' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename;
document.body.appendChild(link); link.click(); document.body.removeChild(link);
}
// 单个任务导出逻辑 (无需改变,因单个任务按题型分就是本身数据)
function exportSingleTop(title, type) {
const db = getDB(); const qs = db.tasks[title]; if (!qs) return;
if (type === 'txt') downloadFile(formatQuestionsToTXT(title, qs), `题库_${title}.txt`);
else downloadFile(JSON.stringify({ version: "2.0", tasks: { [title]: qs } }, null, 2), `题库_${title}.json`, 'application/json');
}
// 全局汇总导出逻辑 (双TXT模式)
function exportGlobalTop(type) {
const db = getDB(); const allTasks = Object.keys(db.tasks);
if (allTasks.length === 0) return showToastTopWindow("题库是空的!", true);
if (type === 'json') {
downloadFile(JSON.stringify(db, null, 2), `超星完整题库备份_${new Date().toISOString().slice(0,10)}.json`, 'application/json');
} else {
// 模式 1: 按作业集分类
let txtByTask = "========== 超星题库汇总 (按作业集分类) ==========\n\n";
allTasks.forEach(title => {
txtByTask += formatQuestionsToTXT(title, db.tasks[title]) + "\n";
});
downloadFile(txtByTask, `题库汇总_按作业集_${new Date().toISOString().slice(0,10)}.txt`);
// 模式 2: 全局按题型合并并去重
let allQs =[];
allTasks.forEach(t => allQs = allQs.concat(db.tasks[t]));
let mergedQs = mergeQuestions([], allQs);
let txtByType = formatQuestionsToTXT("超星题库汇总 (全局按题型分类)", mergedQs);
showToastTopWindow("正在为您导出 2 份 TXT,请在弹窗中【允许浏览器下载多个文件】!");
// 稍加延迟,保证浏览器不会拦截第二个下载
setTimeout(() => {
downloadFile(txtByType, `题库汇总_按题型_${new Date().toISOString().slice(0,10)}.txt`);
}, 800);
}
}
function importJSONTop() {
const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json';
input.onchange = e => {
const file = e.target.files[0]; if(!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const imported = JSON.parse(ev.target.result);
if (!imported.tasks) throw new Error("JSON 格式不兼容");
const db = getDB(); let newTasks = 0, newQs = 0;
for (let title in imported.tasks) {
if (!db.tasks[title]) db.tasks[title] =[];
let oldLen = db.tasks[title].length;
db.tasks[title] = mergeQuestions(db.tasks[title], imported.tasks[title]);
if (!oldLen) newTasks++;
newQs += (db.tasks[title].length - oldLen);
}
saveDB(db); renderListTop();
showToastTopWindow(`🎉 导入成功!新增/更新了 ${newTasks} 个作业的 ${newQs} 道题。`);
} catch(err) { showToastTopWindow("导入失败,文件损坏或格式不正确!", true); }
};
reader.readAsText(file);
};
input.click();
}
// =========================================================
// 6. 初始化与轮询通信机制
// =========================================================
setInterval(() => {
tryAddBatchButton();
createSinglePageExtractButton();
}, 1500);
setInterval(() => {
if (!GM_getValue(AUTO_EXTRACT_KEY, false)) return;
if (!isDetailPage()) return;
if (window._cx_auto_extracted) return;
let container = document.querySelector('#ZyBottom') || document.querySelector('#fanyaMarking') || document.querySelector('.questionLi')?.parentElement;
if (container) {
window._cx_auto_extracted = true;
setTimeout(() => {
let qs = coreExtract(document);
if (qs.length > 0) {
let added = addTaskToDB(getPageTitle(), qs);
// 【修复核心1】 仅当实际增加了新的题目时,才向页面推送弹窗,避免多次触发
if (added > 0) {
notifyTopWindow(`🤖 自动提取完毕!新收录/更新 ${added} 题`);
} else {
console.log("[超星提取器] 题目已在库中,无需更新。");
}
requestTopRender();
}
}, 1000);
}
}, 2000);
if (isTopWindow) {
let initUIInterval = setInterval(() => {
if (document.body) {
clearInterval(initUIInterval);
createTopPanelUI();
}
}, 100);
let lastNotifyToken = 0;
let lastRenderToken = 0;
setInterval(() => {
let notifyMsg = GM_getValue(COM_NOTIFY, { msg: '', t: 0 });
if (notifyMsg.t !== lastNotifyToken) {
lastNotifyToken = notifyMsg.t;
showToastTopWindow(notifyMsg.msg, notifyMsg.isError);
}
let renderMsg = GM_getValue(COM_RENDER, 0);
if (renderMsg !== lastRenderToken) {
lastRenderToken = renderMsg;
const panel = document.getElementById('cx-qb-main-panel');
if (panel && panel.style.display === 'flex') {
renderListTop();
}
}
}, 300);
}
})();