425 lines
11 KiB
JavaScript
425 lines
11 KiB
JavaScript
// ==UserScript==
|
||
// @name 平顶山学院(青书学堂)作业答案助手
|
||
// @namespace http://tampermonkey.net/
|
||
// @version 2026-06-05.1
|
||
// @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;
|
||
}
|
||
`;
|
||
(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 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 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) {
|
||
questionElement.appendChild(
|
||
createAnswerDisplay("官方答案", questionData.solution, "official")
|
||
);
|
||
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;
|
||
} 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();
|
||
}
|
||
});
|
||
})();
|