// ==UserScript== // @name 学伴 · 网课答题助手 // @namespace qoda/study-helper // @version 0.9.3 // @description 自动识别页面试题并作答,题库优先、AI 兜底,适配 90+ 网课平台。仅用于课后练习与自测,请在遵守所在平台规则的前提下使用。 // @author qoda // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPGRlZnM+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9ImJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+CiAgICAgIDxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzNiODJmNiIvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMxZDRlZDgiLz4KICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9InBhZ2UiIHgxPSIwIiB5MT0iMCIgeDI9IjAiIHkyPSIxIj4KICAgICAgPHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZmZmZmZmIi8+CiAgICAgIDxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2U4ZjBmZiIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPHJlY3Qgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiIHJ4PSIzMCIgZmlsbD0idXJsKCNiZykiLz4KICA8IS0tIOaJk+W8gOeahOS5piAtLT4KICA8cGF0aCBkPSJNMjIgNDBjMTQtNiAzMC02IDQyIDIgMTItOCAyOC04IDQyLTJ2NTBjLTE0LTYtMzAtNi00MiAyLTEyLTgtMjgtOC00Mi0yVjQweiIgZmlsbD0idXJsKCNwYWdlKSIvPgogIDxwYXRoIGQ9Ik02NCA0MnY1MCIgc3Ryb2tlPSIjYmZkYmZlIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogIDwhLS0g5Lmm6aG15qiq57q/IC0tPgogIDxwYXRoIGQ9Ik0zMiA1NGM5LTMgMTgtMyAyNSAxTTMyIDY2YzktMyAxOC0zIDI1IDFNMzIgNzhjOS0zIDE4LTMgMjUgMSIgc3Ryb2tlPSIjOTNjNWZkIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgZmlsbD0ibm9uZSIvPgogIDxwYXRoIGQ9Ik05NiA1NGMtOS0zLTE4LTMtMjUgMU05NiA2NmMtOS0zLTE4LTMtMjUgMU05NiA3OGMtOS0zLTE4LTMtMjUgMSIgc3Ryb2tlPSIjOTNjNWZkIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgZmlsbD0ibm9uZSIvPgogIDwhLS0g5a+55Yu+5b695qCHIC0tPgogIDxjaXJjbGUgY3g9Ijk2IiBjeT0iOTIiIHI9IjIyIiBmaWxsPSIjZmZmIiBzdHJva2U9IiMxZDRlZDgiIHN0cm9rZS13aWR0aD0iMyIvPgogIDxwYXRoIGQ9Ik04NiA5Mmw3IDcgMTQtMTUiIHN0cm9rZT0iIzE2YTM0YSIgc3Ryb2tlLXdpZHRoPSI1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGZpbGw9Im5vbmUiLz4KPC9zdmc+Cg== // @compatible chrome firefox edge // @match https://new.oberyun.com/* // @match https://m.umu.cn/* // @match *://*.chaoxing.com/* // @match *://*.hlju.edu.cn/* // @match *://*.ecust.edu.cn/* // @match *://lms.ouchn.cn/* // @match *://*.ouchn.cn/* // @match *://xczxzdbf.moodle.qwbx.ouchn.cn/* // @match *://study.ouchn.cn/* // @match *://moodle.syxy.ouchn.cn/* // @match *://moodle.qwbx.ouchn.cn/* // @match *://*.tongyi.com/* // @match *://chat.deepseek.com/* // @match *://*.zhihuishu.com/* // @match *://course.ougd.cn/* // @match *://elearning.bjou.edu.cn/* // @match *://whkpc.hnqtyq.cn:5678/* // @match *://www.51xinwei.com/* // @match *://*.w-ling.cn/* // @match *://*.edu-edu.com/* // @match *://xuexi.jsou.cn/* // @match *://*.icve.com.cn/* // @match *://zice.cnzx.info/* // @match *://any.cnzx.info:81/* // @match *://www.icourse163.org/* // @match *://*.shou.org.cn/* // @match *://*.ahjxjy.cn/* // @match *://*.chinaedu.net/* // @match *://*.qingshuxuetang.com/* // @match *://cce.org.uooconline.com/* // @match *://*.courshare.cn/* // @match *://*.cep.webtrn.cn/* // @match *://*.webtrn.cn/* // @match *://*.cj-edu.com/* // @match *://*.ytccr.com/* // @match *://*.exam-cloud.cn/* // @match *://gdrtvu.exam-cloud.cn/* // @match *://*.open.ha.cn/* // @match *://lhycjy.cloudwis.tech/* // @match *://*.sclecb.cn/* // @match *://*.web2.superchutou.com/* // @match *://*.ls365.net/* // @match *://*.jijiaox.com/* // @match *://*.wencaischool.net/* // @match *://sdrz.gxk.yxlearning.com/* // @match *://*.crjxjy.net/* // @match *://*.cjnep.net/* // @match *://*.91huayi.com/* // @match *://teach.ynou.edu.cn/* // @match *://*.edu-edu.com.cn/* // @match *://*.21tb.com/* // @match *://*.168wangxiao.com/* // @match *://*.mynep.com/* // @match *://www.mosoteach.cn/* // @match *://*.aufe.edu.cn/* // @match *://*.learnin.com.cn/* // @match *://*.ouchn.edu.cn/* // @match *://*.swufe-online.com/* // @match *://ks.cqsdx.cn/* // @match *://*.qau.edu.cn/* // @match *://*.gdufemooc.cn/* // @match *://*.telfri-edu.com/* // @match *://www.beeline-ai.com/* // @match *://*.wxic.edu.cn/* // @match *://*.yxbyun.com/* // @match *://*.wdjycj.com/* // @match *://*.hbcjpt.com/* // @match *://*.csmu.edu.cn/* // @match *://*.ketangx.net/* // @match *://*.cnzx.info/* // @match *://www.asklib.com/* // @match *://*.xust.edu.cn/* // @match *://*.whut.edu.cn/* // @match *://*.lut.edu.cn/* // @match *://*.wwwwsoft.com/* // @match *://*.dufe.edu.cn/* // @match *://*.lygtc.edu.cn/* // @match *://*.cfyedu.com/* // @match *://*.iwdjy.com/* // @match *://*.jijiaool.com/* // @match *://lms.cjzx.hblll.com/* // @match *://*.ouc-online.com.cn/* // @match *://*.lsedu.vip/* // @match *://*.jxjypt.cn/* // @match *://*.jsnu.edu.cn/* // @match *://*.geovisearth.com/* // @match *://*.bgzk-edu.cn/* // @match *://*.cqie.cn/* // @match *://mooc1.ceduacad.ahut.edu.cn/* // @match http://localhost:8787/quiz.html* // @match http://127.0.0.1:8787/quiz.html* // @match *://*.xuetangx.com/* // @match *://*.rainclassroom.com/* // @match *://*.uooconline.com/* // @match *://*.icourse163.org/* // @match *://*.cnmooc.org/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @connect api.openedu.lovie.me // @connect localhost // @connect 127.0.0.1 // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; if (window.top !== window.self) return; if (window.__qodaBooted) return; if (location.pathname.endsWith('/landing.html')) return; window.__qodaBooted = true; const VERSION = '0.9.3'; const CFG_KEY = 'qoda_cfg_v1'; const ANON_KEY = 'qoda_anon_v1'; const DEVICE_KEY = 'qoda_device_v1'; const LICENSE_KEY = 'qoda_license_v1'; const ACCOUNT_KEY = 'qoda_account_v1'; const SCENE_KEY = 'qoda_scene_dismissed_v1'; const DEFAULTS = { serverBase: 'https://api.openedu.lovie.me', thesisUrl: 'https://zhipu.lovie.me/', mode: 'server', directKey: '', directBase: 'https://open.bigmodel.cn/api/paas/v4', directModel: 'glm-4-flash', hooks: true, preferBank: true, sharedBank: true, cacheAi: true, syncBank: false, payUrl: '', adapterUrl: '', useAdapter: false, useUpstream: false, upstreamUrl: '', coldStart: false, bankServer: 'http://127.0.0.1:8788', telemetry: true, contributeAi: true, // AI 答完静默回写共享题库(隐形默认上传,不设开关) autoAnswer: true, // 傻瓜式:进页面识别到题目就自动作答 autoAnswerDelay: 800, requestTimeout: 12000, concurrency: 3, }; const TYPE_LABEL = { single: '单选', multiple: '多选', judge: '判断', blank: '填空', subjective: '简答', }; const STATUS_TEXT = { todo: '未答', run: '答题中', done: '已答', fail: '失败', wait: '待生成', }; const state = { adapter: null, questions: [], running: false, exam: false, quota: null, lastSummary: null, summaryTracked: false, showSettings: false, bankCount: 0, sharedCount: 0, showSearch: false, searchQuery: '', searchResults: [], searching: false, searchTimer: 0, license: null, payUrl: '', plans: [], freeQuota: 20, quotaExceeded: false, account: null, accountToken: '', points: 0, pointsExhausted: false, aiCost: 3, accountUrl: '/account.html', welcomePoints: 20, dailyGrant: 5, host: null, shadow: null, panel: null, body: null, serverMode: '', stopRequested: false, coldStart: false, coldPicks: {}, coldTexts: {}, coldBackfilling: false, }; // ---------- storage / utils ---------- function gmGet(key, fallback) { try { if (typeof GM_getValue === 'function') { const value = GM_getValue(key); return value === undefined ? fallback : value; } const raw = localStorage.getItem(`qoda.${key}`); return raw === null ? fallback : JSON.parse(raw); } catch { return fallback; } } function gmSet(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; } localStorage.setItem(`qoda.${key}`, JSON.stringify(value)); } catch { // ignore storage failures } } function makeId() { try { if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID(); } catch { // fall through } return `anon-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; } const cfg = Object.assign({}, DEFAULTS, gmGet(CFG_KEY, {})); const ANON_ID = (() => { let id = gmGet(ANON_KEY, ''); if (!id) { id = makeId(); gmSet(ANON_KEY, id); } return id; })(); const DEVICE_ID = (() => { let id = gmGet(DEVICE_KEY, ''); if (!id) { id = makeId(); gmSet(DEVICE_KEY, id); } return id; })(); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const truncate = (value, max) => { const text = String(value || '').replace(/\s+/g, ' ').trim(); return text.length > max ? `${text.slice(0, max)}…` : text; }; const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[ch]); const textOf = (el) => (el ? String(el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim() : ''); const rawText = (el) => (el ? String(el.textContent || '').replace(/\s+/g, ' ').trim() : ''); function isVisible(el) { try { const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; } catch { return true; } } function all(root, selector) { try { return Array.from(root.querySelectorAll(selector)); } catch { return []; } } function firstMatch(root, selectors) { for (const selector of selectors) { try { const el = root.querySelector(selector); if (el && textOf(el)) return el; } catch { // skip bad selector } } return null; } function hashText(text) { let hash = 0; const str = String(text || ''); for (let i = 0; i < str.length; i += 1) hash = (hash * 31 + str.charCodeAt(i)) >>> 0; return hash; } function httpJson({ url, method = 'GET', headers = {}, data, timeout = cfg.requestTimeout || 12000 } = {}) { return new Promise((resolve, reject) => { const payload = data === undefined ? undefined : JSON.stringify(data); if (typeof GM_xmlhttpRequest === 'function') { GM_xmlhttpRequest({ method, url, headers: { 'Content-Type': 'application/json', ...headers }, data: payload, timeout, onload: (res) => { let parsed = {}; try { parsed = res.responseText ? JSON.parse(res.responseText) : {}; } catch { parsed = { raw: res.responseText }; } resolve({ status: res.status, data: parsed }); }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), }); return; } fetch(url, { method, headers: { 'Content-Type': 'application/json', ...headers }, body: payload, }) .then(async (res) => { let parsed = {}; try { parsed = await res.json(); } catch { parsed = {}; } resolve({ status: res.status, data: parsed }); }) .catch(reject); }); } function withTimeout(promise, ms, fallback) { return Promise.race([ Promise.resolve(promise).catch(() => fallback), new Promise((resolve) => { setTimeout(() => resolve(fallback), ms); }), ]); } function base() { return String(cfg.serverBase || '').replace(/\/+$/, ''); } function thesisUrl(content) { let target = cfg.thesisUrl || 'https://zhipu.lovie.me/'; if (!/^https?:/i.test(target)) target = `${base()}${target.startsWith('/') ? '' : '/'}${target}`; try { const url = new URL(target); url.searchParams.set('utm_source', 'study_helper'); url.searchParams.set('utm_medium', 'hook'); url.searchParams.set('utm_campaign', 'v01'); url.searchParams.set('utm_content', content); url.searchParams.set('uid', ANON_ID.slice(0, 8)); return url.toString(); } catch { return target; } } function track(event, meta = {}) { if (!cfg.telemetry) return; try { void httpJson({ url: `${base()}/api/event`, method: 'POST', data: { anonId: ANON_ID, platform: state.adapter ? state.adapter.id : 'unknown', event, meta, host: location.hostname, ts: Date.now(), }, }).catch(() => {}); } catch { // tracking must never break the page } } // ---------- local question bank ---------- const SOURCE_LABEL = { local: '本地题库', shared: '共享题库', ai: 'AI', direct: 'AI', 'local-mock': '模拟', mock: '模拟', adapter: '适配器题库', }; function sourceLabel(source) { return SOURCE_LABEL[source] || ''; } function sourceBucket(source) { if (source === 'local') return 'local'; if (source === 'shared' || source === 'adapter') return 'shared'; if (source === 'mock' || source === 'local-mock') return 'mock'; return 'ai'; } let bankDbPromise = null; let bankCache = null; function bankInvalidate() { bankCache = null; } function bankDb() { if (bankDbPromise) return bankDbPromise; bankDbPromise = new Promise((resolve, reject) => { if (!window.indexedDB) { reject(new Error('当前浏览器不支持本地题库')); return; } const request = indexedDB.open('qoda_bank', 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains('records')) { const store = db.createObjectStore('records', { keyPath: 'id' }); store.createIndex('norm', 'norm', { unique: false }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); request.onblocked = () => reject(new Error('本地题库被其他页面占用')); }).catch((error) => { bankDbPromise = null; throw error; }); return bankDbPromise; } async function bankCount() { const db = await bankDb(); return new Promise((resolve, reject) => { const tx = db.transaction('records', 'readonly'); const req = tx.objectStore('records').count(); req.onsuccess = () => resolve(req.result || 0); req.onerror = () => reject(req.error); }); } async function bankPutMany(records) { if (!records.length) return 0; const db = await bankDb(); return new Promise((resolve, reject) => { const tx = db.transaction('records', 'readwrite'); const store = tx.objectStore('records'); records.forEach((record) => store.put(record)); tx.oncomplete = () => { bankInvalidate(); resolve(records.length); }; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } async function bankGetByNorm(norm) { const db = await bankDb(); return new Promise((resolve, reject) => { const tx = db.transaction('records', 'readonly'); const index = tx.objectStore('records').index('norm'); const req = index.getAll(norm); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); }); } async function bankAll() { if (bankCache) return bankCache; const db = await bankDb(); const rows = await new Promise((resolve, reject) => { const tx = db.transaction('records', 'readonly'); const req = tx.objectStore('records').getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); }); bankCache = rows; return rows; } async function bankClear() { const db = await bankDb(); return new Promise((resolve, reject) => { const tx = db.transaction('records', 'readwrite'); tx.objectStore('records').clear(); tx.oncomplete = () => { bankInvalidate(); resolve(true); }; tx.onerror = () => reject(tx.error); }); } function bankNorm(text) { return String(text ?? '') .replace(/<[^>]*>/g, ' ') .replace(/ /gi, ' ') .replace(/[\s\u3000]+/g, '') .replace(/[,。!?、;:“”‘’()()【】\u005B\u005D《》<>·.,!?;:'"\-_/\\]/g, '') .replace(/^\d+[.、.)]/, '') .replace(/^(单选题?|多选题?|判断题?|填空题?|简答题?|论述题?|名词解释题?)/, '') .toLowerCase(); } function bankBigrams(text) { const set = new Set(); const str = String(text || ''); if (str.length < 2) { if (str) set.add(str); return set; } for (let i = 0; i < str.length - 1; i += 1) set.add(str.slice(i, i + 2)); return set; } function bankJaccard(a, b) { if (!a.size || !b.size) return 0; let inter = 0; for (const item of a) if (b.has(item)) inter += 1; return inter / (a.size + b.size - inter); } function bankSimilarity(a, b) { if (a === b) return 1; return bankJaccard(bankBigrams(a), bankBigrams(b)); } function bankStripPrefix(text) { return String(text ?? '').replace(/^[A-Ha-h][.、.))::]\s*/, '').trim(); } function bankSplitOptions(value) { if (Array.isArray(value)) return value.map((item) => bankStripPrefix(item)).filter(Boolean); if (typeof value !== 'string') return []; return value .split(/\s*[||]\s*|\n+/) .map((item) => bankStripPrefix(item)) .filter(Boolean); } function bankInferType(rawType, rawAnswer, options) { const type = String(rawType || '').trim(); if (/单选/.test(type)) return 'single'; if (/多选/.test(type)) return 'multiple'; if (/判断/.test(type)) return 'judge'; if (/填空/.test(type)) return 'blank'; if (/简答|论述|名词解释|问答/.test(type)) return 'subjective'; const answerText = Array.isArray(rawAnswer) ? rawAnswer.join('') : String(rawAnswer ?? ''); if (options.length === 2 && options.every((item) => /^(正确|错误|对|错|是|否|true|false)$/i.test(item))) return 'judge'; if (/^(正确|错误|对|错|是|否|true|false)$/i.test(answerText.trim())) return 'judge'; if (/^[A-H](?:\s*[,,、;;||]\s*[A-H])+$/i.test(answerText.trim())) return 'multiple'; if (Array.isArray(rawAnswer) && rawAnswer.length > 1) return 'multiple'; if (!options.length) { if (/^[A-H]$/i.test(answerText.trim())) return 'single'; if (/[||;;]/.test(answerText)) return 'blank'; return 'subjective'; } return 'single'; } function bankNormalizeAnswer(rawAnswer, options, type) { if (type === 'judge') { const text = String(rawAnswer ?? '').trim(); if (/^(正确|对|是|true|t|√|✓|1|yes|y)$/i.test(text)) return '正确'; if (/^(错误|错|否|false|f|×|✕|x|0|no|n|不正确)$/i.test(text)) return '错误'; return text; } if (type === 'multiple') { const list = Array.isArray(rawAnswer) ? rawAnswer : String(rawAnswer ?? '').split(/[,,、||;;\s]+/).filter(Boolean); return list.map((item) => String(item).trim()).filter(Boolean); } if (type === 'blank') { const list = Array.isArray(rawAnswer) ? rawAnswer : String(rawAnswer ?? '').split(/[||;;]/).map((item) => item.trim()).filter(Boolean); return list.map((item) => String(item)); } if (Array.isArray(rawAnswer)) return String(rawAnswer[0] ?? '').trim(); return String(rawAnswer ?? '').trim(); } function buildBankRecord(raw, fallbackSource = 'local') { if (!raw || typeof raw !== 'object') return null; const title = String(raw.title ?? raw.question ?? raw['题目'] ?? '').trim(); if (!title) return null; const options = bankSplitOptions(raw.options ?? raw['选项'] ?? []); const rawAnswer = raw.answer ?? raw['答案'] ?? ''; const type = bankInferType(raw.type ?? raw['题型'], rawAnswer, options); const answer = bankNormalizeAnswer(rawAnswer, options, type); const hasAnswer = Array.isArray(answer) ? answer.length > 0 : String(answer).trim().length > 0; if (!hasAnswer) return null; const norm = bankNorm(title); const key = hashText(norm).toString(36); return { id: key, key, title, norm, options, type, answer, source: String(raw.source || fallbackSource).slice(0, 16), createdAt: Number(raw.createdAt) || Date.now(), updatedAt: Date.now(), }; } function parseCsvRows(text) { const rows = []; let row = []; let field = ''; let inQuotes = false; const src = String(text || ''); for (let i = 0; i < src.length; i += 1) { const ch = src[i]; if (inQuotes) { if (ch === '"') { if (src[i + 1] === '"') { field += '"'; i += 1; } else { inQuotes = false; } } else { field += ch; } } else if (ch === '"') { inQuotes = true; } else if (ch === ',') { row.push(field); field = ''; } else if (ch === '\n') { row.push(field); field = ''; rows.push(row); row = []; } else if (ch !== '\r') { field += ch; } } if (field !== '' || row.length) { row.push(field); rows.push(row); } return rows.filter((item) => item.some((cell) => String(cell).trim())); } function parseJsonBank(text) { const data = JSON.parse(String(text || '').replace(/^\uFEFF/, '')); const list = Array.isArray(data) ? data : Array.isArray(data.items) ? data.items : Array.isArray(data.data) ? data.data : []; return list.map((item) => buildBankRecord(item, 'local')).filter(Boolean); } function parseCsvBank(text) { const rows = parseCsvRows(String(text || '').replace(/^\uFEFF/, '')); if (!rows.length) return []; const header = rows[0].map((cell) => String(cell).trim().toLowerCase()); const hasHeader = header.some((h) => /题目|question|title|问题/.test(h)) && header.some((h) => /答案|answer/.test(h)); const findColumn = (patterns, fallback) => { const index = header.findIndex((h) => patterns.some((pattern) => pattern.test(h))); return index >= 0 ? index : fallback; }; let titleIdx = 0; let optionsIdx = 1; let answerIdx = 2; let typeIdx = 3; if (hasHeader) { titleIdx = findColumn([/题目/, /question/, /title/, /问题/], 0); optionsIdx = findColumn([/选项/, /option/, /choice/], 1); answerIdx = findColumn([/答案/, /answer/], 2); typeIdx = findColumn([/题型/, /type/], 3); } const dataRows = hasHeader ? rows.slice(1) : rows; return dataRows .map((row) => buildBankRecord({ title: row[titleIdx], options: row[optionsIdx], answer: row[answerIdx], type: row[typeIdx], source: 'local', }, 'local')) .filter(Boolean); } function parseTxtBank(text) { const lines = String(text || '').replace(/^\uFEFF/, '').split(/\r?\n/); const records = []; for (const line of lines) { const raw = line.trim(); if (!raw || raw.startsWith('#')) continue; let parts = raw.split('\t'); if (parts.length < 2) parts = raw.split('||'); if (parts.length < 2) parts = raw.split('##'); if (parts.length < 2) continue; const title = parts[0].trim(); const answer = parts.slice(1).join('|').trim(); const record = buildBankRecord({ title, options: [], answer, source: 'local' }, 'local'); if (record) records.push(record); } return records; } function parseBankFile(name, text) { const lower = String(name || '').toLowerCase(); if (lower.endsWith('.json')) return parseJsonBank(text); if (lower.endsWith('.csv')) return parseCsvBank(text); return parseTxtBank(text); } function bankOptionScore(record, question) { if (!record.options || !record.options.length || !question.options.length) return 1; const a = record.options.map(bankNorm).filter(Boolean); const b = question.options.map((option) => bankNorm(option.text)).filter(Boolean); if (!a.length || !b.length) return 1; const setA = new Set(a); const setB = new Set(b); let inter = 0; for (const item of setA) if (setB.has(item)) inter += 1; return inter / Math.max(setA.size, setB.size); } function bankAnswerForQuestion(record, question) { const answer = record.answer; if (question.type === 'multiple') { if (Array.isArray(answer)) return answer; return String(answer ?? '').split(/[,,、||;;\s]+/).filter(Boolean); } if (question.type === 'blank') { if (Array.isArray(answer)) return answer; return String(answer ?? '').split(/[||;;]/).map((item) => item.trim()).filter(Boolean); } if (question.type === 'judge') { const text = Array.isArray(answer) ? answer[0] : answer; return String(text ?? '').trim(); } if (Array.isArray(answer)) return String(answer[0] ?? ''); return String(answer ?? ''); } async function bankLookupLocal(question) { if (!cfg.preferBank) return null; try { const norm = bankNorm(question.title); let records = await bankGetByNorm(norm); if (!records.length) { const allRecords = await bankAll(); if (!allRecords.length) return null; let best = null; let bestScore = 0; for (const record of allRecords) { const lenA = norm.length; const lenB = String(record.norm || '').length; if (Math.abs(lenA - lenB) / Math.max(lenA, lenB, 1) > 0.4) continue; const titleScore = bankSimilarity(norm, record.norm); if (titleScore < 0.7) continue; const total = titleScore * 0.75 + bankOptionScore(record, question) * 0.25; if (total > bestScore) { bestScore = total; best = record; } } if (!best || bestScore < 0.82) return null; records = [best]; } let best = records[0]; let bestScore = -1; for (const record of records) { const score = bankOptionScore(record, question); if (score > bestScore) { bestScore = score; best = record; } } return { answer: bankAnswerForQuestion(best, question), source: 'local', record: best }; } catch { return null; } } async function bankLookupShared(question) { if (!cfg.sharedBank) return null; try { const res = await httpJson({ url: `${base()}/api/bank/search`, method: 'POST', data: { title: question.title, options: question.options.map((option) => option.text), type: question.type, }, }); if (res.status === 200 && res.data && res.data.ok && res.data.hit && res.data.record) { return { answer: bankAnswerForQuestion(res.data.record, question), source: 'shared', record: res.data.record, score: res.data.score, }; } } catch { // shared bank is optional } return null; } const TIKU_TYPE = { single: 0, multiple: 1, judge: 3, blank: 4, subjective: 5 }; function adapterEndpoint() { const raw = String(cfg.adapterUrl || '').trim(); if (!raw) return ''; return raw + (raw.includes('?') ? '&' : '?') + 'wannengDisable=1'; } function tikuAnswerToLocal(question, allAnswer) { const list = Array.isArray(allAnswer) ? allAnswer.map((item) => String(item == null ? '' : item).trim()).filter(Boolean) : []; if (!list.length) return null; const joined = list.join(''); const judgeTrue = /^(正确|对|是|true|t|√|✓|1|yes|y)$/i; const judgeFalse = /^(错误|错|否|false|f|×|✕|x|0|no|n)$/i; if (question.type === 'judge') { const text = list[0]; if (judgeTrue.test(text)) return '正确'; if (judgeFalse.test(text)) return '错误'; return text; } if (question.type === 'multiple') { const letters = joined.match(/[A-H]/gi); if (letters && letters.length) return [...new Set(letters.map((ch) => ch.toUpperCase()))].sort(); return list; } if (question.type === 'blank') return list; const single = joined.match(/^[A-H]$/i); return single ? single[0].toUpperCase() : list[0]; } async function bankLookupAdapter(question) { if (!cfg.useAdapter) return null; const url = adapterEndpoint(); if (!url) return null; try { const res = await httpJson({ url, method: 'POST', data: { question: question.title, options: question.options.map((option) => option.text), type: TIKU_TYPE[question.type] == null ? 0 : TIKU_TYPE[question.type], }, timeout: 5000, }); if (res.status !== 200 || !res.data) return null; const allAnswer = (res.data.answer && res.data.answer.allAnswer) || res.data.allAnswer || null; const answer = tikuAnswerToLocal(question, allAnswer); if (answer == null) return null; if (Array.isArray(answer) ? !answer.length : answer === '') return null; return { answer, source: 'adapter', score: 1 }; } catch { return null; } } // AI 答案回写共享题库:静默、失败不阻塞答题、不受本地缓存开关影响。 // 服务端攒够多个不同客户相同答案才写主库,单客户不会污染题库。 function contributeAiAnswer(question, answer) { if (!cfg.contributeAi) return; try { void httpJson({ url: `${base()}/api/bank/contribute`, method: 'POST', data: { title: String(question.title || '').slice(0, 500), options: (question.options || []).map((o) => String(o && o.text != null ? o.text : o)).slice(0, 12), answer, type: question.type || '', anonId: ANON_ID, source: 'ai', }, }).catch(() => {}); track('bank_contribute', { type: question.type || '' }); } catch { // 回写失败绝不能影响客户作答 } } async function bankSaveAi(question, answer) { contributeAiAnswer(question, answer); if (!cfg.cacheAi) return; try { const record = buildBankRecord({ title: question.title, options: question.options.map((option) => option.text), answer, type: question.type, source: 'ai', }, 'ai'); if (!record) return; const existing = await bankGetByNorm(record.norm); if (existing.length) return; await bankPutMany([record]); await refreshBankCount(); track('bank_cache_ai', { type: question.type }); } catch { // cache failures must not break answering } } async function refreshBankCount() { try { state.bankCount = await bankCount(); renderPanel(); } catch { state.bankCount = 0; } } async function refreshSharedCount() { try { const res = await httpJson({ url: `${base()}/api/bank/count` }); if (res.status === 200 && res.data && res.data.ok) { state.sharedCount = res.data.count || 0; renderPanel(); } } catch { // backend offline is fine } } async function importBankFile(file) { try { const text = await file.text(); const parsed = parseBankFile(file.name, text); if (!parsed.length) { toast('没有解析到题目,检查列名或格式', 'warning'); return; } const unique = new Map(); parsed.forEach((record) => unique.set(record.id, record)); const records = [...unique.values()]; await bankPutMany(records); await refreshBankCount(); toast(`已导入 ${records.length} 题,本地题库共 ${state.bankCount} 题`, 'success'); track('bank_import', { count: records.length }); if (cfg.syncBank) { try { const res = await httpJson({ url: `${base()}/api/bank/import`, method: 'POST', data: { records }, }); if (res.status === 200 && res.data && res.data.ok) { state.sharedCount = res.data.total || state.sharedCount; toast(`共享题库新增 ${res.data.imported} 题`, 'success'); renderPanel(); } } catch { // local import already succeeded } } } catch (error) { toast(`导入失败:${error.message}`, 'error'); } } async function searchBank(query) { const text = String(query || '').trim(); state.searchQuery = text; if (!text) { state.searchResults = []; state.searching = false; renderSearchResults(); return; } state.searching = true; renderSearchResults(); const norm = bankNorm(text); const results = []; try { const local = await bankAll(); for (const record of local) { const score = bankSimilarity(norm, record.norm); const contains = record.title.includes(text); if (score >= 0.45 || contains) { results.push({ record, score: contains ? Math.max(score, 0.6) : score, source: 'local' }); } } } catch { // no local bank } try { if (cfg.sharedBank) { const res = await httpJson({ url: `${base()}/api/bank/search`, method: 'POST', data: { title: text, options: [] }, }); if (res.status === 200 && res.data && res.data.ok && res.data.hit && res.data.record) { results.push({ record: res.data.record, score: res.data.score || 0.8, source: 'shared' }); } } } catch { // shared bank offline } const merged = new Map(); for (const item of results) { const key = item.record.key || item.record.id; const existing = merged.get(key); if (!existing) { merged.set(key, item); } else if (existing.source !== 'local') { if (item.source === 'local' || item.score > existing.score) merged.set(key, item); } } state.searchResults = [...merged.values()] .sort((a, b) => b.score - a.score) .slice(0, 6); state.searching = false; renderSearchResults(); } // ---------- license / card key ---------- function licenseQuotaText() { const quota = state.quota || {}; const remaining = quota.remaining != null ? quota.remaining : '-'; const limit = quota.limit != null ? quota.limit : state.freeQuota || '-'; return `免费 · 今日剩 ${remaining}/${limit}`; } function licenseExpiryText() { if (!state.license || !state.license.expiresAt) return ''; try { return `到期:${new Date(state.license.expiresAt).toLocaleDateString('zh-CN')}`; } catch { return ''; } } function licensePlanButtons() { const paid = (state.plans || []).filter((plan) => Number(plan.price) > 0); if (!paid.length) return ''; return paid .map( (plan) => ``, ) .join(''); } function saveLicense(data) { state.license = data || null; gmSet(LICENSE_KEY, data && data.token ? data : null); renderPanel(); } function clearLicenseSilent() { state.license = null; state.quota = null; state.quotaExceeded = false; gmSet(LICENSE_KEY, null); renderPanel(); } function clearLicense() { clearLicenseSilent(); toast('已清除本机授权;设备绑定仍在,换设备需要管理员解绑', 'info'); } async function refreshLicense() { try { if (state.license && state.license.token) { const res = await httpJson({ url: `${base()}/api/key/status`, method: 'POST', data: { token: state.license.token }, }); if (res.status === 200 && res.data && res.data.ok) { state.license = { ...state.license, ...res.data }; state.quota = res.data.quota; state.quotaExceeded = Boolean(res.data.quota && res.data.quota.remaining <= 0); gmSet(LICENSE_KEY, state.license); renderPanel(); return; } if ([401, 403, 404].includes(res.status)) { clearLicenseSilent(); toast('卡密已失效,已切回免费额度', 'warning'); return; } } const res = await httpJson({ url: `${base()}/api/quota?anonId=${encodeURIComponent(ANON_ID)}`, }); if (res.status === 200 && res.data && res.data.ok) { state.quota = res.data.quota; state.freeQuota = res.data.freeQuota || state.freeQuota; state.quotaExceeded = Boolean(state.quota && state.quota.remaining <= 0); renderPanel(); } } catch { // backend offline; keep the local license view } } async function redeemLicense(keyText) { const key = String(keyText || '').trim(); if (!key) { toast('请输入卡密', 'warning'); return; } track('license_redeem', {}); try { const res = await httpJson({ url: `${base()}/api/key/redeem`, method: 'POST', data: { key, deviceId: DEVICE_ID, anonId: ANON_ID }, }); if (res.status !== 200 || !res.data || !res.data.ok) { throw new Error(res.data?.error || `兑换失败 ${res.status}`); } const data = res.data; state.license = { token: data.token, plan: data.plan, planId: data.planId, quota: data.quota, expiresAt: data.expiresAt, deviceId: data.deviceId, }; state.quota = data.quota; state.quotaExceeded = false; gmSet(LICENSE_KEY, state.license); renderPanel(); toast(`兑换成功:${data.plan},剩余 ${data.quota.remaining} 次`, 'success'); track('license_redeem_success', { planId: data.planId }); } catch (error) { const map = { invalid_key_format: '卡密格式不对', invalid_key: '卡密不存在', key_revoked: '卡密已被封禁', key_expired: '卡密已过期', device_mismatch: '这张卡已绑定其他设备,请联系管理员解绑', quota_exhausted: '这张卡次数已用完', device_required: '设备标识缺失', }; toast(`兑换失败:${map[error.message] || error.message}`, 'error'); track('license_redeem_fail', { error: error.message }); } } function openPay(planId) { const plan = (state.plans || []).find((item) => item.id === planId); const url = (plan && plan.buyUrl) || state.payUrl || cfg.payUrl || ''; if (!url) { toast('管理员还没配置购买链接(PAY_URL 或套餐 buyUrl)', 'warning'); return; } let target = url; try { const parsed = new URL(url, location.origin); if (planId) parsed.searchParams.set('plan', planId); parsed.searchParams.set('uid', ANON_ID.slice(0, 12)); parsed.searchParams.set('device', DEVICE_ID.slice(0, 12)); target = parsed.toString(); } catch { // use the raw URL } track('pay_click', { planId: planId || '' }); window.open(target, '_blank', 'noopener'); } function handleLicenseWarning(warning) { if (!warning) return; if (['invalid_token', 'invalid_key', 'key_revoked', 'token_stale', 'device_mismatch', 'key_expired'].includes(warning)) { clearLicenseSilent(); toast('卡密状态已变化,已切回免费额度', 'warning'); } } // ---------- account / points ---------- const ACCOUNT_ERRORS = { invalid_credentials: '用户名或密码不对', invalid_username: '用户名要 3-32 位,字母/数字/中文/下划线', invalid_password: '密码至少 8 位', username_taken: '这个用户名已经被用了', unauthorized: '登录已失效,请重新登录', invalid_token: '登录已失效,请重新登录', invalid_key_format: '卡密格式不对', invalid_key: '卡密不存在', key_used: '这张卡密已经被使用', key_revoked: '这张卡密已被封禁', key_expired: '这张卡密已过期', device_card: '这是旧版设备次数卡,请在旧版入口兑换', invalid_points: '卡密积分无效', too_many_requests: '操作太频繁,稍后再试', }; function accountErrorText(code) { return ACCOUNT_ERRORS[code] || code || '操作失败'; } function accountName() { return state.account && state.account.user ? state.account.user.username : ''; } function accountStatusText() { if (state.accountToken && state.account && state.account.user) { return `积分 ${state.points}`; } return licenseQuotaText(); } function saveAccount(data) { state.account = data || null; state.accountToken = data && data.token ? data.token : ''; state.points = data && data.points != null ? data.points : 0; state.pointsExhausted = false; gmSet(ACCOUNT_KEY, data && data.token ? data : null); renderPanel(); } function clearAccount(silent) { state.account = null; state.accountToken = ''; state.points = 0; state.pointsExhausted = false; gmSet(ACCOUNT_KEY, null); renderPanel(); if (!silent) toast('已退出登录', 'info'); } async function refreshAccount() { if (!state.accountToken) return; try { const res = await httpJson({ url: `${base()}/api/me`, headers: { Authorization: `Bearer ${state.accountToken}` }, }); if (res.status === 200 && res.data && res.data.ok) { state.account = { ...(state.account || {}), token: state.accountToken, user: res.data.user, points: res.data.balance, dailyGrant: res.data.dailyGrant, }; state.points = res.data.balance; state.pointsExhausted = false; gmSet(ACCOUNT_KEY, state.account); renderPanel(); return; } if (res.status === 401 || res.status === 403) { clearAccount(true); toast('账号登录已失效,请重新登录', 'warning'); } } catch { // offline; keep local state } } async function loginAccount(username, password) { const name = String(username || '').trim(); if (!name || !password) { toast('请输入用户名和密码', 'warning'); return; } const res = await httpJson({ url: `${base()}/api/auth/login`, method: 'POST', data: { username: name, password: String(password) }, }); if (res.status !== 200 || !res.data || !res.data.ok) { throw new Error(res.data?.error || `登录失败 ${res.status}`); } saveAccount({ token: res.data.token, user: res.data.user, points: res.data.user.points, dailyGrant: res.data.dailyGrant, }); toast(`登录成功:${res.data.user.username}`, 'success'); if (res.data.dailyGrant) toast(`今日登录送 ${res.data.dailyGrant} 积分`, 'info'); track('account_login', {}); } async function logoutAccount() { try { await httpJson({ url: `${base()}/api/auth/logout`, method: 'POST', headers: { Authorization: `Bearer ${state.accountToken}` }, }); } catch { // local logout still works } clearAccount(); track('account_logout', {}); } async function redeemPoints(code) { const value = String(code || '').trim(); if (!value) { toast('请输入卡密', 'warning'); return; } const res = await httpJson({ url: `${base()}/api/redeem`, method: 'POST', headers: { Authorization: `Bearer ${state.accountToken}` }, data: { code: value }, }); if (res.status !== 200 || !res.data || !res.data.ok) { throw new Error(res.data?.error || `兑换失败 ${res.status}`); } state.points = res.data.balance; state.pointsExhausted = false; if (state.account) state.account.points = res.data.balance; gmSet(ACCOUNT_KEY, state.account); renderPanel(); toast(`兑换成功:+${res.data.points} 积分,当前 ${res.data.balance} 分`, 'success'); track('account_redeem', { points: res.data.points }); } function openAccountPage() { window.open(`${base()}${state.accountUrl || '/account.html'}`, '_blank', 'noopener'); } // ---------- platform adapters ---------- const ADAPTERS = []; function defineAdapter(adapter) { ADAPTERS.push(adapter); return adapter; } const Ouchn = defineAdapter({ id: 'ouchn', name: '国家开放大学', cohort: 'B', test(url) { try { const host = new URL(url).hostname; return /(^|\.)ouchn\.(cn|edu\.cn)$/.test(host) || host === 'lms.ouchn.cn' || host === 'study.ouchn.cn'; } catch { return false; } }, questionSelector: 'li.subject, .subject, .que, .question, .que-item', titleSelectors: ['.summary-title .subject-description', '.subject-description', '.qtext', '.question-title', '.stem', 'h3', 'h4'], optionSelector: '.subject-options li.option label, .subject-options li.option, .answer label, .answer .r0, .answer .r1', }); const Qingshu = defineAdapter({ id: 'qingshu', name: '青书学堂', cohort: 'B', test(url) { try { return /(^|\.)qingshuxuetang\.com$/.test(new URL(url).hostname); } catch { return false; } }, questionSelector: '.question-item, .question, .que-item, .timu, [class*="question"]', titleSelectors: ['.question-title', '.title', '.stem', '.timu-title', 'h3', 'h4'], optionSelector: 'label, .option, li', }); const Jijiao = defineAdapter({ id: 'jijiaox', name: '继教云', cohort: 'B', test(url) { try { return /(^|\.)jijiao(x|ol)\.com$/.test(new URL(url).hostname); } catch { return false; } }, questionSelector: '.question, .question-item, .que-item, .subject, [class*="question"]', titleSelectors: ['.question-title', '.title', '.stem', '.subject-title', 'h3', 'h4'], optionSelector: 'label, .option, li', }); const Chaoxing = defineAdapter({ id: 'chaoxing', name: '超星学习通', cohort: 'A', test(url) { try { return /(^|\.)chaoxing\.com$/.test(new URL(url).hostname); } catch { return false; } }, typeHint(el) { const page = el.closest('[data-seeclick], .questionLi, .TiMu, .ZyQLti, .mark_name, [class*="exam"]'); if (page && /kaoshi|exam|test/i.test(page.className + ' ' + (page.getAttribute('data-seeclick') || ''))) return 'exam'; return 'work'; }, questionSelector: '.questionLi, .TiMu, .ZyQLti, .mark_name, [class*="question"]', titleSelectors: ['.qBord .Zy_TItle .textTitle, .Zy_TItle .textTitle, .Zy_TItle, .tiulanTitle .Zy_TItle, .qtitle, .titlediv, .stem', '.qBord .Zy_TItle'], optionSelector: '.qTimeline .Zy_ulTop li, .Zy_ulTop li, ul li label, .optionCon label, .choiceBox li', fillOverride(question, value) { if (value == null) return false; const root = question.el; const selects = Array.from(root.querySelectorAll('select')); if (selects.length) { const arr = Array.isArray(value) ? value : [value]; if (selects.length !== arr.length && arr.length !== 1) return false; selects.forEach((sel, i) => { const v = arr.length === 1 ? arr[0] : arr[i]; setNativeValue(sel, String(v)); sel.dispatchEvent(new Event('change', { bubbles: true })); }); return true; } const inputs = Array.from(root.querySelectorAll('input[type="radio"], input[type="checkbox"]')); if (!inputs.length) return false; const letters = (Array.isArray(value) ? value : [value]).map((v) => String(v).replace(/[^A-Ha-h]/g, '').toUpperCase()).filter(Boolean); let hit = false; inputs.forEach((input) => { const holder = input.closest('li') || input.parentElement || input; const label = holder.textContent || ''; const letter = (label.match(/^[\s\S]{0,3}([A-Ha-h])[.、.))]/) || [])[1]; if (letter && letters.includes(letter.toUpperCase())) { input.click(); hit = true; } }); return hit; }, }); const Generic = defineAdapter({ id: 'generic', name: '当前页面', cohort: 'B', test() { return true; }, questionSelector: '.que, .question, .question-item, .que-item, .subject-item, [data-question], [class*="question"]', titleSelectors: ['.qtext', '.question-title', '.title', '.stem', '[class*="stem"]', '[class*="title"]', 'h3', 'h4', 'p'], optionSelector: 'label, li', }); function pickAdapter() { const forced = new URLSearchParams(location.search).get('adapter'); if (forced) { const found = ADAPTERS.find((adapter) => adapter.id === forced); if (found) return found; } return ADAPTERS.find((adapter) => adapter.test(location.href)) || Generic; } // ---------- extraction ---------- function extractOptions(root, adapter) { const seen = new Set(); const options = []; const push = (input, labelEl) => { if (!input || seen.has(input)) return; seen.add(input); const holder = labelEl || input.closest('label') || input.parentElement; let text = textOf(holder); if (!text) return; text = text.replace(/^[A-Ha-h][.、.))::]\s*/, '').trim(); options.push({ text, input, label: holder }); }; for (const el of all(root, adapter.optionSelector)) { const input = el.matches('input') ? el : el.querySelector('input[type="radio"], input[type="checkbox"]'); if (input) push(input, el.matches('label') ? el : input.closest('label') || el); } if (!options.length) { for (const input of all(root, 'input[type="radio"], input[type="checkbox"]')) push(input, null); } return options; } function isJudgeOptions(options) { if (options.length !== 2) return false; const truthy = ['正确', '对', '是', '√', '✓', 'true', 't', '1']; const falsy = ['错误', '错', '否', '×', '✕', 'false', 'f', '0', '不正确']; return options.every((opt) => { const text = opt.text.replace(/\s+/g, '').toLowerCase(); return truthy.includes(text) || falsy.includes(text); }); } function extractQuestion(root, adapter) { const titleEl = firstMatch(root, adapter.titleSelectors); let title = textOf(titleEl); if (!title) { const clone = root.cloneNode(true); clone.querySelectorAll('input, textarea, button, script, style, .answer, .options, .option, label').forEach((node) => node.remove()); title = rawText(clone).slice(0, 500); } title = title .replace(/^(单选题?|多选题?|判断题?|填空题?|简答题?|论述题?|名词解释题?)\s*[·::]?\s*/, '') .replace(/^\d+[.、.)]\s*/, '') .trim(); if (!title) return null; const options = extractOptions(root, adapter); const radios = all(root, 'input[type="radio"]'); const checkboxes = all(root, 'input[type="checkbox"]'); const textInputs = all(root, 'input[type="text"], input:not([type]), textarea'); const textareas = textInputs.filter((el) => el.tagName === 'TEXTAREA'); const singleInputs = textInputs.filter((el) => el.tagName !== 'TEXTAREA'); if (!options.length && !radios.length && !checkboxes.length && !textInputs.length) return null; let type = 'subjective'; if (checkboxes.length) type = 'multiple'; else if (radios.length) type = isJudgeOptions(options) ? 'judge' : 'single'; else if (textareas.length) type = 'subjective'; else if (singleInputs.length) type = 'blank'; return { el: root, title: title.slice(0, 1000), type, options, radios, checkboxes, textInputs: singleInputs, textareas, blanks: singleInputs.length, status: type === 'subjective' ? 'wait' : 'todo', hooked: false, answer: null, source: '', error: '', }; } function scanQuestions(silent) { const adapter = state.adapter; let nodes = all(document, adapter.questionSelector); nodes = nodes.filter((node) => !nodes.some((other) => other !== node && other.contains(node))); const questions = []; for (const node of nodes) { if (node.closest('#qoda-host')) continue; const question = extractQuestion(node, adapter); if (!question) continue; question.id = `q${questions.length}`; question.index = questions.length + 1; question.el.dataset.qodaQid = question.id; questions.push(question); } state.questions = questions; state.summaryTracked = false; if (cfg.hooks) questions.forEach(addSubjectiveHook); renderPanel(); if (!silent) { toast(questions.length ? `扫描到 ${questions.length} 道题` : '没找到题目,试试刷新页面或手动滚到题目区域', questions.length ? 'success' : 'warning'); track('scan', { count: questions.length }); } return questions; } // ---------- answering ---------- function mockLocal(question) { const hash = hashText(question.title); if (question.type === 'single') { const count = Math.max(1, question.options.length); return String.fromCharCode(65 + (hash % count)); } if (question.type === 'multiple') { const count = Math.max(2, Math.min(question.options.length || 2, 2 + (hash % 2))); return Array.from({ length: count }, (_, i) => String.fromCharCode(65 + i)); } if (question.type === 'judge') return hash % 2 === 0 ? '正确' : '错误'; if (question.type === 'blank') { return Array.from({ length: Math.max(1, question.blanks) }, (_, i) => `参考要点${i + 1}`); } return `参考要点:${question.title.slice(0, 60)}。建议结合课程内容分点作答,并补充自己的理解。(AI 生成,仅供参考)`; } function localPrompt(question) { const options = question.options.map((opt, i) => `${String.fromCharCode(65 + i)}. ${opt.text}`).join('\n'); const head = `题型:${TYPE_LABEL[question.type]}\n题目:${question.title}\n${options ? `选项:\n${options}\n` : ''}`; if (question.type === 'single') return `${head}\n只输出 JSON:{"answer":"A"}`; if (question.type === 'multiple') return `${head}\n只输出 JSON:{"answer":["A","C"]}`; if (question.type === 'judge') return `${head}\n只输出 JSON:{"answer":"正确"} 或 {"answer":"错误"}`; if (question.type === 'blank') return `${head}\n只输出 JSON:{"answer":["第1空"]},共 ${question.blanks} 个`; return `${head}\n只输出 JSON:{"answer":"简洁的参考答案"}`; } function parseLooseJson(text) { try { return JSON.parse(text); } catch { const match = String(text || '').match(/\{[\s\S]*\}/); if (match) return JSON.parse(match[0]); throw new Error('AI 返回格式不正确'); } } function normalizeLocal(question, value) { if (question.type === 'multiple') { return Array.isArray(value) ? value : String(value ?? '').split(/[,,、;;\s]+/).filter(Boolean); } if (question.type === 'blank') { const list = Array.isArray(value) ? value.map((item) => String(item ?? '')) : [String(value ?? '')]; while (list.length < question.blanks) list.push(''); return list.slice(0, question.blanks); } return String(value ?? ''); } async function askDirect(question) { if (!cfg.directKey) throw new Error('未填写直连 API Key'); const res = await httpJson({ url: `${String(cfg.directBase).replace(/\/+$/, '')}/chat/completions`, method: 'POST', headers: { Authorization: `Bearer ${cfg.directKey}` }, data: { model: cfg.directModel, messages: [ { role: 'system', content: '只输出一行 JSON,不要解释,不要 Markdown。' }, { role: 'user', content: localPrompt(question) }, ], temperature: 0.2, max_tokens: 800, }, }); if (res.status !== 200) throw new Error(`直连返回 ${res.status}`); const text = res.data?.choices?.[0]?.message?.content || ''; const parsed = parseLooseJson(text); return { ok: true, answer: normalizeLocal(question, parsed.answer), source: 'direct' }; } async function ask(question) { const localHit = await withTimeout(bankLookupLocal(question), 2000, null); if (localHit) { return { ok: true, answer: localHit.answer, source: localHit.source, record: localHit.record }; } const sharedHit = await withTimeout(bankLookupShared(question), 6000, null); if (sharedHit) { return { ok: true, answer: sharedHit.answer, source: sharedHit.source, record: sharedHit.record, score: sharedHit.score }; } const adapterHit = await withTimeout(bankLookupAdapter(question), 6000, null); if (adapterHit) { return { ok: true, answer: adapterHit.answer, source: adapterHit.source, score: adapterHit.score }; } if (cfg.mode === 'mock') { return { ok: true, answer: mockLocal(question), source: 'local-mock' }; } if (cfg.mode === 'direct') { const result = await askDirect(question); await bankSaveAi(question, result.answer); return result; } const res = await httpJson({ url: `${base()}/api/answer`, method: 'POST', headers: state.accountToken ? { Authorization: `Bearer ${state.accountToken}` } : {}, data: { anonId: ANON_ID, deviceId: DEVICE_ID, token: state.license ? state.license.token : '', platform: state.adapter.id, type: question.type, title: question.title, options: question.options.map((opt) => opt.text), blanks: question.blanks, }, }); if (res.status === 401 && state.accountToken) { clearAccount(true); throw new Error('登录已失效,请重新登录'); } if (res.status === 402) { if (res.data && res.data.balance != null) state.points = res.data.balance; if (res.data && res.data.cost != null) state.aiCost = res.data.cost; if (res.data && res.data.payUrl) state.payUrl = res.data.payUrl; if (res.data && res.data.error === 'points_exhausted') { state.pointsExhausted = true; } else { state.quota = res.data && res.data.quota ? res.data.quota : state.quota; state.quotaExceeded = Boolean(state.quota && state.quota.remaining <= 0); } renderPanel(); track('insufficient', { error: res.data?.error || '', points: state.points, quotaRemaining: state.quota ? state.quota.remaining : null }); throw new Error(res.data?.error === 'points_exhausted' ? '积分不足,AI 兜底已暂停' : 'AI 兜底次数用完了'); } if (res.status !== 200 || !res.data || !res.data.ok) { throw new Error(res.data?.message || res.data?.error || `服务返回 ${res.status}`); } if (res.data.points != null) { state.points = res.data.points; state.pointsExhausted = false; if (state.account) { state.account.points = res.data.points; gmSet(ACCOUNT_KEY, state.account); } } if (res.data.quota) { state.quota = res.data.quota; state.quotaExceeded = Boolean(res.data.quota.remaining <= 0); renderPanel(); } if (res.data.warning) handleLicenseWarning(res.data.warning); if (res.data.source !== 'mock') { await bankSaveAi(question, res.data.answer); } return res.data; } function resolveOption(question, value) { const text = String(value ?? '').trim(); if (question.type === 'judge') { const truthy = /^(正确|对|是|true|t|√|✓|1|yes|y)$/i.test(text); const falsy = /^(错误|错|否|false|f|×|✕|x|0|no|n|不正确)$/i.test(text); if (!truthy && !falsy) return null; const wantTrue = truthy; const trueSet = ['正确', '对', '是', '√', '✓', 'true', 't', '1']; const falseSet = ['错误', '错', '否', '×', '✕', 'false', 'f', '0', '不正确']; const index = question.options.findIndex((opt) => { const normalized = opt.text.replace(/\s+/g, '').toLowerCase(); return wantTrue ? trueSet.includes(normalized) : falseSet.includes(normalized); }); if (index >= 0) return question.options[index]; const radios = question.radios; const fallback = radios[wantTrue ? 0 : 1]; return fallback ? { input: fallback } : null; } const letter = text.match(/^([A-H])(?:[.、,::)\s]|$)/i); if (letter) { const index = letter[1].toUpperCase().charCodeAt(0) - 65; if (question.options[index]) return question.options[index]; } const normalized = text.replace(/\s+/g, '').toLowerCase(); const index = question.options.findIndex((opt) => opt.text.replace(/\s+/g, '').toLowerCase() === normalized); return index >= 0 ? question.options[index] : null; } function checkInput(input) { if (!input) return; try { input.click(); } catch { // ignore } try { if (!input.checked) input.checked = true; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } catch { // ignore } } function setNativeValue(el, value) { const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; if (setter) setter.call(el, value); else el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('blur', { bubbles: true })); } function fillRichText(question, text) { const scope = question.el; try { const editorNs = window.UE || window.ue; const host = scope.querySelector('textarea[name], .edui-body-container, [contenteditable="true"]'); if (editorNs && typeof editorNs.getEditor === 'function' && host) { const name = (host.getAttribute && (host.getAttribute('name') || host.id)) || ''; const editor = name ? editorNs.getEditor(name) : null; if (editor && typeof editor.setContent === 'function') { editor.setContent(text); return true; } } } catch { // editor namespace may be unavailable on non-UEditor platforms } const editable = scope.querySelector('[contenteditable="true"]'); if (editable) { try { editable.focus(); editable.textContent = text; editable.dispatchEvent(new Event('input', { bubbles: true })); editable.dispatchEvent(new Event('change', { bubbles: true })); return true; } catch { return false; } } return false; } function fillQuestion(question, answer) { if (question.type === 'single' || question.type === 'judge') { const target = resolveOption(question, Array.isArray(answer) ? answer[0] : answer); if (!target) return false; checkInput(target.input); return true; } if (question.type === 'multiple') { const values = Array.isArray(answer) ? answer : String(answer ?? '').split(/[,,、;;\s]+/).filter(Boolean); let filled = 0; for (const value of values) { const target = resolveOption(question, value); if (target) { checkInput(target.input); filled += 1; } } return filled > 0; } if (question.type === 'blank') { const values = Array.isArray(answer) ? answer : [answer]; let filled = 0; question.textInputs.forEach((input, index) => { const value = values[index]; if (value !== undefined && String(value).trim() !== '') { setNativeValue(input, String(value)); filled += 1; } }); return filled > 0; } const text = Array.isArray(answer) ? answer.join('\n') : String(answer ?? ''); if (fillRichText(question, text)) return true; if (question.textareas.length) { setNativeValue(question.textareas[0], text); return true; } return false; } async function answerAll() { if (state.running) return; if (!state.questions.length) scanQuestions(true); const targets = state.questions.filter((question) => question.type !== 'subjective' && question.status !== 'done'); if (!targets.length) { toast('没有需要自动作答的客观题', 'info'); return; } state.running = true; state.lastSummary = null; state.summaryTracked = false; renderPanel(); track('answer_start', { count: targets.length }); let done = 0; let fail = 0; let pointsSpent = 0; const sources = { local: 0, shared: 0, ai: 0, mock: 0 }; state.stopRequested = false; const queue = targets.slice(); const worker = async () => { while (queue.length) { if (state.stopRequested) return; const question = queue.shift(); question.status = 'run'; renderPanel(); try { const res = await ask(question); pointsSpent += Number(res.cost) || 0; const filled = fillQuestion(question, res.answer); question.answer = res.answer; question.source = res.source; question.status = filled ? 'done' : 'fail'; question.error = filled ? '' : '没找到可填写的控件'; if (filled) { done += 1; const bucket = sourceBucket(question.source); sources[bucket] = (sources[bucket] || 0) + 1; } else { fail += 1; } } catch (error) { question.status = 'fail'; question.error = error.message; fail += 1; } track('answer_item', { type: question.type, status: question.status, source: question.source || '' }); renderPanel(); await sleep(320); } }; const width = Math.max(1, Math.min(Number(cfg.concurrency) || 3, 5)); await Promise.all(Array.from({ length: width }, worker)); state.stopRequested = false; state.running = false; const subjective = state.questions.filter((question) => question.type === 'subjective').length; state.lastSummary = { done, fail, subjective, total: state.questions.length, sources, pointsSpent }; track('answer_done', state.lastSummary); renderPanel(); toast( `已答 ${done} 题${fail ? `,失败 ${fail}` : ''}${subjective ? `;还有 ${subjective} 道主观题,可点题目下方的 AI 按钮` : ''}`, fail ? 'warning' : 'success', ); } // ---------- hooks ---------- function addSubjectiveHook(question) { if (question.type !== 'subjective' || question.hooked) return; question.hooked = true; const titleEl = firstMatch(question.el, state.adapter.titleSelectors) || question.el; const wrap = document.createElement('div'); wrap.className = 'qoda-hook-wrap'; wrap.style.cssText = 'margin:10px 0 6px;font:13px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;'; const button = document.createElement('button'); button.type = 'button'; button.textContent = '没思路?一键帮你写'; button.style.cssText = 'border:0;border-radius:8px;background:#2563eb;color:#fff;padding:8px 14px;font-size:13px;cursor:pointer;box-shadow:0 2px 8px rgba(37,99,235,.25);'; button.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); track('hook_click_subjective', { platform: state.adapter.id, type: question.type }); button.disabled = true; button.textContent = '生成中…'; try { const res = await ask(question); const text = Array.isArray(res.answer) ? res.answer.join('\n') : String(res.answer ?? ''); fillQuestion(question, res.answer); question.status = 'done'; question.answer = res.answer; question.source = res.source; if (state.lastSummary) { state.lastSummary.done += 1; state.lastSummary.sources = state.lastSummary.sources || { local: 0, shared: 0, ai: 0, mock: 0 }; const bucket = sourceBucket(res.source); state.lastSummary.sources[bucket] = (state.lastSummary.sources[bucket] || 0) + 1; state.lastSummary.subjective = state.questions.filter( (item) => item.type === 'subjective' && item.status !== 'done', ).length; } renderPanel(); const card = document.createElement('div'); card.style.cssText = 'margin-top:10px;border:1px solid #dbeafe;background:#f8fbff;border-radius:10px;padding:12px;font:13px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:#1f2937;'; const fromBank = res.source === 'local' || res.source === 'shared'; const note = document.createElement('div'); note.textContent = `${fromBank ? '题库参考答案' : 'AI 参考答案'}(仅供参考,请自行核对)`; note.style.cssText = 'font-size:12px;color:#64748b;margin-bottom:6px;'; const body = document.createElement('div'); body.textContent = text; body.style.cssText = 'white-space:pre-wrap;'; const cta = document.createElement('div'); cta.style.cssText = 'margin-top:10px;display:flex;align-items:center;gap:10px;'; const link = document.createElement('a'); link.href = thesisUrl('subjective'); link.target = '_blank'; link.rel = 'noopener'; link.textContent = '要写整篇论文?→ 论文助手'; link.style.cssText = 'color:#64748b;font-weight:400;text-decoration:none;'; link.addEventListener('click', () => track('hook_click_subjective_thesis', { platform: state.adapter.id })); const close = document.createElement('button'); close.type = 'button'; close.textContent = '收起'; close.style.cssText = 'margin-left:auto;border:0;background:transparent;color:#64748b;font-size:12px;cursor:pointer;'; close.addEventListener('click', () => card.remove()); cta.append(link, close); card.append(note, body, cta); wrap.replaceChildren(card); } catch (error) { button.disabled = false; button.textContent = '重试 AI 生成'; toast(`生成失败:${error.message}`, 'error'); } }); wrap.appendChild(button); try { titleEl.insertAdjacentElement('afterend', wrap); } catch { question.el.prepend(wrap); } track('hook_impression_subjective', { platform: state.adapter.id, type: question.type }); } function maybeSceneHook() { const forced = new URLSearchParams(location.search).get('scene') === 'thesis'; const pageText = document.body && document.body.innerText ? document.body.innerText.slice(0, 120000) : ''; if (!forced && !/毕业论文|开题报告|毕业设计|学位论文/.test(pageText)) return; const dismissedUntil = Number(gmGet(SCENE_KEY, 0)) || 0; if (dismissedUntil > Date.now()) return; const card = document.createElement('div'); card.style.cssText = 'position:fixed;left:18px;bottom:18px;z-index:2147483645;width:300px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 16px 40px rgba(15,23,42,.16);padding:14px 16px;font:13px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:#111827;'; const close = document.createElement('button'); close.type = 'button'; close.textContent = '关闭'; close.style.cssText = 'float:right;border:0;background:transparent;color:#64748b;cursor:pointer;font-size:12px;'; close.addEventListener('click', () => { gmSet(SCENE_KEY, Date.now() + 7 * 86400000); card.remove(); }); const title = document.createElement('div'); title.textContent = '毕业论文 / 职称论文'; title.style.cssText = 'font-weight:700;margin-bottom:4px;'; const desc = document.createElement('div'); desc.textContent = '选题、大纲、查重降重、格式排版,一站处理。'; desc.style.cssText = 'color:#4b5563;margin-bottom:10px;'; const link = document.createElement('a'); link.href = thesisUrl('scene'); link.target = '_blank'; link.rel = 'noopener'; link.textContent = '去看看 →'; link.style.cssText = 'color:#64748b;font-weight:400;text-decoration:none;'; link.addEventListener('click', () => track('hook_click_scene_thesis', { platform: state.adapter.id })); card.append(close, title, desc, link); document.documentElement.appendChild(card); track('hook_impression_scene', { platform: state.adapter.id }); } // ---------- UI ---------- // 学伴 logo:蓝底圆角方 + 摊开的书 + 绿色对勾徽标(纯 SVG,零依赖) const LOGO_SVG = "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n"; const CSS = ` :host { all: initial; } *, *::before, *::after { box-sizing: border-box; } .qoda-shell { position: fixed; right: 18px; bottom: 18px; z-index: 2147483646; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; } .qoda-launcher { width: 48px; height: 48px; border-radius: 50%; border: 0; padding: 0; background: transparent; cursor: pointer; box-shadow: 0 8px 24px rgba(37,99,235,.35); display: flex; align-items: center; justify-content: center; overflow: hidden; } .qoda-launcher svg { width: 48px; height: 48px; display: block; } .qoda-panel { position: absolute; right: 0; bottom: 58px; width: 340px; max-height: 72vh; overflow: auto; background: #fff; border: 1px solid #e5e7eb; border-radius: 14px; box-shadow: 0 18px 50px rgba(15,23,42,.18); color: #111827; } .qoda-hidden { display: none !important; } .qoda-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid #f1f5f9; position: sticky; top: 0; background: #fff; z-index: 1; } .qoda-brand { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 7px; } .qoda-brand svg { width: 22px; height: 22px; border-radius: 6px; display: block; flex: 0 0 22px; } .qoda-brand span { font-size: 11px; font-weight: 500; color: #64748b; margin-left: 6px; } .qoda-brand-txt { font-size: 14px; font-weight: 700; color: #111827; margin-left: 0 !important; } .qoda-brand-txt span { font-size: 11px; font-weight: 500; color: #64748b; margin-left: 6px; } .qoda-x { border: 0; background: transparent; font-size: 20px; line-height: 1; color: #94a3b8; cursor: pointer; } .qoda-body { padding: 12px 14px 14px; } .qoda-meta { display: flex; gap: 8px; flex-wrap: wrap; font-size: 11px; color: #64748b; margin-bottom: 10px; } .qoda-meta span { background: #f1f5f9; border-radius: 999px; padding: 3px 8px; } .qoda-alert { background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; border-radius: 8px; padding: 8px 10px; font-size: 12px; margin-bottom: 10px; } .qoda-actions { display: flex; gap: 8px; margin-bottom: 12px; } .qoda-btn { flex: 1; border: 1px solid #e2e8f0; background: #fff; color: #334155; border-radius: 8px; padding: 8px 6px; font-size: 12px; cursor: pointer; } .qoda-btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; font-weight: 600; } .qoda-btn.ghost { flex: 0 0 64px; color: #64748b; } .qoda-btn:disabled { opacity: .5; cursor: not-allowed; } .qoda-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; } .qoda-q { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: 7px; font-size: 12px; cursor: pointer; } .qoda-q:hover { background: #f8fafc; } .qoda-dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd5e1; flex: 0 0 7px; } .qoda-dot.run { background: #f59e0b; } .qoda-dot.done { background: #16a34a; } .qoda-dot.fail { background: #dc2626; } .qoda-dot.wait { background: #2563eb; } .qoda-q-title { flex: 1; color: #334155; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .qoda-q-status { color: #94a3b8; font-size: 11px; } .qoda-empty { font-size: 12px; color: #94a3b8; padding: 8px 0; } .qoda-tools { border-top: 1px dashed #e2e8f0; padding-top: 10px; margin-bottom: 10px; } .qoda-tools-title { font-size: 12px; font-weight: 600; color: #334155; margin-bottom: 6px; } .qoda-tools-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } .qoda-tool { display: block; text-align: center; padding: 6px; border-radius: 7px; background: #f8fafc; color: #64748b; font-size: 12px; text-decoration: none; } .qoda-card { border: 1px solid #e5e7eb; background: #fafafa; border-radius: 10px; padding: 10px 12px; margin-bottom: 10px; } .qoda-card-title { font-size: 13px; font-weight: 700; margin-bottom: 3px; } .qoda-card-text { font-size: 12px; color: #475569; margin-bottom: 6px; } .qoda-card-link { font-size: 12px; color: #64748b; font-weight: 400; text-decoration: none; } .qoda-foot { font-size: 11px; color: #94a3b8; line-height: 1.5; } .qoda-settings { display: flex; flex-direction: column; gap: 9px; } .qoda-label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #475569; } .qoda-input { width: 100%; border: 1px solid #e2e8f0; border-radius: 7px; padding: 7px 9px; font-size: 12px; color: #111827; background: #fff; } .qoda-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #475569; } .qoda-settings-actions { display: flex; gap: 8px; margin-top: 4px; } .qoda-hint { font-size: 11px; color: #94a3b8; line-height: 1.5; } .qoda-actions2 { margin-top: -6px; } .qoda-search { margin-bottom: 12px; } .qoda-search-results { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; } .qoda-result { border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; cursor: pointer; } .qoda-result:hover { background: #f8fafc; } .qoda-result-title { font-size: 12px; font-weight: 600; color: #334155; } .qoda-result-answer { font-size: 12px; color: #1d4ed8; margin-top: 2px; } .qoda-result-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; } .qoda-bank-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; color: #475569; } .qoda-legacy { margin-top: 10px; border-top: 1px dashed #e2e8f0; padding-top: 8px; } .qoda-legacy summary { cursor: pointer; font-size: 12px; color: #64748b; font-weight: 600; } .qoda-plan-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 8px; } .qoda-quota-card { border-color: #fde68a; background: #fffbeb; } .qoda-toasts { position: fixed; right: 18px; bottom: 76px; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; pointer-events: none; } .qoda-toast { background: #111827; color: #fff; border-radius: 8px; padding: 8px 12px; font-size: 12px; max-width: 300px; box-shadow: 0 8px 24px rgba(15,23,42,.2); } .qoda-toast.warning { background: #9a3412; } .qoda-toast.success { background: #166534; } .qoda-toast.error { background: #991b1b; } .qoda-cq { border-radius: 8px; font-size: 12px; padding: 6px 8px; margin-bottom: 4px; background: #f8fafc; } .qoda-cq-head { display: flex; gap: 6px; align-items: center; cursor: pointer; } .qoda-cq-opts { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } .qoda-chip { border: 1px solid #e2e8f0; background: #fff; border-radius: 6px; padding: 3px 7px; font-size: 11px; cursor: pointer; color: #334155; } .qoda-chip.on { background: #2563eb; border-color: #2563eb; color: #fff; font-weight: 600; } .qoda-cold-text { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 5px 7px; font-size: 11px; margin-top: 4px; } `; function questionStatusText(question) { const base = STATUS_TEXT[question.status] || ''; if (question.status === 'done' && question.source) return `${base} · ${sourceLabel(question.source)}`; return base; } function searchResultsHtml() { if (state.searching) return '
搜索中…
'; if (!state.searchQuery) return '
输入关键词,先搜本地题库,再搜共享题库。
'; if (!state.searchResults.length) return '
没找到,试试上传题库或换关键词。
'; return state.searchResults .map((item) => { const record = item.record; const answer = Array.isArray(record.answer) ? record.answer.join(' / ') : String(record.answer ?? ''); const score = item.score || 0; const matchLabel = score >= 0.98 ? '精确匹配' : score >= 0.82 ? '高相似' : '关键词命中'; return `
${esc(truncate(record.title, 36))}
${esc(truncate(answer, 60))}
${sourceLabel(item.source)} · ${matchLabel}
`; }) .join(''); } function searchHtml() { return ``; } function renderSearchResults() { if (!state.body) return; const box = state.body.querySelector('.qoda-search-results'); if (!box) return; box.innerHTML = searchResultsHtml(); } function mainHtml() { const questions = state.questions; const coldCount = coldCountConfirmed(); const quotaText = accountStatusText(); const list = buildListHtml(questions); const tools = [ ['选题/大纲', 'outline'], ['查重/降重', 'plagiarism'], ['格式排版', 'format'], ['答辩PPT', 'ppt'], ] .map( ([label, key]) => `${label}`, ) .join(''); const sources = (state.lastSummary && state.lastSummary.sources) || {}; const sourceText = [ sources.local ? `本地题库 ${sources.local}` : '', sources.shared ? `共享题库 ${sources.shared}` : '', sources.ai ? `AI ${sources.ai}` : '', sources.mock ? `模拟 ${sources.mock}` : '', ] .filter(Boolean) .join(' / '); const summary = state.lastSummary ? `
本次已答 ${state.lastSummary.done} 题${state.lastSummary.fail ? `,失败 ${state.lastSummary.fail}` : ''}
${state.lastSummary.subjective ? `还有 ${state.lastSummary.subjective} 道主观题,可以点题目下方的「一键帮写」。` : '客观题已完成。'}${sourceText ? ` 来源:${sourceText}。` : ''}${state.lastSummary.pointsSpent ? ` 本次消耗 ${state.lastSummary.pointsSpent} 积分。` : ''}
课程论文 / 毕业论文不会写?→ 论文助手
` : ''; const quotaCard = state.quotaExceeded ? `
AI 兜底次数用完了
题库命中仍然免费;AI 兜底按次消耗每日免费额度或积分。登录后可在账户页查看额度或购买积分。
` : ''; const pointsCard = state.pointsExhausted ? `
积分不足
题库命中仍然免费;AI 兜底每次 ${state.aiCost} 分。登录后可在账户页兑换卡密或购买积分。
` : ''; return `
${esc(state.adapter.name)}${questions.length} 题题库 ${state.bankCount} 题${state.sharedCount ? `共享 ${state.sharedCount} 题` : ''}${esc(quotaText)}
${state.running ? '
' : ''}
${state.coldStart ? `
冷启动:只回写你人工勾选 / 填写并确认的题目,来源记 coldstart,比抓第三方干净。
` : ''} ${state.showSearch ? searchHtml() : ''}
${list}
论文工具
${tools}
${summary} ${quotaCard} ${pointsCard}
题库命中不扣积分;AI 兜底按次扣。内容仅供参考,本工具不会自动提交。
`; } function accountSectionHtml() { if (state.accountToken && state.account && state.account.user) { return `
账号 / 积分
${esc(accountName())} · 积分 ${state.points}AI ${state.aiCost} 分/次
题库命中 0 分;AI 兜底每次 ${state.aiCost} 分;每日登录送 ${state.dailyGrant} 分。
`; } return `
账号 / 积分
不登录也能用免费额度;注册送 ${state.welcomePoints} 积分。题库命中 0 分,AI 兜底每次 ${state.aiCost} 分。
`; } function settingsHtml() { return `
本地 ${state.bankCount} 题 · 共享 ${state.sharedCount} 题
题库格式:CSV/JSON/TXT;CSV 列名用「题目、选项、答案、题型」,选项用 | 分隔;Excel 先另存为 CSV。下载示例题库
${accountSectionHtml()}
旧版设备卡密(旧卡仍可用)
${esc(licenseQuotaText())}${esc(licenseExpiryText())}
${licensePlanButtons()}
${state.license && state.license.token ? '' : ''}
旧卡绑定设备、按次数扣;新用户请用上面的账号积分。
普通用户不用填 Key,默认走后台。后台地址和论文站地址由我们配置好即可。
`; } function coldCountConfirmed() { return state.questions.filter( (q) => (state.coldPicks[q.id] && state.coldPicks[q.id].length) || (state.coldTexts[q.id] || '').trim() ).length; } function buildListHtml(questions) { if (!questions.length) return '
还没有扫描题目。点「扫描题目」开始。
'; if (!state.coldStart) { return questions .map( (question) => `
${question.index}. [${TYPE_LABEL[question.type]}] ${esc(truncate(question.title, 24))}${questionStatusText(question)}
`, ) .join(''); } const letterOf = (i) => String.fromCharCode(65 + i); return questions .map((q) => { const picks = state.coldPicks[q.id] || []; let opts = ''; if (q.options.length) { opts = q.options .map((o, i) => { const on = picks.includes(i); const label = q.type === 'judge' ? o.text : `${letterOf(i)}. ${o.text}`; return ``; }) .join(''); } const textVal = state.coldTexts[q.id] || ''; const textInput = q.type === 'blank' || q.type === 'subjective' ? `` : ''; return `
${q.index}. [${TYPE_LABEL[q.type]}] ${esc(truncate(q.title, 30))}
${opts}${textInput}
`; }) .join(''); } function coldPick(qid, idx, multi) { const cur = state.coldPicks[qid] || []; let next; if (multi) { next = cur.includes(idx) ? cur.filter((x) => x !== idx) : cur.concat(idx); } else { next = cur.includes(idx) ? [] : [idx]; } state.coldPicks[qid] = next; renderPanel(); } async function coldBackfillAll() { if (state.coldBackfilling) return; const server = String(cfg.bankServer || '').trim().replace(/\/+$/, ''); if (!server) { toast('请先在设置里填写自有题库服务地址', 'warning'); return; } const items = []; for (const q of state.questions) { const picks = state.coldPicks[q.id] || []; const text = (state.coldTexts[q.id] || '').trim(); let answer = []; if (picks.length) { answer = picks.map((i) => (q.options[i] ? q.options[i].text : '')).filter(Boolean); } else if (text) { answer = [text]; } if (!answer.length) continue; items.push({ question: q.title, options: q.options.map((o) => o.text), answer, type: TIKU_TYPE[q.type] == null ? 0 : TIKU_TYPE[q.type], source: 'coldstart', hint: '', }); } if (!items.length) { toast('还没有勾选 / 填写任何确认答案', 'warning'); return; } state.coldBackfilling = true; renderPanel(); let ok = 0; for (const it of items) { try { const res = await httpJson({ url: `${server}/backfill`, method: 'POST', data: it, timeout: 8000 }); if (res.status === 200 && res.data && res.data.ok) ok += 1; } catch (e) { // keep counting; report partial at the end } } state.coldBackfilling = false; renderPanel(); toast(`已回写 ${ok}/${items.length} 题到自有题库(来源 coldstart)`, ok === items.length ? 'success' : 'info'); track('cold_backfill', { ok, total: items.length }); } function renderPanel() { if (!state.body) return; state.body.innerHTML = state.showSettings ? settingsHtml() : mainHtml(); if (!state.showSettings && state.lastSummary && !state.summaryTracked) { state.summaryTracked = true; track('hook_impression_summary', state.lastSummary); } } function toast(message, type = 'info') { if (!state.shadow) return; const box = state.shadow.querySelector('.qoda-toasts'); if (!box) return; const item = document.createElement('div'); item.className = `qoda-toast ${type}`; item.textContent = message; box.appendChild(item); setTimeout(() => item.remove(), 3200); } function jumpTo(question) { question.el.scrollIntoView({ behavior: 'smooth', block: 'center' }); question.el.style.outline = '2px solid #2563eb'; question.el.style.outlineOffset = '4px'; setTimeout(() => { question.el.style.outline = ''; question.el.style.outlineOffset = ''; }, 1500); } function saveSettings() { const shadow = state.shadow; cfg.serverBase = shadow.querySelector('#qoda-server').value.trim() || DEFAULTS.serverBase; cfg.thesisUrl = shadow.querySelector('#qoda-thesis').value.trim() || DEFAULTS.thesisUrl; cfg.mode = shadow.querySelector('#qoda-mode').value; cfg.directKey = shadow.querySelector('#qoda-key').value.trim(); cfg.hooks = shadow.querySelector('#qoda-hooks').checked; cfg.preferBank = shadow.querySelector('#qoda-prefer-bank').checked; cfg.sharedBank = shadow.querySelector('#qoda-shared').checked; cfg.cacheAi = shadow.querySelector('#qoda-cache-ai').checked; cfg.syncBank = shadow.querySelector('#qoda-sync-bank').checked; cfg.adapterUrl = shadow.querySelector('#qoda-adapter-url').value.trim(); cfg.useAdapter = shadow.querySelector('#qoda-use-adapter').checked; cfg.coldStart = shadow.querySelector('#qoda-cold-start').checked; cfg.bankServer = shadow.querySelector('#qoda-bank-server').value.trim() || DEFAULTS.bankServer; cfg.telemetry = shadow.querySelector('#qoda-telemetry').checked; gmSet(CFG_KEY, { ...cfg }); state.showSettings = false; renderPanel(); toast('设置已保存', 'success'); refreshConfig(); } async function onPanelClick(event) { const target = event.target.closest('[data-action]'); if (!target) return; const action = target.dataset.action; if (action === 'toggle') { const opening = state.panel.classList.contains('qoda-hidden'); state.panel.classList.toggle('qoda-hidden'); if (opening) track('panel_open', { platform: state.adapter.id }); } else if (action === 'close') { state.panel.classList.add('qoda-hidden'); } else if (action === 'scan') { scanQuestions(false); } else if (action === 'answer') { answerAll(); } else if (action === 'stop-answer') { state.stopRequested = true; toast('已请求停止,当前这几道题答完就停', 'info'); } else if (action === 'upload') { const input = state.shadow.querySelector('#qoda-bank-file'); if (input) input.click(); } else if (action === 'search') { state.showSearch = !state.showSearch; if (!state.showSearch) { state.searchQuery = ''; state.searchResults = []; } renderPanel(); if (state.showSearch) { const input = state.body.querySelector('[data-role="search-input"]'); if (input) setTimeout(() => input.focus(), 0); } } else if (action === 'search-result') { const key = target.dataset.key; const item = state.searchResults.find((entry) => (entry.record.key || entry.record.id) === key); if (!item) return; const record = item.record; const norm = bankNorm(record.title); const match = state.questions.find( (question) => bankNorm(question.title) === norm || bankSimilarity(bankNorm(question.title), norm) >= 0.82, ); if (match) { const answer = bankAnswerForQuestion(record, match); const filled = fillQuestion(match, answer); match.answer = answer; match.source = item.source; match.status = filled ? 'done' : 'fail'; renderPanel(); jumpTo(match); toast(filled ? '已填入匹配的题目' : '找到答案,但页面没有可填的控件', filled ? 'success' : 'warning'); track('bank_search_fill', { source: item.source }); } else { const answer = Array.isArray(record.answer) ? record.answer.join('\n') : String(record.answer ?? ''); try { await navigator.clipboard.writeText(answer); toast('答案已复制', 'success'); } catch { toast(answer, 'info'); } track('bank_search_copy', { source: item.source }); } } else if (action === 'clear-bank') { if (!window.confirm('确定清空本地题库?共享题库不受影响。')) return; try { await bankClear(); await refreshBankCount(); toast('本地题库已清空', 'success'); } catch (error) { toast(`清空失败:${error.message}`, 'error'); } } else if (action === 'license-clear') { clearLicense(); } else if (action === 'redeem') { const input = state.body.querySelector('#qoda-license-key'); if (input) { await redeemLicense(input.value); input.value = ''; } } else if (action === 'pay') { openPay(target.dataset.plan || ''); } else if (action === 'account-login') { const usernameInput = state.body.querySelector('#qoda-account-username'); const passwordInput = state.body.querySelector('#qoda-account-password'); try { await loginAccount(usernameInput ? usernameInput.value : '', passwordInput ? passwordInput.value : ''); } catch (error) { toast(`登录失败:${accountErrorText(error.message)}`, 'error'); } } else if (action === 'account-logout') { await logoutAccount(); } else if (action === 'account-redeem') { const input = state.body.querySelector('#qoda-account-code'); if (input) { try { await redeemPoints(input.value); input.value = ''; } catch (error) { toast(`兑换失败:${accountErrorText(error.message)}`, 'error'); } } } else if (action === 'account-page') { openAccountPage(); } else if (action === 'settings') { state.showSettings = true; renderPanel(); } else if (action === 'back') { state.showSettings = false; renderPanel(); } else if (action === 'save') { saveSettings(); } else if (action === 'jump') { const question = state.questions.find((item) => item.id === target.dataset.id); if (question) jumpTo(question); } else if (action === 'thesis-summary') { track('hook_click_summary_thesis', { platform: state.adapter.id }); } else if (action === 'cold-toggle') { state.coldStart = !state.coldStart; if (!state.coldStart) { state.coldPicks = {}; state.coldTexts = {}; } renderPanel(); } else if (action === 'cold-pick') { const qid = target.dataset.qid; const idx = parseInt(target.dataset.idx, 10); const q = state.questions.find((x) => x.id === qid); if (q) coldPick(qid, idx, q.type === 'multiple'); } else if (action === 'cold-backfill') { coldBackfillAll(); } else if (action === 'thesis-tool') { track(`thesis_click_${target.dataset.tool}`, { platform: state.adapter.id }); } } function onPanelInput(event) { const coldText = event.target.closest('[data-role="cold-text"]'); if (coldText) { state.coldTexts[coldText.dataset.qid] = coldText.value; return; } const input = event.target.closest('[data-role="search-input"]'); if (!input) return; state.searchQuery = input.value; clearTimeout(state.searchTimer); state.searchTimer = setTimeout(() => searchBank(state.searchQuery), 280); } function mountUI(adapter, exam) { state.adapter = adapter; state.exam = exam; const host = document.createElement('div'); host.id = 'qoda-host'; host.style.cssText = 'all: initial; position: fixed; z-index: 2147483646; right: 0; bottom: 0; width: 0; height: 0;'; document.documentElement.appendChild(host); const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = `
${LOGO_SVG}学伴 v${VERSION} 内测
`; state.host = host; state.shadow = shadow; state.panel = shadow.querySelector('.qoda-panel'); state.body = shadow.querySelector('.qoda-body'); shadow.addEventListener('click', onPanelClick); shadow.addEventListener('input', onPanelInput); const fileInput = shadow.querySelector('#qoda-bank-file'); fileInput.addEventListener('change', async () => { const file = fileInput.files && fileInput.files[0]; if (file) await importBankFile(file); fileInput.value = ''; }); renderPanel(); } // ---------- boot ---------- function isExamPage() { try { if (new URLSearchParams(location.search).get('qoda_force') === '1') return false; } catch { // ignore malformed query strings } const href = location.href; if (/final-exam|final_exam|\/kaoshi\b|kaoshi\.|\/exam\b/i.test(href)) return true; // 只认 URL / 标题 / 页面顶部,不扫正文选项文字:避免选项里出现「期末考试」被误判成考试页 const title = document.title || ''; const top = document.body ? (document.body.innerText || '').slice(0, 400) : ''; if (/考试剩余时间|正在考试|人脸识别|监考中|考试须知|考试倒计时/.test(title + ' ' + top)) return true; if (/期末考试|终考/.test(title)) return true; return false; } // 登录页检测:客户卡在没适配的登录界面时,主动上报(用于自动反省) function isLoginPage() { const pathLike = location.pathname + ' ' + location.search; if (/login|signin|sign-in|denglu/i.test(pathLike)) return true; const head = (document.body ? (document.body.innerText || '') : '').slice(0, 1200); return /(请先登录|登录后|用户登录|账号登录|扫码登录|统一身份认证)/.test(head) && /(密码|password|验证码)/i.test(head); } async function refreshConfig() { try { const res = await httpJson({ url: `${base()}/api/config` }); if (res.status !== 200 || !res.data || !res.data.ok) return; state.serverMode = res.data.mode || ''; state.payUrl = res.data.payUrl || state.payUrl || ''; state.plans = Array.isArray(res.data.plans) ? res.data.plans : state.plans; state.freeQuota = res.data.freeQuota || state.freeQuota; state.aiCost = res.data.aiCost != null ? res.data.aiCost : state.aiCost; state.welcomePoints = res.data.welcomePoints != null ? res.data.welcomePoints : state.welcomePoints; state.dailyGrant = res.data.dailyGrant != null ? res.data.dailyGrant : state.dailyGrant; state.accountUrl = res.data.accountUrl || state.accountUrl; if (!state.license && !state.quota) { state.quota = { type: 'free', limit: state.freeQuota, remaining: state.freeQuota }; } if (res.data.thesisUrl && !gmGet(CFG_KEY, null)) cfg.thesisUrl = res.data.thesisUrl; if (typeof res.data.bankCount === 'number') state.sharedCount = res.data.bankCount; renderPanel(); } catch { // backend offline is allowed; the panel still works in mock/direct modes } } function observeQuestions() { if (!window.MutationObserver || !document.body) return; let timer = 0; const observer = new MutationObserver(() => { if (state.questions.length) { observer.disconnect(); return; } clearTimeout(timer); timer = setTimeout(() => { if (!state.questions.length) scanQuestions(true); if (state.tryScan) state.tryScan(); if (state.questions.length) observer.disconnect(); }, 1200); }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => observer.disconnect(), 30000); } function boot() { const adapter = pickAdapter(); const exam = isExamPage(); const savedLicense = gmGet(LICENSE_KEY, null); if (savedLicense && savedLicense.token) state.license = savedLicense; const savedAccount = gmGet(ACCOUNT_KEY, null); if (savedAccount && savedAccount.token) { state.account = savedAccount; state.accountToken = savedAccount.token; state.points = savedAccount.points || 0; } state.coldStart = !!cfg.coldStart; mountUI(adapter, exam); track('boot', { platform: adapter.id, exam, host: location.hostname, ver: VERSION }); refreshBankCount(); refreshLicense(); refreshAccount(); if (isLoginPage()) { // 关键反馈:客户停在未适配的登录界面 track('login_page', { host: location.hostname, adapter: adapter.id, title: (document.title || '').slice(0, 80) }); } let autoTimer = 0; const tryScan = () => { if (!state.questions.length) scanQuestions(true); // 勤报:每次扫描结果都上报,前期靠它判断「装了就走 / 界面适配了却没帮到客户」 track('scan_result', { host: location.hostname, adapter: state.adapter ? state.adapter.id : 'unknown', count: state.questions.length, exam: !!state.exam, login: isLoginPage(), }); // 傻瓜式作答:识别到题目就自动答,无需客户点按钮 if (cfg.autoAnswer && !state.running && !state.autoAnswered && state.questions.some((q) => q.status !== 'done' && q.type !== 'subjective')) { clearTimeout(autoTimer); autoTimer = setTimeout(() => { if (state.running || state.autoAnswered) return; state.autoAnswered = true; track('auto_answer', { count: state.questions.length, host: location.hostname }); void answerAll(); }, Number(cfg.autoAnswerDelay) || 800); } }; state.tryScan = tryScan; setTimeout(tryScan, 300); setTimeout(tryScan, 1500); setTimeout(tryScan, 3500); observeQuestions(); if (cfg.hooks) maybeSceneHook(); refreshConfig(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();