// ==UserScript== // @name 柳大师 - 学习助手(v10.9.3) // @namespace local.liudashi.study-helper // @version 10.9.3 // @description 视频+课件+测验 任务队列 + 自动翻页 + AI答题 // @author 柳大师 // @match *://*.chaoxing.com/* // @match *://*.fanya.chaoxing.com/* // @match *://i.chaoxing.com/* // @match *://passport2.chaoxing.com/* // @match *://*.zhihuishu.com/* // @match *://*.icourse163.org/* // @match *://*.xuetangx.com/* // @match *://*.icve.com.cn/* // @match *://*.ulearning.cn/* // @match *://*.cnmooc.org/* // @match *://*.edu.cn/* // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @connect open.bigmodel.cn // @connect api.deepseek.com // @connect dashscope.aliyuncs.com // @connect api.gomooc.net // @connect * // @run-at document-idle // ==/UserScript== (function () { 'use strict'; if (window._sh_script_injected) return; window._sh_script_injected = true; const BRAND = '柳大师'; const VERSION = '10.9.2'; const IS_TOP = window.top === window.self; function getGreeting() { const h = new Date().getHours(); if (h < 6) return '凌晨好'; if (h < 11) return '上午好'; if (h < 13) return '中午好'; if (h < 18) return '下午好'; return '晚上好'; } const sleep = ms => new Promise(r => setTimeout(r, ms)); const rand = (a, b) => a + Math.random() * (b - a); const log = (...a) => console.log(`[${BRAND}]`, ...a); const LOCK_KEY = 'sh_video_lock_v8'; const FRAME_ID = 'f_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now(); let HOST = null, ROOT = null, BALL_EL = null, PANEL_EL = null; function readLock() { try { const r = localStorage.getItem(LOCK_KEY); if (!r) return null; const d = JSON.parse(r); return (Date.now() - d.heartbeat > 30000) ? null : d; } catch (e) { return null; } } function writeLock(d) { try { localStorage.setItem(LOCK_KEY, JSON.stringify(d)); } catch (e) {} } function clearLock() { try { if (readLock()?.frameId === FRAME_ID) localStorage.removeItem(LOCK_KEY); } catch (e) {} } function acquireLock(src) { const now = Date.now(); let d = readLock(); if (!d) { writeLock({ frameId: FRAME_ID, src: src || '', heartbeat: now }); return true; } if (d.frameId === FRAME_ID) { d.heartbeat = now; writeLock(d); return true; } return false; } function lockHeldByOther() { const d = readLock(); return d && d.frameId !== FRAME_ID; } setInterval(() => { const d = readLock(); if (d?.frameId === FRAME_ID) { d.heartbeat = Date.now(); writeLock(d); } }, 10000); const AI_PROVIDERS = { zhipu: { name: '智谱 GLM-4-Flash', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: 'glm-4-flash', tip: '完全免费 · 手机号注册', registerUrl: 'https://open.bigmodel.cn/' }, deepseek: { name: 'DeepSeek', url: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', tip: '注册送额度', registerUrl: 'https://platform.deepseek.com/' }, qwen: { name: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', model: 'qwen-turbo', tip: '新用户免费额度', registerUrl: 'https://dashscope.console.aliyun.com/' }, }; const CONFIG = { enabled: GM_getValue('sh_enabled', true), autoPlay: GM_getValue('sh_autoPlay', true), autoNext: GM_getValue('sh_autoNext', true), autoAnswer: GM_getValue('sh_autoAnswer', true), aiEnabled: GM_getValue('sh_aiEnabled', true), autoSubmit: GM_getValue('sh_autoSubmit', false), stealthMode: GM_getValue('sh_stealthMode', true), muteFallback: GM_getValue('sh_muteFallback', true), playbackRate: GM_getValue('sh_playbackRate', 1.5), nextDelay: GM_getValue('sh_nextDelay', 10), darkMode: GM_getValue('sh_darkMode', false), aiProvider: GM_getValue('sh_aiProvider', 'zhipu'), aiKeys: GM_getValue('sh_aiKeys', {}), enableFreeBank: GM_getValue('sh_enableFreeBank', true), ballPos: GM_getValue('sh_ballPos', { side: 'right', top: 100 }), }; function saveConfig() { try { Object.keys(CONFIG).forEach(k => GM_setValue('sh_' + k, CONFIG[k])); } catch (e) {} } function domGet(id) { try { return ROOT?.getElementById(id); } catch (e) { return null; } } function domQueryAll(sel) { try { return ROOT ? Array.from(ROOT.querySelectorAll(sel)) : []; } catch (e) { return []; } } function loadBank() { try { return GM_getValue('sh_qbank', {}); } catch (e) { return {}; } } function saveBank(b) { try { GM_setValue('sh_qbank', b); } catch (e) {} } function hashQ(t) { const c = (t || '').replace(/\s+/g, '').slice(0, 200); let h = 0; for (let i = 0; i < c.length; i++) { h = ((h << 5) - h) + c.charCodeAt(i); h |= 0; } return 'q_' + Math.abs(h).toString(36); } function queryBank(t) { const b = loadBank(); const e = b[hashQ(t)]; return e ? e.a : null; } function recordBank(t, a, type, src) { if (!t || !a) return false; const b = loadBank(); const k = hashQ(t); const ex = b[k]; if (ex && ex.src === 'system' && ex.a === a) return false; if (ex && ex.src === 'system' && src === 'ai') return false; b[k] = { q: t.slice(0, 300), a, type: type || 'radio', t: Date.now(), src: src || 'ai' }; const keys = Object.keys(b); if (keys.length > 5000) { keys.sort((x, y) => (b[x].t || 0) - (b[y].t || 0)); keys.slice(0, 1000).forEach(kk => delete b[kk]); } saveBank(b); return true; } function deleteBankEntry(k) { const b = loadBank(); if (b[k]) { delete b[k]; saveBank(b); return true; } return false; } function listBank(f) { const b = loadBank(); let arr = Object.entries(b).map(([k, v]) => ({ key: k, ...v })); if (f) { const s = f.toLowerCase(); arr = arr.filter(it => (it.q || '').toLowerCase().includes(s) || (it.a || '').toLowerCase().includes(s)); } return arr.sort((a, b) => (b.t || 0) - (a.t || 0)); } function bankStats() { const b = loadBank(); let sys = 0, ai = 0, man = 0; Object.values(b).forEach(v => { if (v.src === 'system') sys++; else if (v.src === 'manual') man++; else ai++; }); return { total: Object.keys(b).length, sys, ai, man }; } const TASK_CARD_SELECTORS = [ '.ans-job-item', '.posCatalog_select', '.chapter_item', '.catalog_points', '.jobItem', '[class*="ans-job"]', '[class*="job-item"]', '[class*="catalog_points"]', ]; const COMPLETED_MARKERS = [ '.icon_Completed', '.ans-job-icon-come', '.ans-job-icon.ans-job-icon-come', '.icon-dui', '.icon-success', '[class*="complete"]', '[class*="finish"]', '[class*="done"]', '[class*="success"]', '[class*="dui"]', '[class*="passed"]', '[class*="checked"]', ]; const PPT_FRAME_HINTS = /ppt|slide|panview|document|pdf|doc|viewer|office/i; const PPT_NEXT_BTN_SELECTORS = [ '.ppt_next', '.nextBtn', '.next-btn', '.btn-next', '.slide-next', '[title*="下一页"]', '[aria-label*="下一页"]', '[class*="next-page"]', '[class*="nextPage"]', '[class*="next_page"]', '.arrow-right', '.arrowRight', '.icon-next', '[class*="arrow"][class*="right"]', 'a[data-page="next"]', ]; function isGreenColor(str) { if (!str) return false; if (str.includes('green') || str.includes('success') || str.includes('complete') || str.includes('done')) return true; const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (m) { const [r, g, b] = m.slice(1, 4).map(Number); return g > 120 && g > r * 1.15 && g > b * 1.15; } return false; } function isCardCompleted(card) { if (!card) return false; for (const sel of COMPLETED_MARKERS) { const marks = card.querySelectorAll(sel); for (const mark of marks) { const st = getComputedStyle(mark); if (isGreenColor(st.color) || isGreenColor(st.backgroundColor) || isGreenColor(st.borderColor)) return true; try { const bf = getComputedStyle(mark, '::before'); if (/["']?[✓✔√]/.test(bf.content || '')) return true; if (isGreenColor(bf.color) || isGreenColor(bf.backgroundColor)) return true; } catch (e) {} const lb = (mark.getAttribute('aria-label') || mark.getAttribute('title') || '').trim(); if (/完成|已学|已看/.test(lb)) return true; } } const textEls = card.querySelectorAll('span, div, em, i, p, b, strong, label'); for (const el of textEls) { const t = (el.textContent || '').trim(); if (t.length > 15) continue; if (/^(任务点已完成|已完成|已学完|完成学习|学习完成|通过|已通过)$/.test(t)) { const st = getComputedStyle(el); if (isGreenColor(st.color)) return true; const p = el.parentElement; if (p) { const ps = getComputedStyle(p); if (isGreenColor(ps.color) || isGreenColor(ps.backgroundColor)) return true; } } } return false; } function getCardType(card) { if (!card) return 'unknown'; if (card.querySelector('video')) return 'video'; const iframes = card.querySelectorAll('iframe'); for (const f of iframes) { if (/video|media|player|ananas/i.test(f.src || '')) return 'video'; if (PPT_FRAME_HINTS.test(f.src || '')) { try { const idoc = f.contentDocument || f.contentWindow?.document; if (idoc && (idoc.querySelector('video'))) return 'video'; } catch (e) {} return 'ppt'; } } const text = card.textContent || ''; if (/测验|答题|选择题|判断题|考试|考核/.test(text)) return 'quiz'; if (/视频|视屏|课程视频|观看/.test(text)) return 'video'; if (/课件|文档|阅读|ppt|PPT|幻灯片|PPT课件/.test(text)) return 'ppt'; if (/pdf|PDF/.test(text)) return 'doc'; const cls = (card.className || '').toString(); if (/video/i.test(cls)) return 'video'; if (/ppt|slide|courseware/i.test(cls)) return 'ppt'; if (/doc|read|pdf/i.test(cls)) return 'doc'; if (/quiz|exam|test/i.test(cls)) return 'quiz'; return 'unknown'; } function scanTaskCards() { const cardSet = new Set(); for (const sel of TASK_CARD_SELECTORS) { try { document.querySelectorAll(sel).forEach(el => cardSet.add(el)); } catch (e) {} } const cards = [...cardSet].sort((a, b) => { const p = a.compareDocumentPosition(b); if (p & Node.DOCUMENT_POSITION_FOLLOWING) return -1; if (p & Node.DOCUMENT_POSITION_PRECEDING) return 1; return 0; }); const queue = []; for (const card of cards) { const rect = card.getBoundingClientRect(); if (rect.width < 50 || rect.height < 20) continue; if (isCardCompleted(card)) { const v = card.querySelector('video'); if (v) v.dataset.shTaskDone = '1'; continue; } const type = getCardType(card); if (type === 'unknown' && !card.querySelector('video') && !card.querySelector('iframe')) { card.dataset.shSkipped = '1'; continue; } queue.push({ card, type, video: card.querySelector('video'), text: (card.textContent || '').trim().slice(0, 60) }); } return queue; } let CURRENT_TASK = null, IS_SWITCHING = false, VIDEO_ENDED_AT = 0, LAST_ACTION = 0, LAST_SCAN_AT = 0; const SCAN_INTERVAL = 3000; const QUIZ_TIMEOUT = 5 * 60 * 1000; const PPT_STALL_TIMEOUT = 90 * 1000; function findVideoInCard(card) { if (!card) return null; let v = card.querySelector('video'); if (v) return v; const ifr = card.querySelectorAll('iframe'); for (const f of ifr) { try { const d = f.contentDocument || f.contentWindow?.document; if (d) { v = d.querySelector('video'); if (v) return v; } } catch (e) {} } return null; } function findPptFrame(card) { if (!card) return null; const iframes = [...card.querySelectorAll('iframe')]; for (const f of iframes) { if (PPT_FRAME_HINTS.test(f.src || '')) return f; } for (const f of iframes) { try { const d = f.contentDocument; if (d && d.body) return f; } catch (e) {} } if (card.querySelector('[class*="slide"], [class*="ppt"], [class*="panview"]')) return null; return null; } function tryClickPptNext(pageDoc) { if (!pageDoc) return false; for (const sel of PPT_NEXT_BTN_SELECTORS) { try { const btns = pageDoc.querySelectorAll(sel); for (const b of btns) { const style = pageDoc.defaultView ? pageDoc.defaultView.getComputedStyle(b) : null; if (style && (style.display === 'none' || style.visibility === 'hidden')) continue; if (b.disabled) continue; const txt = (b.textContent || '').trim(); const title = (b.getAttribute('title') || b.getAttribute('aria-label') || '').trim(); if (/上一页|prev|previous/i.test(txt + ' ' + title)) continue; try { b.click(); return true; } catch (e) {} try { b.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: pageDoc.defaultView || window })); return true; } catch (e) {} } } catch (e) {} } try { const allClickable = pageDoc.querySelectorAll('a, button, span, div[onclick]'); for (const el of allClickable) { const t = (el.textContent || '').trim(); if (t.length > 10) continue; if (/^(下一页|下一张|继续|next)$/i.test(t)) { try { el.click(); return true; } catch (e) {} } } } catch (e) {} return false; } function tryPressPptKey(pageDoc) { if (!pageDoc) return; const win = pageDoc.defaultView || window; const targets = [pageDoc.body, pageDoc.documentElement].filter(Boolean); const keys = ['ArrowRight', 'PageDown', ' ', 'Right']; keys.forEach(k => { targets.forEach(t => { try { t.dispatchEvent(new KeyboardEvent('keydown', { key: k, keyCode: k === 'ArrowRight' ? 39 : k === 'PageDown' ? 34 : 32, bubbles: true })); t.dispatchEvent(new KeyboardEvent('keyup', { key: k, keyCode: k === 'ArrowRight' ? 39 : k === 'PageDown' ? 34 : 32, bubbles: true })); } catch (e) {} }); try { win.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true })); win.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true })); } catch (e) {} }); } async function handlePptTask(task) { if (isCardCompleted(task.card)) return 'done'; const frame = findPptFrame(task.card); let pageDoc = null; try { pageDoc = frame ? (frame.contentDocument || frame.contentWindow?.document) : null; } catch (e) { pageDoc = null; } if (!pageDoc) pageDoc = task.card.ownerDocument; if (tryClickPptNext(pageDoc)) return 'clicked'; if (pageDoc !== task.card.ownerDocument) { if (tryClickPptNext(task.card.ownerDocument)) return 'clicked'; } tryPressPptKey(pageDoc); return 'key'; } async function pollLoop() { if (!CONFIG.enabled || !CONFIG.autoPlay || IS_SWITCHING) return; if (lockHeldByOther()) { document.querySelectorAll('video').forEach(v => { if (!v.paused) try { v.pause(); } catch (e) {} }); return; } const now = Date.now(); if (now - LAST_SCAN_AT > SCAN_INTERVAL) { LAST_SCAN_AT = now; if (CURRENT_TASK && CURRENT_TASK.card && isCardCompleted(CURRENT_TASK.card)) { await finishTask(CURRENT_TASK, true); return; } } if (CURRENT_TASK) { const task = CURRENT_TASK; if (task.type === 'quiz') { if (!task.card || !document.contains(task.card)) { log('📤 quiz 卡片已移除,释放'); releaseTask(); return; } if (task.startTime && (now - task.startTime > QUIZ_TIMEOUT)) { log('⏱ quiz 超时释放'); releaseTask(); return; } if (isCardCompleted(task.card)) { await finishTask(task, true); return; } return; } if (task.type === 'ppt' || task.type === 'doc') { if (!task.card || !document.contains(task.card)) { log('📤 ppt 卡片已移除,释放'); releaseTask(); return; } if (isCardCompleted(task.card)) { log('✅ 课件完成'); await finishTask(task, true); return; } if (task.lastActionTime && (now - task.lastActionTime > PPT_STALL_TIMEOUT) && (now - task.startTime > PPT_STALL_TIMEOUT * 2)) { log('⚠️ 课件长时间无进展,释放任务'); releaseTask(); return; } if (!task.lastActionTime || now - task.lastActionTime > 2500) { task.lastActionTime = now; const result = await handlePptTask(task); if (result === 'done') { await finishTask(task, true); return; } log('📄 课件翻页:', result); } updateStatus('课件翻页中'); return; } const v = task.video; if (!v || !document.contains(v)) { if (task.card && isCardCompleted(task.card)) await finishTask(task, true); else releaseTask(); return; } const cardDone = task.card && isCardCompleted(task.card); const ended = v.ended || (v.duration && v.currentTime / v.duration >= 0.99); if (ended && !VIDEO_ENDED_AT) { VIDEO_ENDED_AT = Date.now(); updateStatus('等待任务点变绿'); } if (cardDone) { const n = Date.now(); if (n - LAST_ACTION < 1000) return; LAST_ACTION = n; await finishTask(task); return; } if (ended && VIDEO_ENDED_AT && Date.now() - VIDEO_ENDED_AT > 90000) { log('⚠️ 视频超时强制通过'); await finishTask(task, true); return; } if (v.paused && !ended) { try { await v.play(); applyRateDynamic(v, CONFIG.playbackRate); updateStatus('播放中'); } catch (e) { v.muted = true; try { await v.play(); } catch (e2) {} } } else if (!ended) { applyRateDynamic(v, CONFIG.playbackRate); updateStatus('播放中'); } return; } const queue = scanTaskCards(); if (!queue.length) { const quizCount = document.querySelectorAll('.TiMu').length; const videoCount = document.querySelectorAll('video').length; if (quizCount > 0 && videoCount === 0) return; if (IS_TOP) await goNext(); return; } const head = queue[0]; log(`▶ [${head.type}]`, head.text); if (head.type === 'video') { const v = findVideoInCard(head.card) || head.video; if (!v) { if (head.card) head.card.dataset.shTried = '1'; return; } if (!acquireLock(v.currentSrc || 'video')) return; CURRENT_TASK = head; CURRENT_TASK.video = v; CURRENT_TASK.startTime = Date.now(); VIDEO_ENDED_AT = 0; try { head.card.click(); } catch (e) {} await sleep(1500); await playVideoElement(v); return; } else if (head.type === 'ppt' || head.type === 'doc') { CURRENT_TASK = head; CURRENT_TASK.video = null; CURRENT_TASK.startTime = Date.now(); CURRENT_TASK.lastActionTime = 0; log('📄 进入课件任务'); try { head.card.click(); } catch (e) {} await sleep(2000); const result = await handlePptTask(head); if (result === 'done') await finishTask(head, true); return; } else if (head.type === 'quiz') { CURRENT_TASK = head; CURRENT_TASK.video = null; CURRENT_TASK.startTime = Date.now(); log('📝 进入测验任务'); try { head.card.click(); } catch (e) {} return; } } async function playVideoElement(v) { if (!v) return; applyRateDynamic(v, CONFIG.playbackRate); try { await v.play(); updateStatus('播放中'); } catch (e) { if (CONFIG.muteFallback && !v.muted) { v.muted = true; try { await v.play(); updateStatus('播放中(静音)'); } catch (e2) {} } } } async function finishTask(task, force = false) { if (!task) return; if (task.card) try { task.card.dataset.shTaskDone = '1'; } catch (e) {} if (task.video) try { task.video.dataset.shTaskDone = '1'; task.video.pause(); } catch (e) {} const delay = 3000 + Math.random() * 12000; updateStatus(`延时 ${(delay / 1000).toFixed(0)}s`); clearLock(); releaseTask(); await sleep(delay); setTimeout(() => pollLoop().catch(() => {}), 100); } function releaseTask() { if (CURRENT_TASK?.video) try { CURRENT_TASK.video.dataset.shActive = '0'; } catch (e) {} CURRENT_TASK = null; VIDEO_ENDED_AT = 0; } let lastNext = 0; async function goNext() { const now = Date.now(); if (now - lastNext < 30000) return; lastNext = now; IS_SWITCHING = true; updateStatus('切换中...'); const cooldown = CONFIG.stealthMode ? rand(180000, 480000) : 0; await sleep(CONFIG.nextDelay * 1000 + cooldown); tryClickNext(); setTimeout(() => { IS_SWITCHING = false; setTimeout(() => pollLoop().catch(() => {}), 3000); }, 8000); } function tryClickNext() { const sels = ['.next', '.next-btn', '.nextSection', '.next-section', '.chapter-next', '.section-next', '#nextBtn', '[class*="nextBtn"]', '[class*="next"] button', '.nextChapter', '.next-chapter', '[title*="下一"]', 'a[href*="next"]']; for (const s of sels) { const b = document.querySelector(s); if (b && b.offsetParent !== null && !b.disabled) { b.click(); return true; } } for (const el of document.querySelectorAll('a,button,span,div[onclick]')) { const t = (el.textContent || '').trim(); if (t && t.length <= 20 && /^下一[节章课]|继续学习|next$/i.test(t) && el.offsetParent !== null) { el.click(); return true; } } return false; } async function queryFreeBank(q) { if (!CONFIG.enableFreeBank) return null; return new Promise(res => { GM_xmlhttpRequest({ method: 'GET', url: `https://api.gomooc.net/api.php?question=${encodeURIComponent(q)}`, timeout: 5000, onload: r => { try { const d = JSON.parse(r.responseText); const a = d.answer || (d.data && d.data.answer); res(a || null); } catch (e) { res(null); } }, onerror: () => res(null), ontimeout: () => res(null) }); }); } async function callAI(q, opts, type) { if (!CONFIG.aiEnabled) return null; const prov = AI_PROVIDERS[CONFIG.aiProvider]; const key = (CONFIG.aiKeys || {})[CONFIG.aiProvider]; if (!prov || !key) { log(`❌ 未配置 ${prov?.name || 'AI'} 的 Key`); return null; } let sys, usr; if (type === 'radio' || type === 'checkbox') { const optTexts = opts.map(o => o.text || o); const isJudge = opts.length === 2 && optTexts.some(t => /对|错|是|否|正确|错误/.test(t)); if (isJudge) { sys = `你是一位答题助手。用户会给你一道判断题。必须严格遵守:\n1. 只输出一个字:"对" 或 "错"\n2. 不要输出任何解释、标点、其他文字\n3. 如果题目有乱码,根据上下文推测原题再判断\n示例输出:"对"`; usr = `判断题:${q}\n\n只回答"对"或"错":`; } else { const optText = opts.map((o, i) => `${String.fromCharCode(65 + i)}. ${o.text || o}`).join('\n'); sys = `你是一位答题助手。用户会给你一道选择题。必须严格遵守:\n1. 只输出选项字母,如 "A" 或 "AC" 或 "ABD"\n2. 不要输出任何解释、标点、其他文字\n3. 单选题只输出 1 个字母,多选题输出所有正确字母不带空格\n4. 如果题目有乱码,根据上下文推测原题再作答\n示例输出:"A"`; usr = `题目:${q}\n\n选项:\n${optText}\n\n只回答选项字母:`; } } else if (type === 'blank') { sys = `你是一位答题助手。填空题必须严格遵守:\n1. 只输出答案内容,多个空用 || 分隔\n2. 不要输出任何解释、题号、"答案:"等前缀\n3. 如果题目有乱码,根据上下文推测原题再作答\n示例输出:"北京" 或 "异步电动机||变频调速"`; usr = `填空题:${q}\n\n只回答答案内容:`; } else { sys = `你是一位答题助手。只输出答案内容,不要解释。`; usr = `题目:${q}\n答案:`; } log(`🤖 调用 ${prov.name}`); const res = await new Promise(r => { GM_xmlhttpRequest({ method: 'POST', url: prov.url, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` }, data: JSON.stringify({ model: prov.model, messages: [{ role: 'system', content: sys }, { role: 'user', content: usr }], max_tokens: 200, temperature: 0.1 }), timeout: 20000, onload: x => { log('📥 AI 状态:', x.status); try { const d = JSON.parse(x.responseText); if (d.error) { log('❌ AI 错误:', JSON.stringify(d.error).slice(0, 200)); r(null); return; } const t = d.choices[0].message.content.trim(); log('✅ AI 原始返回:', t); r(t); } catch (e) { log('❌ 解析失败:', e.message); r(null); } }, onerror: () => { log('❌ AI 网络错误'); r(null); }, ontimeout: () => { log('❌ AI 超时'); r(null); } }); }); if (!res) return null; return extractAnswerFromText(res, type, opts); } function extractAnswerFromText(text, type, opts) { if (!text) return null; const t = String(text).trim(); if (type === 'radio' || type === 'checkbox') { const optTexts = (opts || []).map(o => o.text || o); const isJudge = (opts || []).length === 2 && optTexts.some(o => /对|错|是|否|正确|错误/.test(o)); if (isJudge) { if (/(?:答案|正确答案)[::是\s]*错/.test(t) || /^错/.test(t) || /判断为错|应该是错|答案是错/.test(t)) return '错'; if (/(?:答案|正确答案)[::是\s]*对/.test(t) || /^对/.test(t) || /判断为对|应该是对|答案是对/.test(t)) return '对'; if (/[×✘]/.test(t)) return '错'; if (/[√✔]/.test(t)) return '对'; const hasErr = /错误/.test(t), hasCorr = /正确/.test(t); if (hasErr && !hasCorr) return '错'; if (hasCorr && !hasErr) return '对'; return t.slice(0, 3); } const m1 = t.match(/(?:答案|正确答案|选|应选|答案为|答案是)[::是\s]*([A-D]{1,4})/i); if (m1) return m1[1].toUpperCase(); const m2 = t.match(/^[A-D]{1,4}$/); if (m2) return t.toUpperCase(); const letters = t.match(/[A-D]/g); if (letters && letters.length > 0 && letters.length <= 4) return [...new Set(letters)].sort().join(''); return t.slice(0, 20); } if (type === 'blank' || type === 'short') { let a = t.replace(/^(?:答案|正确答案|答案为|答案是)[::是\s]*/, '').trim(); a = a.replace(/^["'「『]/, '').replace(/["'」』]$/, ''); return a || null; } return t; } function inViewport(el) { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth; } function isQuestionText(t) { if (!t || t.length < 3) return false; const cn = t.match(/[\u4e00-\u9fa5]/g); if (cn && cn.length / t.length >= 0.1) return true; return /^[a-zA-Z0-9\s.,;:'"!?()\-]+$/.test(t) && t.length > 8; } const Q_SELS = ['.TiMu', '.question-item', '.topic-item', '[class*="question"]', '[class*="exam-item"]', '.pop-question', '.quiz-item', '.ans-video-question', '[class*