// ==UserScript==
// @name 雨课堂刷课助手 v2.7.10
// @namespace http://tampermonkey.net/
// @version 2.7.10
// @description 针对雨课堂视频进行自动播放,配置题库自动答题
// @author 叶屿
// @license GPL3
// @antifeature payment 题库答题功能需要验证码(免费)或激活码(付费),视频播放等基础功能完全免费
// @match *://yuketang.cn/*
// @match *://*.yuketang.cn/*
// @match *://gdufemooc.cn/*
// @match *://*.gdufemooc.cn/*
// @match *://xuetangx.com/*
// @match *://*.xuetangx.com/*
// @include /^https?:\/\/(?:[^\/]+\.)?(?:yuketang\.cn|gdufemooc\.cn|xuetangx\.com)\/.*$/
// @run-at document-start
// @icon http://yuketang.cn/favicon.ico
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @connect qsy.iano.cn
// @connect open.bigmodel.cn
// @connect ark.cn-beijing.volces.com
// @connect api.deepseek.com
// @connect *
// @connect yuketang.cn
// @connect *.yuketang.cn
// @connect gdufemooc.cn
// @connect *.gdufemooc.cn
// @connect xuetangx.com
// @connect *.xuetangx.com
// @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
// @require https://unpkg.com/tesseract.js@v2.1.0/dist/tesseract.min.js
// @require https://cdn.jsdelivr.net/npm/opentype.js@1.3.4/dist/opentype.min.js
// ==/UserScript==
/*
* ==========================================
* 雨课堂刷课助手 v2.7.10
* ==========================================
*
* 【功能说明】
* 1. 视频自动播放(支持倍速、静音、防暂停)
* 2. 作业自动答题(OCR识别 + 题库查询)
* 3. 考试自动答题(支持停止/继续)
* 4. 双模式题库(免费验证码 / 付费激活码)
*
* 【积分购买】
* 联系微信:C919irt
* 价格表:50积分=2元,100积分=4元,150积分=6元,200积分=8元,500积分=18元
* 说明:每次答题消耗1积分,积分永久有效
*
* 【付费声明】
* 本脚本基础功能(视频播放、进度保存)完全免费
* 题库答题功能需要验证码(免费24小时)或激活码(付费永久)
* 付费仅用于题库API调用成本,不强制购买
*
* 【免责声明】
* 本脚本仅供学习交流使用,请勿用于违反学校规定或作弊行为
* 使用本脚本造成的任何后果由使用者自行承担
*
* 【版权信息】
* 作者:叶屿 | 版本:v2.7.10 | 更新:2026-10-17
*
* ==========================================
*/
(() => {
'use strict';
let panel; // UI 面板实例后置初始化
let isRunning = false; // 标记是否正在运行
let stopRequested = false; // 标记是否请求停止
// 当前受控任务的 Promise。仅依靠 isRunning 无法防止旧 runner 在异步收尾
// 时被再次启动,因此生命周期判断统一以此引用为准。
let activeRunPromise = null;
let AI_PROVIDERS = {};
// ---- 脚本配置,用户可修改 ----
const Config = {
version: '2.7.10', // 版本号
playbackRate: 2, // 视频播放倍速
pptInterval: 3000, // ppt翻页间隔
safety: {
actionDelayMin: 450,
actionDelayMax: 1600,
answerDelayMin: 2800,
answerDelayMax: 6200,
aiUnlockedDelayMin: 2600,
aiUnlockedDelayMax: 5200,
aiLockedDelayMin: 35000,
aiLockedDelayMax: 43000,
aiSettleDelayMin: 1200,
aiSettleDelayMax: 2600,
breakEvery: 8,
breakMin: 12000,
breakMax: 28000,
slideJitter: 1800
},
storageKeys: { // 使用者勿动
progress: '[雨课堂脚本]刷课进度信息',
deviceId: 'ykt_device_id',
activationCode: 'ykt_activation_code',
answerMode: 'ykt_answer_mode', // 答题模式:free/paid
verifyValidUntil: 'ykt_verify_valid_until', // 验证码有效期
verifyProof: 'ykt_verify_proof',
verifySessionToken: 'ykt_verify_session_token',
proClassCount: 'pro_lms_classCount',
feature: 'ykt_feature_conf', // 是否开启题库作答/自动评论
runState: 'ykt_run_state',
aiApiKey: 'ykt_ai_api_key',
aiProvider: 'ykt_ai_provider',
aiCustomUrl: 'ykt_ai_custom_url',
aiCustomModel: 'ykt_ai_custom_model',
aiUnlockCode: 'ykt_ai_unlock_code',
aiUnlockUntil: 'ykt_ai_unlock_until',
aiUnlockProof: 'ykt_ai_unlock_proof',
aiUnlockSessionToken: 'ykt_ai_unlock_session_token',
aiLastCallAt: 'ykt_ai_last_call_at',
playbackRate: 'ykt_playback_rate',
unmatchedFallback: 'ykt_unmatched_fallback',
answerProgress: 'ykt_answer_progress',
studyPurposeConfirmed: 'ykt_study_purpose_confirmed'
}
};
// 授权 proof 的签名包含脚本版本。升级脚本时仍兼容最近版本生成的
// proof,避免用户仅因更新脚本就被迫重新验证;新 proof 始终使用
// Config.version 签名。
const LEGACY_PROOF_VERSIONS = Object.freeze(['2.7.9', '2.7.8', '2.7.7', '2.7.6', '2.7.5', '2.7.4']);
function getRainHostFamily(hostname = location.hostname) {
const host = String(hostname || '').toLowerCase();
if (/(^|\.)yuketang\.cn$/.test(host)) return 'yuketang.cn';
if (/(^|\.)gdufemooc\.cn$/.test(host)) return 'gdufemooc.cn';
if (/(^|\.)xuetangx\.com$/.test(host)) return 'xuetangx.com';
return '';
}
function isRainClassroomHost(hostname = location.hostname) {
return !!getRainHostFamily(hostname);
}
function isV2WebPage() {
return isRainClassroomHost() && /\/v2\/web(?:\/|$)/i.test(location.pathname);
}
function isProLmsPage() {
return isRainClassroomHost() && /\/pro\/lms(?:\/|$)/i.test(location.pathname);
}
function isStudentCardsCoursewarePage() {
const pathname = location.pathname;
return isRainClassroomHost() &&
/\/v2\/web\/studentcards?\//i.test(pathname) &&
/\/(?:ppt|pdf|doc|document|office|courseware|file)(?:\/|$)/i.test(pathname);
}
function isYktStudentQuizPage() {
return /\/v2\/web\/student(?:quiz|exam|exercise|homework)(?:\/|$)/i.test(location.pathname);
}
function isYktExerciseLikePage() {
const host = location.host.toLowerCase();
const pathname = location.pathname.toLowerCase();
const path = pathname.split('/').filter(Boolean);
return (isRainClassroomHost() && (
host.includes('exam.') ||
host.includes('-exam.') ||
path.includes('exam') ||
path.includes('exercise') ||
path.includes('homework') ||
path.includes('ai-workspace') ||
isYktStudentQuizPage()
));
}
const Utils = {
// 短暂睡眠,等待网页加载
sleep: (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms)),
// 可取消等待:返回 true 表示正常等待结束,false 表示用户已请求停止。
// 保持 sleep 的旧 API 不变,只有需要响应停止的流程显式使用该方法。
sleepCancellable(ms = 1000, opts = {}) {
const cancelOnStop = opts.cancelOnStop !== false;
return new Promise(resolve => {
if (cancelOnStop && stopRequested) {
resolve(false);
return;
}
const startedAt = Date.now();
let timer = null;
let watcher = null;
const finish = result => {
if (timer) clearTimeout(timer);
if (watcher) clearInterval(watcher);
resolve(result);
};
timer = setTimeout(() => finish(true), Math.max(0, Number(ms) || 0));
if (cancelOnStop) {
watcher = setInterval(() => {
if (stopRequested) finish(false);
else if (Date.now() - startedAt >= Math.max(0, Number(ms) || 0)) finish(true);
}, Math.min(250, Math.max(40, Number(ms) || 40)));
}
});
},
rand(min, max) {
return Math.floor(min + Math.random() * Math.max(0, max - min));
},
async humanSleep(min = Config.safety.actionDelayMin, max = Config.safety.actionDelayMax) {
await this.sleep(this.rand(min, max));
},
async safeClick(el, opts = {}) {
if (!el || stopRequested) return false;
const {
scroll = true,
beforeMin = Config.safety.actionDelayMin,
beforeMax = Config.safety.actionDelayMax,
afterMin = 350,
afterMax = 1100,
guard = null
} = opts;
const allowed = () => !stopRequested && (!guard || guard());
try {
if (scroll && el.scrollIntoView) {
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
await this.humanSleep(250, 750);
}
if (!allowed()) return false;
await this.humanSleep(beforeMin, beforeMax);
if (!allowed()) return false;
for (const type of ['mouseover', 'mousedown', 'mouseup']) {
try {
el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
} catch (_) { }
}
if (!allowed()) return false;
el.click();
await this.humanSleep(afterMin, afterMax);
return !stopRequested;
} catch (_) {
if (!allowed()) return false;
try {
el.click();
return !stopRequested;
} catch (e) {
return false;
}
}
},
async answerCooldown(usedAI = false, logger = null) {
const isAIMode = (typeof Store !== 'undefined' && Store.getAnswerMode && Store.getAnswerMode() === 'ai') || usedAI;
if (isAIMode) {
const ms = this.rand(Config.safety.aiSettleDelayMin, Config.safety.aiSettleDelayMax);
if (logger) logger(`🤖 AI 作答后稍等 ${Math.round(ms / 1000)} 秒`);
await this.sleep(ms);
return ms;
}
const ms = this.rand(Config.safety.answerDelayMin, Config.safety.answerDelayMax);
await this.sleep(ms);
return ms;
},
async maybeStudyBreak(doneCount, logger = null) {
if (!doneCount || doneCount % Config.safety.breakEvery !== 0) return;
const ms = this.rand(Config.safety.breakMin, Config.safety.breakMax);
if (logger) logger(`⏳ 稍作停顿 ${Math.round(ms / 1000)} 秒,降低连续操作频率`);
await this.sleep(ms);
},
collectAccessibleDocuments(rootDoc = document, maxDepth = 3) {
const docs = [];
const seen = new Set();
const visit = (doc, depth) => {
if (!doc || seen.has(doc) || depth > maxDepth) return;
seen.add(doc);
docs.push(doc);
let frames = [];
try {
frames = Array.from(doc.querySelectorAll('iframe,frame'));
} catch (_) {
return;
}
for (const frame of frames) {
try {
const childDoc = frame.contentDocument || frame.contentWindow?.document;
if (childDoc) visit(childDoc, depth + 1);
} catch (_) { }
}
};
visit(rootDoc, 0);
return docs;
},
// 将一个 JSON 字符串解析为 JavaScript 对象
safeJSONParse(value, fallback) {
try {
return JSON.parse(value);
} catch (_) {
return fallback;
}
},
hashString(value) {
const str = String(value || '');
let h1 = 0x811c9dc5;
let h2 = 0x01000193;
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
h1 ^= c;
h1 = Math.imul(h1, 0x01000193);
h2 ^= c + i;
h2 = Math.imul(h2, 0x85ebca6b);
}
return ((h1 >>> 0).toString(36) + (h2 >>> 0).toString(36));
},
// 每隔一段时间检查某个条件是否满足(通过 checker 函数),如果满足就成功返回;如果超时仍未满足,就失败返回
poll(checker, { interval = 1000, timeout = 20000, cancelOnStop = false } = {}) {
return new Promise(resolve => {
const start = Date.now();
const timer = setInterval(() => {
if (cancelOnStop && stopRequested) {
clearInterval(timer);
resolve(false);
return;
}
if (checker()) {
clearInterval(timer);
resolve(true);
return;
}
if (Date.now() - start > timeout) {
clearInterval(timer);
resolve(false);
}
}, interval);
});
},
// 使用UI课程完成度来判别是否完成课程
isProgressDone(text) {
// 雨课堂会在媒体尚未播完时先显示 98%/99%(截图中的“观看覆盖率”也可能
// 只有 75%)。这些值不能作为切课条件,否则会直接跳到下一集。只有明确
// 的 100% 或完成文案才算平台进度完成。
if (!text) return false;
const normalized = String(text).replace(/\s+/g, '');
if (/未(?:完成|完成学习)/.test(normalized)) return false;
return /(?:^|[^\d])100(?:\.0+)?\s*[%%]/.test(normalized) ||
/已完成|完成学习|学习完成|播放完成|观看完成/.test(normalized);
},
isTransientError(err) {
const text = String(err?.message || err || '');
return /网络|超时|timeout|timed\s*out|network|failed\s*to\s*fetch|请求失败|请求超时|HTTP\s*(0|5\d\d)/i.test(text);
},
// 主要是规避firefox会创建多个iframe的问题
inIframe() {
return window.top !== window.self;
},
// 下滑到最底部,触发课程加载
scrollToBottom(containerSelector) {
const el = document.querySelector(containerSelector);
if (el) el.scrollTop = el.scrollHeight;
},
stableProgressKey(kind = 'page', href = location.href) {
try {
const url = new URL(href, location.href);
let pathname = url.pathname.replace(/\/+$/, '') || '/';
if (/\/v2\/web\/studentquiz\//i.test(pathname)) {
pathname = pathname.replace(/\/\d+$/i, '');
}
const params = new URLSearchParams(url.search || '');
['_', 't', 'ts', 'time', 'timestamp', 'random', 'rand', 'from', 'scene'].forEach(key => params.delete(key));
const query = Array.from(params.entries())
.sort((a, b) => (a[0] + a[1]).localeCompare(b[0] + b[1]))
.map(pair => `${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1])}`)
.join('&');
return `${kind}:${url.hostname.toLowerCase()}${pathname.toLowerCase()}${query ? '?' + query : ''}`;
} catch (_) {
return `${kind}:${String(href || '').split('#')[0]}`;
}
},
async getDDL(target = null) {
const element = target?.tagName
? target
: document.querySelector('video') || document.querySelector('audio');
const fallback = 180_000;
if (!element) return fallback;
let duration = Number(element.duration);
if (!Number.isFinite(duration) || duration <= 0) {
// 某些播放器永远不会派发 loadedmetadata;最多等待 5 秒,并且停止时
// 立即结束,避免外层 poll 在这里无限挂起。
await new Promise(resolve => {
let settled = false;
let timer = null;
let watcher = null;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (watcher) clearInterval(watcher);
try { element.removeEventListener?.('loadedmetadata', finish); } catch (_) { }
resolve();
};
try { element.addEventListener?.('loadedmetadata', finish, { once: true }); } catch (_) { }
timer = setTimeout(finish, 5000);
watcher = setInterval(() => {
if (stopRequested) finish();
}, 250);
});
duration = Number(element.duration);
}
if (stopRequested) return 1000;
const elementDurationMs = duration * 1000;
return Number.isFinite(elementDurationMs) && elementDurationMs > 0
? Math.max(elementDurationMs * 3, 10_000)
: fallback;
}
};
// ---- 存储工具 ----
const Store = {
peekProgress(url) {
const raw = localStorage.getItem(Config.storageKeys.progress);
const all = Utils.safeJSONParse(raw, {}) || {};
return all[url] || null;
},
getProgress(url) {
const raw = localStorage.getItem(Config.storageKeys.progress);
const all = Utils.safeJSONParse(raw, {}) || { url: { outside: 0, inside: 0 } };
if (!all[url]) {
all[url] = { outside: 0, inside: 0 };
localStorage.setItem(Config.storageKeys.progress, JSON.stringify(all));
}
return { all, current: all[url] };
},
migrateProgress(stableUrl, legacyUrl) {
const stable = this.peekProgress(stableUrl);
const legacy = legacyUrl ? this.peekProgress(legacyUrl) : null;
const current = stable || legacy || { outside: 0, inside: 0 };
if (!stable && legacy) this.setProgress(stableUrl, legacy.outside || 0, legacy.inside || 0);
return current;
},
setProgress(url, outside, inside = 0) {
const raw = localStorage.getItem(Config.storageKeys.progress);
const all = Utils.safeJSONParse(raw, {});
all[url] = { outside, inside };
localStorage.setItem(Config.storageKeys.progress, JSON.stringify(all));
},
removeProgress(url) {
const raw = localStorage.getItem(Config.storageKeys.progress);
const all = Utils.safeJSONParse(raw, {});
delete all[url];
localStorage.setItem(Config.storageKeys.progress, JSON.stringify(all));
},
getAnswerProgress(key) {
const raw = localStorage.getItem(Config.storageKeys.answerProgress);
const all = Utils.safeJSONParse(raw, {}) || {};
const record = all[key];
if (!record) return { index: 0, total: 0, updatedAt: 0 };
if (record.updatedAt && Date.now() - Number(record.updatedAt) > 7 * 24 * 60 * 60 * 1000) {
delete all[key];
localStorage.setItem(Config.storageKeys.answerProgress, JSON.stringify(all));
return { index: 0, total: 0, updatedAt: 0 };
}
return {
index: Math.max(0, Number(record.index) || 0),
total: Math.max(0, Number(record.total) || 0),
updatedAt: Number(record.updatedAt) || 0,
confirmedThrough: Math.max(0, Number(record.confirmedThrough) || 0)
};
},
setAnswerProgress(key, index, total = 0, confirmedThrough = null) {
const raw = localStorage.getItem(Config.storageKeys.answerProgress);
const all = Utils.safeJSONParse(raw, {}) || {};
all[key] = {
index: Math.max(0, Number(index) || 0),
total: Math.max(0, Number(total) || 0),
updatedAt: Date.now(),
confirmedThrough: Math.max(0, Number(confirmedThrough ?? all[key]?.confirmedThrough) || 0)
};
localStorage.setItem(Config.storageKeys.answerProgress, JSON.stringify(all));
},
removeAnswerProgress(key) {
const raw = localStorage.getItem(Config.storageKeys.answerProgress);
const all = Utils.safeJSONParse(raw, {}) || {};
delete all[key];
localStorage.setItem(Config.storageKeys.answerProgress, JSON.stringify(all));
},
getDeviceId() {
let deviceId = localStorage.getItem(Config.storageKeys.deviceId);
if (!deviceId) {
deviceId = 'ykt_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem(Config.storageKeys.deviceId, deviceId);
}
return deviceId;
},
getActivationCode() {
return localStorage.getItem(Config.storageKeys.activationCode) || '';
},
setActivationCode(code) {
localStorage.setItem(Config.storageKeys.activationCode, code);
},
getAnswerMode() {
return localStorage.getItem(Config.storageKeys.answerMode) || 'free';
},
setAnswerMode(mode) {
localStorage.setItem(Config.storageKeys.answerMode, mode);
},
normalizeValidUntil(timestamp) {
let value = Number(timestamp);
if (!Number.isFinite(value) || value <= 0) return 0;
// 服务端约定 Unix 秒,兼容偶尔返回的毫秒时间戳。
if (value > 1e12) value /= 1000;
return Math.floor(value);
},
getVerifyValidUntil() {
return this.normalizeValidUntil(localStorage.getItem(Config.storageKeys.verifyValidUntil));
},
makeAccessProof(scope, code, validUntil) {
const issuedAt = Date.now();
const nonce = Math.random().toString(36).slice(2) + issuedAt.toString(36);
const family = getRainHostFamily() || 'rain';
const deviceId = this.getDeviceId();
const payload = {
scope,
family,
deviceId,
validUntil: this.normalizeValidUntil(validUntil),
issuedAt,
nonce,
ua: Utils.hashString(navigator.userAgent || ''),
codeHash: Utils.hashString(String(code || '').trim())
};
const source = [
payload.scope,
payload.family,
payload.deviceId,
payload.validUntil,
payload.issuedAt,
payload.nonce,
payload.ua,
Config.version,
payload.codeHash
].join('|');
payload.sig = Utils.hashString(source);
try {
return btoa(JSON.stringify(payload));
} catch (_) {
return '';
}
},
readAccessProof(key) {
const raw = localStorage.getItem(key) || '';
if (!raw) return null;
try {
return JSON.parse(atob(raw));
} catch (_) {
return null;
}
},
isAccessProofValid(scope, key, validUntil) {
const until = this.normalizeValidUntil(validUntil);
if (until <= Date.now() / 1000) return false;
const payload = this.readAccessProof(key);
if (!payload || payload.scope !== scope) return false;
if (this.normalizeValidUntil(payload.validUntil) !== until) return false;
if (payload.deviceId !== this.getDeviceId()) return false;
if (payload.family !== (getRainHostFamily() || 'rain')) return false;
if (payload.ua !== Utils.hashString(navigator.userAgent || '')) return false;
if (!payload.sig || !payload.nonce || !payload.issuedAt || !payload.codeHash) return false;
if (Date.now() - Number(payload.issuedAt) > 30 * 60 * 60 * 1000) return false;
const versions = [Config.version, ...LEGACY_PROOF_VERSIONS]
.filter((version, index, list) => version && list.indexOf(version) === index);
// 旧版本曾直接把服务端返回值写入签名,兼容其可能使用的毫秒值;
// 新版本统一以秒值签名。
const validUntilValues = [...new Set([
String(payload.validUntil),
String(this.normalizeValidUntil(payload.validUntil))
])];
return versions.some(version => validUntilValues.some(validUntilValue => {
const source = [
payload.scope,
payload.family,
payload.deviceId,
validUntilValue,
payload.issuedAt,
payload.nonce,
payload.ua,
version,
payload.codeHash
].join('|');
return payload.sig === Utils.hashString(source);
}));
},
clearVerifySession() {
localStorage.removeItem(Config.storageKeys.verifyValidUntil);
localStorage.removeItem(Config.storageKeys.verifyProof);
localStorage.removeItem(Config.storageKeys.verifySessionToken);
},
setVerifyValidUntil(timestamp, code = '', sessionToken = '') {
let validUntil = this.normalizeValidUntil(timestamp);
const oldProof = this.readAccessProof(Config.storageKeys.verifyProof);
// 会话复核没有原验证码,不能只改有效期而留下旧 proof;保留
// proof 中的期限可使旧签名继续有效,同时不丢失 token。
if (!code && oldProof) {
const proofUntil = this.normalizeValidUntil(oldProof.validUntil);
if (proofUntil) validUntil = proofUntil;
}
localStorage.setItem(Config.storageKeys.verifyValidUntil, String(validUntil));
if (code) {
localStorage.setItem(Config.storageKeys.verifyProof, this.makeAccessProof('free-answer', code, validUntil));
// 新验证码未返回 token 时不沿用旧会话,避免串用旧身份。
localStorage.setItem(Config.storageKeys.verifySessionToken, String(sessionToken || ''));
} else if (sessionToken) {
localStorage.setItem(Config.storageKeys.verifySessionToken, String(sessionToken));
}
},
getVerifySessionToken() {
return localStorage.getItem(Config.storageKeys.verifySessionToken) || '';
},
isVerifyValid() {
const validUntil = this.getVerifyValidUntil();
const ok = this.isAccessProofValid('free-answer', Config.storageKeys.verifyProof, validUntil);
if (!ok && validUntil) this.clearVerifySession();
return ok;
},
getProClassCount() {
const value = localStorage.getItem(Config.storageKeys.proClassCount);
return value ? Number(value) : 1;
},
setProClassCount(count) {
localStorage.setItem(Config.storageKeys.proClassCount, count);
},
getFeatureConf() {
const raw = localStorage.getItem(Config.storageKeys.feature);
const saved = Utils.safeJSONParse(raw, {}) || {};
const conf = {
autoAI: saved.autoAI ?? false,
autoComment: saved.autoComment ?? false,
commentMode: saved.commentMode ?? 'copy', // 'copy' = 复制他人评论, 'ai' = AI生成评论
skipLive: saved.skipLive ?? false,
};
localStorage.setItem(Config.storageKeys.feature, JSON.stringify(conf));
return conf;
},
setFeatureConf(conf) {
localStorage.setItem(Config.storageKeys.feature, JSON.stringify(conf));
},
getRunState() {
const raw = localStorage.getItem(Config.storageKeys.runState);
return Utils.safeJSONParse(raw, null);
},
setRunState(status) {
const state = { status, lastActiveTime: Date.now() };
localStorage.setItem(Config.storageKeys.runState, JSON.stringify(state));
},
clearRunState() {
localStorage.removeItem(Config.storageKeys.runState);
},
touchRunState() {
const state = this.getRunState();
if (state && state.status === 'running') {
state.lastActiveTime = Date.now();
localStorage.setItem(Config.storageKeys.runState, JSON.stringify(state));
}
},
getAIApiKey() {
return localStorage.getItem(Config.storageKeys.aiApiKey) || '';
},
setAIApiKey(key) {
localStorage.setItem(Config.storageKeys.aiApiKey, key);
},
getAIProvider() {
const provider = localStorage.getItem(Config.storageKeys.aiProvider) || 'zhipu';
return ['zhipu', 'doubao', 'deepseek', 'deepseek_v4', 'custom'].includes(provider) ? provider : 'zhipu';
},
setAIProvider(provider) {
localStorage.setItem(Config.storageKeys.aiProvider, provider);
},
getAICustomUrl() {
return localStorage.getItem(Config.storageKeys.aiCustomUrl) || '';
},
setAICustomUrl(url) {
localStorage.setItem(Config.storageKeys.aiCustomUrl, String(url || '').trim());
},
getAICustomModel() {
return localStorage.getItem(Config.storageKeys.aiCustomModel) || '';
},
setAICustomModel(model) {
localStorage.setItem(Config.storageKeys.aiCustomModel, String(model || '').trim());
},
getAIUnlockCode() {
return localStorage.getItem(Config.storageKeys.aiUnlockCode) || '';
},
setAIUnlockCode(code) {
localStorage.setItem(Config.storageKeys.aiUnlockCode, Utils.hashString(String(code || '').trim()));
},
getAIUnlockUntil() {
return this.normalizeValidUntil(localStorage.getItem(Config.storageKeys.aiUnlockUntil));
},
clearAIUnlockSession() {
localStorage.removeItem(Config.storageKeys.aiUnlockUntil);
localStorage.removeItem(Config.storageKeys.aiUnlockProof);
localStorage.removeItem(Config.storageKeys.aiUnlockCode);
localStorage.removeItem(Config.storageKeys.aiUnlockSessionToken);
},
setAIUnlockUntil(timestamp, code = '', sessionToken = '') {
let validUntil = this.normalizeValidUntil(timestamp);
const oldProof = this.readAccessProof(Config.storageKeys.aiUnlockProof);
// 复核没有原解锁码,保留旧 proof 的期限与签名;同时保留旧 token。
if (!code && oldProof) {
const proofUntil = this.normalizeValidUntil(oldProof.validUntil);
if (proofUntil) validUntil = proofUntil;
}
localStorage.setItem(Config.storageKeys.aiUnlockUntil, String(validUntil));
if (code) {
localStorage.setItem(Config.storageKeys.aiUnlockProof, this.makeAccessProof('ai-unlock', code, validUntil));
localStorage.setItem(Config.storageKeys.aiUnlockSessionToken, String(sessionToken || ''));
} else if (sessionToken) {
localStorage.setItem(Config.storageKeys.aiUnlockSessionToken, String(sessionToken));
}
},
getAIUnlockSessionToken() {
return localStorage.getItem(Config.storageKeys.aiUnlockSessionToken) || '';
},
isAIUnlocked() {
const until = this.getAIUnlockUntil();
const ok = this.isAccessProofValid('ai-unlock', Config.storageKeys.aiUnlockProof, until);
if (!ok && until) this.clearAIUnlockSession();
return ok;
},
getAILastCallAt() {
return Number(localStorage.getItem(Config.storageKeys.aiLastCallAt)) || 0;
},
setAILastCallAt(timestamp = Date.now()) {
localStorage.setItem(Config.storageKeys.aiLastCallAt, String(timestamp));
},
getPlaybackRate() {
return Number(localStorage.getItem(Config.storageKeys.playbackRate)) || 2;
},
setPlaybackRate(rate) {
localStorage.setItem(Config.storageKeys.playbackRate, rate);
Config.playbackRate = rate;
},
getUnmatchedFallback() {
// 题库未匹配时的兜底策略:ai(调用AI,按题型支持多选) | skip(跳过)
return localStorage.getItem(Config.storageKeys.unmatchedFallback) || 'ai';
},
setUnmatchedFallback(strategy) {
localStorage.setItem(Config.storageKeys.unmatchedFallback, strategy);
},
isStudyPurposeConfirmed() {
return localStorage.getItem(Config.storageKeys.studyPurposeConfirmed) === '1';
},
setStudyPurposeConfirmed() {
localStorage.setItem(Config.storageKeys.studyPurposeConfirmed, '1');
}
};
// 统一解析当前 AI 配置,供面板与答题/评论模块共同使用。
// 放在 IIFE 顶层,避免 AnswerModule 在 createPanel 作用域外调用时不可见。
function getAIProviderConfig() {
const provider = Store.getAIProvider();
if (provider === 'custom') {
return {
key: provider,
name: '自定义接口',
model: Store.getAICustomModel(),
url: Store.getAICustomUrl(),
free: false,
desc: '用户自定义的 OpenAI 兼容 Chat Completions 接口',
link: '',
linkText: '',
keyHint: '请填写接口地址、模型名称和 API Key'
};
}
return { key: provider, ...(AI_PROVIDERS[provider] || AI_PROVIDERS.zhipu) };
}
function createAIRequestBody(providerConf, prompt, temperature, maxTokens = 200) {
const body = {
model: providerConf.model,
messages: [{ role: 'user', content: prompt }],
temperature,
max_tokens: maxTokens,
stream: false
};
// 官方文档:https://api-docs.deepseek.com/zh-cn/api/create-chat-completion
// 新模型默认开启思考;短答案明确使用非思考模式,保留额度用于最终正文。
if (providerConf.thinking) body.thinking = { type: providerConf.thinking };
return body;
}
function getAIResponseText(json) {
const choice = json?.choices?.[0] || {};
if (choice.finish_reason === 'length') {
throw new Error('AI 输出达到长度上限,内容不完整,请调整模型或输出额度后重试');
}
if (['content_filter', 'insufficient_system_resource', 'aborted', 'tool_calls'].includes(choice.finish_reason)) {
throw new Error('AI 未正常生成最终文本,请稍后重试或调整模型');
}
let content = choice.message?.content ?? choice.text ?? '';
if (Array.isArray(content)) {
content = content.map(part => typeof part === 'string' ? part : (part?.text || '')).join('');
}
return String(content || '').trim();
}
const AccessGuard = {
// 复核结果:true=明确有效,false=明确拒绝,null=网络/网关/格式等临时失败。
// null 不能覆盖仍在有效期内的本地 proof。
_sessionCache: new Map(),
_sessionRequests: new Map(),
_sessionCacheTtl: 60 * 1000,
_sessionTransientTtl: 15 * 1000,
_getSessionIdentity(scope, token) {
const proof = scope === 'free-answer'
? localStorage.getItem(Config.storageKeys.verifyProof) || ''
: localStorage.getItem(Config.storageKeys.aiUnlockProof) || '';
const validUntil = scope === 'free-answer' ? Store.getVerifyValidUntil() : Store.getAIUnlockUntil();
const deviceId = Store.getDeviceId();
return {
scope,
token: String(token || ''),
deviceId: String(deviceId || ''),
proof: String(proof || ''),
validUntil: Store.normalizeValidUntil(validUntil),
key: [scope, String(token || ''), String(deviceId || ''), String(proof || ''), Store.normalizeValidUntil(validUntil)].join('|')
};
},
_isCurrentIdentity(identity) {
if (!identity) return false;
const token = identity.scope === 'free-answer'
? Store.getVerifySessionToken()
: Store.getAIUnlockSessionToken();
return this._getSessionIdentity(identity.scope, token).key === identity.key;
},
_cacheResult(identity, result, ttl) {
if (!identity) return;
const now = Date.now();
const localUntilMs = identity.validUntil > 0 ? identity.validUntil * 1000 : 0;
const maxTtl = localUntilMs > now ? localUntilMs - now : ttl;
this._sessionCache.set(identity.key, {
result,
expiresAt: now + Math.max(1000, Math.min(ttl, maxTtl || ttl))
});
if (this._sessionCache.size > 24) {
const oldest = [...this._sessionCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt)[0];
if (oldest) this._sessionCache.delete(oldest[0]);
}
},
_readCached(identity) {
const entry = identity && this._sessionCache.get(identity.key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
this._sessionCache.delete(identity.key);
return undefined;
}
return entry.result;
},
_clearSessionCache(scope) {
for (const [key] of this._sessionCache) {
if (String(key).startsWith(scope + '|')) this._sessionCache.delete(key);
}
for (const [key] of this._sessionRequests) {
if (String(key).startsWith(scope + '|')) this._sessionRequests.delete(key);
}
},
checkServerSession(scope, token) {
const identity = this._getSessionIdentity(scope, token);
// 没有 session_token 的旧版授权依赖本地 proof,不因无法复核而降级。
if (!identity.token) return Promise.resolve(null);
const cached = this._readCached(identity);
if (cached !== undefined) return Promise.resolve(cached);
const existing = this._sessionRequests.get(identity.key);
if (existing) return existing;
const request = new Promise(resolve => {
let settled = false;
const finish = result => {
if (settled) return;
settled = true;
// 旧请求完成时不影响已换过验证码/解锁码的新身份。
if (!this._isCurrentIdentity(identity)) {
resolve(null);
return;
}
this._cacheResult(identity, result, result === null ? this._sessionTransientTtl : this._sessionCacheTtl);
resolve(result);
};
try {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://qsy.iano.cn/index.php?s=/api/code/check_session',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: [
'scope=' + encodeURIComponent(scope),
'device_id=' + encodeURIComponent(identity.deviceId),
'session_token=' + encodeURIComponent(identity.token)
].join('&'),
timeout: 12000,
onload: res => {
// 401/403 是明确拒绝;其它非 2xx 可能是网关/临时错误,保留本地授权。
if (res.status === 401 || res.status === 403) {
finish(false);
return;
}
try {
const json = JSON.parse(res.responseText);
const validFlag = json.data?.valid;
const sessionValid = validFlag === true || validFlag === 1 || String(validFlag).toLowerCase() === 'true' || String(validFlag) === '1';
if (res.status === 200 && (json.code === 1 || String(json.code) === '1') && sessionValid) {
// 在更新有效期/proof 之前再次确认身份,避免旧请求覆盖
// 用户刚输入的新验证码或解锁码。
if (!this._isCurrentIdentity(identity)) {
finish(null);
return;
}
if (scope === 'free-answer') {
Store.setVerifyValidUntil(json.data.valid_until || Store.getVerifyValidUntil());
} else {
Store.setAIUnlockUntil(json.data.valid_until || Store.getAIUnlockUntil());
}
finish(true);
} else if (res.status === 200 && (json.code === 0 || String(json.code) === '0' || json.data?.valid === false || String(json.data?.valid) === 'false')) {
finish(false);
} else {
finish(null);
}
} catch (_) {
finish(null);
}
},
onerror: () => finish(null),
ontimeout: () => finish(null)
});
} catch (_) {
finish(null);
}
});
this._sessionRequests.set(identity.key, request);
request.then(() => {
if (this._sessionRequests.get(identity.key) === request) this._sessionRequests.delete(identity.key);
});
return request;
},
async requireFreeAnswerAccess() {
if (!Store.isVerifyValid()) {
Store.clearVerifySession();
panel?.log?.('⚠️ 免费题库验证码未验证、已过期或本地凭据异常,请重新验证');
throw '验证码未验证或已过期';
}
const identity = this._getSessionIdentity('free-answer', Store.getVerifySessionToken());
const ok = await this.checkServerSession('free-answer', identity.token);
if (ok === false) {
if (this._isCurrentIdentity(identity)) Store.clearVerifySession();
panel?.log?.('⚠️ 免费题库授权会话已被服务端拒绝,请重新验证验证码');
throw '验证码授权无效或已过期';
}
// null 表示临时网络故障,继续使用仍有效的本地 proof。
if (!Store.isVerifyValid()) throw '验证码未验证或已过期';
return true;
},
async requireAIUnlockAccess() {
if (!Store.isAIUnlocked()) {
Store.clearAIUnlockSession();
panel?.updateAIUnlockStatus?.();
return false;
}
const identity = this._getSessionIdentity('ai-unlock', Store.getAIUnlockSessionToken());
// 没有 session_token 时只依赖本地 proof + 有效期,兼容旧版解锁。
if (!identity.token) return true;
const handleRejected = ok => {
if (ok !== false || !this._isCurrentIdentity(identity)) return;
Store.clearAIUnlockSession();
panel?.log?.('⚠️ AI解锁会话已被服务端拒绝,请重新输入解锁码');
panel?.updateAIUnlockStatus?.();
};
const cached = this._readCached(identity);
if (cached === false) {
handleRejected(false);
return false;
} else if (cached !== true) {
// 远端复核后台进行,避免题间每次等待 12 秒;临时失败只短暂退避。
this.checkServerSession('ai-unlock', identity.token).then(handleRejected);
}
return true;
},
async getAIThrottleWindow() {
const unlocked = await this.requireAIUnlockAccess();
return unlocked
? {
unlocked: true,
min: Config.safety.aiUnlockedDelayMin,
max: Config.safety.aiUnlockedDelayMax
}
: {
unlocked: false,
min: Config.safety.aiLockedDelayMin,
max: Config.safety.aiLockedDelayMax
};
},
async beforeAIRequest(logger = null) {
const throwIfStopped = () => {
if (typeof stopRequested !== 'undefined' && stopRequested) {
const error = new Error('已请求停止');
error.code = 'STOP_REQUESTED';
throw error;
}
};
throwIfStopped();
let throttle = await this.getAIThrottleWindow();
throwIfStopped();
// 远端复核在后台完成时,解锁可能在等待期间过期或被拒绝。
// 每轮等待结束重新读取窗口,锁定后必须按慢速间隔重新计算。
while (true) {
const lastAt = Store.getAILastCallAt();
const requiredGap = Utils.rand(throttle.min, throttle.max);
const waitMs = Math.max(0, lastAt + requiredGap - Date.now());
if (waitMs > 0) {
if (logger) {
logger(throttle.unlocked
? `🤖 AI快速模式冷却中,${Math.ceil(waitMs / 1000)} 秒后继续`
: `🤖 AI防封限速中,${Math.ceil(waitMs / 1000)} 秒后继续(解锁后可加速)`);
}
let remaining = waitMs;
while (remaining > 0) {
throwIfStopped();
const slice = Math.min(remaining, 250);
await new Promise(resolve => setTimeout(resolve, slice));
remaining -= slice;
}
throwIfStopped();
} else if (!throttle.unlocked && logger) {
logger('🤖 AI防封慢速模式:未解锁时请求间隔约35-43秒');
}
const latestThrottle = await this.getAIThrottleWindow();
throwIfStopped();
if (latestThrottle.unlocked !== throttle.unlocked) {
throttle = latestThrottle;
continue;
}
return latestThrottle;
}
},
markAIRequestDone() {
Store.setAILastCallAt(Date.now());
}
};
// ---- UI 面板 ----
function createPanel() {
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.top = '40px';
iframe.style.left = '40px';
iframe.style.width = '480px';
iframe.style.height = '520px';
iframe.style.zIndex = '999999';
iframe.style.border = '1px solid #a3a3a3';
iframe.style.borderRadius = '12px';
iframe.style.background = '#fff';
iframe.style.overflow = 'hidden';
iframe.style.boxShadow = '0 10px 40px rgba(0,0,0,0.2)';
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('id', 'ykt-helper-iframe');
iframe.setAttribute('allowtransparency', 'true');
document.body.appendChild(iframe);
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`
⚠️ 有BUG及时反馈需提供账号
联系微信:C919irt 📋
- ✅ 脚本已加载 v2.7.10
- 📖 题库自动答题模式
- ⚠️ 自动答题需要配置激活
- 💡 点击【题库配置】开始
- 🚀 雨课堂助手已启动
开始前请确认用途
本功能仅用于复习、回看和辅助整理学习进度,请完整阅读后再继续。
请先阅读到末尾,再勾选确认。
领取付费题库测试
按图片中的页面提示领取测试激活码,再回到这里完成激活。
领取成功后,将激活码粘贴到上方输入框,再点击“激活”。
`);
doc.close();
const ui = {
iframe,
doc,
panel: doc.getElementById('panel'),
header: doc.getElementById('header'),
info: doc.getElementById('info'),
btnStart: doc.getElementById('btn-start'),
btnClear: doc.getElementById('btn-clear'),
btnSetting: doc.getElementById('btn-setting'),
settings: doc.getElementById('settings'),
saveSettings: doc.getElementById('save_settings'),
closeSettings: doc.getElementById('close_settings'),
modePaid: doc.getElementById('mode-paid'),
modeFree: doc.getElementById('mode-free'),
modeAI: doc.getElementById('mode-ai'),
aiConfigSection: doc.getElementById('ai-config-section'),
aiApiKeyInput: doc.getElementById('ai_api_key'),
aiProviderSelect: doc.getElementById('ai_provider'),
aiCustomConfig: doc.getElementById('ai_custom_config'),
aiCustomUrlInput: doc.getElementById('ai_custom_url'),
aiCustomModelInput: doc.getElementById('ai_custom_model'),
unmatchedFallback: doc.getElementById('unmatched_fallback'),
aiProviderInfo: doc.getElementById('ai_provider_info'),
aiUnlockCode: doc.getElementById('ai_unlock_code'),
aiUnlockBtn: doc.getElementById('ai_unlock_btn'),
aiUnlockStatus: doc.getElementById('ai_unlock_status'),
activationCodeSection: doc.getElementById('activation-code-section'),
activationCodeInput: doc.getElementById('activation_code'),
activateBtn: doc.getElementById('activate_btn'),
activateStatus: doc.getElementById('activate_status'),
creditsDisplay: doc.getElementById('credits_display'),
wechatCopy: doc.getElementById('wechat_copy'),
paidTrialBtn: doc.getElementById('paid-trial-btn'),
paidTrialModal: doc.getElementById('paid-trial-modal'),
paidTrialClose: doc.getElementById('paid-trial-close'),
paidTrialDone: doc.getElementById('paid-trial-done'),
feedbackWechat: doc.getElementById('feedback_wechat'),
verifyCodeSection: doc.getElementById('verify-code-section'),
verifyCodeInput: doc.getElementById('verify_code'),
verifyBtn: doc.getElementById('verify_btn'),
verifyStatus: doc.getElementById('verify_status'),
featureAutoAI: doc.getElementById('feature_auto_ai'),
featureAutoComment: doc.getElementById('feature_auto_comment'),
commentModeCopy: doc.getElementById('comment_mode_copy'),
commentModeAI: doc.getElementById('comment_mode_ai'),
commentModeSection: doc.getElementById('comment_mode_section'),
featureSkipLive: doc.getElementById('feature_skip_live'),
playbackRate: doc.getElementById('playback_rate'),
minimality: doc.getElementById('minimality'),
question: doc.getElementById('question'),
miniBasic: doc.getElementById('mini-basic'),
miniIcon: doc.getElementById('mini-icon'),
miniText: doc.getElementById('mini-text'),
purposeDialog: doc.getElementById('purpose-dialog'),
purposeScroll: doc.getElementById('purpose-scroll'),
purposeReadHint: doc.getElementById('purpose-read-hint'),
purposeCheck: doc.getElementById('purpose-check'),
purposeCancel: doc.getElementById('purpose-cancel'),
purposeConfirm: doc.getElementById('purpose-confirm')
};
let isDragging = false;
let startX = 0, startY = 0, startLeft = 0, startTop = 0;
const hostWindow = (() => {
try {
if (window.parent && window.parent !== window && window.parent.document) return window.parent;
} catch (_) { }
return window;
})();
const onMove = e => {
if (!isDragging) return;
const deltaX = e.screenX - startX;
const deltaY = e.screenY - startY;
const maxLeft = Math.max(0, hostWindow.innerWidth - iframe.offsetWidth);
const maxTop = Math.max(0, hostWindow.innerHeight - iframe.offsetHeight);
iframe.style.left = Math.min(Math.max(0, startLeft + deltaX), maxLeft) + 'px';
iframe.style.top = Math.min(Math.max(0, startTop + deltaY), maxTop) + 'px';
};
const stopDrag = () => {
if (!isDragging) return;
isDragging = false;
iframe.style.transition = '';
doc.body.style.userSelect = '';
};
// 标题栏拖拽
ui.header.addEventListener('mousedown', e => {
isDragging = true;
startX = e.screenX;
startY = e.screenY;
startLeft = parseFloat(iframe.style.left) || 0;
startTop = parseFloat(iframe.style.top) || 0;
iframe.style.transition = 'none';
doc.body.style.userSelect = 'none';
e.preventDefault();
});
// 缩小按钮拖拽
ui.miniBasic.addEventListener('mousedown', e => {
isDragging = true;
startX = e.screenX;
startY = e.screenY;
startLeft = parseFloat(iframe.style.left) || 0;
startTop = parseFloat(iframe.style.top) || 0;
iframe.style.transition = 'none';
doc.body.style.userSelect = 'none';
e.preventDefault();
e.stopPropagation(); // 防止触发点击事件
});
doc.addEventListener('mousemove', onMove);
hostWindow.addEventListener('mousemove', onMove);
doc.addEventListener('mouseup', stopDrag);
hostWindow.addEventListener('mouseup', stopDrag);
hostWindow.addEventListener('blur', stopDrag);
const normalSize = { width: parseFloat(iframe.style.width), height: parseFloat(iframe.style.height) };
const miniSize = 64;
let isMinimized = false;
const enterMini = () => {
if (isMinimized) return;
isMinimized = true;
ui.panel.style.display = 'none';
ui.miniBasic.classList.add('show');
iframe.style.width = '80px';
iframe.style.height = '64px';
};
const exitMini = () => {
if (!isMinimized) return;
isMinimized = false;
ui.panel.style.display = '';
ui.miniBasic.classList.remove('show');
iframe.style.width = normalSize.width + 'px';
iframe.style.height = normalSize.height + 'px';
};
ui.minimality.addEventListener('click', enterMini);
ui.miniBasic.addEventListener('click', exitMini);
ui.question.addEventListener('click', () => {
hostWindow.alert('雨课堂助手 v2.7.10\n作者:叶屿\n\n功能说明:\n- 自动播放视频/音频\n- 题库自动答题\n- AI智能答题(智谱、豆包、DeepSeek、自定义接口)\n- AI限速解锁(验证码24h极速答题)\n- AI智能评论/自动回复\n- 自动评论回复');
});
let paidTrialReturnFocus = null;
const closePaidTrialModal = (restoreFocus = true) => {
if (!ui.paidTrialModal.classList.contains('is-open')) return;
ui.paidTrialModal.classList.remove('is-open');
ui.paidTrialModal.setAttribute('aria-hidden', 'true');
if (restoreFocus && paidTrialReturnFocus && typeof paidTrialReturnFocus.focus === 'function') {
paidTrialReturnFocus.focus();
}
paidTrialReturnFocus = null;
};
const openPaidTrialModal = () => {
paidTrialReturnFocus = doc.activeElement;
ui.paidTrialModal.classList.add('is-open');
ui.paidTrialModal.setAttribute('aria-hidden', 'false');
ui.paidTrialClose.focus();
};
ui.paidTrialBtn.addEventListener('click', openPaidTrialModal);
ui.paidTrialClose.addEventListener('click', () => closePaidTrialModal());
ui.paidTrialDone.addEventListener('click', () => closePaidTrialModal());
ui.paidTrialModal.addEventListener('click', e => {
if (e.target === ui.paidTrialModal) closePaidTrialModal();
});
doc.addEventListener('keydown', e => {
if (e.key === 'Escape' && ui.paidTrialModal.classList.contains('is-open')) {
e.preventDefault();
closePaidTrialModal();
}
});
const log = message => {
const li = doc.createElement('li');
li.innerText = message;
ui.info.appendChild(li);
// 限制最多 100 条日志,防止课程内容多时爆内存
while (ui.info.children.length > 100) {
ui.info.removeChild(ui.info.firstChild);
}
if (ui.info.lastElementChild) ui.info.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
};
let purposeDialogResolver = null;
const updatePurposeConfirmState = () => {
ui.purposeConfirm.disabled = ui.purposeCheck.disabled || !ui.purposeCheck.checked;
};
const markPurposeReadIfScrolled = () => {
const bottomReached = ui.purposeScroll.scrollTop + ui.purposeScroll.clientHeight >= ui.purposeScroll.scrollHeight - 4;
if (bottomReached) {
ui.purposeCheck.disabled = false;
ui.purposeReadHint.textContent = '已阅读到末尾,可以勾选确认。';
}
updatePurposeConfirmState();
};
const closePurposeDialog = confirmed => {
ui.purposeDialog.style.display = 'none';
if (purposeDialogResolver) {
const resolve = purposeDialogResolver;
purposeDialogResolver = null;
resolve(confirmed);
}
};
const showStudyPurposeDialog = () => {
if (purposeDialogResolver) return new Promise(resolve => {
const previous = purposeDialogResolver;
purposeDialogResolver = confirmed => {
previous(confirmed);
resolve(confirmed);
};
});
return new Promise(resolve => {
purposeDialogResolver = resolve;
ui.purposeCheck.checked = false;
ui.purposeCheck.disabled = true;
ui.purposeConfirm.disabled = true;
ui.purposeReadHint.textContent = '请先阅读到末尾,再勾选确认。';
ui.purposeDialog.style.display = 'flex';
ui.purposeScroll.scrollTop = 0;
setTimeout(() => {
markPurposeReadIfScrolled();
ui.purposeScroll.focus();
}, 0);
});
};
ui.purposeScroll.addEventListener('scroll', markPurposeReadIfScrolled);
ui.purposeCheck.addEventListener('change', updatePurposeConfirmState);
ui.purposeCancel.addEventListener('click', () => closePurposeDialog(false));
ui.purposeConfirm.addEventListener('click', () => {
if (!ui.purposeConfirm.disabled) closePurposeDialog(true);
});
doc.addEventListener('keydown', e => {
if (e.key === 'Escape' && ui.purposeDialog.style.display === 'flex') closePurposeDialog(false);
});
// 查询积分
const queryCredits = async () => {
const deviceId = Store.getDeviceId();
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://qsy.iano.cn/index.php?s=/api/question_bank/credits&device_id=${deviceId}`,
timeout: 15000,
onload: res => {
if (res.status === 200) {
try {
const json = JSON.parse(res.responseText);
if (json.code === 1) {
resolve(json.data);
} else {
reject(json.msg || '查询失败');
}
} catch (e) {
reject('JSON 解析失败');
}
} else {
reject(`请求失败: HTTP ${res.status}`);
}
},
onerror: () => reject('网络错误'),
ontimeout: () => reject('请求超时')
});
});
};
// 更新积分显示
const updateCreditsDisplay = async () => {
try {
const data = await queryCredits();
ui.creditsDisplay.textContent = `${data.remaining_credits} 积分`;
} catch (err) {
ui.creditsDisplay.textContent = '-- 积分';
}
};
// 验证验证码
const verifyCode = async (code, scope = 'free-answer') => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://qsy.iano.cn/index.php?s=/api/code/verify',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: [
'code=' + encodeURIComponent(code),
'scope=' + encodeURIComponent(scope),
'device_id=' + encodeURIComponent(Store.getDeviceId())
].join('&'),
timeout: 15000,
onload: res => {
if (res.status === 200) {
try {
const json = JSON.parse(res.responseText);
if (json.code === 1 && json.data.valid) {
resolve(json);
} else {
reject(json.msg || '验证码无效或已过期');
}
} catch (e) {
reject('JSON 解析失败');
}
} else {
reject(`请求失败: HTTP ${res.status}`);
}
},
onerror: () => reject('网络错误'),
ontimeout: () => reject('请求超时')
});
});
};
// 激活题库激活码
const activateCode = async (code, deviceId) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://qsy.iano.cn/index.php?s=/api/question_bank/activate',
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify({
code: code,
device_id: deviceId
}),
timeout: 15000,
onload: res => {
if (res.status === 200) {
try {
const json = JSON.parse(res.responseText);
if (json.code === 1) {
resolve(json);
} else {
reject(json.msg || '激活失败');
}
} catch (e) {
reject('JSON 解析失败');
}
} else {
reject(`请求失败: HTTP ${res.status}`);
}
},
onerror: () => reject('网络错误'),
ontimeout: () => reject('请求超时')
});
});
};
// 切换答题模式
const switchAnswerMode = (mode) => {
Store.setAnswerMode(mode);
ui.modePaid.classList.remove('selected');
ui.modeFree.classList.remove('selected');
ui.modeAI.classList.remove('selected');
ui.activationCodeSection.style.display = 'none';
ui.verifyCodeSection.style.display = 'none';
ui.aiConfigSection.style.display = 'none';
if (mode === 'paid') {
ui.modePaid.classList.add('selected');
ui.activationCodeSection.style.display = 'block';
updateCreditsDisplay();
} else if (mode === 'ai') {
ui.modeAI.classList.add('selected');
ui.aiConfigSection.style.display = 'block';
ui.aiApiKeyInput.value = Store.getAIApiKey();
ui.aiProviderSelect.value = Store.getAIProvider();
if (ui.aiCustomUrlInput) ui.aiCustomUrlInput.value = Store.getAICustomUrl();
if (ui.aiCustomModelInput) ui.aiCustomModelInput.value = Store.getAICustomModel();
updateAIProviderInfo();
updateAIUnlockStatus();
} else {
ui.modeFree.classList.add('selected');
ui.verifyCodeSection.style.display = 'block';
}
};
// 绑定答题模式切换事件
ui.modePaid.addEventListener('click', () => switchAnswerMode('paid'));
ui.modeFree.addEventListener('click', () => switchAnswerMode('free'));
ui.modeAI.addEventListener('click', () => switchAnswerMode('ai'));
// 绑定微信号复制
ui.wechatCopy.addEventListener('click', () => {
const wechat = 'C919irt';
const textarea = doc.createElement('textarea');
textarea.value = wechat;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
doc.body.appendChild(textarea);
textarea.select();
try {
doc.execCommand('copy');
ui.wechatCopy.textContent = '✅ 已复制';
setTimeout(() => {
ui.wechatCopy.textContent = 'C919irt 📋';
}, 2000);
} catch (err) {
console.error('复制失败', err);
}
doc.body.removeChild(textarea);
});
// 绑定反馈微信号复制
ui.feedbackWechat.addEventListener('click', () => {
const wechat = 'C919irt';
const textarea = doc.createElement('textarea');
textarea.value = wechat;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
doc.body.appendChild(textarea);
textarea.select();
try {
doc.execCommand('copy');
ui.feedbackWechat.textContent = '✅ 已复制';
setTimeout(() => {
ui.feedbackWechat.textContent = 'C919irt 📋';
}, 2000);
} catch (err) {
console.error('复制失败', err);
}
doc.body.removeChild(textarea);
});
// 绑定激活按钮
ui.activateBtn.addEventListener('click', async () => {
const code = ui.activationCodeInput.value.trim();
if (!code) {
ui.activateStatus.className = 'verify-status error';
ui.activateStatus.textContent = '❌ 请输入激活码';
ui.activateStatus.style.display = 'block';
return;
}
ui.activateBtn.disabled = true;
ui.activateBtn.textContent = '激活中...';
ui.activateStatus.style.display = 'none';
try {
const deviceId = Store.getDeviceId();
const result = await activateCode(code, deviceId);
Store.setActivationCode(code);
ui.activateStatus.className = 'verify-status success';
ui.activateStatus.textContent = `✅ 激活成功!获得 ${result.data.credits} 积分`;
ui.activateStatus.style.display = 'block';
ui.activationCodeInput.value = '';
await updateCreditsDisplay(); // 更新积分显示
log(`✅ 激活成功!获得 ${result.data.credits} 积分`);
} catch (err) {
ui.activateStatus.className = 'verify-status error';
ui.activateStatus.textContent = `❌ ${err}`;
ui.activateStatus.style.display = 'block';
} finally {
ui.activateBtn.disabled = false;
ui.activateBtn.textContent = '激活';
}
});
// 绑定验证码验证按钮
ui.verifyBtn.addEventListener('click', async () => {
const vcode = ui.verifyCodeInput.value.trim();
if (!vcode || vcode.length !== 4) {
ui.verifyStatus.className = 'verify-status error';
ui.verifyStatus.textContent = '❌ 请输入4位验证码';
ui.verifyStatus.style.display = 'block';
return;
}
ui.verifyBtn.disabled = true;
ui.verifyBtn.textContent = '验证中...';
ui.verifyStatus.style.display = 'none';
try {
const result = await verifyCode(vcode, 'free-answer');
Store.setVerifyValidUntil(result.data.valid_until, vcode, result.data.session_token || '');
ui.verifyStatus.className = 'verify-status success';
ui.verifyStatus.textContent = `✅ 验证成功!有效期至 ${result.data.valid_until_str}`;
ui.verifyStatus.style.display = 'block';
ui.verifyCodeInput.value = '';
ui.verifyCodeInput.disabled = true;
log(`✅ 验证码验证成功!有效期至 ${result.data.valid_until_str}`);
} catch (err) {
ui.verifyStatus.className = 'verify-status error';
ui.verifyStatus.textContent = `❌ ${err}`;
ui.verifyStatus.style.display = 'block';
} finally {
ui.verifyBtn.disabled = false;
ui.verifyBtn.textContent = '验证';
}
});
// 支持回车键验证
ui.verifyCodeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
ui.verifyBtn.click();
}
});
// ---- AI多模型 Provider 信息配置 ----
// 仅保留内置的 DeepSeek、豆包、智谱,另提供一个 OpenAI 兼容自定义接口。
AI_PROVIDERS = {
zhipu: {
name: '智谱 GLM-4-Flash',
model: 'glm-4-flash',
url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
free: true,
desc: '智谱 AI 免费模型,速度快,中文题目适配较好',
link: 'https://www.bigmodel.cn/',
linkText: '前往智谱 AI 开放平台注册/登录获取 API Key →',
keyHint: '进入控制台 → API Keys → 创建 API Key → 复制'
},
doubao: {
name: '豆包 Doubao-lite',
model: 'doubao-lite-32k',
url: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
free: true,
desc: '字节跳动豆包模型,速度快,需在火山引擎开通模型',
link: 'https://console.volcengine.com/ark/',
linkText: '前往火山引擎 Ark 平台获取 API Key →',
keyHint: '登录 → API Key 管理 → 创建 API Key → 复制'
},
deepseek: {
name: 'DeepSeek Flash(V4.1)',
model: 'deepseek-flash',
url: 'https://api.deepseek.com/chat/completions',
thinking: 'disabled',
answerMaxTokens: 2048,
commentMaxTokens: 512,
timeout: 60000,
free: false,
desc: 'DeepSeek 官方 Flash 模型,当前为 V4.1。使用非思考模式直接生成答案,费用以官方定价为准。',
link: 'https://platform.deepseek.com/',
linkText: '前往 DeepSeek 开放平台注册/登录获取 API Key →',
keyHint: '登录后 → 左侧 API Keys → 创建 API Key → 复制'
},
deepseek_v4: {
name: 'DeepSeek V4 Pro',
model: 'deepseek-v4-pro',
url: 'https://api.deepseek.com/chat/completions',
thinking: 'disabled',
answerMaxTokens: 2048,
commentMaxTokens: 512,
timeout: 60000,
free: false,
desc: 'DeepSeek 官方 Pro 模型。使用非思考模式直接生成答案,与 Flash 共用 API Key,费用以官方定价为准。',
link: 'https://platform.deepseek.com/',
linkText: '前往 DeepSeek 开放平台注册/登录获取 API Key →',
keyHint: '与 Flash 使用同一平台 API Key,可直接切换'
}
};
// 更新AI Provider信息显示
const updateAIProviderInfo = () => {
const provider = ui.aiProviderSelect.value;
const info = AI_PROVIDERS[provider];
if (provider === 'custom') {
ui.aiProviderInfo.innerHTML = '自定义 OpenAI 兼容接口,可接入其他模型服务。自定义
💡 请填写接口地址、模型名称和下方 API Key。
';
if (ui.aiCustomConfig) ui.aiCustomConfig.style.display = 'block';
return;
}
if (ui.aiCustomConfig) ui.aiCustomConfig.style.display = 'none';
if (!info) return;
const freeTag = info.free ? '免费' : '按量计费';
ui.aiProviderInfo.innerHTML = `
${info.desc} ${freeTag}
🔗 ${info.linkText}
💡 操作:${info.keyHint}
`;
};
let aiUnlockUiState = null;
// 更新AI解锁状态显示
const updateAIUnlockStatus = () => {
const unlocked = Store.isAIUnlocked();
aiUnlockUiState = unlocked;
if (unlocked) {
const until = Store.getAIUnlockUntil();
const date = new Date(until * 1000);
const dateStr = date.toLocaleString('zh-CN');
ui.aiUnlockStatus.innerHTML = `✅ 已解锁,有效期至 ${dateStr}(AI请求间隔≈2.6-5.2秒)`;
ui.aiUnlockCode.disabled = true;
ui.aiUnlockBtn.disabled = true;
} else {
ui.aiUnlockStatus.innerHTML = `⏳ 未解锁,当前AI请求间隔35-43秒`;
ui.aiUnlockCode.disabled = false;
ui.aiUnlockBtn.disabled = false;
}
};
// AI Provider 切换事件
ui.aiProviderSelect.addEventListener('change', () => {
Store.setAIProvider(ui.aiProviderSelect.value);
updateAIProviderInfo();
});
// AI 解锁按钮事件
ui.aiUnlockBtn.addEventListener('click', async () => {
const code = ui.aiUnlockCode.value.trim();
if (!code || code.length !== 4) {
ui.aiUnlockStatus.innerHTML = '❌ 请输入4位解锁码';
return;
}
ui.aiUnlockBtn.disabled = true;
ui.aiUnlockBtn.textContent = '验证中...';
try {
const result = await verifyCode(code, 'ai-unlock');
Store.setAIUnlockCode(code);
Store.setAIUnlockUntil(result.data.valid_until, code, result.data.session_token || '');
ui.aiUnlockCode.value = '';
updateAIUnlockStatus();
log(`🚀 AI限速已解锁!有效期至 ${result.data.valid_until_str}`);
} catch (err) {
ui.aiUnlockStatus.innerHTML = `❌ ${err}`;
} finally {
ui.aiUnlockBtn.disabled = false;
ui.aiUnlockBtn.textContent = '解锁';
}
});
// AI 解锁码回车支持
ui.aiUnlockCode.addEventListener('keypress', (e) => {
if (e.key === 'Enter') ui.aiUnlockBtn.click();
});
// 检查验证码状态
const checkVerifyStatus = () => {
if (Store.isVerifyValid()) {
const validUntil = Store.getVerifyValidUntil();
const date = new Date(validUntil * 1000);
const dateStr = date.toLocaleString('zh-CN');
ui.verifyStatus.className = 'verify-status success';
ui.verifyStatus.textContent = `✅ 已验证,有效期至 ${dateStr}`;
ui.verifyCodeInput.disabled = true;
ui.verifyBtn.disabled = true;
} else {
ui.verifyCodeInput.disabled = false;
ui.verifyBtn.disabled = false;
ui.verifyStatus.style.display = 'none';
}
};
const loadAnswerMode = () => {
const mode = Store.getAnswerMode();
switchAnswerMode(mode);
if (mode === 'free') {
checkVerifyStatus();
} else {
updateCreditsDisplay(); // 付费模式加载时查询积分
}
};
const loadActivationCode = () => {
ui.activationCodeInput.value = Store.getActivationCode();
};
const loadFeatureConf = () => {
const saved = Store.getFeatureConf();
ui.featureAutoAI.checked = saved.autoAI;
ui.featureAutoComment.checked = saved.autoComment;
ui.featureSkipLive.checked = saved.skipLive;
// 加载评论模式
if (saved.autoComment) {
ui.commentModeSection.style.display = 'block';
} else {
ui.commentModeSection.style.display = 'none';
}
const commentMode = saved.commentMode || 'copy';
if (ui.commentModeCopy) ui.commentModeCopy.checked = (commentMode === 'copy');
if (ui.commentModeAI) ui.commentModeAI.checked = (commentMode === 'ai');
// 更新AI提示
const aiHint = doc.getElementById('ai_comment_hint');
if (aiHint) aiHint.style.display = (commentMode === 'ai') ? 'block' : 'none';
};
// 评论复选框切换 → 显示/隐藏回复方式选项
ui.featureAutoComment.addEventListener('change', () => {
ui.commentModeSection.style.display = ui.featureAutoComment.checked ? 'block' : 'none';
});
// 评论模式单选切换 → 显示/隐藏AI提示
const commentModeRadios = doc.querySelectorAll('input[name="comment_mode"]');
commentModeRadios.forEach(radio => {
radio.addEventListener('change', () => {
const aiHint = doc.getElementById('ai_comment_hint');
if (aiHint) aiHint.style.display = (radio.value === 'ai' && radio.checked) ? 'block' : 'none';
});
});
loadAnswerMode();
loadActivationCode();
loadFeatureConf();
// 有效期可能在面板打开期间自然到期;定时刷新只在状态变化时
// 重绘,避免干扰用户正在输入解锁码或等待验证的界面。
const aiUnlockStatusTimer = setInterval(() => {
const unlocked = Store.isAIUnlocked();
if (unlocked !== aiUnlockUiState) updateAIUnlockStatus();
}, 1000);
ui.btnSetting.onclick = () => {
loadAnswerMode();
loadActivationCode();
loadFeatureConf();
ui.playbackRate.value = String(Store.getPlaybackRate());
if (ui.unmatchedFallback) ui.unmatchedFallback.value = Store.getUnmatchedFallback();
ui.settings.style.display = 'block';
};
ui.closeSettings.onclick = () => {
ui.settings.style.display = 'none';
};
ui.saveSettings.onclick = async () => {
const mode = Store.getAnswerMode();
// 保存兜底策略
if (ui.unmatchedFallback) Store.setUnmatchedFallback(ui.unmatchedFallback.value);
// 保存播放倍速
const rate = parseFloat(ui.playbackRate.value) || 2;
Store.setPlaybackRate(rate);
// 设置面板可能在视频播放期间打开;保存后立即应用到当前页面及同源
// iframe 的媒体节点,播放器后续重建/播放时由 lockRate 继续保持。
Player.applySpeed(document);
// 保存功能配置(含评论模式)
const commentMode = (ui.commentModeAI && ui.commentModeAI.checked) ? 'ai' : 'copy';
const featureConf = {
autoAI: ui.featureAutoAI.checked,
autoComment: ui.featureAutoComment.checked,
commentMode: commentMode,
skipLive: ui.featureSkipLive.checked
};
Store.setFeatureConf(featureConf);
// 保存AI API Key
if (Store.getAnswerMode() === 'ai') {
Store.setAIApiKey(ui.aiApiKeyInput.value.trim());
if (ui.aiCustomUrlInput) Store.setAICustomUrl(ui.aiCustomUrlInput.value.trim());
if (ui.aiCustomModelInput) Store.setAICustomModel(ui.aiCustomModelInput.value.trim());
}
// 检查激活状态并给出提示
if (featureConf.autoAI) {
if (mode === 'paid') {
try {
const data = await queryCredits();
if (data.remaining_credits <= 0) {
log('⚠️ 积分不足,请先购买激活码充值');
} else {
log(`✅ 题库配置已保存 (付费模式,剩余 ${data.remaining_credits} 积分)`);
}
} catch (err) {
log('⚠️ 题库配置已保存,但未检测到激活码,请先激活');
}
} else if (mode === 'ai') {
const customConfigMissing = Store.getAIProvider() === 'custom' && (!Store.getAICustomUrl() || !Store.getAICustomModel());
if (!Store.getAIApiKey()) {
log('⚠️ 题库配置已保存,但未填写AI API Key');
} else if (customConfigMissing) {
log('⚠️ 题库配置已保存,但自定义接口地址或模型名称为空');
} else {
log('✅ 题库配置已保存 (AI答题模式)');
}
} else {
// 免费模式
if (!Store.isVerifyValid()) {
log('⚠️ 题库配置已保存,但验证码未验证或已过期,请先验证');
} else {
log('✅ 题库配置已保存 (免费模式)');
}
}
} else {
log('✅ 题库配置已保存');
}
ui.settings.style.display = 'none';
};
ui.btnClear.onclick = () => {
let currentHref = location.href;
try {
if (window.parent && window.parent !== window && window.parent.location?.href) {
currentHref = window.parent.location.href;
}
} catch (_) { }
Store.removeProgress(currentHref);
Store.removeProgress(Utils.stableProgressKey('v2-course', currentHref));
Store.removeProgress(Utils.stableProgressKey('pro-course', currentHref));
Store.removeAnswerProgress(Utils.stableProgressKey('answer:generic', currentHref));
Store.removeAnswerProgress(Utils.stableProgressKey('answer:studentQuiz', currentHref));
Store.removeAnswerProgress(Utils.stableProgressKey('answer:aiWorkspace', currentHref));
Store.removeAnswerProgress(Utils.stableProgressKey('answer:v2-homework', currentHref));
localStorage.removeItem(Config.storageKeys.proClassCount);
log('已清除当前页面的刷课/答题进度缓存');
};
// 获取公告
try {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://qsy.iano.cn/index.php?s=/admin/unified_manage/scriptannouncementapi',
timeout: 5000,
onload: function(resp) {
try {
var res = JSON.parse(resp.responseText);
if (res.code === 1 && res.data && res.data.enabled) {
var colors = { info: '#3b82f6', warning: '#f59e0b', error: '#ef4444', success: '#22c55e' };
var bgColors = { info: '#eff6ff', warning: '#fffbeb', error: '#fef2f2', success: '#f0fdf4' };
var color = colors[res.data.type] || '#3b82f6';
var bgColor = bgColors[res.data.type] || '#eff6ff';
var rawContent = String(res.data.content || '');
var noticeImages = [];
if (Array.isArray(res.data.images)) {
noticeImages = res.data.images;
} else if (typeof res.data.images === 'string' && res.data.images.trim()) {
try {
var parsedImages = JSON.parse(res.data.images);
noticeImages = Array.isArray(parsedImages) ? parsedImages : [res.data.images];
} catch (_) {
noticeImages = res.data.images.split(/[\n,]+/);
}
}
if (res.data.image_url) noticeImages.push(res.data.image_url);
rawContent = rawContent.replace(/!\[[^\]]*\]\(([^)]+)\)/g, function(_, url) {
noticeImages.push(url);
return '';
}).trim();
var normalizeImageUrl = function(url) {
url = String(url || '').trim();
if (!url) return '';
if (/^https?:\/\//i.test(url) || /^data:image\//i.test(url)) return url;
if (/^\/\//.test(url)) return 'https:' + url;
try {
return new URL(url, 'https://qsy.iano.cn/').href;
} catch (_) {}
return '';
};
noticeImages = noticeImages.map(normalizeImageUrl).filter(function(url, idx, arr) {
return url && arr.indexOf(url) === idx;
}).slice(0, 6);
var noticeEl = doc.createElement('div');
noticeEl.style.cssText = 'margin:8px 0;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.7;border:2px solid '+color+';background:linear-gradient(135deg,'+bgColor+',white);color:#333;position:relative;box-shadow:0 2px 12px rgba(0,0,0,0.08);animation:noticeSlideIn 0.5s ease;';
var styleTag = doc.createElement('style');
styleTag.textContent = '@keyframes noticeSlideIn{from{opacity:0;transform:translateY(-10px);}to{opacity:1;transform:translateY(0);}} @keyframes noticePulse{0%,100%{transform:scale(1);}50%{transform:scale(1.15);}}';
doc.head.appendChild(styleTag);
var header = doc.createElement('div');
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;gap:10px;';
var titleEl = doc.createElement('div');
titleEl.style.cssText = 'font-weight:700;font-size:14px;color:'+color+';display:flex;align-items:center;gap:6px;min-width:0;';
var iconEl = doc.createElement('span');
iconEl.style.cssText = 'animation:noticePulse 1.5s infinite;display:inline-block;';
iconEl.textContent = '📢';
var titleText = doc.createElement('span');
titleText.textContent = res.data.title || '公告';
titleEl.appendChild(iconEl);
titleEl.appendChild(titleText);
var closeEl = doc.createElement('span');
closeEl.style.cssText = 'cursor:pointer;font-size:18px;color:#999;line-height:1;padding:0 2px;';
closeEl.innerHTML = '×';
header.appendChild(titleEl);
header.appendChild(closeEl);
noticeEl.appendChild(header);
if (rawContent) {
var bodyEl = doc.createElement('div');
bodyEl.style.cssText = 'font-size:13px;color:#444;white-space:pre-wrap;word-break:break-word;';
bodyEl.textContent = rawContent;
noticeEl.appendChild(bodyEl);
}
if (noticeImages.length) {
var imageBox = doc.createElement('div');
imageBox.style.cssText = 'display:flex;flex-direction:column;gap:8px;margin-top:10px;';
noticeImages.forEach(function(url) {
var img = doc.createElement('img');
img.src = url;
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
img.style.cssText = 'max-width:100%;max-height:260px;object-fit:contain;border-radius:8px;border:1px solid rgba(0,0,0,0.08);background:#fff;cursor:pointer;';
img.onclick = function() { window.open(url, '_blank'); };
imageBox.appendChild(img);
});
noticeEl.appendChild(imageBox);
}
closeEl.onclick = function(){ noticeEl.style.display='none'; };
var infoEl = doc.getElementById('info');
if (infoEl && infoEl.parentNode) {
infoEl.parentNode.insertBefore(noticeEl, infoEl);
}
}
} catch(e) {}
}
});
} catch(e) {}
// 后面赋值给panel
return {
...ui,
log,
// 授权复核在后台完成后由 AccessGuard 调用,及时同步面板状态。
updateAIUnlockStatus,
setStartHandler(fn) {
ui.btnStart.onclick = async () => {
// 如果正在运行,点击停止
if (isRunning || activeRunPromise) {
if (!stopRequested) {
stopRequested = true;
log('⏸️ 正在停止...');
ui.btnStart.innerText = '停止中...';
} else {
log('⏳ 上一任务仍在停止收尾,请稍候...');
}
// 旧 runner 尚未退出前禁止再次启动;其 finally 会在退出后恢复按钮。
ui.btnStart.disabled = true;
return;
}
const startText = String(ui.btnStart.innerText || '');
const isAnswerStart = isYktExerciseLikePage() || startText.includes('答题');
if (!isAnswerStart && !Store.isStudyPurposeConfirmed()) {
ui.btnStart.disabled = true;
const allowed = await showStudyPurposeDialog();
ui.btnStart.disabled = false;
if (!allowed) {
log('已取消启动:本功能只能用于课程复习和学习回看');
return;
}
Store.setStudyPurposeConfirmed();
}
log('启动中...');
isRunning = true;
stopRequested = false;
// 检查是否是答题页面
if (isYktExerciseLikePage()) {
ui.btnStart.innerText = '⏸️ 停止答题';
} else {
ui.btnStart.innerText = '⏸️ 停止刷课';
}
ui.btnStart.disabled = false;
Store.setRunState('running');
startMiniStatusUpdate(panel);
// 必须等待整个 runner 链路退出,避免点击停止后旧任务仍在后台继续。
try {
await runManaged(fn, '手动任务');
} finally {
// runManaged 已完成最终清理;这里再兜底恢复按钮,防止启动阶段异常
// 导致界面永久保持禁用。
if (!activeRunPromise && !isRunning) ui.btnStart.disabled = false;
}
};
},
resetStartButton(text = '开始刷课') {
const stopWasRequested = stopRequested;
ui.btnStart.innerText = text;
ui.btnStart.disabled = false;
if (text.includes('刷完') || text.includes('完成')) {
Store.setRunState('completed');
} else if (text.includes('停止') || text.includes('继续')) {
Store.setRunState('stopped');
}
// runner 可能在 finally 前先调用 resetStartButton。此时不能释放运行锁,
// 也不能清掉 stopRequested,否则用户马上点击会启动第二个任务。
if (activeRunPromise) {
isRunning = true;
ui.btnStart.disabled = true;
stopRequested = stopWasRequested || stopRequested;
return;
}
isRunning = false;
// 没有活动任务时只保留已经请求的停止状态,下一次真正启动时再清除。
stopRequested = stopWasRequested;
},
updateMiniStatus(icon, text) {
if (ui.miniIcon) ui.miniIcon.innerText = icon;
if (ui.miniText) ui.miniText.innerText = text;
}
};
}
// ---- 播放器工具 ----
const Player = {
_normalizeRate(rate) {
const value = Number(rate);
return Number.isFinite(value) && value > 0 ? Math.min(16, Math.max(0.25, value)) : 2;
},
_documents(root = document) {
try {
return Utils.collectAccessibleDocuments(root || document);
} catch (_) {
return [root || document];
}
},
_setMediaRate(media, rate) {
if (!media) return false;
const value = this._normalizeRate(rate);
try { media.defaultPlaybackRate = value; } catch (_) { }
try { media.playbackRate = value; } catch (_) { }
return Number.isFinite(Number(media.playbackRate)) && Math.abs(Number(media.playbackRate) - value) < 0.01;
},
// 雨课堂播放器会在 loadedmetadata/play 后把倍速重置回 1x。为当前媒体
// 节点绑定一个轻量守护器,在这些时机及播放期间回写用户选择的倍速。
lockRate(media, rate = Config.playbackRate) {
if (!media) return () => { };
const value = this._normalizeRate(rate);
const previous = media.__yktRateGuard;
if (previous && previous.rate === value && typeof previous.dispose === 'function') {
this._setMediaRate(media, value);
return previous.dispose;
}
if (previous?.dispose) previous.dispose();
let disposed = false;
let timer = null;
const ensure = () => {
if (disposed) return;
if (media.isConnected === false) {
dispose();
return;
}
const current = Number(media.playbackRate);
if (!Number.isFinite(current) || Math.abs(current - value) > 0.01) this._setMediaRate(media, value);
};
const events = ['loadedmetadata', 'durationchange', 'canplay', 'play', 'playing', 'ratechange'];
events.forEach(type => media.addEventListener?.(type, ensure));
timer = setInterval(ensure, 600);
const dispose = () => {
if (disposed) return;
disposed = true;
if (timer) clearInterval(timer);
events.forEach(type => media.removeEventListener?.(type, ensure));
if (media.__yktRateGuard?.dispose === dispose) delete media.__yktRateGuard;
};
media.__yktRateGuard = { rate: value, dispose };
ensure();
return dispose;
},
applySpeed(root = document, media = null) {
const rate = this._normalizeRate(Config.playbackRate);
const docs = this._documents(root);
const medias = [];
if (media) medias.push(media);
for (const doc of docs) {
try { medias.push(...Array.from(doc.querySelectorAll('video, audio'))); } catch (_) { }
}
const guardDisposers = [];
[...new Set(medias)].forEach(node => {
this._setMediaRate(node, rate);
guardDisposers.push(this.lockRate(node, rate));
});
// 兼容旧版 xt 播放器:倍速选项只在控件菜单中存在时才触发,且每个
// iframe 都要分别处理,不能只查顶层 document。
for (const doc of docs) {
try {
const speedWrap = doc.getElementsByTagName('xt-speedbutton')[0];
const speedOptions = Array.from(doc.querySelectorAll('xt-speedlist xt-button'));
const speedBtn = speedOptions.find(option => {
const raw = option.getAttribute('data-speed') || option.getAttribute('keyt') || option.innerText || '';
const parsed = parseFloat(String(raw).replace(/x$/i, ''));
return Number.isFinite(parsed) && Math.abs(parsed - rate) < 0.01;
}) || speedOptions[0] ||
doc.getElementsByTagName('xt-speedlist')[0]?.firstElementChild?.firstElementChild;
if (!speedBtn || !speedWrap) continue;
speedBtn.setAttribute('data-speed', String(rate));
speedBtn.setAttribute('keyt', `${rate}.00`);
speedBtn.innerText = `${rate}.00X`;
const view = doc.defaultView || window;
speedWrap.dispatchEvent(new view.MouseEvent('mousemove', { bubbles: true, cancelable: true }));
speedBtn.click();
} catch (_) { }
}
return () => guardDisposers.forEach(dispose => dispose?.());
},
mute() {
const muteBtn = document.querySelector('#video-box > div > xt-wrap > xt-controls > xt-inner > xt-volumebutton > xt-icon');
if (muteBtn) muteBtn.click();
const video = document.querySelector('video');
if (video) video.volume = 0;
},
applyMediaDefault(media) {
if (!media) return;
// 平台偶尔会给媒体节点保留 loop;刷课场景必须以自然结束为终态。
media.loop = false;
media.volume = 0;
const disposeRate = this.applySpeed(media.ownerDocument || document, media);
if (this.isFinished(media)) {
disposeRate?.();
return disposeRate;
}
if (stopRequested) return disposeRate;
const p = media.play();
if (p !== undefined) {
p.catch(e => {
if (e.name !== 'AbortError') console.warn('[雨课堂助手] applyMediaDefault play失败:', e);
setTimeout(() => {
if (!stopRequested && !this.isFinished(media)) media.play().catch(() => {});
}, 1500);
});
}
return disposeRate;
},
isFinished(media, epsilon = 0.75) {
if (!media) return false;
if (media.ended) return true;
const duration = Number(media.duration);
const currentTime = Number(media.currentTime);
return Number.isFinite(duration) && duration > 0 &&
Number.isFinite(currentTime) && currentTime >= duration - epsilon;
},
observePause(video, opts = {}) {
if (!video) return () => { };
const shouldResume = typeof opts.shouldResume === 'function' ? opts.shouldResume : () => true;
const ownerDoc = video.ownerDocument || document;
const target = ownerDoc.getElementsByClassName('play-btn-tip')[0];
let disposed = false;
let terminal = this.isFinished(video);
let isPlayPending = false;
let replayTimer = null;
const onEnded = () => {
terminal = true;
isPlayPending = false;
if (replayTimer) clearTimeout(replayTimer);
replayTimer = null;
};
video.addEventListener('ended', onEnded);
const canResume = () => !disposed && !terminal && !this.isFinished(video) && shouldResume();
// 安全播放:防止重叠的 play() 调用导致 AbortError
const safePlay = (delay = 500) => {
if (isPlayPending || !canResume()) return;
isPlayPending = true;
replayTimer = setTimeout(() => {
replayTimer = null;
if (!canResume()) {
isPlayPending = false;
return;
}
if (video.paused) {
const p = video.play();
if (p !== undefined) {
p.then(() => { isPlayPending = false; })
.catch(e => {
isPlayPending = false;
if (e.name !== 'AbortError') {
console.warn('[雨课堂助手] 自动播放失败:', e);
}
if (canResume()) safePlay(1500);
});
} else {
isPlayPending = false;
}
} else {
isPlayPending = false;
}
}, delay);
};
if (!target) {
return () => {
disposed = true;
if (replayTimer) clearTimeout(replayTimer);
video.removeEventListener('ended', onEnded);
};
}
safePlay(1500);
const observer = new MutationObserver(list => {
for (const mutation of list) {
if (mutation.type === 'childList' && target.innerText === '播放' && canResume()) {
safePlay(800);
}
}
});
observer.observe(target, { childList: true });
return () => {
disposed = true;
if (replayTimer) clearTimeout(replayTimer);
replayTimer = null;
observer.disconnect();
video.removeEventListener('ended', onEnded);
};
},
/**
* 等待媒体真正结束。
* 返回 true 表示收到了 ended/播放头到达末尾,false 表示超时、停止或媒体无效。
* 旧调用只传 media/timeout 仍然兼容;默认给没有 timeout 的课堂媒体一个有界等待。
*/
waitForEnd(media, timeout = 0, opts = {}) {
const cancelOnStop = opts.cancelOnStop !== false;
if (!media) return Promise.resolve(false);
const duration = Number(media.duration);
const fallbackTimeout = Number.isFinite(duration) && duration > 0
? Math.max(duration * 3000, 10_000)
: 180_000;
const deadline = Date.now() + (Number(timeout) > 0 ? Number(timeout) : fallbackTimeout);
return new Promise(resolve => {
if (this.isFinished(media)) return resolve(true);
let timer = null;
let watcher = null;
let settled = false;
const finish = result => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (watcher) clearInterval(watcher);
try { media.removeEventListener?.('ended', onEnded); } catch (_) { }
resolve(result);
};
const onEnded = () => finish(true);
try { media.addEventListener?.('ended', onEnded, { once: true }); } catch (_) { }
timer = setTimeout(() => finish(false), Math.max(0, deadline - Date.now()));
watcher = setInterval(() => {
if (this.isFinished(media)) {
finish(true);
} else if (cancelOnStop && stopRequested) {
try { media.pause?.(); } catch (_) { }
finish(false);
} else if (Date.now() >= deadline) {
finish(false);
}
}, 250);
});
}
};
const CoursewareNavigator = {
getRoots() {
return Utils.collectAccessibleDocuments(document);
},
isDisabled(el) {
if (!el) return true;
const cls = String(el.className || '');
return !!(
el.disabled ||
el.getAttribute?.('disabled') !== null ||
el.getAttribute?.('aria-disabled') === 'true' ||
/\b(disabled|is-disabled|swiper-button-disabled)\b/i.test(cls)
);
},
isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
const win = el.ownerDocument?.defaultView || window;
const style = win.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
},
normalizeText(el) {
return (el?.innerText || el?.textContent || el?.getAttribute?.('aria-label') || el?.getAttribute?.('title') || '')
.replace(/\s+/g, '')
.trim();
},
getViewport(doc) {
const win = doc?.defaultView || window;
const root = doc?.documentElement || document.documentElement;
return {
width: win.innerWidth || root?.clientWidth || 1920,
height: win.innerHeight || root?.clientHeight || 1080
};
},
extractSlideNumber(el) {
const attrs = ['data-index', 'data-page', 'data-page-no', 'data-page-index', 'aria-label', 'title'];
const values = attrs.map(name => el?.getAttribute?.(name) || '').filter(Boolean);
values.push(this.normalizeText(el).slice(0, 60));
for (const raw of values) {
const text = String(raw || '').replace(/\s+/g, '');
let match = text.match(/^(?:第)?(\d{1,4})(?:页)?(?:$|[^\d])/);
if (!match) match = text.match(/(?:page|slide|ppt|页码|第)(\d{1,4})(?:页)?/i);
if (match) {
const n = Number(match[1]);
if (Number.isFinite(n) && n >= 0 && n <= 9999) return n;
}
}
return 0;
},
slideKey(el, fallback = 0) {
const n = this.extractSlideNumber(el);
if (n) return `n:${n}`;
const img = el?.querySelector?.('img,source');
const mediaSrc = img?.currentSrc || img?.src || img?.srcset || img?.getAttribute?.('src') || img?.getAttribute?.('srcset') || '';
if (mediaSrc) return `img:${mediaSrc.slice(0, 180)}`;
const style = (el?.getAttribute?.('style') || '') + ' ' +
(el?.querySelector?.('[style*="background-image"]')?.getAttribute?.('style') || '');
const bg = style.match(/background-image\s*:\s*[^;]+/i)?.[0] || '';
if (bg) return `bg:${bg.slice(0, 180)}`;
const text = this.normalizeText(el).slice(0, 80);
if (text) return `t:${text}`;
const rect = el.getBoundingClientRect();
return `r:${Math.round(rect.left)}:${Math.round(rect.top)}:${Math.round(rect.width)}:${Math.round(rect.height)}:${fallback}`;
},
toSlideClickTarget(node) {
if (!node?.closest) return node;
const selector = [
'li',
'button',
'a',
'[role="button"]',
'[tabindex]',
'[class*="thumb"]',
'[class*="thumbnail"]',
'[class*="preview"]',
'[class*="slide"]',
'[class*="page-item"]',
'[class*="pageList"]',
'[class*="outline"]',
'[class*="catalog"]'
].join(',');
const target = node.closest(selector);
if (!target || /^(HTML|BODY)$/i.test(target.tagName || '')) return node;
const rect = target.getBoundingClientRect();
const vp = this.getViewport(target.ownerDocument);
const tooLarge = rect.width > Math.max(520, vp.width * 0.5) || rect.height > Math.max(360, vp.height * 0.5);
return tooLarge ? node : target;
},
isLikelySlideNode(node, explicit = false) {
if (!node || !node.getBoundingClientRect || /^(HTML|BODY)$/i.test(node.tagName || '')) return false;
if (!this.isVisible(node)) return false;
const rect = node.getBoundingClientRect();
const vp = this.getViewport(node.ownerDocument);
if (rect.width < 16 || rect.height < 16) return false;
if (rect.width > Math.max(520, vp.width * 0.46) || rect.height > Math.max(320, vp.height * 0.42)) return false;
const cls = String(node.className || '');
const role = node.getAttribute?.('role') || '';
const tag = String(node.tagName || '').toUpperCase();
const strongClassSignal = /thumb|thumbnail|preview|ppt|page-list|page_item|page-item|slide-list|outline|catalog/i.test(cls);
const weakClassSignal = /slide|swiper-slide|page/i.test(cls);
const numberSignal = !!this.extractSlideNumber(node);
const hasMedia = !!(node.querySelector?.('img,canvas,video,svg,[style*="background-image"]') ||
/background-image/i.test(node.getAttribute?.('style') || ''));
const clickableSignal = ['LI', 'BUTTON', 'A'].includes(tag) || role === 'button' ||
node.getAttribute?.('tabindex') !== null || typeof node.onclick === 'function';
const leftPanel = rect.left >= 60 && rect.left <= Math.max(460, vp.width * 0.42) && rect.top >= 60;
const compact = rect.width <= Math.max(360, vp.width * 0.32) && rect.height <= Math.max(220, vp.height * 0.28);
const containerSignal = !!node.closest?.('[class*="thumb"],[class*="thumbnail"],[class*="preview"],[class*="outline"],[class*="catalog"],[class*="page-list"],[class*="slide-list"],[class*="ppt"]');
if (leftPanel && compact && (hasMedia || numberSignal || strongClassSignal || clickableSignal)) return true;
if (containerSignal && (hasMedia || numberSignal || strongClassSignal) && compact) return true;
if (explicit && strongClassSignal && (hasMedia || numberSignal || clickableSignal || compact)) return true;
if (explicit && weakClassSignal && compact && (hasMedia || numberSignal)) return true;
return false;
},
sortSlideNodes(nodes) {
const unique = [];
for (const node of nodes) {
if (!node || unique.includes(node)) continue;
if (unique.some(exist => exist.contains?.(node) && exist !== node)) continue;
const childIndex = unique.findIndex(exist => node.contains?.(exist) && exist !== node);
if (childIndex >= 0) unique[childIndex] = node;
else unique.push(node);
}
return unique.sort((a, b) => {
const na = this.extractSlideNumber(a);
const nb = this.extractSlideNumber(b);
if (na && nb && na !== nb) return na - nb;
const ra = a.getBoundingClientRect();
const rb = b.getBoundingClientRect();
return (ra.top - rb.top) || (ra.left - rb.left);
});
},
collectOutlineSlideNodes(doc) {
const nodes = [];
let containers = [];
try {
containers = Array.from(doc.querySelectorAll([
'aside',
'nav',
'section',
'ul',
'ol',
'div',
'[class*="outline"]',
'[class*="catalog"]',
'[class*="thumb"]',
'[class*="thumbnail"]',
'[class*="preview"]',
'[class*="slide-list"]',
'[class*="page-list"]',
'[class*="ppt"]'
].join(','))).slice(0, 1500);
} catch (_) {
return nodes;
}
const vp = this.getViewport(doc);
for (const container of containers) {
if (!this.isVisible(container)) continue;
const rect = container.getBoundingClientRect();
if (rect.width < 60 || rect.height < 60) continue;
if (rect.width > Math.max(480, vp.width * 0.5) || rect.left > Math.max(520, vp.width * 0.48)) continue;
const cls = String(container.className || '');
const text = this.normalizeText(container).slice(0, 120);
const classSignal = /outline|catalog|thumb|thumbnail|preview|slide-list|page-list|ppt/i.test(cls);
const textSignal = /大纲|目录|缩略图|幻灯片|共\d{1,4}页/i.test(text);
if (!classSignal && !textSignal) continue;
let children = [];
try {
children = Array.from(container.querySelectorAll('li,button,a,[role="button"],[tabindex],div[class],section[class]'));
} catch (_) { }
for (const child of children) {
const target = this.toSlideClickTarget(child);
if (this.isLikelySlideNode(target, false) && !nodes.includes(target)) nodes.push(target);
}
}
return this.sortSlideNodes(nodes);
},
findTotalPageHint(roots) {
let total = 0;
for (const doc of roots) {
const text = doc.body?.innerText || '';
const patterns = [
/学习进度\s*[::]?\s*\d{1,4}\s*[\//]\s*(\d{1,4})/,
/大纲\s*共\s*(\d{1,4})\s*页/,
/共\s*(\d{1,4})\s*页/,
/\d{1,4}\s*[\//]\s*(\d{1,4})/
];
for (const pattern of patterns) {
const match = text.match(pattern);
const n = Number(match?.[1] || 0);
if (Number.isFinite(n) && n > total && n <= 9999) total = n;
}
}
return total;
},
findSlideScroller(roots) {
const selector = [
'[class*="outline"]',
'[class*="catalog"]',
'[class*="thumb"]',
'[class*="thumbnail"]',
'[class*="preview"]',
'[class*="slide-list"]',
'[class*="page-list"]',
'[class*="ppt"]',
'aside',
'nav',
'ul',
'ol',
'section',
'div'
].join(',');
for (const doc of roots) {
let nodes = [];
try {
nodes = Array.from(doc.querySelectorAll(selector)).slice(0, 1500);
} catch (_) {
continue;
}
const vp = this.getViewport(doc);
for (const node of nodes) {
if (!this.isVisible(node)) continue;
if (node.scrollHeight <= node.clientHeight + 60) continue;
const rect = node.getBoundingClientRect();
if (rect.left > Math.max(520, vp.width * 0.48) || rect.width > Math.max(520, vp.width * 0.5)) continue;
const cls = String(node.className || '');
const text = this.normalizeText(node).slice(0, 120);
const signal = /outline|catalog|thumb|thumbnail|preview|slide-list|page-list|ppt/i.test(cls) ||
/大纲|目录|缩略图|幻灯片|共\d{1,4}页/i.test(text) ||
node.querySelector?.('img,canvas');
if (signal) return node;
}
}
return null;
},
scrollSlideList(roots) {
const scroller = this.findSlideScroller(roots);
if (!scroller) return false;
const maxTop = scroller.scrollHeight - scroller.clientHeight;
if (scroller.scrollTop >= maxTop - 6) return false;
const before = scroller.scrollTop;
scroller.scrollTop = Math.min(maxTop, before + Math.max(180, Math.floor(scroller.clientHeight * 0.82)));
return scroller.scrollTop !== before;
},
snapshot(roots) {
const parts = [];
for (const doc of roots) {
try {
const text = doc.body?.innerText || '';
const page = text.match(/(?:第\s*)?(\d{1,4})\s*(?:\/|/|页\/|页,共)\s*(\d{1,4})/);
const active = doc.querySelector([
'.swiper-slide-active',
'[class*="slide"][class*="active"]',
'[class*="page-item"][class*="active"]',
'[class*="thumbnail"][class*="active"]',
'[class*="thumb"][class*="active"]',
'[aria-current="page"]'
].join(','));
const activeMedia = active?.querySelector?.('img,source');
const wrapper = doc.querySelector('.swiper-wrapper,[class*="slide-track"],[class*="page-track"]');
const scrollable = this.findScrollableReader([doc]);
parts.push([
page ? `${page[1]}/${page[2]}` : '',
active ? [
active.getAttribute?.('data-index') || '',
active.getAttribute?.('data-page') || '',
active.getAttribute?.('aria-label') || '',
activeMedia?.currentSrc || activeMedia?.src || '',
this.normalizeText(active).slice(0, 80)
].join(':') : '',
wrapper?.getAttribute?.('style') || '',
scrollable ? `${scrollable.scrollTop}/${scrollable.scrollHeight}` : ''
].join('|'));
} catch (_) { }
}
return parts.join('::');
},
findSlideNodes(roots) {
const selectors = [
'.swiper-wrapper .swiper-slide',
'.swiper-wrapper > *',
'.ppt-outline li',
'.ppt-page-list li',
'.ppt-slide-list li',
'.slide-list li',
'.thumb-list li',
'.thumbnail-list li',
'[class*="outline"] li',
'[class*="catalog"] li',
'[class*="preview"] li',
'[class*="slide"] li',
'[class*="ppt"] li',
'[class*="thumbnail"] li',
'[class*="thumb"] li',
'[class*="page-list"] li',
'[class*="thumbnail"]',
'[class*="thumb"]',
'[aria-label*="页"]',
'[title*="页"]'
];
const nodes = [];
for (const doc of roots) {
for (const sel of selectors) {
try {
for (const node of Array.from(doc.querySelectorAll(sel))) {
const target = this.toSlideClickTarget(node);
if (this.isLikelySlideNode(target, true) && !nodes.includes(target)) nodes.push(target);
}
} catch (_) { }
}
for (const node of this.collectOutlineSlideNodes(doc)) {
if (!nodes.includes(node)) nodes.push(node);
}
}
return this.sortSlideNodes(nodes).slice(0, 300);
},
findViewerContainer(node) {
if (!node?.closest) return null;
return node.closest([
'.swiper-container',
'.swiper',
'.ppt-container',
'.ppt-viewer',
'.courseware-viewer',
'.document-viewer',
'.preview-container',
'[class*="ppt-viewer"]',
'[class*="courseware-viewer"]',
'[class*="slide-container"]',
'[class*="page-container"]',
'[class*="document-viewer"]'
].join(','));
},
findNextButton(roots) {
const selector = [
'.swiper-button-next',
'.next-page',
'.page-next',
'[data-action="next-page"]',
'[aria-label*="下一页"]',
'[title*="下一页"]',
'button',
'a',
'[role="button"]',
'.el-button',
'.ant-btn'
].join(',');
for (const doc of roots) {
let nodes = [];
try {
nodes = Array.from(doc.querySelectorAll(selector));
} catch (_) {
continue;
}
const visible = nodes.filter(node => this.isVisible(node) && !this.isDisabled(node));
const byText = visible.find(node => {
const text = this.normalizeText(node);
const cls = String(node.className || '');
const aria = (node.getAttribute?.('aria-label') || node.getAttribute?.('title') || '').replace(/\s+/g, '');
// .btn-next 在雨课堂外层表示“下一节课程”,绝不能作为 PPT 翻页按钮。
if (/(^|\s)btn-next(?:\s|$)/i.test(cls)) return false;
const viewer = this.findViewerContainer(node);
const explicitText = /^(下一页|下一张|下一屏|下页)(?:[((]|$)/.test(text) ||
/^(下一页|下一张|下一屏|下页)(?:[((]|$)/.test(aria) || /^(next)$/i.test(text || aria);
const pageClass = /swiper-button-next|next-page|page-next/i.test(cls);
return !!viewer && (explicitText || pageClass);
});
if (byText) return byText;
}
return null;
},
getPagePosition(roots) {
for (const doc of roots) {
try {
const next = this.findNextButton([doc]);
const viewer = this.findViewerContainer(next);
const text = viewer?.innerText || viewer?.textContent || doc.body?.innerText || '';
const patterns = [
/(?:第\s*)?(\d{1,4})\s*(?:\/|/|页\/|页,共)\s*(\d{1,4})/,
/当前\s*(\d{1,4})\s*页[^\d]+共\s*(\d{1,4})\s*页/,
/学习进度\s*[::]?\s*(\d{1,4})\s*[\//]\s*(\d{1,4})/
];
for (const pattern of patterns) {
const match = text.match(pattern);
const current = Number(match?.[1] || 0);
const total = Number(match?.[2] || 0);
if (current > 0 && total >= current && total <= 9999) return { current, total };
}
} catch (_) { }
}
return null;
},
isActiveSlide(slide, roots) {
if (!slide) return false;
const cls = String(slide.className || '');
if (/\b(active|is-active|current|swiper-slide-active)\b/i.test(cls)) return true;
if (slide.getAttribute?.('aria-current') === 'page') return true;
const page = this.extractSlideNumber(slide);
const position = this.getPagePosition(roots);
return !!(page && position && page === position.current);
},
findScrollableReader(roots) {
const selectors = [
'.pdfViewer',
'.pdf-viewer',
'.doc-viewer',
'.document-viewer',
'.scroll-container',
'.lms-graph-article',
'.content-wrap',
'.preview-container',
'.viewer-container',
'.ppt-container',
'.swiper-container',
'.el-dialog__body',
'.dialog-body',
'.content',
'main',
'body'
];
for (const doc of roots) {
for (const sel of selectors) {
let nodes = [];
try {
nodes = Array.from(doc.querySelectorAll(sel));
} catch (_) {
continue;
}
for (const node of nodes) {
if (!this.isVisible(node) && node.tagName !== 'BODY') continue;
if (node.tagName === 'BODY') {
const text = node.innerText || '';
const docSignal = node.querySelector('canvas,.page,.pdf-page,[class*="page"],[class*="pdf"],[class*="doc"],img') ||
/(?:第\s*)?\d{1,4}\s*(?:\/|/|页\/|页,共)\s*\d{1,4}/.test(text);
if (!docSignal) continue;
}
if (node.scrollHeight > node.clientHeight + 80) return node;
}
}
}
return null;
},
hasReader(roots = this.getRoots()) {
if (this.findSlideNodes(roots).length > 1) return true;
const reader = this.findScrollableReader(roots);
if (reader && reader.scrollHeight > reader.clientHeight * 1.4) return true;
if (this.findNextButton(roots)) return true;
return false;
},
async playEmbeddedMedia(panel) {
if (stopRequested) return false;
let roots = this.getRoots();
const videoBoxes = [];
for (const doc of roots) {
try {
videoBoxes.push(...Array.from(doc.querySelectorAll('.video-box,[class*="video-box"],[class*="videoBox"]'))
.filter(box => this.isVisible(box) && !/已完成/.test(box.innerText || '')));
} catch (_) { }
}
for (let i = 0; i < videoBoxes.length; i++) {
if (stopRequested) return false;
panel?.log?.(`课件内视频 ${i + 1}/${videoBoxes.length} 打开播放`);
if (!await Utils.safeClick(videoBoxes[i])) return false;
if (!await Utils.sleepCancellable(1200)) return false;
}
roots = this.getRoots();
const medias = [];
for (const doc of roots) {
try {
medias.push(...Array.from(doc.querySelectorAll('video,audio')));
} catch (_) { }
}
for (let i = 0; i < medias.length; i++) {
if (stopRequested) return false;
const media = medias[i];
if (!media || media.ended) continue;
panel?.log?.(`课件内媒体 ${i + 1}/${medias.length} 开始播放`);
const rateGuard = Player.applyMediaDefault(media);
let completed = false;
try {
const duration = Number(media.duration);
const timeout = Number.isFinite(duration) && duration > 0
? Math.max(duration * 3000, 10_000)
: 180_000;
completed = await Player.waitForEnd(media, timeout, { cancelOnStop: true });
} finally {
rateGuard?.();
}
if (!completed) {
panel?.log?.(`⚠️ 课件内媒体 ${i + 1} 未确认播放完成,已停留当前课件`);
return false;
}
if (!await Utils.sleepCancellable(Config.safety.actionDelayMin, { cancelOnStop: true })) return false;
}
if (stopRequested) return false;
return true;
},
async turnBySlides(panel, className, roots) {
const firstSlides = this.findSlideNodes(roots);
if (firstSlides.length <= 1) return false;
const totalHint = this.findTotalPageHint(roots);
const seen = new Set();
let visited = 0;
let emptyRounds = 0;
panel?.log?.(`开始翻阅课件:${className},共 ${totalHint || firstSlides.length} 页`);
for (let round = 0; round < 40; round++) {
if (stopRequested) return false;
roots = round === 0 ? roots : this.getRoots();
const slides = this.findSlideNodes(roots);
const freshSlides = [];
slides.forEach((slide, idx) => {
const key = this.slideKey(slide, idx);
if (seen.has(key)) return;
seen.add(key);
freshSlides.push(slide);
});
if (!freshSlides.length) {
if (this.scrollSlideList(roots)) {
emptyRounds++;
await Utils.humanSleep(600, 1200);
if (emptyRounds < 3) continue;
}
return totalHint ? visited >= totalHint : visited > 1 && visited >= seen.size;
}
emptyRounds = 0;
for (const slide of freshSlides) {
if (stopRequested) return false;
if (this.isActiveSlide(slide, roots)) {
await Utils.humanSleep(Config.pptInterval, Config.pptInterval + Config.safety.slideJitter);
visited++;
panel?.log?.(`${className}:第 ${visited}/${totalHint || Math.max(slides.length, visited)} 页`);
if (totalHint && visited >= totalHint) return true;
continue;
}
const before = this.snapshot(roots);
const clicked = await Utils.safeClick(slide, {
scroll: false,
beforeMin: Config.pptInterval,
beforeMax: Config.pptInterval + Config.safety.slideJitter,
afterMin: 500,
afterMax: 1300
});
if (!clicked) return false;
const changed = await Utils.poll(() => {
const freshRoots = this.getRoots();
const current = this.snapshot(freshRoots);
return (!!before && !!current && current !== before) || this.isActiveSlide(slide, freshRoots);
}, { interval: 250, timeout: 5000 });
if (!changed) {
panel?.log?.(`${className}:缩略页点击后画面未变化,停止以免误判完成`);
return false;
}
visited++;
panel?.log?.(`${className}:第 ${visited}/${totalHint || Math.max(slides.length, visited)} 页`);
if (totalHint && visited >= totalHint) return true;
}
roots = this.getRoots();
const scrolled = this.scrollSlideList(roots);
if (!scrolled && (!totalHint || visited >= slides.length)) {
return totalHint ? visited >= totalHint : visited > 1 && visited >= seen.size;
}
if (scrolled) await Utils.humanSleep(500, 1200);
}
return false;
},
async turnByNextButton(panel, className, roots) {
let turned = 0;
const initialPosition = this.getPagePosition(roots);
if (initialPosition && initialPosition.current >= initialPosition.total) return true;
for (let i = 0; i < 300; i++) {
if (stopRequested) return false;
roots = this.getRoots();
const nextBtn = this.findNextButton(roots);
if (!nextBtn) return false;
const before = this.snapshot(roots);
if (!before) {
panel?.log?.(`${className}:无法读取当前页状态,已停止以免误点课程“下一节”`);
return false;
}
const clicked = await Utils.safeClick(nextBtn, {
scroll: false,
beforeMin: Config.pptInterval,
beforeMax: Config.pptInterval + Config.safety.slideJitter,
afterMin: 700,
afterMax: 1600
});
if (!clicked) return false;
let cur = '';
const changed = await Utils.poll(() => {
const freshRoots = this.getRoots();
cur = this.snapshot(freshRoots);
return !!cur && cur !== before;
}, { interval: 250, timeout: 5000 });
if (!changed) {
panel?.log?.(`${className}:点击后页面未变化,停止以免误判完成`);
return false;
}
turned++;
panel?.log?.(`${className}:已翻到第 ${turned + 1} 页`);
const freshRoots = this.getRoots();
const position = this.getPagePosition(freshRoots);
if (position && position.current >= position.total) return true;
if (!this.findNextButton(freshRoots)) {
await Utils.sleep(800);
if (!this.findNextButton(this.getRoots())) return true;
}
}
return false;
},
async scrollReader(panel, className, roots, opts = {}) {
const reader = this.findScrollableReader(roots);
if (!reader) return false;
const cls = String(reader.className || '');
const dedicatedReader = /pdf|doc|document|viewer|preview|courseware|reader/i.test(cls);
if (reader.tagName === 'BODY' && !opts.allowBody) return false;
if (reader.tagName !== 'BODY' && !dedicatedReader && !opts.allowGeneric) return false;
let turned = 0;
let lastTop = -1;
panel?.log?.(`检测到文档阅读器,开始滚动阅读:${className}`);
for (let i = 0; i < 120; i++) {
if (stopRequested) return false;
const maxTop = reader.scrollHeight - reader.clientHeight;
if (reader.scrollTop >= maxTop - 8 || reader.scrollTop === lastTop) break;
lastTop = reader.scrollTop;
reader.scrollTop = Math.min(maxTop, reader.scrollTop + Math.max(240, Math.floor(reader.clientHeight * 0.82)));
turned++;
await Utils.humanSleep(Config.pptInterval, Config.pptInterval + Config.safety.slideJitter);
}
if (turned > 0) panel?.log?.(`${className}:文档滚动阅读完成`);
return turned > 0;
},
async run(panel, className = '课件', classType = '') {
await Utils.sleep(1200);
let roots = this.getRoots();
const typeHint = `${classType} ${className}`;
const readerLikely = /ppt|课件|文档|pdf|slide|doc|阅读|tuwen|图文/i.test(typeHint) || this.hasReader(roots);
if (!readerLikely) return false;
const hasSlideStructure = this.findSlideNodes(roots).length > 0 || !!this.findNextButton(roots);
const scrollDocumentLike = /pdf|doc|文档|阅读|tuwen|图文/i.test(typeHint) && !/ppt|slide|幻灯/i.test(typeHint);
let handled = false;
const slideHandled = await this.turnBySlides(panel, className, roots);
handled = slideHandled || handled;
if (!slideHandled) {
roots = this.getRoots();
handled = await this.turnByNextButton(panel, className, roots) || handled;
}
if (!handled && !hasSlideStructure && scrollDocumentLike) {
roots = this.getRoots();
handled = await this.scrollReader(panel, className, roots, {
allowBody: true,
allowGeneric: true
}) || handled;
}
if (handled) {
const mediaHandled = await this.playEmbeddedMedia(panel);
if (!mediaHandled) {
panel?.log?.('⚠️ 课件内媒体未播放完整,已停留当前课件');
return false;
}
panel?.log?.(`${className} 翻阅完成`);
}
return handled;
}
};
// ---- 防切屏 ----
let screenCheckPrevented = false;
function preventScreenCheck() {
if (screenCheckPrevented) return;
screenCheckPrevented = true;
const win = unsafeWindow || window;
const blackList = new Set(['visibilitychange', 'blur', 'pagehide']);
const winAddEventListener = typeof win.addEventListener === 'function' ? win.addEventListener : null;
const docAddEventListener = typeof document.addEventListener === 'function' ? document.addEventListener : null;
if (winAddEventListener) {
try {
win.addEventListener = function(...args) {
if (blackList.has(args[0])) return undefined;
return winAddEventListener.apply(win, args);
};
} catch (_) { }
}
if (docAddEventListener) {
try {
document.addEventListener = function(...args) {
if (blackList.has(args[0])) return undefined;
return docAddEventListener.apply(document, args);
};
} catch (_) { }
}
const defineSafely = (target, property, descriptor) => {
try { Object.defineProperty(target, property, descriptor); } catch (_) { }
};
defineSafely(document, 'hidden', { value: false, writable: true, configurable: true });
defineSafely(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
defineSafely(document, 'hasFocus', { value: () => true, writable: true, configurable: true });
defineSafely(document, 'onvisibilitychange', { get: () => undefined, set: () => { }, configurable: true });
defineSafely(document, 'onblur', { get: () => undefined, set: () => { }, configurable: true });
defineSafely(win, 'onblur', { get: () => undefined, set: () => { }, configurable: true });
defineSafely(win, 'onpagehide', { get: () => undefined, set: () => { }, configurable: true });
}
// ---- 字体反混淆 ----
let glyphHashMap = null; // SHA-1哈希 -> 原始Unicode码点
let fontCharMap = null; // 混淆字符 -> 原始字符
let glyphHashMapPromise = null; // 映射表加载Promise
let deobfuscationPromise = null; // 反混淆进行中的Promise
const parsedFontMaps = new Map(); // 字体 URL/ArrayBuffer -> 字符映射,不能跨字体复用
const registeredFontMaps = new WeakMap(); // Document -> Map
const isEncryptedFontName = name => /encrypt|decrypt|xuetangx|exam[_-]?(?:data[_-]?)?font/i.test(String(name || ''));
const normalizeFontFamily = value => String(value || '').trim().replace(/^["']|["']$/g, '').toLowerCase();
const isEncodedFontChar = char => /[\p{Unified_Ideograph}\uF900-\uFAFF\uE000-\uF8FF]/u.test(char);
// FontFace 构造函数拦截:捕获 new FontFace(family, ArrayBuffer) 形式注入的字体
// AI学习空间的混淆字体很可能从 JS 直接以 ArrayBuffer 注入而不走 @font-face / 网络请求
const capturedFontBuffers = []; // [{ family, buffer }]
function setupFontFaceHook() {
try {
const win = unsafeWindow;
const OriginalFontFace = win.FontFace;
if (!OriginalFontFace) { console.warn('[雨课堂助手] FontFace API 不可用'); return; }
const HookedFontFace = new Proxy(OriginalFontFace, {
construct(target, args) {
try {
const family = args[0];
const source = args[1];
if (Object.prototype.toString.call(source) === '[object ArrayBuffer]') {
capturedFontBuffers.push({ family, buffer: source, ownerDocument: document });
console.log('[雨课堂助手] 🎯 拦截 FontFace(ArrayBuffer):', family, 'bytes:', source.byteLength);
} else if (source && ArrayBuffer.isView(source)) {
const sliced = source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
capturedFontBuffers.push({ family, buffer: sliced, ownerDocument: document });
console.log('[雨课堂助手] 🎯 拦截 FontFace(TypedArray):', family, 'bytes:', source.byteLength);
} else if (typeof source === 'string') {
const url = fontURLsInSource(source, document.baseURI)[0];
if (url) capturedFontBuffers.push({ family, url, ownerDocument: document });
}
} catch (e) { console.warn('[雨课堂助手] FontFace hook 异常:', e); }
return Reflect.construct(target, args, target);
}
});
win.FontFace = HookedFontFace;
} catch (e) {
console.warn('[雨课堂助手] setupFontFaceHook 失败:', e);
}
}
function loadGlyphHashMap() {
glyphHashMapPromise = new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://qsy.iano.cn/original_glyph_to_uni_v2.json',
timeout: 15000,
onload: (res) => {
try {
glyphHashMap = JSON.parse(res.responseText);
console.log('[雨课堂助手] 字形映射表加载成功, 共', Object.keys(glyphHashMap).length, '个字形');
resolve(glyphHashMap);
} catch (e) {
console.error('[雨课堂助手] 解析字形映射表失败:', e);
reject(e);
}
},
onerror: (err) => {
console.error('[雨课堂助手] 加载字形映射表失败:', err);
reject(err);
},
ontimeout: () => reject(new Error('字形映射表加载超时')),
});
});
return glyphHashMapPromise;
}
async function sha1Hex(str) {
const data = new TextEncoder().encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function buildFontCharMapFromBuffer(buffer, label) {
if (parsedFontMaps.has(buffer)) return parsedFontMaps.get(buffer);
if (!glyphHashMap) {
console.warn('[雨课堂助手] 字形映射表未加载,跳过反混淆');
return null;
}
try {
// unsafeWindow 捕获的 ArrayBuffer 可能来自另一个 realm,复制为本 realm。
const font = opentype.parse(new Uint8Array(buffer).slice().buffer);
const charMap = {};
let matchCount = 0;
let glyphsUsable = 0;
for (let i = 0; i < font.glyphs.length; i++) {
const glyph = font.glyphs.get(i);
const unicodes = [...new Set([glyph.unicode, ...(glyph.unicodes || [])])]
.filter(code => Number.isInteger(code) && code >= 0 && code <= 0x10ffff);
if (!unicodes.length || !glyph.path?.commands?.length) continue;
glyphsUsable++;
// v2 表使用 SVG path;兼容参考文章的 commands 键值拼接格式。
const serializations = [glyph.path.toPathData?.(),
glyph.path.commands.map(command => Object.entries(command).map(([key, value]) => key + value).join('')).join(''),
glyph.path.commands.map(command => Object.keys(command).sort().map(key => key + command[key]).join('')).join('')];
let originalUnicode;
for (const serialized of new Set(serializations.filter(Boolean))) {
const hash = await sha1Hex(serialized);
const code = Number(glyphHashMap[hash]);
if (Number.isInteger(code) && code >= 0 && code <= 0x10ffff) { originalUnicode = code; break; }
}
if (originalUnicode !== undefined) {
for (const unicode of unicodes) charMap[String.fromCodePoint(unicode)] = String.fromCodePoint(originalUnicode).normalize('NFKC');
matchCount += unicodes.filter(unicode => unicode !== originalUnicode).length;
}
}
console.log('[雨课堂助手] 字体反混淆映射建立完成:', matchCount, '个字符需要还原 (总字形:', font.glyphs.length, '有unicode+path:', glyphsUsable, label || '', ')');
// 原样字形也保留,才能验证每个被加密的汉字确实已匹配。
parsedFontMaps.set(buffer, charMap);
return charMap;
} catch (e) {
console.error('[雨课堂助手] 解析字体失败:', label, e);
return null;
}
}
async function buildFontCharMap(fontUrl) {
if (parsedFontMaps.has(fontUrl)) return parsedFontMaps.get(fontUrl);
if (!glyphHashMap) {
console.warn('[雨课堂助手] 字形映射表未加载,跳过反混淆');
return {};
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: fontUrl,
responseType: 'arraybuffer',
timeout: 15000,
onload: async (res) => {
try {
const charMap = await buildFontCharMapFromBuffer(res.response, 'URL: ' + fontUrl);
if (charMap === null) { reject(new Error('font parse failed')); return; }
parsedFontMaps.set(fontUrl, charMap);
resolve(charMap);
} catch (e) {
console.error('[雨课堂助手] 解析混淆字体失败:', e);
reject(e);
}
},
onerror: (err) => {
console.error('[雨课堂助手] 下载混淆字体失败:', err);
reject(err);
},
ontimeout: () => reject(new Error('字体下载超时')),
});
});
}
function registerFontMap(ownerDocument, family, source, map) {
let records = registeredFontMaps.get(ownerDocument);
if (!records) { records = new Map(); registeredFontMaps.set(ownerDocument, records); }
family = normalizeFontFamily(family);
records.set(family, { source, map, sources: getCurrentFontSources({ ownerDocument }, family) });
}
function getElementFontFamilies(element) {
if (!element) return [];
const ownerWindow = element.ownerDocument?.defaultView || window;
try {
return String(ownerWindow.getComputedStyle(element).fontFamily || '')
.split(',').map(normalizeFontFamily).filter(Boolean);
} catch (_) { return []; }
}
function getEncryptedElementFamily(element) {
const families = getElementFontFamilies(element);
const encrypted = families.find(isEncryptedFontName);
if (encrypted) return encrypted;
// detached JSON HTML 无 computed style,仍需识别自身/祖先的内联字体。
for (let node = element; node; node = node.parentElement) {
const inline = String(node.style?.fontFamily || '').split(',').map(normalizeFontFamily).filter(Boolean);
if (inline.length) {
const family = inline.find(isEncryptedFontName);
return family || ''; // 内层显式普通字体覆盖外层加密字体,不能再次解码。
}
if (node.matches?.('.xuetangx-com-encrypted-font,[class*="encrypt-font"],[class*="decrypt-font"]')) {
return families[0] || 'exam-data-decrypt-font';
}
}
return '';
}
function isFontTextVisible(element) {
const ownerWindow = element?.ownerDocument?.defaultView || window;
for (let node = element; node; node = node.parentElement) {
if (node.hidden || /^(?:SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(node.tagName)) return false;
try {
const style = ownerWindow.getComputedStyle(node);
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return false;
} catch (_) {}
}
return true;
}
function getEncryptedFontElements(element) {
if (!element) return [];
const ownerDocument = element.ownerDocument || document;
const walker = ownerDocument.createTreeWalker(element, 4);
const result = new Set();
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (Array.from(node.nodeValue || '').some(isEncodedFontChar) && isFontTextVisible(node.parentElement) && getEncryptedElementFamily(node.parentElement)) result.add(node.parentElement);
}
return [...result];
}
function resolveFontURL(url, base = document.baseURI) {
try {
const target = new URL(String(url || '').trim(), base);
return /^(?:https?:|blob:|data:)$/.test(target.protocol) ? target.href : '';
} catch (_) { return ''; }
}
function fontURLsInSource(src, base) {
const result = [];
const pattern = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/gi;
for (const match of String(src || '').matchAll(pattern)) {
const url = resolveFontURL(match[1] || match[2] || match[3], base);
if (url) result.push(url);
}
return result;
}
function getFontFaceSources(ownerDocument = document) {
const result = [];
const visit = (rules, base) => {
for (const rule of Array.from(rules || [])) {
if (rule.type === 5) {
const family = normalizeFontFamily(rule.style?.getPropertyValue?.('font-family') || '');
const urls = fontURLsInSource(rule.style?.getPropertyValue?.('src'), base);
for (const source of urls) result.push({ family, source, face: rule });
}
try { if (rule.cssRules) visit(rule.cssRules, base); } catch (_) {}
try { if (rule.styleSheet) visit(rule.styleSheet.cssRules, rule.styleSheet.href || base); } catch (_) {}
}
};
for (const sheet of Array.from(ownerDocument.styleSheets || [])) {
try { visit(sheet.cssRules || sheet.rules, sheet.href || ownerDocument.baseURI); } catch (_) {}
}
return result;
}
function discoverEncryptedFontUrls(ownerDocument = document) {
return [...new Set(getFontFaceSources(ownerDocument).filter(item => isEncryptedFontName(item.family)).map(item => item.source))];
}
function findFontUrlsForElement(element) {
if (!element) return [];
const families = getElementFontFamilies(element);
return [...new Set(getFontFaceSources(element.ownerDocument || document)
.filter(item => families.includes(item.family)).map(item => item.source))];
}
function discoverFontUrlsFromPerformance() {
try {
return [...new Set(performance.getEntriesByType('resource').map(entry => entry.name)
.filter(url => /\.(woff2?|ttf|otf)(?:[?#]|$)/i.test(url)))];
} catch (_) { return []; }
}
function getCurrentFontSources(element, family) {
const ownerDocument = element.ownerDocument || document;
const declarations = getFontFaceSources(ownerDocument).filter(item => item.family === family);
// 同名 @font-face 被新试卷重新声明时,优先最新规则;保留该规则内的格式回退顺序。
const activeFace = declarations[declarations.length - 1]?.face;
const sources = declarations.filter(item => item.face === activeFace).map(item => item.source);
// 同族名字可能被下一份试卷复用,只使用最新捕获的 FontFace。
const captured = [...capturedFontBuffers].reverse().find(item => item.ownerDocument === ownerDocument && normalizeFontFamily(item.family) === family);
if (captured) return [captured.buffer || captured.url];
return [...new Set(sources.filter(Boolean))];
}
function getFontMapForElement(element) {
const family = getEncryptedElementFamily(element);
const record = registeredFontMaps.get(element?.ownerDocument)?.get(family);
if (!record) return null;
const sources = getCurrentFontSources(element, family);
// 一旦 CSS/FontFace 已指向新字体,旧映射即使包含相同汉字也不能复用。
if (sources.length !== record.sources.length || sources.some((source, index) => source !== record.sources[index])) return null;
if (sources.length && !sources.includes(record.source)) return null;
return record.map;
}
function hasUnresolvedEncryptedText(element) {
return getEncryptedFontElements(element).some(node => {
const map = getFontMapForElement(node);
const directText = Array.from(node.childNodes).filter(child => child.nodeType === 3).map(child => child.nodeValue).join('');
return Array.from(directText).some(char => isEncodedFontChar(char) && (!map || !Object.hasOwn(map, char)));
});
}
function readDecodedElementText(element) {
if (!element || !isFontTextVisible(element)) return '';
if (!getEncryptedFontElements(element).length && typeof element.innerText === 'string') return element.innerText;
const read = node => {
if (node.nodeType === 3) {
const map = getFontMapForElement(node.parentElement);
return Array.from(node.nodeValue || '').map(char => map?.[char] ?? char).join('');
}
if (node.nodeType !== 1 || !isFontTextVisible(node)) return '';
if (node.tagName === 'BR') return '\n';
const text = Array.from(node.childNodes).map(read).join('');
return /^(?:DIV|P|LI|UL|OL|H[1-6]|SECTION|ARTICLE|TR|LABEL)$/.test(node.tagName) ? '\n' + text + '\n' : text;
};
return read(element);
}
// 每道题按实际字体源检查映射和覆盖,不能把任意一个非空全局映射当作解码成功。
async function ensureFontCharMap(hintElement) {
if (!hintElement) return null;
if (glyphHashMapPromise && !glyphHashMap) {
try { await glyphHashMapPromise; } catch (_) { return null; }
}
if (!glyphHashMap) return null;
const ownerDocument = hintElement.ownerDocument || document;
try { await ownerDocument.fonts?.ready; } catch (_) {}
const elements = getEncryptedFontElements(hintElement);
if (!elements.length && getEncryptedElementFamily(hintElement)) elements.push(hintElement);
const attempted = new Set();
for (const element of elements) {
const family = getEncryptedElementFamily(element);
if (!family || attempted.has(family)) continue;
attempted.add(family);
const currentMap = getFontMapForElement(element);
if (currentMap) continue;
for (const source of getCurrentFontSources(element, family)) {
try {
const map = typeof source === 'string' ? await buildFontCharMap(source) : await buildFontCharMapFromBuffer(source, family);
if (!map || !Object.keys(map).length) continue;
registerFontMap(ownerDocument, family, source, map);
fontCharMap = map; // 仅供带 HTML 加密标记的旧 JSON 接口使用。
break;
} catch (error) { console.warn('[雨课堂助手] 字体下载/解析失败:', family, error?.message || error); }
}
}
return getFontMapForElement(elements[0] || hintElement);
}
// 仅对 ... 内的字符做还原
// 参考万能脚本 getEncryptString 的思路,避免整段替换误伤题干中与混淆字符
// 重码的普通汉字(CJK 码点巧合时会出现)
function deobfuscateHTML(html, charMap) {
const map = charMap === undefined ? fontCharMap : charMap;
if (!map || !html) return html;
const container = document.createElement('div');
container.innerHTML = String(html);
const walker = document.createTreeWalker(container, 4);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (getEncryptedElementFamily(node.parentElement)) {
node.nodeValue = Array.from(node.nodeValue || '').map(char => map[char] ?? char).join('');
}
}
return container.innerHTML;
}
function hasUnresolvedEncryptedHTML(html, map) {
const container = document.createElement('div');
container.innerHTML = String(html || '');
return getEncryptedFontElements(container).some(node =>
Array.from(node.childNodes).filter(child => child.nodeType === 3).some(child =>
Array.from(child.nodeValue || '').some(char => isEncodedFontChar(char) && (!map || !Object.hasOwn(map, char)))));
}
// ---- JSON Hook:拦截雨课堂服务器返回的题目数据 ----
let interceptedProblems = null; // 存储拦截到的题目数据
function setupJSONParseHook() {
const originalParse = JSON.parse;
JSON.parse = function (...args) {
const result = originalParse.call(this, ...args);
try {
if (result && result.data && result.data.problems && result.data.problems.length > 0) {
const fontUrl = result.data.font;
if (fontUrl) {
deobfuscationPromise = (glyphHashMapPromise || Promise.resolve()).then(() => {
if (!glyphHashMap) return null;
return buildFontCharMap(fontUrl);
}).then(charMap => {
interceptedProblems = parseYktProblems(result.data.problems, charMap);
console.log('[雨课堂助手] 拦截到题目数据(已反混淆):', interceptedProblems.length, '题');
}).catch(err => {
console.warn('[雨课堂助手] 字体反混淆失败,保留未解码标记供 OCR 处理:', err);
interceptedProblems = parseYktProblems(result.data.problems, null);
});
} else {
interceptedProblems = parseYktProblems(result.data.problems, null);
console.log('[雨课堂助手] 拦截到题目数据:', interceptedProblems.length, '题');
}
}
} catch (e) {
console.warn('[雨课堂助手] JSON Hook 解析异常:', e);
}
return result;
};
}
// 解析雨课堂题目数据(参考官方脚本的 parseYkt)
function parseYktProblems(problems, charMap) {
// 仅对 内字符反混淆,避免误伤普通汉字
const deobfuscate = (text) => deobfuscateHTML(text, charMap);
// 调试:打印第一道题的原始JSON结构
if (problems.length > 0) {
const first = problems[0];
console.log('[雨课堂助手] 原始问题结构:', JSON.stringify({
hasBody: !!first.Body,
hasContentBody: !!(first.content && first.content.Body),
bodyPreview: first.Body ? String(first.Body).substring(0, 80) : null,
contentBodyPreview: first.content && first.content.Body ? String(first.content.Body).substring(0, 80) : null,
hasTypeText: !!first.TypeText,
hasContentTypeText: !!(first.content && first.content.TypeText),
typeText: first.TypeText || (first.content && first.content.TypeText) || '',
hasOptions: !!first.Options,
hasContentOptions: !!(first.content && first.content.Options),
optionsCount: (first.Options || (first.content && first.content.Options) || []).length,
problemId: first.problem_id
}));
}
return problems.map(item => {
// 与万能脚本对齐:优先使用 item.Body(考试格式),
// 仅在不存在时回退到 item.content.Body(作业格式)
const body = item.Body || (item.content ? item.content.Body : '') || '';
const typeText = item.TypeText || (item.content ? item.content.TypeText : '') || '';
const type = getQuestionType(typeText);
const question = formatString(deobfuscate(body));
const optionsData = item.Options || (item.content ? item.content.Options : null);
const encryptedUnresolved = [body, ...(Array.isArray(optionsData) ? optionsData.map(opt => opt.value || '') : [])]
.some(html => hasUnresolvedEncryptedHTML(html, charMap));
let options = [];
if (type <= 1 && optionsData && optionsData.length > 0) {
options = optionsData
.slice()
.sort((a, b) => (a.key || '').charCodeAt(0) - (b.key || '').charCodeAt(0))
.map(opt => {
let val = formatString(deobfuscate(opt.value || ''));
// 去掉选项值前面的字母前缀(如 "A xxx" → "xxx"),与万能脚本一致
return typeof val === 'string' ? val.replace(/^[A-G]\s/, '') : val;
});
} else if (type === 3) {
// 判断题:新版接口常把选项 key 直接写成 true/false,保留接口顺序。
const judgeOptions = Array.isArray(optionsData) ? optionsData.map(opt => {
const key = String(opt?.key ?? '').trim().toLowerCase();
if (/^(true|yes|1)$/.test(key)) return '正确';
if (/^(false|no|0)$/.test(key)) return '错误';
return '';
}).filter(Boolean) : [];
options = judgeOptions.length === 2 && new Set(judgeOptions).size === 2
? judgeOptions
: ['正确', '错误'];
}
// 提取已有答案(如果有的话,用于结果页面)
let answer = [];
const answerData = item.Answer || (item.content ? item.content.Answer : null);
if (answerData) {
if (Array.isArray(answerData)) {
answer = answerData.map(value => String(value));
} else if (typeof answerData === 'string') {
answer = type === 3 ? [answerData] : answerData.split('');
}
}
// 处理用户已答的答案
if (item.user && item.user.is_show_answer && item.user.answer) {
if (type === 3) {
const userAnswers = Array.isArray(item.user.answer) ? item.user.answer : [item.user.answer];
answer = userAnswers.map(a => String(a).replace(/true/gi, '正确').replace(/false/gi, '错误'));
} else {
answer = Array.isArray(item.user.answer) ? item.user.answer : [item.user.answer];
}
}
return {
qid: item.problem_id || '',
question,
options,
type,
answer,
encryptedUnresolved,
};
}).filter(i => i.question);
}
// 题目类型映射(参考官方脚本)
function getQuestionType(str) {
if (!str) return 4;
str = str.trim().replace(/\s+/g, '');
const TYPE = {
'单选题': 0, '单选': 0, '单项选择题': 0, '单项选择': 0,
'多选题': 1, '多选': 1, '多项选择题': 1, '多项选择': 1, '案例分析': 1,
'填空题': 2, '填空': 2,
'判断题': 3, '判断': 3, '对错题': 3, '判断正误': 3,
'问答题': 4, '简答题': 4, '主观题': 4, '其它': 4
};
if (TYPE[str] !== undefined) return TYPE[str];
for (const key of Object.keys(TYPE)) {
if (str.includes(key)) return TYPE[key];
}
return 4;
}
// 格式化字符串(参考官方脚本的 formatString)
function formatString(src) {
if (!src) return '';
src = String(src);
// 去除所有HTML标签,提取纯文本(含img的图片题也提取文字部分用于匹配)
const temp = document.createElement('div');
temp.innerHTML = src;
src = temp.innerText || temp.textContent || '';
// 全角转半角
src = src.replace(/[\uff01-\uff5e]/g, ch =>
String.fromCharCode(ch.charCodeAt(0) - 65248)
);
return src.replace(/\s+/g, ' ')
.replace(/[""]/g, '"')
.replace(/['']/g, "'")
.replace(/。/g, '.')
.replace(/[,.?:!;]$/, '')
.trim();
}
// 字符串相似度计算(编辑距离,参考官方脚本)
function stringSimilarity(s, t) {
if (!s || !t) return 0;
if (s === t) return 100;
const l = Math.max(s.length, t.length);
const n = s.length, m = t.length;
const d = [];
for (let i = 0; i <= n; i++) { d[i] = []; d[i][0] = i; }
for (let j = 0; j <= m; j++) { d[0][j] = j; }
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
const cost = s.charAt(i - 1) === t.charAt(j - 1) ? 0 : 1;
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
}
}
return (1 - d[n][m] / l) * 100;
}
// ---- OCR & 题库API ----
const Solver = {
isAiWorkspaceExercisePage() {
// 长江雨课堂等 AI 学习空间路径,含 /ai-workspace/ 即可(兼容 lms-graph、exercise 等子路由)
return isRainClassroomHost() &&
location.pathname.includes('/ai-workspace/');
},
isStudentQuizPage() {
return isYktStudentQuizPage();
},
isStructurallyVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
const ownerWindow = el.ownerDocument?.defaultView || window;
const style = ownerWindow.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
},
isVisibleElement(el) {
if (!this.isStructurallyVisible(el)) return false;
const rect = el.getBoundingClientRect();
const ownerWindow = el.ownerDocument?.defaultView || window;
if (rect.bottom < 0 || rect.right < 0 || rect.top > ownerWindow.innerHeight || rect.left > ownerWindow.innerWidth) return false;
return true;
},
isStudentQuizNoiseLine(line) {
const t = String(line || '').replace(/\s+/g, '').trim();
return !t ||
/^(展开|收起|上一题|下一题|提交|交卷|确认|取消|保存|已完成|未完成|答题卡|题卡)$/.test(t) ||
/^答题卡[\((]?\d+\s*[\//]\s*\d+[\))]?$/.test(t) ||
/^试卷不限时/.test(t) ||
/^\d{1,2}:\d{2}(?::\d{2})?$/.test(t) ||
/^第?\d+题$/.test(t) ||
/^\d+(?:\.\d+)?分$/.test(t);
},
getQuestionTypePattern() {
return /^\s*(?:(?:第?\s*\d+\s*题?|\d+\s*[\//]\s*\d+)\s*[.、.]?\s*)?[【\[\((]?(单选题|多选题|判断题|填空题|单项选择题|多项选择题|不定项选择题)[】\]\))]?\s*(?:第\s*\d+\s*题)?\s*(?:\d+(?:\.\d+)?\s*分)?/;
},
getAiWorkspaceLines(element) {
if (!element) return [];
const pseudoParts = [];
const pseudoTargets = [element, ...Array.from(element.querySelectorAll?.('.item-type') || [])];
const ownerWindow = (element.ownerDocument && element.ownerDocument.defaultView) || window;
for (const target of pseudoTargets) {
if (!target || !this.isStructurallyVisible(target)) continue;
for (const pseudo of ['::before', '::after']) {
const content = ownerWindow.getComputedStyle(target, pseudo).content;
if (!content || content === 'none' || content === 'normal') continue;
const text = content.replace(/^["']|["']$/g, '').replace(/\\A/g, '\n').trim();
if (text) pseudoParts.push(text);
}
}
const raw = [...pseudoParts, readDecodedElementText(element)].join('\n');
return raw.split(/\n+/)
.map(line => formatString(line).replace(/\s+/g, ' ').trim())
.filter(Boolean);
},
isAiWorkspaceNoiseLine(line) {
return /^(展开|收起|上一题|下一题|提交|确认|取消|已完成|未完成|知识点|作业)$/.test(line) ||
/^\d+$/.test(line) ||
/^完成度/.test(line) ||
/^搜索/.test(line);
},
cleanAiWorkspaceOptionText(text) {
return formatString(String(text || ''))
.replace(/\s+/g, ' ')
.replace(/^([A-Ga-g])\s*[.、.::))]?\s*/, '')
.trim();
},
// 雨课堂判断题的选项可能只有图标,语义由 radio value 或 SVG symbol 标识。
getJudgeOptionValue(node) {
if (!node) return '';
const input = node.matches?.('input[type="radio"], input[type="checkbox"]')
? node
: node.querySelector?.('input[type="radio"], input[type="checkbox"]');
const inputValue = String(input?.value ?? input?.getAttribute?.('value') ?? '').trim().toLowerCase();
if (inputValue === 'true' || inputValue === 'false') return inputValue;
const symbols = Array.from(node.querySelectorAll?.('use') || []);
for (const symbol of symbols) {
const href = String(
symbol.getAttribute?.('xlink:href') || symbol.getAttribute?.('href') || ''
).toLowerCase();
if (/tiankongtizhengque|zhengque|correct|right|true|yes/.test(href)) return 'true';
if (/tiankongticuowu|cuowu|wrong|false|error|no/.test(href)) return 'false';
}
// 旧版页面可能只渲染文字,没有 value 或 SVG 语义。
const labels = [
node.innerText || node.textContent || '',
node.getAttribute?.('aria-label') || '',
node.getAttribute?.('title') || ''
];
for (const raw of labels) {
const text = String(raw).replace(/\s+/g, '')
.replace(/^[A-G][.、.::))]/i, '').trim();
if (/^(正确|对|true|yes|√|t)$/i.test(text)) return 'true';
if (/^(错误|错|false|no|×|✗|f)$/i.test(text)) return 'false';
}
return '';
},
getJudgeOptionText(node) {
if (!node) return '';
const value = this.getJudgeOptionValue(node);
if (value === 'true') return '正确';
if (value === 'false') return '错误';
return this.cleanAiWorkspaceOptionText(readDecodedElementText(node));
},
parseAiWorkspaceOptionsFromLines(lines) {
const options = [];
const typePattern = /^\s*(?:(?:第?\s*\d+\s*题?|\d+\s*[\//]\s*\d+)\s*[.、.]?\s*)?[【\[\((]?(单选题|多选题|判断题|填空题|单项选择题|多项选择题|不定项选择题)[】\]\))]?/;
const optionPattern = /^([A-G])(?:\s*[.、.::))]|\s+)(.+)$/;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (this.isAiWorkspaceNoiseLine(line) || typePattern.test(line)) continue;
const inline = line.match(optionPattern);
if (inline) {
const text = this.cleanAiWorkspaceOptionText(inline[2]);
if (text) options.push(text);
continue;
}
if (/^[A-G]$/.test(line)) {
const parts = [];
let j = i + 1;
for (; j < lines.length; j++) {
const next = lines[j];
if (/^[A-G]$/.test(next) || optionPattern.test(next) || typePattern.test(next) || this.isAiWorkspaceNoiseLine(next)) break;
parts.push(next);
}
const text = this.cleanAiWorkspaceOptionText(parts.join(' '));
if (text) options.push(text);
i = j - 1;
}
}
return [...new Set(options)];
},
extractAiWorkspaceOptionsFromLines(lines) {
const parsed = this.parseAiWorkspaceOptionsFromLines(lines);
if (parsed.length >= 2) return parsed;
const typePattern = /^\s*(?:(?:第?\s*\d+\s*题?|\d+\s*[\//]\s*\d+)\s*[.、.]?\s*)?[【\[\((]?(单选题|多选题|判断题|填空题|单项选择题|多项选择题|不定项选择题)[】\]\))]?/;
const filtered = lines.filter(line => !this.isAiWorkspaceNoiseLine(line));
const typeIndex = filtered.findIndex(line => typePattern.test(line));
if (typeIndex < 0) return parsed;
const afterType = filtered.slice(typeIndex + 1).filter(line => !typePattern.test(line));
if (afterType.length < 3) return parsed;
let questionEndIndex = afterType.findIndex(line => /[??]|\(\)|()|[::]$/.test(line));
if (questionEndIndex < 0) questionEndIndex = 0;
const options = afterType.slice(questionEndIndex + 1)
.map(line => this.cleanAiWorkspaceOptionText(line))
.filter(line => line && line.length <= 200)
.slice(0, 7);
return options.length >= 2 ? [...new Set(options)] : parsed;
},
findAiWorkspaceOptionNodes(element) {
const root = element || this.findAiWorkspaceQuestionArea(document);
if (!root) return [];
const addUnique = (arr, node) => {
if (!node) return;
// 同一选项通常同时匹配 li 和内部 label,统一提升到 li 避免重复计数/错位点击。
const row = node.closest?.('li') || node;
if (arr.some(item => item === row)) return;
arr.push(row);
};
const nodes = [];
const preciseNodes = Array.from(root.querySelectorAll('ul.list-unstyled-radio > li, ul.list-unstyled-checkbox > li, .list-unstyled-radio li, .list-unstyled-checkbox li, label.homeworkElRadio, label.homeworkElCheckbox'));
for (const node of preciseNodes) {
if (!this.isVisibleElement(node)) continue;
const label = node.querySelector?.('.el-radio__label, .el-checkbox__label') || node;
const text = this.getJudgeOptionText(label);
const judgeValue = this.getJudgeOptionValue(node);
if ((text && text.length <= 300) || judgeValue) addUnique(nodes, node);
}
if (nodes.length >= 2) return nodes;
const candidates = Array.from(root.querySelectorAll('label,[role="radio"],[role="checkbox"],.ant-radio-wrapper,.ant-checkbox-wrapper,.el-radio,.el-checkbox,[class*="option"],[class*="Option"],[class*="answer"],[class*="Answer"]'));
for (const node of candidates) {
if (!this.isVisibleElement(node)) continue;
const text = (node.innerText || node.textContent || '').trim();
if (!text || text.length > 300) continue;
const parsed = this.parseAiWorkspaceOptionsFromLines(this.getAiWorkspaceLines(node));
if (parsed.length === 1) addUnique(nodes, node);
}
if (nodes.length >= 2) return nodes;
const letterNodes = Array.from(root.querySelectorAll('span,div,b,i')).filter(node => {
const text = (node.innerText || node.textContent || '').trim();
return this.isVisibleElement(node) && /^[A-G]$/.test(text);
});
for (const letterNode of letterNodes) {
let cur = letterNode;
let selected = null;
for (let depth = 0; cur && cur !== root.parentElement && depth < 6; depth++, cur = cur.parentElement) {
const text = (cur.innerText || cur.textContent || '').trim();
if (!text || text.length > 300) continue;
const parsed = this.parseAiWorkspaceOptionsFromLines(this.getAiWorkspaceLines(cur));
if (parsed.length === 1) {
selected = cur;
break;
}
}
addUnique(nodes, selected || letterNode);
}
if (nodes.length >= 2) return nodes;
const fallbackOptions = this.extractAiWorkspaceOptionsFromLines(this.getAiWorkspaceLines(root));
if (fallbackOptions.length >= 2) {
const descendants = Array.from(root.querySelectorAll('label,div,span,p,li,button,[role="radio"],[role="checkbox"]'));
for (const opt of fallbackOptions) {
const optText = this.cleanAiWorkspaceOptionText(opt);
const matches = descendants.filter(node => {
if (!this.isVisibleElement(node)) return false;
const text = this.cleanAiWorkspaceOptionText(node.innerText || node.textContent || '');
if (!text || text.length > 300) return false;
return text === optText || text.includes(optText);
}).sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return (ar.width * ar.height) - (br.width * br.height);
});
if (matches[0]) addUnique(nodes, matches[0]);
}
}
return nodes;
},
findStudentQuizOptionNodes(element) {
const root = element || this.findStudentQuizQuestionArea(document);
if (!root) return [];
const textOf = (node) => (node?.innerText || node?.textContent || '').trim();
const addUnique = (arr, node) => {
if (!node) return;
const row = node.closest?.('li') || node;
if (arr.some(item => item === row)) return;
arr.push(row);
};
const nodes = [];
const preciseSelector = [
'ul.list-unstyled-radio > li',
'ul.list-unstyled-checkbox > li',
'.list-unstyled-radio li',
'.list-unstyled-checkbox li',
'label.el-radio',
'label.el-checkbox',
'.homeworkElRadio',
'.homeworkElCheckbox',
'.ant-radio-wrapper',
'.ant-checkbox-wrapper',
'[role="radio"]',
'[role="checkbox"]'
].join(',');
for (const node of Array.from(root.querySelectorAll(preciseSelector))) {
if (!this.isVisibleElement(node)) continue;
const text = this.cleanAiWorkspaceOptionText(textOf(node));
const judgeValue = this.getJudgeOptionValue(node);
if ((text && text.length <= 300) || judgeValue) addUnique(nodes, node);
}
if (nodes.length >= 2) return nodes;
const clickableAncestor = (node) => {
let best = null;
for (let cur = node; cur && cur !== root.parentElement; cur = cur.parentElement) {
if (!this.isStructurallyVisible(cur)) continue;
const rect = cur.getBoundingClientRect();
const text = textOf(cur).replace(/\s+/g, ' ').trim();
const cls = String(cur.className || '');
const role = cur.getAttribute?.('role') || '';
const cursor = window.getComputedStyle(cur).cursor;
const parsed = this.parseAiWorkspaceOptionsFromLines(
this.getAiWorkspaceLines(cur).filter(line => !this.isStudentQuizNoiseLine(line))
);
const looksLikeRow = parsed.length === 1 ||
(/^[A-G]\s+.{2,}/.test(text) && text.length <= 350 && rect.height <= 140);
if (looksLikeRow) best = cur;
if (looksLikeRow && (
cur.tagName === 'LABEL' ||
cur.tagName === 'BUTTON' ||
role === 'radio' ||
role === 'checkbox' ||
cursor === 'pointer' ||
typeof cur.onclick === 'function' ||
/option|answer|choice|radio|checkbox|select/i.test(cls)
)) return cur;
if (best && rect.width > 260 && rect.height > 24 && rect.height <= 120) return best;
}
return best || node;
};
const letterNodes = Array.from(root.querySelectorAll('span,div,b,i,em,strong,p,button,a,svg text,svg tspan')).filter(node => {
const text = textOf(node);
return this.isVisibleElement(node) && /^[A-G]$/.test(text);
});
for (const letterNode of letterNodes) {
addUnique(nodes, clickableAncestor(letterNode));
}
if (nodes.length >= 2) return nodes;
const looseSelector = 'li,[class*="option"],[class*="Option"],[class*="answer"],[class*="Answer"],[class*="choice"],[class*="Choice"]';
for (const node of Array.from(root.querySelectorAll(looseSelector))) {
if (!this.isVisibleElement(node)) continue;
const lines = this.getAiWorkspaceLines(node).filter(line => !this.isStudentQuizNoiseLine(line));
if (this.parseAiWorkspaceOptionsFromLines(lines).length === 1) addUnique(nodes, node);
}
if (nodes.length >= 2) return nodes;
const fallbackOptions = this.extractAiWorkspaceOptionsFromLines(
this.getAiWorkspaceLines(root).filter(line => !this.isStudentQuizNoiseLine(line))
);
if (fallbackOptions.length >= 2) {
const descendants = Array.from(root.querySelectorAll('label,button,a,li,div,span,p,section,svg g,svg text'));
for (const option of fallbackOptions) {
const optText = this.cleanAiWorkspaceOptionText(option);
if (!optText) continue;
const matches = descendants.filter(node => {
if (!this.isVisibleElement(node)) return false;
const text = this.cleanAiWorkspaceOptionText(textOf(node));
if (!text || text.length > 400) return false;
return text === optText || text.includes(optText) || optText.includes(text);
}).sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return (ar.width * ar.height) - (br.width * br.height);
});
if (matches[0]) addUnique(nodes, clickableAncestor(matches[0]));
}
}
return nodes;
},
findStudentQuizQuestionArea(root = document) {
if (!this.isStudentQuizPage()) return null;
const typePattern = this.getQuestionTypePattern();
const isBadContainer = (el) => !!(
el.closest?.('#ykt-helper-iframe') ||
el.closest?.('.answer-sheet') ||
el.closest?.('.sheet-box') ||
el.closest?.('.sheet-list') ||
el.closest?.('.problem-fixedbar') ||
el.closest?.('.aside-body') ||
el.closest?.('.exam-aside')
);
const looksLikeQuestion = (el) => {
if (!el || isBadContainer(el) || !this.isStructurallyVisible(el)) return false;
const rect = el.getBoundingClientRect();
if (rect.width < 260 || rect.height < 120 || rect.height > window.innerHeight * 1.8) return false;
const lines = this.getAiWorkspaceLines(el).filter(line => !this.isStudentQuizNoiseLine(line));
if (lines.length < 3 || lines.length > 140) return false;
const options = this.extractAiWorkspaceOptionsFromLines(lines);
const optionNodes = this.findStudentQuizOptionNodes(el);
const hasQuestionSignal = lines.some(line => typePattern.test(line)) ||
lines.some(line => /[??]|\(\)|()/.test(line));
return hasQuestionSignal && (options.length >= 2 || optionNodes.length >= 2);
};
const textLooksLikeQuestion = (el) => {
if (!el || isBadContainer(el) || !this.isStructurallyVisible(el)) return false;
const lines = this.getAiWorkspaceLines(el).filter(line => !this.isStudentQuizNoiseLine(line));
if (lines.length < 3 || lines.length > 260) return false;
const options = this.extractAiWorkspaceOptionsFromLines(lines);
const hasQuestionSignal = lines.some(line => typePattern.test(line)) ||
lines.some(line => /[??]|\(\)|()/.test(line));
return hasQuestionSignal && options.length >= 2;
};
const directSelectors = [
'.container-problem .subject-item',
'.container-problem',
'.exam-main--body .subject-item',
'.exam-main--body',
'.exam-main',
'.container-body',
'.subject-item',
'.item-body',
'.problem-body',
'[class*="studentQuiz"]',
'[class*="student-quiz"]',
'[class*="quiz"]',
'[class*="paper"]',
'[class*="question"]'
];
for (const sel of directSelectors) {
for (const node of Array.from(root.querySelectorAll(sel))) {
const area = node.matches?.('.problem-body') ? (node.closest('.subject-item') || node.parentElement || node) : node;
if (looksLikeQuestion(area)) return area;
}
}
const candidates = Array.from(root.querySelectorAll('main,article,section,form,div')).filter(looksLikeQuestion);
candidates.sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return (ar.width * ar.height) - (br.width * br.height);
});
if (candidates[0]) return candidates[0];
const textCandidates = Array.from(root.querySelectorAll('main,article,section,form,div')).filter(textLooksLikeQuestion);
textCandidates.sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return (ar.width * ar.height) - (br.width * br.height);
});
if (textCandidates[0]) return textCandidates[0];
// 新版 studentQuiz 可能没有任何题目 class/role,只保留纯文本与可点击行。
// 此时先把正文交给识别流程,后续再用文本行和坐标附近的元素匹配选项。
if (root.body && textLooksLikeQuestion(root.body)) return root.body;
return null;
},
readStudentQuizFromDOM(element) {
const root = element || this.findStudentQuizQuestionArea(document);
if (!root) return { question: '', options: [], type: 4 };
const lines = this.getAiWorkspaceLines(root).filter(line => !this.isStudentQuizNoiseLine(line));
const typePattern = this.getQuestionTypePattern();
const optionPattern = /^([A-G])(?:\s*[.、.::))]|\s+)(.+)$/;
const optionNodes = this.findStudentQuizOptionNodes(root);
const optionsFromNodes = optionNodes.map(node => {
const parsed = this.extractAiWorkspaceOptionsFromLines(this.getAiWorkspaceLines(node));
return this.getJudgeOptionText(node) || parsed[0] || '';
}).filter(Boolean);
const options = optionsFromNodes.length >= 2
? optionsFromNodes
: this.extractAiWorkspaceOptionsFromLines(lines);
const normalizedOptions = options.map(option => String(option).replace(/\s+/g, '').toLowerCase());
const hasJudgeOptionTexts = normalizedOptions.some(option => /^(正确|对|√|true|是|right|yes)$/.test(option)) &&
normalizedOptions.some(option => /^(错误|错|×|false|否|wrong|no)$/.test(option));
let type = 4;
const typeLine = lines.find(line => typePattern.test(line)) || '';
if (hasJudgeOptionTexts) {
type = 3;
} else if (typeLine) {
const typeMatch = typeLine.match(typePattern);
type = getQuestionType(typeMatch?.[1] || typeLine);
}
if (type === 4) {
const joined = lines.join(' ');
if (/多选|多项选择|不定项/.test(joined)) type = 1;
else if (/判断|对错/.test(joined)) type = 3;
else if (options.length >= 2) type = 0;
}
const firstOptionIndex = lines.findIndex(line => /^[A-G]$/.test(line) || optionPattern.test(line));
const head = firstOptionIndex > 0 ? lines.slice(0, firstOptionIndex) : lines;
const questionParts = [];
for (const line of head) {
if (typePattern.test(line)) {
const rest = line.replace(typePattern, '').trim();
if (rest) questionParts.push(rest);
continue;
}
if (/^[A-G]$/.test(line) || optionPattern.test(line)) break;
questionParts.push(line);
}
let question = questionParts.join(' ')
.replace(/\s+/g, ' ')
.replace(/^\d+\s*[.、.]\s*/, '')
.replace(/^第\s*\d+\s*题\s*/, '')
.trim();
if (!question && firstOptionIndex > 0) {
question = head.filter(line => !typePattern.test(line)).pop() || '';
}
console.log('[雨课堂助手] studentQuiz DOM解析:', { question, options, type, lines });
return { question, options, type };
},
extractAiWorkspaceOptions(element) {
const nodes = this.findAiWorkspaceOptionNodes(element);
const fromNodes = nodes.map(node => {
const parsed = this.extractAiWorkspaceOptionsFromLines(this.getAiWorkspaceLines(node));
return this.getJudgeOptionText(node) || parsed[0] || '';
}).filter(Boolean);
if (fromNodes.length >= 2) return [...new Set(fromNodes)];
return this.extractAiWorkspaceOptionsFromLines(this.getAiWorkspaceLines(element));
},
// 智能学伴使用独立聊天题卡;仅读取该卡的题干/选项,避免混入聊天记录或解析。
findAiWorkspaceCompanionOptionNodes(card) {
if (!card || !this.isStructurallyVisible(card)) return [];
const isContent = card.matches?.('.content') && card.parentElement?.matches?.('.chat-card-question');
if (!isContent && !card.matches?.('.chat-card-question')) return [];
const contents = isContent ? [card] : Array.from(card.children || []).filter(node => node.matches?.('.content'));
// 多题卡由调用方逐个传入 .content,不能把多题选项合并成一道题。
if (contents.length !== 1 || !this.isStructurallyVisible(contents[0])) return [];
const lists = Array.from(contents[0].children || []).filter(node => node.matches?.('.list'));
if (lists.length !== 1) return [];
const rows = Array.from(lists[0].children || []).filter(node => node.matches?.('.list-item'));
if (rows.length < 2 || rows.length > 7 || rows.some(node => !this.isStructurallyVisible(node))) return [];
return rows.every((node, index) => {
const letter = String(node.querySelector('.tag span')?.textContent || '').trim();
return letter === String.fromCharCode(65 + index) && !!node.querySelector('.item-container .title');
}) ? rows : [];
},
readAiWorkspaceCompanionFromDOM(card) {
const optionNodes = this.findAiWorkspaceCompanionOptionNodes(card);
if (optionNodes.length < 2) return null;
const content = optionNodes[0].closest('.content');
const stem = content?.querySelector('.list > .card-question');
const heading = Array.from(stem?.children || []).find(node => node.tagName === 'H3');
if (!heading) return null;
const readText = node => readDecodedElementText(node)
.replace(/\s+/g, ' ').trim();
const type = getQuestionType(readText(heading));
if (![0, 1, 3].includes(type)) return null;
const question = Array.from(stem.children).filter(node => node !== heading).map(readText).filter(Boolean).join(' ');
const options = optionNodes.map(node => readText(node.querySelector('.item-container .title')));
if (question.length < 4 || options.some(text => !text)) return null;
// 不去重,也不删除英文开头:选项文本可能相同,A/B 标号在独立 .tag 中。
return { question, options, type };
},
readAiWorkspaceStructuredFromDOM(element) {
const root = element || this.findAiWorkspaceQuestionArea(document);
if (!root) return null;
const subject = root.matches?.('.subject-item') ? root : (root.closest?.('.subject-item') || root.querySelector?.('.subject-item') || root);
const problemBody = subject.querySelector?.('.problem-body') || root.querySelector?.('.problem-body');
if (!problemBody || !this.isStructurallyVisible(problemBody)) return null;
const question = this.getAiWorkspaceLines(problemBody)
.filter(line => !/^[((]\s*\d+(?:\.\d+)?\s*分\s*[))]$/.test(line))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
const optionSelector = 'ul.list-unstyled-radio > li, ul.list-unstyled-checkbox > li, .list-unstyled-radio li, .list-unstyled-checkbox li';
let optionNodes = Array.from(subject.querySelectorAll?.(optionSelector) || []).filter(node => this.isStructurallyVisible(node));
if (optionNodes.length < 2) {
optionNodes = Array.from(subject.querySelectorAll?.('label.el-radio, label.el-checkbox, .homeworkElRadio, .homeworkElCheckbox') || []).filter(node => this.isStructurallyVisible(node));
}
const judgeValues = optionNodes.map(node => this.getJudgeOptionValue(node));
const hasJudgeOptions = judgeValues.includes('true') && judgeValues.includes('false');
const options = optionNodes.map(node => {
const label = node.querySelector?.('.el-radio__label, .el-checkbox__label') || node;
return this.getJudgeOptionText(label) || this.getJudgeOptionText(node);
}).filter(Boolean);
// 题型识别优先看选项 DOM 元素类型(最可靠):
// ul.list-unstyled-checkbox / label.el-checkbox / .homeworkElCheckbox → 多选题
// ul.list-unstyled-radio / label.el-radio / .homeworkElRadio → 单选题
// 万一选项 DOM 不是这两类(如填空/判断),再回退用 .item-type 的文字内容
const checkboxCount = (subject.querySelectorAll?.('ul.list-unstyled-checkbox > li, label.el-checkbox, label.homeworkElCheckbox') || []).length;
const radioCount = (subject.querySelectorAll?.('ul.list-unstyled-radio > li, label.el-radio, label.homeworkElRadio') || []).length;
let type;
if (hasJudgeOptions) {
type = 3; // 判断题(选项可能只有“正确/错误”图标)
} else if (checkboxCount > 0 && checkboxCount >= radioCount) {
type = 1; // 多选题
} else if (radioCount > 0) {
type = 0; // 单选题
} else {
const typeText = this.getAiWorkspaceLines(subject.querySelector?.('.item-type') || subject).join(' ');
type = getQuestionType(typeText);
if (type === 4) {
if (/多选|多项选择/.test(typeText)) type = 1;
else if (/判断|对错/.test(typeText)) type = 3;
else if (options.length >= 2) type = 0;
}
}
if (question.length > 3 && options.length >= 2) {
return { question, options, type };
}
return null;
},
collectAiWorkspaceRoots(root = document) {
// AI 学习空间常见“课程外壳 -> 内容 iframe -> 题目 iframe”的多层结构。
return Utils.collectAccessibleDocuments(root, 4);
},
findAiWorkspaceQuestionArea(root = document) {
if (!this.isAiWorkspaceExercisePage()) return null;
const typePattern = /^\s*(?:(?:第?\s*\d+\s*题?|\d+\s*[\//]\s*\d+)\s*[.、.]?\s*)?[【\[\((]?(单选题|多选题|判断题|填空题|单项选择题|多项选择题|不定项选择题)[】\]\))]?/;
const optionLikeLine = (line) => {
if (!line || this.isAiWorkspaceNoiseLine(line) || typePattern.test(line)) return false;
if (/^[A-G]$/.test(line)) return false;
if (/^[A-G](?:\s*[.、.::))]|\s+).+/.test(line)) return true;
return line.length >= 2 && line.length <= 120;
};
const searchRoots = this.collectAiWorkspaceRoots(root);
const debugStats = { subjectItems: 0, problemBodies: 0, optionGroups: 0, iframes: searchRoots.length - 1 };
for (const r of searchRoots) {
debugStats.subjectItems += r.querySelectorAll('.subject-item, .item-body').length;
debugStats.problemBodies += r.querySelectorAll('.problem-body').length;
debugStats.optionGroups += r.querySelectorAll('ul.list-unstyled-radio, ul.list-unstyled-checkbox, label.homeworkElRadio, label.homeworkElCheckbox').length;
}
for (const r of searchRoots) {
const subjectCandidates = Array.from(r.querySelectorAll('.subject-item, .item-body')).map(el => el.closest('.subject-item') || el);
for (const subject of [...new Set(subjectCandidates)]) {
if (!this.isStructurallyVisible(subject)) continue;
const problemBody = subject.querySelector('.problem-body');
if (!problemBody || !this.isStructurallyVisible(problemBody)) continue;
const questionText = this.getAiWorkspaceLines(problemBody).join(' ').trim();
const optionNodes = subject.querySelectorAll('label.el-radio, label.el-checkbox, .homeworkElRadio, .homeworkElCheckbox, .list-unstyled-radio li, .list-unstyled-checkbox li');
if (questionText.length > 3 && optionNodes.length >= 2) return subject;
}
}
console.log('[雨课堂助手] AI学习空间未命中.subject-item,DOM统计:', debugStats, 'pathname:', location.pathname);
for (const r of searchRoots) {
const nodes = Array.from(r.querySelectorAll('div,section,article,main,form,p,span,h1,h2,h3,h4')).filter(el => {
if (!this.isStructurallyVisible(el)) return false;
const lines = this.getAiWorkspaceLines(el);
return lines.some(line => typePattern.test(line));
});
for (const node of nodes) {
let cur = node;
const stopAt = (r.body || r);
for (let depth = 0; cur && cur !== stopAt && depth < 10; depth++, cur = cur.parentElement) {
if (!this.isStructurallyVisible(cur)) continue;
const rect = cur.getBoundingClientRect();
const lines = this.getAiWorkspaceLines(cur);
if (rect.width < 260 || rect.height < 100 || lines.length > 120) continue;
if (!lines.some(line => typePattern.test(line))) continue;
if (this.extractAiWorkspaceOptionsFromLines(lines).length >= 2) return cur;
if (lines.length >= 2 && rect.width >= 500 && rect.height >= 180) return cur;
}
}
}
for (const r of searchRoots) {
const fallback = Array.from(r.querySelectorAll('div,section,main,article')).filter(el => {
if (!this.isStructurallyVisible(el)) return false;
const rect = el.getBoundingClientRect();
if (rect.width < 500 || rect.height < 180 || rect.height > window.innerHeight * 1.5) return false;
const lines = this.getAiWorkspaceLines(el);
if (lines.length < 3 || lines.length > 80) return false;
const optionLikeCount = lines.filter(optionLikeLine).length;
return lines.some(line => typePattern.test(line)) &&
(this.extractAiWorkspaceOptionsFromLines(lines).length >= 2 || lines.some(line => /[??]|\(\)|()/.test(line)) || optionLikeCount >= 3);
}).sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return (ar.width * ar.height) - (br.width * br.height);
});
if (fallback[0]) return fallback[0];
}
return null;
},
readAiWorkspaceFromDOM(element) {
const root = element || this.findAiWorkspaceQuestionArea(document);
if (!root) return { question: '', options: [], type: 4 };
const structured = this.readAiWorkspaceStructuredFromDOM(root);
if (structured) {
console.log('[雨课堂助手] AI学习空间结构化DOM解析:', structured);
return structured;
}
const lines = this.getAiWorkspaceLines(root);
const typePattern = /^\s*(?:(?:第?\s*\d+\s*题?|\d+\s*[\//]\s*\d+)\s*[.、.]?\s*)?[【\[\((]?(单选题|多选题|判断题|填空题|单项选择题|多项选择题|不定项选择题)[】\]\))]?\s*(?:[((]\s*\d+(?:\.\d+)?\s*分\s*[))])?\s*(.*)$/;
const optionPattern = /^([A-G])(?:\s*[.、.::))]|\s+)(.+)$/;
const options = this.extractAiWorkspaceOptions(root);
const questionLines = [];
let type = 4;
let seenType = false;
let seenOption = false;
for (const line of lines) {
if (this.isAiWorkspaceNoiseLine(line)) continue;
const typeMatch = line.match(typePattern);
if (typeMatch) {
type = getQuestionType(typeMatch[1]);
seenType = true;
const rest = typeMatch[2].replace(/[((]\s*\d+(\.\d+)?\s*分\s*[))]/g, '').trim();
if (rest) questionLines.push(rest);
continue;
}
if (/^[A-G]$/.test(line) || optionPattern.test(line)) {
seenOption = true;
continue;
}
if (seenType && !seenOption) questionLines.push(line);
}
let question = questionLines.join(' ').replace(/\s+/g, ' ').trim();
if (options.length >= 2 && !lines.some(line => /^[A-G]$/.test(line) || optionPattern.test(line))) {
const filtered = lines.filter(line => !this.isAiWorkspaceNoiseLine(line));
const typeIndex = filtered.findIndex(line => typePattern.test(line));
if (typeIndex >= 0) {
const typeMatch = filtered[typeIndex].match(typePattern);
const questionParts = [];
const rest = (typeMatch?.[2] || '').replace(/[((]\s*\d+(\.\d+)?\s*分\s*[))]/g, '').trim();
if (rest) questionParts.push(rest);
const afterType = filtered.slice(typeIndex + 1).filter(line => !typePattern.test(line));
let questionEndIndex = afterType.findIndex(line => /[??]|\(\)|()|[::]$/.test(line));
if (questionEndIndex < 0) {
questionEndIndex = Math.max(0, afterType.length - options.length - 1);
}
questionParts.push(...afterType.slice(0, questionEndIndex + 1));
const fallbackQuestion = questionParts.join(' ').replace(/\s+/g, ' ').trim();
if (fallbackQuestion.length > 3) question = fallbackQuestion;
}
}
if (!question && options.length >= 2) {
const firstOptionIndex = lines.findIndex(line => /^[A-G]$/.test(line) || optionPattern.test(line));
const head = firstOptionIndex > 0 ? lines.slice(0, firstOptionIndex) : lines;
question = head.filter(line => !this.isAiWorkspaceNoiseLine(line) && !typePattern.test(line)).pop() || '';
}
if (type === 4) {
const joined = lines.join(' ');
if (/多选|多项选择/.test(joined)) type = 1;
else if (/判断|对错/.test(joined)) type = 3;
else if (options.length >= 2) type = 0;
}
console.log('[雨课堂助手] AI学习空间DOM解析:', { question, options, type, lines });
return { question, options, type };
},
// 直接从DOM读取题目(不再依赖OCR截图)
readFromDOM(element) {
if (!element) return { question: '', options: [], type: 4 };
if (element.closest?.('.chat-card-question')) {
const companion = this.readAiWorkspaceCompanionFromDOM(element);
if (companion) return companion;
}
if (this.isAiWorkspaceExercisePage()) {
const aiResult = this.readAiWorkspaceFromDOM(element);
if (aiResult.question && aiResult.question.length > 3) return aiResult;
}
if (this.isStudentQuizPage()) {
const quizResult = this.readStudentQuizFromDOM(element);
if (quizResult.question && quizResult.question.length > 3) return quizResult;
}
// 读取题目文本 - 使用官方脚本的选择器
let questionText = '';
const questionEl = element.querySelector('h4') ||
element.querySelector('.problem-body') ||
element.querySelector('span:first-child');
if (questionEl) {
questionText = formatString(readDecodedElementText(questionEl));
}
if (!questionText) {
questionText = formatString(readDecodedElementText(element));
}
// 清理题号和分数
questionText = questionText
.replace(/^\d+[、.]\s*/, '')
.replace(/[((](\d+(\.\d+)?分)[))]$/, '')
.trim();
// 读取选项
let options = [];
const optionContainer = element.querySelector('ul');
if (optionContainer) {
const optionEls = optionContainer.querySelectorAll('li');
options = Array.from(optionEls).map(li => {
const text = formatString(readDecodedElementText(li));
return text.replace(/^[A-Ga-g][.、]\s*/, '').trim();
}).filter(t => t);
}
// 判断题目类型
const typeEl = element.querySelector('.item-type') ||
element.closest('.subject-item')?.querySelector('.item-type');
let type = 4;
if (typeEl) {
type = getQuestionType(typeEl.innerText || typeEl.textContent || '');
} else if (options.length === 2) {
// 两个选项大概率是判断题
const joined = options.join(',').toLowerCase();
if (joined.includes('正确') || joined.includes('错误') ||
joined.includes('对') || joined.includes('错') ||
joined.includes('true') || joined.includes('false')) {
type = 3;
}
} else if (options.length >= 3) {
type = 0; // 默认单选
}
return { question: questionText, options, type };
},
// 从拦截的数据中查找匹配的题目
findInterceptedQuestion(index) {
if (!interceptedProblems || !interceptedProblems.length) return null;
if (index >= 0 && index < interceptedProblems.length) {
return interceptedProblems[index];
}
return null;
},
parseOcrQuestion(text, metadata) {
const lines = String(text || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
const marker = line => {
const match = line.match(/^([A-G])(?:\s*[.、.::))]\s*|\s+)(.*)$/i) || line.match(/^([A-G])$/i);
return match ? { letter: match[1].toUpperCase(), text: (match[2] || '').trim() } : null;
};
const expected = metadata.options?.length || 0;
const choice = [0, 1, 3].includes(metadata.type) || expected >= 2;
// 只接受从 A 开始且连续的标号;字母开头的英文句子不能直接被当作选项。
let options = [], start = -1;
for (let i = 0; i < lines.length; i++) {
if (marker(lines[i])?.letter !== 'A') continue;
const candidate = [];
let valid = true;
for (let j = i; j < lines.length; j++) {
const line = lines[j];
if (this.isAiWorkspaceNoiseLine(line) || /^(?:已提交|提交答案|重新作答|参考答案|正确答案|答案解析|解析)(?:\s|[::]|$)/.test(line)) break;
const item = marker(line);
if (item) {
if (item.letter !== String.fromCharCode(65 + candidate.length)) { valid = false; break; }
candidate.push(item.text);
} else if (candidate.length) {
candidate[candidate.length - 1] += (candidate[candidate.length - 1] ? ' ' : '') + line;
}
}
if (valid && candidate.length >= 2 && candidate.every(Boolean) && (!expected || candidate.length === expected)) {
start = i; options = candidate; break;
}
}
if (choice && start < 0) return null;
const head = (start < 0 ? lines : lines.slice(0, start)).filter(line => !this.isAiWorkspaceNoiseLine(line));
let type = metadata.type;
if (type === 4) type = getQuestionType(head.join(' '));
if (type === 4 && options.length >= 2) type = 0;
const question = head.join(' ').replace(/\s+/g, ' ')
.replace(/^(?:第?\s*\d+\s*[.、.]?\s*)?[【\[((]?(?:单项选择|多项选择|不定项选择|单选|多选|判断|填空|问答|简答)题?[】\]))]?\s*(?:[((]\s*\d+(?:\.\d+)?\s*分\s*[))])?\s*/, '')
.trim();
if (question.length < 4 || ([0, 1, 3].includes(type) && options.length < 2)) return null;
return { question, options, type };
},
// 综合识别:只有确认解码完整的数据才可交给题库,否则用截图 OCR。
async recognize(element, questionIndex) {
if (deobfuscationPromise) {
try { await deobfuscationPromise; } catch (e) { /* already handled */ }
deobfuscationPromise = null;
}
if (getEncryptedFontElements(element).length) {
try {
await ensureFontCharMap(element);
} catch (e) { console.warn('[雨课堂助手] ensureFontCharMap 失败:', e); }
}
const intercepted = this.findInterceptedQuestion(questionIndex);
if (intercepted && !intercepted.encryptedUnresolved && intercepted.question && intercepted.question.length > 3) {
panel.log(`📋 从服务器数据获取题目 (第${questionIndex + 1}题)`);
return intercepted;
}
const unresolved = hasUnresolvedEncryptedText(element);
// 即使文本未解码,也可用 DOM 题型和选项数量验证 OCR,不能发送其中的乱码。
const domResult = this.readFromDOM(element);
if (!unresolved && domResult.question && domResult.question.length > 5) {
panel.log('📖 从页面读取题目(字体已校验)');
return domResult;
}
if (unresolved) panel.log('⚠️ 当前题目字体映射不完整,改用截图 OCR');
if (element && typeof html2canvas !== 'undefined' && typeof Tesseract !== 'undefined') {
let canvas = null;
try {
panel.log('📸 正在截图识别题目...');
canvas = await html2canvas(element, {
useCORS: true, logging: false, scale: 2, backgroundColor: '#ffffff',
onclone: async clonedDocument => {
// 动态 FontFace 不会随 CSS 克隆;确保截图仍使用页面实际字形。
const fonts = element.ownerDocument?.fonts;
if (fonts && clonedDocument.fonts) {
fonts.forEach(face => clonedDocument.fonts.add(face));
await clonedDocument.fonts.ready;
}
}
});
panel.log('🔍 正在OCR识别...');
const { data: { text } } = await Tesseract.recognize(canvas, 'chi_sim');
const result = this.parseOcrQuestion(text, domResult);
if (result) {
panel.log('📝 OCR 识别完成,选项: ' + result.options.length);
return result;
}
panel.log('⚠️ OCR 题干或选项不完整,暂停并保留断点');
} catch (err) {
panel.log(`⚠️ OCR失败: ${err.message || '未知错误'}`);
} finally {
if (canvas) { canvas.width = 0; canvas.height = 0; }
}
}
return { question: '', options: [], type: domResult.type };
},
async askQuestionBank(question, options = [], type = 4, qid = null) {
if (stopRequested) throw new Error('已停止答题');
const originalQuestion = question;
// 清理题目文本:去掉序号前缀、题型标头(不清理LaTeX,lyck6需要原始LaTeX)
question = question
.replace(/^[一二三四五六七八九十⼀⼆⼆⼋⼗⼗⼀⼗⼆⼗三⼗四⼗五⼗六⼗七⼗⼋⼗九⼆⼗]+[、..]\s*/, '')
.replace(/^判断下列[\s\S]*?[.。::]\s*/, '')
.replace(/^【[^】]*】\s*/, '')
.replace(/[((]\s*\d+(\.\d+)?\s*分\s*[))]\s*/g, '')
.trim();
// 从问题中去掉内嵌的选项文本(www.yuketang.cn的Body包含选项)
// 安全策略:只在检测到至少2个选项标记(A. xxx B.)时才截断
if (options.length >= 2) {
const m = question.match(/\s*[A-G][.、.]\s*[\s\S]*?[A-G][.、.]/);
if (m) {
const cleaned = question.substring(0, m.index).trim();
if (cleaned.length > 3) {
question = cleaned;
}
}
}
if (!question || question.length < 3) question = originalQuestion;
console.log('[雨课堂助手] 发送题目:', question, '选项:', options);
const mode = Store.getAnswerMode();
if (mode === 'ai') {
return this.askAI(question, options, type);
} else if (mode === 'free') {
await AccessGuard.requireFreeAnswerAccess();
if (stopRequested) throw new Error('已停止答题');
return this.askFreeQuestionBank(question, options, type, qid);
} else {
return this.askPaidQuestionBank(question, options, type, qid);
}
},
async askFreeQuestionBank(question, options = [], type = 4, qid = null) {
if (stopRequested) throw new Error('已停止答题');
const API_URL = 'https://qsy.iano.cn/index.php?s=/api/code/free_answer';
return new Promise((resolve, reject) => {
panel.log('🔍 正在查询免费题库...');
GM_xmlhttpRequest({
method: 'POST',
url: API_URL,
headers: { 'Content-Type': 'application/json' },
// 与万能脚本 formatSearchAnswer 字段对齐:plat+qid 让后端走精确匹配(命中率显著提升)
data: JSON.stringify({
session_token: Store.getVerifySessionToken(),
device_id: Store.getDeviceId(),
plat: 50,
qid: qid ? String(qid) : null,
question: question,
options: options,
options_id: [],
type: type,
location: location.href
}),
timeout: 15000,
onload: res => {
if (res.status === 200) {
try {
const json = JSON.parse(res.responseText);
if (json.code === 1 && json.data && json.data.result) {
const bankData = json.data.result;
const answers = bankData.answers || [];
const success = bankData.success || false;
let finalAnswers = [];
if (success && Array.isArray(answers) && answers.length > 0) {
if (typeof answers[0] === 'number') {
finalAnswers = answers.map(idx => options[idx] || String.fromCharCode(65 + idx));
} else {
finalAnswers = answers;
}
} else if (Array.isArray(answers) && answers.length > 0) {
finalAnswers = Array.isArray(answers[0]) ? answers[0] : answers;
}
if (!finalAnswers.length) {
panel.log('⚠️ 免费题库未找到匹配答案');
reject('未找到匹配答案');
return;
}
panel.log('✅ 免费题库查询成功');
resolve({ answers: finalAnswers, remaining: -1 });
} else {
const msg = json.msg || json.message || '题库查询失败';
panel.log(`⚠️ ${msg}`);
reject(msg);
}
} catch (e) { reject('JSON 解析失败'); }
} else {
const err = `请求失败: HTTP ${res.status}`;
panel.log(err);
reject(err);
}
},
onerror: () => reject('网络错误'),
ontimeout: () => reject('请求超时')
});
});
},
async askPaidQuestionBank(question, options = [], type = 4, qid = null) {
if (stopRequested) throw new Error('已停止答题');
const API_URL = 'https://qsy.iano.cn/index.php?s=/api/question_bank/answer';
const deviceId = Store.getDeviceId();
return new Promise((resolve, reject) => {
panel.log('🔍 正在查询付费题库...');
const params = new URLSearchParams();
params.append('device_id', deviceId);
params.append('plat', '50');
if (qid) params.append('qid', String(qid));
params.append('question', question);
params.append('type', type);
params.append('location', location.href);
if (Array.isArray(options)) options.forEach((opt, i) => params.append('options[' + i + ']', opt));
GM_xmlhttpRequest({
method: 'POST',
url: API_URL,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: params.toString(),
timeout: 15000,
onload: res => {
if (res.status === 200) {
try {
const json = JSON.parse(res.responseText);
if (json.code === 1 && json.data && json.data.result) {
const rawAnswers = json.data.result.answers || [];
const success = json.data.result.success;
const remaining = json.data.remaining_credits || 0;
console.log('[雨课堂助手] 付费题库原始返回:', JSON.stringify(json.data.result));
let finalAnswers = [];
if (success && Array.isArray(rawAnswers) && rawAnswers.length > 0) {
if (typeof rawAnswers[0] === 'number') {
// lyck6 success=true 时 answers 是选项索引数组,去重
const uniqueIndices = [...new Set(rawAnswers)];
finalAnswers = uniqueIndices.map(idx => options[idx] || String.fromCharCode(65 + idx));
} else {
finalAnswers = rawAnswers.map(a => String(a)).filter(a => a.trim() !== '');
}
} else if (!success && Array.isArray(rawAnswers) && rawAnswers.length > 0) {
// success=false: answers是二维数组,取第一个匹配
const first = Array.isArray(rawAnswers[0]) ? rawAnswers[0] : rawAnswers;
finalAnswers = first.map(a => String(a)).filter(a => a.trim() !== '');
}
if (finalAnswers.length > 0) {
panel.log(`✅ 付费题库查询成功,剩余积分: ${remaining}`);
resolve({ answers: finalAnswers, remaining });
} else {
panel.log(`⚠️ 付费题库未匹配到答案,剩余积分: ${remaining}`);
reject('付费题库未匹配到答案');
}
} else {
const msg = json.msg || '题库查询失败';
panel.log(`⚠️ ${msg}`);
reject(msg);
}
} catch (e) {
reject('JSON 解析失败');
}
} else {
const err = `请求失败: HTTP ${res.status}`;
panel.log(err);
console.error('[雨课堂助手] 付费题库错误响应:', res.status);
reject(err);
}
},
onerror: (e) => { console.error('[雨课堂助手] 付费题库网络错误:', e); reject('网络错误'); },
ontimeout: () => reject('请求超时')
});
});
},
async askAI(question, options = [], type = 4) {
const apiKey = Store.getAIApiKey();
if (!apiKey) {
throw '未配置AI API Key,请在【题库配置】中填写';
}
const providerConf = getAIProviderConfig();
if (!providerConf.url || !providerConf.model) {
throw '自定义接口配置不完整,请填写接口地址和模型名称';
}
// 构建题目类型描述
const typeNames = { 0: '单选题', 1: '多选题', 2: '填空题', 3: '判断题', 4: '其他' };
const typeName = typeNames[type] || '其他';
// 构建选项文本
let optionsText = '';
if (options.length > 0) {
optionsText = '\n选项:\n' + options.map((opt, i) => `${String.fromCharCode(65 + i)}. ${opt}`).join('\n');
}
const prompt = `你是一个答题助手。请直接给出答案,不要解释。
题目类型:${typeName}
题目:${question}${optionsText}
回答要求:
- 单选题:只回答一个选项的完整内容(如选项A的内容是"经济全球化",就回答"经济全球化")
- 多选题:回答所有正确选项的完整内容,用"||"分隔(如"经济全球化||科技进步")
- 判断题:只回答"正确"或"错误"
- 填空题:直接给出填空内容;若有多个空,按顺序用"||"分隔每个空的答案
- 解答题/简答题/问答题:直接给出完整作答内容,不要分点编号,用连贯的文字回答
- 不要回答选项字母(A/B/C/D),要回答选项的具体内容`;
// 内置模型和自定义接口统一使用 OpenAI Chat Completions 请求格式。
let requestUrl = providerConf.url;
let requestHeaders = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` };
const requestBody = createAIRequestBody(providerConf, prompt, 0.1, providerConf.answerMaxTokens || 200);
await AccessGuard.beforeAIRequest(panel?.log?.bind(panel));
if (stopRequested) {
const error = new Error('已请求停止,取消 AI 请求');
error.code = 'STOP_REQUESTED';
throw error;
}
return new Promise((resolve, reject) => {
if (stopRequested) {
const error = new Error('已请求停止,取消 AI 请求');
error.code = 'STOP_REQUESTED';
reject(error);
return;
}
const doneResolve = value => {
AccessGuard.markAIRequestDone();
resolve(value);
};
const doneReject = err => {
AccessGuard.markAIRequestDone();
reject(err);
};
panel.log(`🤖 正在使用 ${providerConf.name} 答题...`);
GM_xmlhttpRequest({
method: 'POST',
url: requestUrl,
headers: requestHeaders,
data: JSON.stringify(requestBody),
timeout: providerConf.timeout || 30000,
onload: res => {
if (res.status >= 200 && res.status < 300) {
try {
const json = JSON.parse(res.responseText);
const aiAnswerText = getAIResponseText(json);
if (aiAnswerText) {
let aiAnswer = aiAnswerText;
// 清理AI回答中可能的多余内容
aiAnswer = aiAnswer.replace(/^答案[::]\s*/i, '').replace(/^答[::]\s*/i, '').trim();
let answers = [];
if ((type === 1 || type === 2) && aiAnswer.includes('||')) {
// 多选题:每个正确选项一个答案;填空题:每个空一个答案。均用 || 分隔
answers = aiAnswer.split('||').map(a => a.trim()).filter(a => a);
} else {
answers = [aiAnswer];
}
panel.log(`🤖 AI答案:${answers.join(', ')}`);
doneResolve({ answers, remaining: -1 });
} else {
doneReject('AI未返回最终答案,请检查模型配置后重试');
}
} catch (e) {
doneReject(e instanceof SyntaxError ? 'AI响应解析失败' : (e.message || 'AI返回格式异常'));
}
} else if (res.status === 401) {
doneReject('API Key无效,请检查配置');
} else if (res.status === 429) {
doneReject('请求过于频繁,请稍后重试');
} else {
doneReject(`AI请求失败: HTTP ${res.status}`);
}
},
onerror: () => doneReject('AI网络错误'),
ontimeout: () => doneReject('AI请求超时')
});
});
},
// AI 兜底(题库未命中时调用):返回 {answers, used} 或 null
// logger: 接收消息字符串的回调(如 panel.log.bind(panel))
// label: 题目标号(如 "第 3 题")
async aiFallback(result, logger = console.log, label = '本题') {
if (Store.getUnmatchedFallback() !== 'ai') return null;
if (typeof stopRequested !== 'undefined' && stopRequested) {
logger(`⏹️ ${label}已请求停止,跳过 AI 兜底`);
return null;
}
if (!Store.getAIApiKey()) {
logger(`🤖 ${label}想用 AI 兜底但未配 API Key,跳过(请到【题库配置】填写 API Key)`);
return null;
}
const opts = (result?.options || []).filter(Boolean);
// 选择题/不定项需要选项;填空题(2)、判断题(3)、解答题(4)可不需要选项(靠题干作答)
if (!opts.length && result?.type !== 2 && result?.type !== 3 && result?.type !== 4) {
logger(`🤖 ${label}想用 AI 兜底但选项为空,跳过`);
return null;
}
try {
const typeMap = { 0: '单选题', 1: '多选题', 2: '填空题', 3: '判断题', 4: '其他' };
const detectedType = result.type !== undefined ? result.type : 4;
logger(`🤖 ${label}题库未匹配,调用 AI 兜底(题型:${typeMap[detectedType] || '未知'},选项数:${opts.length})...`);
const ai = await this.askAI(result.question, result.options || [], detectedType);
if (typeof stopRequested !== 'undefined' && stopRequested) {
logger(`⏹️ ${label}已请求停止,忽略 AI 返回`);
return null;
}
const rawAnswers = Array.isArray(ai?.answers) ? ai.answers : (ai?.answers ? [ai.answers] : []);
const answers = rawAnswers
.flatMap(answer => String(answer ?? '').split(/\s*\|\|\s*/))
.map(answer => answer.trim())
.filter(Boolean);
if (answers.length) {
logger(`🤖 AI 返回 ${answers.length} 个答案:${answers.join(' / ')}`);
return answers;
}
logger(`🤖 ${label} AI 未返回有效答案,跳过`);
return null;
} catch (err) {
logger(`🤖 ${label} AI 兜底失败:${err}`);
return null;
}
},
// AI生成评论/讨论回复
// pageContent: 页面正文内容, title: 标题, typeText: 类型(图文/讨论), existingComments: 已有评论列表
async generateAIComment(pageContent, title, typeText, existingComments = []) {
const apiKey = Store.getAIApiKey();
if (!apiKey) {
throw '未配置AI API Key,请在【题库配置】中填写';
}
const providerConf = getAIProviderConfig();
if (!providerConf.url || !providerConf.model) {
throw '自定义接口配置不完整,请填写接口地址和模型名称';
}
// 构建上下文信息
let contextText = '';
if (title) contextText += `标题:${title}\n`;
if (typeText) contextText += `类型:${typeText}\n`;
if (pageContent) {
// 截取前500字避免超token
const content = pageContent.length > 500 ? pageContent.substring(0, 500) + '...' : pageContent;
contextText += `正文内容:${content}\n`;
}
if (existingComments.length > 0) {
contextText += `\n其他同学的评论(参考风格但不要重复):\n${existingComments.slice(0, 5).map((c, i) => `${i + 1}. ${c}`).join('\n')}\n`;
}
const prompt = `你是一名大学生,正在参与课程${typeText || '讨论'}。请根据以下内容写一段简短的评论/回复。
${contextText}
要求:
- 用自然、口语化的中文表达,像真实学生一样
- 字数控制在30-80字之间
- 不要使用"首先、其次、最后"等模板化表达
- 可以表达个人观点、感悟、补充或提问
- 内容要与主题相关且有见解,不要说空话套话
- 不要使用markdown格式,直接输出纯文本
- 不要与已有评论内容雷同
- 只输出评论内容,不要加任何前缀或说明`;
let requestUrl = providerConf.url;
let requestHeaders = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` };
const requestBody = createAIRequestBody(providerConf, prompt, 0.8, providerConf.commentMaxTokens || 200);
await AccessGuard.beforeAIRequest(panel?.log?.bind(panel));
if (stopRequested) {
const error = new Error('已请求停止,取消 AI 请求');
error.code = 'STOP_REQUESTED';
throw error;
}
return new Promise((resolve, reject) => {
if (stopRequested) {
const error = new Error('已请求停止,取消 AI 请求');
error.code = 'STOP_REQUESTED';
reject(error);
return;
}
const doneResolve = value => {
AccessGuard.markAIRequestDone();
resolve(value);
};
const doneReject = err => {
AccessGuard.markAIRequestDone();
reject(err);
};
panel.log(`🤖 正在使用 ${providerConf.name} 生成评论...`);
GM_xmlhttpRequest({
method: 'POST',
url: requestUrl,
headers: requestHeaders,
data: JSON.stringify(requestBody),
timeout: providerConf.timeout || 30000,
onload: res => {
if (res.status >= 200 && res.status < 300) {
try {
const json = JSON.parse(res.responseText);
const commentText = getAIResponseText(json);
if (commentText) {
let comment = commentText;
// 清理可能的引号包裹和前缀
comment = comment.replace(/^["'""]|["'""]$/g, '').replace(/^(评论|回复)[::]\s*/i, '').trim();
panel.log(`🤖 AI生成评论:${comment.substring(0, 50)}${comment.length > 50 ? '...' : ''}`);
doneResolve(comment);
} else {
doneReject('AI未返回最终评论,请检查模型配置后重试');
}
} catch (e) {
doneReject(e instanceof SyntaxError ? 'AI响应解析失败' : (e.message || 'AI返回格式异常'));
}
} else if (res.status === 401) {
doneReject('API Key无效,请检查配置');
} else if (res.status === 429) {
doneReject('请求过于频繁,请稍后重试');
} else {
doneReject(`AI请求失败: HTTP ${res.status}`);
}
},
onerror: () => doneReject('AI网络错误'),
ontimeout: () => doneReject('AI请求超时')
});
});
},
// 返回当前题目的最小 DOM 作用域。提交、输入框都禁止向上扩大到整张试卷,
// 避免题内找不到按钮时误点击「提交试卷」。
getQuestionScope(rootEl) {
if (!rootEl) return null;
const root = rootEl.nodeType === 9 ? (rootEl.body || rootEl) : rootEl;
const marker = '.subject-item, .problem-body, .item-body, .question-item, .exam-question, .quiz-question, [data-question-id], [data-problem-id]';
const broadMarker = '.subject-item, .problem-body, .question-item, .exam-question, .quiz-question, [data-question-id], [data-problem-id]';
let candidate = null;
let current = root;
for (let depth = 0; current && depth < 8; depth++, current = current.parentElement) {
try {
if (!current.matches?.(marker)) continue;
// .item-body 常是题目内容子容器,优先提升到同题的 .subject-item,
// 这样可以找到同一题旁边的提交按钮但不会扩大到整张试卷。
if (current.matches?.(broadMarker)) return current;
if (!candidate) candidate = current;
} catch (_) { }
}
return candidate || root;
},
_findAnswerInputs(rootEl) {
const scope = this.getQuestionScope(rootEl);
if (!scope?.querySelectorAll) return [];
const selector = 'input[type="text"], input[type="number"], input:not([type]), textarea, [contenteditable="true"]';
return [...scope.querySelectorAll(selector)].filter(el => {
if (el.disabled || el.readOnly) return false;
// offsetParent 是页面上最可靠的隐藏判断;测试环境没有布局信息时放宽判断。
if (el.offsetParent !== null) return true;
if (el.ownerDocument?.defaultView?.getComputedStyle) {
try {
const style = el.ownerDocument.defaultView.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect?.();
if (rect && (rect.width > 0 || rect.height > 0)) return true;
} catch (_) { }
}
return typeof el.offsetParent === 'undefined';
});
},
_readAnswerInput(el) {
if (!el) return '';
const value = el.contentEditable === 'true' ? (el.innerText ?? el.textContent ?? '') : (el.value ?? '');
return String(value).replace(/\u00a0/g, ' ').trim();
},
// 供上层在翻页前复核当前题。requiredIndices 用于多选题,要求这些选项全部选中。
isQuestionComplete(rootEl, type, requiredIndices = null, optionEls = null) {
const scope = this.getQuestionScope(rootEl);
if (!scope) return false;
const inputs = this._findAnswerInputs(scope);
if (type === 2 || type === 4 || inputs.length) {
return inputs.length > 0 && inputs.every(input => !!this._readAnswerInput(input));
}
const options = Array.isArray(optionEls) && optionEls.length
? optionEls
: [...(scope.querySelectorAll?.('li, label, [role="radio"], [role="checkbox"], .el-radio, .el-checkbox') || [])];
const isSelected = node => {
if (!node) return false;
const input = node.matches?.('input[type="radio"],input[type="checkbox"]')
? node : node.querySelector?.('input[type="radio"],input[type="checkbox"]');
if (input?.checked) return true;
const aria = node.getAttribute?.('aria-checked');
if (aria === 'true') return true;
const selectedDescendant = node.querySelector?.('.is-checked,.checked,.selected,.active,[aria-checked="true"],[class*="-checked"]');
if (selectedDescendant) return true;
const cls = String(node.className || '') + ' ' + String(node.parentElement?.className || '');
return /(?:^|\s)(?:is-checked|checked|selected|active|ant-(?:radio|checkbox)-checked)(?:\s|$)/i.test(cls);
};
if (Array.isArray(requiredIndices) && requiredIndices.length) {
return requiredIndices.every(index => isSelected(options[index]));
}
return options.some(isSelected);
},
findScopedSubmitButton(rootEl) {
const scope = this.getQuestionScope(rootEl);
if (!scope?.querySelectorAll) return null;
const nodes = [];
if (scope.matches?.('button,a,[role="button"],.el-button,.ant-btn')) nodes.push(scope);
nodes.push(...scope.querySelectorAll('button,a,[role="button"],.el-button,.ant-btn'));
return nodes.find(btn => {
if (!this.isStructurallyVisible(btn) && btn.offsetParent === null) return false;
const text = String(btn.innerText || btn.textContent || '').replace(/\s+/g, '').trim();
if (!text || /提交作业|提交试卷|提交整卷|整卷|全部答案|交卷/.test(text)) return false;
// 单题提交只接受明确的单题文案,不能用 contains 把「提交作业」等整卷按钮带进来。
return new Set(['提交', '提交答案', '确认提交', '确认并提交', '提交本题']).has(text) &&
!btn.disabled && btn.getAttribute?.('aria-disabled') !== 'true';
}) || null;
},
_getExamDocuments(root = document) {
const docs = typeof Utils.collectAccessibleDocuments === 'function' ? Utils.collectAccessibleDocuments(root) : [root];
return docs.filter(doc => {
try {
const frame = doc?.defaultView?.frameElement;
const identity = `${frame?.id || ''} ${frame?.className || ''} ${frame?.title || ''}`;
return !/(?:ykt-helper-iframe|codex|assistant|sidebar|panel)/i.test(identity);
} catch (_) { return true; }
});
},
_isPageLevelExamNode(node) {
if (!node) return false;
const excluded = '.subject-item,.problem-body,.item-body,.question-item,.exam-question,.quiz-question,[data-question-id],[data-problem-id],#panel,#ykt-helper-iframe,[data-ykt-helper],.ykt-helper';
return !node.closest?.(excluded);
},
// 检查整卷提交后的明确成功证据。单题「已提交」按钮或普通提示不算整卷完成。
// allowTransientToast 仅由本次提交动作之后使用,避免初始页面残留 toast 误判。
isWholeExamSubmitted(root = document, { allowTransientToast = false, baselineNotices = null } = {}) {
const docs = this._getExamDocuments(root);
for (const doc of docs) {
let bodyText = '';
try {
const body = doc.body;
const clone = body?.cloneNode?.(true);
clone?.querySelectorAll?.('#panel,#ykt-helper-iframe,[data-ykt-helper],.ykt-helper,.subject-item,.problem-body,.item-body,.question-item,.exam-question,.quiz-question,[data-question-id],[data-problem-id],[role="dialog"],.el-dialog,.ant-modal,.modal')?.forEach(node => node.remove());
bodyText = String(clone ? (clone.innerText ?? clone.textContent ?? '') : (body?.innerText || body?.textContent || '')).replace(/\s+/g, '');
} catch (_) { }
const resultPath = (() => {
try {
const href = String(doc.defaultView?.location?.href || (doc === document ? location.href : '') || '');
return /\/(?:exam|exercise|homework|studentquiz)\/(?:[^?#]*?\/)?(?:result|review|score|success|submitted)(?:[/_.?&=#]|$)/i.test(href);
} catch (_) { return false; }
})();
const successText = /(?:整卷|试卷|答卷|作业|考试|全部答案)(?:已提交|提交成功|已交卷)|(?:交卷成功|已交卷)/.test(bodyText);
if (resultPath || successText) return true;
let nodes = [];
try {
nodes = [...doc.querySelectorAll('.submit-success,.exam-result,.exam-result-page,[data-submitted="true"],[data-submit-success="true"]')];
} catch (_) { }
if (nodes.some(node => {
if (!this.isStructurallyVisible(node)) return false;
const text = String(node.innerText || node.textContent || '').replace(/\s+/g, '');
if (!/(?:整卷|试卷|答卷|作业|考试|全部答案)(?:已提交|提交成功|已交卷)|(?:交卷成功|已交卷)/.test(text)) return false;
return this._isPageLevelExamNode(node);
})) return true;
if (allowTransientToast) {
let notices = [];
try { notices = [...doc.querySelectorAll('.el-message,.el-message-box__message,.ant-message-notice-content,.toast,.notification,.message')]; } catch (_) { }
if (notices.some(node => {
if (!this.isStructurallyVisible(node)) return false;
const text = String(node.innerText || node.textContent || '').replace(/\s+/g, '');
if (!/(?:提交成功|交卷成功|已交卷|已提交)/.test(text)) return false;
if (baselineNotices?.get(node) === text) return false;
return this._isPageLevelExamNode(node);
})) return true;
}
// 仅页面级的「试卷已提交/答卷已提交/已交卷」状态可以作为整卷成功证据,
// 题目容器内的「已提交」状态忽略。
let buttons = [];
try { buttons = [...doc.querySelectorAll('button,a,[role="button"],.el-button,.ant-btn')]; } catch (_) { }
if (buttons.some(btn => {
if (!this.isStructurallyVisible(btn) && btn.offsetParent === null) return false;
const text = String(btn.innerText || btn.textContent || '').replace(/\s+/g, '').trim();
if (!/^(答卷已提交|试卷已提交|已交卷)$/.test(text)) return false;
return this._isPageLevelExamNode(btn);
})) return true;
}
return false;
},
// 只接受调用方明确确认全卷已完成时的整卷提交。默认不接受普通「提交」按钮,
// 避免最终提交辅助被错误复用于单题按钮。
async submitWholeExam({ confirmComplete = false, root = document, allowGeneric = false, logger = null } = {}) {
const log = logger || panel?.log?.bind(panel) || console.log;
if (stopRequested) return false;
if (confirmComplete !== true) {
log('⚠️ 未确认整卷已完成,跳过最终提交');
return false;
}
if (this.isWholeExamSubmitted(root)) {
log('✅ 检测到整卷已提交');
return true;
}
const docs = this._getExamDocuments(root);
const baselineNotices = new Map();
for (const doc of docs) {
for (const node of doc.querySelectorAll('.el-message,.el-message-box__message,.ant-message-notice-content,.toast,.notification,.message')) {
baselineNotices.set(node, String(node.innerText || node.textContent || '').replace(/\s+/g, ''));
}
}
for (const doc of docs) {
let buttons = [];
try { buttons = [...doc.querySelectorAll('button,a,[role="button"],.el-button,.ant-btn')]; } catch (_) { continue; }
const button = buttons.find(btn => {
if (!this.isStructurallyVisible(btn) && btn.offsetParent === null) return false;
const text = String(btn.innerText || btn.textContent || '').replace(/\s+/g, '').trim();
if (!text || btn.disabled || btn.getAttribute?.('aria-disabled') === 'true') return false;
const whole = /^(?:提交试卷|提交整卷|提交作业|提交答卷|提交测验|提交全部答案|完成并提交|提交考试|交卷)$/.test(text);
if (!whole && !allowGeneric) return false;
if (!whole && !/^(?:提交|提交全部)$/.test(text)) return false;
if (!this._isPageLevelExamNode(btn)) return false;
return /提交|交卷|完成/.test(text);
});
if (!button) continue;
log('正在提交整卷...');
const clicked = await Utils.safeClick(button, { scroll: false });
if (!clicked || (typeof stopRequested !== 'undefined' && stopRequested)) return false;
// 雨课堂部分页面会在整卷提交后弹出二次确认;只接受位于页面级作用域的确认按钮。
await Utils.sleep(350);
const findConfirmation = () => {
let confirmations = [];
try { confirmations = [...doc.querySelectorAll('button,a,[role="button"],.el-button,.ant-btn')]; } catch (_) { confirmations = []; }
return confirmations.find(btn => {
if (!this.isStructurallyVisible(btn) && btn.offsetParent === null) return false;
if (btn.disabled || btn.getAttribute?.('aria-disabled') === 'true') return false;
const text = String(btn.innerText || btn.textContent || '').replace(/\s+/g, '').trim();
if (!/^(确认提交|确定提交|确认交卷|确定交卷|提交|交卷|确定|确认)$/.test(text)) return false;
const dialog = btn.closest?.('[role="dialog"],.el-dialog,.ant-modal,.modal,[class*="dialog"],[class*="modal"]');
if (!dialog || !this.isStructurallyVisible(dialog)) return false;
const dialogText = String(dialog.innerText || dialog.textContent || '').replace(/\s+/g, '');
if (!/(提交|交卷)/.test(dialogText)) return false;
return this._isPageLevelExamNode(btn);
});
};
let confirmButton = findConfirmation();
if (!confirmButton && !this.isWholeExamSubmitted(root, { allowTransientToast: true, baselineNotices })) {
await Utils.poll(() => {
confirmButton = findConfirmation();
return !!confirmButton || this.isWholeExamSubmitted(root, { allowTransientToast: true, baselineNotices });
}, { interval: 250, timeout: 3000, cancelOnStop: true });
}
if (stopRequested) return false;
if (confirmButton) {
const confirmed = await Utils.safeClick(confirmButton, { scroll: false });
if (!confirmed || (typeof stopRequested !== 'undefined' && stopRequested)) return false;
}
const submitted = await Utils.poll(() => this.isWholeExamSubmitted(root, { allowTransientToast: true, baselineNotices }), {
interval: 300,
timeout: 8000,
cancelOnStop: true
});
if (!submitted) log('⚠️ 点击后未检测到整卷提交成功状态,已保留断点');
return submitted;
}
log('⚠️ 未找到安全的整卷提交按钮,请手动提交');
return false;
},
// 填空题/解答题:将答案逐个写入输入框(兼容 Vue/React 受控组件,支持一题多个空)。
// 已有内容保留,只填充空白输入框;返回本次实际写入的输入框数量。
fillBlanks(answers, rootEl) {
if (!rootEl || !answers || !answers.length) return 0;
const inputs = this._findAnswerInputs(rootEl);
if (!inputs.length) return 0;
let normalized = answers.flatMap(answer => String(answer ?? '').split(/\s*\|\|\s*/)).map(a => a.trim()).filter(Boolean);
// 单输入框多答案是主观题/兼容旧页面的兜底;多输入框必须逐空对应,不能把一个答案误填到所有空。
if (inputs.length === 1 && normalized.length > 1) normalized = [normalized.join(' ')];
const existing = inputs.map(input => this._readAnswerInput(input));
const values = existing.slice();
let answerIndex = 0;
for (let i = 0; i < inputs.length && answerIndex < normalized.length; i++) {
if (values[i]) continue;
values[i] = normalized.length >= inputs.length ? normalized[i] : normalized[answerIndex++];
if (normalized.length >= inputs.length) answerIndex++;
}
const writeOne = (el, val) => {
try { el.focus(); } catch (_) {}
if (el.contentEditable === 'true') {
el.textContent = val;
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: val, inputType: 'insertText' }));
} else {
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (setter) setter.call(el, val);
else el.value = val;
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: val, inputType: 'insertText' }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
}
try { el.blur(); } catch (_) {}
};
let filledCount = 0;
for (let i = 0; i < inputs.length; i++) {
if (existing[i] || !values[i]) continue;
try { writeOne(inputs[i], values[i]); filledCount++; }
catch (_) { try { inputs[i].value = values[i]; filledCount++; } catch (_) {} }
}
return filledCount;
},
async autoSelectAndSubmit(answers, itemBodyElement, decodedOptions, opts = {}) {
const canAct = () => {
if (stopRequested) return false;
try { return typeof opts.guard !== 'function' || opts.guard() === true; }
catch (_) { return false; }
};
if (!canAct()) return false;
const autoSubmit = opts.autoSubmit !== false; // 默认提交;考试长条模式应传 false
const type = opts.type; // 题型:2=填空、4=解答/主观(由调用方传入,用于分支到填空写入)
if (!itemBodyElement || !answers || !answers.length) {
panel.log('⚠️ 未获取到答案,请人工检查');
return false;
}
const suppliedOptionNodes = Array.isArray(opts.optionNodes) ? [...opts.optionNodes] : null;
if (suppliedOptionNodes && (suppliedOptionNodes.length < 2 ||
new Set(suppliedOptionNodes).size !== suppliedOptionNodes.length ||
suppliedOptionNodes.some(node => !itemBodyElement.contains?.(node) || !this.isStructurallyVisible(node)))) return false;
// 只在当前题作用域内提交。绝不向 document 兜底查找,避免误点「提交试卷」。
const doSubmit = async () => {
if (!canAct()) return false;
if (!autoSubmit) {
// 考试长条模式:只作答,不点提交(整卷提交由 submitWholeExam 负责)。
return true;
}
const submitBtn = this.findScopedSubmitButton(itemBodyElement);
if (submitBtn) {
const originalScope = this.getQuestionScope(itemBodyElement);
const questionIdentity = scope => {
const id = scope?.getAttribute?.('data-question-id') || scope?.getAttribute?.('data-problem-id');
if (id) return String(id);
const stem = scope?.querySelector?.('.question-title,.problem-title,.question-stem,.problem-stem,.stem');
return String(stem?.innerText || stem?.textContent || '').replace(/\s+/g, '').trim();
};
const originalIdentity = questionIdentity(originalScope);
panel.log('正在提交...');
const clicked = await Utils.safeClick(submitBtn, { scroll: false, guard: canAct });
if (!clicked || !canAct()) return false;
// loading 可能使按钮暂时禁用/消失,只有明确的提交状态或题目身份变化才算成功。
const accepted = await Utils.poll(() => {
if (!canAct()) return false;
const scope = this.getQuestionScope(itemBodyElement);
if (!scope) return false;
const statuses = [...(scope.querySelectorAll?.('button,[role="status"],.question-status,.answer-status,.answer-result,.submit-success') || [])];
if (statuses.some(node => {
if (!this.isStructurallyVisible(node)) return false;
const text = String(node.innerText || node.textContent || '').replace(/\s+/g, '');
return /^(?:已提交|已作答|提交成功|答案已提交|本题已提交)$/.test(text);
})) return true;
const currentIdentity = questionIdentity(scope);
return !!(originalIdentity && currentIdentity && originalIdentity !== currentIdentity);
}, { interval: 250, timeout: 3000, cancelOnStop: true });
if (!accepted) panel.log('⚠️ 点击后未确认当前题提交成功');
return accepted;
} else {
panel.log('⚠️ 当前题作用域内未找到提交按钮,请手动提交');
return false;
}
};
// 填空题(2)/解答题(4) 或 页面无选项但有输入框 → 走填空写入分支
const isFillType = type === 2 || type === 4;
const hasFillInputs = () => this._findAnswerInputs(itemBodyElement).length > 0;
if (!suppliedOptionNodes && (isFillType || hasFillInputs())) {
const filled = this.fillBlanks(answers, itemBodyElement);
if (filled > 0) {
panel.log(`✏️ 已填入 ${filled} 个空:${answers.join(' | ')}`);
await Utils.sleep(300);
if (!canAct()) return false;
}
// 所有输入框都必须有内容,部分填空不得提交或推进。
if (this.isQuestionComplete(itemBodyElement, type)) {
return await doSubmit();
}
// 没找到输入框/仍有空白:若题型明确是填空/解答则直接失败;否则继续走选择题逻辑(兜底)。
if (isFillType) {
panel.log('⚠️ 填空题仍有未填写的空,暂不提交,请手动作答');
return false;
}
}
// 处理答案格式,使用改进的匹配逻辑
let targetIndices = [];
const letterMap = { 'A': 0, 'B': 1, 'C': 2, 'D': 3, 'E': 4, 'F': 5, 'G': 6 };
// 获取页面上的选项文本,用于相似度匹配
// 查找选项容器:多级查找 + 验证是否为真正的选项列表
const findOptionsList = (root) => {
if (!root) return null;
// 优先精确选择器
const selectors = [
'ul.list-inline.pt10',
'.list-inline.list-unstyled-radio',
'.list-unstyled.list-unstyled-radio',
'ul.list-unstyled-radio',
'ul.list-unstyled-checkbox',
'ul.list-unstyled',
'ul.list-inline',
'ul.list',
'ul'
];
for (const sel of selectors) {
const candidates = root.querySelectorAll(sel);
for (const ul of candidates) {
// 验证:必须有至少2个li子元素,且不是导航栏(排除只含按钮的ul)
const lis = ul.querySelectorAll('li');
if (lis.length >= 2) {
const hasButton = ul.querySelector('button.el-button');
const isNav = ul.closest('.problem-fixedbar') || ul.closest('.aside-body');
if (!hasButton && !isNav) return ul;
}
}
}
return null;
};
let studentQuizOptionNodes = [];
if (!suppliedOptionNodes && this.isStudentQuizPage()) {
const quizRoot = itemBodyElement || this.findStudentQuizQuestionArea(document);
studentQuizOptionNodes = this.findStudentQuizOptionNodes(quizRoot);
}
let listContainer = suppliedOptionNodes || studentQuizOptionNodes.length >= 2 ? null : findOptionsList(itemBodyElement);
if (!suppliedOptionNodes && !listContainer) {
const parent = itemBodyElement.closest('.subject-item') || itemBodyElement.parentElement;
if (!studentQuizOptionNodes.length) listContainer = findOptionsList(parent);
}
// 不再从整张试卷的 mainArea 兜底抓取选项;题目作用域内找不到就失败,
// 防止当前题误操作到其他题的选项。
let aiOptionNodes = [];
if (!suppliedOptionNodes && !listContainer && this.isAiWorkspaceExercisePage()) {
const aiRoot = itemBodyElement || this.findAiWorkspaceQuestionArea(document);
aiOptionNodes = this.findAiWorkspaceOptionNodes(aiRoot);
}
const optionEls = suppliedOptionNodes || (listContainer ? Array.from(listContainer.querySelectorAll('li')) : (aiOptionNodes.length ? aiOptionNodes : studentQuizOptionNodes));
const domOptions = Array.from(optionEls).map(li => {
if (suppliedOptionNodes) {
const title = li.querySelector('.item-container .title');
return readDecodedElementText(title)
.replace(/\s+/g, '').toLowerCase();
}
return this.getJudgeOptionText(li).toLowerCase();
});
let pageOptions = domOptions.slice();
// 如果有解码后的选项数据,优先使用(因为DOM文本可能是字体混淆的)
if (Array.isArray(decodedOptions) && decodedOptions.length > 0) {
pageOptions = decodedOptions.map((opt, index) => {
const decoded = suppliedOptionNodes
? String(opt).replace(/\s+/g, '').toLowerCase()
: String(opt).replace(/^[A-Ga-g][.、\s]\s*/, '').trim().toLowerCase();
return decoded || domOptions[index] || '';
});
console.log('[雨课堂助手] 使用已识别选项:', pageOptions);
} else if (optionEls.length > 0) {
console.log('[雨课堂助手] 使用DOM选项:', pageOptions);
} else {
console.log('[雨课堂助手] 未找到选项容器');
}
const judgeOptionIndex = expectedValue => {
const index = optionEls.findIndex(node => this.getJudgeOptionValue(node) === expectedValue);
if (index >= 0) return index;
const textIndex = pageOptions.findIndex(option => {
const value = String(option || '').replace(/\s+/g, '').toLowerCase();
return expectedValue === 'true'
? /^(正确|对|true|yes|√|t)$/.test(value)
: /^(错误|错|false|no|×|✗|f)$/.test(value);
});
// 老版本/非标准页面没有语义时,保留正确在前、错误在后的顺序兜底。
return textIndex >= 0 ? textIndex : (expectedValue === 'true' ? 0 : 1);
};
// 题库有时把多选/多空压成一个字符串;仅对明确的分隔格式展开,避免拆散主观题文本。
const answerItems = answers.flatMap(answer => {
const text = String(answer ?? '').trim();
if ((type === 1 || type === 2) && text.includes('||')) {
return text.split(/\s*\|\|\s*/).map(item => item.trim()).filter(Boolean);
}
if (type === 1 && /^[A-G](?:\s*[,,、;;/]\s*[A-G])+$/i.test(text)) {
return text.split(/\s*[,,、;;/]\s*/).map(item => item.trim()).filter(Boolean);
}
return [text];
}).filter(Boolean);
const resolveAnswerIndices = answer => {
const answerStr = String(answer).trim()
.replace(/^答案[::]\s*/i, '')
.replace(/[。.!!??]+$/, '')
.trim();
const answerUpper = answerStr.toUpperCase();
const answerLower = answerStr.toLowerCase().replace(/\s/g, '');
// 多选题中的“正确/错误”也是普通选项文字,不得提前退出剩余答案匹配。
if (type === 3 && /^(对|正确|√|T|true|是|yes|right)$/i.test(answerStr)) {
return [judgeOptionIndex('true')];
}
if (type === 3 && /^(错|错误|×|✗|F|不是|false|否|no|wrong)$/i.test(answerStr)) {
return [judgeOptionIndex('false')];
}
// 部分题库把判断题答案编码为 1=正确、0=错误;先按语义映射,避免误当成 B/A 索引。
if (type === 3 && /^(0|1)$/.test(answerStr)) {
return [judgeOptionIndex(answerStr === '1' ? 'true' : 'false')];
}
// 1.5 纯数字答案(0-based索引,如 0=A, 1=B, 2=C)
if (/^\d+$/.test(answerStr)) {
const idx = parseInt(answerStr, 10);
if (idx >= 0 && idx < optionEls.length) return [idx];
}
// 1.8 带前缀的字母答案(如 "E. 机体作为..." → E,"A、没有疾病" → A)
const letterPrefixMatch = answerStr.match(/^([A-Ga-g])\s*[.、.::))]/);
if (letterPrefixMatch) {
const ch = letterPrefixMatch[1].toUpperCase();
if (letterMap[ch] !== undefined) return [letterMap[ch]];
}
// 2. 纯字母答案(如 "A", "AC", "BCD")
if (/^[A-G]+$/.test(answerUpper) && answerUpper.length <= 7) {
let isOrdered = true;
for (let i = 1; i < answerUpper.length; i++) {
if (answerUpper.charCodeAt(i) <= answerUpper.charCodeAt(i - 1)) {
isOrdered = false;
break;
}
}
if (isOrdered) {
return [...answerUpper].map(ch => letterMap[ch]);
}
}
// 3. 精确匹配选项文本
const exactIdx = pageOptions.indexOf(answerLower);
if (exactIdx >= 0) return [exactIdx];
// 4. 包含匹配(答案文本是选项的子串或选项包含答案)
if (answerLower.length >= 2 && pageOptions.length > 0) {
const containIdx = pageOptions.findIndex(opt => opt && (opt.includes(answerLower) || answerLower.includes(opt)));
if (containIdx >= 0) return [containIdx];
}
// 5. 相似度匹配
if (answerLower.length >= 2 && pageOptions.length > 0) {
const ratings = pageOptions.map(opt => stringSimilarity(answerLower, opt));
const maxScore = Math.max(...ratings);
if (maxScore > 60) {
const bestIdx = ratings.indexOf(maxScore);
return [bestIdx];
}
}
return [];
};
const answerMatches = answerItems.map(resolveAnswerIndices);
const unmatched = answerMatches.some(indices => !indices.length || indices.some(index => !Number.isInteger(index) || index < 0 || index >= optionEls.length));
if (unmatched) {
panel.log(`⚠️ 答案未完整匹配:${answers.join(', ')}`);
return false;
}
targetIndices = answerMatches.flat();
if (!listContainer && optionEls.length === 0) {
panel.log('⚠️ 未找到选项容器');
return false;
}
targetIndices = [...new Set(targetIndices.filter(index => Number.isInteger(index) && index >= 0 && index < optionEls.length))];
const validTargetIndices = targetIndices.filter(idx => Number.isInteger(idx) && idx >= 0 && idx < optionEls.length);
if (!validTargetIndices.length) {
panel.log(`⚠️ 无法解析答案: ${answers.join(', ')}`);
return false;
}
if (suppliedOptionNodes && type !== 1 && validTargetIndices.length !== 1) return false;
panel.log(`✅ 题库答案:${answers.join(', ')} → 选择第 ${validTargetIndices.map(i => String.fromCharCode(65 + i)).join('')} 项`);
const options = optionEls;
const isSelected = node => {
if (!node) return false;
if (suppliedOptionNodes && node.classList?.contains('select')) return true;
const input = node.matches?.('input[type="radio"],input[type="checkbox"]')
? node : node.querySelector?.('input[type="radio"],input[type="checkbox"]');
if (input?.checked) return true;
if (node.getAttribute?.('aria-checked') === 'true') return true;
const selectedDescendant = node.querySelector?.('.is-checked,.checked,.selected,.active,[aria-checked="true"],[class*="-checked"]');
if (selectedDescendant) return true;
const cls = String(node.className || '') + ' ' + String(node.parentElement?.className || '');
return /(?:^|\s)(?:is-checked|checked|selected|active|ant-(?:radio|checkbox)-checked)(?:\s|$)/i.test(cls);
};
const optionsStillCurrent = () => canAct() && (!suppliedOptionNodes ||
options.every(node => itemBodyElement.contains(node) && this.isStructurallyVisible(node)));
// 学伴多选允许切换选项;先清掉此前误选的非目标项,再补齐答案。
// 普通题目的既有选择行为保持原样。
if (suppliedOptionNodes && type === 1) {
for (let index = 0; index < options.length; index++) {
if (!optionsStillCurrent()) return false;
if (validTargetIndices.includes(index) || !isSelected(options[index])) continue;
try { options[index].click(); } catch (_) { return false; }
if (!optionsStillCurrent()) return false;
await Utils.sleep(300);
if (!optionsStillCurrent() || isSelected(options[index])) return false;
}
}
for (const idx of validTargetIndices) {
if (!optionsStillCurrent()) return false;
if (!options[idx]) continue;
// 保留用户/前序步骤已有选择,多选不能再次点击已选项(会被反选)。
if (isSelected(options[idx])) continue;
const clickable = suppliedOptionNodes ? options[idx] : options[idx].querySelector('label.el-radio') ||
options[idx].querySelector('label.el-checkbox') ||
options[idx].querySelector('.ant-radio-wrapper') ||
options[idx].querySelector('.ant-checkbox-wrapper') ||
options[idx].querySelector('[role="radio"]') ||
options[idx].querySelector('[role="checkbox"]') ||
options[idx].querySelector('.el-radio__input') ||
options[idx].querySelector('.el-checkbox__input') ||
options[idx].querySelector('.el-radio__label') ||
options[idx].querySelector('.el-checkbox__label') ||
options[idx].querySelector('input') ||
options[idx];
try { clickable.click(); } catch (_) {
panel.log(`⚠️ 第 ${String.fromCharCode(65 + idx)} 项点击失败`);
return false;
}
if (!optionsStillCurrent()) return false;
await Utils.sleep(300);
if (!optionsStillCurrent()) return false;
}
// 逐项点击后复核目标选项全部处于选中状态,尤其是多选题。
if (!validTargetIndices.every(idx => isSelected(options[idx]))) {
panel.log('⚠️ 选项未全部选中,暂不提交');
return false;
}
if (suppliedOptionNodes && options.some((node, index) => isSelected(node) !== validTargetIndices.includes(index))) return false;
return await doSubmit();
}
};
// ---- v2 逻辑 ----
class V2Runner {
constructor(panel) {
this.panel = panel;
this.baseUrl = Utils.stableProgressKey('v2-course');
const current = Store.migrateProgress(this.baseUrl, location.href);
this.outside = current.outside;
this.inside = current.inside;
}
updateProgress(outside, inside = 0) {
this.outside = outside;
this.inside = inside;
Store.setProgress(this.baseUrl, outside, inside);
}
_findAccessibleNode(selector, preferredDoc = null) {
const docs = Utils.collectAccessibleDocuments(document);
if (preferredDoc) {
const index = docs.indexOf(preferredDoc);
if (index > 0) docs.unshift(docs.splice(index, 1)[0]);
else if (index < 0) docs.unshift(preferredDoc);
}
for (const doc of docs) {
try {
const node = doc.querySelector(selector);
if (node) return node;
} catch (_) { }
}
return null;
}
// 查找课程列表项:优先 .logs-list,然后尝试备选选择器
// 在指定文档中搜索列表(复用逻辑)
_searchListInDoc(doc) {
if (!doc) return null;
// a) .logs-list(用 children 排除文本节点)
const logsList = doc.querySelector('.logs-list');
if (logsList && logsList.children.length) {
console.log(`[雨课堂助手] 匹配: .logs-list (${logsList.children.length} items)`);
return { container: logsList, items: logsList.children };
}
// b) .el-timeline
const timeline = doc.querySelector('.el-timeline');
if (timeline) {
const items = timeline.querySelectorAll(':scope > .el-timeline-item');
if (items.length) return { container: timeline, items };
}
// c) 在各种可能的容器内查找 .logs-list(深层嵌套)
for (const rootSel of ['.section-fr', '.section-f1', '#chapter-section', '.viewContainer', '.studentLog_view']) {
const root = doc.querySelector(rootSel);
if (!root) continue;
const deepLogs = root.querySelector('.logs-list');
if (deepLogs && deepLogs.children.length) {
console.log(`[雨课堂助手] 匹配: ${rootSel} > .logs-list (${deepLogs.children.length} items)`);
return { container: deepLogs, items: deepLogs.children };
}
}
// d) .section-fr 内 .leaf-detail(iframe 新版 v2 结构:每个 leaf-detail 是一个视频/作业等)
const sectionFr = doc.querySelector('.section-fr');
if (sectionFr) {
const leafItems = sectionFr.querySelectorAll('.leaf-detail');
if (leafItems.length) {
console.log(`[雨课堂助手] 匹配: .section-fr .leaf-detail (${leafItems.length} items)`);
return { container: sectionFr, items: leafItems, leafMode: true };
}
}
// d) 常见列表容器选择器
const vc = doc.querySelector('.viewContainer') || doc.querySelector('.studentLog_view') || doc.querySelector('.section-fr') || doc.body;
if (vc) {
for (const sel of ['.timeline', '.cell_list', '.course-activity', '.student-log-list', '.learning-activity']) {
const sub = vc.querySelector(sel);
if (sub && sub.children.length) return { container: sub, items: sub.children };
}
// 只匹配 div > .content-box(不再匹配裸 section,避免误中布局面板)
const boxItems = vc.querySelectorAll('div:has(> .content-box)');
if (boxItems.length > 1) {
console.log(`[雨课堂助手] 匹配: content-box 兜底 (${boxItems.length} items)`);
return { container: boxItems[0].parentElement, items: boxItems };
}
}
return null;
}
_findCourseListItems() {
// 1) 主文档搜索
const mainResult = this._searchListInDoc(document);
if (mainResult) return mainResult;
// 2) iframe 内搜索(新版雨课堂某些课程把内容放在 iframe 内)
try {
const iframes = document.querySelectorAll('iframe.tab-pane-content-iframe, iframe[src*="studentLog"], iframe[src*="course"]');
for (const iframe of iframes) {
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (!iframeDoc) continue;
const iframeResult = this._searchListInDoc(iframeDoc);
if (iframeResult) {
console.log('[雨课堂助手] 在 iframe 中找到课程列表');
return iframeResult;
}
}
// 诊断(仅一次):输出 iframe 内 .section-fr 结构
if (!this._iframeDiagDone) {
this._iframeDiagDone = true;
for (const iframe of iframes) {
try {
const iDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (!iDoc) continue;
const sectionFr = iDoc.querySelector('.section-fr');
if (sectionFr) {
const frChildren = Array.from(sectionFr.querySelectorAll('*')).slice(0, 100);
const frClasses = [...new Set(frChildren.map(t => typeof t.className === 'string' ? t.className : '').filter(Boolean))].slice(0, 30);
console.log(`[雨课堂助手] iframe .section-fr 内部 class(前30): ${frClasses.join(' | ')}`);
console.log(`[雨课堂助手] iframe .section-fr children=${sectionFr.children.length}, innerHTML前500:`, (sectionFr.innerHTML || '').slice(0, 500));
break;
}
} catch (_) {}
}
}
} catch (e) {
console.log('[雨课堂助手] iframe 访问失败(可能跨域):', e.message);
}
// 3) .el-tabs__content 内的 tab pane 搜索
const tabContent = document.querySelector('.el-tabs__content');
if (tabContent) {
for (const pane of tabContent.children) {
const paneResult = this._searchListInDoc({ querySelector: s => pane.querySelector(s), querySelectorAll: s => pane.querySelectorAll(s), body: pane });
if (paneResult) return paneResult;
}
}
return null;
}
async run() {
preventScreenCheck();
this.panel.log(`检测到已播放到第 ${this.outside} 集,继续刷课...`);
let diagLogged = false;
while (true) {
// 检查是否请求停止
if (stopRequested) {
this.panel.log('⏸️ 已停止刷课');
this.panel.resetStartButton('继续刷课');
return;
}
await this.autoSlide();
const found = this._findCourseListItems();
const list = found?.items;
if (!list || !list.length) {
if (!diagLogged) {
diagLogged = true;
// 诊断:输出 DOM 结构帮助排查(深度3层)
const descTag = (el) => `${el.tagName}.${(el.className || '').split(' ').slice(0, 3).join('.')}`;
const dumpTree = (root, depth = 0) => {
if (!root || depth > 3) return [];
const lines = [];
Array.from(root.children).slice(0, 8).forEach(c => {
lines.push(' '.repeat(depth) + descTag(c) + ` [${c.children.length}ch]`);
if (c.children.length <= 5) lines.push(...dumpTree(c, depth + 1));
});
return lines;
};
const vc = document.querySelector('.viewContainer');
if (vc) {
const tree = dumpTree(vc);
console.log('[雨课堂助手] viewContainer DOM 树:\n' + tree.join('\n'));
this.panel.log(`未找到课程列表,DOM 诊断已输出到控制台(F12)`);
} else {
console.log('[雨课堂助手] .viewContainer 未找到');
this.panel.log('未找到课程列表容器,稍后重试');
}
} else {
this.panel.log('未找到课程列表,稍后重试');
}
await Utils.sleep(2000);
continue;
}
console.log(`当前集数:${this.outside}/全部集数${list.length}`);
if (this.outside >= list.length) {
this.panel.log('课程刷完啦 🎉');
this.panel.resetStartButton('刷完啦~');
Store.removeProgress(this.baseUrl);
break;
}
let course, type;
const listItem = list[this.outside];
if (found.leafMode) {
// iframe 新版 v2 结构:.leaf-detail 就是可点击项
course = listItem;
const iconCls = listItem?.querySelector('.iconfont')?.className || '';
if (iconCls.includes('icon--shipin')) type = 'shipin';
else if (iconCls.includes('icon--zuoye')) type = 'piliang';
else if (iconCls.includes('icon--tuwen')) type = 'kejian';
else if (iconCls.includes('icon--kaoshi')) type = 'kaoshi';
else type = 'piliang';
} else {
course = listItem?.querySelector('.content-box')?.querySelector('section');
type = course?.querySelector('.tag')?.querySelector('use')?.getAttribute('xlink:href') || 'piliang';
}
if (!course) {
this.panel.log('未找到当前课程节点,跳过');
this.updateProgress(this.outside + 1, 0);
continue;
}
// Check if course is already completed
const itemText = listItem?.innerText || '';
if (itemText.includes('100%') || itemText.includes('\u5df2\u5b8c\u6210')) {
this.panel.log(`\u7b2c ${this.outside + 1} \u4e2a\u5df2\u5b8c\u6210\uff0c\u8df3\u8fc7`);
this.updateProgress(this.outside + 1, 0);
continue;
}
// 每次进入新课程前清空拦截数据,防止跨课程数据残留导致内存泄漏
interceptedProblems = null;
this.panel.log(`刷课状态:第 ${this.outside + 1}/${list.length} 个,类型 ${type}`);
if (type.includes('shipin')) {
if (await this.handleVideo(course) === false) return;
} else if (type.includes('piliang')) {
if (await this.handleBatch(course, list) === false) return;
} else if (type.includes('ketang')) {
const featureFlags = Store.getFeatureConf();
if (featureFlags.skipLive) {
this.panel.log(`第 ${this.outside + 1} 个为直播课/课堂,已设置跳过`);
this.updateProgress(this.outside + 1, 0);
} else {
if (await this.handleClassroom(course) === false) return;
}
} else if (type.includes('kejian')) {
if (await this.handleCourseware(course) === false) return;
} else if (type.includes('kaoshi')) {
this.panel.log('考试区域脚本会被屏蔽,已跳过');
this.updateProgress(this.outside + 1, 0);
} else {
this.panel.log('非视频/批量/课件/考试,已跳过');
this.updateProgress(this.outside + 1, 0);
}
await Utils.maybeStudyBreak(this.outside, this.panel.log.bind(this.panel));
}
}
async autoSlide() {
const frequency = Math.floor((this.outside + 1) / 20) + 1;
for (let i = 0; i < frequency; i++) {
Utils.scrollToBottom('.viewContainer');
await Utils.sleep(800);
}
}
async handleVideo(course) {
const contextDoc = course.ownerDocument || document;
await Utils.safeClick(course);
await Utils.sleep(3000);
if (stopRequested) {
this.panel.resetStartButton('继续刷课');
return false;
}
const progressText = () => this._findAccessibleNode('.progress-wrap .text', contextDoc)?.innerHTML || '';
// 部分版本没有进度文字节点,此时以媒体自然结束为准;有进度节点时必须
// 等到明确 100%/完成,避免 98%/99% 的中间同步状态提前切课。
const progressDone = () => {
const text = progressText();
return !text || Utils.isProgressDone(text);
};
const title = this._findAccessibleNode('.title', contextDoc)?.innerText || '视频';
const isDeadline = this._findAccessibleNode('.box', contextDoc)?.innerText.includes('已过考核截止时间');
if (isDeadline) this.panel.log(`${title} 已过截止,进度不再增加,将直接跳过`);
let video = this._findAccessibleNode('video', contextDoc);
let waitCount = 0;
while (!video && waitCount < 15) {
if (stopRequested) return false;
await Utils.sleep(1000);
video = this._findAccessibleNode('video', contextDoc);
waitCount++;
}
if (!video && !isDeadline) {
this.panel.log('⚠️ 未找到视频播放器,已停留当前课程');
this.panel.resetStartButton('继续刷课');
return false;
}
let rateGuard = null;
if (video) {
video.volume = 0;
rateGuard = Player.applySpeed(contextDoc, video);
await Utils.sleep(1500);
if (stopRequested) {
rateGuard?.();
video.pause?.();
this.panel.resetStartButton('继续刷课');
return false;
}
await video.play().catch(() => {});
}
rateGuard = Player.applySpeed(contextDoc, video) || rateGuard;
Player.mute();
const stopObserve = Player.observePause(video, { shouldResume: () => !stopRequested });
const completed = await Utils.poll(() => stopRequested || isDeadline ||
(Player.isFinished(video) && progressDone()), {
interval: 1000,
timeout: await Utils.getDDL()
});
stopObserve();
rateGuard?.();
if (stopRequested) {
video?.pause?.();
this.panel.resetStartButton('继续刷课');
return false;
}
if (!isDeadline && !completed) {
this.panel.log('⚠️ 视频已结束但平台进度尚未同步,已停止,避免从头重复播放');
this.panel.resetStartButton('继续刷课');
return false;
}
this.updateProgress(this.outside + 1, 0);
history.back();
await Utils.sleep(1200);
return true;
}
async handleBatch(course, list) {
const expandBtn = course.querySelector('.sub-info')?.querySelector('.gray')?.querySelector('span');
if (!expandBtn) {
this.panel.log('未找到批量展开按钮,跳过');
this.updateProgress(this.outside + 1, 0);
return;
}
await Utils.safeClick(expandBtn);
await Utils.sleep(1200);
const activities = list[this.outside]?.querySelector('.leaf_list__wrap')?.querySelectorAll('.activity__wrap') || [];
let idx = this.inside;
this.panel.log(`进入批量区,内部进度 ${idx}/${activities.length}`);
while (idx < activities.length) {
if (stopRequested) return;
const item = activities[idx];
if (!item) break;
const tagText = item.querySelector('.tag')?.innerText || '';
const tagHref = item.querySelector('.tag')?.querySelector('use')?.getAttribute('xlink:href') || '';
const title = item.querySelector('h2')?.innerText || `第${idx + 1}项`;
if (tagText === '音频') {
const nextIndex = await this.playAudioItem(item, title, idx);
if (nextIndex === null) return false;
idx = nextIndex;
} else if (tagHref.includes('shipin')) {
const nextIndex = await this.playVideoItem(item, title, idx);
if (nextIndex === null) return false;
idx = nextIndex;
} else if (tagHref.includes('tuwen') || tagHref.includes('taolun')) {
idx = await this.autoCommentItem(item, tagHref.includes('tuwen') ? '图文' : '讨论', idx);
} else if (tagHref.includes('zuoye')) {
const homeworkIndex = await this.handleHomework(item, idx);
if (homeworkIndex === false || homeworkIndex === null) return false;
idx = homeworkIndex;
} else {
this.panel.log(`类型未知,已跳过:${title}`);
idx++;
this.updateProgress(this.outside, idx);
}
}
this.updateProgress(this.outside + 1, 0);
await Utils.sleep(1000);
return true;
}
async playAudioItem(item, title, idx) {
const contextDoc = item.ownerDocument || document;
this.panel.log(`开始播放音频:${title}`);
await Utils.safeClick(item);
await Utils.sleep(2500);
if (stopRequested) return null;
let audio = this._findAccessibleNode('audio', contextDoc);
let waitCount = 0;
while (!audio && waitCount < 15) {
if (stopRequested) return null;
await Utils.sleep(1000);
audio = this._findAccessibleNode('audio', contextDoc);
waitCount++;
}
if (!audio) {
this.panel.log(`⚠️ ${title} 未找到音频播放器,已停留当前项目`);
this.panel.resetStartButton('继续刷课');
return null;
}
const audioRateGuard = Player.applyMediaDefault(audio);
const audioProgressDone = () => {
const text = this._findAccessibleNode('.progress-wrap .text', contextDoc)?.innerHTML || '';
return !text || Utils.isProgressDone(text);
};
const audioCompleted = await Utils.poll(() =>
Player.isFinished(audio) && audioProgressDone(), {
interval: 3000,
timeout: await Utils.getDDL()
});
audioRateGuard?.();
if (!audioCompleted) {
this.panel.log(`⚠️ ${title} 未确认播放完整或平台进度未同步,已停留当前项目`);
this.panel.resetStartButton('继续刷课');
return null;
}
this.panel.log(`${title} 播放完成`);
idx++;
this.updateProgress(this.outside, idx);
history.back();
await Utils.sleep(1500);
return idx;
}
async playVideoItem(item, title, idx) {
const contextDoc = item.ownerDocument || document;
this.panel.log(`开始播放视频:${title}`);
await Utils.safeClick(item);
await Utils.sleep(2500);
if (stopRequested) return null;
let video = this._findAccessibleNode('video', contextDoc);
let waitCount = 0;
while (!video && waitCount < 15) {
if (stopRequested) return null;
await Utils.sleep(1000);
video = this._findAccessibleNode('video', contextDoc);
waitCount++;
}
let rateGuard = null;
if (video) {
video.volume = 0;
rateGuard = Player.applySpeed(contextDoc, video);
await Utils.sleep(1500);
if (stopRequested) {
rateGuard?.();
video.pause?.();
return null;
}
await video.play().catch(() => {});
}
if (!video) {
this.panel.log(`⚠️ ${title} 未找到视频播放器,已停留当前项目`);
this.panel.resetStartButton('继续刷课');
return null;
}
rateGuard = Player.applySpeed(contextDoc, video) || rateGuard;
Player.mute();
const stopObserve = Player.observePause(video, { shouldResume: () => !stopRequested });
const progressText = () => this._findAccessibleNode('.progress-wrap .text', contextDoc)?.innerHTML || '';
const progressDone = () => {
const text = progressText();
return !text || Utils.isProgressDone(text);
};
const completed = await Utils.poll(() => stopRequested ||
(Player.isFinished(video) && progressDone()), { interval: 1000, timeout: await Utils.getDDL() });
stopObserve();
rateGuard?.();
if (stopRequested) {
video?.pause?.();
this.panel.resetStartButton('继续刷课');
return null;
}
if (!completed) {
this.panel.log(`⚠️ ${title} 已结束但平台进度尚未同步,已停止,避免重复播放`);
this.panel.resetStartButton('继续刷课');
return null;
}
this.panel.log(`${title} 播放完成`);
idx++;
this.updateProgress(this.outside, idx);
history.back();
await Utils.sleep(1500);
return idx;
}
async autoCommentItem(item, typeText, idx) {
const featureFlags = Store.getFeatureConf();
if (!featureFlags.autoComment) {
this.panel.log('已关闭自动回复评论,跳过该项');
idx++;
this.updateProgress(this.outside, idx);
return idx;
}
const commentMode = featureFlags.commentMode || 'copy';
const title = item.querySelector('h2')?.innerText || '';
this.panel.log(`开始处理${typeText}:${title}`);
await Utils.safeClick(item);
await Utils.sleep(1200);
window.scrollTo(0, document.body.scrollHeight);
await Utils.sleep(800);
window.scrollTo(0, 0);
// ---- 收集页面内容信息 ----
// 1. 获取主题/标题内容(图文正文或讨论话题)
const contentSelectors = [
'.article-content', '.rich-text', '.post-content',
'.detail-content', '.topic-content', '.content-detail',
'.discuss-content', '.tuwen-content',
'.el-card__body', '.content-box'
];
let pageContent = '';
for (const sel of contentSelectors) {
const el = document.querySelector(sel);
if (el && el.innerText?.trim().length > 10) {
pageContent = el.innerText.trim();
break;
}
}
// 如果没找到专用内容区,尝试获取页面主体文字
if (!pageContent) {
const mainBody = document.querySelector('.container-body') || document.querySelector('.main-content') || document.querySelector('.app-body');
if (mainBody) pageContent = mainBody.innerText?.trim().substring(0, 800) || '';
}
// 2. 获取其他人的评论列表(用于参考)
const commentSelectors = ['#new_discuss .new_discuss_list .cont_detail', '.new_discuss_list dd .cont_detail', '.cont_detail.word-break'];
let existingComments = [];
let firstComment = '';
for (let retry = 0; retry < 30; retry++) {
for (const sel of commentSelectors) {
const list = document.querySelectorAll(sel);
for (const node of list) {
const text = node?.innerText?.trim();
if (text && text.length > 0) {
existingComments.push(text);
if (!firstComment) firstComment = text;
}
}
if (existingComments.length > 0) break;
}
if (existingComments.length > 0) break;
await Utils.sleep(500);
}
// ---- 根据模式生成评论 ----
let commentText = '';
if (commentMode === 'ai') {
// AI 智能回复模式
const apiKey = Store.getAIApiKey();
if (!apiKey) {
this.panel.log('⚠️ AI评论需要API Key,自动切换为复制模式');
commentText = firstComment;
} else {
try {
commentText = await Solver.generateAIComment(pageContent, title, typeText, existingComments);
this.panel.log(`🤖 AI生成评论:${commentText.substring(0, 50)}${commentText.length > 50 ? '...' : ''}`);
} catch (err) {
this.panel.log(`⚠️ AI生成评论失败:${err},尝试复制模式`);
commentText = firstComment;
}
}
} else {
// 复制首条评论模式
commentText = firstComment;
}
if (!commentText) {
this.panel.log('未找到评论内容且AI生成失败,跳过该项');
} else {
const input = document.querySelector('.el-textarea__inner');
if (input) {
// 使用 nativeInputValueSetter 确保 Vue/React 能检测到值变化
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
if (nativeSetter) {
nativeSetter.call(input, commentText);
} else {
input.value = commentText;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
await Utils.sleep(800);
const sendBtn = document.querySelector('.el-button.submitComment') ||
document.querySelector('.publish_discuss .postBtn button') ||
document.querySelector('.el-button--primary');
if (sendBtn && !sendBtn.disabled && !sendBtn.classList.contains('is-disabled')) {
sendBtn.click();
const modeLabel = commentMode === 'ai' ? '(AI生成)' : '(复制)';
this.panel.log(`已在${typeText}区发表评论 ${modeLabel}`);
} else {
this.panel.log('发送按钮不可用或不存在');
}
} else {
this.panel.log('未找到评论输入框,跳过');
}
}
idx++;
this.updateProgress(this.outside, idx);
history.back();
await Utils.sleep(1000);
return idx;
}
async handleHomework(item, idx) {
if (stopRequested) return false;
if (!Store.getFeatureConf().autoAI) {
this.panel.log('已关闭题库自动答题,跳过该项');
this.updateProgress(this.outside, idx + 1);
return idx + 1;
}
const answerMode = Store.getAnswerMode();
if ((answerMode === 'ai' && !Store.getAIApiKey()) ||
(answerMode === 'free' && !Store.isVerifyValid())) {
this.panel.log('⚠️ 答题配置未就绪,请配置后继续当前作业');
this.panel.resetStartButton('继续答题');
return false;
}
interceptedProblems = null;
deobfuscationPromise = null;
fontCharMap = null;
if (!await Utils.safeClick(item) || !await Utils.sleepCancellable(2000)) return false;
if (answerMode === 'paid') {
try {
const data = await queryCredits();
if (stopRequested) return false;
if (data.remaining_credits <= 0) {
this.panel.log('⚠️ 积分不足,已保留当前作业');
this.panel.resetStartButton('继续答题');
return false;
}
} catch (error) {
this.panel.log('⚠️ 积分查询失败:' + (error?.message || error));
this.panel.resetStartButton('继续答题');
return false;
}
}
// 旧作业与普通考试共用完整性、兜底和提交结果判断,失败不推进课程。
const runner = new ExamRunner(this.panel);
const completed = await runner.run({ progressKind: 'v2-homework', embedded: true });
if (completed !== true || stopRequested) return false;
this.updateProgress(this.outside, idx + 1);
history.back();
await Utils.sleepCancellable(1200);
return stopRequested ? false : idx + 1;
}
async handleClassroom(course) {
this.panel.log('进入课堂模式...');
if (stopRequested) return false;
await Utils.safeClick(course);
if (!await Utils.sleepCancellable(5000)) return false;
const iframe = document.querySelector('iframe.lesson-report-mobile');
if (!iframe || !iframe.contentDocument) {
this.panel.log('⚠️ 未找到课堂 iframe,已停留当前课程');
return false;
}
const video = iframe.contentDocument.querySelector('video');
const audio = iframe.contentDocument.querySelector('audio');
if (!video && !audio) {
this.panel.log('⚠️ 课堂中未找到可播放媒体,已停留当前课程');
return false;
}
if (stopRequested) return false;
if (video) {
const rateGuard = Player.applyMediaDefault(video);
try {
const duration = Number(video.duration);
const timeout = Number.isFinite(duration) && duration > 0
? Math.max(duration * 3000, 10_000)
: 180_000;
if (!await Player.waitForEnd(video, timeout, { cancelOnStop: true })) {
this.panel.log('⚠️ 课堂视频未播放完整或已停止,已停留当前课程');
return false;
}
} finally {
rateGuard?.();
}
}
if (audio) {
if (stopRequested) return false;
const rateGuard = Player.applyMediaDefault(audio);
try {
const duration = Number(audio.duration);
const timeout = Number.isFinite(duration) && duration > 0
? Math.max(duration * 3000, 10_000)
: 180_000;
if (!await Player.waitForEnd(audio, timeout, { cancelOnStop: true })) {
this.panel.log('⚠️ 课堂音频未播放完整或已停止,已停留当前课程');
return false;
}
} finally {
rateGuard?.();
}
}
if (stopRequested) return false;
this.updateProgress(this.outside + 1, 0);
history.go(-1);
await Utils.sleepCancellable(1200, { cancelOnStop: false });
return true;
}
async handleCourseware(course) {
const tableData = course.parentNode?.parentNode?.parentNode?.__vue__?.tableData;
const deadlinePassed = (tableData?.deadline || tableData?.end) ? (tableData.deadline < Date.now() || tableData.end < Date.now()) : false;
if (deadlinePassed) {
this.panel.log(`${course.querySelector('h2')?.innerText || '课件'} 已结课,跳过`);
this.updateProgress(this.outside + 1, 0);
return true;
}
await Utils.safeClick(course);
await Utils.sleep(3000);
const classType = document.querySelector('.el-card__header')?.innerText || '';
const className = document.querySelector('.dialog-header')?.firstElementChild?.innerText || '课件';
const readerDetected = CoursewareNavigator.hasReader();
const coursewareHandled = await CoursewareNavigator.run(this.panel, className, classType);
let completed = coursewareHandled;
if (!coursewareHandled && !readerDetected) {
const videoBox = document.querySelector('.video-box');
if (videoBox) {
if (stopRequested) return false;
await Utils.safeClick(videoBox);
await Utils.sleep(1800);
if (stopRequested) return false;
const media = videoBox.querySelector?.('video,audio') || document.querySelector('video,audio');
if (!media) {
this.panel.log(`${className} 未找到实际媒体节点,已保留在当前页面`);
this.panel.resetStartButton('继续刷课');
return false;
}
const muteBtn = document.querySelector('.xt_video_player_common_icon');
muteBtn && muteBtn.click();
const rateGuard = Player.applyMediaDefault(media);
try {
completed = await Player.waitForEnd(media, await Utils.getDDL(media), { cancelOnStop: true });
} finally {
rateGuard?.();
}
if (completed) this.panel.log(`${className} 视频播放完毕`);
} else {
this.panel.log(`${className} 未识别到可翻页课件或媒体,已保留在当前页面`);
}
}
if (!completed) {
this.panel.resetStartButton('继续刷课');
return false;
}
this.updateProgress(this.outside + 1, 0);
history.back();
return true;
}
}
// ---- 直达课件/PPT 页面逻辑 ----
class DirectCoursewareRunner {
constructor(panel) {
this.panel = panel;
}
getTitle() {
const selectors = [
'.header-title',
'.courseware-title',
'.ppt-title',
'.title',
'header h1',
'h1'
];
for (const sel of selectors) {
const text = document.querySelector(sel)?.innerText?.trim();
if (text) return text;
}
const headerText = Array.from(document.querySelectorAll('header, .header, .top, .nav-header'))
.map(el => el.innerText?.trim())
.find(text => text && text.length <= 120);
return headerText || document.title || '课件';
}
async run() {
preventScreenCheck();
await Utils.sleep(1200);
const className = this.getTitle();
this.panel.log('检测到直接课件/PPT页面,开始自动翻页');
const handled = await CoursewareNavigator.run(this.panel, className, 'studentCards ppt 课件');
if (handled) {
this.panel.log('直接课件/PPT页面处理完成');
this.panel.resetStartButton('翻页完成');
} else {
this.panel.log('未识别到可翻页的课件结构,请把页面DOM或截图发我继续适配');
this.panel.resetStartButton('开始刷课');
}
}
}
// ---- 考试答题 Runner ----
class ExamRunner {
constructor(panel) {
this.panel = panel;
}
answerProgressKey(kind) {
return Utils.stableProgressKey(`answer:${kind}`);
}
getAnswerProgressStart(kind, total = 0) {
const key = this.answerProgressKey(kind);
const record = Store.getAnswerProgress(key);
let index = Math.min(record.index || 0, record.confirmedThrough || 0);
if (total > 0) index = Math.min(index, Math.max(0, total - 1));
return { key, index, record };
}
saveAnswerProgress(key, index, total = 0, confirmedThrough = null) {
Store.setAnswerProgress(key, index, total, confirmedThrough);
}
clearAnswerProgress(key) {
Store.removeAnswerProgress(key);
}
async submitFinalExam(label = '整卷') {
if (stopRequested) return false;
const submitted = await Solver.submitWholeExam({
confirmComplete: true, root: document, allowGeneric: true,
logger: this.panel.log.bind(this.panel)
});
if (!submitted) this.panel.log('⚠️ ' + label + '尚未确认提交成功,已保留断点');
return submitted === true && !stopRequested;
}
pauseAnswer(message) {
this.panel.log(stopRequested ? '⏸️ 已停止答题,断点已保留' : '⚠️ ' + message + ',断点已保留');
this.panel.resetStartButton('继续答题');
return false;
}
questionSignature(area, aiWorkspace = false) {
if (!area) return '';
const data = aiWorkspace ? Solver.readAiWorkspaceFromDOM(area) : Solver.readStudentQuizFromDOM(area);
return data?.question ? Utils.hashString(JSON.stringify([data.qid || '', data.question, data.options || []])) : '';
}
getExercisePosition(roots = [document]) {
const area = Solver.findAiWorkspaceQuestionArea(document);
const docs = area?.ownerDocument ? [area.ownerDocument] : roots;
const navItems = this._getNumberedQuestionNavItems(docs);
let index = this._getAiWorkspaceHeadingIndex(area);
if (index === null) {
const active = navItems.find(({ node }) => node.classList?.contains('active') ||
node.classList?.contains('is-active') || node.getAttribute?.('aria-current') === 'true' ||
node.getAttribute?.('aria-current') === 'step');
if (active) index = active.index;
}
let total = 0;
for (const root of docs) {
const body = root.body || root;
const clone = body.cloneNode?.(true);
clone?.querySelectorAll?.('#panel,#ykt-helper-iframe,[data-ykt-helper],.ykt-helper').forEach(node => node.remove());
const text = String(clone ? clone.textContent : (body.innerText || body.textContent || '')).replace(/\s+/g, '');
const match = text.match(/答题卡[((](\d+)[//](\d+)[))]/) ||
text.match(/第(\d+)题(?:[//,,]|,?共)(\d+)题/) ||
text.match(/(\d+)[//](\d+)题/);
if (match && Number(match[2]) > 0 && Number(match[2]) <= 1000) {
total = Number(match[2]);
break;
}
}
// “1/20 题”是已作答数量;不能用它定位当前题(题卡 2 激活时仍可能为 1/20)。
// 展开题卡会按题型显示各组 N/M 题;不能把首组的分母误当整套习题总数。
if (navItems.length) total = Math.max(total, Math.max(...navItems.map(item => item.index)) + 1);
return { index, total: total || interceptedProblems?.length || 0 };
}
_getAiWorkspaceHeadingIndex(area) {
if (!area) return null;
const headings = Array.from(area.querySelectorAll?.('.item-type,.question-type,.subject-type,.question-title') || []);
const lines = [...headings.map(node => Solver.getAiWorkspaceLines(node).join(' ')),
...String(area.innerText || area.textContent || '').split(/\n+/)];
for (const line of lines) {
const match = line.match(/^\s*(?:第\s*)?(\d+)\s*(?:题\s*[.、.::]?|[.、.])?\s*[【\[((]?(?:单选题|多选题|判断题|填空题|单项选择题|多项选择题|不定项选择题|简答题|主观题)/);
if (match && Number(match[1]) > 0 && Number(match[1]) <= 1000) return Number(match[1]) - 1;
}
return null;
}
_getNumberedQuestionNavItems(roots = [document]) {
const result = [];
const seen = new Set();
const excluded = '#panel,#ykt-helper-iframe,[data-ykt-helper],.ykt-helper,[class*="course-catalog"],[class*="course-menu"]';
const readNumber = node => {
const clone = node.cloneNode?.(true);
clone?.querySelectorAll?.('.icon-status,.text-status,svg').forEach(el => el.remove());
const text = String(clone ? clone.textContent : (node.innerText || node.textContent || '')).replace(/\s+/g, '');
const match = text.match(/^(?:第)?(\d+)(?:题)?$/);
return match && Number(match[1]) > 0 && Number(match[1]) <= 1000 ? Number(match[1]) - 1 : null;
};
for (const root of roots) {
if (root?.nodeType === 9 && !this._isAiWorkspaceDocumentVisible(root)) continue;
// 雨课堂真实题卡:.J_order 的 data-order 是平台标识;显示题号优先,不能假定标识从 0 或 1 起算。
const nativeItems = Array.from(root.querySelectorAll?.('.problems-aside .subject-item') || []);
if (nativeItems.length) {
nativeItems.forEach((node, order) => {
if (node.closest?.(excluded) || !Solver.isStructurallyVisible(node) || seen.has(node)) return;
seen.add(node);
result.push({ node, index: readNumber(node) ?? order });
});
continue;
}
const containers = Array.from(root.querySelectorAll?.('.answer-sheet,.sheet-box,.sheet-list,.aside-body,.exam-aside,[class*="answer-card"],[class*="question-card"],[class*="question-nav"],[class*="sheet"],[class*="aside"],[class*="card-list"]') || []);
for (const container of containers) {
if (container.closest?.(excluded)) continue;
for (const child of container.querySelectorAll('button,a,li,div,span,[role="button"],[tabindex]')) {
if (!Solver.isStructurallyVisible(child) || child.closest?.(excluded)) continue;
const index = readNumber(child);
if (index === null) continue;
const node = child.closest?.('button,a,li,[role="button"],[tabindex]') || child;
if (!container.contains(node) || seen.has(node) || readNumber(node) !== index) continue;
const rect = node.getBoundingClientRect();
if (rect.width > 110 || rect.height > 110) continue;
seen.add(node);
result.push({ node, index });
}
}
}
return result;
}
async queryAnswers(result, label) {
const checkStop = () => {
if (stopRequested) throw new Error('已停止答题');
};
checkStop();
let answers = null;
let usedAI = Store.getAnswerMode() === 'ai';
for (let attempt = 0; attempt < 2; attempt++) {
try {
const response = await Solver.askQuestionBank(result.question, result.options || [],
result.type ?? 4, result.qid);
checkStop();
answers = response?.answers;
break;
} catch (error) {
checkStop();
const noMatch = /未(?:找到|匹配|命中)|无答案/.test(String(error?.message || error));
if (usedAI || (!noMatch && !Utils.isTransientError(error))) throw error;
if (noMatch) break;
if (attempt > 0) throw error;
this.panel.log('查询暂时失败,稍后重试当前题');
await Utils.sleepCancellable(2000);
checkStop();
}
}
if (!answers?.length) {
checkStop();
answers = await Solver.aiFallback(result, this.panel.log.bind(this.panel), label);
usedAI = !!answers?.length;
}
checkStop();
if (!answers?.length) throw new Error('未取得完整答案');
return { answers, usedAI };
}
findNumberedQuestionNavButton(index, roots = [document]) {
return this._getNumberedQuestionNavItems(roots).find(item => item.index === index &&
!item.node.disabled && item.node.getAttribute?.('aria-disabled') !== 'true' &&
!/(?:^|\s)(?:is-disabled|disabled)(?:\s|$)/.test(String(item.node.className || '')))?.node || null;
}
async fastClickQuestionNav(button) {
return Utils.safeClick(button, {
scroll: false,
beforeMin: 120,
beforeMax: 320,
afterMin: 450,
afterMax: 900
});
}
async _aiWorkspaceFallbackAnswers(result) {
if (Store.getUnmatchedFallback() !== 'ai') {
this.panel.log('⏭️ 题库未匹配,按设置跳过不答(注意:连续跳过会被平台锁死下一题按钮)');
return null;
}
return await Solver.aiFallback(result, this.panel.log.bind(this.panel), '');
}
findAiWorkspaceClickableButton(textOptions, opts) {
opts = opts || {};
const wanted = new Set(textOptions);
// wanted 关键字命中:"文本完全等于" 或 "按钮内 文本等于" 或 "包含该关键字"
const matchText = (raw) => {
const t = (raw || '').replace(/\s+/g, '').trim();
if (!t) return false;
if (wanted.has(t)) return true;
for (const w of wanted) { if (t.includes(w)) return true; }
return false;
};
// AI 工作空间是嵌套 iframe(lms-graph 外壳 + exercise 内层),按钮通常在 iframe 内,
// 必须跨同源 iframe 收集所有 button,否则顶层 document.querySelectorAll 会查不到任何按钮
const roots = (typeof Solver.collectAiWorkspaceRoots === 'function')
? Solver.collectAiWorkspaceRoots(document)
: [document];
const buttons = [];
for (const r of roots) {
try {
buttons.push(...Array.from(r.querySelectorAll('button,a[role="button"],[role="button"],.el-button,.ant-btn')));
} catch (_) { /* cross-origin */ }
}
const visible = buttons.filter(b => Solver.isStructurallyVisible(b));
const matched = visible.filter(btn => {
const text = btn.innerText || btn.textContent || '';
const aria = btn.getAttribute('aria-label') || '';
if (!matchText(text) && !matchText(aria)) return false;
const className = String(btn.className || '');
if (btn.disabled) return false;
if (btn.getAttribute('aria-disabled') === 'true') return false;
if (/\bis-disabled\b/.test(className)) return false;
return true;
});
if (matched.length) return matched[0];
// 兜底诊断:把可见按钮文本列表同时打到 F12 和面板,方便不开 F12 也能定位
try {
const summary = visible.slice(0, 20).map(b => {
const t = (b.innerText || b.textContent || '').replace(/\s+/g, '').trim() || '(空文本)';
const cls = String(b.className || '');
const dis = b.disabled || b.getAttribute('aria-disabled') === 'true' || /\bis-disabled\b/.test(cls);
return `${t}${dis ? '[禁]' : ''}`;
}).join(' / ');
console.log('[雨课堂助手] 未找到目标按钮', Array.from(wanted), ';可见按钮:', summary);
if (this.panel && opts.logToPanel) {
this.panel.log(`🔎 没匹配到「${Array.from(wanted).join('/')}」按钮,可见按钮:${summary || '(无)'}`);
}
} catch (_) {}
return null;
}
findAiWorkspaceNextButton(logToPanel, index = null) {
const area = Solver.findAiWorkspaceQuestionArea(document);
const roots = area?.ownerDocument ? [area.ownerDocument] : this._getAiWorkspaceRoots();
const currentIndex = index ?? this.getExercisePosition(roots).index;
if (currentIndex !== null) {
const numbered = this.findNumberedQuestionNavButton(currentIndex + 1, roots);
if (numbered) return numbered;
}
// 课程外壳的左右箭头跳转学习内容,不是题目导航。文字按钮也只在本题区查找。
const next = this._getAiWorkspaceActionButtons(area).find(node => {
const labels = [node.innerText || node.textContent || '', node.getAttribute('aria-label'), node.getAttribute('title')];
return labels.some(text => /^(?:下一题|下一道|下一个)$/.test(String(text || '').replace(/\s+/g, '')));
}) || null;
if (!next && logToPanel) this.panel.log('当前题目未找到可用的下一题题卡或按钮');
return next;
}
findAiWorkspaceQuestionNavButtonByIndex(index) {
const area = Solver.findAiWorkspaceQuestionArea(document);
const roots = area?.ownerDocument ? [area.ownerDocument] : this._getAiWorkspaceRoots();
return this.findNumberedQuestionNavButton(index, roots);
}
async seekAiWorkspaceIndex(targetIndex) {
if (!targetIndex || targetIndex <= 0) return false;
const targetBtn = this.findAiWorkspaceQuestionNavButtonByIndex(targetIndex);
if (!targetBtn) return false;
this.panel.log(`↪️ 恢复到上次断点:第 ${targetIndex + 1} 题`);
await this.fastClickQuestionNav(targetBtn);
await Utils.sleep(1200);
return true;
}
findAiWorkspaceSubmitButton(logToPanel, questionArea = null) {
const area = questionArea || Solver.findAiWorkspaceQuestionArea(document);
const button = this._getAiWorkspaceActionButtons(area).find(node => {
let text = String(node.innerText || node.textContent || '').replace(/\s+/g, '');
if (/提交作业|提交试卷|提交整卷|整卷|全部答案|交卷/.test(text)) return false;
// 平台固定操作栏会显示“剩余 3 次 提交”或“提交(不限次数)”。
text = text.replace(/[((](?:剩余\d+次|不限次数)[))]/g, '')
.replace(/^剩余\d+次/, '').replace(/(?:剩余\d+次|不限次数)$/, '');
return /^(?:提交|提交答案|确认提交|确认并提交|提交本题)$/.test(text);
}) || null;
if (!button && logToPanel) this.panel.log('当前题目未找到单题提交按钮');
return button;
}
_getAiWorkspaceActionButtons(area, opts = {}) {
if (!area) return [];
const subject = area.closest?.('.subject-item') || Solver.getQuestionScope(area);
// 固定操作栏与题目正文是兄弟节点,最多扩到当前题的 container-problem,不能扩到 document。
const scopes = [...new Set([Solver.getQuestionScope(area), area.closest?.('.problem-box'),
area.closest?.('.container-problem')].filter(Boolean))];
const seen = new Set();
const buttons = [];
for (const scope of scopes) {
for (const node of scope.querySelectorAll?.('button,a[role="button"],[role="button"],.el-button,.ant-btn') || []) {
if (seen.has(node) || !Solver.isStructurallyVisible(node) || (!opts.includeDisabled && (node.disabled || node.getAttribute('aria-disabled') === 'true' ||
/(?:^|\s)(?:is-disabled|disabled)(?:\s|$)/.test(String(node.className || ''))))) continue;
const ownerSubject = node.closest?.('.subject-item');
if (ownerSubject && ownerSubject !== subject) continue;
seen.add(node);
buttons.push(node);
}
}
return buttons;
}
_isAiWorkspaceCardSubmitted(card) {
if (card?.classList?.contains('primary')) return true;
return !!card && Array.from(card.querySelectorAll('.icon-status,.text-status')).some(node =>
Solver.isStructurallyVisible(node) && (node.classList.contains('icon-status') || /未批改/.test(node.textContent || '')));
}
isAiWorkspaceQuestionAnswered(questionArea = null, expectedIndex = null) {
// Vue 提交后会重建题目节点;不能一直读取提交前捕获的、已脱离文档的旧节点。
const area = questionArea?.isConnected ? questionArea : Solver.findAiWorkspaceQuestionArea(document);
const scope = Solver.getQuestionScope(area);
if (!scope?.querySelectorAll) return false;
const hasLocalSubmittedStatus = (withFixedBar = false) => [...new Set([
...scope.querySelectorAll('button,[role="status"],.answer-status,.submit-status,.el-button,.ant-btn'),
...(withFixedBar ? this._getAiWorkspaceActionButtons(area, { includeDisabled: true }) : [])
])].some(node => {
const text = (node.innerText || node.textContent || '').replace(/\s+/g, '');
return Solver.isStructurallyVisible(node) && /^(已提交|已作答|已完成|提交成功|已答)$/.test(text);
});
// 普通考试和旧版 studentQuiz 也复用此方法;仅 AI 学习空间读取其标题、题卡结构。
if (!Solver.isAiWorkspaceExercisePage()) return hasLocalSubmittedStatus();
const roots = area?.ownerDocument ? [area.ownerDocument] : this._getAiWorkspaceRoots();
const headingIndex = this._getAiWorkspaceHeadingIndex(area);
const index = expectedIndex ?? headingIndex ?? this.getExercisePosition(roots).index;
const card = index !== null ? this._getNumberedQuestionNavItems(roots).find(item => item.index === index)?.node : null;
if (this._isAiWorkspaceCardSubmitted(card)) return true;
// 提交后可能自动切题;只能检查原题卡,不能把新题的结果算到原题。
const currentIndex = headingIndex ?? this.getExercisePosition(roots).index;
if (expectedIndex !== null && (currentIndex === null || currentIndex !== expectedIndex)) return false;
// 新版题卡未必有 primary/icon-status;固定操作栏的禁用“已提交”同样是平台明确的提交结果。
if (hasLocalSubmittedStatus(true)) return true;
// 截止但未作答时平台也会显示 0 分和正确答案;分数本身不能证明提交成功。
return false;
}
async waitForAiWorkspaceQuestionChange(prevQuestion) {
return Utils.poll(() => {
const area = Solver.findAiWorkspaceQuestionArea(document);
const fresh = Solver.readAiWorkspaceFromDOM(area);
return !!(fresh.question && fresh.question !== prevQuestion);
}, { interval: 500, timeout: 8000 });
}
getStudentQuizCurrentIndex() {
if (!Solver.isStudentQuizPage()) return null;
const route = location.pathname.match(/\/student(?:quiz|exam|exercise|homework)\/[^/]+\/(\d+)(?:\/|$)/i);
if (route) {
const n = Number(route[1]);
return n > 0 ? n - 1 : null;
}
const text = (document.body?.innerText || '').replace(/\s+/g, ' ');
const match = text.match(/答题卡\s*[\((]\s*(\d+)\s*[\//]\s*(\d+)\s*[\))]/);
if (match) return Math.max(0, parseInt(match[1], 10) - 1);
return null;
}
getStudentQuizTotalQuestions() {
const text = (document.body?.innerText || '').replace(/\s+/g, ' ');
const match = text.match(/答题卡\s*[\((]\s*\d+\s*[\//]\s*(\d+)\s*[\))]/);
if (match) return parseInt(match[1], 10);
if (interceptedProblems && interceptedProblems.length) return interceptedProblems.length;
return 0;
}
findStudentQuizNavButtonByIndex(index) {
const targetNo = index + 1;
const link = Array.from(document.querySelectorAll('a[href*="/studentQuiz/"]')).find(a => {
if (!Solver.isStructurallyVisible(a)) return false;
const href = a.getAttribute('href') || '';
return new RegExp(`/studentQuiz/\\d+/${targetNo}(?:[/?#]|$)`, 'i').test(href);
});
return link || this.findNumberedQuestionNavButton(index, [document]);
}
async seekStudentQuizIndex(targetIndex) {
if (!targetIndex || targetIndex <= 0) return false;
let currentIndex = this.getStudentQuizCurrentIndex();
if (currentIndex !== null && currentIndex >= targetIndex) return true;
const targetBtn = this.findStudentQuizNavButtonByIndex(targetIndex);
if (targetBtn) {
this.panel.log(`↪️ 恢复到上次断点:第 ${targetIndex + 1} 题`);
await this.fastClickQuestionNav(targetBtn);
await Utils.sleep(900);
return true;
}
this.panel.log(`↪️ 恢复到上次断点:尝试快进到第 ${targetIndex + 1} 题`);
for (let i = 0; i < targetIndex + 2; i++) {
currentIndex = this.getStudentQuizCurrentIndex();
if (currentIndex !== null && currentIndex >= targetIndex) return true;
const nextBtn = this.findStudentQuizNextButton(false);
if (!nextBtn) break;
const prevUrl = location.href;
await this.fastClickQuestionNav(nextBtn);
await Utils.poll(() => location.href !== prevUrl || (this.getStudentQuizCurrentIndex() || 0) > (currentIndex || 0), {
interval: 250,
timeout: 2500
});
}
return (this.getStudentQuizCurrentIndex() || 0) >= targetIndex;
}
isStudentQuizQuestionAnswered(questionArea) {
const scope = Solver.getQuestionScope(questionArea || Solver.findStudentQuizQuestionArea(document));
if (!scope) return false;
if (this.isAiWorkspaceQuestionAnswered(scope)) return true;
const inputs = Solver._findAnswerInputs(scope);
// 部分勾选不能证明多选完整;填空只有所有空都有内容时才可以跳过写入。
return inputs.length > 0 && inputs.every(input => !!Solver._readAnswerInput(input));
}
findStudentQuizClickableButton(textOptions, opts = {}) {
const wanted = new Set(textOptions.map(t => String(t).replace(/\s+/g, '').trim()).filter(Boolean));
const matchText = (raw) => {
const t = (raw || '').replace(/\s+/g, '').trim();
if (!t) return false;
if (wanted.has(t)) return true;
for (const w of wanted) { if (t.includes(w)) return true; }
return false;
};
const selector = 'button,a,[role="button"],.el-button,.ant-btn,[class*="button"],[class*="btn"]';
const nodes = Array.from(document.querySelectorAll(selector)).filter(btn => {
if (!Solver.isStructurallyVisible(btn)) return false;
const text = btn.innerText || btn.textContent || '';
const aria = btn.getAttribute('aria-label') || btn.getAttribute('title') || '';
if (!matchText(text) && !matchText(aria)) return false;
const cls = String(btn.className || '');
if (btn.disabled) return false;
if (btn.getAttribute('aria-disabled') === 'true') return false;
if (/\b(is-disabled|disabled)\b/.test(cls)) return false;
return true;
});
if (nodes.length) return nodes[0];
if (opts.logToPanel) {
const visible = Array.from(document.querySelectorAll(selector)).filter(btn => Solver.isStructurallyVisible(btn));
const summary = visible.slice(0, 20).map(btn => {
const text = (btn.innerText || btn.textContent || btn.getAttribute('aria-label') || '').replace(/\s+/g, '').trim() || '(空文本)';
const cls = String(btn.className || '');
const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true' || /\b(is-disabled|disabled)\b/.test(cls);
return `${text}${disabled ? '[禁]' : ''}`;
}).join(' / ');
console.log('[雨课堂助手] studentQuiz 未找到目标按钮', Array.from(wanted), ';可见按钮:', summary);
this.panel.log(`🔎 没匹配到「${Array.from(wanted).join('/')}」按钮,可见按钮:${summary || '(无)'}`);
}
return null;
}
findStudentQuizNextButton(logToPanel = false) {
const btn = this.findStudentQuizClickableButton(['下一题', '下一道', '下一个'], { logToPanel: false });
if (btn) return btn;
const currentIndex = this.getStudentQuizCurrentIndex();
if (currentIndex !== null) {
const nextNo = currentIndex + 2;
const link = Array.from(document.querySelectorAll('a[href*="/studentQuiz/"]')).find(a => {
if (!Solver.isStructurallyVisible(a)) return false;
const href = a.getAttribute('href') || '';
return new RegExp(`/studentQuiz/\\d+/${nextNo}(?:[/?#]|$)`, 'i').test(href);
});
if (link) return link;
const cardRoots = Array.from(document.querySelectorAll(
'.answer-sheet,.sheet-box,.sheet-list,.aside-body,.exam-aside,[class*="answer-card"],[class*="question-card"],[class*="card"]'
));
for (const root of cardRoots) {
const node = Array.from(root.querySelectorAll('button,a,li,div,span')).find(el => {
if (!Solver.isStructurallyVisible(el)) return false;
const text = (el.innerText || el.textContent || '').replace(/\s+/g, '').trim();
return text === String(nextNo);
});
if (node) return node;
}
}
return this.findStudentQuizClickableButton(['下一题', '下一道', '下一个'], { logToPanel });
}
async waitForStudentQuizQuestionChange(prevQuestion, prevUrl) {
return Utils.poll(() => {
if (location.href !== prevUrl) return true;
const area = Solver.findStudentQuizQuestionArea(document);
const fresh = Solver.readStudentQuizFromDOM(area);
return !!(fresh.question && fresh.question !== prevQuestion);
}, { interval: 400, timeout: 8000, cancelOnStop: true });
}
async runStudentQuiz() {
this.panel.log('检测到雨课堂测验页,开始逐题答题');
const total = this.getStudentQuizTotalQuestions();
const progress = this.getAnswerProgressStart('studentQuiz', total);
let index = progress.index;
if (this.getStudentQuizCurrentIndex() !== index) {
const button = this.findStudentQuizNavButtonByIndex(index);
if (!button || !await this.fastClickQuestionNav(button)) return this.pauseAnswer('无法定位到第 ' + (index + 1) + ' 题');
const located = await Utils.poll(() => this.getStudentQuizCurrentIndex() === index,
{ interval: 250, timeout: 5000, cancelOnStop: true });
if (!located) return this.pauseAnswer('未确认断点题目切换');
}
for (let step = 0; step < 1000; step++) {
if (stopRequested) return this.pauseAnswer('');
this.saveAnswerProgress(progress.key, index, total);
let area = null;
await Utils.poll(() => !!(area = Solver.findStudentQuizQuestionArea(document)),
{ interval: 300, timeout: 6000, cancelOnStop: true });
if (!area || stopRequested) return this.pauseAnswer('未找到当前题目');
const actualIndex = this.getStudentQuizCurrentIndex();
if (actualIndex !== null && actualIndex !== index) return this.pauseAnswer('题号不连续,停止以免漏题');
this.panel.log('📝 正在处理第 ' + (index + 1) + (total ? '/' + total : '') + ' 题');
let result, usedAI = false;
try {
result = await Solver.recognize(area, index);
if (stopRequested) return this.pauseAnswer('');
if (!result?.question || result.question.length <= 3) return this.pauseAnswer('当前题识别失败');
if (!this.isStudentQuizQuestionAnswered(area)) {
const query = await this.queryAnswers(result, '第 ' + (index + 1) + ' 题');
usedAI = query.usedAI;
const selected = await Solver.autoSelectAndSubmit(query.answers, area, result.options, { autoSubmit: false, type: result.type });
if (selected !== true || stopRequested) return this.pauseAnswer('当前题答案未完整写入');
}
} catch (error) {
return this.pauseAnswer('当前题处理失败:' + (error?.message || error));
}
this.saveAnswerProgress(progress.key, index, total, index + 1);
if (total > 0 && index + 1 === total) {
if (!await this.submitFinalExam('测验')) return this.pauseAnswer('测验尚未提交');
this.clearAnswerProgress(progress.key);
this.panel.log('🎉 测验答题并提交完成');
this.panel.resetStartButton('答题完成');
return true;
}
const next = this.findStudentQuizNextButton(false);
if (!next) return this.pauseAnswer(total ? '未到最后一题但无法翻页' : '未能确定总题数和最后一题');
const beforeUrl = location.href;
if (!await Utils.safeClick(next, { scroll: false }) || stopRequested) return this.pauseAnswer('');
if (!await this.waitForStudentQuizQuestionChange(result.question, beforeUrl) || stopRequested) {
return this.pauseAnswer('未检测到下一题');
}
const nextIndex = this.getStudentQuizCurrentIndex();
if (nextIndex !== null && nextIndex !== index + 1) return this.pauseAnswer('翻页后的题号不连续');
index++;
this.saveAnswerProgress(progress.key, index, total);
await Utils.answerCooldown(usedAI, this.panel.log.bind(this.panel));
}
return this.pauseAnswer('处理数量达到上限,未确认全部完成');
}
_readAiWorkspaceQuestionState() {
const area = Solver.findAiWorkspaceQuestionArea(document);
const roots = area?.ownerDocument ? [area.ownerDocument] : this._getAiWorkspaceRoots();
const position = this.getExercisePosition(roots);
const headingIndex = this._getAiWorkspaceHeadingIndex(area);
const navItems = this._getNumberedQuestionNavItems(roots);
const active = navItems.find(({ node }) => node.classList?.contains('active') ||
node.classList?.contains('is-active') || /^(true|step)$/.test(node.getAttribute?.('aria-current') || ''));
// 首次 recognize 会解码字体,解码前后的题干文字不同,但题目并没有切换。
// 原生题目以原始正文/选项计算身份,避免把字体映射或作答结果当成新题。
const body = area?.querySelector?.('.problem-body');
const optionNodes = Array.from(area?.querySelectorAll?.('ul.list-unstyled-radio > li,ul.list-unstyled-checkbox > li,label.el-radio,label.el-checkbox,.homeworkElRadio,.homeworkElCheckbox') || []);
const rawOptions = optionNodes.map(node => {
const label = (node.querySelector?.('.el-radio__label,.el-checkbox__label') || node).cloneNode(true);
label.querySelectorAll('.icon-status,.text-status').forEach(el => el.remove());
return String(label.textContent || '').replace(/\s+/g, ' ').trim();
});
const signature = body ? Utils.hashString(JSON.stringify([
String(body.textContent || '').replace(/\s+/g, ' ').trim(), rawOptions,
Array.from(body.querySelectorAll('img')).map(img => img.getAttribute('src') || img.getAttribute('data-src') || '')
])) : this.questionSignature(area, true);
const hasContent = body ? !!String(body.textContent || '').trim() || !!body.querySelector('img') : !!signature;
const scope = area?.closest?.('.container-problem,.problem-box') || area;
const loading = Array.from(scope?.querySelectorAll?.('.el-loading-mask,[aria-busy="true"]') || [])
.some(node => Solver.isStructurallyVisible(node));
return {
area, index: position.index, total: position.total, headingIndex, signature, navItems,
ready: !!area && hasContent && !!signature && !loading &&
(headingIndex === null || !active || headingIndex === active.index)
};
}
async _waitAiWorkspaceQuestion(index = null, previous = null) {
let candidate = null, stableKey = '';
const ready = await Utils.poll(() => {
const state = this._readAiWorkspaceQuestionState();
let matches = state.ready && (index === null || state.index === index || state.index === null);
if (matches && previous) {
// 先变蓝的题卡不代表正文已经更新。正文题号可区分题干完全相同的两道题。
matches = state.headingIndex !== null ? state.headingIndex === index :
state.signature !== previous.signature;
}
if (!matches) { stableKey = ''; return false; }
const key = JSON.stringify([state.index, state.headingIndex, state.signature]);
candidate = state;
if (key === stableKey) return true;
stableKey = key;
return false;
}, { interval: 300, timeout: 6500, cancelOnStop: true });
return ready && !stopRequested ? candidate : null;
}
_isSameAiWorkspaceQuestion(previous) {
if (stopRequested) return false;
const state = this._readAiWorkspaceQuestionState();
return state.ready && state.index === previous.index && state.signature === previous.signature &&
state.area?.ownerDocument === previous.area?.ownerDocument;
}
_getAiWorkspaceSubmittedPrefix(state, total) {
const items = state.navItems.filter(({ node }) => node.closest?.('.problems-aside'));
const indices = new Set(items.map(item => item.index));
if (!total || indices.size !== total || !Array.from({ length: total }, (_, i) => i).every(i => indices.has(i))) return null;
// 有些新版题卡只标当前题,不提供提交标记。全无标记不能解释成“全部未答”,
// 否则每次继续都会把已确认的断点清成第 1 题。
if (!items.some(({ node }) => this._isAiWorkspaceCardSubmitted(node))) {
// 旧版 J_order 明确以 icon-status 标记提交;全空表明服务器没有已答题,
// 要覆盖冲突的旧断点(例如重做同一份作业),防止跳过真正未答的第 1 题。
return items.every(({ node }) => node.classList?.contains('J_order')) ? 0 : null;
}
let prefix = 0;
while (prefix < total && this.isAiWorkspaceQuestionAnswered(state.area, prefix)) prefix++;
return prefix;
}
_hasAiWorkspaceWholeSubmitControl(area) {
const root = area?.ownerDocument || document;
return Array.from(root.querySelectorAll('button,a,[role="button"],.el-button,.ant-btn')).some(node => {
if (!Solver.isStructurallyVisible(node) || !Solver._isPageLevelExamNode(node) ||
node.closest?.('.container-problem,.problem-box,.problems-aside')) return false;
const text = String(node.innerText || node.textContent || '').replace(/\s+/g, '');
return /^(?:提交试卷|提交整卷|提交作业|提交答卷|提交测验|提交全部答案|完成并提交|提交考试|交卷|提交全部|提交)$/.test(text);
});
}
async runAiWorkspaceExercise() {
this.panel.log('检测到 AI 学习空间章节测试,开始逐题答题');
const progress = this.getAnswerProgressStart('aiWorkspace');
let state = await this._waitAiWorkspaceQuestion();
if (!state) return this.pauseAnswer('当前题目尚未加载完成');
let total = state.total || progress.record.total || 0;
let confirmedThrough = progress.record.confirmedThrough || 0;
const livePrefix = this._getAiWorkspaceSubmittedPrefix(state, total);
if (livePrefix !== null) confirmedThrough = livePrefix;
// 平台有完整题卡状态时优先从首道未提交题继续;否则使用已确认的本地断点。
// 页面默认展示第 1 题或用户切回旧题时,不能反过来覆盖保存的断点。
let index = livePrefix !== null ? Math.min(livePrefix, Math.max(0, total - 1)) :
Math.min(progress.index, confirmedThrough, total > 0 ? total - 1 : Infinity);
this.saveAnswerProgress(progress.key, index, total, confirmedThrough);
if ((state.index !== null && state.index !== index) || (index > 0 && state.index === null)) {
const target = this.findAiWorkspaceQuestionNavButtonByIndex(index);
if (!target) return this.pauseAnswer('无法定位到未完成的第 ' + (index + 1) + ' 题');
const before = state;
if (!await this.fastClickQuestionNav(target)) return this.pauseAnswer('断点定位未执行');
state = await this._waitAiWorkspaceQuestion(index, before);
if (!state) return this.pauseAnswer('断点题目未切换');
}
for (let step = 0; step < 1000; step++) {
if (stopRequested) return this.pauseAnswer('');
state = await this._waitAiWorkspaceQuestion(index);
if (!state) return this.pauseAnswer('当前题号与断点不一致或正文尚未就绪');
const area = state.area;
total = state.total || total;
this.saveAnswerProgress(progress.key, index, total, confirmedThrough);
this.panel.log('📝 正在处理第 ' + (index + 1) + (total ? '/' + total : '') + ' 题');
let usedAI = false;
const submitted = () => this.isAiWorkspaceQuestionAnswered(area, index);
try {
if (!submitted()) {
const result = await Solver.recognize(area, index);
if (!this._isSameAiWorkspaceQuestion(state)) return this.pauseAnswer('识别期间题目已切换');
if (!result?.question || result.question.length <= 3) return this.pauseAnswer('当前题识别失败');
const query = await this.queryAnswers(result, '第 ' + (index + 1) + ' 题');
usedAI = query.usedAI;
if (!this._isSameAiWorkspaceQuestion(state)) return this.pauseAnswer('查询期间题目已切换');
if (!submitted() && await Solver.autoSelectAndSubmit(query.answers, area, result.options, { autoSubmit: false, type: result.type }) !== true) {
return this.pauseAnswer('当前题答案未完整写入');
}
} else {
this.panel.log('✅ 本题已提交,继续处理下一题');
}
if (stopRequested) return this.pauseAnswer('');
if (!submitted()) {
if (!this._isSameAiWorkspaceQuestion(state)) return this.pauseAnswer('题目已切换,尚未确认原题提交');
const submit = this.findAiWorkspaceSubmitButton(false, Solver.findAiWorkspaceQuestionArea(document));
if (!submit) return this.pauseAnswer('未找到本题提交按钮,尚未确认本题提交');
if (!await Utils.safeClick(submit, { scroll: false, guard: () => this._isSameAiWorkspaceQuestion(state) })) {
return this.pauseAnswer('单题提交未执行');
}
// 平台可能提交后自动跳题;必须查询原题卡,不能拿新题的选中或解析状态代替。
if (!await Utils.poll(submitted, { interval: 300, timeout: 6500, cancelOnStop: true }) || stopRequested) {
return this.pauseAnswer('尚未确认本题提交成功');
}
}
} catch (error) {
return this.pauseAnswer('当前题处理失败:' + (error?.message || error));
}
if (stopRequested) return this.pauseAnswer('');
confirmedThrough = Math.max(confirmedThrough, index + 1);
this.saveAnswerProgress(progress.key, index, total, confirmedThrough);
if (total > 0 && index + 1 === total) {
const current = this._readAiWorkspaceQuestionState();
const submittedPrefix = this._getAiWorkspaceSubmittedPrefix(current, total);
const hasWholeSubmit = this._hasAiWorkspaceWholeSubmitControl(current.area);
const chapterComplete = !hasWholeSubmit && (submittedPrefix === total ||
(submittedPrefix === null && confirmedThrough >= total));
if (!chapterComplete) {
if (submittedPrefix !== null && submittedPrefix < total) return this.pauseAnswer('仍有题目未确认提交');
if (!hasWholeSubmit && !Solver.isWholeExamSubmitted(document)) return this.pauseAnswer('尚未确认全部题目提交');
if (!await this.submitFinalExam('AI 学习空间测试')) return this.pauseAnswer('测试尚未提交');
}
if (stopRequested) return this.pauseAnswer('');
this.clearAnswerProgress(progress.key);
this.panel.log(chapterComplete ? '🎉 章节习题已全部逐题提交完成' : '🎉 AI 学习空间答题并提交完成');
this.panel.resetStartButton('答题完成');
return true;
}
const current = await this._waitAiWorkspaceQuestion();
if (!current) return this.pauseAnswer('提交后题目正文尚未就绪');
const stillCurrent = current.index === index || (current.index === null && current.signature === state.signature);
if (stillCurrent) {
const next = this.findAiWorkspaceNextButton(false, index);
if (!next) return this.pauseAnswer(total ? '未到最后一题但无法翻页' : '未能确定总题数和最后一题');
if (!await Utils.safeClick(next, { scroll: false, guard: () => this._isSameAiWorkspaceQuestion(state) })) {
return this.pauseAnswer('下一题切换未执行');
}
}
const advanced = await this._waitAiWorkspaceQuestion(index + 1, state);
if (!advanced) return this.pauseAnswer('未确认下一题正文加载完成');
index++;
this.saveAnswerProgress(progress.key, index, total, confirmedThrough);
await Utils.answerCooldown(usedAI, this.panel.log.bind(this.panel));
}
return this.pauseAnswer('处理数量达到上限,未确认全部完成');
}
_getAiWorkspaceRoots() {
return (typeof Solver.collectAiWorkspaceRoots === 'function')
? Solver.collectAiWorkspaceRoots(document) : [document];
}
_isAiWorkspaceDocumentVisible(doc) {
let currentDoc = doc;
for (let depth = 0; currentDoc && depth < 6; depth++) {
let frame = null;
try {
frame = currentDoc.defaultView?.frameElement || null;
} catch (_) {
return false;
}
if (!frame) return true;
if (!Solver.isStructurallyVisible(frame)) return false;
currentDoc = frame.ownerDocument;
}
return true;
}
_findAiWorkspaceMedia() {
const roots = this._getAiWorkspaceRoots().filter(root => this._isAiWorkspaceDocumentVisible(root));
for (const root of roots) {
try {
const video = root.querySelector('video');
if (video && Solver.isStructurallyVisible(video) && (video.currentSrc || video.src || video.querySelector('source'))) {
return { media: video, mediaType: '视频' };
}
const audio = root.querySelector('audio');
if (audio && !audio.hidden && (audio.currentSrc || audio.src || audio.querySelector('source'))) {
return { media: audio, mediaType: '音频' };
}
} catch (_) { }
}
return null;
}
_hasAiWorkspaceExerciseContent() {
return this._getAiWorkspaceRoots().some(root => {
try {
const nodes = Array.from(root.querySelectorAll('.subject-item, .problem-body, .item-body, .container-problem'));
return nodes.some(node => Solver.isStructurallyVisible(node));
} catch (_) {
return false;
}
});
}
_isAiWorkspaceCompanionArea(area) {
const selector = '.chat-card-question,.main-company,.ai-chat,.receive-box,.user-received-item-card';
return !!(area?.closest?.(selector) || area?.querySelector?.('.chat-card-question'));
}
_latestAiWorkspaceCompanionCard() {
let latest = null;
for (const root of this._getAiWorkspaceRoots()) {
if (!this._isAiWorkspaceDocumentVisible(root)) continue;
for (const card of root.querySelectorAll('.chat-card-question')) {
if (!Solver.isStructurallyVisible(card) || card.closest('#panel,#ykt-helper-iframe,[role="dialog"],.el-dialog,.ant-modal')) continue;
latest = card;
}
}
// 聊天记录会保留旧题;只处理当前会话最后一张题卡,不能回头填历史未答题。
const message = latest?.closest('.msg-item');
if (message?.parentElement) {
const messages = Array.from(message.parentElement.children).filter(node =>
node.matches('.msg-item') && Solver.isStructurallyVisible(node));
const index = messages.indexOf(message);
if (index >= 0 && messages.slice(index + 1).some(node => String(node.textContent || '').trim())) return null;
}
return latest;
}
_aiWorkspaceCompanionParts(card) {
return Array.from(card?.children || []).filter(node => node.classList?.contains('content'));
}
_isAiWorkspaceCompanionAnswered(card) {
if (!card) return false;
const own = selector => Array.from(card.querySelectorAll(selector)).filter(node =>
node.closest('.chat-card-question') === card && Solver.isStructurallyVisible(node));
const parts = this._aiWorkspaceCompanionParts(card);
// 普通卡片在总 footer 中反馈;批量卡片则每一题都有独立的 result。
if (own('.footer .tips.success,.footer .tips.fail').some(node => !node.closest('.content'))) return true;
return parts.length > 0 && parts.every(part =>
Array.from(part.querySelectorAll('.footer .tips.success,.footer .tips.fail,.list-item.right,.list-item.wrong'))
.some(node => Solver.isStructurallyVisible(node)));
}
_aiWorkspaceCompanionSignature(card) {
const parts = this._aiWorkspaceCompanionParts(card).map(part => [
part.querySelector('.card-question')?.textContent || '',
Array.from(part.querySelectorAll('.list-item .item-container .title')).map(node => node.textContent || '')
]);
return parts.length ? Utils.hashString(JSON.stringify(parts)) : '';
}
_aiWorkspaceCompanionConfigKey() {
// 只在内存中比较配置指纹,用于用户保存新配置后重试,不记录密钥内容。
return Utils.hashString(JSON.stringify([Store.getAnswerMode(), Store.getAIProvider(), Store.getAIApiKey(),
Store.getAICustomUrl(), Store.getAICustomModel(), Store.getActivationCode(), Store.getVerifySessionToken()]));
}
_findActiveAiWorkspaceCompanionQuiz(media) {
const area = this._latestAiWorkspaceCompanionCard();
if (!area || this._isAiWorkspaceCompanionAnswered(area)) return null;
const parts = this._aiWorkspaceCompanionParts(area);
if (!parts.length || parts.some(part => !Solver.readAiWorkspaceCompanionFromDOM(part))) return null;
const signature = this._aiWorkspaceCompanionSignature(area);
const configKey = this._aiWorkspaceCompanionConfigKey();
this._companionQuizStates ||= new WeakMap();
const previous = this._companionQuizStates.get(area);
if (previous?.signature === signature && previous.configKey === configKey &&
(previous.status === 'handling' || (previous.status === 'failed' && Date.now() - previous.at < 60000))) return null;
return { area, scope: area, parts, media, signature, configKey, href: location.href,
messageId: area.closest('.msg-item')?.id || '',
mediaSrc: media?.currentSrc || media?.src || '' };
}
_isCurrentAiWorkspaceCompanion(found, requireActive = true) {
if (stopRequested || !found.area?.isConnected || !found.media?.isConnected || location.href !== found.href) return false;
if ((found.media.currentSrc || found.media.src || '') !== found.mediaSrc ||
(found.area.closest('.msg-item')?.id || '') !== found.messageId ||
!this._isAiWorkspaceDocumentVisible(found.area.ownerDocument)) return false;
return this._aiWorkspaceCompanionSignature(found.area) === found.signature &&
(!requireActive || (this._latestAiWorkspaceCompanionCard() === found.area && !this._findActiveAiWorkspaceVideoQuiz(found.media)));
}
_findAiWorkspaceCompanionSubmit(card) {
const scopes = [card];
const batch = card.closest('.chat-card-overflow');
if (batch && batch.querySelectorAll('.chat-card-question').length === 1) {
scopes.push(...Array.from(batch.children).filter(node => node.classList.contains('chat-card-botbar')));
}
for (const scope of scopes) {
const buttons = scope.querySelectorAll('.footer .functional-area .btn.flatten,.chat-card-botbar .btn.flatten,.btn.flatten');
for (const button of buttons) {
if (scope === card && !button.closest('.footer .functional-area')) continue;
if (!Solver.isStructurallyVisible(button) || button.disabled ||
button.closest('.disabled,.is-disabled,[aria-disabled="true"]')) continue;
if (/^(提交|提交答案)$/.test(String(button.textContent || '').replace(/\s+/g, ''))) return button;
}
}
return null;
}
async _answerAiWorkspaceCompanionQuiz(found) {
this._companionQuizStates ||= new WeakMap();
const remember = status => this._companionQuizStates.set(found.area, {
status, signature: found.signature, configKey: found.configKey, at: Date.now()
});
if (!this._isCurrentAiWorkspaceCompanion(found)) return false;
if (this._isAiWorkspaceCompanionAnswered(found.area)) return true;
remember('handling');
const current = () => this._isCurrentAiWorkspaceCompanion(found);
try {
this.panel.log('📝 检测到智能学伴习题,正在自动作答...');
for (const part of found.parts) {
if (!current()) { remember('failed'); return false; }
const result = await Solver.recognize(part, -1);
if (!result?.question || result.options.length < 2) throw new Error('题目尚未完整加载');
const query = await this.queryAnswers(result, '智能学伴');
if (!current()) { remember('failed'); return false; }
// 用户可能在等待 AI 时已手动提交,不能再次修改已提交答案。
if (this._isAiWorkspaceCompanionAnswered(found.area)) { remember('done'); return true; }
const selected = await Solver.autoSelectAndSubmit(query.answers, part, result.options, {
autoSubmit: false, type: result.type,
optionNodes: Solver.findAiWorkspaceCompanionOptionNodes(part),
guard: () => current() && !this._isAiWorkspaceCompanionAnswered(found.area)
});
if (selected !== true || !current()) throw new Error('答案未完整选中或题目已切换');
}
// 多题卡必须所有题选完才提交;普通单题同样需要显式点击 div 提交控件。
let submit = null;
const ready = await Utils.poll(() => current() && !!(submit = this._findAiWorkspaceCompanionSubmit(found.area)),
{ interval: 250, timeout: 2500, cancelOnStop: true });
if (!ready || !submit) throw new Error('未找到当前题卡可用的提交按钮');
if (!await Utils.safeClick(submit, { scroll: false,
guard: () => current() && !this._isAiWorkspaceCompanionAnswered(found.area) })) throw new Error('提交未执行');
const submitted = await Utils.poll(() => this._isCurrentAiWorkspaceCompanion(found, false) && this._isAiWorkspaceCompanionAnswered(found.area),
{ interval: 300, timeout: 6500, cancelOnStop: true });
if (!submitted) throw new Error('尚未确认题卡提交结果');
remember('done');
this.panel.log('✅ 智能学伴习题已提交');
return true;
} catch (error) {
remember('failed');
if (!stopRequested) this.panel.log('⚠️ 智能学伴暂未完成:' + (error?.message || error));
return false;
}
}
_findActiveAiWorkspaceVideoQuiz(media) {
const roots = this._getAiWorkspaceRoots()
.filter(root => this._isAiWorkspaceDocumentVisible(root));
const mediaDoc = media?.ownerDocument || null;
roots.sort((a, b) => (a === mediaDoc ? -1 : b === mediaDoc ? 1 : 0));
const popupSelector = [
'[role="dialog"]',
'.el-dialog',
'.ant-modal',
'.modal',
'[class*="quiz-popup"]',
'[class*="question-popup"]',
'[class*="overlay"]'
].join(',');
const pausedMidStream = !!media && media.paused && !Player.isFinished(media) && Number(media.currentTime) > 0.5;
for (const root of roots) {
let nodes = [];
try {
nodes = Array.from(root.querySelectorAll('.subject-item,.item-body,.container-problem,.problem-body'));
} catch (_) {
continue;
}
if (!nodes.length) {
try {
const fallback = Solver.findAiWorkspaceQuestionArea(root);
if (fallback?.ownerDocument === root) nodes = [fallback];
} catch (_) { }
}
const areas = [...new Set(nodes.map(node => node.closest?.('.subject-item,.item-body,.container-problem') || node))];
for (const area of areas) {
if (!Solver.isStructurallyVisible(area)) continue;
if (this._isAiWorkspaceCompanionArea(area)) continue;
const helperFrame = area.ownerDocument?.defaultView?.frameElement;
if (helperFrame?.id === 'ykt-helper-iframe') continue;
const modalScope = area.closest?.(popupSelector) || null;
const sameDocument = area.ownerDocument === mediaDoc;
if (!modalScope && !sameDocument && !pausedMidStream) continue;
const options = Solver.findAiWorkspaceOptionNodes(area);
if (options.length < 2) continue;
return { area, scope: modalScope || area.parentElement || area, media };
}
}
return null;
}
_findAiWorkspaceButtonInScope(scope, textOptions) {
if (!scope?.querySelectorAll) return null;
const wanted = textOptions.map(text => String(text).replace(/\s+/g, ''));
let buttons = [];
try {
buttons = Array.from(scope.querySelectorAll('button,a,[role="button"],.el-button,.ant-btn'));
} catch (_) {
return null;
}
const candidates = buttons.filter(button => {
if (!Solver.isStructurallyVisible(button)) return false;
if (button.disabled || button.getAttribute?.('aria-disabled') === 'true') return false;
const cls = String(button.className || '');
if (/\b(?:disabled|is-disabled)\b/i.test(cls)) return false;
const text = (button.innerText || button.textContent || button.getAttribute?.('aria-label') || '')
.replace(/\s+/g, '');
return wanted.includes(text);
});
return candidates[0] || null;
}
_aiWorkspaceVideoQuizKey(result) {
if (!result?.question) return '';
return Utils.hashString(`${formatString(result.question)}\n${(result.options || []).map(formatString).join('\n')}`);
}
_isAiWorkspaceVideoQuizVisible(key, media) {
const found = this._findActiveAiWorkspaceVideoQuiz(media);
if (!found) return false;
const current = Solver.readAiWorkspaceFromDOM(found.area);
return this._aiWorkspaceVideoQuizKey(current) === key;
}
async _answerAiWorkspaceVideoQuiz(found) {
this._videoQuizStates ||= new Map();
this._videoQuizHandling = true;
const media = found.media;
try {
if (stopRequested) return false;
if (!media.paused) media.pause();
const result = await Solver.recognize(found.area, -1);
if (stopRequested) return false;
const key = this._aiWorkspaceVideoQuizKey(result);
if (!key || !result?.question || (result.options || []).length < 2) {
this.panel.log('⚠️ 检测到视频弹题,但题目识别失败,已暂停等待处理');
return false;
}
const previous = this._videoQuizStates.get(key);
if (previous?.status === 'failed' && Date.now() - previous.at < 60000) return false;
if (previous?.status === 'done') {
if (stopRequested) return false;
const continueBtn = this._findAiWorkspaceButtonInScope(found.scope, ['继续播放', '继续观看', '继续', '确定', '关闭']);
if (continueBtn) {
await Utils.safeClick(continueBtn, { scroll: false });
if (stopRequested) return false;
}
return !this._isAiWorkspaceVideoQuizVisible(key, media);
}
this._videoQuizStates.set(key, { status: 'handling', at: Date.now() });
this.panel.log('📝 检测到视频中途答题,正在自动作答...');
const { answers } = await this.queryAnswers(result, '视频弹题');
if (stopRequested) return false;
if (!answers?.length) {
this._videoQuizStates.set(key, { status: 'failed', at: Date.now() });
this.panel.log('⚠️ 视频弹题未取得答案,视频保持暂停以免漏题');
return false;
}
const selected = await Solver.autoSelectAndSubmit(answers, found.area, result.options, {
autoSubmit: false,
type: result.type
});
if (stopRequested) return false;
if (selected !== true) {
this._videoQuizStates.set(key, { status: 'failed', at: Date.now() });
this.panel.log('⚠️ 视频弹题答案未完整写入,视频保持暂停以免漏题');
return false;
}
await Utils.sleep(500);
if (stopRequested) return false;
if (this._isAiWorkspaceVideoQuizVisible(key, media)) {
const submitBtn = this._findAiWorkspaceButtonInScope(found.scope, ['提交', '提交答案', '确认提交', '确认', '确定']);
if (submitBtn) {
if (stopRequested) return false;
await Utils.safeClick(submitBtn, { scroll: false });
if (stopRequested) return false;
}
}
let resolved = await Utils.poll(
() => !this._isAiWorkspaceVideoQuizVisible(key, media),
{ interval: 300, timeout: 6000, cancelOnStop: true }
);
if (stopRequested) return false;
if (!resolved) {
const continueBtn = this._findAiWorkspaceButtonInScope(found.scope, ['继续播放', '继续观看', '继续', '确定', '关闭']);
if (continueBtn) {
if (stopRequested) return false;
await Utils.safeClick(continueBtn, { scroll: false });
if (stopRequested) return false;
resolved = await Utils.poll(
() => !this._isAiWorkspaceVideoQuizVisible(key, media),
{ interval: 300, timeout: 4000, cancelOnStop: true }
);
if (stopRequested) return false;
}
}
this._videoQuizStates.set(key, {
status: resolved ? 'done' : 'failed',
at: Date.now()
});
if (!resolved) this.panel.log('⚠️ 视频弹题提交后仍未关闭,已暂停等待检查');
return resolved;
} catch (err) {
this.panel.log(`⚠️ 视频弹题处理失败:${err?.message || err}`);
return false;
} finally {
this._videoQuizHandling = false;
}
}
_isAiWorkspaceMediaComplete(media, endedLatch = false) {
const mediaComplete = endedLatch || Player.isFinished(media);
if (!mediaComplete) return false;
let hasTimeSignal = false;
let timeComplete = false;
let hasProgressText = false;
let progressComplete = false;
for (const root of this._getAiWorkspaceRoots()) {
try {
const timeDisplay = root.querySelector('.xt_video_player_current_time_display, .video-time, .time-display');
if (timeDisplay) {
hasTimeSignal = true;
const [nowTime, totalTime] = (timeDisplay.innerText || '').split(' / ').map(text => text?.trim());
if (nowTime && totalTime && nowTime === totalTime && totalTime !== '00:00') timeComplete = true;
}
const progressText = root.querySelector('.progress-wrap .text, .progress-text');
if (progressText) {
hasProgressText = true;
if (Utils.isProgressDone(progressText.innerHTML)) progressComplete = true;
}
} catch (_) { }
}
// 页面没有可读的进度控件时,媒体的 ended/currentTime 是唯一可靠信号;
// 有进度控件则必须等待该控件同步到明确完成。
if (progressComplete) return true;
if (timeComplete && !hasProgressText) return true;
return !hasTimeSignal && !hasProgressText;
}
_aiWorkspaceText(el) {
if (!el) return '';
return (el.innerText || el.textContent || el.getAttribute?.('aria-label') ||
el.getAttribute?.('title') || '').replace(/\s+/g, ' ').trim();
}
_isAiWorkspaceActiveNode(el) {
if (!el) return false;
const ariaCurrent = String(el.getAttribute?.('aria-current') || '').toLowerCase();
const dataState = String(
el.getAttribute?.('data-active') || el.getAttribute?.('data-selected') ||
el.getAttribute?.('data-current') || ''
).toLowerCase();
const cls = String(el.className || '');
return ariaCurrent === 'true' || ariaCurrent === 'page' || ariaCurrent === 'step' ||
dataState === 'true' || dataState === 'active' || dataState === 'selected' ||
/(^|[\s_-])(active|is-active|selected|current)([\s_-]|$)/i.test(cls);
}
_aiWorkspaceCatalogItems() {
const items = [];
const seenTargets = new Set();
const routePattern = /\/(?:video|graph|tuwen|article|exercise|homework|exam|document|material|attachment|file|courseware|reading)\//i;
const typePattern = /(?:视频|图文|音频|作业|练习|考试|测验|讨论|阅读|资料|附件|课件|文档|pptx?|pdf)/i;
const excludedSelector = [
'#ykt-helper-iframe',
'.subject-item', '.problem-body', '.item-body', '.container-problem',
'[role="dialog"]', '.el-dialog', '.ant-modal', '.modal',
'.ppt-container', '.ppt-viewer', '.swiper-container', '.video-js'
].join(',');
const nodeSelector = [
'a[href]', 'button', '[role="button"]', '[tabindex]', 'li',
'[class*="item"]', '[class*="lesson"]', '[class*="unit"]',
'[class*="activity"]', '[data-id]', '[data-lesson-id]', '[data-unit-id]'
].join(',');
const routeAttrs = (node) => [
node.getAttribute?.('href'), node.getAttribute?.('data-href'),
node.getAttribute?.('data-url'), node.getAttribute?.('to'),
node.getAttribute?.('data-id'), node.getAttribute?.('data-lesson-id'),
node.getAttribute?.('data-unit-id')
].filter(Boolean).join(' ');
const isClickable = (node) => {
if (!node) return false;
const tag = String(node.tagName || '').toUpperCase();
const cls = String(node.className || '');
return ['A', 'BUTTON'].includes(tag) || node.getAttribute?.('role') === 'button' ||
node.getAttribute?.('tabindex') !== null || typeof node.onclick === 'function' ||
/(?:item|lesson|unit|activity|catalog|chapter|course)/i.test(cls);
};
const normalizeHref = (raw) => {
if (!raw) return '';
try {
const url = new URL(raw, location.href);
return `${url.pathname}${url.search}`.replace(/\/+$/, '');
} catch (_) {
return String(raw).replace(/\s/g, '').replace(/\/+$/, '');
}
};
const routeLeafKey = (raw) => {
const normalized = normalizeHref(raw);
const match = normalized.match(/\/(?:video|graph|tuwen|article|exercise|homework|exam|document|material|attachment|file|courseware|reading)\/([^/?#]+)/i);
return match?.[1] || '';
};
const currentPath = `${location.pathname}${location.search}`.replace(/\/+$/, '');
const currentLeafKey = routeLeafKey(location.href);
for (const root of this._getAiWorkspaceRoots()) {
const frame = root.defaultView?.frameElement;
if (frame?.id === 'ykt-helper-iframe') continue;
let nodes = [];
try {
nodes = Array.from(root.querySelectorAll(nodeSelector));
} catch (_) {
continue;
}
const vp = root.defaultView || window;
for (const node of nodes) {
if (!Solver.isStructurallyVisible(node) || node.closest?.(excludedSelector)) continue;
const rect = node.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0 ||
rect.width > Math.max(620, (vp.innerWidth || 1920) * 0.78) ||
rect.height > Math.max(420, (vp.innerHeight || 1080) * 0.72)) continue;
const text = this._aiWorkspaceText(node);
if (text.length < 2 || text.length > 220) continue;
const attrs = routeAttrs(node);
const classText = `${String(node.className || '')} ${String(node.parentElement?.className || '')}`;
const href = normalizeHref(
node.getAttribute?.('href') || node.getAttribute?.('data-href') ||
node.getAttribute?.('data-url') || node.getAttribute?.('to') || ''
);
const routeSignal = routePattern.test(`${attrs} ${href}`);
const typeSignal = typePattern.test(text) || typePattern.test(classText);
if (!routeSignal && !typeSignal) continue;
const navAncestor = node.closest?.([
'aside', 'nav', '[class*="catalog"]', '[class*="directory"]',
'[class*="course-menu"]', '[class*="course-list"]', '[class*="lesson-list"]',
'[class*="chapter"]', '[class*="sidebar"]', '[class*="sider"]', '[class*="menu"]'
].join(','));
const leftPanel = rect.left <= Math.max(560, (vp.innerWidth || 1920) * 0.48);
if (!navAncestor && !leftPanel && !routeSignal) continue;
// 章节容器会包含多个视频/图文子项,不能把整组容器当成一个目录项。
let descendants = [];
try {
descendants = Array.from(node.querySelectorAll('a[href],button,[role="button"],[data-id]'));
} catch (_) { }
if (!node.matches?.('a,button,[role="button"]') && descendants.length > 1 && text.length > 100) continue;
let target = node.matches?.('a,button,[role="button"]') ? node :
node.querySelector?.('a[href],button,[role="button"],[tabindex]') || node;
if (!Solver.isStructurallyVisible(target) || !isClickable(target)) {
if (!isClickable(node)) continue;
target = node;
}
const activeNodes = [node, target, node.parentElement, node.closest?.('li,div,section')];
const active = activeNodes.some(candidate => this._isAiWorkspaceActiveNode(candidate));
const targetHref = normalizeHref(
target.getAttribute?.('href') || target.getAttribute?.('data-href') ||
target.getAttribute?.('data-url') || target.getAttribute?.('to') || href
);
const routeCurrent = !!targetHref && (
targetHref === currentPath || targetHref.endsWith(currentPath) || currentPath.endsWith(targetHref) ||
(!!currentLeafKey && routeLeafKey(targetHref) === currentLeafKey)
);
const stateText = `${String(node.className || '')} ${String(target.className || '')} ` +
`${node.getAttribute?.('data-status') || ''} ${target.getAttribute?.('data-status') || ''}`;
const completed = /(?:已完成|完成学习|学习完成|100\s*%|100%|finished|completed|complete|is-finish|is-finished|is-completed|done)/i.test(`${text} ${stateText}`);
const key = targetHref || target.getAttribute?.('data-id') ||
target.getAttribute?.('data-lesson-id') || target.getAttribute?.('data-unit-id') || target;
const record = {
node,
target,
text,
href: targetHref,
leafKey: routeLeafKey(targetHref),
routeSignal,
completed,
active: active || routeCurrent,
order: items.length
};
if (seenTargets.has(key)) {
const previous = items.find(item => item.key === key);
if (previous) {
previous.active = previous.active || record.active;
previous.completed = previous.completed || record.completed;
previous.routeSignal = previous.routeSignal || record.routeSignal;
}
continue;
}
seenTargets.add(key);
record.key = key;
items.push(record);
}
}
return items;
}
_aiWorkspaceRouteLeafKey(raw) {
const text = String(raw || '');
const match = text.match(/\/(?:video|graph|tuwen|article|exercise|homework|exam|document|material|attachment|file|courseware|reading)\/([^/?#]+)/i);
return match?.[1] || '';
}
_isAiWorkspaceCatalogPage() {
const path = String(location.pathname || '');
if (!/\/ai-workspace\/lms-graph(?:\/|$)/i.test(path)) return false;
if (/\/(?:video|graph|tuwen|article|exercise|homework|exam|document|material|attachment|file|courseware|reading)\//i.test(path)) return false;
return !this._hasAiWorkspaceExerciseContent();
}
_isAiWorkspaceCatalogComplete() {
const items = this._aiWorkspaceCatalogItems().filter(item => item.href || item.routeSignal || item.leafKey);
return items.length > 0 && items.every(item => item.completed);
}
_findAiWorkspaceCatalogNext(before = null) {
const items = this._aiWorkspaceCatalogItems();
if (!items.length) return null;
const previousLeaf = before?.leafKey || this._aiWorkspaceRouteLeafKey(before?.href);
const previousKey = before?.catalogKey || '';
let currentIndex = -1;
if (previousKey) currentIndex = items.findIndex(item => item.key === previousKey);
if (currentIndex < 0 && previousLeaf) currentIndex = items.findIndex(item => item.leafKey === previousLeaf);
if (currentIndex < 0 && before?.catalogText) {
currentIndex = items.findIndex(item => item.text === before.catalogText ||
item.text.includes(before.catalogText) || before.catalogText.includes(item.text));
}
if (currentIndex < 0) currentIndex = items.findIndex(item => item.active);
// 在课程目录根页没有 active 子项时,从第一个未完成的可点击单元开始。
if (currentIndex < 0) {
return items.find(item => !item.completed && item.target && item.target.isConnected &&
Solver.isStructurallyVisible(item.target)) || null;
}
for (let i = currentIndex + 1; i < items.length; i++) {
const item = items[i];
if (!item?.target || !item.target.isConnected) continue;
if (!Solver.isStructurallyVisible(item.target)) continue;
if (item.completed) continue;
return item;
}
return null;
}
_isAiWorkspaceTextUnit() {
if (/\/(?:graph|tuwen|article|document|doc|reading|material|attachment|file|courseware)\//i.test(location.pathname)) {
return true;
}
const current = this._aiWorkspaceCatalogItems().find(item => item.active);
return !!current && /(?:图文|阅读|文档|资料|附件|课件|pptx?|pdf)/i.test(current.text);
}
_findAiWorkspaceTextContent() {
const selectors = [
'.tuwen-content', '.article-content', '.rich-text', '.content-detail',
'.knowledge-guide', '.courseware-content', '.document-content',
'.lms-graph-article', '.content-wrap', '.word-break.custom-ueditor',
'.attachment-wrap', '.scroll-container',
'[class*="tuwen"]', '[class*="article"]', '[class*="rich-text"]',
'[class*="document"]', '[class*="courseware"]', '[class*="knowledge"]',
'main', '[role="main"]'
];
const excluded = '.subject-item,.problem-body,.item-body,.container-problem,[role="dialog"],.el-dialog,.ant-modal,#ykt-helper-iframe';
for (const root of this._getAiWorkspaceRoots()) {
for (const selector of selectors) {
let nodes = [];
try { nodes = Array.from(root.querySelectorAll(selector)); } catch (_) { continue; }
for (const node of nodes) {
if (!Solver.isStructurallyVisible(node) || node.closest?.(excluded)) continue;
const text = this._aiWorkspaceText(node);
const hasMedia = !!node.querySelector?.('img,svg,canvas,iframe,.attachment,[class*="attachment"]');
if (text.length >= 20 || hasMedia) return node;
}
}
}
return null;
}
async _tryAiWorkspaceTextUnit() {
if (!this._isAiWorkspaceTextUnit() || this._hasAiWorkspaceExerciseContent()) return null;
let content = this._findAiWorkspaceTextContent();
if (!content) {
await Utils.poll(() => !!this._findAiWorkspaceTextContent(), {
interval: 400,
timeout: 8000
});
content = this._findAiWorkspaceTextContent();
}
if (!content) {
return { handled: true, completed: false, kind: 'text', reason: '图文正文尚未加载' };
}
const scrollables = [];
const addScrollable = (node) => {
if (!node || scrollables.includes(node) || !Solver.isStructurallyVisible(node)) return;
if (node.scrollHeight > node.clientHeight + 24) scrollables.push(node);
};
addScrollable(content);
let parent = content.parentElement;
for (let depth = 0; parent && depth < 8; depth++, parent = parent.parentElement) {
addScrollable(parent);
}
let ancestors = [];
try { ancestors = Array.from(content.parentElement ? content.parentElement.querySelectorAll('*') : []); } catch (_) { }
for (const node of ancestors.slice(0, 500)) addScrollable(node);
const body = content.ownerDocument?.body;
if (body && body.scrollHeight > body.clientHeight + 24) addScrollable(body);
for (const reader of scrollables) {
let lastTop = -1;
for (let i = 0; i < 120 && !stopRequested; i++) {
const maxTop = Math.max(0, reader.scrollHeight - reader.clientHeight);
if (reader.scrollTop >= maxTop - 8 || reader.scrollTop === lastTop) break;
lastTop = reader.scrollTop;
reader.scrollTop = Math.min(maxTop, reader.scrollTop + Math.max(260, Math.floor(reader.clientHeight * 0.82)));
try { reader.dispatchEvent(new Event('scroll', { bubbles: true })); } catch (_) { }
await Utils.humanSleep(450, 900);
}
}
if (stopRequested) return { handled: true, completed: false, kind: 'text', reason: 'stopped' };
const mediaCompleted = await CoursewareNavigator.playEmbeddedMedia(this.panel);
if (!mediaCompleted || stopRequested) {
return { handled: true, completed: false, kind: 'text', reason: stopRequested ? 'stopped' : '媒体未播放完整' };
}
this.panel.log('✅ 图文内容已阅读完成');
return { handled: true, completed: true, kind: 'text', media: null };
}
_snapshotAiWorkspaceUnit() {
const media = this._findAiWorkspaceMedia()?.media || null;
const src = media?.currentSrc || media?.src || media?.querySelector?.('source')?.src || '';
const stableParts = [location.href, src];
const catalogCurrent = this._aiWorkspaceCatalogItems().find(item => item.active);
if (catalogCurrent) {
stableParts.push(catalogCurrent.href || '', catalogCurrent.text || '');
}
for (const root of this._getAiWorkspaceRoots()) {
try {
const active = root.querySelector([
'[aria-current="page"]',
'[aria-current="true"]',
'[class*="catalog"] .active',
'[class*="outline"] .active',
'[class*="chapter"] .active',
'[class*="lesson"] .active',
'[class*="item"].active',
'[class*="item"].is-active',
'[class*="item"].selected'
].join(','));
const title = root.querySelector('.header-title,.course-title,.lesson-title,.courseware-title,h1,h2');
if (active) {
stableParts.push(active.getAttribute?.('data-id') || '', active.getAttribute?.('href') || '', (active.innerText || '').trim());
}
if (title) stableParts.push((title.innerText || title.textContent || '').trim());
} catch (_) { }
}
return {
key: Utils.hashString(stableParts.join('|')),
media,
src,
href: location.href,
leafKey: this._aiWorkspaceRouteLeafKey(location.href),
catalogKey: catalogCurrent?.key || '',
catalogText: catalogCurrent?.text || ''
};
}
_findAiWorkspaceLessonButtons() {
const rank = new Map([
['下一节', 0], ['下一节课', 0], ['下一课', 1], ['继续学习', 2], ['下一个', 3], ['完成', 4]
]);
const found = [];
for (const root of this._getAiWorkspaceRoots()) {
let buttons = [];
try {
buttons = Array.from(root.querySelectorAll([
'button', 'a', '[role="button"]', '.el-button', '.ant-btn',
'.learning-space-control-unit .unit-arrow',
'.learning-space-control-unit-tooltip .unit-arrow'
].join(',')));
} catch (_) {
continue;
}
for (const button of buttons) {
if (!Solver.isStructurallyVisible(button) || button.disabled || button.getAttribute?.('aria-disabled') === 'true') continue;
if (button.closest?.('.subject-item,.problem-body,.item-body,[role="dialog"],.el-dialog,.ant-modal,.ppt-container,.ppt-viewer,.swiper-container')) continue;
const tooltip = button.closest?.('.learning-space-control-unit-tooltip');
const tooltipText = tooltip?.getAttribute?.('content') || tooltip?.getAttribute?.('title') || '';
const cls = String(button.className || '');
const text = (button.innerText || button.textContent || button.getAttribute?.('aria-label') || tooltipText || '')
.replace(/\s+/g, '');
const isNextUnitArrow = /\bunit-arrow\b/i.test(cls) &&
(/\barrow-reverse\b/i.test(cls) || /下一单元|下一节|下一个/.test(tooltipText));
if (!rank.has(text) && !isNextUnitArrow) continue;
if (isNextUnitArrow && /\b(?:disabled|is-disable|is-disabled)\b/i.test(
`${cls} ${String(button.parentElement?.className || '')} ${String(tooltip?.className || '')}`
)) continue;
found.push({ button, text: isNextUnitArrow ? '下一单元' : text, rank: isNextUnitArrow ? 0 : rank.get(text) });
}
}
return found.sort((a, b) => a.rank - b.rank);
}
_isAiWorkspaceCourseComplete() {
for (const root of this._getAiWorkspaceRoots()) {
let nodes = [];
try {
nodes = Array.from(root.querySelectorAll('.course-complete,.study-complete,.complete-page,[class*="completion"],[class*="complete-result"]'));
} catch (_) {
continue;
}
if (nodes.some(node => Solver.isStructurallyVisible(node) && /全部.*完成|课程.*完成|学习.*完成|恭喜.*完成/.test(node.innerText || node.textContent || ''))) {
return true;
}
}
return false;
}
async _advanceAiWorkspaceLesson(before) {
if (stopRequested) return 'stopped';
const ready = await Utils.poll(() => this._findAiWorkspaceLessonButtons().length > 0 ||
!!this._findAiWorkspaceCatalogNext(before) || this._isAiWorkspaceCourseComplete() ||
this._isAiWorkspaceCatalogComplete(), {
interval: 400,
timeout: 8000,
cancelOnStop: true
});
if (stopRequested) return 'stopped';
if (!ready) return 'stalled';
if (this._isAiWorkspaceCourseComplete() || this._isAiWorkspaceCatalogComplete()) return 'complete';
const candidates = this._findAiWorkspaceLessonButtons();
const catalogNext = this._findAiWorkspaceCatalogNext(before);
if (catalogNext && !candidates.some(candidate => candidate.button === catalogNext.target)) {
candidates.push({
button: catalogNext.target,
text: `目录:${catalogNext.text.slice(0, 60)}`,
rank: 5,
catalog: true
});
}
if (!candidates.length) return 'stalled';
candidates.sort((a, b) => a.rank - b.rank);
for (const candidate of candidates) {
if (stopRequested) return 'stopped';
Store.touchRunState();
this.panel.log(`➡️ 点击${candidate.text}并确认页面切换...`);
const clicked = await Utils.safeClick(candidate.button, { scroll: false });
if (stopRequested) return 'stopped';
if (!clicked) continue;
const changed = await Utils.poll(() => {
if (stopRequested) return false;
if (this._isAiWorkspaceCourseComplete()) return true;
const after = this._snapshotAiWorkspaceUnit();
return after.key !== before.key ||
(!!before.media && !before.media.isConnected) ||
(!!after.media && after.media !== before.media) ||
after.src !== before.src;
}, { interval: 400, timeout: 12000, cancelOnStop: true });
if (stopRequested) return 'stopped';
if (changed) return this._isAiWorkspaceCourseComplete() ? 'complete' : 'advanced';
}
return 'stalled';
}
// AI workspace 视频/音频/课件:只处理当前单元,切课由外层状态机负责。
async _tryAiWorkspaceMedia() {
let result = this._findAiWorkspaceMedia();
if (!result) {
await Utils.poll(() => {
result = this._findAiWorkspaceMedia();
return !!result || this._hasAiWorkspaceExerciseContent() ||
this._isAiWorkspaceCatalogPage() ||
(this._isAiWorkspaceTextUnit() && !!this._findAiWorkspaceTextContent());
}, { interval: 400, timeout: 15000 });
}
if (!result) {
if (this._isAiWorkspaceCatalogPage()) {
const catalogReady = await Utils.poll(() =>
!!this._findAiWorkspaceCatalogNext() || this._isAiWorkspaceCatalogComplete(), {
interval: 400,
timeout: 10000
});
if (this._isAiWorkspaceCatalogComplete()) {
return { handled: true, completed: true, kind: 'catalog' };
}
if (catalogReady && this._findAiWorkspaceCatalogNext()) {
this.panel.log('📚 检测到课程目录,准备进入下一个未完成单元...');
return { handled: true, completed: true, kind: 'catalog' };
}
return { handled: true, completed: false, kind: 'catalog', reason: '课程目录尚未加载' };
}
const textResult = await this._tryAiWorkspaceTextUnit();
if (textResult) return textResult;
if (!this._hasAiWorkspaceExerciseContent() && CoursewareNavigator.hasReader()) {
const classType = this._isAiWorkspaceTextUnit() ? 'AI学习空间图文' : '课件';
const handled = await CoursewareNavigator.run(this.panel, 'AI学习空间课件', classType);
return { handled: true, completed: handled, kind: 'courseware', media: null };
}
return { handled: false, completed: false, kind: 'exercise', media: null };
}
const { media, mediaType } = result;
this.panel.log(`🎬 检测到AI学习空间${mediaType},开始自动播放...`);
preventScreenCheck();
this._videoQuizStates ||= new Map();
this._videoQuizHandling = false;
let endedLatch = Player.isFinished(media);
let resumeTimer = null;
let rateGuardDispose = null;
const onEnded = () => { endedLatch = true; };
media.addEventListener('ended', onEnded);
rateGuardDispose = Player.applyMediaDefault(media);
this.panel.log(`⏩ 播放倍速: ${Config.playbackRate}x`);
const shouldResume = () => !stopRequested && !endedLatch && !this._videoQuizHandling &&
!this._findActiveAiWorkspaceVideoQuiz(media) && !Player.isFinished(media);
const autoContinue = () => {
if (!shouldResume() || !media.paused) return;
if (resumeTimer) clearTimeout(resumeTimer);
resumeTimer = setTimeout(() => {
resumeTimer = null;
if (shouldResume() && media.paused) media.play().catch(() => {});
}, 800);
};
media.addEventListener('pause', autoContinue);
const stopObserve = Player.observePause(media, { shouldResume });
const deadline = Date.now() + 4 * 60 * 60 * 1000;
let completed = false;
let failureReason = '';
let completionSeenAt = 0;
try {
while (!stopRequested && Date.now() < deadline) {
const quiz = this._findActiveAiWorkspaceVideoQuiz(media);
if (quiz) {
completionSeenAt = 0;
const resolved = await this._answerAiWorkspaceVideoQuiz(quiz);
if (!resolved) {
failureReason = '视频弹题未能自动完成';
break;
}
await Utils.sleep(400);
if (shouldResume() && media.paused) media.play().catch(() => {});
continue;
}
const companion = this._findActiveAiWorkspaceCompanionQuiz(media);
if (companion) {
// 学伴习题由播放时间推送到聊天区,提交后仍保留卡片和解析,不阻塞原视频。
await this._answerAiWorkspaceCompanionQuiz(companion);
if (stopRequested) break;
if (this._findActiveAiWorkspaceVideoQuiz(media)) continue;
if (shouldResume() && media.paused) media.play().catch(() => {});
}
if (this._isAiWorkspaceMediaComplete(media, endedLatch)) {
if (!completionSeenAt) completionSeenAt = Date.now();
// ended 与结尾弹题的 DOM 渲染可能错开,留出一次稳定观察窗口。
if (Date.now() - completionSeenAt >= 1500) {
completed = true;
break;
}
await Utils.sleep(250);
continue;
}
completionSeenAt = 0;
if (!media.isConnected) {
failureReason = '播放器节点已被页面替换';
break;
}
await Utils.sleep(600);
}
if (!completed && !failureReason && !stopRequested) failureReason = '播放等待超时';
} finally {
if (resumeTimer) clearTimeout(resumeTimer);
media.removeEventListener('ended', onEnded);
media.removeEventListener('pause', autoContinue);
stopObserve();
rateGuardDispose?.();
}
if (stopRequested) return { handled: true, completed: false, kind: 'media', media, reason: 'stopped' };
if (!completed) {
this.panel.log(`⚠️ ${mediaType}未完成:${failureReason}`);
return { handled: true, completed: false, kind: 'media', media, reason: failureReason };
}
this.panel.log(`✅ ${mediaType}播放完毕`);
return { handled: true, completed: true, kind: 'media', media };
}
async runAiWorkspace() {
const visited = new Set();
for (let step = 0; step < 300; step++) {
if (stopRequested) {
this.panel.log('⏸️ 已停止');
this.panel.resetStartButton('继续刷课');
return;
}
Store.touchRunState();
const unitBefore = this._snapshotAiWorkspaceUnit();
if (step > 0 && visited.has(unitBefore.key)) {
this.panel.log('⚠️ 检测到课程页面循环,已停止以免重复播放当前单元');
this.panel.resetStartButton('继续刷课');
return;
}
visited.add(unitBefore.key);
const content = await this._tryAiWorkspaceMedia();
if (!content.handled) {
return await this.runAiWorkspaceExercise();
}
if (!content.completed) {
if (content.reason !== 'stopped') this.panel.log('当前单元尚未确认完成,已保留在本页');
this.panel.resetStartButton('继续刷课');
return;
}
// 以本轮开始时的单元作为锚点。视频结束后平台可能先把路由退回课程目录,
// 此时重新 snapshot 会丢失刚完成的视频 ID,容易再次点击同一项。
const transition = await this._advanceAiWorkspaceLesson(unitBefore);
if (transition === 'advanced') {
await Utils.sleep(1500);
continue;
}
if (transition === 'complete') {
this.panel.log('🎉 AI学习空间课程已完成');
this.panel.resetStartButton('课程完成');
return;
}
this.panel.log('⚠️ 未能确认“下一节”切换,已停止以免重复当前内容');
this.panel.resetStartButton('继续刷课');
return;
}
this.panel.log('⚠️ 连续处理单元数量已达上限,请检查课程目录');
this.panel.resetStartButton('继续刷课');
}
async run(opts = {}) {
this.panel.log('🎯 开始自动答题...');
if (!await Utils.sleepCancellable(2000)) return this.pauseAnswer('');
if (Solver.isAiWorkspaceExercisePage()) {
try {
return await this.runAiWorkspace();
} catch (err) {
this.panel.log(`⚠️ AI学习空间流程异常:${err?.message || err}`);
this.panel.log('已暂停在当前页面,点击“继续刷课”可重试');
this.panel.resetStartButton('继续刷课');
}
return;
}
if (Solver.isStudentQuizPage()) {
return await this.runStudentQuiz();
}
const totalFromData = interceptedProblems ? interceptedProblems.length : 0;
const isInSidebar = (el) => !!(
el.closest('.aside-body') || el.closest('.exam-aside') ||
el.closest('.answer-sheet') || el.closest('.sheet-box') ||
el.closest('.sheet-list') || el.closest('.problem-fixedbar')
);
const collectMainItems = () => {
const containers = ['.container-problem', '.exam-main--body', '.exam-main', '.container-body'];
for (const sel of containers) {
const container = document.querySelector(sel);
if (!container) continue;
const items = Array.from(container.querySelectorAll('.subject-item')).filter(el => !isInSidebar(el));
if (items.length > 0) return items;
}
return Array.from(document.querySelectorAll('.subject-item')).filter(el => !isInSidebar(el));
};
let navButtons = null;
const navSelectors = [
'.aside-body .subject-item',
'.aside-body li',
'.exam-aside .subject-item',
'.sheet-box .item', '.sheet-list .item',
'.answer-sheet .item'
];
for (const sel of navSelectors) {
const found = document.querySelectorAll(sel);
if (found && found.length > 1) { navButtons = found; break; }
}
if (!navButtons || navButtons.length <= 1) {
const allBtns = document.querySelectorAll('button, div[data-order]');
const numBtns = Array.from(allBtns).filter(b => {
const t = b.textContent.trim();
return /^\d+$/.test(t) && parseInt(t) >= 1 && parseInt(t) <= 50;
});
if (numBtns.length >= 5) navButtons = numBtns;
}
let allItems = [];
let retryCount = 0;
while (retryCount < 5) {
allItems = collectMainItems();
if (allItems.length > 0 || (navButtons && navButtons.length > 1)) break;
retryCount++;
this.panel.log(`未找到题目,等待页面加载... (${retryCount}/5)`);
await Utils.sleep(3000);
}
const hasNav = navButtons && navButtons.length > 1;
const isPaginated = !!(hasNav && allItems.length <= 1);
let totalQuestions = isPaginated ? navButtons.length : allItems.length;
if (totalQuestions === 0 && totalFromData > 0) {
totalQuestions = totalFromData;
}
if (totalQuestions === 0) {
this.panel.log('❌ 未找到题目,请确认页面已加载完成');
this.panel.resetStartButton('开始答题');
return;
}
const progress = this.getAnswerProgressStart(opts.progressKind || 'generic', totalQuestions);
const startIndex = isPaginated ? progress.index : 0;
this.panel.log('找到 ' + totalQuestions + ' 道题目,开始检查并作答');
for (let i = startIndex; i < totalQuestions; i++) {
if (stopRequested) return this.pauseAnswer('');
this.saveAnswerProgress(progress.key, i, totalQuestions);
const readMain = () => {
const items = collectMainItems();
return items[0] || document.querySelector('.container-problem .item-body,.exam-main--body .item-body,.container-body .item-body');
};
if (isPaginated) {
const button = navButtons[i];
if (!button) return this.pauseAnswer('无法定位第 ' + (i + 1) + ' 题');
const before = (readMain()?.innerText || readMain()?.textContent || '').trim();
const active = () => button.getAttribute?.('aria-current') === 'true' ||
button.getAttribute?.('aria-current') === 'page' ||
button.getAttribute?.('aria-selected') === 'true' ||
/(?:^|\s)(?:active|is-active|current|selected)(?:\s|$)/.test(String(button.className || ''));
if (!active()) {
if (!await Utils.safeClick(button, { scroll: false })) return this.pauseAnswer('');
const moved = await Utils.poll(() => {
const current = (readMain()?.innerText || readMain()?.textContent || '').trim();
return active() || (!!current && current !== before);
}, { interval: 300, timeout: 6000, cancelOnStop: true });
if (!moved) return this.pauseAnswer('第 ' + (i + 1) + ' 题翻页未生效');
}
}
if (stopRequested) return this.pauseAnswer('');
const item = isPaginated ? readMain() : collectMainItems()[i];
const area = item?.querySelector('.item-body') || item;
if (!area) return this.pauseAnswer('未找到第 ' + (i + 1) + ' 题');
let usedAI = false;
try {
if (!this.isAiWorkspaceQuestionAnswered(item)) {
const result = await Solver.recognize(area, i);
if (stopRequested) return this.pauseAnswer('');
if (!result?.question || result.question.length <= 3) return this.pauseAnswer('第 ' + (i + 1) + ' 题识别失败');
const query = await this.queryAnswers(result, '第 ' + (i + 1) + ' 题');
usedAI = query.usedAI;
// 整卷分页页面也可能只有最终提交。只有题内存在单题按钮时才单题提交。
const localSubmit = Solver.findScopedSubmitButton(item);
const selected = await Solver.autoSelectAndSubmit(query.answers, item, result.options,
{ autoSubmit: !!localSubmit, type: result.type });
if (selected !== true || stopRequested) return this.pauseAnswer('第 ' + (i + 1) + ' 题未完整作答或提交');
}
} catch (error) {
return this.pauseAnswer('第 ' + (i + 1) + ' 题处理失败:' + (error?.message || error));
}
this.saveAnswerProgress(progress.key, i + 1, totalQuestions, i + 1);
await Utils.answerCooldown(usedAI, this.panel.log.bind(this.panel));
}
if (stopRequested) return this.pauseAnswer('');
if (!await this.submitFinalExam('试卷')) return this.pauseAnswer('试卷尚未确认提交');
this.clearAnswerProgress(progress.key);
this.panel.log('🎉 答题并提交完成');
if (!opts.embedded) this.panel.resetStartButton('答题完成');
return true;
}
}
// ---- pro/lms 旧版(仅做转发) ----
class ProOldRunner {
constructor(panel) {
this.panel = panel;
}
run() {
this.panel.log('准备打开新标签页...');
const leafDetail = document.querySelectorAll('.leaf-detail');
let classCount = Store.getProClassCount() - 1;
while (leafDetail[classCount] && !leafDetail[classCount].firstChild.querySelector('i').className.includes('shipin')) {
classCount++;
Store.setProClassCount(classCount + 1);
this.panel.log('课程不属于视频,已跳过');
}
Store.touchRunState();
leafDetail[classCount]?.click();
}
}
// ---- pro/lms 新版(主要逻辑) ----
class ProNewRunner {
constructor(panel) {
this.panel = panel;
}
async run() {
preventScreenCheck();
let classCount = Store.getProClassCount();
while (true) {
// 检查是否请求停止
if (stopRequested) {
this.panel.log('⏸️ 已停止刷课');
this.panel.resetStartButton('继续刷课');
return;
}
this.panel.log(`准备播放第 ${classCount} 集...`);
await Utils.sleep(2000);
const className = document.querySelector('.header-bar')?.firstElementChild?.innerText || '';
const classType = document.querySelector('.header-bar')?.firstElementChild?.firstElementChild?.getAttribute('class') || '';
const classStatus = document.querySelector('#app > div.app_index-wrapper > div.wrap > div.viewContainer.heightAbsolutely > div > div > div > div > section.title')?.lastElementChild?.innerText || '';
const coursewareLike = /tuwen|kejian|ppt|pdf|doc|wendang|slide/i.test(classType) ||
/PPT|课件|文档|PDF|幻灯|阅读|图文/i.test(className);
if (coursewareLike && !classStatus.includes('已读') && !classStatus.includes('100%')) {
this.panel.log(`正在阅读/翻阅:${className}`);
const handled = await CoursewareNavigator.run(this.panel, className || '课件', classType);
if (!handled) {
this.panel.log('⚠️ 未能确认课件已翻阅完成,已停留当前课件');
this.panel.resetStartButton('继续刷课');
return;
}
} else if (classType.includes('taolun')) {
this.panel.log(`讨论区暂不自动发帖,${className}`);
await Utils.sleep(2000);
} else if (classType.includes('shipin') && !Utils.isProgressDone(classStatus)) {
this.panel.log(`2s 后开始播放:${className}`);
await Utils.sleep(2000);
let videoTimer;
let stopVideoObserve = null;
let rateGuardDispose = null;
let video = null;
let mediaEnded = false;
let completed = false;
try {
const videoWaitStart = Date.now();
videoTimer = setInterval(() => {
const current = document.querySelector('video');
if (current && current !== video) {
video = current;
mediaEnded = Player.isFinished(video);
const onEnded = () => { mediaEnded = true; };
video.addEventListener('ended', onEnded, { once: true });
rateGuardDispose = Player.applySpeed(document, video);
Player.mute();
stopVideoObserve?.();
stopVideoObserve = Player.observePause(video, { shouldResume: () => !stopRequested });
video.play?.().catch(() => {});
clearInterval(videoTimer);
videoTimer = null;
} else if (Date.now() - videoWaitStart > 20000) {
clearInterval(videoTimer);
videoTimer = null;
}
}, 500);
await Utils.sleep(8000);
completed = await Utils.poll(() => {
const status = document.querySelector('#app > div.app_index-wrapper > div.wrap > div.viewContainer.heightAbsolutely > div > div > div > div > section.title')?.lastElementChild?.innerText || '';
// 仅在媒体确实播到结尾且服务端同步到明确 100%/完成后切集。
// 98%/99% 是同步过程中的中间值,不能再作为完成信号。
const mediaComplete = mediaEnded || Player.isFinished(video);
return mediaComplete && Utils.isProgressDone(status);
}, { interval: 1000, timeout: await Utils.getDDL() });
if (!completed) {
this.panel.log(`⚠️ ${className} 未确认播放完整或平台进度未同步,已停留当前课程`);
this.panel.resetStartButton('继续刷课');
return;
}
this.panel.log(`${className} 播放完毕`);
} finally {
if (videoTimer) clearInterval(videoTimer);
if (stopVideoObserve) stopVideoObserve();
if (rateGuardDispose) rateGuardDispose();
}
} else if (classType.includes('zuoye')) {
this.panel.log(`进入作业:${className}(暂无自动答题)`);
await Utils.sleep(2000);
} else if (classType.includes('kaoshi')) {
this.panel.log(`进入考试:${className}(不会自动答题)`);
await Utils.sleep(2000);
} else if (classType.includes('ketang')) {
const featureFlags = Store.getFeatureConf();
if (featureFlags.skipLive) {
this.panel.log(`直播课/课堂:${className},已设置跳过`);
} else {
this.panel.log(`进入课堂:${className}(暂无自动功能)`);
}
await Utils.sleep(2000);
} else {
this.panel.log(`已看过:${className}`);
await Utils.sleep(2000);
}
this.panel.log(`第 ${classCount} 集播放完毕`);
classCount++;
Store.setProClassCount(classCount);
const nextBtn = document.querySelector('.btn-next');
if (nextBtn) {
const event1 = new Event('mousemove', { bubbles: true });
event1.clientX = 9999;
event1.clientY = 9999;
nextBtn.dispatchEvent(event1);
await Utils.maybeStudyBreak(classCount - 1, this.panel.log.bind(this.panel));
Store.touchRunState();
await Utils.safeClick(nextBtn, { scroll: false });
} else {
localStorage.removeItem(Config.storageKeys.proClassCount);
Store.setRunState('completed');
this.panel.log('课程播放完毕 🎉');
this.panel.resetStartButton('刷完啦~');
break;
}
}
}
}
// ---- 最小化面板状态更新 ----
let miniTimer = null;
function startMiniStatusUpdate(p) {
if (miniTimer) return;
miniTimer = setInterval(() => {
if (!isRunning) {
p.updateMiniStatus('📚', '雨课堂助手');
clearInterval(miniTimer);
miniTimer = null;
return;
}
const video = document.querySelector('video');
if (video && !video.paused && video.duration) {
const remaining = video.duration - video.currentTime;
const min = Math.floor(remaining / 60).toString().padStart(2, '0');
const sec = Math.floor(remaining % 60).toString().padStart(2, '0');
p.updateMiniStatus('▶️', `${min}:${sec}`);
} else {
p.updateMiniStatus('⏳', '下一节...');
}
}, 1000);
}
// 统一承载手动启动、路由恢复和启动时自动恢复的 runner。
// activeRunPromise 在整个异步链路结束前保持有效,resetStartButton 不能提前释放它。
function runManaged(fn, label = '任务') {
if (activeRunPromise) return activeRunPromise;
const task = (async () => {
try {
return await (typeof fn === 'function' ? fn() : undefined);
} catch (err) {
if (!stopRequested) {
const message = err?.message || err || `${label}异常`;
try { panel?.log?.(`⚠️ ${label}失败:${message}`); } catch (_) { }
try { panel?.resetStartButton?.('继续刷课'); } catch (_) { }
}
return undefined;
} finally {
// resetStartButton 可能在 runner 内部提前设置了文案,但只能在这里释放
// 运行锁。旧任务退出前始终保持 stopRequested,避免其继续点击/请求。
if (activeRunPromise === task) {
const wasStopped = stopRequested;
const button = panel?.btnStart;
if (wasStopped && button && /停止中|停止答题|停止刷课/.test(String(button.innerText || ''))) {
button.innerText = isYktExerciseLikePage() ? '继续答题' : '继续刷课';
try { Store.setRunState('stopped'); } catch (_) { }
}
activeRunPromise = null;
isRunning = false;
stopRequested = false;
if (button) button.disabled = false;
}
}
})();
activeRunPromise = task;
return task;
}
// ---- 自动恢复判断 ----
function shouldAutoResume(state, url, running, now) {
if (!state) return false;
if (state.status !== 'running') return false;
if (now - state.lastActiveTime > 1800000) return 'expired';
if (!url.includes('pro/lms') && !url.includes('/ai-workspace/')) return false;
if (running) return false;
return true;
}
function waitForDOMReady(p, timeout = 15000) {
const selectors = [
'.btn-next', '.header-bar', '.leaf-detail',
'video', 'audio', '.subject-item', '.problem-body',
'.ppt-container', '.swiper-container', '[class*="courseware"]',
'.learning-space-control-unit', '.lms-graph-article', '.scroll-container',
'[class*="learning-space"]', '[class*="catalog"]', '[class*="course-list"]',
'[href*="/ai-workspace/"]'
];
return new Promise(resolve => {
if (selectors.some(s => document.querySelector(s))) { resolve(); return; }
p.log('等待页面加载中...');
const start = Date.now();
const timer = setInterval(() => {
if (selectors.some(s => document.querySelector(s)) || Date.now() - start > timeout) {
clearInterval(timer);
resolve();
}
}, 500);
});
}
function setupRouteListener(p) {
let lastUrl = location.href;
let debounceTimer = null;
const onRouteChange = () => {
const currentUrl = location.href;
if (currentUrl === lastUrl) return;
lastUrl = currentUrl;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const result = shouldAutoResume(Store.getRunState(), currentUrl, isRunning, Date.now());
if (result === true) {
p.log('检测到路由变化,自动恢复刷课中...');
await waitForDOMReady(p);
if (!isRunning && !activeRunPromise) {
isRunning = true;
stopRequested = false;
p.btnStart.innerText = '⏸️ 停止刷课';
p.log('自动恢复刷课中...');
startMiniStatusUpdate(p);
runManaged(start, '路由恢复任务');
} else if (activeRunPromise) {
p.log('⏳ 上一任务仍在收尾,暂不重复启动');
}
}
}, 1000);
};
window.addEventListener('popstate', onRouteChange);
const origPush = history.pushState;
const origReplace = history.replaceState;
history.pushState = function(...args) { origPush.apply(this, args); onRouteChange(); };
history.replaceState = function(...args) { origReplace.apply(this, args); onRouteChange(); };
}
// ---- 路由 ----
async function start() {
const host = location.hostname.toLowerCase();
const pathname = location.pathname.toLowerCase();
panel.log(`正在匹配处理逻辑:${host}${pathname}`);
if (!isRainClassroomHost(host)) {
panel.resetStartButton('开始刷课');
panel.log(`当前域名未纳入雨课堂匹配:${host}`);
panel.log('若这是学校定制域名,请把域名发我补到 @match/@include');
return;
}
if (isStudentCardsCoursewarePage()) {
return await new DirectCoursewareRunner(panel).run();
}
// 检查是否是答题页面(含 AI 学习空间、v2/web/studentQuiz 等子路由)
if (isYktExerciseLikePage()) {
// 检查是否是结果页面
if (location.pathname.includes('/result/')) {
panel.log('检测到考试结果页面,无法答题');
panel.log('💡 提示:此页面为已完成的考试结果,无法修改答案');
panel.resetStartButton('查看结果');
return;
}
const isAiWs = location.pathname.includes('/ai-workspace/');
panel.log(isAiWs ? '检测到AI学习空间,准备启动(支持视频播放+自动答题)' : '检测到答题页面,准备启动自动答题功能');
// AI workspace 直接启动(视频播放不需要题库配置,答题时内部会检查)
if (isAiWs) {
return await new ExamRunner(panel).run();
}
// 检查是否启用自动答题
const featureFlags = Store.getFeatureConf();
if (!featureFlags.autoAI) {
panel.log('⚠️ 请先在【题库配置】中启用自动答题功能');
panel.resetStartButton('开始答题');
return;
}
// 检查验证/激活状态
const mode = Store.getAnswerMode();
if (mode === 'ai') {
if (!Store.getAIApiKey()) {
panel.log('⚠️ 未配置AI API Key,请先在【题库配置】中填写');
panel.resetStartButton('开始答题');
return;
}
panel.log(Store.isAIUnlocked()
? '✅ AI答题模式,已解锁快速随机间隔,启动自动答题'
: '✅ AI答题模式,未解锁将按35-43秒防封间隔运行');
} else if (mode === 'free') {
// 免费模式,检查验证码
if (!Store.isVerifyValid()) {
panel.log('⚠️ 验证码未验证或已过期,请先在【题库配置】中验证');
panel.resetStartButton('开始答题');
return;
}
panel.log('✅ 验证码有效,启动自动答题');
} else {
// 付费模式,检查积分(不阻止启动,只提示)
panel.log('✅ 付费模式,启动自动答题');
}
// 启动考试答题
return await new ExamRunner(panel).run();
}
if (isV2WebPage()) {
return await new V2Runner(panel).run();
} else if (isProLmsPage()) {
// 视频播放页:.btn-next 可能异步加载,需要等待
if (location.pathname.includes('/video/')) {
const found = await Utils.poll(() => document.querySelector('.btn-next'), { interval: 500, timeout: 10000 });
if (found) {
return await new ProNewRunner(panel).run();
} else {
panel.log('⚠️ 未找到播放控件,尝试刷新页面');
location.reload();
}
} else if (document.querySelector('.btn-next')) {
return await new ProNewRunner(panel).run();
} else {
return await new ProOldRunner(panel).run();
}
} else {
panel.resetStartButton('开始刷课');
panel.log('当前页面非刷课页面,应匹配 */v2/web/* 或 */pro/lms/*');
panel.log('考试页面请点击【开始答题】启动自动答题');
}
}
function shouldInitInCurrentWindow() {
if (!Utils.inIframe()) return true;
try {
const topHost = window.top.location.hostname;
if (isRainClassroomHost(topHost)) return false;
} catch (_) {
return isRainClassroomHost();
}
return isRainClassroomHost();
}
// ---- 启动 ----
if (!shouldInitInCurrentWindow()) return;
// 尽早设置 JSON Hook(在 document-start 阶段拦截数据)
setupFontFaceHook(); // FontFace 构造函数 hook 必须最早,捕获 ArrayBuffer 字体
setupJSONParseHook();
loadGlyphHashMap().catch(() => {}); // 加载失败由识别流程转入 OCR,不产生未处理的拒绝。
// 等待 document.body 加载完成
const waitForBody = () => {
return new Promise(resolve => {
if (document.body) {
resolve();
} else {
const observer = new MutationObserver(() => {
if (document.body) {
observer.disconnect();
resolve();
}
});
observer.observe(document.documentElement, { childList: true });
}
});
};
// 初始化面板
(async () => {
await waitForBody();
panel = createPanel();
// 从存储恢复播放倍速
Config.playbackRate = Store.getPlaybackRate();
// 初始信息已在HTML中显示,不需要额外log
panel.setStartHandler(start);
// 自动恢复检测
const runState = Store.getRunState();
const resumeResult = shouldAutoResume(runState, location.href, isRunning, Date.now());
if (resumeResult === 'expired') {
Store.clearRunState();
panel.log('⏰ 刷课状态已过期,请重新开始');
} else if (resumeResult === true) {
panel.log('🔄 检测到未完成的刷课任务...');
await waitForDOMReady(panel);
panel.log('🔄 自动恢复刷课中...');
isRunning = true;
stopRequested = false;
panel.btnStart.innerText = '⏸️ 停止刷课';
startMiniStatusUpdate(panel);
runManaged(start, '启动恢复任务');
}
// 启动 SPA 路由监听
setupRouteListener(panel);
})();
})();