// ==UserScript==
// @name 学习通题目提取助手
// @namespace https://docs.ocsjs.com/cx-extractor
// @version 6.1.0
// @description 专为超星学习通打造:右上角原地展开直接点击抓取、顶部2×2对齐滑块、零卡顿上下拖拽(背景下沉+卡片浮起)、跨页实时同步、纯黑微软雅黑零延迟导出标准 .docx。
// @author OCS-Extractor-Engineer
// @license MIT
// @match *://*.chaoxing.com/*
// @match *://*.edu.cn/*
// @match *://*.org.cn/*
// @match *://*.xueyinonline.com/*
// @match *://*.hnsyu.net/*
// @match *://*.qutjxjy.cn/*
// @match *://*.ynny.cn/*
// @match *://*.hnvist.cn/*
// @match *://*.fjlecb.cn/*
// @match *://*.gdhkmooc.com/*
// @match *://*.cugbonline.cn/*
// @match *://*.zjelib.cn/*
// @match *://*.cqrspx.cn/*
// @match *://*.neauce.com/*
// @match *://*.zhihui-yun.com/*
// @match *://*.cqie.cn/*
// @match *://*.ccqmxx.com/*
// @match *://*.jxgmxy.com/*
// @match *://*.jnzyjsxy.cn/*
// @match *://*.smjyxy.cn/*
// @match *://*.sslibrary.com/*
// @match *://*.xuexi365.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
if (window.top !== window.self) {
return;
}
/* ==========================================================================
* 顶部配置常量 (CONFIG) —— 已锁定定制液态玻璃参数
* ========================================================================== */
const CONFIG = {
STORAGE_KEY_STATE: 'ocs_cx_qe_state_v5',
STORAGE_KEY_POS: 'ocs_cx_qe_ball_pos_v5',
// 你调校锁定的 12 项 Apple 液态玻璃光学参数
GLASS_THEME: {
shellAlpha: 0.42,
shellGray: 231,
blur: 5,
brightness: 88,
saturate: 150,
borderAlpha: 0.2,
shadowAlpha: 0.4,
innerGlow: 0.34,
cardAlpha: 0.86,
cardBorder: 0.25,
grooveAlpha: 0.09,
badgeOffset: 11
},
PERF: {
IMG_CONCURRENCY: 6,
IMG_TIMEOUT_MS: 3500,
WAIT_FONT_DECRYPT_MS: 800,
LONG_PRESS_MS: 220
},
UI: {
BALL_SIZE: 48,
DEFAULT_RIGHT: 24,
DEFAULT_TOP: 96,
PANEL_WIDTH: 336,
LIST_MAX_HEIGHT: 330,
COLLAPSE_DELAY_MS: 300,
SCROLL_IDLE_MS: 250,
Z_INDEX: 2147483647,
EMPTY_TEXT: '轻点右上图标或「抓取」收录本页题目'
},
SELECTORS: {
courseTitle: [
'.course_name',
'.portal_course_name',
'#courseName',
'.nav_side .title',
'.header .courseName',
'.backOld .course-name'
].join(', '),
workTitle: [
'.mark_title',
'#workTitle',
'.ceyan_name h3',
'.Cy_TItle1 h2',
'.posCatalog_active .posCatalog_name',
'.top-back-box .title'
].join(', '),
questionRoot: '.questionLi, .TiMu, .question-item, [id*="sigleQuestionDiv"]',
title: [
'.mark_name',
'.splitS-left .mark_name',
'.qtContent',
'.Zy_TItle .clearfix',
'.question-name .html-content-box',
':scope > h3'
].join(', '),
titleExclude: [
'.mark_letter',
'.stem_answer',
'.mark_answer',
'.answerBg',
'.Py_answer',
'.Py_tk',
'.Zy_ulTop',
'.Zy_ulBottom',
'.option-result',
'.analysisInfo',
'.qtAnalysis'
].join(', '),
options: [
'.mark_letter li',
'.stem_answer .answer_p',
'.answerBg .answer_p',
'.Zy_ulTop li',
'ul li .after',
'.option-result'
].join(', '),
typeInput: 'input[name^="type"], input[id^="answertype"]',
typeAndScoreText: '.mark_name .colorShallow, .Zy_TItle .clearfix, .qtTitle',
answerBlock: '.mark_answer, .Py_answer, .Py_tk, .analysisInfo, .qtAnalysis, .answer_info',
teacherAnswerNodes: [
'.mark_answer .teacher_answer',
'.mark_answer .mark_teacher_answer',
'.mark_answer .revise_answer',
'.Py_answer .teacher_answer',
'[class*="teacherAnswer"]',
'[class*="teacher_answer"]'
].join(', '),
standardAnswerNodes: '.mark_key .colorGreen, .mark_fill .colorGreen, .Py_answer .colorGreen, .rightAnswer',
myAnswerNodes: '.mark_key .colorDeep, .mark_fill .colorDeep, .Py_answer .colorDeep, .myAnswer',
teacherCommentNodes: '.mark_Protext, .teacher_remark, .Py_tk .teacher_remark, .teacherComment, [class*="teacherRemark"]',
rightIcon: '.marking_dui, .Py_answer .ri, .mark_judge_name .marking_dui',
wrongIcon: '.marking_cuo, .Py_answer .wr, .mark_judge_name .marking_cuo',
halfRightIcon: '.marking_bandui',
scoreNodes: '.mark_score .totalScore, .mark_score, .Py_answer .score',
analysisNodes: '.qtAnalysis, .analysisInfo, .mark_answer .analysis'
},
IFRAME_SKIP_RE: /\/ananas\/modules\/(video|audio|pdf|ppt|innerbook|zt)\/|timing=/i,
PATTERNS: {
TEACHER_ANS_LABEL: /(?:老师批改(?:后的)?答案|教师批改(?:后的)?答案|老师修改(?:后的)?答案|教师修改答案|老师给出的答案|教师给出的答案|老师答案|教师答案|批改答案|订正答案)\s*[::]\s*([\s\S]*?)(?=(?:我的答案|学生答案|标准答案|参考答案|正确答案|老师评语|教师评语|批改结果|得分|答案解析|解析|$))/i,
STD_ANS_LABEL: /(?:正确答案|参考答案|标准答案|系统答案)\s*[::]\s*([\s\S]*?)(?=(?:我的答案|学生答案|老师批改|教师批改|老师答案|教师答案|老师评语|教师评语|批改结果|得分|答案解析|解析|$))/i,
MY_ANS_LABEL: /(?:我的答案|学生答案|你的答案|作答)\s*[::]\s*([\s\S]*?)(?=(?:正确答案|参考答案|标准答案|老师批改|教师批改|老师答案|教师答案|老师评语|教师评语|批改结果|得分|答案解析|解析|$))/i,
COMMENT_LABEL: /(?:老师评语|教师评语|批改评语|教师批注|老师批注|评语)\s*[::]\s*([\s\S]*?)(?=(?:答案解析|解析|得分|正确答案|标准答案|$))/i,
ANALYSIS_LABEL: /(?:答案解析|试题解析|题目解析|解析)\s*[::]\s*([\s\S]*?)(?=(?:老师评语|教师评语|得分|$))/i,
TEACHER_GRADE_TRACE: /(?:老师批改|教师批改|教师评语|老师评语|批改评语|教师已批|老师已批|人工批改|批阅)/i
}
};
const CX_TYPE_MAP = {
0: '单选题', 1: '多选题', 2: '填空题', 3: '判断题', 4: '简答题',
5: '名词解释', 6: '论述题', 7: '计算题', 8: '其它题', 9: '分录题',
10: '资料题', 11: '连线题', 14: '完形填空', 15: '阅读理解'
};
const TYPE_ORDER_WEIGHT = {
'单选题': 1, '多选题': 2, '判断题': 3, '填空题': 4, '简答题': 5,
'问答题': 6, '名词解释': 7, '计算题': 8, '论述题': 9, '完形填空': 10,
'阅读理解': 11, '连线题': 12, '分录题': 13, '资料题': 14, '其它题': 99
};
const CN_NUMS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二', '十三', '十四', '十五'];
/* ==========================================================================
* 极细去色矢量图标集 (1.6px stroke)
* ========================================================================== */
const ICONS = {
brand: ``,
grab: ``,
export: ``,
folder: ``,
edit: ``
};
/* ==========================================================================
* 样式注入 (GM_addStyle) —— 固化用户调校的 12 项液态玻璃参数
* ========================================================================== */
const T = CONFIG.GLASS_THEME;
GM_addStyle(`
.ocs-qe-root, .lg-tuner-panel { display: none !important; }
.lg-root {
--cfg-shell-alpha: ${T.shellAlpha};
--cfg-shell-gray: ${T.shellGray};
--cfg-blur: ${T.blur}px;
--cfg-brightness: ${T.brightness}%;
--cfg-saturate: ${T.saturate}%;
--cfg-border-alpha: ${T.borderAlpha};
--cfg-shadow-alpha: ${T.shadowAlpha};
--cfg-inner-glow: ${T.innerGlow};
--cfg-card-alpha: ${T.cardAlpha};
--cfg-card-border: ${T.cardBorder};
--cfg-groove-alpha: ${T.grooveAlpha};
--cfg-badge-offset: ${T.badgeOffset}px;
position: fixed;
top: ${CONFIG.UI.DEFAULT_TOP}px;
right: ${CONFIG.UI.DEFAULT_RIGHT}px;
width: ${CONFIG.UI.BALL_SIZE}px;
height: ${CONFIG.UI.BALL_SIZE}px;
z-index: ${CONFIG.UI.Z_INDEX};
font-family: "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
font-size: 13px;
line-height: 1.45;
color: #18181B;
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.85);
user-select: none;
pointer-events: none;
box-sizing: border-box;
}
.lg-root *, .lg-root *::before, .lg-root *::after {
box-sizing: border-box;
}
/* 主液态玻璃外壳(v0.3.0 架构 + 独立背景模糊层防卡顿) */
.lg-shell {
position: absolute;
top: 0;
right: 0;
width: ${CONFIG.UI.BALL_SIZE}px;
max-height: ${CONFIG.UI.BALL_SIZE}px;
border-radius: 24px;
box-shadow:
inset 0 2px 1.5px 0 rgba(255, 255, 255, var(--cfg-inner-glow)),
inset 1.5px 0 1.5px 0 rgba(255, 255, 255, calc(var(--cfg-inner-glow) * 0.88)),
inset -1.5px -2px 3px 0 rgba(0, 0, 0, 0.05),
0 0 0 1px rgba(0, 0, 0, var(--cfg-border-alpha)),
0 14px 32px -6px rgba(0, 0, 0, var(--cfg-shadow-alpha)),
0 4px 12px -2px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.45));
overflow: hidden;
pointer-events: auto;
transform: translateZ(0);
transition:
width 400ms cubic-bezier(0.32, 1.2, 0.54, 1),
max-height 420ms cubic-bezier(0.32, 1.2, 0.54, 1),
border-radius 400ms cubic-bezier(0.32, 1.2, 0.54, 1),
transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 独立静态 GPU 模糊层(拖拽与列表滚动时零重绘开销) */
.lg-glass-bg {
--mx: 160px;
--my: 40px;
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
contain: strict;
background:
radial-gradient(220px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.65) 0%, rgba(255, 255, 255, 0) 65%),
linear-gradient(
145deg,
rgba(255, 255, 255, calc(var(--cfg-shell-alpha) + 0.10)) 0%,
rgba(var(--cfg-shell-gray), var(--cfg-shell-gray), calc(var(--cfg-shell-gray) + 2), var(--cfg-shell-alpha)) 55%,
rgba(calc(var(--cfg-shell-gray) - 6), calc(var(--cfg-shell-gray) - 4), var(--cfg-shell-gray), calc(var(--cfg-shell-alpha) + 0.06)) 100%
);
backdrop-filter: blur(var(--cfg-blur)) saturate(var(--cfg-saturate)) brightness(var(--cfg-brightness));
-webkit-backdrop-filter: blur(var(--cfg-blur)) saturate(var(--cfg-saturate)) brightness(var(--cfg-brightness));
}
/* 表面斜向纯白光泽带 */
.lg-shell::after {
content: "";
position: absolute;
top: -40%;
left: -40%;
width: 180%;
height: 180%;
background: linear-gradient(
115deg,
rgba(255, 255, 255, 0) 32%,
rgba(255, 255, 255, calc(var(--cfg-inner-glow) * 0.35)) 47%,
rgba(255, 255, 255, 0.04) 53%,
rgba(255, 255, 255, 0) 65%
);
pointer-events: none;
z-index: 1;
}
.lg-root.lg-align-left .lg-shell {
right: auto;
left: 0;
}
.lg-root.lg-expanded .lg-shell {
width: ${CONFIG.UI.PANEL_WIDTH}px;
max-height: 600px;
border-radius: 26px;
box-shadow:
inset 0 2.5px 2px 0 rgba(255, 255, 255, var(--cfg-inner-glow)),
inset 1.5px 0 1.5px 0 rgba(255, 255, 255, calc(var(--cfg-inner-glow) * 0.9)),
inset -1.5px -2.5px 4px 0 rgba(0, 0, 0, 0.05),
0 0 0 1px rgba(0, 0, 0, var(--cfg-border-alpha)),
0 26px 54px -12px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 1.25)),
0 8px 20px -4px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.55));
}
.lg-shell.lg-pressing {
transform: scale(0.90, 0.93) translateZ(0) !important;
}
.lg-shell.lg-dragging {
cursor: grabbing !important;
transform: scale(1.06) translateZ(0) !important;
}
/* 顶部栏 & 右上角原地水滴图标 */
.lg-top-bar {
position: relative;
height: ${CONFIG.UI.BALL_SIZE}px;
width: 100%;
display: flex;
align-items: center;
z-index: 4;
}
.lg-orb-trigger {
position: absolute;
top: 0;
right: 0;
width: ${CONFIG.UI.BALL_SIZE}px;
height: ${CONFIG.UI.BALL_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #18181B;
border-radius: 24px;
z-index: 6;
transition:
transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1),
background 200ms ease,
box-shadow 200ms ease,
border-radius 220ms ease;
}
.lg-root.lg-align-left .lg-orb-trigger {
right: auto;
left: 0;
}
.lg-root.lg-expanded .lg-orb-trigger {
top: 7px;
right: 10px;
width: 34px;
height: 34px;
border-radius: 17px;
background: linear-gradient(145deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(244, 245, 247, calc(var(--cfg-card-alpha) * 0.65)) 100%);
box-shadow:
inset 0 1.5px 1px rgba(255, 255, 255, var(--cfg-inner-glow)),
0 1.5px 3px rgba(0, 0, 0, 0.07),
0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border));
}
.lg-root.lg-align-left.lg-expanded .lg-orb-trigger {
right: auto;
left: 10px;
}
.lg-root.lg-expanded .lg-orb-trigger:hover {
transform: scale(1.06);
background: #FFFFFF;
}
.lg-orb-trigger:active {
transform: scale(0.88) !important;
}
.lg-orb-trigger svg {
width: 20px;
height: 20px;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.lg-root.lg-expanded .lg-orb-trigger svg {
width: 16.5px;
height: 16.5px;
}
/* 右上角无圈数字角标(向内收敛 11px,0 不显示) */
.lg-count-num {
position: absolute;
top: var(--cfg-badge-offset);
right: calc(var(--cfg-badge-offset) + 1px);
font-size: 10px;
font-weight: 800;
line-height: 1;
color: #18181B;
text-shadow: 0 0 3px #FFFFFF;
pointer-events: none;
font-variant-numeric: tabular-nums;
transition: top 200ms ease, right 200ms ease, font-size 200ms ease;
}
.lg-root.lg-expanded .lg-count-num {
top: calc(var(--cfg-badge-offset) * 0.5);
right: calc(var(--cfg-badge-offset) * 0.65);
font-size: 9px;
}
.lg-count-num.lg-hidden {
display: none !important;
}
/* 课程总标题水滴条 */
.lg-course-wrap {
flex: 1;
min-width: 0;
padding: 6px 52px 0 12px;
display: flex;
align-items: center;
opacity: 0;
transform: translateX(8px);
pointer-events: none;
transition: opacity 200ms ease 40ms, transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1) 40ms;
}
.lg-root.lg-align-left .lg-course-wrap {
padding: 6px 12px 0 52px;
}
.lg-root.lg-expanded .lg-course-wrap {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.lg-course-title {
flex: 1;
min-width: 0;
font-size: 13.5px;
font-weight: 700;
color: #18181B;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
padding: 5px 10px;
border-radius: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(255, 255, 255, calc(var(--cfg-card-alpha) * 0.55)) 100%);
box-shadow:
inset 0 1.5px 1px rgba(255, 255, 255, var(--cfg-inner-glow)),
0 1px 2px rgba(0, 0, 0, 0.04),
0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border));
transition: background 150ms ease;
}
.lg-course-title:hover {
background: #FFFFFF;
}
.lg-course-input {
flex: 1;
min-width: 0;
height: 28px;
padding: 0 10px;
font-size: 12.5px;
font-weight: 600;
color: #18181B;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(0, 0, 0, 0.18);
border-radius: 10px;
outline: none;
}
/* 面板主体:2×2 严丝合缝对齐矩阵 */
.lg-body {
position: relative;
z-index: 4;
padding: 8px 12px 10px 12px;
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 200ms ease, transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.lg-root.lg-expanded .lg-body {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
transition-delay: 40ms;
}
.lg-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.lg-btn {
height: 36px;
border-radius: 14px;
font-size: 12.5px;
font-weight: 700;
color: #18181B;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
outline: none;
border: none;
transition:
transform 160ms cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 160ms ease,
background 160ms ease,
opacity 140ms ease;
}
.lg-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.lg-btn svg {
width: 15px;
height: 15px;
}
.lg-btn-primary {
background: linear-gradient(165deg, rgba(255, 255, 255, calc(var(--cfg-card-alpha) + 0.12)) 0%, rgba(242, 244, 247, calc(var(--cfg-card-alpha) * 0.82)) 100%);
box-shadow:
inset 0 2px 1px rgba(255, 255, 255, var(--cfg-inner-glow)),
inset 0 -1.5px 3px rgba(0, 0, 0, 0.05),
0 4px 10px -2px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.6)),
0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border));
}
.lg-btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
background: #FFFFFF;
}
.lg-btn-secondary {
background: linear-gradient(165deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(240, 242, 245, calc(var(--cfg-card-alpha) * 0.58)) 100%);
box-shadow:
inset 0 2px 1px rgba(255, 255, 255, var(--cfg-inner-glow)),
inset 0 -1.5px 2.5px rgba(0, 0, 0, 0.04),
0 3px 8px -2px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.45)),
0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border));
}
.lg-btn-secondary:hover:not(:disabled) {
transform: translateY(-1px);
background: #FFFFFF;
}
.lg-btn:active:not(:disabled) {
transform: scale(0.95) translateY(1px) !important;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.12) !important;
}
/* 第二行:顺序/类型 | 默认/纯净(与抓取、导出 1:1 严丝合缝对齐) */
.lg-sub-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 8px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(0, 0, 0, calc(var(--cfg-border-alpha) * 0.75));
}
.lg-seg-switch {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
background: rgba(0, 0, 0, var(--cfg-groove-alpha));
box-shadow:
inset 0 1.5px 3px rgba(0, 0, 0, calc(var(--cfg-groove-alpha) * 1.3)),
0 1px 0 rgba(255, 255, 255, 0.8);
border-radius: 11px;
padding: 2.5px;
width: 100%;
height: 30px;
}
.lg-seg-glider {
position: absolute;
top: 2.5px;
left: 2.5px;
width: calc(50% - 2.5px);
height: calc(100% - 5px);
background: linear-gradient(165deg, #FFFFFF 0%, rgba(246, 247, 249, 0.88) 100%);
border-radius: 8.5px;
box-shadow:
inset 0 1.5px 1px #FFFFFF,
0 1.5px 4px rgba(0, 0, 0, 0.10),
0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border));
transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
pointer-events: none;
z-index: 1;
}
.lg-seg-switch[data-second="true"] .lg-seg-glider {
transform: translateX(100%);
}
.lg-seg-opt {
position: relative;
z-index: 2;
border: none;
background: transparent;
color: #52525B;
font-size: 11.5px;
font-weight: 600;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 180ms ease;
}
.lg-seg-opt.active {
color: #18181B;
font-weight: 700;
}
/* 轻提示条 */
.lg-toast {
margin-top: 8px;
padding: 6px 11px;
border-radius: 12px;
font-size: 11.5px;
font-weight: 600;
display: none;
align-items: center;
justify-content: space-between;
word-break: break-all;
background: rgba(255, 255, 255, 0.88);
color: #18181B;
box-shadow:
inset 0 1.5px 1px #FFFFFF,
0 3px 8px rgba(0, 0, 0, 0.06),
0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border));
}
.lg-toast.lg-show {
display: flex;
}
.lg-toast-warn {
background: rgba(254, 252, 232, 0.92);
color: #854D0E;
}
.lg-toast-error {
background: rgba(254, 242, 242, 0.92);
color: #991B1B;
}
/* 列表玻璃凹槽 & 水滴作业卡 */
.lg-list-wrap {
margin-top: 10px;
background: rgba(0, 0, 0, var(--cfg-groove-alpha));
box-shadow:
inset 0 2px 5px rgba(0, 0, 0, calc(var(--cfg-groove-alpha) * 1.2)),
0 1px 0 rgba(255, 255, 255, 0.75);
border-radius: 18px;
padding: 6px;
transition:
transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1),
background-color 200ms ease,
box-shadow 200ms ease;
}
.lg-list-wrap.lg-recessed {
background: rgba(0, 0, 0, calc(var(--cfg-groove-alpha) + 0.045));
transform: scale(0.98);
box-shadow: inset 0 3px 10px rgba(0, 0, 0, 0.12);
}
.lg-list {
display: block !important;
max-height: ${CONFIG.UI.LIST_MAX_HEIGHT}px;
overflow-y: auto;
overflow-x: hidden;
margin: 0;
padding: 2px;
contain: content;
}
.lg-list::-webkit-scrollbar {
width: 4px;
}
.lg-list::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
border-radius: 2px;
}
.lg-empty {
padding: 26px 10px;
text-align: center;
color: #52525B;
font-weight: 500;
font-size: 12px;
}
.lg-group {
display: block !important;
margin-bottom: 6px;
border-radius: 15px;
transition: opacity 160ms ease;
}
.lg-group:last-child {
margin-bottom: 0;
}
.lg-list-wrap.lg-recessed .lg-group:not(.lg-drag-placeholder) {
opacity: 0.62;
}
.lg-group-header {
display: flex !important;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 13px;
color: #18181B;
background: linear-gradient(145deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(246, 247, 249, calc(var(--cfg-card-alpha) * 0.65)) 100%);
box-shadow:
inset 0 1.5px 1px rgba(255, 255, 255, var(--cfg-inner-glow)),
0 2px 6px -1px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.4)),
0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border));
transition: background 160ms ease, box-shadow 160ms ease;
}
.lg-group-header:hover {
background: linear-gradient(145deg, rgba(255, 255, 255, calc(var(--cfg-card-alpha) + 0.12)) 0%, rgba(250, 250, 252, calc(var(--cfg-card-alpha) * 0.8)) 100%);
}
.lg-group.lg-group-open .lg-group-header {
background: linear-gradient(145deg, #FFFFFF 0%, rgba(242, 244, 247, 0.90) 100%);
box-shadow:
inset 0 2px 1.5px #FFFFFF,
0 5px 14px -3px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.7)),
0 0 0 1px rgba(0, 0, 0, calc(var(--cfg-card-border) * 1.25));
}
/* 题数以左区域:删去六个点,按住即可上下拖动,轻点展开 */
.lg-group-drag-zone {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
cursor: grab;
}
.lg-group-drag-zone:active {
cursor: grabbing;
}
.lg-folder-icon {
width: 15px;
height: 15px;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.78;
flex-shrink: 0;
}
.lg-folder-icon svg {
width: 15px;
height: 15px;
}
.lg-group-name {
flex: 1;
min-width: 0;
font-weight: 700;
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lg-group-input {
flex: 1;
min-width: 0;
height: 24px;
padding: 0 6px;
font-size: 12px;
font-weight: 600;
color: #18181B;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 7px;
outline: none;
background: #FFFFFF;
}
.lg-group-count {
font-size: 11px;
font-weight: 700;
color: #3F3F46;
background: rgba(0, 0, 0, var(--cfg-groove-alpha));
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.04), 0 1px 0 #FFFFFF;
padding: 1px 7px;
border-radius: 8px;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.lg-icon-btn {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: #3F3F46;
opacity: 0.65;
font-size: 13px;
border-radius: 7px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
transition: opacity 120ms ease, background-color 120ms ease;
}
.lg-icon-btn svg {
width: 12.5px;
height: 12.5px;
}
.lg-icon-btn:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.06);
}
.lg-sublist {
display: none;
margin: 6px 4px 4px 16px;
padding: 2px 4px 4px 12px;
border-left: 1.5px solid rgba(0, 0, 0, 0.14);
flex-wrap: wrap;
gap: 6px;
}
.lg-group.lg-group-open .lg-sublist {
display: flex !important;
}
.lg-type-badge-row {
width: 100%;
font-size: 11px;
font-weight: 700;
color: #52525B;
padding: 3px 2px 1px 2px;
}
.lg-qchip {
display: inline-flex !important;
align-items: center;
gap: 4px;
padding: 4px 9px;
background: linear-gradient(155deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(244, 245, 247, calc(var(--cfg-card-alpha) * 0.7)) 100%);
border-radius: 10px;
font-size: 12px;
color: #18181B;
cursor: grab;
box-shadow:
inset 0 1.5px 1px #FFFFFF,
0 2px 4px rgba(0, 0, 0, 0.05),
0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border));
transition: background 140ms ease, opacity 140ms ease;
}
.lg-qchip:hover {
background: #FFFFFF;
}
.lg-list-wrap.lg-recessed .lg-qchip:not(.lg-drag-placeholder) {
opacity: 0.58;
}
.lg-qchip-label {
font-weight: 600;
cursor: pointer;
}
.lg-qchip-del {
border: none;
background: transparent;
color: #71717A;
font-size: 13px;
line-height: 1;
padding: 0 1px;
cursor: pointer;
border-radius: 4px;
}
.lg-qchip-del:hover {
color: #18181B;
background: rgba(0, 0, 0, 0.07);
}
.lg-teacher-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #18181B;
display: inline-block;
flex-shrink: 0;
}
.lg-teacher-warn-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #D97706;
display: inline-block;
flex-shrink: 0;
}
/* 零延迟 GPU 悬浮拖拽卡片 */
.lg-drag-placeholder {
background: rgba(0, 0, 0, 0.03) !important;
border: 1.5px dashed rgba(0, 0, 0, 0.25) !important;
box-shadow: none !important;
}
.lg-drag-placeholder * {
visibility: hidden !important;
}
.lg-floating-card {
position: fixed;
z-index: 2147483647;
pointer-events: none;
background: #FFFFFF !important;
color: #18181B !important;
border-radius: 13px !important;
box-shadow:
inset 0 2px 1px #FFFFFF,
0 0 0 1px rgba(0, 0, 0, 0.12),
0 16px 32px -6px rgba(0, 0, 0, 0.22),
0 4px 10px -2px rgba(0, 0, 0, 0.10) !important;
will-change: transform;
transition: none !important;
}
.lg-qeditor {
width: 100%;
margin-top: 5px;
padding: 9px;
background: rgba(255, 255, 255, 0.92);
border-radius: 12px;
font-size: 12px;
box-shadow:
inset 0 1.5px 1px #FFFFFF,
0 4px 10px rgba(0, 0, 0, 0.06),
0 0 0 0.5px rgba(0, 0, 0, 0.10);
}
.lg-qeditor-row {
margin-bottom: 6px;
}
.lg-qeditor-row label {
display: block;
font-size: 11px;
font-weight: 700;
color: #52525B;
margin-bottom: 2px;
}
.lg-qeditor-row textarea,
.lg-qeditor-row input {
width: 100%;
padding: 5px 7px;
font-size: 12px;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 8px;
color: #18181B;
background: #FFFFFF;
font-family: inherit;
outline: none;
}
.lg-qeditor-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.lg-qeditor-btn {
padding: 3px 10px;
font-size: 11px;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #F4F4F5;
color: #18181B;
}
.lg-qeditor-btn.primary {
background: #18181B;
color: #FFFFFF;
border-color: #18181B;
}
.lg-footer {
margin-top: 6px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
font-size: 11.5px;
padding: 0 2px;
}
.lg-text-btn {
border: none;
background: transparent;
color: #52525B;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
padding: 3px 8px;
border-radius: 8px;
transition: background-color 140ms ease, transform 80ms ease;
}
.lg-text-btn:active {
transform: scale(0.92);
}
.lg-text-btn.lg-undo-btn {
color: #18181B;
background: rgba(255, 255, 255, 0.8);
box-shadow: inset 0 1px 0.5px #FFFFFF, 0 1px 3px rgba(0, 0, 0, 0.06), 0 0 0 0.5px rgba(0, 0, 0, 0.08);
font-weight: 700;
display: none;
}
.lg-text-btn.lg-undo-btn.lg-show {
display: inline-block;
}
.lg-text-btn.lg-clear-btn:hover {
color: #18181B;
background: rgba(0, 0, 0, 0.05);
}
`);
/* ==========================================================================
* 状态与跨标签页/返回列表页实时同步管理
* ========================================================================== */
const imageCache = new Map();
const state = {
courseTitle: '',
grabCount: 0,
groups: [],
sortMode: 'order', // 'order' | 'type'
docStyle: 'default', // 'default' | 'pure'
typeGroupExpanded: {},
undoBackup: null,
ballPos: null,
isExpanded: false,
isBallDragging: false,
isListDragging: false,
isScrolling: false,
isEditingInput: false,
editingCourseTitle: false,
editingGroupId: null,
editingQuestionLoc: null,
collapseTimer: null,
scrollTimer: null
};
let suppressNextHeaderClick = false;
function cleanText(str) {
return (str || '')
.replace(/\u00A0/g, ' ')
.replace(/[ \t]+/g, ' ')
.replace(/\n\s*\n/g, '\n')
.trim();
}
function cleanSingleLine(str) {
return (str || '').replace(/\s+/g, ' ').trim();
}
function deduplicateAnswerString(ansStr) {
if (!ansStr) return '';
const parts = String(ansStr)
.split(/[;;]/)
.map((s) => cleanSingleLine(s).replace(/^([A-Z])\s+(?=[.\u4e00-\u9fa5A-Za-z0-9])/i, '$1. '))
.filter(Boolean);
return [...new Set(parts)].join(';');
}
function computeFingerprint(q) {
if (q._fp) return q._fp;
const stemNorm = cleanText(q.stem).replace(/^\d+[\.、.\s]*/, '').replace(/\s+/g, '');
const optNorm = (q.options || []).map((o) => cleanText(o.text).replace(/\s+/g, '')).join('|');
q._fp = `${q.type || ''}::${stemNorm}::${optNorm}`;
return q._fp;
}
function getTotalQuestionCount() {
return state.groups.reduce((sum, g) => sum + (Array.isArray(g.questions) ? g.questions.length : 0), 0);
}
function getTypeWeight(typeStr) {
const t = cleanText(typeStr);
if (TYPE_ORDER_WEIGHT[t] !== undefined) return TYPE_ORDER_WEIGHT[t];
for (const k of Object.keys(TYPE_ORDER_WEIGHT)) {
if (t.includes(k.replace('题', ''))) return TYPE_ORDER_WEIGHT[k];
}
return 50;
}
function normalizeGroupQuestionsByType(questions) {
return [...(questions || [])].sort((a, b) => getTypeWeight(a.type) - getTypeWeight(b.type));
}
function sanitizeStoredGroups(groups) {
if (!Array.isArray(groups)) return [];
groups.forEach((grp) => {
(grp.questions || []).forEach((q) => {
if (q.myAnswer) q.myAnswer = deduplicateAnswerString(q.myAnswer);
if (q.teacherAnswer) q.teacherAnswer = cleanSingleLine(q.teacherAnswer);
if (q.standardAnswer) q.standardAnswer = cleanSingleLine(q.standardAnswer);
});
});
return groups;
}
function syncStateFromStorage() {
if (state.isEditingInput || state.isListDragging) return;
try {
const raw = GM_getValue(CONFIG.STORAGE_KEY_STATE, '') || GM_getValue('ocs_cx_qe_state_v4', '');
if (raw) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === 'object') {
const prevExpandedMap = new Map(state.groups.map((g) => [g.id, g.expanded]));
const loadedGroups = sanitizeStoredGroups(parsed.groups);
loadedGroups.forEach((g) => {
if (prevExpandedMap.has(g.id)) {
g.expanded = prevExpandedMap.get(g.id);
}
});
state.groups = loadedGroups;
state.grabCount = typeof parsed.grabCount === 'number' ? parsed.grabCount : state.groups.length;
if (parsed.courseTitle) state.courseTitle = parsed.courseTitle;
state.sortMode = parsed.sortMode === 'type' ? 'type' : 'order';
state.docStyle = parsed.docStyle === 'pure' || parsed.docStyle === 'quiz' ? 'pure' : 'default';
}
}
} catch (_) {}
try {
const rawPos = GM_getValue(CONFIG.STORAGE_KEY_POS, '') || GM_getValue('ocs_cx_qe_ball_pos_v4', '');
if (rawPos) {
const pos = typeof rawPos === 'string' ? JSON.parse(rawPos) : rawPos;
if (pos && typeof pos.left === 'number' && typeof pos.top === 'number') {
state.ballPos = pos;
}
}
} catch (_) {}
}
function saveStateNow() {
try {
GM_setValue(
CONFIG.STORAGE_KEY_STATE,
JSON.stringify({
courseTitle: state.courseTitle,
grabCount: state.grabCount,
groups: state.groups,
sortMode: state.sortMode,
docStyle: state.docStyle
})
);
} catch (e) {
showToast('存储空间不足,请先导出并清空旧记录', 'error');
}
}
function saveBallPosition(left, top) {
state.ballPos = { left, top };
try {
GM_setValue(CONFIG.STORAGE_KEY_POS, JSON.stringify(state.ballPos));
} catch (_) {}
}
/* ==========================================================================
* 超星页面扫描、课程名/作业名提取与题目解析
* ========================================================================== */
function getChaoxingQuizDocuments(rootDoc = document) {
const docs = [rootDoc];
const queue = Array.from(rootDoc.querySelectorAll('iframe'));
while (queue.length > 0) {
const frame = queue.shift();
const src = frame.getAttribute('src') || '';
if (src && CONFIG.IFRAME_SKIP_RE.test(src)) continue;
try {
const subDoc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
if (subDoc) {
docs.push(subDoc);
const childFrames = subDoc.querySelectorAll('iframe');
if (childFrames.length > 0) queue.push(...Array.from(childFrames));
}
} catch (_) {}
}
return docs;
}
function detectCourseTitle(docs) {
for (const doc of docs) {
const el = doc.querySelector(CONFIG.SELECTORS.courseTitle);
if (el) {
const t = cleanSingleLine(el.textContent);
if (t && t.length > 1) return t;
}
}
return state.courseTitle || '学习通课程题库汇总';
}
function detectCurrentWorkTitle(docs, fallbackIndex) {
for (const doc of docs) {
const titleEl = doc.querySelector(CONFIG.SELECTORS.workTitle);
if (titleEl) {
const t = cleanSingleLine(titleEl.textContent)
.replace(/^(?:\d{4,}[-\s_]*)+/, '')
.trim();
if (t) return t;
}
}
const docTitle = cleanSingleLine(document.title)
.replace(/[-_—]\s*(超星学习通|学习通|泛雅|作业详情|整卷预览).*$/i, '')
.replace(/^(?:\d{4,}[-\s_]*)+/, '')
.trim();
if (docTitle && docTitle !== '作业详情' && docTitle !== '学生端' && docTitle !== '章节学习') {
return docTitle;
}
return `作业${fallbackIndex}`;
}
async function waitForCxFontDecryptedIfNeeded(docs) {
const hasSecretFont = () => docs.some((d) => d.querySelector('.font-cxsecret') !== null);
if (!hasSecretFont()) return true;
const start = Date.now();
while (Date.now() - start < CONFIG.PERF.WAIT_FONT_DECRYPT_MS) {
await new Promise((r) => setTimeout(r, 150));
if (!hasSecretFont()) return true;
}
return !hasSecretFont();
}
async function runWithConcurrency(tasks, limit = CONFIG.PERF.IMG_CONCURRENCY) {
const results = new Array(tasks.length);
let idx = 0;
const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
while (idx < tasks.length) {
const cur = idx++;
results[cur] = await tasks[cur]();
}
});
await Promise.all(workers);
return results;
}
async function imageToBase64Cached(imgEl) {
if (!imgEl) return null;
const rawSrc = imgEl.getAttribute('data-src') || imgEl.getAttribute('data-original') || imgEl.src;
if (!rawSrc) return null;
let absUrl = rawSrc;
try {
absUrl = new URL(rawSrc, (imgEl.ownerDocument || document).baseURI || location.href).href;
} catch (_) {}
if (imageCache.has(absUrl)) return imageCache.get(absUrl);
const naturalW = imgEl.naturalWidth || imgEl.width || 160;
const naturalH = imgEl.naturalHeight || imgEl.height || 90;
if (absUrl.startsWith('data:image/')) {
const info = { base64: absUrl, width: naturalW, height: naturalH };
imageCache.set(absUrl, info);
return info;
}
const fetchPromise = new Promise((resolve) => {
if (typeof GM_xmlhttpRequest !== 'function') {
resolve(null);
return;
}
try {
GM_xmlhttpRequest({
method: 'GET',
url: absUrl,
responseType: 'blob',
timeout: CONFIG.PERF.IMG_TIMEOUT_MS,
onload: (res) => {
if (res.status >= 200 && res.status < 300 && res.response) {
const reader = new FileReader();
reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(res.response);
} else {
resolve(null);
}
},
onerror: () => resolve(null),
ontimeout: () => resolve(null)
});
} catch (_) {
resolve(null);
}
});
const timeoutGuard = new Promise((resolve) => setTimeout(() => resolve(null), CONFIG.PERF.IMG_TIMEOUT_MS + 200));
const gmBase64 = await Promise.race([fetchPromise, timeoutGuard]);
if (gmBase64 && gmBase64.startsWith('data:image/')) {
const info = { base64: gmBase64, width: naturalW, height: naturalH };
imageCache.set(absUrl, info);
return info;
}
try {
const canvas = document.createElement('canvas');
canvas.width = naturalW;
canvas.height = naturalH;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgEl, 0, 0, naturalW, naturalH);
const info = { base64: canvas.toDataURL('image/png'), width: naturalW, height: naturalH };
imageCache.set(absUrl, info);
return info;
} catch (_) {
return null;
}
}
function extractNodeTextAndImageEls(containerEl, isTitleNode = false) {
if (!containerEl) return { text: '', imgEls: [] };
const clone = containerEl.cloneNode(true);
if (isTitleNode) {
clone.querySelectorAll('.colorShallow').forEach((n) => n.remove());
clone.querySelectorAll(CONFIG.SELECTORS.titleExclude).forEach((n) => n.remove());
}
clone.querySelectorAll('script, style, input[type="hidden"]').forEach((n) => n.remove());
clone.querySelectorAll('img.kfformula, img[data-latex]').forEach((img) => {
const latex = img.getAttribute('data-latex') || img.getAttribute('data-slatex') || '';
if (latex.trim()) {
img.replaceWith(` $${latex.replace(/\\\\/g, '\\')}$ `);
}
});
if (!isTitleNode) {
clone.querySelectorAll('.ri').forEach((el) => el.replaceWith('对'));
clone.querySelectorAll('.wr').forEach((el) => el.replaceWith('错'));
}
const imgEls = Array.from(containerEl.querySelectorAll('img')).filter((img) => {
if (isTitleNode && img.closest(CONFIG.SELECTORS.titleExclude)) return false;
const src = img.getAttribute('data-src') || img.src || '';
if (!src || /marking_(dui|cuo|bandui)|space\.gif|blank\.gif|icon/i.test(src)) return false;
return true;
});
const text = isTitleNode ? cleanText(clone.innerText || clone.textContent || '') : cleanSingleLine(clone.innerText || clone.textContent || '');
return { text, imgEls };
}
function parseCxTypeAndScore(rootEl) {
let qType = '';
let qScore = '';
const typeInput = rootEl.querySelector(CONFIG.SELECTORS.typeInput);
if (typeInput && typeInput.value !== '') {
const code = parseInt(typeInput.value, 10);
if (!Number.isNaN(code) && CX_TYPE_MAP[code]) qType = CX_TYPE_MAP[code];
}
if (!qType && rootEl.classList.contains('question-item')) {
if (rootEl.classList.contains('single-choice')) qType = '单选题';
else if (rootEl.classList.contains('multiple-choice')) qType = '多选题';
}
const metaEl = rootEl.querySelector(CONFIG.SELECTORS.typeAndScoreText);
const metaText = metaEl ? cleanSingleLine(metaEl.textContent) : '';
if (!qType && metaText) {
const tm = metaText.match(/(单选题|多选题|判断题|填空题|简答题|问答题|论述题|计算题|名词解释|完形填空|阅读理解|连线题|单选|多选|判断|填空)/);
if (tm) qType = tm[1].endsWith('题') || tm[1].length > 2 ? tm[1] : `${tm[1]}题`;
}
const sm = metaText.match(/(\d+(?:\.\d+)?)\s*分/);
if (sm) qScore = sm[1];
return { qType, qScore };
}
function parseCxGradingAndAnswers(rootEl, totalScoreHint) {
const answerBlocks = Array.from(rootEl.querySelectorAll(CONFIG.SELECTORS.answerBlock));
const fullBlockText = (answerBlocks.length > 0 ? answerBlocks : [rootEl])
.map((n) => cleanText(n.innerText || n.textContent))
.join('\n');
let teacherAnswer = '';
let standardAnswer = '';
let myAnswer = '';
let judgeResult = '';
let obtainedScore = '';
let scoreDisplay = '';
let teacherComment = '';
let analysis = '';
let hasTeacherGrade = false;
const teacherAnsNode = rootEl.querySelector(CONFIG.SELECTORS.teacherAnswerNodes);
if (teacherAnsNode) {
hasTeacherGrade = true;
teacherAnswer = cleanSingleLine(teacherAnsNode.innerText || teacherAnsNode.textContent)
.replace(/^(?:老师批改(?:后的)?答案|教师批改(?:后的)?答案|老师答案|教师答案|批改答案|订正答案)\s*[::]\s*/i, '');
}
if (!teacherAnswer) {
const tMatch = fullBlockText.match(CONFIG.PATTERNS.TEACHER_ANS_LABEL);
if (tMatch && tMatch[1]) {
hasTeacherGrade = true;
teacherAnswer = cleanSingleLine(tMatch[1]);
}
}
const stdAnsNode = rootEl.querySelector(CONFIG.SELECTORS.standardAnswerNodes);
if (stdAnsNode) {
standardAnswer = cleanSingleLine(stdAnsNode.innerText || stdAnsNode.textContent)
.replace(/^(?:正确答案|参考答案|标准答案|系统答案|答案)\s*[::]\s*/i, '');
}
if (!standardAnswer) {
const sMatch = fullBlockText.match(CONFIG.PATTERNS.STD_ANS_LABEL);
if (sMatch && sMatch[1]) standardAnswer = cleanSingleLine(sMatch[1]);
}
const myAnsNode = rootEl.querySelector(CONFIG.SELECTORS.myAnswerNodes);
if (myAnsNode) {
myAnswer = cleanSingleLine(myAnsNode.innerText || myAnsNode.textContent)
.replace(/^(?:我的答案|学生答案|你的答案|作答)\s*[::]\s*/i, '');
}
if (!myAnswer) {
const mMatch = fullBlockText.match(CONFIG.PATTERNS.MY_ANS_LABEL);
if (mMatch && mMatch[1]) myAnswer = cleanSingleLine(mMatch[1]);
}
if (!myAnswer) {
const rawCheckedNodes = Array.from(
rootEl.querySelectorAll('[class*="check_answer"], .option-result.active, label input[checked="checked"], [aria-checked="true"], input[type="radio"]:checked, input[type="checkbox"]:checked')
);
if (rawCheckedNodes.length > 0) {
const uniqueRowSet = new Set();
const extractedAnswers = [];
rawCheckedNodes.forEach((el) => {
const rowContainer = el.closest('.answerBg, li, .option-result, label') || el;
if (uniqueRowSet.has(rowContainer)) return;
uniqueRowSet.add(rowContainer);
const numSpan = rowContainer.querySelector('.num_option, .num_option_dx');
const pSpan = rowContainer.querySelector('.answer_p, .after');
if (numSpan && pSpan) {
const letter = cleanSingleLine(numSpan.textContent);
const content = cleanSingleLine(pSpan.textContent);
extractedAnswers.push(content ? `${letter}. ${content}` : letter);
} else if (el.tagName === 'INPUT' && el.value) {
extractedAnswers.push(cleanSingleLine(el.value));
} else {
const rowText = cleanSingleLine(rowContainer.textContent).replace(/^([A-Z])\s+/i, '$1. ');
if (rowText) extractedAnswers.push(rowText);
}
});
myAnswer = [...new Set(extractedAnswers)].join(';');
} else {
const textVals = [];
rootEl.querySelectorAll('textarea, .line_answer input[name^="answer"]').forEach((inp) => {
const val = cleanSingleLine(inp.value);
if (val) textVals.push(val);
});
rootEl.querySelectorAll('iframe[id^="ueditor_"]').forEach((frame) => {
try {
const uText = cleanSingleLine(frame.contentDocument.body.innerText);
if (uText) textVals.push(uText);
} catch (_) {}
});
if (textVals.length > 0) myAnswer = [...new Set(textVals)].join(';');
}
}
myAnswer = deduplicateAnswerString(myAnswer);
const commentNode = rootEl.querySelector(CONFIG.SELECTORS.teacherCommentNodes);
if (commentNode) {
const cText = cleanSingleLine(commentNode.innerText || commentNode.textContent)
.replace(/^(?:老师评语|教师评语|批改评语|评语)\s*[::]\s*/i, '');
if (cText) {
teacherComment = cText;
hasTeacherGrade = true;
}
}
if (!teacherComment) {
const cMatch = fullBlockText.match(CONFIG.PATTERNS.COMMENT_LABEL);
if (cMatch && cMatch[1]) {
teacherComment = cleanSingleLine(cMatch[1]);
if (teacherComment) hasTeacherGrade = true;
}
}
if (rootEl.querySelector(CONFIG.SELECTORS.rightIcon)) {
judgeResult = '✔';
} else if (rootEl.querySelector(CONFIG.SELECTORS.wrongIcon)) {
judgeResult = '✘';
} else if (rootEl.querySelector(CONFIG.SELECTORS.halfRightIcon)) {
judgeResult = '✔/✘ (半对)';
} else {
const jm = fullBlockText.match(/(?:批改结果|状态)\s*[::]?\s*(正确|错误|对|错|√|×|✔|✘)/);
if (jm) judgeResult = /(正确|对|√|✔)/.test(jm[1]) ? '✔' : '✘';
}
const scoreNodes = Array.from(rootEl.querySelectorAll(CONFIG.SELECTORS.scoreNodes));
const scoreRaw = scoreNodes.map((n) => cleanSingleLine(n.textContent)).join(' ') + '\n' + fullBlockText;
const ratioMatch = scoreRaw.match(/(\d+(?:\.\d+)?)\s*(?:分)?\s*(?:\/|/)\s*(\d+(?:\.\d+)?)/);
if (ratioMatch) {
obtainedScore = ratioMatch[1];
scoreDisplay = `${ratioMatch[1]}/${ratioMatch[2]}`;
} else {
const totalScoreItalic = rootEl.querySelector('.mark_score .totalScore i');
const italicScoreText = totalScoreItalic ? cleanSingleLine(totalScoreItalic.textContent) : '';
const singleScoreMatch =
(italicScoreText !== '' && /^\d+(?:\.\d+)?$/.test(italicScoreText) ? [null, italicScoreText] : null) ||
scoreRaw.match(/(?:得分|实际得分|获得分数)\s*[::]\s*(\d+(?:\.\d+)?)\s*分?/);
if (singleScoreMatch && singleScoreMatch[1] !== undefined) {
obtainedScore = singleScoreMatch[1];
scoreDisplay = totalScoreHint ? `${obtainedScore}/${totalScoreHint}` : `${obtainedScore}分`;
}
}
if (
teacherAnswer ||
teacherComment ||
CONFIG.PATTERNS.TEACHER_GRADE_TRACE.test(fullBlockText) ||
rootEl.querySelector('.mark_Protext, .teacher_remark')
) {
hasTeacherGrade = true;
}
const analysisNode = rootEl.querySelector(CONFIG.SELECTORS.analysisNodes);
if (analysisNode) {
analysis = cleanText(analysisNode.innerText || analysisNode.textContent)
.replace(/^(?:答案解析|试题解析|题目解析|解析)\s*[::]\s*/i, '');
}
if (!analysis) {
const aMatch = fullBlockText.match(CONFIG.PATTERNS.ANALYSIS_LABEL);
if (aMatch && aMatch[1]) analysis = cleanText(aMatch[1]);
}
const finalAnswer = teacherAnswer || standardAnswer || '';
const missingTeacherAnswerWarn = Boolean(hasTeacherGrade && !teacherAnswer && !standardAnswer);
const isGraded = Boolean(judgeResult !== '' || obtainedScore !== '');
return {
myAnswer,
teacherAnswer,
standardAnswer,
finalAnswer,
hasTeacherGrade,
missingTeacherAnswerWarn,
isGraded,
judgeResult,
obtainedScore,
score: scoreDisplay,
teacherComment,
analysis
};
}
function parseCxQuestionSync(rootEl) {
const titleNodes = Array.from(rootEl.querySelectorAll(CONFIG.SELECTORS.title)).filter(
(el) => !el.closest(CONFIG.SELECTORS.titleExclude)
);
const primaryTitleEl = titleNodes[0] || rootEl;
const { text: rawStemText, imgEls: stemImgEls } = extractNodeTextAndImageEls(primaryTitleEl, true);
const cleanedStem = rawStemText
.replace(/^\d+[\.、.\s]+/, '')
.replace(/^[\((【\[][^\))】\]]*(?:..题|名词解释|完形填空|阅读理解|\d+(?:\.\d+)?\s*分)[^\))】\]]*[\))】\]]\s*/, '')
.replace(/\s+[A-D][\.\s、]+对\s+[A-D][\.\s、]+错[\s\S]*$/, '')
.trim();
if (!cleanedStem && stemImgEls.length === 0) return null;
const { qType, qScore } = parseCxTypeAndScore(rootEl);
const rawOptNodes = Array.from(rootEl.querySelectorAll(CONFIG.SELECTORS.options));
const optNodes = rawOptNodes.filter(
(node) => !rawOptNodes.some((other) => other !== node && other.contains(node))
);
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const options = [];
for (let i = 0; i < optNodes.length; i++) {
const { text, imgEls } = extractNodeTextAndImageEls(optNodes[i], false);
if (!text && imgEls.length === 0) continue;
const prefixLetter = letters[i] || String(i + 1);
const parentOptionSpan = optNodes[i].parentElement && optNodes[i].parentElement.querySelector('.num_option, .num_option_dx');
const detectedLetter = parentOptionSpan ? cleanSingleLine(parentOptionSpan.textContent) : '';
const finalLetter = /^[A-Z]$/i.test(detectedLetter) ? detectedLetter.toUpperCase() : prefixLetter;
const hasPrefix = /^[A-Z][\s\.、.::)\)]/i.test(text);
const normalizedText = hasPrefix
? text.replace(/^([A-Z])[\s\.、.::)\)]+/i, (_, l) => `${l.toUpperCase()}. `)
: `${finalLetter}. ${text}`;
options.push({
prefix: finalLetter,
text: normalizedText,
_imgEls: imgEls,
images: []
});
}
const grading = parseCxGradingAndAnswers(rootEl, qScore);
const qObj = {
id: `cx_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
stem: cleanedStem || '[图片题干]',
_stemImgEls: stemImgEls,
stemImages: [],
type: qType || (options.length === 2 ? '判断题' : options.length > 2 ? '单选题' : '简答题'),
totalScore: qScore,
options,
myAnswer: grading.myAnswer,
teacherAnswer: grading.teacherAnswer,
standardAnswer: grading.standardAnswer,
finalAnswer: grading.finalAnswer,
hasTeacherGrade: grading.hasTeacherGrade,
missingTeacherAnswerWarn: grading.missingTeacherAnswerWarn,
isGraded: grading.isGraded,
judgeResult: grading.judgeResult,
obtainedScore: grading.obtainedScore,
score: grading.score,
teacherComment: grading.teacherComment,
analysis: grading.analysis
};
computeFingerprint(qObj);
return qObj;
}
/* ==========================================================================
* 抓取主流程
* ========================================================================== */
let isGrabbing = false;
async function grabCurrentPageQuestions() {
if (isGrabbing) return;
isGrabbing = true;
syncStateFromStorage();
if (grabBtnEl) {
grabBtnEl.disabled = true;
grabBtnEl.innerHTML = `${ICONS.grab}抓取中...`;
}
if (shellEl) {
shellEl.classList.add('lg-pressing');
setTimeout(() => shellEl.classList.remove('lg-pressing'), 180);
}
try {
const docs = getChaoxingQuizDocuments(document);
await waitForCxFontDecryptedIfNeeded(docs);
if (!state.courseTitle) {
state.courseTitle = detectCourseTitle(docs);
}
const candidateRoots = [];
for (const doc of docs) {
const matched = Array.from(doc.querySelectorAll(CONFIG.SELECTORS.questionRoot));
const leafRoots = matched.filter(
(el) => !matched.some((other) => other !== el && el.contains(other))
);
candidateRoots.push(...leafRoots);
}
if (candidateRoots.length === 0) {
expandPanel();
showToast('未识别到题目,请确认已进入学习通作业/测验页', 'error');
return;
}
const detectedTitle = detectCurrentWorkTitle(docs, state.groups.length + 1);
const firstQPreview = parseCxQuestionSync(candidateRoots[0]);
const firstFp = firstQPreview ? firstQPreview._fp : '';
let targetGroup = firstFp
? state.groups.find((g) => (g.questions || []).some((q) => computeFingerprint(q) === firstFp))
: null;
const isNewGroup = !targetGroup;
if (!targetGroup) {
let uniqueName = detectedTitle;
if (state.groups.some((g) => g.name === uniqueName)) {
uniqueName = `${detectedTitle} (${state.groups.length + 1})`;
}
targetGroup = {
id: `grp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name: uniqueName,
expanded: false,
questions: []
};
}
const existingFpSet = new Set(targetGroup.questions.map(computeFingerprint));
let addedCount = 0;
let skippedCount = 0;
const newQuestions = [];
const imageTasks = [];
for (const rootEl of candidateRoots) {
const qData = parseCxQuestionSync(rootEl);
if (!qData) continue;
if (existingFpSet.has(qData._fp)) {
skippedCount++;
continue;
}
existingFpSet.add(qData._fp);
newQuestions.push(qData);
addedCount++;
qData._stemImgEls.forEach((imgEl) => {
imageTasks.push(async () => {
const info = await imageToBase64Cached(imgEl);
if (info && info.base64) qData.stemImages.push(info);
});
});
qData.options.forEach((opt) => {
opt._imgEls.forEach((imgEl) => {
imageTasks.push(async () => {
const info = await imageToBase64Cached(imgEl);
if (info && info.base64) opt.images.push(info);
});
});
});
}
if (addedCount === 0 && skippedCount === 0) {
expandPanel();
showToast('未解析出有效题干,请检查页面是否加载完成', 'error');
return;
}
if (imageTasks.length > 0 && grabBtnEl) {
grabBtnEl.innerHTML = `${ICONS.grab}存图(${imageTasks.length})...`;
await runWithConcurrency(imageTasks, CONFIG.PERF.IMG_CONCURRENCY);
}
newQuestions.forEach((q) => {
delete q._stemImgEls;
q.options.forEach((opt) => delete opt._imgEls);
targetGroup.questions.push(q);
});
targetGroup.questions = normalizeGroupQuestionsByType(targetGroup.questions);
if (isNewGroup && targetGroup.questions.length > 0) {
state.groups.push(targetGroup);
}
if (addedCount > 0) {
state.grabCount = Math.max(state.groups.length, state.grabCount + 1);
saveStateNow();
renderListAndBadge();
}
const msgParts = [`「${targetGroup.name}」+${addedCount}题`];
if (skippedCount > 0) msgParts.push(`跳过重复${skippedCount}题`);
showToast(msgParts.join(','), 'info');
} catch (err) {
console.error('[OCS-CX-QE] 抓取异常:', err);
showToast(`抓取失败:${err.message || String(err)}`, 'error');
} finally {
isGrabbing = false;
if (grabBtnEl) {
grabBtnEl.disabled = false;
grabBtnEl.innerHTML = `${ICONS.grab}抓取`;
}
}
}
/* ==========================================================================
* 纯本地零延迟标准 .docx 导出引擎(纯黑 #000000 微软雅黑)
* ========================================================================== */
function base64ToUint8Array(dataUrl) {
try {
const base64Str = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl;
const binaryStr = atob(base64Str);
const len = binaryStr.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
return bytes;
} catch (_) {
return null;
}
}
function detectImageExt(dataUrl) {
const m = /^data:image\/(png|jpe?g|gif|bmp|webp)/i.exec(dataUrl || '');
if (!m) return 'png';
const sub = m[1].toLowerCase();
return sub === 'jpg' ? 'jpeg' : sub;
}
function buildStandardDocxBlob() {
const enc = new TextEncoder();
const escXml = (s) =>
String(s || '')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/g, '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
const mediaFiles = [];
const relsItems = [];
const addImageRel = (imgInfo, maxPx = 360) => {
if (!imgInfo || !imgInfo.base64) return '';
const bytes = base64ToUint8Array(imgInfo.base64);
if (!bytes || bytes.length === 0) return '';
const ext = detectImageExt(imgInfo.base64);
const idx = mediaFiles.length + 1;
const name = `image${idx}.${ext}`;
const rId = `rIdImg${idx}`;
mediaFiles.push({ name, data: bytes, rId, ext });
relsItems.push(
`