// ==UserScript==
// @name 小雅粘粘粘
// @namespace http://tampermonkey.net/
// @author Qy
// @version 1.9
// @description 小雅粘粘粘:提取题目、生成 AI 作答模板,并保存作答记录
// @match *://*.ai-augmented.com/*
// @require https://scriptcat.org/lib/6743/1.0/MathJax%20%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6.js?sha384-Tkb2NhxAQ3j7F5KiTPLq9u2jQizdt2xfUNGsyFFVweCcRXYCxGo1QJNTS/RZXSKm
// @require https://scriptcat.org/lib/6742/14.1.0/markdown-it.js?sha384-wLhprpjsmjc/XYIcF+LpMxd8yS1gss6jhevOp6F6zhiIoFK6AmHtm4bGKtehTani
// @require https://scriptcat.org/lib/6741/3.2.2/MathJax%203.2.2.js?sha384-4kE/rQ11E8xT9QgrCBTyvenkuPfQo8rXYQvJZuMgxyPOoUfpatjQPlgdv6V5yhUK
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @connect *.ai-augmented.com
// @connect *.aliyuncs.com
// @connect xiaoya-notice-dwafgrs416f1w156r1fasd11jt.qyrun.me
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPgogIDxkZWZzPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMxNTFhMWEiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMjAyNzI3Ii8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KICA8cmVjdCB4PSI2NCIgeT0iNjQiIHdpZHRoPSI4OTYiIGhlaWdodD0iODk2IiByeD0iMjEwIiBmaWxsPSJ1cmwoI2JnKSIvPgogIDxwYXRoIGZpbGw9IiNmNGYwZTgiIGQ9Ik0xOTAgMTcwaDE0MmwxMjMgMjE0LTk3IDEyNHoiLz4KICA8cGF0aCBmaWxsPSIjZjRmMGU4IiBkPSJNNzE0IDE3MGgxNDBMNDI4IDc5MkgyODZ6Ii8+CiAgPHBhdGggZmlsbD0iIzNkYjg3YyIgZD0iTTUwOCA2NjBoMzA0djEzMkg0MTZ6Ii8+Cjwvc3ZnPg==
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const SCRIPT_NAME = "小雅粘粘粘";
const SCRIPT_VERSION = "1.9";
console.log(`[${SCRIPT_NAME} v${SCRIPT_VERSION}] 脚本已启动`);
let globalQuestionsData =[];
let globalExtractedText = "";
let globalImageAssets = [];
let globalPdfQuestions = [];
let globalQuestionSections = [];
let globalPaperMeta = { title: '', totalScore: null };
let globalCourseMeta = { groupId: '', courseName: '', loading: false, error: '' };
let globalSubmissionResult = { state: 'waiting', message: '等待题目数据加载...' };
let resultPanelVisible = false;
let resultFilter = 'all';
let imageDrawerVisible = false;
let homeworkDrawerVisible = false;
let homeworkExportOptions = { headerMode: 'course_homework', includeAnswers: false, answerPosition: 'inline' };
let activeTaskKey = "";
let panelExpanded = false;
let lastTriggerWidth = 170;
let uiTransitionToken = 0;
let uiTransitionTimer = null;
let resultPanelTransitionTimer = null;
let noticeState = {
content: '公告加载中...',
version: '',
updatedAt: '',
fetchedAt: 0,
hasUnread: false,
loading: false,
error: ''
};
let globalGroupId = "";
let globalNodeId = "";
let globalPaperId = "";
let globalRecordId = "";
let globalToken = "";
const UI_POSITION_KEY = "xy_magic_box_position_v16";
const LEGACY_UI_POSITION_KEY = "xy_magic_box_position_v13";
const DEFAULT_TRIGGER_WIDTH = 170;
const UI_MARGIN = 8;
const NOTICE_API = "https://xiaoya-notice-dwafgrs416f1w156r1fasd11jt.qyrun.me/notice";
const NOTICE_CHANNEL = "zhanzhanzhan";
const NOTICE_CACHE_KEY = "xy_zhanzhanzhan_notice_cache_v1";
const NOTICE_READ_KEY = "xy_zhanzhanzhan_notice_read_v1";
const NOTICE_CACHE_TTL = 6 * 60 * 60 * 1000;
const PDF_SAVE_TIP = '请选择浏览器内置的“保存为 PDF / 另存为 PDF”。';
const HOMEWORK_EXPORT_OPTIONS_KEY = "xy_homework_export_options_v18";
const COURSE_NAME_CACHE_PREFIX = "xy_course_name_v18_";
const originalXHROpen = XMLHttpRequest.prototype.open;
const originalXHRSend = XMLHttpRequest.prototype.send;
const originalFetch = window.fetch;
function capturePaperRequestParams(rawUrl) {
try {
const urlObj = new URL(rawUrl, window.location.origin);
globalGroupId = urlObj.searchParams.get('group_id') || globalGroupId;
globalNodeId = urlObj.searchParams.get('node_id') || globalNodeId;
globalPaperId = urlObj.searchParams.get('paper_id') || globalPaperId;
} catch (e) {
console.warn(`[${SCRIPT_NAME}] 无法解析题目数据请求参数`, e);
}
}
function getGroupIdFromGroupRequestUrl(rawUrl) {
try {
const urlObj = new URL(rawUrl, window.location.origin);
const match = urlObj.pathname.match(/\/api\/jx-iresource\/group\/queryGroup\/([^/]+)/);
return match && match[1] ? decodeURIComponent(match[1]) : '';
} catch (e) {
console.warn(`[${SCRIPT_NAME}] 无法解析课程信息请求参数`, e);
return '';
}
}
function captureGroupRequestParams(rawUrl) {
const capturedGroupId = getGroupIdFromGroupRequestUrl(rawUrl);
if (!capturedGroupId) return '';
if (globalGroupId && capturedGroupId !== globalGroupId && (activeTaskKey || globalQuestionsData.length > 0)) {
console.warn(`[${SCRIPT_NAME}] 已忽略非当前任务课程信息:${capturedGroupId}`);
return '';
}
globalGroupId = capturedGroupId || globalGroupId;
return capturedGroupId;
}
async function parseFetchResponseAsJson(response) {
try {
return await response.clone().json();
} catch (jsonError) {
const text = await response.clone().text();
return JSON.parse(text);
}
}
function buildTaskKey(groupId, nodeId, paperId) {
return [groupId || '', nodeId || '', paperId || ''].join(':');
}
function processCapturedJson(rawUrl, jsonData) {
if (!rawUrl) return;
if (rawUrl.includes("/queryStuPaper/v2")) {
processPaperData(jsonData);
return;
}
if (rawUrl.includes("/api/jx-iresource/group/queryGroup/")) {
const capturedGroupId = captureGroupRequestParams(rawUrl);
if (!capturedGroupId) return;
applyCourseInfo(jsonData?.data || jsonData, 'capture');
}
}
function resetCapturedTaskState(reason = '') {
if (!activeTaskKey && globalQuestionsData.length === 0) return;
console.log(`[${SCRIPT_NAME}] 已清空当前任务缓存${reason ? `:${reason}` : ''}`);
globalQuestionsData = [];
globalExtractedText = "";
globalImageAssets = [];
globalPdfQuestions = [];
globalQuestionSections = [];
globalPaperMeta = { title: '', totalScore: null };
globalCourseMeta = { groupId: '', courseName: '', loading: false, error: '' };
globalSubmissionResult = { state: 'waiting', message: '等待题目数据加载...' };
resultPanelVisible = false;
resultFilter = 'all';
imageDrawerVisible = false;
homeworkDrawerVisible = false;
globalGroupId = "";
globalNodeId = "";
globalPaperId = "";
globalRecordId = "";
activeTaskKey = "";
updateUIPanelData();
}
function currentUrlMatchesActiveTask() {
if (!activeTaskKey) return true;
const href = window.location.href;
try {
const urlObj = new URL(href, window.location.origin);
const groupParam = urlObj.searchParams.get('group_id');
const nodeParam = urlObj.searchParams.get('node_id');
const paperParam = urlObj.searchParams.get('paper_id');
if (groupParam && globalGroupId && groupParam !== globalGroupId) return false;
if (nodeParam && globalNodeId && nodeParam !== globalNodeId) return false;
if (paperParam && globalPaperId && paperParam !== globalPaperId) return false;
} catch (e) {
}
const requiredIds = [globalGroupId, globalNodeId].filter(Boolean);
if (requiredIds.length > 0 && !requiredIds.every(id => href.includes(id))) return false;
if (globalPaperId && href.includes('paper_id') && !href.includes(globalPaperId)) return false;
return true;
}
function handleRouteChange() {
setTimeout(() => {
if (!currentUrlMatchesActiveTask()) {
resetCapturedTaskState('页面路由已离开当前作业任务');
}
}, 80);
}
function installRouteWatcher() {
if (installRouteWatcher.installed) return;
installRouteWatcher.installed = true;
const wrapHistoryMethod = methodName => {
const original = history[methodName];
if (typeof original !== 'function') return;
history[methodName] = function(...args) {
const result = original.apply(this, args);
handleRouteChange();
return result;
};
};
wrapHistoryMethod('pushState');
wrapHistoryMethod('replaceState');
window.addEventListener('popstate', handleRouteChange);
window.addEventListener('hashchange', handleRouteChange);
}
XMLHttpRequest.prototype.open = function(method, url, ...args) {
this._requestUrl = typeof url === 'string' ? url : url.toString();
return originalXHROpen.apply(this,[method, url, ...args]);
};
XMLHttpRequest.prototype.send = function(...args) {
this.addEventListener('load', function() {
try {
if (this._requestUrl && this._requestUrl.includes("/queryStuPaper/v2")) {
console.log(`[${SCRIPT_NAME}] 已捕获题目数据包`);
capturePaperRequestParams(this._requestUrl);
if (this.responseType === 'blob' && this.response) {
this.response.text().then(text => processCapturedJson(this._requestUrl, JSON.parse(text)));
} else if (this.responseType === 'json' && this.response) {
processCapturedJson(this._requestUrl, this.response);
} else if ((this.responseType === '' || this.responseType === 'text') && this.responseText) {
processCapturedJson(this._requestUrl, JSON.parse(this.responseText));
}
} else if (this._requestUrl && this._requestUrl.includes("/api/jx-iresource/group/queryGroup/")) {
captureGroupRequestParams(this._requestUrl);
if (this.responseType === 'blob' && this.response) {
this.response.text().then(text => processCapturedJson(this._requestUrl, JSON.parse(text)));
} else if (this.responseType === 'json' && this.response) {
processCapturedJson(this._requestUrl, this.response);
} else if ((this.responseType === '' || this.responseType === 'text') && this.responseText) {
processCapturedJson(this._requestUrl, JSON.parse(this.responseText));
}
}
} catch (e) {
console.error("[解包失败]", e);
}
});
return originalXHRSend.apply(this, args);
};
window.fetch = async function(input, init) {
const response = await originalFetch.apply(this, arguments);
try {
const rawUrl = typeof input === 'string' ? input : (input && input.url ? input.url : String(input));
if (rawUrl && rawUrl.includes("/queryStuPaper/v2")) {
console.log(`[${SCRIPT_NAME}] 已捕获 fetch 题目数据包`);
capturePaperRequestParams(rawUrl);
parseFetchResponseAsJson(response)
.then(jsonData => processCapturedJson(rawUrl, jsonData))
.catch(e => console.error(`[${SCRIPT_NAME}] fetch 数据解包失败`, e));
} else if (rawUrl && rawUrl.includes("/api/jx-iresource/group/queryGroup/")) {
captureGroupRequestParams(rawUrl);
parseFetchResponseAsJson(response)
.then(jsonData => processCapturedJson(rawUrl, jsonData))
.catch(e => console.error(`[${SCRIPT_NAME}] fetch 数据解包失败`, e));
}
} catch (e) {
console.error(`[${SCRIPT_NAME}] fetch 拦截处理失败`, e);
}
return response;
};
function normalizeEntityMap(entityMap) {
if (!entityMap || typeof entityMap !== 'object') return {};
return entityMap;
}
function getEntityByKey(entityMap, key) {
const normalizedMap = normalizeEntityMap(entityMap);
if (Object.prototype.hasOwnProperty.call(normalizedMap, key)) return normalizedMap[key];
const stringKey = String(key);
if (Object.prototype.hasOwnProperty.call(normalizedMap, stringKey)) return normalizedMap[stringKey];
return null;
}
function getDataType(data = {}) {
return String(data.type || data.blockType || data.kind || '').toUpperCase();
}
function getImageSrcFromData(data = {}) {
const type = getDataType(data);
return data.src || data.imageUrl || data.image_url || data?.data?.src || data?.data?.imageUrl || data?.data?.image_url ||
((type.includes('IMAGE') || type === 'IMG') ? (data.url || data.href || data?.data?.url || data?.data?.href || '') : '');
}
function getFormulaFromData(data = {}) {
return data.teX || data.tex || data.latex || data.formula || data.value || data.content || data.text || data?.data?.teX || data?.data?.tex || data?.data?.latex || '';
}
function isImageData(data = {}) {
const type = getDataType(data);
return type.includes('IMAGE') || type === 'IMG' || !!data.src || !!data.imageUrl || !!data.image_url || !!data?.data?.src;
}
function isFormulaData(data = {}) {
const type = getDataType(data);
return type.includes('TEX') || type.includes('MATH') || type.includes('FORMULA');
}
function parseRichContent(rawContent) {
if (!rawContent) return { text: "", images: [], segments: [] };
let contentObject = null;
if (typeof rawContent === 'string') {
try {
contentObject = JSON.parse(rawContent);
} catch (e) {
const cleanText = rawContent.replace(/^"|"$/g, '').trim();
return { text: cleanText, images: [], segments: cleanText ? [{ type: 'text', value: cleanText }] : [] };
}
} else if (typeof rawContent === 'object') {
contentObject = rawContent;
} else {
const cleanText = String(rawContent).trim();
return { text: cleanText, images: [], segments: cleanText ? [{ type: 'text', value: cleanText }] : [] };
}
if (!contentObject || !Array.isArray(contentObject.blocks)) {
const fallbackText = typeof contentObject === 'string' ? contentObject.trim() : (typeof rawContent === 'string' ? rawContent.trim() : JSON.stringify(rawContent));
return {
text: fallbackText,
images: [],
segments: fallbackText ? [{ type: 'text', value: fallbackText }] : []
};
}
const images = [];
const segments = [];
const entityMap = normalizeEntityMap(contentObject.entityMap);
const pushText = text => {
const value = String(text || '');
if (value) segments.push({ type: 'text', value });
};
const pushImage = src => {
if (!src) return;
images.push({ src, kind: 'image' });
segments.push({ type: 'image', src });
};
const pushMath = (formula, display = false) => {
const cleanFormula = String(formula || '').trim();
if (cleanFormula) segments.push({ type: 'math', value: cleanFormula, display: display === true });
};
const pushBlockBreak = () => {
if (segments.length && segments[segments.length - 1].type !== 'blockBreak') {
segments.push({ type: 'blockBreak' });
}
};
const handleMediaData = (data = {}) => {
if (isImageData(data)) {
pushImage(getImageSrcFromData(data));
return true;
}
if (isFormulaData(data)) {
pushMath(getFormulaFromData(data), data.display === true || data.displayMode === true || data.block === true);
return true;
}
return false;
};
contentObject.blocks.forEach((block, blockIndex) => {
if (!block) return;
if (blockIndex > 0) pushBlockBreak();
if (block.type === 'atomic' && block.data) {
handleMediaData(block.data);
return;
}
const blockText = String(block.text || '');
const ranges = (Array.isArray(block.entityRanges) ? block.entityRanges : [])
.map(range => ({
offset: Math.max(0, Number(range?.offset) || 0),
length: Math.max(0, Number(range?.length) || 0),
entity: getEntityByKey(entityMap, range?.key)
}))
.filter(item => item.entity && item.length >= 0)
.sort((a, b) => a.offset - b.offset);
let cursor = 0;
ranges.forEach(range => {
const start = Math.min(range.offset, blockText.length);
const end = Math.min(start + range.length, blockText.length);
if (start < cursor) return;
if (start > cursor) pushText(blockText.slice(cursor, start));
const data = { ...(range.entity?.data || {}), type: range.entity?.type || range.entity?.data?.type };
const handled = handleMediaData(data);
if (!handled && end > start) pushText(blockText.slice(start, end));
cursor = end;
});
if (cursor < blockText.length) pushText(blockText.slice(cursor));
});
while (segments.length && segments[segments.length - 1].type === 'blockBreak') segments.pop();
return { text: segmentsToPlainText(segments), images, segments };
}
function extractTextFromRichJSON(rawContent) {
return parseRichContent(rawContent).text;
}
function collectImageAssets(questionIndex, source, optionLetter, parsedContent) {
if (!parsedContent || !Array.isArray(parsedContent.images)) return;
parsedContent.images.forEach(image => {
if (!image.src) return;
const duplicated = globalImageAssets.some(asset =>
asset.questionIndex === questionIndex &&
asset.source === source &&
asset.optionLetter === optionLetter &&
asset.src === image.src
);
if (duplicated) return;
globalImageAssets.push({
questionIndex,
source,
optionLetter,
src: image.src
});
});
}
function getQuestionTypeLabel(type) {
const labels = {
1: "[单选题]",
2: "[多选题]",
4: "[填空题]",
5: "[判断题]",
6: "[简答题]",
7: "[附件题]",
9: "[题组]",
13: "[匹配题]"
};
return labels[type] || "[其他]";
}
function parseMaybeJson(value) {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
if (!trimmed) return value;
if (/^\d+$/.test(trimmed)) return value;
try {
return JSON.parse(trimmed);
} catch (e) {
return value;
}
}
function extractPlainAnswerText(value) {
if (value === null || value === undefined) return '';
const parsed = parseMaybeJson(value);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.blocks)) {
return parseRichContent(parsed).text;
}
if (Array.isArray(parsed)) return parsed.map(extractPlainAnswerText).filter(Boolean).join(';');
if (parsed && typeof parsed === 'object') {
return Object.values(parsed).map(extractPlainAnswerText).filter(Boolean).join(';');
}
return String(parsed).trim();
}
function extractRichAnswerDisplay(value) {
if (value === null || value === undefined || value === '') return '';
const parsed = parseRichContent(value);
if (parsed.text) return parsed.text;
return extractPlainAnswerText(value);
}
function toOptionalNumber(value) {
if (value === null || value === undefined || value === '') return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function normalizeAnswerIds(answer) {
if (Array.isArray(answer)) return answer.map(item => String(item).trim()).filter(Boolean);
if (answer === null || answer === undefined) return [];
const parsed = parseMaybeJson(answer);
if (Array.isArray(parsed)) return parsed.map(item => String(item).trim()).filter(Boolean);
return String(parsed).split(/[,,、\s]+/).map(item => item.trim()).filter(Boolean);
}
function formatChoiceAnswer(qData, answer) {
const ids = normalizeAnswerIds(answer);
if (!ids.length) return '未作答';
return ids.map(id => {
const option = qData.options?.find(item => String(item.id) === String(id));
if (!option) return id;
const text = option.text ? ` ${option.text}` : '';
return `${option.letter}.${text}`;
}).join(';');
}
function formatFillAnswer(qData, answer) {
const parsed = parseMaybeJson(answer);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const text = extractPlainAnswerText(answer);
return text || '未作答';
}
const parts = qData.sortedItems.map((item, index) => {
const value = extractPlainAnswerText(parsed[item.id]);
return `空${index + 1}:${value || '未填'}`;
});
return parts.length ? parts.join(';') : '未作答';
}
function formatMatchingAnswer(qData, answer) {
const parsed = parseMaybeJson(answer);
const leftItems = qData.matchingLeftItems || [];
const rightItems = qData.matchingRightItems || [];
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const text = extractPlainAnswerText(answer);
return text || '未作答';
}
const rightById = new Map(rightItems.map(item => [String(item.id), item]));
let hasAnswer = false;
const lines = leftItems.map(left => {
const rawValue = parsed[left.id] ?? parsed[String(left.id)];
const rightIds = normalizeAnswerIds(rawValue);
if (!rightIds.length) return `${left.letter}. ${left.text || ''} => 未匹配`;
hasAnswer = true;
const rightText = rightIds.map(id => {
const right = rightById.get(String(id));
return right ? `${right.letter}. ${right.text || ''}` : id;
}).join('、');
return `${left.letter}. ${left.text || ''} => ${rightText}`;
});
return hasAnswer ? lines.join('\n') : '未作答';
}
function formatAnswerForDisplay(qData, answer) {
if (!qData) return extractPlainAnswerText(answer) || '未作答';
if (qData.type === 1 || qData.type === 2 || qData.type === 5) return formatChoiceAnswer(qData, answer);
if (qData.type === 4) return formatFillAnswer(qData, answer);
if (qData.type === 6) return extractRichAnswerDisplay(answer) || '未作答';
if (qData.type === 7) return '附件题';
if (qData.type === 13) return formatMatchingAnswer(qData, answer);
return extractPlainAnswerText(answer) || '未作答';
}
function getSegmentsOrText(parsedContent, fallbackText = '') {
if (parsedContent?.segments && parsedContent.segments.length) return parsedContent.segments;
return fallbackText ? [{ type: 'text', value: fallbackText }] : [];
}
function getSortedItems(items, sortValue) {
const rawItems = Array.isArray(items) ? [...items] : [];
const sortIds = String(sortValue || '').split(',').map(id => id.trim()).filter(Boolean);
if (!sortIds.length || !rawItems.length) return rawItems;
const byId = new Map(rawItems.map(item => [String(item?.id), item]));
const sorted = sortIds.map(id => byId.get(String(id))).filter(Boolean);
const usedIds = new Set(sorted.map(item => String(item?.id)));
return sorted.concat(rawItems.filter(item => !usedIds.has(String(item?.id))));
}
function getQuestionSubItems(question) {
const subQuestions = Array.isArray(question?.subQuestions)
? question.subQuestions
: (Array.isArray(question?.sub_questions) ? question.sub_questions : []);
return getSortedItems(subQuestions, question?.sub_questions_sort);
}
function createQuestionArtifacts(q, questionIndex, sectionKey, sectionTitleText) {
const parsedTitle = parseRichContent(q.title);
const qTitle = parsedTitle.text;
const qTypeStr = getQuestionTypeLabel(q.type);
const resultOptions = [];
const matchingLeftItems = [];
const matchingRightItems = [];
collectImageAssets(questionIndex, 'title', null, parsedTitle);
const sortedItems = getSortedItems(q.answer_items, q.answer_items_sort);
const pdfQuestion = {
index: questionIndex,
id: q.id,
type: q.type,
typeLabel: qTypeStr,
titleSegments: getSegmentsOrText(parsedTitle, qTitle),
options: [],
matchingLeftItems: [],
matchingRightItems: [],
blankCount: sortedItems.length,
sectionKey
};
if (q.type === 1 || q.type === 2 || q.type === 5) {
let letterCharCode = 65;
sortedItems.forEach(opt => {
const optionLetter = String.fromCharCode(letterCharCode);
const parsedOption = parseRichContent(opt.value);
let optText = parsedOption.text;
collectImageAssets(questionIndex, 'option', optionLetter, parsedOption);
if (q.type === 5 && !optText) {
optText = optionLetter === 'A' ? '正确' : '错误';
}
pdfQuestion.options.push({
letter: optionLetter,
text: optText,
segments: getSegmentsOrText(parsedOption, optText)
});
resultOptions.push({
id: opt.id,
letter: optionLetter,
text: optText,
answerChecked: opt.answer_checked
});
letterCharCode++;
});
} else if (q.type === 13) {
const leftRawItems = sortedItems.filter(item => item && item.is_target_opt !== true);
const rightRawItems = sortedItems.filter(item => item && item.is_target_opt === true);
leftRawItems.forEach((opt, itemIndex) => {
const optionLetter = String.fromCharCode(65 + itemIndex);
const parsedOption = parseRichContent(opt.value);
const optText = parsedOption.text;
const itemData = {
id: opt.id,
letter: optionLetter,
text: optText,
answer: opt.answer,
segments: getSegmentsOrText(parsedOption, optText)
};
collectImageAssets(questionIndex, 'option', optionLetter, parsedOption);
matchingLeftItems.push(itemData);
pdfQuestion.matchingLeftItems.push(itemData);
});
rightRawItems.forEach((opt, itemIndex) => {
const optionLetter = String.fromCharCode(97 + itemIndex);
const parsedOption = parseRichContent(opt.value);
const optText = parsedOption.text;
const itemData = {
id: opt.id,
letter: optionLetter,
text: optText,
segments: getSegmentsOrText(parsedOption, optText)
};
collectImageAssets(questionIndex, 'option', optionLetter, parsedOption);
matchingRightItems.push(itemData);
pdfQuestion.matchingRightItems.push(itemData);
});
}
const qData = {
index: questionIndex,
id: q.id,
type: q.type,
score: q.score,
titleText: qTitle,
titleSegments: pdfQuestion.titleSegments,
typeLabel: qTypeStr,
sectionKey,
sectionTitleText,
options: resultOptions,
matchingLeftItems,
matchingRightItems,
sortedItems
};
return { qData, pdfQuestion };
}
function normalizePaperQuestions(questionsArray) {
const sections = [];
const flattenedQuestions = [];
const pdfQuestions = [];
let questionIndex = 1;
(Array.isArray(questionsArray) ? questionsArray : []).forEach((rawQuestion, rawIndex) => {
const subQuestions = getQuestionSubItems(rawQuestion);
const parsedSectionTitle = parseRichContent(rawQuestion.title);
const sectionTitleText = parsedSectionTitle.text;
const sectionKey = `section-${rawQuestion?.id || rawIndex}`;
if (Number(rawQuestion?.type) === 9 && subQuestions.length > 0) {
const section = {
key: sectionKey,
titleText: sectionTitleText,
titleSegments: getSegmentsOrText(parsedSectionTitle, sectionTitleText),
isGroup: true,
questionIds: [],
questionIndexes: [],
questions: []
};
collectImageAssets(questionIndex, 'title', null, parsedSectionTitle);
subQuestions.forEach(subQuestion => {
const artifacts = createQuestionArtifacts(subQuestion, questionIndex, sectionKey, sectionTitleText);
section.questionIds.push(String(artifacts.qData.id));
section.questionIndexes.push(questionIndex);
section.questions.push(artifacts.qData);
flattenedQuestions.push(artifacts.qData);
pdfQuestions.push(artifacts.pdfQuestion);
questionIndex++;
});
sections.push(section);
return;
}
const section = {
key: sectionKey,
titleText: '',
titleSegments: [],
isGroup: false,
questionIds: [],
questionIndexes: [],
questions: []
};
const artifacts = createQuestionArtifacts(rawQuestion, questionIndex, sectionKey, '');
section.questionIds.push(String(artifacts.qData.id));
section.questionIndexes.push(questionIndex);
section.questions.push(artifacts.qData);
flattenedQuestions.push(artifacts.qData);
pdfQuestions.push(artifacts.pdfQuestion);
sections.push(section);
questionIndex++;
});
return { sections, flattenedQuestions, pdfQuestions };
}
function appendPromptQuestion(lines, qData) {
lines.push(`${qData.index}. ${qData.titleText} ${qData.typeLabel}`);
if (qData.type === 1 || qData.type === 2 || qData.type === 5) {
qData.options.forEach(option => {
lines.push(` ${option.letter}. ${option.text}`);
});
} else if (qData.type === 4) {
lines.push(` (本题共 ${qData.sortedItems.length} 个填空)`);
} else if (qData.type === 7) {
lines.push(` 附件题无需回答。`);
} else if (qData.type === 13) {
lines.push(` 左侧:`);
qData.matchingLeftItems.forEach(item => lines.push(` ${item.letter}. ${item.text}`));
lines.push(``);
lines.push(` 右侧候选:`);
qData.matchingRightItems.forEach(item => lines.push(` ${item.letter}. ${item.text}`));
}
lines.push('');
}
function buildAiPromptText() {
const lines = [
'【小雅粘粘粘:AI 作答模板】'
];
const courseName = getCurrentCourseName();
if (courseName) lines.push(`课程:${courseName}`);
lines.push(
'请根据以下题目,严格按照指定格式输出答案,不要输出解析、注释或额外说明。',
'【输出格式要求】',
'单选/判断题格式:[题号] => [大写字母] (如 1 => A)',
'多选题格式:[题号] => [大写字母] (多选请用英文逗号分隔,如 2 => A,C)',
'填空题格式:[题号] => [空1] | [空2] (如 3 => const | let)',
'简答题格式:[题号] => [完整文字答案]',
'匹配题格式:[题号] => A:a,d | B:b,c',
'附件题无需回答。',
'',
'--- 以下为考试内容 ---',
''
);
globalQuestionSections.forEach(section => {
if (section.isGroup && section.titleText) {
lines.push(`【题组】`);
lines.push(section.titleText);
lines.push('');
}
section.questions.forEach(question => appendPromptQuestion(lines, question));
});
return lines.join('\n');
}
function getStandardAnswerDisplay(qData, canShowStandardAnswer) {
if (!canShowStandardAnswer || !qData) return '';
if (qData.type === 1 || qData.type === 2 || qData.type === 5) {
const correctOptions = qData.options?.filter(item => item.answerChecked === 2) || [];
return correctOptions.map(option => {
const text = option.text ? ` ${option.text}` : '';
return `${option.letter}.${text}`;
}).join(';');
}
if (qData.type === 4) {
const parts = qData.sortedItems.map((item, index) => {
const value = extractPlainAnswerText(item.answer);
return value ? `空${index + 1}:${value}` : '';
}).filter(Boolean);
return parts.join(';');
}
if (qData.type === 6 && qData.sortedItems[0]) {
return extractRichAnswerDisplay(qData.sortedItems[0].answer);
}
if (qData.type === 13) {
const mapping = {};
(qData.matchingLeftItems || []).forEach(left => {
if (left.answer) mapping[left.id] = left.answer;
});
return Object.keys(mapping).length ? formatMatchingAnswer(qData, mapping) : '';
}
return '';
}
function getQuestionResultState(answerRecord, qData) {
if (!answerRecord) return { label: '未作答', tone: 'muted' };
const score = toOptionalNumber(answerRecord.score);
const correct = toOptionalNumber(answerRecord.correct);
const fullScore = toOptionalNumber(qData?.score);
const hasScore = score !== null;
const hasFullScore = fullScore !== null && fullScore > 0;
if (correct === 2 || (hasScore && hasFullScore && score >= fullScore)) {
return { label: '正确', tone: 'ok' };
}
if (hasScore && score > 0) return { label: '部分得分', tone: 'partial' };
if (correct === 1 || (hasScore && score === 0)) return { label: '错误', tone: 'bad' };
return { label: '待批改', tone: 'pending' };
}
function isConfirmedCorrectAnswer(answerRecord, qData) {
if (!answerRecord) return false;
const correct = toOptionalNumber(answerRecord.correct);
if (correct === 2) return true;
const score = toOptionalNumber(answerRecord.score);
const fullScore = toOptionalNumber(qData?.score);
return score !== null && fullScore !== null && fullScore > 0 && score >= fullScore;
}
function getExportAnswerDisplay(qData, answerRecord, canShowStandardAnswer) {
const standardAnswer = getStandardAnswerDisplay(qData, canShowStandardAnswer);
if (standardAnswer) return standardAnswer;
if (isConfirmedCorrectAnswer(answerRecord, qData)) {
return formatAnswerForDisplay(qData, answerRecord?.answer);
}
return '';
}
function buildSubmissionResult(paperData) {
const answerRecord = paperData?.answer_record;
const answers = answerRecord?.answers;
if (!answerRecord || !Array.isArray(answers) || answers.length === 0) {
return {
state: globalQuestionsData.length ? 'not_submitted' : 'waiting',
message: globalQuestionsData.length ? '未检测到已提交作业记录。' : '等待题目数据加载...'
};
}
const isSubmitted = Number(answerRecord.status) === 2;
if (!isSubmitted) {
return { state: 'not_submitted', message: '检测到作答记录,但当前任务尚未提交。' };
}
const canShowStandardAnswer = paperData?.publish_record?.is_show_answer === true;
const answersByQuestionId = new Map(answers.map(ans => [String(ans.question_id), ans]));
const totalScore = toOptionalNumber(paperData?.total_score);
const actualScore = toOptionalNumber(answerRecord.actual_score ?? answerRecord.score);
const answerNum = toOptionalNumber(answerRecord.answer_num || globalQuestionsData.length);
const correctNum = toOptionalNumber(answerRecord.answer_correct_num);
const questionResults = globalQuestionsData.map(qData => {
const answer = answersByQuestionId.get(String(qData.id));
const resultState = getQuestionResultState(answer, qData);
const score = toOptionalNumber(answer?.score);
const fullScore = toOptionalNumber(qData.score);
const scoreText = score !== null
? `${score} / ${fullScore !== null ? fullScore : '-'} 分`
: `- / ${fullScore !== null ? fullScore : '-'} 分`;
return {
index: qData.index,
id: qData.id,
type: qData.type,
typeLabel: getQuestionTypeLabel(qData.type),
title: qData.titleText,
stateLabel: resultState.label,
tone: resultState.tone,
scoreText,
userAnswer: formatAnswerForDisplay(qData, answer?.answer),
standardAnswer: getStandardAnswerDisplay(qData, canShowStandardAnswer),
exportAnswer: getExportAnswerDisplay(qData, answer, canShowStandardAnswer),
sectionKey: qData.sectionKey
};
});
const resultByQuestionId = new Map(questionResults.map(item => [String(item.id), item]));
const resultSections = globalQuestionSections.map(section => ({
key: section.key,
titleText: section.titleText,
titleSegments: section.titleSegments,
isGroup: section.isGroup,
questionResults: (section.questionIds || [])
.map(id => resultByQuestionId.get(String(id)))
.filter(Boolean)
})).filter(section => section.questionResults.length > 0);
return {
state: 'submitted',
canShowStandardAnswer,
totalScore,
actualScore,
answerNum: answerNum !== null ? answerNum : questionResults.length,
correctNum,
questionResults,
sections: resultSections
};
}
function processPaperData(jsonData) {
if (!jsonData || !jsonData.data || !jsonData.data.questions) {
console.warn(`[${SCRIPT_NAME}] 题目数据结构不完整,已跳过处理`);
return;
}
globalPaperId = globalPaperId || jsonData.data.paper_id || jsonData.data.paperId || jsonData.data.id || "";
if(!globalGroupId) globalGroupId = jsonData.data.group_id;
if (globalCourseMeta.groupId && globalGroupId && globalCourseMeta.groupId !== globalGroupId) {
globalCourseMeta = { groupId: globalGroupId, courseName: getCachedCourseName(globalGroupId), loading: false, error: '' };
}
if (globalGroupId && !globalCourseMeta.courseName) {
const cachedCourseName = getCachedCourseName(globalGroupId);
if (cachedCourseName) globalCourseMeta = { ...globalCourseMeta, groupId: globalGroupId, courseName: cachedCourseName };
}
globalPaperMeta = {
title: String(jsonData.data.title || '课程作业'),
totalScore: toOptionalNumber(jsonData.data.total_score),
canShowStandardAnswer: jsonData.data.publish_record?.is_show_answer === true
};
globalImageAssets = [];
globalQuestionSections = [];
globalPdfQuestions = [];
const normalized = normalizePaperQuestions(jsonData.data.questions);
globalQuestionsData = normalized.flattenedQuestions;
globalQuestionSections = normalized.sections;
globalPdfQuestions = normalized.pdfQuestions;
globalExtractedText = buildAiPromptText();
activeTaskKey = buildTaskKey(globalGroupId, globalNodeId, globalPaperId);
globalSubmissionResult = buildSubmissionResult(jsonData.data);
resultPanelVisible = globalSubmissionResult.state === 'submitted';
resultFilter = 'all';
imageDrawerVisible = false;
homeworkDrawerVisible = false;
fetchCourseInfoForCurrentGroup();
console.log("✅ 数据清洗完毕,已生成 v1.8 题组结构!");
createUIPanel();
}
function getToken() {
const cookies = document.cookie.split('; ');
for (let cookie of cookies) {
const[name, value] = cookie.split('=');
if (name.includes('prd-access-token')) return value;
}
return null;
}
async function fetchRecordId() {
if (!globalToken) globalToken = getToken();
if (!globalToken) throw new Error("未获取到 Token");
if (!globalNodeId || !globalGroupId) throw new Error("未获取到课程或节点参数");
const url = `${window.location.origin}/api/jx-iresource/survey/course/task/flow/v2?node_id=${globalNodeId}&group_id=${globalGroupId}`;
const response = await fetch(url, {
headers: { 'authorization': `Bearer ${globalToken}`, 'content-type': 'application/json' },
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Record ID 请求失败:${response.status}`);
}
const data = await response.json();
if (data.success && data.data) {
if (data.data.task_flow_record?.[0]?.answer_record_id) return data.data.task_flow_record[0].answer_record_id;
if (data.data.task_flow_template?.[0]?.answer_record_id) return data.data.task_flow_template[0].answer_record_id;
}
throw new Error("无法获取 Record ID");
}
async function submitSingleAnswer(questionId, answerPayload) {
if (!globalPaperId) throw new Error("未获取到 paper_id");
const requestBody = {
record_id: globalRecordId,
question_id: questionId,
answer: answerPayload,
ext_answer: "",
group_id: globalGroupId,
paper_id: globalPaperId,
is_try: 0
};
const response = await fetch(`${window.location.origin}/api/jx-iresource/survey/answer`, {
method: 'POST',
headers: {
'accept': '*/*',
'authorization': `Bearer ${globalToken}`,
'content-type': 'application/json; charset=UTF-8'
},
credentials: 'include',
body: JSON.stringify(requestBody)
});
let responseData = null;
try {
responseData = await response.clone().json();
} catch (e) {
responseData = null;
}
if (!response.ok || responseData?.success === false) {
const message = responseData?.message || responseData?.error || response.statusText || "未知错误";
throw new Error(`保存作答失败:${message}`);
}
return responseData;
}
function parseAIAnswerBlocks(aiText) {
const blocks = [];
let current = null;
String(aiText || '').split(/\r?\n/).forEach(line => {
const match = line.match(/^\s*(\d+)\s*=>\s*(.*)$/);
if (match) {
if (current) {
current.answer = current.lines.join('\n').trim();
blocks.push(current);
}
current = {
index: parseInt(match[1], 10),
lines: [match[2].trim()]
};
return;
}
if (current) current.lines.push(line.trimEnd());
});
if (current) {
current.answer = current.lines.join('\n').trim();
blocks.push(current);
}
return blocks.filter(block => Number.isFinite(block.index) && block.answer);
}
function createRichTextAnswer(text, questionId) {
const lines = String(text || '').trim().split(/\r?\n/);
return JSON.stringify({
blocks: lines.map((line, index) => ({
key: `ans-${index}`,
text: line,
type: 'unstyled',
depth: 0,
inlineStyleRanges: [],
entityRanges: [],
data: {}
})),
entityMap: {}
});
}
function createMatchingAnswerPayload(qData, answerText) {
const leftByLetter = new Map((qData.matchingLeftItems || []).map(item => [String(item.letter).toUpperCase(), item]));
const rightByLetter = new Map((qData.matchingRightItems || []).map(item => [String(item.letter).toLowerCase(), item]));
const payload = {};
const segments = String(answerText || '').split(/[||;\n;]+/).map(item => item.trim()).filter(Boolean);
segments.forEach(segment => {
const match = segment.match(/^\s*([A-Za-z])\s*(?:=>|->|[::=])\s*(.+?)\s*$/);
if (!match) {
console.warn(`[${SCRIPT_NAME}] 匹配题答案片段无法识别:${segment}`);
return;
}
const leftLetter = match[1].toUpperCase();
const leftItem = leftByLetter.get(leftLetter);
if (!leftItem) {
console.warn(`[${SCRIPT_NAME}] 匹配题左侧字母无效:${leftLetter}`);
return;
}
const rightIds = match[2]
.split(/[,,、\s]+/)
.map(token => token.trim().replace(/[.。]/g, '').toLowerCase())
.filter(Boolean)
.map(letter => {
const rightItem = rightByLetter.get(letter);
if (!rightItem) {
console.warn(`[${SCRIPT_NAME}] 匹配题右侧字母无效:${letter}`);
return null;
}
return rightItem.id;
})
.filter(Boolean);
if (rightIds.length > 0) {
payload[leftItem.id] = rightIds.join(',');
}
});
return payload;
}
async function executeFill(aiText) {
try {
globalRecordId = await fetchRecordId();
console.log("✅ 成功获取 Record ID: ", globalRecordId);
} catch (e) {
alert("初始化提交参数失败,请刷新页面重试!\n" + e.message);
return;
}
const answerBlocks = parseAIAnswerBlocks(aiText);
let successCount = 0;
let failureCount = 0;
for (let answerBlock of answerBlocks) {
let qIndex = answerBlock.index;
let qAnswerStr = answerBlock.answer.trim();
let qData = globalQuestionsData.find(q => q.index === qIndex);
if (!qData) continue;
if (qData.type === 7) continue;
let answerPayload =[];
if (qData.type === 1 || qData.type === 2 || qData.type === 5) {
let letters = qAnswerStr
.split(/[,,、\s]+/)
.map(s => s.trim().toUpperCase())
.filter(Boolean);
letters.forEach(letter => {
let targetIndex = letter.charCodeAt(0) - 65;
if (qData.sortedItems[targetIndex]) {
answerPayload.push(qData.sortedItems[targetIndex].id);
}
});
}
else if (qData.type === 4) {
let blanks = qAnswerStr.split(/[||]/).map(s => s.trim());
let fillObject = {};
qData.sortedItems.forEach((item, idx) => {
if (blanks[idx]) {
fillObject[item.id] = blanks[idx];
}
});
answerPayload = [fillObject];
}
else if (qData.type === 6 && qAnswerStr) {
answerPayload = [createRichTextAnswer(qAnswerStr, qData.id)];
}
else if (qData.type === 13) {
const matchObject = createMatchingAnswerPayload(qData, qAnswerStr);
if (Object.keys(matchObject).length > 0) {
answerPayload = [matchObject];
}
}
if (answerPayload.length > 0) {
try {
await submitSingleAnswer(qData.id, answerPayload);
successCount++;
console.log(`[${SCRIPT_NAME}] 第 ${qIndex} 题作答已保存,Payload:`, answerPayload);
} catch (error) {
failureCount++;
console.error(`[${SCRIPT_NAME}] 第 ${qIndex} 题保存失败`, error);
}
}
}
if (successCount > 0) {
alert(`${SCRIPT_NAME} 已完成:成功保存 ${successCount} 道题,失败 ${failureCount} 道。点击确定后刷新页面查看结果。`);
window.location.reload();
} else {
alert(`${SCRIPT_NAME} 未保存任何答案,请检查 AI 输出格式或控制台错误。`);
}
}
function setUIStatus(message, isError = false) {
const status = document.getElementById('xy-status');
if (status) {
status.innerText = message;
status.style.color = isError ? '#dc2626' : '#6b7280';
}
const floatingStatus = document.getElementById('xy-floating-status');
if (floatingStatus) {
floatingStatus.innerText = globalQuestionsData.length > 0 ? `${globalQuestionsData.length} 道题已同步` : '等待同步题目';
}
}
function safeLocalStorageGet(key) {
try {
return localStorage.getItem(key);
} catch (error) {
console.warn(`[${SCRIPT_NAME}] localStorage 读取失败:${key}`, error);
return null;
}
}
function safeLocalStorageSet(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch (error) {
console.warn(`[${SCRIPT_NAME}] localStorage 写入失败:${key}`, error);
return false;
}
}
function readHomeworkExportOptions() {
const fallback = { headerMode: 'course_homework', includeAnswers: false, answerPosition: 'inline' };
const raw = safeLocalStorageGet(HOMEWORK_EXPORT_OPTIONS_KEY);
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw);
return {
headerMode: ['course_homework', 'course', 'homework', 'none'].includes(parsed?.headerMode) ? parsed.headerMode : fallback.headerMode,
includeAnswers: parsed?.includeAnswers === true,
answerPosition: ['inline', 'appendix'].includes(parsed?.answerPosition) ? parsed.answerPosition : fallback.answerPosition
};
} catch (error) {
console.warn(`[${SCRIPT_NAME}] 作业导出设置解析失败`, error);
return fallback;
}
}
function saveHomeworkExportOptions() {
safeLocalStorageSet(HOMEWORK_EXPORT_OPTIONS_KEY, JSON.stringify(homeworkExportOptions));
}
function getCourseNameCacheKey(groupId = globalGroupId) {
return groupId ? `${COURSE_NAME_CACHE_PREFIX}${groupId}` : '';
}
function getCachedCourseName(groupId = globalGroupId) {
const key = getCourseNameCacheKey(groupId);
return key ? (safeLocalStorageGet(key) || '').trim() : '';
}
function cacheCourseName(courseName, groupId = globalGroupId) {
const key = getCourseNameCacheKey(groupId);
const value = String(courseName || '').trim();
if (key && value) safeLocalStorageSet(key, value);
}
function getCurrentCourseName() {
return String(globalCourseMeta.courseName || getCachedCourseName() || '').trim();
}
function applyCourseInfo(record, source = 'unknown') {
const courseName = String(record?.name || record?.course_name || record?.courseName || record?.group_name || record?.groupName || '').trim();
const groupId = String(record?.id || record?.group_id || globalGroupId || '').trim();
if (groupId) globalGroupId = globalGroupId || groupId;
if (!courseName) return;
globalCourseMeta = {
...globalCourseMeta,
groupId: groupId || globalGroupId,
courseName,
loading: false,
error: ''
};
cacheCourseName(courseName, groupId || globalGroupId);
globalExtractedText = buildAiPromptText();
updateHomeworkDrawerUI();
updateUIPanelData();
console.log(`[${SCRIPT_NAME}] 已更新课程信息:${courseName} (${source})`);
}
async function fetchCourseInfoForCurrentGroup() {
if (!globalGroupId) return;
const cached = getCachedCourseName();
if (cached && !globalCourseMeta.courseName) {
globalCourseMeta = { ...globalCourseMeta, groupId: globalGroupId, courseName: cached, error: '' };
}
if (globalCourseMeta.loading || (globalCourseMeta.groupId === globalGroupId && globalCourseMeta.courseName && !globalCourseMeta.error)) {
updateHomeworkDrawerUI();
return;
}
if (!globalToken) globalToken = getToken();
globalCourseMeta = { ...globalCourseMeta, groupId: globalGroupId, loading: true, error: '' };
updateHomeworkDrawerUI();
try {
const response = await originalFetch.call(window, `${window.location.origin}/api/jx-iresource/group/queryGroup/${encodeURIComponent(globalGroupId)}`, {
headers: {
'authorization': globalToken ? `Bearer ${globalToken}` : '',
'content-type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) throw new Error(`课程信息请求失败:${response.status}`);
const data = await response.json();
if (!data?.success || !data.data) throw new Error(data?.message || '课程信息为空');
applyCourseInfo(data.data, 'fetch');
} catch (error) {
globalCourseMeta = { ...globalCourseMeta, loading: false, error: error?.message || String(error) };
updateHomeworkDrawerUI();
console.warn(`[${SCRIPT_NAME}] 课程信息读取失败`, error);
}
}
function isNewerVersion(latest, current) {
if (!latest) return false;
const latestParts = String(latest).split('.').map(part => Number(part) || 0);
const currentParts = String(current).split('.').map(part => Number(part) || 0);
const length = Math.max(latestParts.length, currentParts.length);
for (let index = 0; index < length; index++) {
const latestPart = latestParts[index] || 0;
const currentPart = currentParts[index] || 0;
if (latestPart > currentPart) return true;
if (latestPart < currentPart) return false;
}
return false;
}
function getNoticeFingerprint(record = noticeState) {
return [record.version || '', record.updatedAt || '', record.content || ''].join('|');
}
function readNoticeCache() {
const cached = safeLocalStorageGet(NOTICE_CACHE_KEY);
if (!cached) return null;
try {
const parsed = JSON.parse(cached);
if (!parsed || typeof parsed !== 'object') return null;
return {
content: String(parsed.content || '暂无公告'),
version: String(parsed.version || ''),
updatedAt: String(parsed.updatedAt || ''),
fetchedAt: Number(parsed.fetchedAt) || 0
};
} catch (error) {
console.warn(`[${SCRIPT_NAME}] 公告缓存解析失败`, error);
return null;
}
}
function applyNoticeRecord(record, options = {}) {
const normalized = {
content: String(record?.content || '暂无公告'),
version: String(record?.version || ''),
updatedAt: String(record?.updatedAt || ''),
fetchedAt: Number(record?.fetchedAt) || Date.now()
};
const readFingerprint = safeLocalStorageGet(NOTICE_READ_KEY) || '';
noticeState = {
...noticeState,
...normalized,
loading: false,
error: options.error || '',
hasUnread: options.preventUnread
? false
: normalized.content !== '暂无公告'
&& !!getNoticeFingerprint(normalized)
&& getNoticeFingerprint(normalized) !== readFingerprint
};
updateNoticeUI();
}
function updateNoticeUI() {
const dot = document.querySelector('.xy-floating-dot');
if (dot) dot.classList.toggle('xy-notice-unread', noticeState.hasUnread);
const body = document.getElementById('xy-notice-body');
if (body) body.textContent = noticeState.content || '暂无公告';
const meta = document.getElementById('xy-notice-meta');
if (meta) {
const timeText = noticeState.updatedAt
? new Date(noticeState.updatedAt).toLocaleString()
: '暂无更新时间';
meta.textContent = noticeState.error ? `${noticeState.error} · ${timeText}` : timeText;
}
const versionBadge = document.getElementById('xy-notice-version-badge');
if (versionBadge) {
const showVersion = isNewerVersion(noticeState.version, SCRIPT_VERSION);
versionBadge.style.display = showVersion ? 'inline-flex' : 'none';
versionBadge.textContent = showVersion ? `v${noticeState.version} 可用` : '';
}
const toggle = document.getElementById('xy-notice-toggle');
if (toggle) {
const needsToggle = (noticeState.content || '').length > 70 || (noticeState.content || '').includes('\n');
toggle.style.display = needsToggle ? 'inline-flex' : 'none';
}
const refresh = document.getElementById('xy-notice-refresh');
if (refresh) {
refresh.disabled = noticeState.loading;
refresh.title = noticeState.loading ? '公告刷新中...' : '刷新公告';
refresh.innerHTML = renderIconSvg('refresh', 12);
}
}
function fetchNoticeWithGM() {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== 'function') {
reject(new Error('GM_xmlhttpRequest 不可用'));
return;
}
GM_xmlhttpRequest({
method: 'POST',
url: NOTICE_API,
data: JSON.stringify({ action: 'get_notice', channel: NOTICE_CHANNEL, client: `xiaoya-zhanzhanzhan-v${SCRIPT_VERSION}` }),
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
timeout: 10000,
onload: response => {
if (response.status < 200 || response.status >= 300) {
reject(new Error(`公告请求失败:${response.status}`));
return;
}
try {
resolve(JSON.parse(response.responseText));
} catch (error) {
reject(new Error('公告响应解析失败'));
}
},
onerror: () => reject(new Error('公告网络请求失败')),
ontimeout: () => reject(new Error('公告网络请求超时'))
});
});
}
async function fetchNoticeRecord() {
try {
const response = await fetch(NOTICE_API, {
method: 'POST',
body: JSON.stringify({ action: 'get_notice', channel: NOTICE_CHANNEL, client: `xiaoya-zhanzhanzhan-v${SCRIPT_VERSION}` })
});
if (!response.ok) throw new Error(`公告请求失败:${response.status}`);
return await response.json();
} catch (error) {
console.warn(`[${SCRIPT_NAME}] 页面 fetch 获取公告失败,尝试 GM_xmlhttpRequest`, error);
return fetchNoticeWithGM();
}
}
async function runNoticeCheck(forceRefresh = false) {
const cached = readNoticeCache();
if (cached) applyNoticeRecord(cached);
if (!forceRefresh && cached && Date.now() - cached.fetchedAt < NOTICE_CACHE_TTL) return;
noticeState.loading = true;
updateNoticeUI();
try {
const data = await fetchNoticeRecord();
if (!data || data.ok !== true) throw new Error('公告返回数据异常');
if (Number(data.apiVersion) < 2 || data.channel !== NOTICE_CHANNEL) {
throw new Error('公告服务尚未完成多频道升级');
}
const record = {
content: String(data.content || '暂无公告'),
version: String(data.version || ''),
updatedAt: String(data.updatedAt || ''),
fetchedAt: Date.now()
};
safeLocalStorageSet(NOTICE_CACHE_KEY, JSON.stringify(record));
applyNoticeRecord(record);
} catch (error) {
console.warn(`[${SCRIPT_NAME}] 公告加载失败`, error);
if (cached) {
applyNoticeRecord(cached, { error: '公告刷新失败,正在显示缓存' });
} else {
applyNoticeRecord({
content: '公告加载失败',
version: '',
updatedAt: '',
fetchedAt: Date.now()
}, { error: '公告加载失败', preventUnread: true });
}
}
}
function markNoticeAsRead() {
if (!noticeState.hasUnread) return;
safeLocalStorageSet(NOTICE_READ_KEY, getNoticeFingerprint());
noticeState.hasUnread = false;
updateNoticeUI();
}
function toggleNoticeBody() {
const body = document.getElementById('xy-notice-body');
const toggle = document.getElementById('xy-notice-toggle');
if (!body || !toggle) return;
const expanded = body.classList.toggle('xy-notice-expanded');
toggle.textContent = expanded ? '收起' : '展开';
}
function initializeNoticeSystem() {
const cached = readNoticeCache();
if (cached) applyNoticeRecord(cached);
runNoticeCheck(false);
}
function getImageAssetLabel(asset) {
if (asset.source === 'option' && asset.optionLetter) {
return `第 ${asset.questionIndex} 题选项 ${asset.optionLetter} 图片`;
}
return `第 ${asset.questionIndex} 题题干图片`;
}
async function fetchImageBlobWithPageFetch(src) {
const response = await fetch(src, { credentials: 'include', redirect: 'follow' });
if (!response.ok) throw new Error(`图片请求失败:${response.status}`);
return response.blob();
}
function fetchImageBlobWithGM(src) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== 'function') {
reject(new Error('GM_xmlhttpRequest 不可用'));
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: src,
responseType: 'blob',
withCredentials: true,
timeout: 15000,
onload: response => {
if (response.status >= 200 && response.status < 300 && response.response) {
resolve(response.response);
} else {
reject(new Error(`GM 图片请求失败:${response.status}`));
}
},
onerror: () => reject(new Error('GM 图片请求失败')),
ontimeout: () => reject(new Error('GM 图片请求超时'))
});
});
}
async function getImageBlob(src) {
try {
return await fetchImageBlobWithPageFetch(src);
} catch (fetchError) {
console.warn(`[${SCRIPT_NAME}] 页面 fetch 获取图片失败,尝试 GM_xmlhttpRequest`, fetchError);
return fetchImageBlobWithGM(src);
}
}
async function mapLimit(list, limit, worker) {
const results = new Array(list.length);
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, list.length) }, async () => {
while (cursor < list.length) {
const currentIndex = cursor++;
results[currentIndex] = await worker(list[currentIndex], currentIndex);
}
});
await Promise.all(runners);
return results;
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('图片编码失败'));
reader.readAsDataURL(blob);
});
}
async function hydratePdfImages(assets) {
const uniqueSrcs = Array.from(new Set((assets || []).map(asset => asset.src).filter(Boolean)));
if (uniqueSrcs.length === 0) return new Map();
const results = await mapLimit(uniqueSrcs, 3, async src => {
try {
const blob = await getImageBlob(src);
const dataUrl = await blobToDataUrl(blob);
return { src, ok: true, dataUrl };
} catch (error) {
console.warn(`[${SCRIPT_NAME}] PDF 图片读取失败`, error);
return { src, ok: false, error: error?.message || String(error) };
}
});
const imageMap = new Map();
results.forEach(result => imageMap.set(result.src, result));
return imageMap;
}
function getPdfFileName() {
const now = new Date();
const pad = value => String(value).padStart(2, '0');
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
return `${SCRIPT_NAME}_AI图文题目_${stamp}.pdf`;
}
function getTimestamp() {
const now = new Date();
const pad = value => String(value).padStart(2, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
function sanitizeFileNamePart(value) {
return String(value || '课程作业').replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim().slice(0, 80) || '课程作业';
}
function getHomeworkBaseName() {
return sanitizeFileNamePart(globalPaperMeta.title || '课程作业') + `_${getTimestamp()}`;
}
function downloadBlobFile(fileName, content, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
setTimeout(() => {
URL.revokeObjectURL(url);
link.remove();
}, 1000);
}
let markdownRenderer = null;
function getMarkdownRenderer() {
if (markdownRenderer !== null) return markdownRenderer || null;
const factory = globalThis.markdownit || globalThis.markdownIt || globalThis.MarkdownIt;
if (typeof factory !== 'function') {
markdownRenderer = false;
return null;
}
markdownRenderer = factory({
html: false,
linkify: false,
typographer: false,
breaks: true
});
return markdownRenderer;
}
function hasBlockMarkdown(value) {
const text = String(value || '');
return /\n\s*\n/.test(text) ||
/(^|\n)\s{0,3}(#{1,6}\s+|>\s+|[-*+]\s+|\d+\.\s+|```|~~~)/.test(text) ||
/(^|\n)\s*\|.+\|\s*(\n|$)/.test(text);
}
function renderMarkdownSource(source, blockMode = false) {
const text = String(source || '');
if (!text) return '';
const renderer = getMarkdownRenderer();
if (renderer) {
const html = (blockMode || hasBlockMarkdown(text))
? renderer.render(text)
: renderer.renderInline(text).replace(/\n/g, '
');
return html.trim();
}
return escapeHTML(text).replace(/\n/g, '
');
}
function isEscapedDelimiter(text, index) {
let slashCount = 0;
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) slashCount++;
return slashCount % 2 === 1;
}
function findUnescapedDelimiter(text, delimiter, startIndex) {
let index = startIndex;
while (index < text.length) {
const found = text.indexOf(delimiter, index);
if (found < 0) return -1;
if (!isEscapedDelimiter(text, found)) return found;
index = found + delimiter.length;
}
return -1;
}
function splitDollarMathText(value) {
const text = String(value || '');
if (!text.includes('$')) return text ? [{ type: 'text', value: text }] : [];
const parts = [];
let cursor = 0;
let searchIndex = 0;
while (searchIndex < text.length) {
const openIndex = text.indexOf('$', searchIndex);
if (openIndex < 0) break;
if (isEscapedDelimiter(text, openIndex)) {
searchIndex = openIndex + 1;
continue;
}
const display = text[openIndex + 1] === '$';
const delimiter = display ? '$$' : '$';
const formulaStart = openIndex + delimiter.length;
const closeIndex = findUnescapedDelimiter(text, delimiter, formulaStart);
if (closeIndex < 0) break;
const formula = text.slice(formulaStart, closeIndex).trim();
if (!formula) {
searchIndex = closeIndex + delimiter.length;
continue;
}
if (openIndex > cursor) parts.push({ type: 'text', value: text.slice(cursor, openIndex) });
parts.push({ type: 'math', value: formula, display });
cursor = closeIndex + delimiter.length;
searchIndex = cursor;
}
if (cursor < text.length) parts.push({ type: 'text', value: text.slice(cursor) });
return parts;
}
function getMathAssetKey(tex, display = false) {
return `${display ? 'display' : 'inline'}:${String(tex || '').trim()}`;
}
function getMathSegmentText(segment) {
const tex = String(segment?.value || '').trim();
if (!tex) return '';
return segment.display ? `$$${tex}$$` : `$${tex}$`;
}
function segmentsToPlainText(segments) {
const parts = [];
(segments || []).forEach(segment => {
if (!segment) return;
if (segment.type === 'blockBreak') {
parts.push('\n');
} else if (segment.type === 'image') {
parts.push('[图片]');
} else if (segment.type === 'math' || segment.type === 'formula') {
parts.push(getMathSegmentText(segment));
} else {
parts.push(String(segment.value || ''));
}
});
return parts.join('')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function addMathEntry(mathEntries, tex, display = false) {
const cleanTex = String(tex || '').trim();
if (!cleanTex) return;
const key = getMathAssetKey(cleanTex, display);
if (!mathEntries.has(key)) {
mathEntries.set(key, { key, tex: cleanTex, display: display === true });
}
}
function collectMathFromDollarText(text, mathEntries) {
splitDollarMathText(text).forEach(part => {
if (part.type === 'math') addMathEntry(mathEntries, part.value, part.display);
});
}
function collectMathFromSegments(segments, mathEntries) {
(segments || []).forEach(segment => {
if (!segment) return;
if (segment.type === 'math' || segment.type === 'formula') {
addMathEntry(mathEntries, segment.value, segment.display === true);
} else if (segment.type === 'text') {
collectMathFromDollarText(segment.value, mathEntries);
}
});
}
function collectDocumentMathEntries(answerMap = new Map()) {
const mathEntries = new Map();
globalQuestionSections.forEach(section => collectMathFromSegments(section.titleSegments, mathEntries));
globalPdfQuestions.forEach(question => {
collectMathFromSegments(question.titleSegments, mathEntries);
(question.options || []).forEach(option => collectMathFromSegments(option.segments, mathEntries));
(question.matchingLeftItems || []).forEach(item => collectMathFromSegments(item.segments, mathEntries));
(question.matchingRightItems || []).forEach(item => collectMathFromSegments(item.segments, mathEntries));
});
if (answerMap && typeof answerMap.forEach === 'function') {
answerMap.forEach(answer => collectMathFromDollarText(answer, mathEntries));
}
return mathEntries;
}
function getMathJaxInstance() {
return globalThis.MathJax || null;
}
async function ensureMathJaxReady() {
const mathJax = getMathJaxInstance();
if (!mathJax) throw new Error('MathJax 未加载');
if (mathJax.startup?.promise) await mathJax.startup.promise;
if (typeof mathJax.tex2svg !== 'function') {
throw new Error('MathJax tex2svg 不可用');
}
return mathJax;
}
async function renderMathSvgMarkup(tex, display = false) {
const mathJax = await ensureMathJaxReady();
const node = mathJax.tex2svg(String(tex || ''), { display: display === true });
const wrapper = document.createElement('div');
wrapper.appendChild(node);
return wrapper.innerHTML;
}
function cssLengthToPx(value, fallback) {
const text = String(value || '').trim();
const match = text.match(/^([\d.]+)\s*(px|pt|em|ex)?$/i);
if (!match) return fallback;
const num = Number(match[1]);
if (!Number.isFinite(num) || num <= 0) return fallback;
const unit = (match[2] || 'px').toLowerCase();
if (unit === 'pt') return num * 96 / 72;
if (unit === 'em') return num * 16;
if (unit === 'ex') return num * 8.5;
return num;
}
function normalizeSvgMarkupForImage(mathSvgMarkup) {
const wrapper = document.createElement('div');
wrapper.innerHTML = mathSvgMarkup || '';
const svg = wrapper.querySelector('svg');
if (!svg) return '';
if (!svg.getAttribute('xmlns')) svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const viewBox = String(svg.getAttribute('viewBox') || '').split(/\s+/).map(Number);
const viewWidth = Number.isFinite(viewBox[2]) && viewBox[2] > 0 ? viewBox[2] : 1000;
const viewHeight = Number.isFinite(viewBox[3]) && viewBox[3] > 0 ? viewBox[3] : 400;
const fallbackHeight = 28;
const fallbackWidth = Math.max(24, fallbackHeight * viewWidth / viewHeight);
const width = Math.max(12, Math.ceil(cssLengthToPx(svg.getAttribute('width'), fallbackWidth)));
const height = Math.max(12, Math.ceil(cssLengthToPx(svg.getAttribute('height'), fallbackHeight)));
svg.setAttribute('width', String(width));
svg.setAttribute('height', String(height));
svg.setAttribute('style', 'background: transparent;');
return new XMLSerializer().serializeToString(svg);
}
function svgMarkupToPngDataUrl(mathSvgMarkup) {
const svgMarkup = normalizeSvgMarkupForImage(mathSvgMarkup);
if (!svgMarkup) return Promise.reject(new Error('未找到公式 SVG'));
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
try {
const scale = 2;
const width = Math.max(1, image.naturalWidth || image.width || 24);
const height = Math.max(1, image.naturalHeight || image.height || 24);
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(width * scale);
canvas.height = Math.ceil(height * scale);
const context = canvas.getContext('2d');
context.scale(scale, scale);
context.drawImage(image, 0, 0, width, height);
resolve(canvas.toDataURL('image/png'));
} catch (error) {
reject(error);
}
};
image.onerror = () => reject(new Error('公式 SVG 转图片失败'));
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
});
}
async function hydrateDocumentMathAssets(options = {}) {
const mathEntries = collectDocumentMathEntries(options.answerMap || new Map());
if (mathEntries.size === 0) return new Map();
try {
await ensureMathJaxReady();
} catch (error) {
console.warn(`[${SCRIPT_NAME}] MathJax 不可用,公式将以 TeX 文本显示`, error);
return new Map();
}
const entries = Array.from(mathEntries.values());
const results = await mapLimit(entries, 4, async entry => {
try {
const svg = await renderMathSvgMarkup(entry.tex, entry.display);
const result = { ...entry, ok: true, svg };
if (options.docMode === true) {
try {
result.pngDataUrl = await svgMarkupToPngDataUrl(svg);
} catch (pngError) {
console.warn(`[${SCRIPT_NAME}] 公式转图片失败,将使用 SVG 或 TeX 回退`, pngError);
}
}
return result;
} catch (error) {
console.warn(`[${SCRIPT_NAME}] 公式渲染失败`, entry.tex, error);
return { ...entry, ok: false, error: error?.message || String(error) };
}
});
const mathMap = new Map();
results.forEach(result => mathMap.set(result.key, result));
return mathMap;
}
function renderMathSegment(segment, options = {}) {
const tex = String(segment?.value || '').trim();
if (!tex) return '';
const display = segment?.display === true;
const key = getMathAssetKey(tex, display);
const mathMap = options.mathMap instanceof Map ? options.mathMap : new Map();
const record = mathMap.get(key);
const sourceText = display ? `$$${tex}$$` : `$${tex}$`;
const escapedTex = escapeHTML(tex);
if (options.docMode === true && record?.pngDataUrl) {
return ``;
}
if (record?.svg) {
return `${record.svg}${escapeHTML(sourceText)}`;
}
return `${escapeHTML(sourceText)}`;
}
function renderMarkdownSegments(segments, options = {}) {
const mathTokens = [];
let source = '';
(segments || []).forEach(segment => {
if (!segment) return;
if (segment.type === 'math' || segment.type === 'formula') {
const token = `XYMATHPLACEHOLDER${mathTokens.length}XY`;
mathTokens.push({ token, html: renderMathSegment(segment, options) });
source += token;
} else if (segment.type === 'text') {
splitDollarMathText(segment.value).forEach(part => {
if (part.type === 'math') {
const token = `XYMATHPLACEHOLDER${mathTokens.length}XY`;
mathTokens.push({ token, html: renderMathSegment(part, options) });
source += token;
} else {
source += part.value || '';
}
});
}
});
let html = renderMarkdownSource(source, options.blockMarkdown === true);
mathTokens.forEach(item => {
html = html.split(item.token).join(item.html);
});
return html;
}
function renderPrintImageSegment(segment, imageMap) {
const hydratedImages = imageMap instanceof Map ? imageMap : new Map();
const imageRecord = hydratedImages.get(segment.src);
if (!imageRecord || !imageRecord.ok || !imageRecord.dataUrl) {
return '