// ==UserScript== // @name 北京执业药师-金航联平台 (Pro) // @namespace http://tampermonkey.net/ // @version 2.0.0 // @description 金航联平台自动化刷课脚本 v2.0 — 模块化重构,增强性能/可靠性/UI // @author Coren + fix // @match *://bjzyys.mtnet.com.cn/* // @run-at document-start // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_setClipboard // @connect api.deepseek.com // @license CC BY-NC-SA 4.0 // ==/UserScript== (function () { 'use strict'; // ========================================================================= // CONFIG // ========================================================================= const CFG = { MODELS: { flash: 'deepseek-v4-flash', chat: 'deepseek-chat' }, DEFAULT_MODEL: 'flash', API_URL: 'https://api.deepseek.com/chat/completions', API_PROMPT: '你是一个乐于助人的问题回答助手。聚焦于执业药师相关的内容,请根据用户提出的问题,提供准确、清晰的解答。注意回答时仅仅包括答案,不允许其他额外任何解释,输出为一行一道题目的答案,答案只能是题目序号:字母选项,不能包含文字内容。单选输出示例:1.A。多选输出示例:1.ABC。', KEEPALIVE_INTERVAL: 500, URL_CHECK_INTERVAL: 1500, MUTE_SYNC_INTERVAL: 2000, CHAPTER_CHECK_INTERVAL: 5000, API_TIMEOUT: 30000, API_RETRY: 3, API_RETRY_DELAY: 1000, }; // ========================================================================= // STATE // ========================================================================= const S = { get serviceActive() { return GM_getValue('mtnet_service_active', true); }, set serviceActive(v) { GM_setValue('mtnet_service_active', v); }, get muted() { return GM_getValue('mtnet_is_muted', true); }, set muted(v) { GM_setValue('mtnet_is_muted', v); }, get apiKey() { return GM_getValue('deepseek_api_key', ''); }, set apiKey(v) { GM_setValue('deepseek_api_key', v); }, get autoExam() { return GM_getValue('mtnet_auto_exam_mode', false); }, set autoExam(v) { GM_setValue('mtnet_auto_exam_mode', v); }, get model() { return GM_getValue('mtnet_model', CFG.DEFAULT_MODEL); }, set model(v) { GM_setValue('mtnet_model', v); }, get panelCollapsed() { return GM_getValue('mtnet_panel_collapsed', false); }, set panelCollapsed(v) { GM_setValue('mtnet_panel_collapsed', v); }, get examHistory() { return JSON.parse(GM_getValue('mtnet_exam_history', '[]')); }, set examHistory(v) { GM_setValue('mtnet_exam_history', JSON.stringify(v)); }, get logLevel() { return GM_getValue('mtnet_log_level', 1); }, set logLevel(v) { GM_getValue('mtnet_log_level', v); }, // runtime videoRef: null, keepAliveTimer: null, chapterTimer: null, muteTimer: null, urlCheckTimer: null, panelCreated: false, examPanelCreated: false, }; // ========================================================================= // LOGGER // ========================================================================= const LOG = { _fmt(level, tag, msg) { return `[${new Date().toLocaleTimeString()}] [${level}] [${tag}] ${msg}`; }, debug(tag, msg, ...a) { if (S.logLevel >= 0) console.log(this._fmt('DBG', tag, msg), ...a); }, info(tag, msg, ...a) { if (S.logLevel >= 1) console.log(this._fmt('INF', tag, msg), ...a); }, warn(tag, msg, ...a) { if (S.logLevel >= 2) console.warn(this._fmt('WRN', tag, msg), ...a); }, error(tag, msg, ...a) { if (S.logLevel >= 3) console.error(this._fmt('ERR', tag, msg), ...a); }, }; // ========================================================================= // UTILS // ========================================================================= const $ = (sel, root) => (root || document).querySelector(sel); const $$ = (sel, root) => [...(root || document).querySelectorAll(sel)]; const delay = (ms) => new Promise((r) => setTimeout(r, ms)); const origin = () => window.location.origin; function findElementByText(selector, text) { return $$(selector).find((el) => el.innerText?.trim().includes(text.trim())); } function clickEl(el, desc) { if (!el || typeof el.click !== 'function') { LOG.warn('util', `点击失败: ${desc}`); return false; } LOG.debug('util', `点击: ${desc}`); el.click(); return true; } function clickElEnhanced(el, desc) { if (!el) { LOG.warn('util', `增强点击失败: ${desc}`); return false; } LOG.debug('util', `增强点击: ${desc}`); const input = $('input[type="radio"], input[type="checkbox"]', el); if (input) { input.click(); return true; } el.click(); try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); } catch {} return true; } // ========================================================================= // ANTI-DETECTION (must run at document-start) // ========================================================================= const AntiDetect = { init() { this._hijackVisibility(); this._hijackHasFocus(); this._interceptEvents(); this._interceptPause(); this._setupFocusRestore(); LOG.info('anti', '防检测机制已初始化'); }, _hijackVisibility() { const def = (obj, prop, val) => { try { Object.defineProperty(obj, prop, { get: () => val, configurable: false }); } catch { try { Object.defineProperty(obj, prop, { get: () => val, configurable: true }); } catch (e) { LOG.error('anti', `hijack ${prop} 失败`, e); } } }; def(document, 'visibilityState', 'visible'); def(document, 'hidden', false); }, _hijackHasFocus() { try { Document.prototype.hasFocus = () => true; } catch (e) { LOG.error('anti', 'hijack hasFocus 失败', e); } }, _interceptEvents() { document.addEventListener('visibilitychange', (e) => e.stopImmediatePropagation(), true); window.addEventListener('blur', (e) => e.stopImmediatePropagation(), true); }, _interceptPause() { document.addEventListener('pause', (e) => { if (!(e.target instanceof HTMLVideoElement)) return; e.stopImmediatePropagation(); if (!e.target.ended) e.target.play().catch(() => {}); }, true); }, _setupFocusRestore() { window.addEventListener('focus', () => { for (const v of $$('video')) { if (v.paused && !v.ended) v.play().catch(() => {}); } }); }, }; // ========================================================================= // VIDEO CONTROLLER // ========================================================================= const VideoCtrl = { _cachedRef: null, start() { KeepAlive.start(); MuteSync.start(); this._observeDOM(); }, stop() { KeepAlive.stop(); MuteSync.stop(); }, async find(timeout = 15000) { if (this._cachedRef && !this._cachedRef.isConnected) this._cachedRef = null; if (this._cachedRef) return this._cachedRef; const t0 = Date.now(); while (Date.now() - t0 < timeout) { const v = $('video'); if (v) { this._cachedRef = v; return v; } const iframe = $('iframe'); if (iframe?.contentWindow) { try { const iv = iframe.contentWindow.document.querySelector('video'); if (iv) { this._cachedRef = iv; return iv; } } catch {} } await delay(500); } return null; }, _observeDOM() { const obs = new MutationObserver(() => { this._cachedRef = null; }); obs.observe(document.body || document.documentElement, { childList: true, subtree: true }); }, }; // ========================================================================= // KEEP-ALIVE // ========================================================================= const KeepAlive = { start() { if (S.keepAliveTimer) return; S.keepAliveTimer = setInterval(() => { for (const v of $$('video')) { if (v.paused && !v.ended && v.readyState >= 2) v.play().catch(() => {}); } }, CFG.KEEPALIVE_INTERVAL); }, stop() { if (S.keepAliveTimer) { clearInterval(S.keepAliveTimer); S.keepAliveTimer = null; } }, }; // ========================================================================= // MUTE SYNC // ========================================================================= const MuteSync = { start() { this._tick(); }, stop() { if (S.muteTimer) { clearTimeout(S.muteTimer); S.muteTimer = null; } }, async _tick() { if (!S.serviceActive || !window.location.href.includes('/video/')) { S.muteTimer = setTimeout(() => this._tick(), CFG.MUTE_SYNC_INTERVAL); return; } try { const v = await VideoCtrl.find(); if (v && v.muted !== S.muted) v.muted = S.muted; } catch (e) { LOG.warn('mute', '同步异常', e); } S.muteTimer = setTimeout(() => this._tick(), CFG.MUTE_SYNC_INTERVAL); }, }; // ========================================================================= // API CLIENT // ========================================================================= const Api = { async ask(questions, modelKey) { const model = CFG.MODELS[modelKey || S.model] || CFG.MODELS.flash; let lastErr; for (let i = 0; i < CFG.API_RETRY; i++) { try { const result = await this._request(model, questions); return result; } catch (e) { lastErr = e; LOG.warn('api', `请求失败 (${i + 1}/${CFG.API_RETRY}): ${e.message}`); if (i < CFG.API_RETRY - 1) await delay(CFG.API_RETRY_DELAY * (i + 1)); } } throw lastErr; }, _request(model, content) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('API 请求超时')), CFG.API_TIMEOUT); GM_xmlhttpRequest({ method: 'POST', url: CFG.API_URL, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${S.apiKey}` }, data: JSON.stringify({ model, messages: [ { role: 'system', content: CFG.API_PROMPT }, { role: 'user', content }, ], stream: false, thinking: { type: 'disabled' }, }), onload(res) { clearTimeout(timer); if (res.status === 200) { try { const r = JSON.parse(res.responseText); resolve(r.choices[0].message.content); } catch (e) { reject(new Error('解析响应失败')); } } else { reject(new Error(`API 状态码: ${res.status}`)); } }, onerror(e) { clearTimeout(timer); reject(new Error('网络请求错误')); }, }); }); }, }; // ========================================================================= // EXAM ENGINE // ========================================================================= const ExamEngine = { extract() { const qs = $$('div[id^="q"]'); if (!qs.length) { LOG.error('exam', '未找到题目容器'); return null; } let text = ''; let n = 1; for (const q of qs) { const allText = q.innerText; const opts = $$('.el-radio__label, .el-checkbox__label', q); let end = allText.length; for (const o of opts) { const idx = allText.indexOf(o.innerText); if (idx !== -1 && idx < end) end = idx; } const stem = allText.substring(0, end).trim().replace(/^\d+\.?\s*/, ''); if (!stem) continue; text += `${n}. ${stem}\n`; for (const o of opts) text += o.innerText + '\n'; text += '\n'; n++; } LOG.info('exam', `提取 ${n - 1} 道题`); return text || null; }, parseAnswers(answerText) { const map = new Map(); answerText.trim().split('\n').forEach((line) => { const m = line.match(/(\d+)\s*\.\s*([A-Z]+)/i); if (m) map.set(parseInt(m[1], 10), m[2].toUpperCase()); }); return map; }, async fill(answers) { const map = typeof answers === 'string' ? this.parseAnswers(answers) : answers; const qs = $$('div[id^="q"]'); for (let i = 0; i < qs.length; i++) { const q = qs[i]; const num = i + 1; if (!map.has(num)) { LOG.warn('exam', `第 ${num} 题无答案`); continue; } const chars = map.get(num); LOG.debug('exam', `填写第 ${num} 题: ${chars}`); for (const ch of chars.split('')) { const labels = $$('.el-radio, .el-checkbox', q); let ok = false; for (const lb of labels) { const lbText = $('.el-radio__label, .el-checkbox__label', lb)?.innerText?.trim(); if (lbText?.startsWith(ch)) { clickElEnhanced(lb, `Q${num} 选项 ${ch}`); await delay(200); ok = true; } } if (!ok) LOG.warn('exam', `Q${num} 未找到选项 ${ch}`); } } }, findSubmitBtn() { for (const kw of ['交卷', '提交试卷', '提交']) { const btn = findElementByText('span, button, a, div, .btn, .el-button', kw); if (btn) return btn; } return $('.nextClick, .nextBtn, .loginBtn, .btn-submit, .submit-btn, .el-button--primary, button[type="submit"]'); }, async confirmDialog() { for (let i = 0; i < 40; i++) { await delay(250); for (const sel of [ '.el-message-box__btns .el-button--primary', '.el-dialog__footer .el-button--primary', '.el-message-box__btns button', '.el-dialog__footer button', ]) { for (const btn of $$(sel)) { const t = btn.innerText?.trim(); if (['确定', '确认', '确认交卷', '是', 'OK'].includes(t)) { LOG.info('exam', `确认按钮: ${t}`); btn.click(); return true; } } } for (const kw of ['确定', '确认', '确认交卷']) { const btn = findElementByText('button, .el-button, .btn', kw); if (btn?.closest('.el-message-box, .el-dialog, .modal, .popup, .el-message')) { btn.click(); return true; } } } LOG.warn('exam', '未找到确认对话框'); return false; }, saveHistory(text, answers) { const h = S.examHistory; h.push({ time: new Date().toISOString(), url: location.href, questions: text, answers }); if (h.length > 50) h.splice(0, h.length - 50); S.examHistory = h; }, }; // ========================================================================= // NOTIFICATION // ========================================================================= const Notify = { show(title, text, onclick) { try { GM_notification({ title, text, timeout: 5000, onclick }); } catch {} }, }; // ========================================================================= // UI — CONTROL PANEL // ========================================================================= const UI = { create() { if (S.panelCreated) return; S.panelCreated = true; this._injectStyles(); const panel = this._buildPanel(); document.body.appendChild(panel); requestAnimationFrame(() => { const w = panel.offsetWidth || 240; const h = panel.offsetHeight || 260; panel.style.left = Math.max(0, window.innerWidth - w - 20) + 'px'; panel.style.top = Math.max(0, window.innerHeight - h - 20) + 'px'; }); this._bindEvents(panel); Draggable.init(panel, $('#control-panel-header', panel)); }, _injectStyles() { GM_addStyle(` #control-panel{position:fixed;width:240px;background:#fff;border:1px solid #e0e0e0;border-radius:10px;box-shadow:0 4px 20px rgba(0,0,0,.15);z-index:10000;font-family:'Microsoft YaHei',sans-serif;transition:height .25s,box-shadow .25s;overflow:hidden} #control-panel:hover{box-shadow:0 6px 28px rgba(0,0,0,.2)} #control-panel-header{padding:8px 12px;background:linear-gradient(135deg,#007bff,#0056b3);color:#fff;cursor:move;user-select:none;font-size:13px;display:flex;align-items:center;justify-content:space-between} #control-panel-header .panel-title{font-weight:600;letter-spacing:.5px} .panel-header-btns{display:flex;gap:4px} .panel-header-btns button{background:rgba(255,255,255,.2);border:none;color:#fff;width:24px;height:24px;border-radius:4px;cursor:pointer;font-size:14px;line-height:24px;text-align:center;padding:0;transition:background .2s} .panel-header-btns button:hover{background:rgba(255,255,255,.35)} #control-panel-content{padding:12px;display:flex;flex-direction:column;gap:8px;transition:max-height .25s,opacity .2s,padding .25s;overflow:hidden} #control-panel-content.collapsed{max-height:0!important;padding:0 12px!important;opacity:0;pointer-events:none} .panel-btn{padding:7px 14px;font-size:13px;color:#fff;border:none;border-radius:6px;cursor:pointer;transition:all .2s;width:100%;box-sizing:border-box;font-weight:500} .panel-btn:hover{filter:brightness(1.1);transform:translateY(-1px)} .panel-btn:active{transform:translateY(0)} .service-btn-active{background:linear-gradient(135deg,#28a745,#1e7e34)} .service-btn-paused{background:linear-gradient(135deg,#dc3545,#b02a37)} .mute-btn-on{background:linear-gradient(135deg,#ffc107,#e0a800);color:#333} .mute-btn-off{background:linear-gradient(135deg,#6c757d,#5a6268)} .panel-divider{width:100%;height:1px;background:#eee;margin:2px 0} .setting-row{display:flex;flex-direction:column;width:100%;gap:4px} .setting-row>label{font-size:12px;font-weight:600;color:#666} #api-key-input{width:100%;padding:6px 8px;border-radius:6px;border:1px solid #ddd;box-sizing:border-box;font-size:12px;transition:border-color .2s} #api-key-input:focus{border-color:#007bff;outline:none;box-shadow:0 0 0 2px rgba(0,123,255,.15)} .model-select{width:100%;padding:5px 8px;border-radius:6px;border:1px solid #ddd;box-sizing:border-box;font-size:12px} .config-btns{display:flex;gap:4px} .config-btns button{flex:1;padding:5px;font-size:11px;border:1px solid #ddd;border-radius:4px;cursor:pointer;background:#f8f9fa} .config-btns button:hover{background:#e9ecef} .log-level-row{display:flex;align-items:center;gap:6px} .log-level-row label{font-size:11px;color:#999} .log-level-row select{font-size:11px;padding:2px 4px;border-radius:4px;border:1px solid #ddd} `); }, _buildPanel() { const collapsed = S.panelCollapsed; const div = document.createElement('div'); div.id = 'control-panel'; div.innerHTML = `