// ==UserScript== // @name 学习通 章节测验助手(自动识别 / 作答 / 跳章 / 视频倍速) // @namespace local.yg.chaoxing.chapter // @version 2.2.0 // @description 自动识别章节测验题目(含多层 iframe 与字体加密反爬),按可设正确率作答并提交,提交后自动跳转下一章;可自动点开并倍速播放视频任务点;接口地址/模型/API Key 均可在面板自填,支持任意 OpenAI 兼容服务。 // @author YG // @license MIT // @match *://*.chaoxing.com/* // @match *://*.chaoxing.cn/* // @match *://*.edu.cn/* // @match *://*.mooc1.com/* // @match *://*.mooc2.com/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @connect * // @run-at document-idle // @noframes false // 发布提示:托管到 GreasyFork 时它会自动注入 @downloadURL / @updateURL,无需手写。 // 若改用 GitHub raw 直装,把下面两行的注释去掉,并把 USER/REPO 换成你自己的仓库: // @homepageURL https://github.com/USER/REPO // @supportURL https://github.com/USER/REPO/issues // ==/UserScript== (function () { 'use strict' /* ============================================================ * 0. 常量 / 默认配置 * ============================================================ */ const TAG = '[CX2]' const VER = '2.2.0' const KEY_CFG = 'cx2.cfg.v1' const KEY_BANK = 'cx2.bank.v1' const KEY_STATE = 'cx2.state.v1' const CMD_KEY = 'cx2.cmd' const DEFAULT_CFG = { // —— 接口(开源版默认留空,首次使用请在面板「接口」里填自己的)—— endpoint: '', model: '', apiKey: '', accuracy: 100, // 目标正确率 % autoSubmit: true, // 自动点提交 autoNext: true, // 提交后自动跳下一章 aiEnabled: true, // 题库未命中时用 AI batchSize: 6, // 每次 AI 请求打包的题数 stepDelay: 260, // 每题操作间隔 ms submitDelay: 800, // 点提交前的停顿 nextDelay: 2200, // 跳章前停顿 openTask: true, // 章节内自动点开「测验」任务点 autoVideo: true, // 自动点开视频任务点并播放 videoRate: 2, // 视频倍速 videoMute: true, // 静音播放(绕过浏览器自动播放限制) account: '', // 自动登录用(可选) password: '', autoLogin: false, } /* 常用 OpenAI 兼容接口预设,面板里一键填 */ const API_PRESETS = [ { name: '千问兼容模式', endpoint: 'https://maas.qianwenaiapi.com/compatible-mode/v1/chat/completions', model: 'qwen3.8-flash' }, { name: 'DeepSeek', endpoint: 'https://api.deepseek.com/chat/completions', model: 'deepseek-chat' }, { name: '硅基流动', endpoint: 'https://api.siliconflow.cn/v1/chat/completions', model: 'Qwen/Qwen2.5-7B-Instruct' }, { name: 'OpenAI', endpoint: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o-mini' }, { name: '月之暗面', endpoint: 'https://api.moonshot.cn/v1/chat/completions', model: 'moonshot-v1-8k' }, ] /* 用户可能只填 https://api.deepseek.com 或带尾斜杠,这里补全成可用的 completions 地址 */ function normEndpoint(u) { let s = String(u || '').trim() if (!s) return '' s = s.replace(/\s+/g, '') if (!/^https?:\/\//i.test(s)) s = 'https://' + s s = s.replace(/\/+$/, '') if (/\/chat\/completions$/i.test(s) || /\/completions$/i.test(s)) return s if (/\/v\d+$/i.test(s)) return s + '/chat/completions' return s + '/v1/chat/completions' } const IS_TOP = (() => { try { return window.top === window.self } catch (e) { return false } })() /* ============================================================ * 1. 存储(GM_* 优先,退回 localStorage) * ============================================================ */ const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function' const store = { getRaw(k) { if (hasGM) { try { const v = GM_getValue(k, null) if (v != null) return typeof v === 'string' ? v : JSON.stringify(v) } catch (e) {} } try { return localStorage.getItem(k) } catch (e) { return null } }, setRaw(k, v) { if (hasGM) { try { GM_setValue(k, v) } catch (e) {} } try { localStorage.setItem(k, v) } catch (e) {} }, get(k, dflt) { const raw = this.getRaw(k) if (!raw) return dflt try { return Object.assign({}, dflt, JSON.parse(raw)) } catch (e) { return dflt } }, set(k, obj) { this.setRaw(k, JSON.stringify(obj)) }, } let cfg = Object.assign({}, DEFAULT_CFG, store.get(KEY_CFG, DEFAULT_CFG)) let bank = store.get(KEY_BANK, {}) let state = store.get(KEY_STATE, { running: false, lastChapter: '', doneList: [], updatedAt: 0 }) const saveCfg = () => store.set(KEY_CFG, cfg) const saveBank = () => store.set(KEY_BANK, bank) const saveState = () => { state.updatedAt = Date.now() store.set(KEY_STATE, state) } /* 学习通的 iframe 多为跨子域(mooc2-ans / detect / i.mooc…),contentDocument 拿不到, 只能靠每个框架里各自注入的实例自救。GM 存储是「按脚本全局」的,跨域共享,用它当总线。 */ const KEY_FRAME = 'cx2.frames' let lastSync = 0 function refreshShared(force) { const now = Date.now() if (!force && now - lastSync < 1000) return lastSync = now try { const st = store.get(KEY_STATE, null) if (st) { if (typeof st.running === 'boolean') state.running = st.running if (Array.isArray(st.doneKeys)) state.doneKeys = st.doneKeys } } catch (e) {} } const KEY_LOCK = 'cx2.lock' const INSTANCE = Math.random().toString(36).slice(2, 10) const LOCK_TTL = 90000 function lockHolder() { try { const j = JSON.parse(store.getRaw(KEY_LOCK) || '{}') return j && typeof j === 'object' ? j : {} } catch (e) { return {} } } /* 同一批题可能被「祖先框架」和「题目所在框架」同时看到(同源时尤其如此), 用 GM 全局锁互斥,避免两边一起点选项、重复提交。 */ function acquireLock() { const now = Date.now() const cur = lockHolder() if (cur.owner && cur.owner !== INSTANCE && now - (cur.at || 0) < LOCK_TTL) return false store.setRaw(KEY_LOCK, JSON.stringify({ owner: INSTANCE, at: now })) return lockHolder().owner === INSTANCE } function touchLock() { if (lockHolder().owner !== INSTANCE) return store.setRaw(KEY_LOCK, JSON.stringify({ owner: INSTANCE, at: Date.now() })) } function releaseLock() { if (lockHolder().owner === INSTANCE) store.setRaw(KEY_LOCK, JSON.stringify({ owner: '', at: 0 })) } function publishSelf(extra) { try { let reg = {} try { reg = JSON.parse(store.getRaw(KEY_FRAME) || '{}') || {} } catch (e) { reg = {} } let id = location.href.slice(0, 120) try { id = location.host + location.pathname.slice(0, 60) } catch (e) {} const base = { url: location.href.slice(0, 220), at: Date.now(), top: IS_TOP, ver: VER, fs: (() => { try { return window.frames.length } catch (e) { return -1 } })(), ifr: document.querySelectorAll('iframe').length, q: (() => { try { return scanQuestions(document).length } catch (e) { return -1 } })(), } reg[id] = Object.assign(base, extra || {}) const keys = Object.keys(reg) .sort((a, b) => (reg[b].at || 0) - (reg[a].at || 0)) .slice(0, 24) const out = {} for (const k of keys) out[k] = reg[k] store.setRaw(KEY_FRAME, JSON.stringify(out)) } catch (e) {} } /* ============================================================ * 2. 工具 * ============================================================ */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const LOG_MAX = 400 const logs = [] function log(msg, kind) { const line = `${new Date().toLocaleTimeString()} ${msg}` logs.push({ line, kind: kind || 'info' }) if (logs.length > LOG_MAX) logs.shift() try { if (IS_TOP && panel.logBox) { const d = document.createElement('div') d.className = 'cx2-line cx2-' + (kind || 'info') d.textContent = line panel.logBox.appendChild(d) while (panel.logBox.childNodes.length > LOG_MAX) panel.logBox.removeChild(panel.logBox.firstChild) panel.logBox.scrollTop = panel.logBox.scrollHeight } console.log(TAG, line) } catch (e) {} } function txt(el) { if (!el) return '' return (el.innerText || el.textContent || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim() } function norm(s) { return (s || '') .replace(/^\s*\d+[\s.、.))]*/, '') .replace(/【[^】]{0,12}】/g, '') .replace(/(\s*\d+\s*分\s*)/g, '') .replace(/\(\s*\d+\s*分\s*\)/g, '') .replace(/[\s\u3000]+/g, '') .replace(/[,。;:、,.;:!?!?"'“”‘’()()《》〈〉\-—_]/g, '') .toLowerCase() .slice(0, 160) } function pickLetterFromText(s) { const m = /^\s*([A-Za-z])\s*[.、.))::]/.exec(s || '') return m ? m[1].toUpperCase() : '' } function winOf(el) { try { return el.ownerDocument.defaultView || window } catch (e) { return window } } function fireInput(el) { if (!el) return try { const setter = Object.getOwnPropertyDescriptor(el.constructor.prototype, 'value')?.set if (setter) setter.call(el, el.value) } catch (e) {} for (const t of ['input', 'change', 'keyup', 'blur']) { try { el.dispatchEvent(new Event(t, { bubbles: true })) } catch (e) {} } } function realClick(el) { if (!el) return false const w = winOf(el) const r = el.getBoundingClientRect ? el.getBoundingClientRect() : { left: 0, top: 0, width: 0, height: 0 } const opts = { bubbles: true, cancelable: true, view: w, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2 } try { el.dispatchEvent(new w.MouseEvent('mousedown', opts)) el.dispatchEvent(new w.MouseEvent('mouseup', opts)) el.dispatchEvent(new w.MouseEvent('click', opts)) return true } catch (e) { try { el.click() return true } catch (e2) { return false } } } /* ============================================================ * 3. 框架遍历(学习通把题目埋在多層 iframe 里,必须钻进去) * * 注意:脚本猫沙箱里 `window.frames[i].document` 往往拿不到子框架文档 * (诊断实测 window.frames.length = 7,但遍历只拿到 1 层), * 而 DOM 侧的 `iframe.contentDocument` 是好的。所以走 DOM。 * ============================================================ */ function collectDocs(rootDoc, maxDepth) { const out = [] const seen = new Set() const limit = maxDepth || 4 function walk(doc, depth) { if (!doc || seen.has(doc) || depth > limit) return seen.add(doc) out.push(doc) let list = [] try { list = Array.from(doc.querySelectorAll('iframe, frame')) } catch (e) { return } for (const f of list) { let cd = null try { cd = f.contentDocument } catch (e) { cd = null } if (!cd) { try { cd = f.contentWindow && f.contentWindow.document } catch (e) { cd = null } } if (cd) walk(cd, depth + 1) } } walk(rootDoc || document, 0) return out } function sameOriginWindows(rootWin) { let root = document try { if (rootWin && rootWin.document) root = rootWin.document } catch (e) {} const wins = [] const seen = new Set() for (const d of collectDocs(root)) { let w = null try { w = d.defaultView } catch (e) { w = null } if (w && !seen.has(w)) { seen.add(w) wins.push(w) } } if (!wins.length) wins.push(window) return wins } /* ============================================================ * 4. 题目识别(接受任意 document) * ============================================================ */ const TYPE_MARK = [ [/多\s*选/, 'multi'], [/单\s*选/, 'single'], [/判\s*断/, 'judge'], [/填\s*空/, 'fill'], [/简\s*答|论\s*述|名\s*词解释|计\s*算\s*题|案\s*例/, 'text'], ] const Q_SELECTORS = [ '.questionLi', '.TiMu', '.Py_TiMu', '[class*="questionLi"]', '.timu', '.ans-videoquiz', '.subject_describe', '.mark_item', ].join(',') const OPT_SELECTORS = [ '.answerBg', '.Zy_ulTop > li', '.Zy_ulTop li', 'ul.Zy_ulTop > li', '.optionItem', '.ans-option', '.option', 'li.option', '.stem_answer', ] function detectType(container, stem, opts, hasTextInput) { const probes = [ txt(container.querySelector('.mark_name, .Zy_TItle, .questionName, .stem, .fl.after')), txt(container), container.getAttribute('data-type') || '', container.className || '', stem, ].join(' ') for (const [re, t] of TYPE_MARK) if (re.test(probes)) return t if (hasTextInput) return 'fill' const kindAttr = container.getAttribute('qtype') || container.getAttribute('data-qtype') || '' if (/3|multi|checkbox/i.test(kindAttr)) return 'multi' if (/1|single|radio/i.test(kindAttr)) return 'single' const hasCheckbox = !!container.querySelector('input[type=checkbox]') const hasRadio = !!container.querySelector('input[type=radio]') if (hasCheckbox && !hasRadio) return 'multi' if (hasRadio) return 'single' if (opts.length === 2) { const t = opts.map((o) => o.text.replace(/[。.\s]/g, '')) if (t.every((x) => /^(对|正确|是|√|T|True)$/i.test(x) || /^(错|错误|否|×|F|False)$/i.test(x))) return 'judge' } return opts.length > 1 ? 'single' : 'fill' } function parseOptions(container) { let nodes = [] for (const sel of OPT_SELECTORS) { nodes = Array.from(container.querySelectorAll(sel)) nodes = nodes.filter((n) => !nodes.some((m) => m !== n && m.contains(n))) if (nodes.length >= 2) break } if (!nodes.length) { nodes = Array.from(container.querySelectorAll('label')).filter((l) => l.querySelector('input[type=radio],input[type=checkbox]')) } const opts = [] nodes.forEach((node, i) => { let letter = '' const idxEl = node.querySelector('.num_option, .optionIndex, .num, .index, .option-letter') if (idxEl) letter = pickLetterFromText(txt(idxEl)) || (txt(idxEl).match(/[A-Za-z]/) || [''])[0].toUpperCase() const textEl = node.querySelector('.answer_p, .optionText, .option-text, .ans_p, .text') let text = textEl ? txt(textEl) : txt(node) if (!letter) letter = pickLetterFromText(text) if (letter && text) text = text.replace(new RegExp('^\\s*' + letter + '\\s*[.、.))::]?\\s*'), '') if (!letter) letter = String.fromCharCode(65 + i) const input = node.querySelector('input[type=radio], input[type=checkbox]') opts.push({ letter: letter.toUpperCase(), text: text || txt(node), node, input }) }) return opts } function parseContainer(container) { const stemEl = container.querySelector('.mark_name, .Zy_TItle, .questionName, .stem, .fl.after, .subject_describe .clearfix') let stem = txt(stemEl) || txt(container.querySelector('.clearfix')) || '' const opts = parseOptions(container) const textInputs = Array.from( container.querySelectorAll('input[type=text], input:not([type]), textarea, .input-answer input'), ).filter((el) => { if (el.closest('.answerBg, .Zy_ulTop, li')) return false const t = el.getAttribute('type') return !t || t === 'text' }) const type = detectType(container, stem, opts, textInputs.length > 0) if (!stem) stem = txt(container).slice(0, 200) stem = stem.replace(/^\s*\d+[\s.、.))]*/, '').trim() return { el: container, stem, type, options: opts, textInputs } } function scanQuestions(doc) { const d = doc || document if (!d || !d.querySelectorAll) return [] let containers = Array.from(d.querySelectorAll(Q_SELECTORS)) containers = containers.filter((c) => !containers.some((o) => o !== c && o.contains(c))) containers = containers.filter((c) => c.offsetParent !== null || c.getClientRects().length > 0 || d.visibilityState === 'hidden') const list = containers.map(parseContainer).filter((q) => q.stem || q.options.length) list.forEach((q, i) => (q.idx = i + 1)) return list } /* ============================================================ * 4.5 字体反爬解码 * * 学习通把题干/选项里的一部分汉字换成「乱码码位」(每页随机,如 咈/媋), * 再用页面内嵌的私有字体(font-cxsecret,base64 TTF)渲染成正确字形。 * 所以: * 1) 读内嵌字体的 cmap → 得到「被替换过的码位集合」; * 2) 用 canvas 把这几个码位按该字体画出来 → 得到真字形; * 3) 和常用汉字(U+4E00..U+9FA5)用宋体画出来逐一比对 → 反解真字。 * 这样 AI 拿到的就是正常题目,题库 key 也才稳定可复用。 * ============================================================ */ const FONT_MAP_KEY = 'cx2.fontmap.v1' const GLYPH_S = 32 const GLYPH_N = 20 const REF_FONTS = [ { id: 'hei', spec: '"SimHei","黑体","Heiti SC",sans-serif' }, { id: 'yh', spec: '"Microsoft YaHei","微软雅黑","PingFang SC",sans-serif' }, { id: 'sun', spec: '"SimSun","宋体","Songti SC",serif' }, ] let refState = { chars: null, fonts: {} } let lastDecode = null function djb2(s) { let h = 5381 for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) & 0x7fffffff return h.toString(36) } function allFontFaceCss(doc) { const out = [] try { for (const st of Array.from(doc.styleSheets || [])) { let rules = [] try { rules = Array.from(st.cssRules || []) } catch (e) { continue } for (const r of rules) { const t = String(r.cssText || '') if (/@font-face/i.test(t)) out.push(t) } } } catch (e) {} try { for (const s of Array.from(doc.querySelectorAll('style'))) { const t = String(s.textContent || '') if (/@font-face/i.test(t)) out.push(t) } } catch (e) {} return out } function bytesFromFontSrc(src) { const m = /^data:([^,]*),(.*)$/s.exec(String(src || '').trim()) if (!m) return null try { if (/base64/i.test(m[1])) { const bin = atob(m[2].replace(/\\s/g, '')) const b = new Uint8Array(bin.length) for (let i = 0; i < bin.length; i++) b[i] = bin.charCodeAt(i) return b } const txt = decodeURIComponent(m[2]) const b = new Uint8Array(txt.length) for (let i = 0; i < txt.length; i++) b[i] = txt.charCodeAt(i) & 0xff return b } catch (e) { return null } } /* 解析 TTF/OTF 的 cmap,返回「确实映射到字形」的码位集合 */ function cmapCodepoints(b) { const out = new Set() try { const dv = new DataView(b.buffer, b.byteOffset, b.byteLength) const tagAt = (o) => String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]) if (dv.getUint32(0) !== 0x00010000 && tagAt(0) !== 'OTTO' && tagAt(0) !== 'true') return out const numTables = dv.getUint16(4) let cmapOff = 0 for (let i = 0; i < numTables; i++) { const rec = 12 + i * 16 if (rec + 16 > b.length) break if (tagAt(rec) === 'cmap') { cmapOff = dv.getUint32(rec + 8) break } } if (!cmapOff || cmapOff + 4 > b.length) return out const nSub = dv.getUint16(cmapOff + 2) for (let i = 0; i < nSub; i++) { const rec = cmapOff + 4 + i * 8 if (rec + 8 > b.length) break const sub = cmapOff + dv.getUint32(rec + 4) if (sub + 2 > b.length) continue const format = dv.getUint16(sub) if (format === 4) { const segX2 = dv.getUint16(sub + 6) const ends = sub + 14 const starts = ends + segX2 + 2 const deltas = starts + segX2 const ranges = deltas + segX2 if (ranges + segX2 > b.length) continue for (let s = 0; s < segX2; s += 2) { const end = dv.getUint16(ends + s) const start = dv.getUint16(starts + s) const delta = dv.getInt16(deltas + s) const ro = dv.getUint16(ranges + s) for (let c = start; c <= end && c < 0xffff; c++) { let gid = 0 if (ro === 0) { gid = (c + delta) & 0xffff } else { const gi = ranges + s + ro + (c - start) * 2 if (gi + 1 >= b.length) continue gid = dv.getUint16(gi) if (gid) gid = (gid + delta) & 0xffff } if (gid) out.add(c) } } } else if (format === 12) { if (sub + 16 > b.length) continue const groups = dv.getUint32(sub + 12) for (let g = 0; g < groups; g++) { const o = sub + 16 + g * 12 if (o + 12 > b.length) break const sc = dv.getUint32(o) const ec = dv.getUint32(o + 4) for (let c = sc; c <= ec && c < 0x110000; c++) out.add(c) } } } } catch (e) {} return out } function secretFontInfo(doc) { const d = doc || document const faces = allFontFaceCss(d) if (!faces.length) return null let family = '' try { if (d.querySelector('.font-cxsecret, [class*="font-cxsecret"]')) family = 'font-cxsecret' } catch (e) {} const parsed = [] for (const css of faces) { const fam = (/font-family\s*:\s*([^;{}]+)/i.exec(css) || [])[1] if (!fam) continue const name = fam.replace(/["']/g, '').split(',')[0].trim() if (!name || /^(inherit|serif|sans-serif|arial|monospace|system-ui)$/i.test(name)) continue const src = (/url\((["']?)([^)"']+)\1\)/i.exec(css) || [])[2] || '' parsed.push({ name, src, css }) } if (!parsed.length) return null let pick = family ? parsed.find((p) => p.name === family) : null if (!pick) pick = parsed.find((p) => /data:.*base64/i.test(p.src)) || parsed[0] const bytes = pick.src ? bytesFromFontSrc(pick.src) : null const subset = bytes ? cmapCodepoints(bytes) : new Set() return { family: pick.name, isData: /^data:/i.test(pick.src), src: pick.src, subset, hash: djb2(pick.src ? pick.src.slice(0, 200000) : pick.css), faces: parsed.map((p) => p.name), } } function glyphCtx(doc) { let cv = doc.__cx2cv if (!cv) { cv = doc.createElement('canvas') cv.width = GLYPH_S cv.height = GLYPH_S doc.__cx2cv = cv } return cv.getContext('2d', { willReadFrequently: true }) } function glyphMask(doc, ch, fontSpec) { const ctx = glyphCtx(doc) ctx.clearRect(0, 0, GLYPH_S, GLYPH_S) ctx.font = '29px ' + fontSpec ctx.textBaseline = 'alphabetic' ctx.fillStyle = '#000' ctx.fillText(ch, 1, 30) const img = ctx.getImageData(0, 0, GLYPH_S, GLYPH_S).data const mask = new Uint8Array(GLYPH_S * GLYPH_S) for (let i = 0, p = 3; i < mask.length; i++, p += 4) mask[i] = img[p] > 96 ? 1 : 0 return mask } /* 按字形墨迹外框归一化到 GLYPH_N×GLYPH_N: 不同字体字号/字面大小不同,直接比像素会输给字号;归一化后只比字形 */ function normMask(mask) { let minX = GLYPH_S let minY = GLYPH_S let maxX = -1 let maxY = -1 for (let y = 0; y < GLYPH_S; y++) { const row = y * GLYPH_S for (let x = 0; x < GLYPH_S; x++) { if (!mask[row + x]) continue if (x < minX) minX = x if (x > maxX) maxX = x if (y < minY) minY = y if (y > maxY) maxY = y } } const n = new Uint8Array(GLYPH_N * GLYPH_N) if (maxX < 0) return n const w = maxX - minX + 1 const h = maxY - minY + 1 for (let ty = 0; ty < GLYPH_N; ty++) { const sy = minY + Math.min(h - 1, Math.floor(((ty + 0.5) * h) / GLYPH_N)) const row = sy * GLYPH_S const orow = ty * GLYPH_N for (let tx = 0; tx < GLYPH_N; tx++) { const sx = minX + Math.min(w - 1, Math.floor(((tx + 0.5) * w) / GLYPH_N)) n[orow + tx] = mask[row + sx] } } return n } /* 5×5 密度签名,用于从两万个汉字里快速筛出候选 */ function sigOf(n) { const sig = new Uint8Array(25) const b = GLYPH_N / 5 for (let y = 0; y < GLYPH_N; y++) { const by = ((y / b) | 0) * 5 for (let x = 0; x < GLYPH_N; x++) { if (n[y * GLYPH_N + x]) sig[by + ((x / b) | 0)]++ } } return sig } function iouOf(a, b) { let inter = 0 let uni = 0 for (let i = 0; i < a.length; i++) { const x = a[i] const y = b[i] if (x & y) inter++ if (x | y) uni++ } return uni ? inter / uni : 0 } /* 参照表按字体懒构建:多数字黑体就能定,只有拿不准时才去建雅黑/宋体, 真机上从 13 秒降到 3~5 秒(越靠后越省) */ const REF_STOP = 0.72 function refChars() { if (refState.chars) return refState.chars const n = 0x9fa5 - 0x4e00 + 1 const chars = new Int32Array(n) for (let i = 0; i < n; i++) chars[i] = 0x4e00 + i refState.chars = chars refState.fonts = refState.fonts || {} return chars } function ensureRefFont(doc, id) { const chars = refChars() if (refState.fonts[id]) return refState.fonts[id].sigs const f = REF_FONTS.find((x) => x.id === id) const t0 = Date.now() const sigs = new Uint8Array(chars.length * 25) for (let i = 0; i < chars.length; i++) { sigs.set(sigOf(normMask(glyphMask(doc, String.fromCharCode(chars[i]), f.spec))), i * 25) } refState.fonts[id] = { sigs } log(`字体参照表:${f.id} ${chars.length} 字,${Date.now() - t0} ms`) return sigs } function buildRefSigs(doc) { refChars() for (const f of REF_FONTS) ensureRefFont(doc, f.id) return refState } function matchGlyph(doc, ch, family, refs) { const chars = refChars() const tN = normMask(glyphMask(doc, ch, '"' + family + '"')) const tSig = sigOf(tN) let best = null for (const f of REF_FONTS) { if (best && best.score >= REF_STOP) break // 已经够像,不再烧时间 const sigs = ensureRefFont(doc, f.id) const n = chars.length const top = [] for (let i = 0; i < n; i++) { const off = i * 25 let d = 0 for (let k = 0; k < 25; k++) d += Math.abs(sigs[off + k] - tSig[k]) if (top.length < 40) { top.push({ i, d }) if (top.length === 40) top.sort((a, b) => a.d - b.d) } else if (d < top[39].d) { top[39] = { i, d } top.sort((a, b) => a.d - b.d) } } for (const c of top) { const cand = String.fromCharCode(chars[c.i]) const cN = normMask(glyphMask(doc, cand, f.spec)) const score = iouOf(tN, cN) if (!best || score > best.score) best = { ch: cand, score, font: f.id } } } return best } async function decodeQuestions(doc, qs) { const d = doc || document const info = secretFontInfo(d) if (!info) { lastDecode = { ok: false, reason: 'no-font-face' } return lastDecode } if (!info.subset.size) { lastDecode = { ok: false, reason: 'cmap-empty', family: info.family, isData: info.isData } return lastDecode } try { await (d.fonts && d.fonts.load ? d.fonts.load('29px "' + info.family + '"') : Promise.resolve()) } catch (e) {} const all = store.get(FONT_MAP_KEY, {}) const cached = all[info.hash] || {} const map = Object.assign({}, cached) const need = new Set() const scan = (s) => { for (const ch of String(s || '')) { const cp = ch.codePointAt(0) if (info.subset.has(cp) && !map[ch]) need.add(ch) } } for (const q of qs) { scan(q.stem) for (const o of q.options) scan(o.text) } let decoded = 0 let scoreSum = 0 let scoreN = 0 const sample = [] if (need.size) { const t0 = Date.now() buildRefSigs(d) for (const ch of need) { const r = matchGlyph(d, ch, info.family) // 只对「确实被替换过的码位」做替换,所以取最像的字总比留着乱码强; // 阈值只用来挡住空字形造成的荒谬结果。 if (r && r.score >= 0.30) { map[ch] = r.ch decoded++ scoreSum += r.score scoreN++ } if (sample.length < 40) sample.push(ch + '→' + (r ? r.ch : '?') + '(' + (r ? r.score.toFixed(2) : '-') + ')') } log( `字体解码 ${decoded}/${need.size} 字(${Date.now() - t0} ms,均分 ${(scoreSum / Math.max(1, scoreN)).toFixed(2)})`, decoded ? 'ok' : 'warn', ) if (decoded) log('解码样本:' + sample.slice(0, 12).join(' ')) } const fix = (s) => Array.from(String(s || '')) .map((ch) => map[ch] || ch) .join('') for (const q of qs) { q.stem = fix(q.stem) for (const o of q.options) o.text = fix(o.text) } if (decoded) { all[info.hash] = map const keys = Object.keys(all) if (keys.length > 24) for (const k of keys.slice(0, keys.length - 24)) delete all[k] store.set(FONT_MAP_KEY, all) } lastDecode = { ok: true, family: info.family, isData: info.isData, subset: info.subset.size, need: need.size, decoded, avgScore: scoreN ? scoreSum / scoreN : 0, sample, keyCount: Object.keys(map).length, decodedStem: String((qs[0] && qs[0].stem) || '').slice(0, 120) } return lastDecode } /* ============================================================ * 5. 作答 * ============================================================ */ function isSelected(opt) { try { if (opt.input && opt.input.checked) return true const n = opt.node if (!n) return false const cn = typeof n.className === 'string' ? n.className : '' if (/active|checked|selected|curr/i.test(cn)) return true if (n.getAttribute('aria-checked') === 'true') return true if (n.querySelector && n.querySelector('input:checked')) return true const inner = n.querySelector && n.querySelector('.answerBg, .option, li') if (inner && inner !== n && /active|checked|selected/i.test(inner.className || '')) return true } catch (e) {} return false } function selectedLetters(q) { return q.options.filter(isSelected).map((o) => o.letter) } function clickOption(opt) { const tries = [ opt.node, opt.node && opt.node.querySelector('label'), opt.input, opt.node && opt.node.querySelector('.answer_p, .optionText'), ] for (const t of tries) { if (!t) continue realClick(t) if (isSelected(opt)) return true } return isSelected(opt) } async function applyAnswer(q, letters) { if (q.type === 'fill' || q.type === 'text') { const val = (letters || []).join('\n') if (!val || !q.textInputs.length) return false for (const el of q.textInputs) { el.focus() el.value = val fireInput(el) await sleep(60) } return q.textInputs.some((el) => (el.value || '').trim()) } const want = new Set(letters) for (const o of q.options) { if (!want.has(o.letter) && isSelected(o)) { clickOption(o) await sleep(60) } } for (const o of q.options) { if (!want.has(o.letter)) continue if (isSelected(o)) continue clickOption(o) await sleep(90) } const same = (arr) => arr.length === want.size && arr.every((l) => want.has(l)) if (same(selectedLetters(q))) return true await sleep(220) return same(selectedLetters(q)) } /* ============================================================ * 6. AI 出答案 * ============================================================ */ function httpJson({ url, headers, body, timeout }) { const budget = timeout || 45000 const viaGM = () => new Promise((resolve, reject) => { let done = false const timer = setTimeout(() => { if (!done) { done = true reject(new Error('GM 请求超时')) } }, budget) try { GM_xmlhttpRequest({ method: 'POST', url, headers, data: body, timeout: budget, onload: (r) => { if (done) return done = true clearTimeout(timer) resolve({ status: r.status, text: r.responseText }) }, onerror: (e) => { if (done) return done = true clearTimeout(timer) reject(new Error('GM 请求失败: ' + (e && e.error))) }, ontimeout: () => { if (done) return done = true clearTimeout(timer) reject(new Error('GM 请求超时')) }, }) } catch (e) { done = true clearTimeout(timer) reject(e) } }) const viaFetch = async () => { const ac = new AbortController() const timer = setTimeout(() => ac.abort(), budget) try { const r = await fetch(url, { method: 'POST', headers, body, signal: ac.signal }) return { status: r.status, text: await r.text() } } finally { clearTimeout(timer) } } return (async () => { if (typeof GM_xmlhttpRequest === 'function') { try { return await viaGM() } catch (e) { // GM 通道失败(@connect 拦截、扩展异常等)→ 退回 fetch } } return await viaFetch() })() } function extractJsonArray(s) { if (!s) return null const fenced = s.match(/```(?:json)?\s*([\s\S]*?)```/i) const body = fenced ? fenced[1] : s const start = body.indexOf('[') const end = body.lastIndexOf(']') if (start >= 0 && end > start) { try { return JSON.parse(body.slice(start, end + 1)) } catch (e) {} } const os = body.indexOf('{') const oe = body.lastIndexOf('}') if (os >= 0 && oe > os) { try { const o = JSON.parse(body.slice(os, oe + 1)) if (Array.isArray(o)) return o if (o && typeof o === 'object') { for (const k of ['answers', 'data', 'result', 'items']) if (Array.isArray(o[k])) return o[k] return [o] } } catch (e) {} } return null } function buildPrompt(batch) { const items = batch.map((q) => ({ i: q.idx, t: q.type, q: q.stem, o: q.options.map((o) => `${o.letter}. ${o.text}`), })) return [ { role: 'system', content: '你是中国高校课程在线测验答题引擎。只输出 JSON 数组,不要任何解释、不要 markdown。' + '每项格式 {"i":题号,"a":["字母"]}。单选与判断只给 1 个字母;多选给全部正确字母(按字母序);填空题给 1 个字符串数组表示各空答案。', }, { role: 'user', content: '作答下列题目。判断题 A=对 B=错。选项字母必须来自题目给出的选项。\n' + '注意:题面是从网页里提取的,其中个别汉字被网站做了形近替换(反爬),' + '下面给出的文本已经尽力还原,但仍可能残留个别形近字(例如「列/例」「特/恃」「竞/竟」「质/盾」)。' + '请先按语义把题面在脑子里纠正通顺,再按纠正后的题意作答,不要因为个别字怪就乱选。\n' + JSON.stringify(items), }, ] } async function askAI(batch) { const url = normEndpoint(cfg.endpoint) if (!url) throw new Error('还没填接口地址:面板「接口」里选个预设或粘贴自己的 OpenAI 兼容地址') if (!cfg.apiKey) throw new Error('还没填 API Key:面板「接口」里填自己的 Key(只存在本机)') if (!cfg.model) throw new Error('还没填模型名:面板「接口」里填,例如 deepseek-chat') const headers = { 'content-type': 'application/json', authorization: 'Bearer ' + cfg.apiKey } const body = JSON.stringify({ model: cfg.model, messages: buildPrompt(batch), temperature: 0, max_tokens: 900, stream: false, response_format: { type: 'json_object' }, }) let r try { r = await httpJson({ url, headers, body }) } catch (e) { const body2 = JSON.stringify({ model: cfg.model, messages: buildPrompt(batch), temperature: 0, max_tokens: 900 }) r = await httpJson({ url, headers, body: body2 }) } // 有些兼容层不认 response_format,去掉再试一次 if (r && r.status >= 400 && /response_format|json_object|unsupported|invalid.*parameter/i.test(String(r.text))) { const body3 = JSON.stringify({ model: cfg.model, messages: buildPrompt(batch), temperature: 0, max_tokens: 900 }) r = await httpJson({ url, headers, body: body3 }) } if (!r || r.status < 200 || r.status >= 300) throw new Error(`AI HTTP ${r && r.status}: ${String(r && r.text).slice(0, 180)}`) let data try { data = JSON.parse(r.text) } catch (e) { throw new Error('AI 响应不是 JSON') } const content = data?.choices?.[0]?.message?.content || '' const arr = extractJsonArray(content) if (!arr) throw new Error('AI 未返回可解析的答案数组: ' + String(content).slice(0, 120)) const map = new Map() for (const it of arr) { const i = Number(it.i ?? it.index ?? it.id) let a = it.a ?? it.answer ?? it.answers if (typeof a === 'string') a = a.match(/[A-Za-z]/g) || [a] if (Array.isArray(a)) a = a.map((x) => String(x).trim()).filter(Boolean) if (Number.isFinite(i) && a && a.length) map.set(i, a) } return map } /* ============================================================ * 7. 正确率控制 * ============================================================ */ function degradeAnswer(q, letters) { const opts = q.options if (!opts.length) return letters const right = letters.slice() if (q.type === 'judge' || q.type === 'single') { const others = opts.map((o) => o.letter).filter((l) => !right.includes(l)) if (!others.length) return right return [others[Math.floor(Math.random() * others.length)]] } if (q.type === 'multi') { if (right.length >= 2) return right.slice(0, right.length - 1) const others = opts.map((o) => o.letter).filter((l) => !right.includes(l)) return others.length ? [others[0]] : right } return right } function applyAccuracy(q, letters, forceWrong) { if (!letters || !letters.length) return letters const acc = Math.max(0, Math.min(100, Number(cfg.accuracy) || 0)) if (acc >= 100) return letters if (q.type === 'fill' || q.type === 'text') return letters // 填空/简答不主动搞错 if (!forceWrong) return letters const wrong = degradeAnswer(q, letters) log(`题${q.idx} 按 ${acc}% 正确率策略故意答错(模拟真人)`, 'warn') return wrong } /* ============================================================ * 8. 题库 * ============================================================ */ function bankKey(q) { return norm(q.stem) + '|' + q.type } function bankLookup(q) { const k = bankKey(q) if (bank[k]) return bank[k].letters const base = norm(q.stem).slice(0, 60) if (base.length < 8) return null for (const kk of Object.keys(bank)) { const nk = kk.split('|')[0] if (nk.slice(0, 60) === base) return bank[kk].letters } return null } function bankSave(q, letters) { if (!q.stem || !letters || !letters.length) return bank[bankKey(q)] = { stem: q.stem.slice(0, 300), type: q.type, letters: letters.slice(), options: q.options.map((o) => `${o.letter}.${o.text}`.slice(0, 160)), ts: Date.now(), } saveBank() } /* ============================================================ * 9. 提交(指定 document) * ============================================================ */ /* 提交按钮候选:作业页(doHomeWorkNew)的按钮形态五花八门, 与其猜一个,不如按分数排序逐个试,直到页面真的变了。 */ function submitCandidates(doc) { const d = doc || document const sels = [ '.completeBtn', '#submitBtn', '#submit', '.btnSubmit', '.workSubmit', '.tj_btn', 'a[onclick*="submit"]', 'a[onclick*="Submit"]', '[class*="complete"]', '[class*="submit"]', 'button', 'input[type=submit]', 'input[type=button]', 'a', 'div[role=button]', ] const out = [] const seen = new Set() for (const sel of sels) { let list = [] try { list = Array.from(d.querySelectorAll(sel)) } catch (e) { continue } for (const el of list) { if (seen.has(el)) continue seen.add(el) const t = (txt(el) || el.value || '').replace(/\s/g, '') if (!t || t.length > 20) continue if (!/提交|交卷|交作业|完成测验|立即提交/.test(t)) continue if (/保存|草稿|暂存/.test(t) && !/提交/.test(t)) continue const visible = el.offsetParent !== null || el.getClientRects().length if (!visible) continue if (el.disabled || /disabled/i.test(String(el.className || ''))) continue const cls = String(el.className || '') + ' ' + String(el.id || '') let score = 1 if (/^提交(答案|作业|试卷|试题)?$|^交卷$/.test(t)) score += 5 if (/completeBtn|submitBtn|btnSubmit|workSubmit/i.test(cls)) score += 4 if (/submit/i.test(String(el.getAttribute('onclick') || ''))) score += 3 let r = { top: 0, width: 0 } try { r = el.getBoundingClientRect() } catch (e) {} if (r.width > 0) score += 1 score += Math.min(2, Math.max(0, r.top || 0) / 2000) // 越靠下越像底部提交 out.push({ el, score, text: t }) } } out.sort((a, b) => b.score - a.score) return out } function findSubmitButton(doc) { const c = submitCandidates(doc) return c.length ? c[0].el : null } function findDialogConfirm(doc) { const d = doc || document const sels = [ '.layui-layer-btn0', '.layui-layer-btn a', '#popok', '.popBottom .jb_btn', '.conform', '.wrap .btn-ok', '.dialog .confirm', '#confirmBtn', '.popSetDiv .jb_btn', '.bluebtn', '.maskDiv a', ] for (const sel of sels) { let els = [] try { els = Array.from(d.querySelectorAll(sel)) } catch (e) { continue } for (const e of els) { if (!(e.offsetParent !== null || e.getClientRects().length)) continue if (/确定|确认|是|提交|继续|知道了/.test(txt(e) || '')) return e } } return null } /* 兜底:任何浮层里的「确定 / 确认 / 提交」都点一下。 学习通各模块的弹窗类名不统一,靠类名猜不可靠,靠位置猜很可靠。 */ function findAnyConfirm(doc) { const d = doc || document let els = [] try { els = Array.from(d.querySelectorAll('a, button, span, div, input[type=button]')) } catch (e) { return null } for (const el of els) { const t = (txt(el) || el.value || '').replace(/\s/g, '') if (!/^(确定|确认|是|提交|提交答案|继续|知道了|好的|同意)$/.test(t)) continue let r try { r = el.getBoundingClientRect() } catch (e) { continue } if (!(r.width > 0 && r.height > 0)) continue let p = el let isFloat = false try { for (let i = 0; i < 6 && p; i++) { const st = d.defaultView.getComputedStyle(p) if (/fixed|absolute/.test(st.position)) { const zi = Number(st.zIndex || 0) if (zi > 0 || /fixed/.test(st.position)) { isFloat = true break } } p = p.parentElement } } catch (e) {} if (isFloat) return el } return null } /* 交卷是否真的完成了 */ async function submitLooksDone(d) { if (!submitCandidates(d).length) return true if (pageLooksFinished(d)) return true try { const t = txt(d.body) if (/提交成功|交卷成功|已交卷|本次得分|测评结果|查看解析/.test(t)) return true } catch (e) {} return false } function safeCall(w, name, args) { try { return typeof w[name] === 'function' ? String(w[name].apply(w, args || [])) : '' } catch (e) { return '' } } function postJson($, url, data) { return new Promise((resolve) => { try { $.ajax({ type: 'POST', url, data, dataType: 'json', timeout: 20000, success: (r) => resolve({ ok: true, r }), error: (xhr, st) => resolve({ ok: false, why: st + ' ' + (xhr && xhr.status) }), }) } catch (e) { resolve({ ok: false, why: String(e && e.message) }) } }) } /* 照页面自己的 saveWork() 抄一份,但 saveStatus=0(正式提交)而不是 tempsave=1(暂存)。 走 $.ajax 而不是 fetch,为的是原封不动带上页面的 header 与 cookie 语义。 */ async function directSubmitAjax(d, w) { const $ = w.jQuery || w.$ if (!$ || !$.ajax) return { ok: false, why: '页面没有 jQuery' } const g = (sel) => { const el = d.querySelector(sel) return el && el.value != null ? String(el.value) : '' } const classId = g('#classId') const courseId = g('#courseId') const token = g('#enc_work') const total = g('#totalQuestionNum') if (!classId || !courseId) return { ok: false, why: '拿不到 classId/courseId' } if (typeof w.updateAllFillinBlanksData === 'function') { try { w.updateAllFillinBlanksData() } catch (e) {} } let data = '' try { data = $('#form1').serialize() } catch (e) { return { ok: false, why: '#form1 serialize 失败' } } const host = w._HOST_CP2_ || '' const base = host + '/work/addStudentWorkNew?_classId=' + encodeURIComponent(classId) + '&courseid=' + encodeURIComponent(courseId) + '&token=' + encodeURIComponent(token) + '&totalQuestionNum=' + encodeURIComponent(total) + '&ua=' + encodeURIComponent(safeCall(w, 'clientType')) + '&formType=' + encodeURIComponent(safeCall(w, 'getformType')) log('直接发提交请求:addStudentWorkNew(saveStatus=0)') const r = await postJson($, base + '&saveStatus=0&version=1', data) if (!r.ok) return { ok: false, why: r.why } const res = r.r || {} log('提交接口返回:' + JSON.stringify(res).slice(0, 300)) if (res.status === true || res.status === 'true' || res.status === 1) { // 超星提交成功通常会跳转到成绩页 try { if (res.url) { log('按接口返回跳转:' + String(res.url).slice(0, 120)) w.location.href = res.url } } catch (e) {} return { ok: true, res } } return { ok: false, why: '接口 status 非成功:' + JSON.stringify(res).slice(0, 160) } } async function doSubmit(doc) { const d = doc || document let w = window try { w = d.defaultView || window } catch (e) {} // 两个框架实例指向同一个题目文档时,靠这里的文档级闸门保证只提交一次 // (GM 锁是跨实例的软锁,会有时间窗;这个标记挂在文档对象上,同文档必共享) const now = Date.now() if (d.__cx2SubmittingAt && now - d.__cx2SubmittingAt < 45000) { log('本题已在提交流程中,跳过重复提交', 'warn') return false } d.__cx2SubmittingAt = now const cands = submitCandidates(d) if (!cands.length) { log('未找到「提交」按钮(页面结构可能不同),请手动交一次', 'warn') return false } // 确认弹窗可能弹在任何一层同源框架里(题目 iframe / cards 页 / 顶层),逐个找 const clickConfirm = async () => { for (let round = 0; round < 2; round++) { let hit = null const docs = (() => { try { const list = collectDocs() return list.length ? list : [d] } catch (e) { return [d] } })() for (const dd of docs) { const el = (() => { try { return findDialogConfirm(dd) || findAnyConfirm(dd) } catch (e) { return null } })() if (el) { hit = { el, dd } break } } if (!hit) return log('确认弹窗:' + (txt(hit.el) || '确定')) realClick(hit.el) await sleep(1100) } } for (const c of cands.slice(0, 3)) { const tag = c.el.tagName.toLowerCase() const idc = c.el.id ? '#' + c.el.id : '' const clsc = c.el.className ? '.' + String(c.el.className).split(' ')[0] : '' log(`点击提交:${c.text}(${tag}${idc}${clsc})`) realClick(c.el) await sleep(900) await clickConfirm() await sleep(1200) if (await submitLooksDone(d)) { log('提交成功(按钮已消失 / 页面显示已提交)', 'ok') return true } const oc = String(c.el.getAttribute('onclick') || '') const fn = (/^\s*([A-Za-z_$][\w$]*)\s*\(/.exec(oc) || [])[1] if (!fn || typeof w[fn] !== 'function') continue // 学习通的 btnBlueSubmit 有次数守卫:reqLimit < 0 就静默 return(只弹一句“请刷新页面后重试”) try { if (/reqLimit/.test(String(w[fn]))) { w.reqLimit = 5 log('提交次数守卫 reqLimit 已重置为 5,再调用一次 ' + fn + '()') } w[fn]() await sleep(1400) await clickConfirm() await sleep(1400) if (await submitLooksDone(d)) { log('提交成功(页面函数 ' + fn + ' 生效)', 'ok') return true } } catch (e) { log('提交函数报错:' + (e && e.message), 'err') } // 还不行就绕过守卫:扒出页面自己写的 validateTimeNew(班级,课程,作业, cb) 参数直接调 try { const src = String(w[fn]) const call = (src.match(/validateTimeNew\s*\(([^)]*)\)/) || [])[1] if (call && typeof w.validateTimeNew === 'function') { const nums = call .split(',') .map((s) => s.trim()) .filter((s) => /^\d+$/.test(s)) if (nums.length >= 3) { log('绕过守卫:validateTimeNew(' + nums.slice(0, 3).join(',') + ') → toadd') // 给 toadd 套一层,看它到底有没有被调用 let fired = false if (typeof w.toadd === 'function' && !w.toadd.__cx2wrap) { const orig = w.toadd const wrapped = function () { fired = true log('toadd 被调用(参数 ' + Array.prototype.map.call(arguments, (a) => JSON.stringify(a).slice(0, 80)).join(', ') + ')') return orig.apply(this, arguments) } wrapped.__cx2wrap = true try { w.toadd = wrapped } catch (e) {} } const args = nums.slice(0, 3).map(Number) args.push(function () { try { if (typeof w.toadd === 'function') w.toadd(arguments) else log('页面没有 toadd 函数', 'warn') } catch (e) { log('toadd 出错:' + (e && e.message), 'err') } }) w.validateTimeNew.apply(w, args) await sleep(2000) await clickConfirm() await sleep(2000) if (!fired) log('validateTimeNew 的回调没被触发(多半是它的时间/状态校验没过)', 'warn') if (await submitLooksDone(d)) { log('提交成功(走 validateTimeNew → toadd)', 'ok') return true } } } } catch (e) { log('绕过提交失败:' + (e && e.message), 'warn') } } // 最后一层:不依赖页面 JS,照 saveWork() 抄一份请求,把 saveStatus 换成 0 直接提交 if (await submitLooksDone(d)) { log('提交成功(前面的方式已生效)', 'ok') return true } if (!d.__cx2directTried) { d.__cx2directTried = true const r = await directSubmitAjax(d, w) if (r.ok) { log('提交成功(直接调用 addStudentWorkNew 接口)', 'ok') return true } log('直接提交接口失败:' + r.why, 'warn') await clickConfirm() await sleep(1500) if (await submitLooksDone(d)) { log('提交成功(接口已回执,页面已变化)', 'ok') return true } } log('四种提交方式都没让页面变化。点「导出诊断」,我按页面真实逻辑补', 'warn') return false } function pageLooksFinished(doc) { const d = doc || document const t = txt(d.body) if (!/已完成|已提交|提交成功|成绩|得分|查看详情|请勿重复/.test(t)) return false // 关键:不能只看正文有没有「提交」二字——提交后页面照样有“提交成功/提交时间”。 // 判据是「提交按钮是否还在」。 return !findSubmitButton(d) } /* 已提交记录(跨框架共享,防止同一份测验被不同实例重复提交) */ function quizKeyFor(doc) { try { const u = new URL(doc.location.href) const cid = u.searchParams.get('chapterId') || u.searchParams.get('knowledgeid') || u.searchParams.get('workId') || u.searchParams.get('jobid') || '' const base = u.host + u.pathname if (base && base.length > 3) return (base + '|' + cid).slice(0, 200) } catch (e) {} try { const qs = scanQuestions(doc) if (qs.length) return norm(qs[0].stem).slice(0, 80) } catch (e) {} return '' } /* ============================================================ * 10. 视频:点开任务点 + 播放 + 倍速 * ============================================================ */ let videoStat = { lastLog: 0, lastRate: 0, playBtns: 0, videos: 0 } /* 播放器的「大播放按钮」各家不一,全都试一遍 */ const PLAY_BTN_SEL = [ '.vjs-big-play-button', '.vjs-play-control.vjs-paused', '.prism-big-play-btn', '.xgplayer-start', '.xgplayer-play[data-state="paused"]', '.pv-play', '.play-btn', '.video-play', '[class*="big-play"]', ].join(',') function videoDocs() { const out = [] try { for (const d of collectDocs()) out.push(d) } catch (e) { out.push(document) } return out } /* 把可达框架里的