tampermonkey_script/qingshuxuetang_homework_ans...

531 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name 平顶山学院(青书学堂)作业答案助手
// @namespace http://tampermonkey.net/
// @version 2026-06-05.3
// @description 在青书学堂作业页面展示官方答案,缺少官方答案时使用 DeepSeek 生成参考答案
// @author JK
// @license MIT
// @match https://degree.qingshuxuetang.com/*/Student/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=qingshuxuetang.com
// @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();
}
});
})();