// ==UserScript== // @name 雨课堂刷课助手 // @namespace http://tampermonkey.net/ // @version 2.3.1 // @description 针对雨课堂视频进行自动播放,配置题库自动答题 // @author 叶屿 // @license GPL3 // @antifeature payment 题库答题功能需要验证码(免费)或激活码(付费),视频播放等基础功能完全免费 // @match *://*.yuketang.cn/* // @match *://*.gdufemooc.cn/* // @match *://*exam.yuketang.cn/* // @match *://*-exam.yuketang.cn/* // @run-at document-start // @icon http://yuketang.cn/favicon.ico // @grant unsafeWindow // @grant GM_xmlhttpRequest // @connect qsy.iano.cn // @connect lyck6.cn // @connect cdn.jsdelivr.net // @connect unpkg.com // @connect open.bigmodel.cn // @connect dashscope.aliyuncs.com // @connect ark.cn-beijing.volces.com // @connect spark-api-open.xf-yun.com // @connect aip.baidubce.com // @connect api.deepseek.com // @connect api.moonshot.cn // @connect api.openai.com // @connect generativelanguage.googleapis.com // @connect api.groq.com // @connect api.siliconflow.cn // @connect api.lingyiwanwu.com // @connect api.minimax.chat // @connect api.stepfun.com // @connect api.baichuan-ai.com // @connect yuketang.cn // @connect gdufemooc.cn // @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.3.1 * ========================================== * * 【功能说明】 * 1. 视频自动播放(支持倍速、静音、防暂停) * 2. 作业自动答题(OCR识别 + 题库查询) * 3. 考试自动答题(支持停止/继续) * 4. 双模式题库(免费验证码 / 付费激活码) * * 【积分购买】 * 联系微信:C919irt * 价格表:50积分=2元,100积分=4元,150积分=6元,200积分=8元,500积分=18元 * 说明:每次答题消耗1积分,积分永久有效 * * 【付费声明】 * 本脚本基础功能(视频播放、进度保存)完全免费 * 题库答题功能需要验证码(免费24小时)或激活码(付费永久) * 付费仅用于题库API调用成本,不强制购买 * * 【免责声明】 * 本脚本仅供学习交流使用,请勿用于违反学校规定或作弊行为 * 使用本脚本造成的任何后果由使用者自行承担 * * 【版权信息】 * 作者:叶屿 | 版本:v2.3.1 | 更新:2026-04-26 * * ========================================== */ (() => { 'use strict'; let panel; // UI 面板实例后置初始化 let isRunning = false; // 标记是否正在运行 let stopRequested = false; // 标记是否请求停止 let AI_PROVIDERS = {}; // ---- 脚本配置,用户可修改 ---- const Config = { version: '2.3.1', // 版本号 playbackRate: 2, // 视频播放倍速 pptInterval: 3000, // ppt翻页间隔 storageKeys: { // 使用者勿动 progress: '[雨课堂脚本]刷课进度信息', deviceId: 'ykt_device_id', activationCode: 'ykt_activation_code', answerMode: 'ykt_answer_mode', // 答题模式:free/paid verifyValidUntil: 'ykt_verify_valid_until', // 验证码有效期 proClassCount: 'pro_lms_classCount', feature: 'ykt_feature_conf', // 是否开启题库作答/自动评论 runState: 'ykt_run_state', aiApiKey: 'ykt_ai_api_key', aiProvider: 'ykt_ai_provider', aiUnlockCode: 'ykt_ai_unlock_code', aiUnlockUntil: 'ykt_ai_unlock_until', playbackRate: 'ykt_playback_rate', unmatchedFallback: 'ykt_unmatched_fallback' } }; const Utils = { // 短暂睡眠,等待网页加载 sleep: (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms)), // 将一个 JSON 字符串解析为 JavaScript 对象 safeJSONParse(value, fallback) { try { return JSON.parse(value); } catch (_) { return fallback; } }, // 每隔一段时间检查某个条件是否满足(通过 checker 函数),如果满足就成功返回;如果超时仍未满足,就失败返回 poll(checker, { interval = 1000, timeout = 20000 } = {}) { return new Promise(resolve => { const start = Date.now(); const timer = setInterval(() => { if (checker()) { clearInterval(timer); resolve(true); return; } if (Date.now() - start > timeout) { clearInterval(timer); resolve(false); } }, interval); }); }, // 使用UI课程完成度来判别是否完成课程 isProgressDone(text) { if (!text) return false; return text.includes('100%') || text.includes('99%') || text.includes('98%') || text.includes('已完成'); }, // 主要是规避firefox会创建多个iframe的问题 inIframe() { return window.top !== window.self; }, // 下滑到最底部,触发课程加载 scrollToBottom(containerSelector) { const el = document.querySelector(containerSelector); if (el) el.scrollTop = el.scrollHeight; }, async getDDL() { const element = 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) { await new Promise(resolve => element.addEventListener('loadedmetadata', resolve, { once: true })); duration = Number(element.duration); } const elementDurationMs = duration * 1000; // 转为秒 const timeout = Math.max(elementDurationMs * 3, 10_000); // 至少 10 秒(防极短视频); return timeout; } }; // ---- 存储工具 ---- const Store = { 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] }; }, 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)); }, 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); }, getVerifyValidUntil() { return Number(localStorage.getItem(Config.storageKeys.verifyValidUntil)) || 0; }, setVerifyValidUntil(timestamp) { localStorage.setItem(Config.storageKeys.verifyValidUntil, timestamp); }, isVerifyValid() { const validUntil = this.getVerifyValidUntil(); return validUntil > Date.now() / 1000; }, 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() { return localStorage.getItem(Config.storageKeys.aiProvider) || 'zhipu'; }, setAIProvider(provider) { localStorage.setItem(Config.storageKeys.aiProvider, provider); }, getAIUnlockCode() { return localStorage.getItem(Config.storageKeys.aiUnlockCode) || ''; }, setAIUnlockCode(code) { localStorage.setItem(Config.storageKeys.aiUnlockCode, code); }, getAIUnlockUntil() { return Number(localStorage.getItem(Config.storageKeys.aiUnlockUntil)) || 0; }, setAIUnlockUntil(timestamp) { localStorage.setItem(Config.storageKeys.aiUnlockUntil, timestamp); }, isAIUnlocked() { const until = this.getAIUnlockUntil(); return until > Date.now() / 1000; }, 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); } }; // ---- 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(`
📚
雨课堂助手
💳
付费题库
需要激活码
🆓
免费题库
需要验证码
🤖
AI答题
需要API Key
扫码获取验证码
扫码观看广告获取验证码
验证后免费使用24小时
题库未匹配到答案时怎么处理。盲蒙单选 A 在多选题下"少选不得分"反而拖累正确率,所以只保留 AI 兜底(按题型返回单/多选)或跳过。AI 学习空间连续跳过太多题会让"下一题"按钮被锁,建议优先 AI。
`); 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'), 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'), 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') }; let isDragging = false; let startX = 0, startY = 0, startLeft = 0, startTop = 0; const hostWindow = window.parent || 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', () => { window.parent.alert('雨课堂助手 v2.3.1\n作者:叶屿\n\n功能说明:\n- 自动播放视频/音频\n- 题库自动答题\n- AI智能答题(14种大模型可选)\n- AI限速解锁(验证码24h极速答题)\n- AI智能评论/自动回复\n- 自动评论回复'); }); 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' }); }; // 查询积分 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) => { 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), 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(); 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); Store.setVerifyValidUntil(result.data.valid_until); 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 信息配置 ---- 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/invite?icode=upWK8Rq09RynLhZ8CwWAqf2gad6AKpjZefIo3dVEQyA%3D', linkText: '前往智谱AI开放平台注册/登录获取API Key →', keyHint: '进入控制台 → API Keys → 创建API Key → 复制' }, deepseek: { name: 'DeepSeek-V3', model: 'deepseek-chat', url: 'https://api.deepseek.com/chat/completions', free: false, desc: 'DeepSeek高性价比模型,百万token仅需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', free: false, desc: 'DeepSeek最新旗舰模型(1.6T参数/1M上下文),答题准确率显著高于V3,限时75折', link: 'https://platform.deepseek.com/', linkText: '前往DeepSeek开放平台注册/登录获取API Key →', keyHint: '登录后 → 左侧API Keys → 创建API Key → 复制(与V3共用同一Key)' }, qwen: { name: '通义千问Qwen-Plus', model: 'qwen-plus', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', free: false, desc: '阿里云通义千问,新用户有免费额度,知识面广', link: 'https://dashscope.console.aliyun.com/', linkText: '前往阿里云百炼平台注册/登录获取API Key →', keyHint: '登录后 → 右上角API-KEY → 创建API Key → 复制' }, doubao: { name: '豆包Doubao-lite-32k', 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 → 复制(需先开通模型)' }, spark: { name: '讯飞星火Spark Lite', model: 'spark-lite', url: 'https://spark-api-open.xf-yun.com/v1/chat/completions', free: true, desc: '科大讯飞星火免费模型,中文理解能力强', link: 'https://console.xfyun.cn/', linkText: '前往讯飞开放平台注册/登录获取API Key →', keyHint: '登录 → 控制台 → 星火大模型 → 创建应用 → 获取APIKey和APISecret' }, kimi: { name: 'Kimi/Moonshot', model: 'moonshot-v1-8k', url: 'https://api.moonshot.cn/v1/chat/completions', free: false, desc: 'Moonshot Kimi模型,长文本理解能力强,新用户有免费额度', link: 'https://platform.moonshot.cn/', linkText: '前往Moonshot开放平台注册/登录获取API Key →', keyHint: '登录 → 左侧API Key管理 → 新建API Key → 复制' }, gpt: { name: 'ChatGPT (GPT-4o-mini)', model: 'gpt-4o-mini', url: 'https://api.openai.com/v1/chat/completions', free: false, desc: 'OpenAI GPT-4o-mini,全球最流行AI,综合能力强,需科学上网', link: 'https://platform.openai.com/api-keys', linkText: '前往OpenAI平台注册/登录获取API Key →', keyHint: '登录 → API Keys → Create new secret key → 复制(需科学上网)' }, gemini: { name: 'Google Gemini Flash', model: 'gemini-2.0-flash', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', free: true, desc: 'Google Gemini 2.0 Flash免费模型,速度快,多语言能力强,需科学上网', link: 'https://aistudio.google.com/apikey', linkText: '前往Google AI Studio获取API Key →', keyHint: '登录Google账号 → 点击Get API Key → Create → 复制(需科学上网)' }, groq: { name: 'Groq (Llama 3.3 70B)', model: 'llama-3.3-70b-versatile', url: 'https://api.groq.com/openai/v1/chat/completions', free: true, desc: 'Groq超快推理引擎,免费使用Llama 3.3 70B,速度全网最快,需科学上网', link: 'https://console.groq.com/keys', linkText: '前往Groq控制台注册/登录获取API Key →', keyHint: '登录 → API Keys → Create API Key → 复制(需科学上网)' }, siliconflow: { name: '硅基流动 (Qwen3-8B)', model: 'Qwen/Qwen3-8B', url: 'https://api.siliconflow.cn/v1/chat/completions', free: true, desc: '硅基流动聚合平台,免费调用多种开源模型,国内直连无需科学上网', link: 'https://cloud.siliconflow.cn/', linkText: '前往硅基流动平台注册/登录获取API Key →', keyHint: '登录 → 左侧API密钥 → 创建API Key → 复制' }, yi: { name: '零一万物 (Yi-Lightning)', model: 'yi-lightning', url: 'https://api.lingyiwanwu.com/v1/chat/completions', free: false, desc: '零一万物Yi系列模型,性价比高,中英文能力出色', link: 'https://platform.lingyiwanwu.com/', linkText: '前往零一万物开放平台注册/登录获取API Key →', keyHint: '登录 → API Key管理 → 创建API Key → 复制' }, minimax: { name: 'MiniMax (abab6.5s)', model: 'abab6.5s-chat', url: 'https://api.minimax.chat/v1/text/chatcompletion_v2', free: false, desc: 'MiniMax大模型,新用户赠送免费额度,对话能力强', link: 'https://platform.minimaxi.com/', linkText: '前往MiniMax开放平台注册/登录获取API Key →', keyHint: '登录 → 接口密钥 → 创建新的密钥 → 复制' }, stepfun: { name: '阶跃星辰 (Step-1-Flash)', model: 'step-1-flash', url: 'https://api.stepfun.com/v1/chat/completions', free: true, desc: '阶跃星辰免费模型,推理能力强,适合复杂题目', link: 'https://platform.stepfun.com/', linkText: '前往阶跃星辰开放平台注册/登录获取API Key →', keyHint: '登录 → API Key → 创建API Key → 复制' }, baichuan: { name: '百川智能 (Baichuan4)', model: 'Baichuan4', url: 'https://api.baichuan-ai.com/v1/chat/completions', free: false, desc: '百川智能大模型,新用户赠送免费token,中文能力优秀', link: 'https://platform.baichuan-ai.com/', linkText: '前往百川智能开放平台注册/登录获取API Key →', keyHint: '登录 → API Keys → 创建API Key → 复制' } }; // 更新AI Provider信息显示 const updateAIProviderInfo = () => { const provider = ui.aiProviderSelect.value; const info = AI_PROVIDERS[provider]; if (!info) return; const freeTag = info.free ? '免费' : '低价'; ui.aiProviderInfo.innerHTML = `
${info.desc} ${freeTag}
🔗 ${info.linkText}
💡 操作:${info.keyHint}
`; }; // 更新AI解锁状态显示 const updateAIUnlockStatus = () => { if (Store.isAIUnlocked()) { const until = Store.getAIUnlockUntil(); const date = new Date(until * 1000); const dateStr = date.toLocaleString('zh-CN'); ui.aiUnlockStatus.innerHTML = `✅ 已解锁,有效期至 ${dateStr}(答题间隔≈1.5秒)`; ui.aiUnlockCode.disabled = true; ui.aiUnlockBtn.disabled = true; } else { ui.aiUnlockStatus.innerHTML = `⏳ 未解锁,当前答题间隔35秒`; 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); Store.setAIUnlockCode(code); Store.setAIUnlockUntil(result.data.valid_until); 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(); 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); // 保存功能配置(含评论模式) 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 (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') { if (!Store.getAIApiKey()) { log('⚠️ 题库配置已保存,但未填写AI API Key'); } else { log('✅ 题库配置已保存 (AI答题模式)'); } } else { // 免费模式 if (!Store.isVerifyValid()) { log('⚠️ 题库配置已保存,但验证码未验证或已过期,请先验证'); } else { log('✅ 题库配置已保存 (免费模式)'); } } } else { log('✅ 题库配置已保存'); } ui.settings.style.display = 'none'; }; ui.btnClear.onclick = () => { Store.removeProgress(window.parent.location.href); localStorage.removeItem(Config.storageKeys.proClassCount); log('已清除当前课程的刷课进度缓存'); }; // 后面赋值给panel return { ...ui, log, setStartHandler(fn) { ui.btnStart.onclick = () => { // 如果正在运行,点击停止 if (isRunning) { stopRequested = true; log('⏸️ 正在停止...'); ui.btnStart.innerText = '停止中...'; ui.btnStart.disabled = true; return; } log('启动中...'); isRunning = true; stopRequested = false; // 检查是否是考试页面 const url = location.host; const path = location.pathname.split('/'); if (url.includes('exam.yuketang.cn') || path.includes('exam') || path.includes('exercise') || path.includes('homework')) { ui.btnStart.innerText = '⏸️ 停止答题'; } else { ui.btnStart.innerText = '⏸️ 停止刷课'; } ui.btnStart.disabled = false; Store.setRunState('running'); startMiniStatusUpdate(panel); fn && fn(); }; }, resetStartButton(text = '开始刷课') { 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'); } isRunning = false; stopRequested = false; }, updateMiniStatus(icon, text) { if (ui.miniIcon) ui.miniIcon.innerText = icon; if (ui.miniText) ui.miniText.innerText = text; } }; } // ---- 播放器工具 ---- const Player = { applySpeed() { const rate = Config.playbackRate; const speedBtn = document.querySelector('xt-speedlist xt-button') || document.getElementsByTagName('xt-speedlist')[0]?.firstElementChild?.firstElementChild; const speedWrap = document.getElementsByTagName('xt-speedbutton')[0]; if (speedBtn && speedWrap) { speedBtn.setAttribute('data-speed', rate); speedBtn.setAttribute('keyt', `${rate}.00`); speedBtn.innerText = `${rate}.00X`; const mousemove = document.createEvent('MouseEvent'); mousemove.initMouseEvent('mousemove', true, true, unsafeWindow, 0, 10, 10, 10, 10, 0, 0, 0, 0, 0, null); speedWrap.dispatchEvent(mousemove); speedBtn.click(); } else if (document.querySelector('video')) { document.querySelector('video').playbackRate = rate; } }, 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; media.volume = 0; media.playbackRate = Config.playbackRate; const p = media.play(); if (p !== undefined) { p.catch(e => { if (e.name !== 'AbortError') console.warn('[雨课堂助手] applyMediaDefault play失败:', e); setTimeout(() => media.play().catch(() => {}), 1500); }); } }, observePause(video) { if (!video) return () => { }; const target = document.getElementsByClassName('play-btn-tip')[0]; if (!target) return () => { }; let isPlayPending = false; // 安全播放:防止重叠的 play() 调用导致 AbortError const safePlay = (delay = 500) => { if (isPlayPending) return; isPlayPending = true; setTimeout(() => { 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); } setTimeout(() => safePlay(1500), 1500); }); } else { isPlayPending = false; } } else { isPlayPending = false; } }, delay); }; safePlay(1500); const observer = new MutationObserver(list => { for (const mutation of list) { if (mutation.type === 'childList' && target.innerText === '播放') { safePlay(800); } } }); observer.observe(target, { childList: true }); return () => observer.disconnect(); }, waitForEnd(media, timeout = 0) { return new Promise(resolve => { if (!media) return resolve(); if (media.ended) return resolve(); let timer; const onEnded = () => { clearTimeout(timer); resolve(); }; media.addEventListener('ended', onEnded, { once: true }); if (timeout > 0) { timer = setTimeout(() => { media.removeEventListener('ended', onEnded); resolve(); }, timeout); } }); } }; // ---- 防切屏 ---- function preventScreenCheck() { const win = unsafeWindow; const blackList = new Set(['visibilitychange', 'blur', 'pagehide']); win._addEventListener = win.addEventListener; win.addEventListener = (...args) => blackList.has(args[0]) ? undefined : win._addEventListener(...args); document._addEventListener = document.addEventListener; document.addEventListener = (...args) => blackList.has(args[0]) ? undefined : document._addEventListener(...args); Object.defineProperties(document, { hidden: { value: false }, visibilityState: { value: 'visible' }, hasFocus: { value: () => true }, onvisibilitychange: { get: () => undefined, set: () => { } }, onblur: { get: () => undefined, set: () => { } } }); Object.defineProperties(win, { onblur: { get: () => undefined, set: () => { } }, onpagehide: { get: () => undefined, set: () => { } } }); } // ---- 字体反混淆 ---- 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]); } } // 兜底:扫