531 lines
14 KiB
JavaScript
531 lines
14 KiB
JavaScript
// ==UserScript==
|
||
// @name 平顶山学院(青书学堂)作业答案助手
|
||
// @namespace http://tampermonkey.net/
|
||
// @version 2026-06-05.4
|
||
// @description 在青书学堂作业页面展示官方答案,缺少官方答案时使用 DeepSeek 生成参考答案
|
||
// @author JK
|
||
// @license MIT
|
||
// @match https://degree.qingshuxuetang.com/pdsu/Student/ExercisePaper*
|
||
// @icon https://assets.qsxt.info/frontend/prod/dist/mooc/favicon.ico
|
||
// @grant GM_getValue
|
||
// @grant GM_setValue
|
||
// @grant GM_deleteValue
|
||
// @grant GM_registerMenuCommand
|
||
// @grant GM_xmlhttpRequest
|
||
// @connect api.deepseek.com
|
||
// @connect degree.qingshuxuetang.com
|
||
// ==/UserScript==
|
||
|
||
(function () {
|
||
"use strict";
|
||
|
||
const CONFIG_STORAGE_KEY = "qingshu_homework_answer_config";
|
||
const ANSWER_CLASS = "qingshu-homework-answer";
|
||
const STYLE_ID = "qingshu-homework-answer-style";
|
||
|
||
const DEFAULT_CONFIG = {
|
||
deepseekApiKey: "",
|
||
deepseekModel: "deepseek-chat",
|
||
deepseekApiUrl: "https://api.deepseek.com/chat/completions",
|
||
autoShowAnswers: true,
|
||
};
|
||
|
||
const CONFIG = {
|
||
...DEFAULT_CONFIG,
|
||
...GM_getValue(CONFIG_STORAGE_KEY, {}),
|
||
};
|
||
|
||
const saveConfig = (patch) => {
|
||
Object.assign(CONFIG, patch);
|
||
GM_setValue(CONFIG_STORAGE_KEY, CONFIG);
|
||
alert("设置已保存,刷新页面后生效");
|
||
};
|
||
|
||
const registerConfigMenus = () => {
|
||
GM_registerMenuCommand("设置 DeepSeek API Key", () => {
|
||
const input = prompt(
|
||
"请输入 DeepSeek API Key",
|
||
CONFIG.deepseekApiKey ? "已设置,输入新值可覆盖" : ""
|
||
);
|
||
|
||
if (input === null) {
|
||
return;
|
||
}
|
||
|
||
const apiKey = input.trim();
|
||
|
||
if (!apiKey) {
|
||
alert("API Key 不能为空");
|
||
return;
|
||
}
|
||
|
||
saveConfig({ deepseekApiKey: apiKey });
|
||
});
|
||
|
||
GM_registerMenuCommand("设置 DeepSeek 模型", () => {
|
||
const input = prompt("请输入 DeepSeek 模型名", CONFIG.deepseekModel);
|
||
|
||
if (input === null) {
|
||
return;
|
||
}
|
||
|
||
const model = input.trim();
|
||
|
||
if (!model) {
|
||
alert("模型名不能为空");
|
||
return;
|
||
}
|
||
|
||
saveConfig({ deepseekModel: model });
|
||
});
|
||
|
||
GM_registerMenuCommand("设置是否自动显示答案", () => {
|
||
const input = prompt(
|
||
"是否自动显示答案?输入 true 或 false",
|
||
String(CONFIG.autoShowAnswers)
|
||
);
|
||
|
||
if (input === null) {
|
||
return;
|
||
}
|
||
|
||
saveConfig({ autoShowAnswers: input.trim().toLowerCase() === "true" });
|
||
});
|
||
|
||
GM_registerMenuCommand("手动显示作业答案", () => {
|
||
showAnswers();
|
||
});
|
||
|
||
GM_registerMenuCommand("恢复默认设置", () => {
|
||
GM_deleteValue(CONFIG_STORAGE_KEY);
|
||
Object.assign(CONFIG, DEFAULT_CONFIG);
|
||
alert("设置已重置,刷新页面后生效");
|
||
});
|
||
};
|
||
|
||
const ensureStyle = () => {
|
||
if (document.querySelector("#" + STYLE_ID)) {
|
||
return;
|
||
}
|
||
|
||
const style = document.createElement("style");
|
||
style.id = STYLE_ID;
|
||
style.textContent = `
|
||
.${ANSWER_CLASS} {
|
||
margin: 12px 0;
|
||
padding: 12px 14px;
|
||
border-left: 4px solid #2563eb;
|
||
border-radius: 6px;
|
||
background: #eff6ff;
|
||
color: #1f2937;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.${ANSWER_CLASS}.official {
|
||
border-left-color: #16a34a;
|
||
background: #f0fdf4;
|
||
}
|
||
|
||
.${ANSWER_CLASS}.ai {
|
||
border-left-color: #7c3aed;
|
||
background: #f5f3ff;
|
||
}
|
||
|
||
.${ANSWER_CLASS}.error {
|
||
border-left-color: #dc2626;
|
||
background: #fef2f2;
|
||
}
|
||
|
||
.${ANSWER_CLASS} .answer-title {
|
||
margin-bottom: 6px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.${ANSWER_CLASS}.fillable {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.${ANSWER_CLASS}.fillable .answer-title::after {
|
||
content: "(双击填入)";
|
||
margin-left: 6px;
|
||
color: #6b7280;
|
||
font-weight: 400;
|
||
}
|
||
`;
|
||
(document.head || document.documentElement).appendChild(style);
|
||
};
|
||
|
||
const getUrlParams = () => {
|
||
const url = new URL(window.location.href);
|
||
|
||
return {
|
||
quizId: url.searchParams.get("quizId"),
|
||
courseId: url.searchParams.get("courseId"),
|
||
teachPlanId: url.searchParams.get("teachPlanId"),
|
||
periodId: url.searchParams.get("periodId"),
|
||
};
|
||
};
|
||
|
||
const getSchoolCode = () => {
|
||
const [, schoolCode] = location.pathname.split("/");
|
||
|
||
return schoolCode || "pdsu";
|
||
};
|
||
|
||
const waitForCondition = (getResult, timeoutMs = 30000) => {
|
||
const result = getResult();
|
||
|
||
if (result) {
|
||
return Promise.resolve(result);
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
let timeoutId;
|
||
|
||
const observer = new MutationObserver(() => {
|
||
const result = getResult();
|
||
|
||
if (result) {
|
||
observer.disconnect();
|
||
clearTimeout(timeoutId);
|
||
resolve(result);
|
||
}
|
||
});
|
||
|
||
observer.observe(document.documentElement, {
|
||
childList: true,
|
||
subtree: true,
|
||
});
|
||
|
||
timeoutId = setTimeout(() => {
|
||
observer.disconnect();
|
||
resolve(null);
|
||
}, timeoutMs);
|
||
});
|
||
};
|
||
|
||
const fetchQuizData = async (quizId) => {
|
||
const timestamp = Date.now();
|
||
const url =
|
||
"https://degree.qingshuxuetang.com/" +
|
||
getSchoolCode() +
|
||
"/Student/DetailData?quizId=" +
|
||
encodeURIComponent(quizId) +
|
||
"&_=" +
|
||
timestamp +
|
||
"&_t=" +
|
||
(timestamp + 2000);
|
||
|
||
const response = await fetch(url, {
|
||
method: "GET",
|
||
headers: {
|
||
Accept: "application/json, text/javascript, */*; q=0.01",
|
||
"Content-Type": "application/json",
|
||
"X-Requested-With": "XMLHttpRequest",
|
||
},
|
||
credentials: "include",
|
||
});
|
||
|
||
return response.json();
|
||
};
|
||
|
||
const parseDescription = (description = "") => {
|
||
const element = document.createElement("div");
|
||
element.innerHTML = description;
|
||
|
||
return {
|
||
text: element.textContent.trim(),
|
||
imageUrls: Array.from(element.querySelectorAll("img"))
|
||
.map((image) => image.src)
|
||
.filter(Boolean),
|
||
};
|
||
};
|
||
|
||
const htmlToText = (html = "") => {
|
||
const element = document.createElement("div");
|
||
element.innerHTML = html;
|
||
|
||
return element.textContent.trim();
|
||
};
|
||
|
||
const parseAnswerLabels = (solution = "") => {
|
||
const text = htmlToText(solution).toUpperCase();
|
||
const labels = text.match(/[A-Z]/g) || [];
|
||
|
||
return Array.from(new Set(labels));
|
||
};
|
||
|
||
const formatOfficialAnswer = (questionData) => {
|
||
const solution = questionData.solution || "";
|
||
const answerLabels = parseAnswerLabels(solution);
|
||
|
||
if (answerLabels.length === 0 || !questionData.options?.length) {
|
||
return htmlToText(solution) || solution;
|
||
}
|
||
|
||
const optionMap = new Map(
|
||
questionData.options.map((option) => [
|
||
String(option.label || "").trim().toUpperCase(),
|
||
htmlToText(option.description),
|
||
])
|
||
);
|
||
|
||
const matchedAnswers = answerLabels.map((label) => {
|
||
const optionText = optionMap.get(label);
|
||
|
||
return optionText ? label + ". " + optionText : label;
|
||
});
|
||
|
||
return matchedAnswers.join("\n");
|
||
};
|
||
|
||
const getQuestionTypeName = (typeId) => {
|
||
if (typeId === 1) {
|
||
return "单选题";
|
||
}
|
||
|
||
if (typeId === 2) {
|
||
return "多选题";
|
||
}
|
||
|
||
if (typeId === 3) {
|
||
return "简答题";
|
||
}
|
||
|
||
return "未知题型";
|
||
};
|
||
|
||
const buildQuestionPrompt = (questionData) => {
|
||
const { text, imageUrls } = parseDescription(questionData.description);
|
||
const options = (questionData.options || []).map(
|
||
(option) => option.label + ". " + option.description
|
||
);
|
||
const typeName = getQuestionTypeName(questionData.typeId);
|
||
|
||
return [
|
||
"请根据题目给出参考答案。",
|
||
"题型:" + typeName,
|
||
"要求:选择题只输出正确选项和简要理由;简答题输出简洁答案。",
|
||
imageUrls.length > 0
|
||
? "注意:题目包含图片,但当前脚本暂未发送图片,请根据文字和选项作答。"
|
||
: "",
|
||
"",
|
||
"题目:",
|
||
text,
|
||
options.length > 0 ? "\n选项:\n" + options.join("\n") : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
};
|
||
|
||
const requestDeepSeek = (questionData) =>
|
||
new Promise((resolve, reject) => {
|
||
if (!CONFIG.deepseekApiKey) {
|
||
reject(new Error("未设置 DeepSeek API Key"));
|
||
return;
|
||
}
|
||
|
||
GM_xmlhttpRequest({
|
||
method: "POST",
|
||
url: CONFIG.deepseekApiUrl,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: "Bearer " + CONFIG.deepseekApiKey,
|
||
},
|
||
data: JSON.stringify({
|
||
model: CONFIG.deepseekModel,
|
||
messages: [
|
||
{
|
||
role: "system",
|
||
content:
|
||
"你是作业参考答案助手。请直接给出简洁参考答案,不要输出无关寒暄。",
|
||
},
|
||
{
|
||
role: "user",
|
||
content: buildQuestionPrompt(questionData),
|
||
},
|
||
],
|
||
temperature: 0.2,
|
||
}),
|
||
onload: (response) => {
|
||
try {
|
||
const data = JSON.parse(response.responseText);
|
||
const answer = data.choices?.[0]?.message?.content;
|
||
|
||
if (!answer) {
|
||
reject(new Error("DeepSeek 未返回答案"));
|
||
return;
|
||
}
|
||
|
||
resolve(answer);
|
||
} catch (error) {
|
||
reject(error);
|
||
}
|
||
},
|
||
onerror: () => {
|
||
reject(new Error("DeepSeek 请求失败"));
|
||
},
|
||
});
|
||
});
|
||
|
||
const createAnswerDisplay = (title, content, type) => {
|
||
const answerDisplay = document.createElement("div");
|
||
answerDisplay.className = ANSWER_CLASS + " " + type;
|
||
|
||
const titleElement = document.createElement("div");
|
||
titleElement.className = "answer-title";
|
||
titleElement.textContent = title;
|
||
|
||
const contentElement = document.createElement("div");
|
||
contentElement.textContent = content;
|
||
|
||
answerDisplay.appendChild(titleElement);
|
||
answerDisplay.appendChild(contentElement);
|
||
|
||
return answerDisplay;
|
||
};
|
||
|
||
const findEssayEditor = (questionElement) =>
|
||
questionElement.querySelector(
|
||
".cke_wysiwyg_div[contenteditable='true'], [contenteditable='true'][role='textbox']"
|
||
);
|
||
|
||
const triggerEditorEvents = (editor) => {
|
||
["input", "keyup", "change", "blur"].forEach((eventName) => {
|
||
editor.dispatchEvent(
|
||
new Event(eventName, {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
})
|
||
);
|
||
});
|
||
};
|
||
|
||
const fillEssayAnswer = (questionElement, answerText) => {
|
||
const editor = findEssayEditor(questionElement);
|
||
|
||
if (!editor) {
|
||
alert("未找到简答题输入框");
|
||
return;
|
||
}
|
||
|
||
editor.focus();
|
||
editor.innerHTML = "";
|
||
answerText.split(/\n+/).filter(Boolean).forEach((line) => {
|
||
const paragraph = document.createElement("p");
|
||
paragraph.textContent = line;
|
||
editor.appendChild(paragraph);
|
||
});
|
||
triggerEditorEvents(editor);
|
||
};
|
||
|
||
const enableEssayFill = (answerDisplay, questionElement, answerText) => {
|
||
answerDisplay.classList.add("fillable");
|
||
answerDisplay.title = "双击将答案填入简答题输入框";
|
||
answerDisplay.addEventListener("dblclick", () => {
|
||
fillEssayAnswer(questionElement, answerText);
|
||
});
|
||
};
|
||
|
||
const findQuestionElement = (questionId) =>
|
||
document.querySelector('[id="' + questionId + '"]');
|
||
|
||
const showAnswers = async () => {
|
||
ensureStyle();
|
||
document.querySelectorAll("." + ANSWER_CLASS).forEach((item) => item.remove());
|
||
|
||
const { quizId } = getUrlParams();
|
||
|
||
if (!quizId) {
|
||
alert("当前页面没有 quizId,暂时无法获取作业数据");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = await fetchQuizData(quizId);
|
||
const questions = data?.data?.paperDetail?.questions || [];
|
||
|
||
if (questions.length === 0) {
|
||
alert("未获取到题目数据,请确认当前页面是否为作业页面");
|
||
return;
|
||
}
|
||
|
||
await waitForCondition(
|
||
() => questions.some((question) => findQuestionElement(question.questionId)),
|
||
30000
|
||
);
|
||
|
||
for (const questionData of questions) {
|
||
const questionElement = findQuestionElement(questionData.questionId);
|
||
|
||
if (!questionElement) {
|
||
continue;
|
||
}
|
||
|
||
if (questionData.solution) {
|
||
const officialAnswer = formatOfficialAnswer(questionData);
|
||
const answerDisplay = createAnswerDisplay(
|
||
"官方答案",
|
||
officialAnswer,
|
||
"official"
|
||
);
|
||
|
||
if (questionData.typeId === 3) {
|
||
enableEssayFill(answerDisplay, questionElement, officialAnswer);
|
||
}
|
||
|
||
questionElement.appendChild(
|
||
answerDisplay
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (questionData.userAnswer) {
|
||
questionElement.appendChild(
|
||
createAnswerDisplay("已作答", "当前题目已作答,未调用 AI。", "official")
|
||
);
|
||
continue;
|
||
}
|
||
|
||
const loadingDisplay = createAnswerDisplay(
|
||
"AI 参考答案",
|
||
"正在请求 DeepSeek...",
|
||
"ai"
|
||
);
|
||
questionElement.appendChild(loadingDisplay);
|
||
|
||
try {
|
||
const aiAnswer = await requestDeepSeek(questionData);
|
||
loadingDisplay.querySelector(".answer-title").textContent =
|
||
"DeepSeek 参考答案";
|
||
loadingDisplay.querySelector("div:last-child").textContent = aiAnswer;
|
||
|
||
if (questionData.typeId === 3) {
|
||
enableEssayFill(loadingDisplay, questionElement, aiAnswer);
|
||
}
|
||
} catch (error) {
|
||
loadingDisplay.className = ANSWER_CLASS + " error";
|
||
loadingDisplay.querySelector(".answer-title").textContent = "获取失败";
|
||
loadingDisplay.querySelector("div:last-child").textContent =
|
||
error.message || "DeepSeek 请求失败";
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("显示答案失败:", error);
|
||
alert("显示答案失败,请查看控制台日志");
|
||
}
|
||
};
|
||
|
||
registerConfigMenus();
|
||
|
||
window.addEventListener("load", () => {
|
||
const { quizId } = getUrlParams();
|
||
|
||
if (CONFIG.autoShowAnswers && quizId) {
|
||
showAnswers();
|
||
}
|
||
});
|
||
})();
|