// ==UserScript==
// @name 雨课堂刷课助手 v2.7.9
// @namespace http://tampermonkey.net/
// @version 2.7.9
// @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.9
* ==========================================
*
* 【功能说明】
* 1. 视频自动播放(支持倍速、静音、防暂停)
* 2. 作业自动答题(OCR识别 + 题库查询)
* 3. 考试自动答题(支持停止/继续)
* 4. 双模式题库(免费验证码 / 付费激活码)
*
* 【积分购买】
* 联系微信:C919irt
* 价格表:50积分=2元,100积分=4元,150积分=6元,200积分=8元,500积分=18元
* 说明:每次答题消耗1积分,积分永久有效
*
* 【付费声明】
* 本脚本基础功能(视频播放、进度保存)完全免费
* 题库答题功能需要验证码(免费24小时)或激活码(付费永久)
* 付费仅用于题库API调用成本,不强制购买
*
* 【免责声明】
* 本脚本仅供学习交流使用,请勿用于违反学校规定或作弊行为
* 使用本脚本造成的任何后果由使用者自行承担
*
* 【版权信息】
* 作者:叶屿 | 版本:v2.7.9 | 更新:2026-10-13
*
* ==========================================
*/
(() => {
'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.9', // 版本号
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.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.9
- 📖 题库自动答题模式
- ⚠️ 自动答题需要配置激活
- 💡 点击【题库配置】开始
- 🚀 雨课堂助手已启动
开始前请确认用途
本功能仅用于复习、回看和辅助整理学习进度,请完整阅读后再继续。
请先阅读到末尾,再勾选确认。
领取付费题库测试
按图片中的页面提示领取测试激活码,再回到这里完成激活。
领取成功后,将激活码粘贴到上方输入框,再点击“激活”。
`);
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.9\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
// 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 (source instanceof ArrayBuffer) {
capturedFontBuffers.push({ family, buffer: source });
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 });
console.log('[雨课堂助手] 🎯 拦截 FontFace(TypedArray):', family, 'bytes:', source.byteLength);
} else if (typeof source === 'string') {
const m = source.match(/url\(["\']?([^"\')]+)["\']?\)/);
if (m) console.log('[雨课堂助手] 🎯 拦截 FontFace(URL):', family, m[1]);
}
} 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',
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);
}
});
});
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 (!glyphHashMap) {
console.warn('[雨课堂助手] 字形映射表未加载,跳过反混淆');
return null;
}
try {
const font = opentype.parse(buffer);
const charMap = {};
let matchCount = 0;
let glyphsUsable = 0;
for (let i = 0; i < font.glyphs.length; i++) {
const glyph = font.glyphs.get(i);
if (!glyph.unicode || !glyph.path || !glyph.path.commands || glyph.path.commands.length === 0) continue;
glyphsUsable++;
const pathStr = glyph.path.toPathData();
if (!pathStr) continue;
const hash = await sha1Hex(pathStr);
const originalUnicode = glyphHashMap[hash];
if (originalUnicode !== undefined && originalUnicode !== glyph.unicode) {
charMap[String.fromCodePoint(glyph.unicode)] = String.fromCodePoint(originalUnicode);
matchCount++;
}
}
console.log('[雨课堂助手] 字体反混淆映射建立完成:', matchCount, '个字符需要还原 (总字形:', font.glyphs.length, '有unicode+path:', glyphsUsable, label || '', ')');
if (matchCount > 0) fontCharMap = charMap;
return charMap;
} catch (e) {
console.error('[雨课堂助手] 解析字体失败:', label, e);
return null;
}
}
async function buildFontCharMap(fontUrl) {
if (!glyphHashMap) {
console.warn('[雨课堂助手] 字形映射表未加载,跳过反混淆');
return {};
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: fontUrl,
responseType: 'arraybuffer',
onload: async (res) => {
try {
const charMap = await buildFontCharMapFromBuffer(res.response, 'URL: ' + fontUrl);
if (charMap === null) { reject(new Error('font parse failed')); return; }
resolve(charMap);
} catch (e) {
console.error('[雨课堂助手] 解析混淆字体失败:', e);
reject(e);
}
},
onerror: (err) => {
console.error('[雨课堂助手] 下载混淆字体失败:', err);
reject(err);
}
});
});
}
// 对整段文本逐字替换(仅在拿不到 HTML 上下文时使用,例如已渲染后的 DOM innerText)
function deobfuscateText(text) {
if (!fontCharMap || !text) return text;
return Array.from(text).map(ch => fontCharMap[ch] || ch).join('');
}
// 从页面 CSS 中发现可能的混淆字体 URL(AI学习空间等场景 JSON Hook 拿不到 fontUrl 时使用)
function discoverEncryptedFontUrls() {
const candidates = new Set();
const isLikelyEncrypted = (text) => /encrypt(ed)?|xuetangx|yuketang|exam[_-]?data[_-]?decrypt|exam[_-]?font/i.test(text || '');
// 优先扫描 styleSheets(拿到的是已解析后的 CSSRule,比较干净)
for (const sheet of Array.from(document.styleSheets || [])) {
let rules;
try { rules = sheet.cssRules || sheet.rules; } catch (_) { continue; /* 跨域样式表无法读取 */ }
if (!rules) continue;
for (const rule of Array.from(rules)) {
// CSSRule.FONT_FACE_RULE === 5
if (rule.type !== 5) continue;
const family = (rule.style?.getPropertyValue?.('font-family') || '').replace(/['"]/g, '');
const src = rule.style?.getPropertyValue?.('src') || '';
if (!isLikelyEncrypted(family) && !isLikelyEncrypted(src)) continue;
const re = /url\(["']?([^"')]+\.(?:woff2?|ttf|otf)[^"')]*)["']?\)/gi;
let m;
while ((m = re.exec(src)) !== null) candidates.add(m[1]);
}
}
// 兜底:扫