// ==UserScript== // @name 智学 - 学习通自动答题+倍速 // @namespace zhixue-assistant // @version 3.6.13 // @description 全自动刷课:倍速播放+自动下一节+AI答题+字体解密+AI总结+红金共青团风格v6 // @icon https://s.coze.cn/image/Cc2jwhdqgjU/ // @author zhixue // @match *://*.chaoxing.com/* // @match *://*.edu.cn/* // @match *://*.nbdlib.cn/* // @match *://*.hnsyu.net/* // @match *://*.ac.cn/* // @match *://*.mooc1.cn/* // @match *://*.mooc1-1.cn/* // @match *://*.mooc1-2.cn/* // @match *://*/* // @include * // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_notification // @grant GM_download // @grant GM_openInTab // @connect api.zhixue.icu // @connect forestpolice.org // @connect chaoxing.com // @connect static.chaoxing.com // @connect scriptcat.org // @connect pan-yz.chaoxing.com // @connect mooc1.chaoxing.com // @connect d0.ananas.chaoxing.com // @connect contestyd.chaoxing.com // @connect pan-yz.chaoxing.com // @connect cdnjs.cloudflare.com // @run-at document-start // @license MIT // ==/UserScript== (function() { 'use strict'; // ========================================== // PHASE 1: document-start — 学习通视频倍速锁定解除 // 学习通使用 Ext.define("ans.VideoJs") 限制视频倍速, // 必须在页面脚本之前覆盖构造函数,从源头解除限制。 // ========================================== (function(){ if (location.href.indexOf('/ananas/modules/video') === -1) return; function _hackSpeedLock(){ try { if (typeof videojs === 'undefined' || typeof Ext === 'undefined') return; Ext.define('ans.VideoJs', { override: 'ans.VideoJs', constructor: function(b) { b = b || {}; var e = this; e.addEvents(['seekstart']); e.mixins.observable.constructor.call(e, b); videojs(b.videojs, e.params2VideoOpt(b.params), function(){}); Ext.fly(b.videojs).on('contextmenu', function(f){ f.preventDefault(); }); Ext.fly(b.videojs).on('keydown', function(f){ if ([32,37,39,107].indexOf(f.keyCode) > -1) f.preventDefault(); }); } }); } catch(e){ console.warn('[智学] Ext.define override:', e); } } _hackSpeedLock(); document.addEventListener('readystatechange', _hackSpeedLock); window.addEventListener('load', _hackSpeedLock); })(); /** * Combined by jsDelivr. * Original files: * - /gh/photopea/Typr.js@15aa12ffa6cf39e8788562ea4af65b42317375fb/src/Typr.min.js * - /gh/photopea/Typr.js@f4fcdeb8014edc75ab7296bd85ac9cde8cb30489/src/Typr.U.min.js * - /npm/blueimp-md5@2.19.0/js/md5.min.js * * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files */ /** * Minified by jsDelivr using Terser v5.10.0. * Original file: /gh/photopea/Typr.js@15aa12ffa6cf39e8788562ea4af65b42317375fb/src/Typr.js * * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files */ var Typr={parse:function(r){var e=function(r,e,a,t){Typr.B;var n=Typr.T,o={cmap:n.cmap,head:n.head,hhea:n.hhea,maxp:n.maxp,hmtx:n.hmtx,name:n.name,"OS/2":n.OS2,post:n.post,loca:n.loca,kern:n.kern,glyf:n.glyf,"CFF ":n.CFF,"SVG ":n.SVG},i={_data:r,_index:e,_offset:a};for(var s in o){var d=Typr.findTable(r,s,a);if(d){var u=d[0],h=t[u];null==h&&(h=o[s].parseTab(r,u,d[1],i)),i[s]=t[u]=h}}return i},a=Typr.B,t=new Uint8Array(r),n={};if("ttcf"==a.readASCII(t,0,4)){var o=4;a.readUshort(t,o);o+=2;a.readUshort(t,o);o+=2;var i=a.readUint(t,o);o+=4;for(var s=[],d=0;d>8&255,r[e+1]=255&a},readUshorts:function(r,e,a){for(var t=[],n=0;n>24&255,r[e+1]=a>>16&255,r[e+2]=a>>8&255,r[e+3]=a>>0&255},readUint64:function(r,e){return 4294967296*Typr.B.readUint(r,e)+Typr.B.readUint(r,e+4)},readASCII:function(r,e,a){for(var t="",n=0;n255?-1:Typr.T.CFF.glyphByUnicode(r,Typr.T.CFF.tableSE[e])},readCharset:function(r,e,a){var t=Typr.B,n=[".notdef"],o=r[e];if(e++,0==o)for(var i=0;i>4,v=15&f;if(15!=l&&p.push(l),15!=v&&p.push(v),15==v)break}for(var y="",c=[0,1,2,3,4,5,6,7,8,9,".","e","e-","reserved","-","endOfNumber"],S=0;S>>1;a.searchRange=n(r,e),e+=2,a.entrySelector=n(r,e),e+=2,a.rangeShift=n(r,e),e+=2,a.endCount=o(r,e,u),e+=2*u,e+=2,a.startCount=o(r,e,u),e+=2*u,a.idDelta=[];for(var h=0;h>>1),a},parse6:function(r,e,a){var t=Typr.B;e+=2;t.readUshort(r,e);e+=2;t.readUshort(r,e);e+=2,a.firstCode=t.readUshort(r,e),e+=2;var n=t.readUshort(r,e);e+=2,a.glyphIdArray=[];for(var o=0;o=i.xMax||i.yMin>=i.yMax)return null;if(i.noc>0){i.endPts=[];for(var s=0;s>>8;0==(h&=15)&&(e=o.readFormat0(r,e,s))}return s},parseV1:function(r,e,a,t){var n=Typr.B,o=Typr.T.kern,i=(n.readFixed(r,e),n.readUint(r,e+4));e+=8;for(var s={glyph1:[],rval:[]},d=0;d65535&&a++,n.push(Typr.U.codeToGlyph(t,h))}var o=[];for(a=0;a>>1);t[a*e]<=r?s=a:n=a}return s*e},o=r.tables[s],f=o.format,i=-1;if(0==f)i=e>=o.map.length?0:o.map[e];else if(4==f){var l=-1,c=o.endCount;if(e>c[c.length-1]?l=-1:c[l=h(c,1,e)]>1)-(o.idRangeOffset.length-l)]:e+o.idDelta[l])}}else if(6==f){var u=e-o.firstCode,d=o.glyphIdArray;i=u<0||u>=d.length?0:d[u]}else{if(12!=f)throw"unknown cmap table format "+o.format;var v=o.groups;e>v[v.length-2]?i=0:(v[a=h(v,3,e)]<=e&&e<=v[a+1]&&(i=v[a+2]+(e-v[a])),-1==i&&(i=0))}var p=t["SVG "],g=t.loca;return 0==i||null!=t["CFF "]||null!=p&&null!=p.entries[i]||g[i]!=g[i+1]||-1!=[9,10,11,12,13,32,133,160,5760,8232,8233,8239,12288,6158,8203,8204,8205,8288,65279].indexOf(e)||8192<=e&&e<=8202||(i=0),i},glyphToPath:function(t,e){var r={cmds:[],crds:[]},s=t["SVG "],n=t["CFF "],a=Typr.U;if(s&&s.entries[e]){var h=s.entries[e];null!=h&&("string"==typeof h&&(h=a.SVG.toPath(h),s.entries[e]=h),r=h)}else if(n){var o=n.Private,f={x:0,y:0,stack:[],nStems:0,haveWidth:!1,width:o?o.defaultWidthX:0,open:!1};if(n.ROS){for(var i=0;n.FDSelect[i+2]<=e;)i+=2;o=n.FDArray[n.FDSelect[i+1]].Private}a._drawCFF(n.CharStrings[e],f,n,o,r)}else t.glyf&&a._drawGlyf(e,t,r);return{cmds:r.cmds,crds:r.crds}},_drawGlyf:function(t,e,r){var s=e.glyf[t];null==s&&(s=e.glyf[t]=Typr.T.glyf._parseGlyf(e,t)),null!=s&&(s.noc>-1?Typr.U._simpleGlyph(s,r):Typr.U._compoGlyph(s,e,r))},_simpleGlyph:function(t,e){for(var r=Typr.U.P,s=0;s>1,a.length=0,o=!0;else if("o3"==S||"o23"==S){a.length%2!=0&&!o&&(f=a.shift()+P),h+=a.length>>1,a.length=0,o=!0}else if("o4"==S)a.length>1&&!o&&(f=a.shift()+P,o=!0),i&&x.ClosePath(n),u+=a.pop(),x.MoveTo(n,c,u),i=!0;else if("o5"==S)for(;a.length>0;)c+=a.shift(),u+=a.shift(),x.LineTo(n,c,u);else if("o6"==S||"o7"==S)for(var F=a.length,A="o6"==S,U=0;UMath.abs(C-u)?c=T+a.shift():u=C+a.shift(),x.CurveTo(n,d,v,p,g,b,_),x.CurveTo(n,m,y,T,C,c,u));else if("o14"==S){if(a.length>0&&!o&&(f=a.shift()+r.nominalWidthX,o=!0),4==a.length){var k=a.shift(),O=a.shift(),V=a.shift(),W=a.shift(),B=M.glyphBySE(r,V),I=M.glyphBySE(r,W);Typr.U._drawCFF(r.CharStrings[B],e,r,s,n),e.x=k,e.y=O,Typr.U._drawCFF(r.CharStrings[I],e,r,s,n)}i&&(x.ClosePath(n),i=!1)}else if("o19"==S||"o20"==S){a.length%2!=0&&!o&&(f=a.shift()+P),h+=a.length>>1,a.length=0,o=!0,l+=h+7>>3}else if("o21"==S)a.length>2&&!o&&(f=a.shift()+P,o=!0),u+=a.pop(),c+=a.pop(),i&&x.ClosePath(n),x.MoveTo(n,c,u),i=!0;else if("o22"==S)a.length>1&&!o&&(f=a.shift()+P,o=!0),c+=a.pop(),i&&x.ClosePath(n),x.MoveTo(n,c,u),i=!0;else if("o25"==S){for(;a.length>6;)c+=a.shift(),u+=a.shift(),x.LineTo(n,c,u);d=c+a.shift(),v=u+a.shift(),p=d+a.shift(),g=v+a.shift(),c=p+a.shift(),u=g+a.shift(),x.CurveTo(n,d,v,p,g,c,u)}else if("o26"==S)for(a.length%2&&(c+=a.shift());a.length>0;)d=c,v=u+a.shift(),c=p=d+a.shift(),u=(g=v+a.shift())+a.shift(),x.CurveTo(n,d,v,p,g,c,u);else if("o27"==S)for(a.length%2&&(u+=a.shift());a.length>0;)v=u,p=(d=c+a.shift())+a.shift(),g=v+a.shift(),c=p+a.shift(),u=g,x.CurveTo(n,d,v,p,g,c,u);else if("o10"==S||"o29"==S){var q="o10"==S?s:r;if(0==a.length)console.log("error: empty stack");else{var Q=a.pop(),X=q.Subrs[Q+q.Bias];e.x=c,e.y=u,e.nStems=h,e.haveWidth=o,e.width=f,e.open=i,Typr.U._drawCFF(X,e,r,s,n),c=e.x,u=e.y,h=e.nStems,o=e.haveWidth,f=e.width,i=e.open}}else if("o30"==S||"o31"==S){var D=a.length,E=(L=0,"o31"==S);for(L+=D-(F=-3&D);L0&&"e"!=r[a-1]&&(r=r.slice(0,a)+" "+r.slice(a),a++,n=!0)}if(r=r.split(/\s*[\s,]\s*/).map(parseFloat),"translate"==e)1==r.length?t.translate(s,r[0],0):t.translate(s,r[0],r[1]);else if("scale"==e)1==r.length?t.scale(s,r[0],r[0]):t.scale(s,r[0],r[1]);else if("rotate"==e){var o=0,f=0;1!=r.length&&(o=r[1],f=r[2]),t.translate(s,-o,-f),t.rotate(s,-Math.PI*r[0]/180),t.translate(s,o,f)}else"matrix"==e?s=r:console.log("unknown transform: ",e);return s}function n(e,s,a){for(var o=0;o=0?1:-1)*Math.acos(Math.max(-1,Math.min(1,n)))},N=(V-E)/w,j=(W-H)/S,J=z(1,0,N,j),K=z(N,j,(-V-E)/w,(-W-H)/S);!function(t,e,r,s,n,a,h){var o=function(t,e){var r=Math.sin(e),s=Math.cos(e),n=(e=t[0],t[1]),a=t[2],h=t[3];t[0]=e*s+n*r,t[1]=-e*r+n*s,t[2]=a*s+h*r,t[3]=-a*r+h*s},f=function(t,e){for(var r=0;rn;)a-=2*Math.PI;else for(;a>>2,a=n.hb_buffer_get_glyph_positions(t,0)>>>2,h=0;h>16)+(t>>16)+(r>>16)<<16|65535&r}function f(n,t,r,e,o,u){return d((u=d(d(t,n),d(e,u)))<>>32-o,r)}function l(n,t,r,e,o,u,c){return f(t&r|~t&e,n,t,o,u,c)}function g(n,t,r,e,o,u,c){return f(t&e|r&~e,n,t,o,u,c)}function v(n,t,r,e,o,u,c){return f(t^r^e,n,t,o,u,c)}function m(n,t,r,e,o,u,c){return f(r^(t|~e),n,t,o,u,c)}function c(n,t){var r,e,o,u;n[t>>5]|=128<>>9<<4)]=t;for(var c=1732584193,f=-271733879,i=-1732584194,a=271733878,h=0;h>5]>>>e%32&255);return t}function a(n){var t=[];for(t[(n.length>>2)-1]=void 0,e=0;e>5]|=(255&n.charCodeAt(e/8))<>>4&15)+r.charAt(15&t);return e}function r(n){return unescape(encodeURIComponent(n))}function o(n){return i(c(a(n=r(n)),8*n.length))}function u(n,t){return function(n,t){var r,e=a(n),o=[],u=[];for(o[15]=u[15]=void 0,16 { const t = setTimeout(r, ms); const c = setInterval(() => { if (!Answer.running) { clearTimeout(t); clearInterval(c); r(); } }, 200); }); } function isTop() { return window.self === window.top; } // ========================================== // API 层(替代 chrome.runtime.sendMessage + background.js) // ========================================== const Api = { _tokenKey: 'zhixue_authToken', _userKey: 'zhixue_userInfo', getToken() { return GM_getValue(this._tokenKey, ''); }, setToken(token) { GM_setValue(this._tokenKey, token); }, getUserInfo() { try { return JSON.parse(GM_getValue(this._userKey, '{}')); } catch { return {}; } }, setUserInfo(data) { GM_setValue(this._userKey, JSON.stringify({ username: data.username, quota: data.quota, used: data.used, isWhitelisted: data.is_whitelisted, })); }, clearAuth() { GM_deleteValue(this._tokenKey); GM_deleteValue(this._userKey); }, // 答题 API(替代 background.js 的 SEARCH_ANSWER) async searchAnswer(question, rawQuestion, questionType, options, forceAi = false) { const token = this.getToken(); if (!token) { return { success: false, error: '未登录,请先登录', code: 'AUTH_REQUIRED' }; } return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: `${API_BASE}/api/answer`, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, data: JSON.stringify({ question, rawQuestion, questionType, options, forceAi }), timeout: 60000, onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (resp.status === 401) { this.clearAuth(); resolve({ success: false, error: '登录已过期,请重新登录', code: 'AUTH_EXPIRED' }); return; } if (resp.status === 403 && data.code === 'QUOTA_EXCEEDED') { resolve({ success: false, error: '额度不足,请充值', code: 'QUOTA_EXCEEDED' }); return; } resolve(data); } catch (e) { resolve({ success: false, error: '响应解析失败' }); } }, onerror: () => resolve({ success: false, error: '网络错误' }), ontimeout: () => resolve({ success: false, error: '请求超时' }), }); }); }, // 答题结果上报 async reportAnswer(questionHash, isCorrect, question, answer, questionType) { const token = this.getToken(); if (!token || !questionHash) return { success: false, error: '参数不完整' }; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: `${API_BASE}/api/answer/report`, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, data: JSON.stringify({ question_hash: questionHash, is_correct: isCorrect, question: question, answer: answer, question_type: questionType }), timeout: 10000, onload: (resp) => { try { resolve(JSON.parse(resp.responseText)); } catch (e) { resolve({ success: false, error: '响应解析失败' }); } }, onerror: () => resolve({ success: false, error: '网络错误' }), ontimeout: () => resolve({ success: false, error: '请求超时' }), }); }); }, // v3.4.5: 上报学习通官方显示的正确答案(置信度拉到0.95) // v3.6.7: 新增选项文字参数,路由改为 /api/answer/report async correctAnswer(correctAnswer, questionHash, questionText, questionType, correctAnswerText) { const token = this.getToken(); if (!token || !questionHash) return; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: `${API_BASE}/api/answer/report`, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, data: JSON.stringify({ question_hash: questionHash, question: questionText || '', correct_answer: correctAnswer, correct_answer_text: correctAnswerText || '', question_type: questionType || 'single' }), timeout: 10000, onload: (resp) => { try { resolve(JSON.parse(resp.responseText)); } catch (e) { resolve({}); } }, onerror: () => resolve({}), ontimeout: () => resolve({}), }); }); }, // 登录 async login(username, password) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: `${API_BASE}/api/login`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ username, password }), timeout: 15000, onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (data.success && data.data) { this.setToken(data.data.token); this.setUserInfo(data.data); } resolve(data); } catch (e) { resolve({ success: false, error: '响应解析失败' }); } }, onerror: () => resolve({ success: false, error: '网络错误' }), ontimeout: () => resolve({ success: false, error: '连接超时' }), }); }); }, // 注册 async register(username, password, inviteCode) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: `${API_BASE}/api/register`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ username, password, inviteCode, deviceFp: getDeviceFingerprint() }), timeout: 15000, onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (data.success && data.data) { this.setToken(data.data.token); this.setUserInfo(data.data); } resolve(data); } catch (e) { resolve({ success: false, error: '响应解析失败' }); } }, onerror: () => resolve({ success: false, error: '网络错误' }), ontimeout: () => resolve({ success: false, error: '连接超时' }), }); }); }, // 获取用户额度 async getUserQuota() { const token = this.getToken(); if (!token) return { loggedIn: false }; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `${API_BASE}/api/user/quota`, headers: { 'Authorization': `Bearer ${token}` }, timeout: 10000, onload: (resp) => { try { const data = JSON.parse(resp.responseText); if (data.success && data.data) { this.setUserInfo(data.data); resolve({ loggedIn: true, username: data.data.username, quota: data.data.quota, used: data.data.used, remaining: data.data.remaining, isWhitelisted: data.data.is_whitelisted, }); } else if (resp.status === 401) { this.clearAuth(); resolve({ loggedIn: false }); } else { resolve({ loggedIn: true, ...this.getUserInfo() }); } } catch (e) { resolve({ loggedIn: true, ...this.getUserInfo() }); } }, onerror: () => resolve({ loggedIn: true, ...this.getUserInfo() }), ontimeout: () => resolve({ loggedIn: true, ...this.getUserInfo() }), }); }); }, // 获取公开配置 async getConfig() { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `${API_BASE}/api/config`, timeout: 10000, onload: (resp) => { try { resolve(JSON.parse(resp.responseText)); } catch { resolve({}); } }, onerror: () => resolve({}), ontimeout: () => resolve({}), }); }); }, // 版本检查 async checkVersion() { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `${API_BASE}/api/version`, timeout: 10000, onload: (resp) => { try { resolve(JSON.parse(resp.responseText)); } catch { resolve({}); } }, onerror: () => resolve({}), ontimeout: () => resolve({}), }); }); }, // 加载字体映射表 async loadFontTable() { // 先检查缓存 const cached = GM_getValue('zhixue_fontTable', ''); const cacheTime = GM_getValue('zhixue_fontTableTime', 0); // 缓存24小时内有效 if (cached && Date.now() - cacheTime < 86400000) { try { return JSON.parse(cached); } catch {} } // 从服务器加载 return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `${API_BASE}/static/table.json`, timeout: 30000, onload: (resp) => { try { const data = JSON.parse(resp.responseText); // 缓存 try { GM_setValue('zhixue_fontTable', resp.responseText); GM_setValue('zhixue_fontTableTime', Date.now()); } catch {} resolve(data); } catch { resolve(cached ? JSON.parse(cached) : null); } }, onerror: () => { try { resolve(JSON.parse(cached)); } catch { resolve(null); } }, ontimeout: () => { try { resolve(JSON.parse(cached)); } catch { resolve(null); } }, }); }); }, // 提交反馈建议 async submitFeedback(content) { const token = this.getToken(); if (!token) return { success: false, error: '未登录' }; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: `${API_BASE}/api/feedback`, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, data: JSON.stringify({ content }), timeout: 10000, onload: (resp) => { try { resolve(JSON.parse(resp.responseText)); } catch { resolve({ success: false, error: '响应解析失败' }); } }, onerror: () => resolve({ success: false, error: '网络错误' }), ontimeout: () => resolve({ success: false, error: '请求超时' }), }); }); }, // 检查是否有回复 async checkFeedbackReply() { const token = this.getToken(); if (!token) return { success: true, hasReply: false }; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `${API_BASE}/api/feedback`, headers: { 'Authorization': `Bearer ${token}` }, timeout: 10000, onload: (resp) => { try { resolve(JSON.parse(resp.responseText)); } catch { resolve({ success: true, hasReply: false }); } }, onerror: () => resolve({ success: true, hasReply: false }), ontimeout: () => resolve({ success: true, hasReply: false }), }); }); }, }; // ========================================== // 字体解密(v5.0.0 新增,脚本猫版适配) // 学习通使用自定义字体加密题目和选项文字 // HTML源码中的Unicode字符是乱码,通过自定义字体渲染后才显示正确文字 // .innerText 拿到的是乱码Unicode,导致答案文本匹配永远失败 // 解密后替换DOM中的乱码字符,使 .innerText 返回正确汉字 // // v3.3.8 彻底重写(参考 ScriptCat #4981 + 52pojie 方案): // - _replaceEncryptedText: 移除早期return,始终全文档扫描,不修改class // - _extractFontBase64: 收集所有@font-face,取加密字体(排除标准字体) // - _createCharMap: 扫描ASCII+CJK全范围,增加诊断日志 // - decrypt(): 增加 forestpolice.org 作为表备用源 // // v3.3.9 修复: // - _extractFontData: 同时支持base64内嵌和外部URL字体加载(GM_xmlhttpRequest获取) // - 增加诊断日志,console.warn确保可见 // - 修复备用源URL:移除scriptcat.org错误路径 // - decrypt()每个阶段都输出console.warn便于定位问题 // ========================================== const FontDecrypt = { _charMap: null, _tableLoaded: false, _observer: null, _decrypted: false, async decrypt() { // v3.3.9: 每个入口都输出warn日志确保可见 warn(`FontDecrypt.decrypt() 调用 (已有映射表: ${!!this._charMap}, 已解密: ${this._decrypted})`); // 如果已经解密过且映射表存在,直接增量替换 if (this._charMap) { const replaced = this._replaceEncryptedText(this._charMap); warn(`FontDecrypt 增量替换: ${replaced}处文本节点`); return true; } try { // v3.3.9: 使用新的_extractFontData同时支持base64和外部URL const fontSource = await this._extractFontData(); if (!fontSource) { warn('⚠️ 未检测到加密字体(所有@font-face src均不可用或404),跳过解密'); return true; } if (typeof Typr === 'undefined' || typeof md5 === 'undefined') { warn('❌ Typr/md5 未加载'); return false; } const fontData = Typr.parse(this._base64ToUint8Array(fontSource)); if (!fontData || !fontData[0]) { warn('❌ TTF解析失败'); return false; } // 加载映射表(forestpolice主源 → api.zhixue.icu备用源) let table = await this._loadFallbackTable(); if (table && Object.keys(table).length < 50) { warn(`forestpolice映射表过少(${Object.keys(table).length}条),尝试备用源...`); table = null; } if (!table) { warn('forestpolice主源失败,尝试api.zhixue.icu...'); table = await Api.loadFontTable(); } if (!table) { warn('❌ 所有映射表源均失败'); return false; } warn(`映射表加载成功: ${Object.keys(table).length}条`); const { charMap, totalGlyphs, matchedCount } = this._createCharMap(fontData[0], table); if (matchedCount === 0) { warn(`❌ 字体解析完成但0匹配(字体含${totalGlyphs}个加密字形,表含${Object.keys(table).length}条)`); return false; } this._charMap = charMap; warn(`✅ 字体解密: ${matchedCount}/${totalGlyphs} 字形匹配 (${(matchedCount/totalGlyphs*100).toFixed(1)}%)`); // 全文档替换 const replaced = this._replaceEncryptedText(charMap); warn(`✅ DOM替换: ${replaced}处文本节点`); // v3.6.10.6: 恢复填空位下划线——查找.f16t7cxsecret元素中纯空白文本的节点,补充下划线 this._restoreBlanksUnderline(); this._startObserver(); this._decrypted = true; return true; } catch (e) { err('❌ 字体解密异常:', e.message); return false; } }, /** * v3.3.9: 重写为_extractFontData,同时支持base64内嵌和外部URL字体 * 先检测页面是否有font-cxsecret字体加载 * 然后从@font-face中提取字体数据(base64或外部URL) */ async _extractFontData() { // ==== 四层检测 ==== // 第1层: styleSheets | 第2层: style标签 | 第3层: 外部CSS fetch | 第4层: 直接URL探针 // ================== const fontFaces = []; // ---- 第1层: styleSheets ---- try { for (const sheet of document.styleSheets) { try { const rules = sheet.cssRules || sheet.rules; if (!rules) continue; for (const rule of rules) { if (rule instanceof CSSFontFaceRule) { const family = rule.style.getPropertyValue('font-family') || ''; const src = rule.style.getPropertyValue('src') || ''; if (family && src) fontFaces.push({ family: family.replace(/['"]/g, '').trim(), src }); } } } catch (e) { /* 跨域跳过 */ } } } catch (e) {} warn(`[第1层] styleSheets: ${fontFaces.length}个@font-face`); // ---- 第2层: style标签 ---- if (fontFaces.length === 0) { const styles = document.querySelectorAll('style'); for (const el of styles) { const text = el.textContent || ''; const m = /@font-face\s*\{([^}]+)\}/gi; let fm; while ((fm = m.exec(text)) !== null) { const body = fm[1]; const fmMatch = body.match(/font-family\s*:\s*['"]?([^;'"}]+)['"]?\s*;?/i); const sm = body.match(/src\s*:\s*([^;}]+)\s*;?/i); if (fmMatch && sm && !fontFaces.some(f => f.family === fmMatch[1].trim())) { fontFaces.push({ family: fmMatch[1].trim(), src: sm[1].trim() }); } } } warn(`[第2层] style标签: ${fontFaces.length}个@font-face`); } // ---- 第3层: fetch外部CSS ---- if (fontFaces.length === 0) { const links = document.querySelectorAll('link[rel="stylesheet"]'); warn(`[第3层] 尝试${links.length}个外部CSS...`); for (const link of links) { try { const href = link.href; if (!href) continue; const resp = await fetch(href, { signal: AbortSignal.timeout(10000) }); if (!resp.ok) continue; const cssText = await resp.text(); warn(`[第3层] ${href} → ${cssText.length}字符`); const m = /@font-face\s*\{([^}]+)\}/gi; let fm; while ((fm = m.exec(cssText)) !== null) { const body = fm[1]; const fmMatch = body.match(/font-family\s*:\s*['"]?([^;'"}]+)['"]?\s*;?/i); const sm = body.match(/src\s*:\s*([^;}]+)\s*;?/i); if (fmMatch && sm && !fontFaces.some(f => f.family === fmMatch[1].trim())) { fontFaces.push({ family: fmMatch[1].trim(), src: sm[1].trim() }); } } } catch (e) { warn(`[第3层] fetch失败: ${e.message}`); } } warn(`[第3层] 外部CSS: ${fontFaces.length}个@font-face`); } // ---- 第4层: 直接URL探针 ---- if (fontFaces.length === 0) { warn(`[第4层] 探针已知font-cxsecret URL...`); const probeUrls = [ '//static.chaoxing.com/font/cxsecret.woff2', '//static.chaoxing.com/font/cxsecret.woff', '//static.chaoxing.com/font/cxsecret.ttf', '//pan-yz.chaoxing.com/font/cxsecret.woff2', '//pan-yz.chaoxing.com/font/cxsecret.woff', '//pan-yz.chaoxing.com/font/cxsecret.ttf', ]; for (const path of probeUrls) { const url = location.protocol + path; try { const result = await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: 10000, onload: (r) => resolve(r.status >= 200 && r.status < 300 ? r : null), onerror: () => resolve(null), ontimeout: () => resolve(null), }); }); if (result) { warn(`✅ [第4层] 找到: ${url}`); fontFaces.push({ family: 'cxsecret', src: `url('${url}') format('woff2')` }); break; } } catch (e) { warn(`[第4层] ${url}失败: ${e.message}`); } } } if (fontFaces.length === 0) { warn('⚠️ [四层全失败] 未找到任何@font-face'); return null; } warn(`[字体] ${fontFaces.length}个: ${fontFaces.map(f => f.family).join(', ')}`); // ---- 识别加密字体 ---- // 排除已知标准/图标字体 const knownStandard = ['fontawesome', 'material icons', 'materialdesignicons', 'iconfont', 'glyphicons', 'source han', 'noto sans', 'noto serif', 'pingfang', 'helvetica', 'arial', 'sans-serif', 'serif', 'monospace', 'roboto', 'open sans', 'microsoft yahei', 'simsun', 'icomoon', 'fontello', 'dripicons', 'typicons', 'feather']; // 优先匹配加密字体关键词 let targetFont = fontFaces.find(f => ['secret', 'cxsecret', '加密', 'chinese', 'quote'].some(k => f.family.toLowerCase().includes(k)) ); // 没匹配到则选出现次数最多的非标准字体(CXChineseQuote出现4次,核心字体) if (!targetFont) { const customFonts = fontFaces.filter(f => !knownStandard.some(k => f.family.toLowerCase().includes(k)) ); if (customFonts.length === 0) { warn('⚠️ 所有字体均为标准字体'); return null; } // 按family出现次数降序,取出现最多的 const freq = {}; fontFaces.forEach(f => { freq[f.family] = (freq[f.family] || 0) + 1; }); targetFont = customFonts.sort((a, b) => (freq[b.family] || 0) - (freq[a.family] || 0))[0]; warn(`[降级] 取出现最多的: ${targetFont.family} (${freq[targetFont.family]}次)`); } else { warn(`[加密字体] ${targetFont.family}`); } // ---- 提取字体数据 ---- const src = targetFont.src; // 尝试base64 const b64Match = src.match(/url\(['"]?data:[^'"]*?base64,([A-Za-z0-9+/=]+)['"]?\)/i); if (b64Match) { warn(`✅ base64: ${b64Match[1].length}字节`); return b64Match[1]; } // 尝试外部URL(支持协议绝对、协议相对、无协议相对路径) const urlMatch = src.match(/url\(['"]?((?:https?:)?\/\/[^'"\)]+\.(?:woff2?|ttf|otf|eot)[^'"\)]*)['"]?\)/i) || src.match(/url\(['"]?((?:https?:)?\/\/[^'"\)]+)['"]?\)/i) || src.match(/url\(['"]?([^'"\)]+\.(?:woff2?|ttf))['"]?\)/i); if (urlMatch) { let fontUrl = urlMatch[1]; // 相对路径补全 if (!fontUrl.startsWith('http') && !fontUrl.startsWith('//')) { fontUrl = location.origin + '/' + fontUrl.replace(/^\.?\//, ''); } warn(`📥 下载: ${fontUrl}`); return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: fontUrl, responseType: 'arraybuffer', timeout: 30000, onload: (resp) => { if (resp.status >= 200 && resp.status < 300 && resp.response) { try { const bytes = new Uint8Array(resp.response); let binary = ''; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); const base64 = btoa(binary); warn(`✅ 下载成功: ${base64.length}字节`); resolve(base64); } catch (e) { err('❌ 转base64:', e.message); resolve(null); } } else { err(`❌ HTTP ${resp.status}`); resolve(null); } }, onerror: (e) => { err('❌ 下载错误:', e); resolve(null); }, ontimeout: () => { err('❌ 超时'); resolve(null); }, }); }); } warn(`⚠️ src无法解析: ${src.substring(0, 80)},尝试已知URL探针下载...`); // 探针下载已知字体URL(学习通标准 cxsecret 路径) const probeUrls = [ '//static.chaoxing.com/font/cxsecret.woff2', '//static.chaoxing.com/font/cxsecret.woff', '//static.chaoxing.com/font/cxsecret.ttf', '//pan-yz.chaoxing.com/font/cxsecret.woff2', '//pan-yz.chaoxing.com/font/cxsecret.woff', '//pan-yz.chaoxing.com/font/cxsecret.ttf', ]; for (const path of probeUrls) { const url = location.protocol + path; try { const result = await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: 10000, onload: (r) => resolve(r.status >= 200 && r.status < 300 ? r : null), onerror: () => resolve(null), ontimeout: () => resolve(null), }); }); if (result) { warn(`✅ 探针下载成功: ${url}`); const bytes = new Uint8Array(result.response); let binary = ''; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); const base64 = btoa(binary); warn(`✅ 探针下载: ${base64.length}字节`); return base64; } } catch (e) { /* 继续尝试下一个 */ } } warn('❌ 所有已知URL探针均失败'); return null; }, /** * v3.3.9: 备用映射表加载(只保留有效的 forestpolice.org) * 移除 scriptcat.org/lib/668/1.0/table.json(该URL实际返回TyprMd5.js而非映射表) */ async _loadFallbackTable() { const urls = [ 'https://www.forestpolice.org/ttf/2.0/table.json', ]; for (const url of urls) { try { const data = await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 15000, onload: (resp) => { try { resolve(JSON.parse(resp.responseText)); } catch { resolve(null); } }, onerror: () => resolve(null), ontimeout: () => resolve(null), }); }); if (data) { warn(`备用映射表加载成功: ${url} (${Object.keys(data).length}条)`); return data; } } catch (e) { /* 继续尝试下一个 */ } } return null; }, _base64ToUint8Array(base64) { const data = window.atob(base64); return new Uint8Array([...data].map(c => c.charCodeAt(0))); }, /** * v3.3.9: 增加decimal key回退(forestpolice表混合hex/numeric格式) */ _createCharMap(font, table) { const charMap = {}; let totalGlyphs = 0; let matchedCount = 0; const unmatchedSamples = []; // 扫描范围: ASCII (32-126) + 全角ASCII (65281-65374) + CJK (19968-40959) + CJK Ext A (13312-19903) const ranges = [ [32, 126], // ASCII 可打印字符 [65281, 65374], // 全角ASCII [19968, 40959], // CJK Unified Ideographs [13312, 19903], // CJK Extension A [131072, 173791], // CJK Extension B (可能部分) ]; for (const [start, end] of ranges) { for (let i = start; i <= end; i++) { try { const glyph = Typr.U.codeToGlyph(font, i); if (!glyph) continue; totalGlyphs++; const path = Typr.U.glyphToPath(font, glyph); // 与 ScriptCat #4981 / 52pojie 完全一致: md5(path).slice(24) const pathHash = md5(JSON.stringify(path)).slice(24); // v3.3.9: 同时尝试hex key和decimal key(forestpolice表混合格式) let decoded = table[pathHash]; if (!decoded) { // 尝试decimal key: 将hex字符串转为十进制数字 const decimalKey = parseInt(pathHash, 16); decoded = table[decimalKey]; } if (decoded) { const decodedCP = Number(decoded); // v3.6.10.6: 只保留解码结果为 CJK 汉字的映射 // 填空位字符(空格/下划线占位符)在字体中也被映射,但解码结果不是汉字 // 如果在 charMap 中包含它们,解密时填空位会被替换,失去下划线 const isCJK = (decodedCP >= 0x3400 && decodedCP <= 0x4DBF) || // CJK Ext A (decodedCP >= 0x4E00 && decodedCP <= 0x9FFF) || // CJK 统一表意文字 (decodedCP >= 0x20000 && decodedCP <= 0x2A6DF); // CJK Ext B if (isCJK) { charMap[String.fromCodePoint(i)] = String.fromCodePoint(decodedCP); matchedCount++; } } else if (unmatchedSamples.length < 10) { // 收集前10个未匹配的样本用于诊断 unmatchedSamples.push({ encrypted: String.fromCodePoint(i), encryptedHex: i.toString(16), hashPrefix: pathHash.substring(0, 8) }); } } catch (e) { /* skip individual glyph errors */ } } } if (unmatchedSamples.length > 0) { warn(`未匹配样本(前10): ${unmatchedSamples.map(s => `${s.encrypted}(U+${s.encryptedHex})`).join(', ')}`); } return { charMap, totalGlyphs, matchedCount }; }, /** * v3.3.8: 完全重写 - 始终全文档TreeWalker扫描 * 不再依赖.font-cxsecret类,不再提前return */ _replaceEncryptedText(charMap) { const encChars = Object.keys(charMap); if (encChars.length === 0 || !document.body) return 0; // 构建高效替换:对每个文本节点,检查是否包含任意加密字符 const encSet = new Set(encChars); // 预编译正则:匹配任意加密字符 const encRegex = new RegExp(encChars.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g'); const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const textNodes = []; while (walker.nextNode()) { const node = walker.currentNode; if (node.textContent && encChars.some(c => node.textContent.includes(c))) { textNodes.push(node); } } let replacedCount = 0; for (const node of textNodes) { const original = node.textContent; // v3.6.10.6: 只替换解密结果为汉字的字符 // 填空位下划线字符在字体中渲染为横线,映射表中它们可能被映射到空格/句号等非汉字 // 如果替换这些字符,填空位会失去下划线;保留它们,让自定义字体继续渲染为横线 const replaced = original.replace(encRegex, (match) => { const decoded = charMap[match]; if (decoded) { const cp = decoded.codePointAt(0); // CJK 统一表意文字: U+4E00-U+9FFF (19968-40959) // CJK 扩展A: U+3400-U+4DBF (13312-19903) // CJK 扩展B: U+20000-U+2A6DF (131072-173791) if ((cp >= 0x3400 && cp <= 0x4DBF) || (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x20000 && cp <= 0x2A6DF)) { return decoded; } // 非汉字映射(空格、句号等)→ 填空位字符,不替换,保留自定义字体渲染 } return match; }); if (replaced !== original) { node.textContent = replaced; replacedCount++; } } // 不移除.font-cxsecret class——填空下划线依赖自定义字体渲染 // 移除会导致填空下划线字符失去字体变成不可见(2026-07-06 修复) return replacedCount; }, /** * v3.6.10.6: 恢复填空位下划线 * 查找所有 .font-cxsecret 元素,对文本内容全是空白字符的元素补充下划线样式 * 防止解密替换或字体渲染问题导致填空位失去下划线 * * v3.6.10.6: 增加文本替换——将空白字符替换为实际下划线字符, * 确保导出文档时填空位可见(自定义字体在导出文档中不可用) */ _restoreBlanksUnderline() { try { const els = document.querySelectorAll('.font-cxsecret'); let restored = 0; els.forEach(el => { const text = el.textContent || ''; // 纯空白字符:空格、全角空格、制表符、不换行空格等 if (text && /^[\s\u00A0\u3000]+$/.test(text)) { // v3.6.10.6: 替换空白文本为实际下划线,移除自定义字体class // 这样无论导出还是DOM提取,填空位都是可见的下划线字符 const blankLen = text.length; el.textContent = '_'.repeat(blankLen); el.classList.remove('font-cxsecret'); // 补充CSS下划线样式兜底 const style = el.style; if (!style.textDecoration || style.textDecoration === 'none') { style.textDecoration = 'underline'; style.textDecorationColor = '#333'; } restored++; } }); if (restored > 0) warn(`✅ 填空位下划线恢复: ${restored}处`); } catch(e) { err('❌ 填空位下划线恢复失败:', e.message); } }, /** * MutationObserver: 监听新增DOM节点,自动解密 */ _startObserver() { if (this._observer) return; if (!this._charMap) return; const charMap = this._charMap; const encChars = Object.keys(charMap); if (encChars.length === 0) return; this._observer = new MutationObserver((mutations) => { let needsReplace = false; for (const mut of mutations) { if (mut.type === 'childList') { for (const node of mut.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) { const text = node.textContent || ''; for (const enc of encChars) { if (text.includes(enc)) { needsReplace = true; break; } } if (needsReplace) break; } } } else if (mut.type === 'characterData') { needsReplace = true; } if (needsReplace) break; } if (needsReplace) { this._replaceEncryptedText(charMap); } }); this._observer.observe(document.body, { childList: true, subtree: true, characterData: true }); }, _stopObserver() { if (this._observer) { this._observer.disconnect(); this._observer = null; } } }; // ========================================== // 答案匹配引擎(6.x 原版,5层降级匹配) // ========================================== const Matcher = { /** * 将AI/题库返回的答案转换为选项字母列表 * v5.3.0: 参考chaoxing-ai彻底重写 * - L1: 纯字母格式匹配(ABC / A,B,C / A|B|C),严格要求答案去除分隔符后仅含字母 * - L2: 格式提取("答案是X") * - L3: 分隔符拆分逐片段匹配(支持单字母+文本混合) * - L4: 文本匹配(精确→去标点→模糊) * - L5: 索引兜底 */ resolveAnswer(answer, options, questionType) { const ans = String(answer).trim(); if (!ans || !options || options.length === 0) return []; // 计算合法字母范围 const validLetters = options.map(o => (o.letter || '').toUpperCase()).filter(l => /^[A-Z]$/.test(l)); const maxLetter = validLetters.length > 0 ? validLetters[validLetters.length - 1] : 'Z'; const optionCount = options.length; // ===== 第1层:纯字母格式匹配(最可靠) ===== // 参考chaoxing-ai: 只有去除分隔符后仅含字母才走这条路 // 避免从解释文本中误提取字母(如"ABC(注:由于...)"中括号外的A/B/C被正确提取, // 但括号内如果也有字母就不会被误抓——因为整体去除分隔符后不是纯字母) const cleaned = ans.replace(/[\s||,,;;、\n\r\t\u3000]+/g, ''); // 去分隔符 if (/^[A-Za-z]+$/.test(cleaned)) { // 纯字母格式:ABC → [A,B,C] 或 A,B,C → [A,B,C] const letters = cleaned.toUpperCase().split('').filter(l => l.charCodeAt(0) - 65 < optionCount); if (letters.length > 0) { if (questionType === 'single' && letters.length > 1) { log(`匹配L1(纯字母): ${ans} → ${letters.join('')} → 单选取首: ${letters[0]}`); return [letters[0]]; } log(`匹配L1(纯字母): ${ans} → ${letters.join('')}`); return letters; } } // ===== 第2层:格式提取 ===== const patterns = [ /(?:答案[是为]|正确答案|参考答案|选择|选)[::]*\s*([A-Z])/i, /(?:应该选|应选|当选)[::]*\s*([A-Z])/i, /^([A-Z])\s*[.。、))]/m ]; for (const p of patterns) { const m = ans.match(p); if (m && m[1].charCodeAt(0) - 65 < optionCount) { log(`匹配L2(格式提取): ${ans} → ${m[1]}`); return [m[1]]; } } // ===== 第3层:分隔符拆分逐片段匹配(参考chaoxing-ai cxaiFindMultipleIndices) ===== // 处理 "A|B|C"、"A,B,C"、"A、马克思主义 B、科学" 等混合格式 const parts = ans.split(/[||\n;;,,、]+/).map(p => p.trim()).filter(p => p.length > 0); if (parts.length >= 2) { const matched = []; for (const part of parts) { // 3a: 单字母片段 const singleLetter = part.match(/^([A-Ga-g])$/); if (singleLetter) { const l = singleLetter[1].toUpperCase(); if (l.charCodeAt(0) - 65 < optionCount && !matched.includes(l)) { matched.push(l); continue; } } // 3b: "A.xxx" 格式 const letterPrefix = part.match(/^([A-Ga-g])\s*[.。、::))]/); if (letterPrefix) { const l = letterPrefix[1].toUpperCase(); if (l.charCodeAt(0) - 65 < optionCount && !matched.includes(l)) { matched.push(l); continue; } } // 3c: 文本片段 → 匹配选项 const textHit = Matcher._matchTextToOption(part, options); if (textHit && !matched.includes(textHit)) { matched.push(textHit); } } if (matched.length > 0) { if (questionType === 'single' && matched.length > 1) matched.length = 1; log(`匹配L3(逐片段): "${ans}" → ${matched.join('')}`); return matched; } } // ===== 第4层:文本匹配(整体匹配) ===== const matched = []; // 4a: 精确文本 for (const opt of options) { if (opt.text && opt.text === ans && !matched.includes(opt.letter)) { matched.push(opt.letter); } } // 4b: 包含文本 if (matched.length === 0) { for (const opt of options) { if (opt.text && (opt.text.includes(ans) || ans.includes(opt.text)) && !matched.includes(opt.letter)) { matched.push(opt.letter); } } } // 4c: 去标点+模糊 if (matched.length === 0) { const normalize = s => s.replace(/[《》""''\s·、,。,.\-—_()()\[\]【】::!!??;;]/g, '').toLowerCase(); const normAns = normalize(ans); let bestOpt = null, bestScore = 0; for (const opt of options) { const normOpt = normalize(opt.text || ''); if (!normOpt || normOpt.length < 2) continue; if (normOpt.includes(normAns) || normAns.includes(normOpt)) { if (!matched.includes(opt.letter)) { matched.push(opt.letter); } } if (matched.length === 0) { const score = Matcher._similarity(normAns, normOpt); if (score > bestScore) { bestScore = score; bestOpt = opt; } } } if (matched.length === 0 && bestOpt && bestScore >= 0.6) { matched.push(bestOpt.letter); log(`匹配L4c(模糊${(bestScore*100).toFixed(0)}%): "${ans}" → ${bestOpt.letter}`); } } if (matched.length > 0) { if (questionType === 'single' && matched.length > 1) matched.length = 1; if (matched.length > 1) log(`匹配L4(文本): "${ans}" → ${matched.join('')}`); return matched; } // ===== 第5层:选项索引兜底 ===== const idxMatch = ans.match(/(\d+)/); if (idxMatch) { const idx = parseInt(idxMatch[1]) - 1; if (idx >= 0 && idx < optionCount) { log(`匹配L5(索引兜底): "${ans}" → 选项${options[idx].letter}`); return [options[idx].letter]; } } // ===== 第6层:最佳相似度兜底(单选题必选一个,不再跳题) ===== if (questionType === 'single') { const normalize = s => s.replace(/[《》""''\s·、,。,.\-—_()()\[\]【】::!!??;;]/g, '').toLowerCase(); const normAns = normalize(ans); let bestOpt = null, bestScore = 0; for (const opt of options) { const normOpt = normalize(opt.text || ''); if (!normOpt || normOpt.length < 2) continue; const score = Matcher._similarity(normAns, normOpt); if (score > bestScore) { bestScore = score; bestOpt = opt; } } if (bestOpt && bestScore >= 0) { log(`匹配L6(单选兜底${(bestScore*100).toFixed(0)}%): "${ans}" → ${bestOpt.letter} ${bestOpt.text}`); return [bestOpt.letter]; } } warn(`5层匹配全部失败: "${ans}", 选项: [${options.map(o => `${o.letter}:${o.text}`).join(', ')}]`); return []; }, /** 文本片段匹配到选项 */ _matchTextToOption(text, options) { const t = text.trim(); // 精确 for (const opt of options) { if (opt.text && opt.text === t) return opt.letter; } // 包含 for (const opt of options) { if (opt.text && (opt.text.includes(t) || t.includes(opt.text))) return opt.letter; } // 去标点 const normalize = s => s.replace(/[《》""''\s·、,。,.\-—_()()\[\]【】::!!??;;]/g, ''); const nt = normalize(t); for (const opt of options) { const no = normalize(opt.text || ''); if (no && nt && (no.includes(nt) || nt.includes(no))) return opt.letter; } return null; }, /** 字符级相似度(Jaccard系数 + 长序列匹配) */ _similarity(a, b) { if (!a || !b) return 0; if (a === b) return 1; // Jaccard字符集合相似度 const setA = new Set(a); const setB = new Set(b); let intersection = 0; for (const c of setA) { if (setB.has(c)) intersection++; } const jaccard = intersection / (setA.size + setB.size - intersection); // 最长公共子序列比 const lcsLen = Matcher._lcs(a, b).length; const lcsRatio = (2 * lcsLen) / (a.length + b.length); // 综合得分 return Math.max(jaccard, lcsRatio); }, /** 最长公共子序列 */ _lcs(a, b) { const m = a.length, n = b.length; const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]); } } // 回溯LCS let result = '', i = m, j = n; while (i > 0 && j > 0) { if (a[i-1] === b[j-1]) { result = a[i-1] + result; i--; j--; } else if (dp[i-1][j] > dp[i][j-1]) i--; else j--; } return result; } }; // ========================================== // 题目检测(6.x 原版) // ========================================== const Detectors = { detect() { const questions = []; const seenElements = new Set(); const seenTitles = new Set(); const containerSelectors = [ '.questionLi', '.TiMu', '.previewTiMu', '.singleQuesId', '[typename]', '.answerCard', '.queBox', '.queItem', '.mark_item', '[data-type="1"]', '[data-type="2"]', '[data-type="3"]', '[data-type="4"]', '[data-type="5"]' ]; // 第一遍:标准扫描,seenElements 去重防止同一元素被多选择器重复处理 // 【修复v3.6.15】改用 questionId 去重而非前50字——部分页面存在大量题目前50字相同的情况 for (const sel of containerSelectors) { const found = document.querySelectorAll(sel); found.forEach(container => { if (seenElements.has(container)) return; seenElements.add(container); const q = Detectors.parseQuestion(container, questions.length); if (q && q.text) { // 检查选项数是否合理(超过26个选项一定是脏数据,如AI讲解区整段内容) if (q.options && q.options.length > 26) { return; } // 【修复v3.6.15】改用 questionId 去重,避免前50字相同导致题目丢失 var _dedupKey = q.questionId || ('TITLE::' + q.text.trim().substring(0, 50)); if (!seenTitles.has(_dedupKey)) { seenTitles.add(_dedupKey); questions.push(q); } } }); } // 【修复v3.6.14】二次扫描:强制处理所有 .singleQuesId 元素 // 某些页面中 .singleQuesId 和 .TiMu 在同一元素上,.TiMu 先处理后 parseQuestion 返回 null //(如缺少 .questionLi / [typename] 时结构不完整),导致 .singleQuesId 路径被 seenElements 阻挡 // 二次扫描跳过 seenElements,但 title 去重仍生效,不会产生重复题 // 【修复v3.6.15】二次扫描也改用 questionId 去重 var _allSQ = document.querySelectorAll('.singleQuesId'); if (_allSQ.length > questions.length) { _allSQ.forEach(function(_sqContainer) { var _sq = Detectors.parseQuestion(_sqContainer, questions.length); if (_sq && _sq.text) { if (_sq.options && _sq.options.length > 26) return; var _sqDedupKey = _sq.questionId || ('TITLE::' + _sq.text.trim().substring(0, 50)); if (!seenTitles.has(_sqDedupKey)) { seenTitles.add(_sqDedupKey); questions.push(_sq); } } }); } if (questions.length > 0) { log(`检测到 ${questions.length} 道题目`); return questions; } return Detectors.detectByInputs(); }, parseQuestion(container, index) { let titleText = ''; const titleSelectors = [ '.fontLabel', 'h3.mark_name', '.Zy_TItle .clearfix', '.TiMu .qtContent', '.qtContent', '.Zy_TItle', '.queTitle', '.questionTitle', '.qtit', 'h3', 'h2', 'h4', '.title', '.mark_name', '.subject_type', '.question_stem' ]; for (const sel of titleSelectors) { const el = container.querySelector(sel); if (el && el.innerText.trim().length > 1) { titleText = el.innerText.trim(); break; } } if (!titleText) { for (const child of container.children) { const text = child.innerText?.trim(); if (text && text.length > 3 && !child.classList.contains('answerBg') && !child.querySelector('.answerBg') && !/^\d+$/.test(text)) { titleText = text; break; } } } if (!titleText) { const fullText = container.innerText?.trim(); if (fullText && fullText.length > 5) { const firstLine = fullText.split('\n')[0].trim(); if (firstLine.length > 3) titleText = firstLine; } } if (!titleText) { warn(`题${index + 1}: 无法提取题干 (class: ${container.className})`); return null; } titleText = titleText.replace(/\n/g, ' ').replace(/^\d+[\.\、\)\]\s]+/, '').trim(); if (titleText.length < 2) { warn(`题${index + 1}: 题干过短 "${titleText}"`); return null; } // 【修复v3.6.10】题干过短时从容器完整文本提取(简答题/填空题在work/view页面可能只匹配到题型标签) if (titleText.length < 35) { var _fullBody = container.innerText?.trim() || ''; if (_fullBody.length > titleText.length + 15) { var _cleanTxt = _fullBody.replace(/\n/g, ' ').trim(); var _ansMark = _cleanTxt.search(/[我]?答案[::]/); if (_ansMark > 20) { titleText = _cleanTxt.substring(0, _ansMark).trim(); // 【修复v3.6.12-2】剥离题号(避免导出时序号如"17. 17."重复)和末尾分数(1.0) titleText = titleText.replace(/^\d+[\.\、\)\]\s]+/, '').trim(); titleText = titleText.replace(/[((]\d+\.?\d*\s*分?\s*[))]\s*$/, '').trim(); } else if (_cleanTxt.length > titleText.length + 20) { titleText = _cleanTxt.substring(0, Math.min(_cleanTxt.length, 280)).trim(); // 【修复v3.6.12】题干过短时剥离混入的选项文本(如 A. xxx B. xxx) var _optIdx = titleText.search(/\s+[A-E][\.\、\)]\s*/); if (_optIdx > 5) { titleText = titleText.substring(0, _optIdx).trim(); } // 【修复v3.6.12-2】剥离题号(避免导出时序号重复)和末尾分数(1.0) titleText = titleText.replace(/^\d+[\.\、\)\]\s]+/, '').trim(); titleText = titleText.replace(/[((]\d+\.?\d*\s*分?\s*[))]\s*$/, '').trim(); } } } const typeName = container.getAttribute('typename') || ''; let type = 'unknown'; if (typeName.includes('单选')) type = 'single'; else if (typeName.includes('多选')) type = 'multiple'; else if (typeName.includes('判断')) type = 'judge'; else if (typeName.includes('填空')) type = 'fill'; else if (typeName.includes('简答') || typeName.includes('论述')) type = 'essay'; // 已完成页面没有 typename 属性,从标题文本推断 if (type === 'unknown') { var _typePattern = titleText.match(/【(.+?)】/); if (_typePattern) { var _t = _typePattern[1]; if (_t.includes('单选')) type = 'single'; else if (_t.includes('多选')) type = 'multiple'; else if (_t.includes('判断')) type = 'judge'; else if (_t.includes('填空')) type = 'fill'; else if (_t.includes('简答') || _t.includes('论述') || _t.includes('问答')) type = 'essay'; } } // 【修复v3.6.10】标题使用(...)格式时的题型检测 if (type === 'unknown') { var _typePattern2 = titleText.match(/[((](.+?)[))]/); if (_typePattern2) { var _t2 = _typePattern2[1]; if (_t2.includes('填空')) type = 'fill'; else if (_t2.includes('简答') || _t2.includes('论述') || _t2.includes('问答')) type = 'essay'; } } const questionId = container.getAttribute('data') || container.getAttribute('questionid') || container.id || ''; const options = Detectors.extractOptions(container); // 【修复v3.6.8】广域答案检测——答案DOM可能在容器外部(兄弟节点等) if (options && options.length > 0) { var _hasAns = false; options.forEach(function(_o) { if (_o.isUserAnswer || _o.isCorrectAnswer) _hasAns = true; }); if (!_hasAns) { // 【修复v3.6.15-hotfix4】优先从容器内搜索答案元素,避免跨题匹配 var _ansEls = container.querySelectorAll('.stuAnswerContent, .rightAnswerContent, .myAnswerBx .answerCon, .myAnswer .answerCon, .pyAnswer .answerCon, .pyAnswer_question .answerCon'); if (!_ansEls || _ansEls.length === 0) { // 容器内没有时,再在父级范围内搜索 var _parent = container.parentElement; _ansEls = (_parent ? _parent.querySelectorAll('.stuAnswerContent, .rightAnswerContent, .myAnswerBx .answerCon, .myAnswer .answerCon, .pyAnswer .answerCon, .pyAnswer_question .answerCon') : []); } if (!_ansEls || _ansEls.length === 0) { _ansEls = document.querySelectorAll('.stuAnswerContent, .rightAnswerContent, .myAnswerBx .answerCon, .myAnswer .answerCon, .pyAnswer .answerCon, .pyAnswer_question .answerCon'); } var _ansText = ''; _ansEls.forEach(function(_el) { var _txt = _el.innerText.trim(); // 只取离当前题目最近的答案 var _dist = Math.abs(_el.getBoundingClientRect().top - container.getBoundingClientRect().top); if (_dist < 200) { if (_el.classList.contains('stuAnswerContent') || _el.classList.contains('rightAnswerContent')) { _ansText = _txt; } else { _ansText = _txt.replace(/我的答案[::]\s*/i, ''); } } }); if (_ansText) { var _ansLetters = _ansText.match(/[A-E]/gi); if (_ansLetters) { _ansLetters = _ansLetters.map(function(l){return l.toUpperCase();}); options.forEach(function(_o) { if (_ansLetters.indexOf(_o.letter) !== -1) { _o.isUserAnswer = true; _o.isCorrectAnswer = true; _o.isSelected = true; } }); } } // 【修复v3.6.15-hotfix4】文本兜底:优先只从容器自身文本匹配答案,不拼接兄弟/父级文本 // 避免题干相同的题目(如148/157题)因广域搜索匹配到其他题的答案文本 if (!_ansText) { // 第一步:只从当前容器内匹配(不拼接任何外部文本) var _searchText = container.innerText || ''; // 第二步:仅在容器内没有答案文本时,才有限搜索下一个兄弟 var _cMatch2 = _searchText.match(/正确答案[::]\s*([A-E]+)/i); var _uMatch2 = _searchText.match(/(?:我的答案|你[的得]答案|学生答案|已选|选择答案|作答答案)[::]\s*([A-E]+)/i); if (!_cMatch2 && !_uMatch2) { var _sib = container.nextElementSibling; if (_sib) _searchText += ' ' + (_sib.innerText || ''); } // 单选题/多选题答案:A-E字母 var _cMatch = _cMatch2 || _searchText.match(/正确答案[::]\s*([A-E]+)/i); var _uMatch = _uMatch2 || _searchText.match(/(?:我的答案|你[的得]答案|学生答案|已选|选择答案|作答答案)[::]\s*([A-E]+)/i); // 判断题答案:对/错 var _jcMatch = _searchText.match(/正确答案[::]\s*(对|错|正确|错误|√|×)/i); var _juMatch = _searchText.match(/(?:我的答案|你[的得]答案|学生答案|已选)[::]\s*(对|错|正确|错误|√|×)/i); if (_cMatch || _uMatch || _jcMatch || _juMatch) { var _cLetters = _cMatch ? _cMatch[1].toUpperCase().split('') : []; var _uLetters = _uMatch ? _uMatch[1].toUpperCase().split('') : []; // 判断题:对→A, 错→B if (_jcMatch) { _cLetters.push((_jcMatch[1]==='对'||_jcMatch[1]==='正确'||_jcMatch[1]==='√') ? 'A' : 'B'); } if (_juMatch) { _uLetters.push((_juMatch[1]==='对'||_juMatch[1]==='正确'||_juMatch[1]==='√') ? 'A' : 'B'); } options.forEach(function(_o) { if (_cLetters.indexOf(_o.letter) !== -1) _o.isCorrectAnswer = true; if (_uLetters.indexOf(_o.letter) !== -1) { _o.isUserAnswer = true; _o.isSelected = true; } if (_o.isCorrectAnswer && !_o.isUserAnswer) _o.isSelected = true; }); } } } } if (type === 'unknown') { type = Detectors.guessType(container, options); } // 【修复v3.6.10】填空题/简答题答案文本抓取(跳过选项检查,直接从DOM抓取文本答案) var answer = ''; if (type === 'fill' || type === 'essay') { // 【修复v3.6.10】填空题/简答题答案文本抓取(跳过选项检查,直接从DOM抓取文本答案) // 优先只抓取正确答案,避免"我的答案"和"正确答案"同时匹配导致 (1)我的答案(1)正确答案 的混乱 var _rightAnsSelector = '.rightAnswerContent, .pyAnswer .answerCon, .pyAnswer_question .answerCon, .answerContent'; var _ansSelector = '.stuAnswerContent, .rightAnswerContent, .myAnswerBx .answerCon, .myAnswer .answerCon, .answerTxt, .answerContent, .pyAnswer .answerCon, .pyAnswer_question .answerCon'; // 1) 容器内搜索——优先只搜正确答案,没有才搜全部 var _cAnsEls = container.querySelectorAll(_rightAnsSelector); if (_cAnsEls.length === 0) _cAnsEls = container.querySelectorAll(_ansSelector); if (_cAnsEls.length > 0) { answer = Array.from(_cAnsEls).map(function(_el) { return _el.innerText.trim(); }).filter(Boolean).join('\n'); } if (!answer) { // 2) 尝试从输入框获取(已填写的只读输入框,多空可能有多个) var _inputEls = container.querySelectorAll('input[type="text"][readonly], input[type="text"][disabled], textarea[readonly], textarea[disabled]'); if (_inputEls.length > 0) { answer = Array.from(_inputEls).map(function(_el) { return _el.value.trim() || _el.innerText.trim(); }).filter(Boolean).join('\n'); } } if (!answer) { // 3) 从父级及向上5层搜索——优先正确答案 var _walker = container.parentElement; var _depth = 0; while (_walker && _depth < 5 && !answer) { var _wAnsEls = _walker.querySelectorAll(_rightAnsSelector); if (_wAnsEls.length === 0) _wAnsEls = _walker.querySelectorAll(_ansSelector); if (_wAnsEls.length > 0) { var _nearbyTexts = []; var _cRectA = container.getBoundingClientRect(); Array.from(_wAnsEls).forEach(function(_el) { var _d = Math.abs(_el.getBoundingClientRect().top - _cRectA.top); if (_d < 600) _nearbyTexts.push(_el.innerText.trim()); }); if (_nearbyTexts.length > 0) answer = _nearbyTexts.join('\n'); } _walker = _walker.parentElement; _depth++; } } if (!answer) { // 4) 全文档搜索最近的答案元素——优先正确答案 var _docAnsEls2 = document.querySelectorAll(_rightAnsSelector); if (_docAnsEls2.length === 0) _docAnsEls2 = document.querySelectorAll(_ansSelector); if (_docAnsEls2.length > 0) { var _nearbyTexts2 = []; var _cRectB = container.getBoundingClientRect(); Array.from(_docAnsEls2).forEach(function(_el) { var _d = Math.abs(_el.getBoundingClientRect().top - _cRectB.top); if (_d < 600) _nearbyTexts2.push(_el.innerText.trim()); }); if (_nearbyTexts2.length > 0) answer = _nearbyTexts2.join('\n'); } } // 清理答案文本:合并连续空格和多余换行 if (answer) { answer = answer.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+/g, ' ').trim(); } } // 检测分组标题(非题目):如"一. 单选题(共29题,78.3分)" // 【修复v3.6.12】如果标题包含题号(如"1. (单选题)"),则是题目而非分组标题 // 【修复v3.6.13】如果容器是题目容器(.singleQuesId/[typename]等),即使文本匹配分组标题格式也不跳过 if (/^[一二三四五六七八九十]+[\.\、\)\)]\s*/.test(titleText) && /共\d+题/.test(titleText)) { var _hasQNum = /\d+\s*[\.\、\)\)]\s*[((]/.test(titleText); var _isQContainer = container && container.matches && container.matches('.singleQuesId, [typename], .questionLi, .TiMu, .previewTiMu'); if (!_hasQNum && !_isQContainer) { warn(`题${index+1}: 跳过分组标题 "${titleText.substring(0, 30)}..."`); return null; } } // v3.6.10.6: 填空位下划线——将   (U+00A0) 替换为实际下划线字符 // 填空空位是 实体(U+00A0),不在加密字体扫描范围内,不会被解密替换 // 且不在.font-cxsecret元素内,_restoreBlanksUnderline也覆盖不到 // 注意:填空位的 可能被加密字符隔开(如"加密> >加密> "),不连续 // 所以不能只用{2,}匹配连续序列,需针对填空题替换所有单个  // 直接在此处替换,确保提取的titleText含实际下划线,后续存入IndexedDB的q.text正确 if (type === 'fill') { // 填空题:替换所有  (可能被加密字符隔开,不成连续序列) titleText = titleText.replace(/\u00A0/g, '_'); // v3.6.10.7: 合并分散下划线——加密字符穿插在 之间,替换后_被隔开呈分散状态 // 将间距≤3字符的_合并为连续下划线序列,避免导出后填空位显示为虚线 titleText = titleText.replace(/(?:_[^_\n]{0,3}){2,}_?/g, function(m) { return '_'.repeat((m.match(/_/g) || []).length); }); } else { // 其他题型:只替换连续2个以上的  ,避免误替换单字符间距 titleText = titleText.replace(/\u00A0{2,}/g, function(m) { return '_'.repeat(m.length); }); } // v3.6.10.6: 从题干中剥离嵌入的选项文本和"我的"后缀 // 某些页面布局下,选项文本和用户答案标记混在题干容器中 // 【修复v3.6.12-3】放宽正则:\s+替代\s{2,},A-E替代A-D,增加index>10防误伤 var _optInTitleM = titleText.match(/\s+[A-E][\.\、\)]/); if (_optInTitleM && _optInTitleM.index > 10) { titleText = titleText.substring(0, _optInTitleM.index).trim(); } // 【修复v3.6.12-3】剥离末尾分数标记(如 (1.0)、(2.0分)) titleText = titleText.replace(/[((]\d+\.?\d*\s*分?\s*[))]\s*$/, '').trim(); titleText = titleText.replace(/\s*我的\s*$/, '').trim(); return { text: titleText, type, questionId, options, container, index, answer }; }, guessType(container, options) { if (options.length === 0) { const hasTextarea = container.querySelector('textarea, [id^="answerEditor"], iframe[title="编辑器"]'); return hasTextarea ? 'fill' : 'unknown'; } if (options.length === 2) { const optTexts = options.map(o => o.text).join(''); if (/对|错|正确|错误|√|×/.test(optTexts)) return 'judge'; } // v3.5.8-fix: 选项过多(>10)时容器可能跨题,优先检查radio,有radio判single if (options.length > 10) { const hasRadio = container.querySelector('input[type="radio"]') !== null || container.querySelector('[role="radio"]') !== null; return hasRadio ? 'single' : 'unknown'; } const hasCheckbox = container.querySelector('input[type="checkbox"]') !== null || container.querySelector('[role="checkbox"]') !== null || container.querySelector('.check_answer_dx') !== null || container.querySelector('.num_option_dx') !== null; return hasCheckbox ? 'multiple' : 'single'; }, extractOptions(container) { let options = []; // 【修复v3.6.8】方案0: 学习通已批阅页专用 —— Zy_ulTop + newAnswerBx 结构 var _zyUl = container && container.querySelector('.Zy_ulTop'); var _newAnsBx = container && container.querySelector('.newAnswerBx'); if (_zyUl || _newAnsBx) { // 提取选项 var _liOpts = []; if (_zyUl) { _zyUl.querySelectorAll('li').forEach(function(_li, _liIdx) { var _iEl = _li.querySelector('i'); var _letter = ''; if (_iEl) { var _iText = _iEl.innerText.trim(); var _letterMatch = _iText.match(/^([A-E])/i); if (_letterMatch) _letter = _letterMatch[1].toUpperCase(); } if (!_letter) _letter = String.fromCharCode(65 + _liIdx); var _aEl = _li.querySelector('a'); var _text = _aEl ? _aEl.innerText.trim() : _li.innerText.trim().replace(/^[A-E][\.\、\))\s]*/, '').trim(); _liOpts.push({ letter:_letter, text:_text.substring(0,80), element:_li, isSelected:false }); }); } // 提取答案(我的答案 + 正确答案) var _myAnsText = '', _corrAnsText = ''; if (_newAnsBx) { var _myAnsEl = _newAnsBx.querySelector('.myAnswerBx .answerCon'); if (_myAnsEl) _myAnsText = _myAnsEl.innerText.trim(); var _corrAnsEl = _newAnsBx.querySelector('.correctAnswerBx .correctAnswer .answerCon'); if (_corrAnsEl) _corrAnsText = _corrAnsEl.innerText.trim(); } // 判断题:无选项列表但有答案文本 if (_liOpts.length === 0 && (_myAnsText || _corrAnsText)) { if (/^[对错正确错误√×]/.test(_myAnsText) || /^[对错正确错误√×]/.test(_corrAnsText)) { _liOpts = [ { letter:'A', text:'对', element:null, isSelected:false }, { letter:'B', text:'错', element:null, isSelected:false } ]; } } // 标注答案到选项 if (_liOpts.length > 0) { var _myLetters = _myAnsText ? _myAnsText.match(/[A-E]/gi) : null; var _corrLetters = _corrAnsText ? _corrAnsText.match(/[A-E]/gi) : null; // 判断题:对→A, 错→B if (!_myLetters && _myAnsText && /^[对错正确错误√×]/.test(_myAnsText)) { _myLetters = (_myAnsText==='对'||_myAnsText==='正确'||_myAnsText==='√') ? ['A'] : ['B']; } if (!_corrLetters && _corrAnsText && /^[对错正确错误√×]/.test(_corrAnsText)) { _corrLetters = (_corrAnsText==='对'||_corrAnsText==='正确'||_corrAnsText==='√') ? ['A'] : ['B']; } if (_myLetters) { _myLetters = _myLetters.map(function(l){return l.toUpperCase();}); } if (_corrLetters) { _corrLetters = _corrLetters.map(function(l){return l.toUpperCase();}); } _liOpts.forEach(function(_o) { if (_myLetters && _myLetters.indexOf(_o.letter) !== -1) { _o.isUserAnswer = true; _o.isSelected = true; } if (_corrLetters && _corrLetters.indexOf(_o.letter) !== -1) { _o.isCorrectAnswer = true; _o.isSelected = true; } }); // 如果选项来自判题且无isSelected,则正确答案也标记为选中 _liOpts.forEach(function(_o) { if (_o.isCorrectAnswer && !_o.isUserAnswer && !_o.isSelected) _o.isSelected = true; }); return _liOpts; } } // 方案1: .answerBg(标准答题页) const answerBgs = container.querySelectorAll('.answerBg'); if (answerBgs.length > 0) { answerBgs.forEach((div, i) => { const labelSpan = div.querySelector('.num_option, .num_option_dx'); const letter = labelSpan?.getAttribute('data') || String.fromCharCode(65 + i); const pDiv = div.querySelector('.answer_p'); const text = pDiv ? pDiv.innerText.trim() : div.innerText.replace(/^[A-Z][\.\s、]*/, '').trim(); const isSelected = labelSpan?.classList.contains('check_answer') || labelSpan?.classList.contains('check_answer_dx') || div.classList.contains('cur'); options.push({ letter, text: text.substring(0, 80), element: div, isSelected }); }); return options; } // 方案2: ARIA role(无障碍选择题) const ariaOpts = container.querySelectorAll('[role="radio"], [role="checkbox"]'); if (ariaOpts.length > 0) { ariaOpts.forEach((el, i) => { const letter = el.getAttribute('data') || String.fromCharCode(65 + i); const text = el.innerText.trim().substring(0, 80); options.push({ letter, text, element: el, isSelected: el.getAttribute('aria-checked') === 'true' }); }); return options; } // 方案3: 标准选项元素(li.after / .num_option / .answer_p) const lis = container.querySelectorAll('li.after, li[data-answer], .options li, .num_option, .num_option_dx, .answer_p'); if (lis.length > 0) { lis.forEach((li, i) => { const letter = li.getAttribute('data') || String.fromCharCode(65 + i); const text = li.innerText.trim().substring(0, 80); const isSelected = li.classList.contains('bgright') || li.classList.contains('rightAnswer') || li.classList.contains('userAnswer') || li.classList.contains('cur') || li.classList.contains('checked') || li.closest('.bgright, .rightAnswer, .userAnswer, .cur') !== null; options.push({ letter, text, element: li, isSelected }); }); // 去重(同一选项被多个选择器匹配) const seen = new Set(); const deduped = options.filter(o => { const k = o.text; if (seen.has(k)) return false; seen.add(k); return true; }); if (deduped.length >= 2) return deduped; } // 方案4: 查找 container 内所有
  • (已批阅页常用结构) const allLis = container.querySelectorAll('li'); if (allLis.length >= 2 && allLis.length <= 12) { const liOpts = []; allLis.forEach((li, i) => { // 跳过只有数字的题号li、跳过包含输入框的 const txt = li.innerText.trim(); if (!txt || /^\d+[\.\、\)\s]*$/.test(txt) || txt.length > 120) return; if (li.querySelector('input, textarea, select')) return; const letter = li.getAttribute('data') || String.fromCharCode(65 + i); const text = txt.replace(/^[A-Za-z][\.\、\s\)]*/, '').substring(0, 80); const isSelected = li.classList.contains('bgright') || li.classList.contains('rightAnswer') || li.classList.contains('userAnswer') || li.classList.contains('cur') || li.querySelector('.green, .correct, .bgright, .rightAnswer') !== null; liOpts.push({ letter, text, element: li, isSelected }); }); if (liOpts.length >= 2) return liOpts; options.length = 0; // 清空,继续尝试 } // 方案5: [id^="answer"] 选项容器(已批阅页 ID 结构) const ansDivs = container.querySelectorAll('[id^="answer"]'); if (ansDivs.length >= 2 && ansDivs.length <= 12) { const ansOpts = []; ansDivs.forEach((div, i) => { const letter = div.getAttribute('data') || String.fromCharCode(65 + i); const text = div.innerText.trim().replace(/^[A-Za-z][\.\、\s\)]*/, '').substring(0, 80); const isSelected = div.classList.contains('bgright') || div.classList.contains('rightAnswer') || div.classList.contains('cur') || div.querySelector('.green, .correct, .bgright') !== null; ansOpts.push({ letter, text, element: div, isSelected }); }); if (ansOpts.length >= 2) return ansOpts; } // 方案6: label + input(标准表单页) const labels = container.querySelectorAll('label'); const labelOpts = []; labels.forEach((label, i) => { const inp = label.querySelector('input[type="radio"], input[type="checkbox"]'); if (inp) { const letter = String.fromCharCode(65 + i); const text = label.innerText.trim().substring(0, 80); labelOpts.push({ letter, text, element: label, isSelected: inp.checked }); } }); if (labelOpts.length > 0) { if (labelOpts.length > 12) { labelOpts.length = 4; } return labelOpts; } // 方案7: 文本模式兜底 —— 解析容器内的 A. xxx B. xxx 模式 // 【修复v3.6.10】填空题/简答题跳过文本选项提取——题目文本中的a)b)c)标记会误匹配 var _isFillOrEssay7 = container && /[((](填空[题]?|简答[题]?|论述[题]?|问答[题]?)[))]/.test(container.innerText || ''); if (options.length === 0 && !_isFillOrEssay7) { const raw = container.innerHTML || ''; // 匹配常见选项标识: A. / A、/ A./ A) / A) 等,支持中英文字母 const optPattern = /([A-Ea-e][\.\、\))\s]\s*)/g; const parts = container.innerText.split(optPattern); const textOpts = []; for (let i = 1; i < parts.length - 1; i += 2) { const letter = parts[i].trim().charAt(0).toUpperCase(); const txt = (parts[i + 1] || '').trim().substring(0, 80); if (txt && txt.length < 100 && /^[A-E]$/.test(letter)) { textOpts.push({ letter, text: txt, element: null, isSelected: false }); } } if (textOpts.length >= 2) { // 再检查是否有正确标记(常规DOM标记) const hasRightMarks = /(bgright|rightAnswer|userAnswer|checked|correct|green)/i.test(raw); if (hasRightMarks) { textOpts.forEach(opt => { const re = new RegExp(opt.letter + '[^<]{0,50}(' + ['bgright', 'rightAnswer', 'userAnswer', 'checked', 'correct', 'green'].join('|') + ')', 'i'); if (re.test(raw)) opt.isSelected = true; }); } // 【修复v3.6.8】从文本中提取答案标记并标注到选项上(方案7提前return导致方案8没机会执行) var _ct = container.innerText || ''; var _cMatch = _ct.match(/正确答案[::]\s*([A-E]+)/i); var _uMatch = _ct.match(/(?:我的答案|你[的得]答案|学生答案|已选|选择答案|作答答案)[::]\s*([A-E]+)/i); var _jcMatch = _ct.match(/正确答案[::]\s*(对|错|正确|错误|√|×)/i); var _juMatch = _ct.match(/(?:我的答案|你[的得]答案)[::]\s*(对|错|正确|错误|√|×)/i); if (_cMatch || _uMatch || _jcMatch || _juMatch) { var _correctLetters = _cMatch ? _cMatch[1].toUpperCase().split('') : []; var _userLetters = _uMatch ? _uMatch[1].toUpperCase().split('') : []; if (_jcMatch) { var _jv = _jcMatch[1]; _correctLetters.push((_jv==='对'||_jv==='正确'||_jv==='√')?'A':'B'); } if (_juMatch) { var _jv2 = _juMatch[1]; _userLetters.push((_jv2==='对'||_jv2==='正确'||_jv2==='√')?'A':'B'); } textOpts.forEach(function(_o) { if (_correctLetters.indexOf(_o.letter)!==-1) _o.isCorrectAnswer = true; if (_userLetters.indexOf(_o.letter)!==-1) _o.isUserAnswer = true; if (_o.isCorrectAnswer || _o.isUserAnswer) _o.isSelected = true; }); } // 【修复v3.6.8】从DOM中提取答案(.stuAnswerContent/.rightAnswerContent 等) var _stuAns = container.querySelector('.stuAnswerContent'); var _rightAns = container.querySelector('.rightAnswerContent'); var _myAnsBx = container.querySelector('.myAnswerBx .answerCon, .myAnswer .answerCon'); if (_stuAns || _rightAns || _myAnsBx) { var _ansText = ''; if (_stuAns) _ansText = _stuAns.innerText.trim(); if (_rightAns) { var _rt = _rightAns.innerText.trim(); if (_rt && !_ansText) _ansText = _rt; } if (_myAnsBx) { var _mt = _myAnsBx.innerText.trim().replace(/我的答案[::]\s*/i,''); if (_mt && !_ansText) _ansText = _mt; } // 找到答案字母(a-e)并标记 var _ansLetters = _ansText.match(/[A-E]/gi); if (_ansLetters) { _ansLetters = _ansLetters.map(function(l){return l.toUpperCase();}); textOpts.forEach(function(_o) { if (_ansLetters.indexOf(_o.letter)!==-1) { _o.isSelected = true; _o.isUserAnswer = true; _o.isCorrectAnswer = true; } }); } } return textOpts; } } // 方案8: 从页面"正确答案"/"我的答案"文字中提取答案(已批阅页常用) // 【独立方案】不依赖前面DOM方案是否提取到选项,自己从文本中提取选项+答案标记 { var containerText = container.innerText || ''; // 匹配"正确答案:A"、"正确答案:B"、"正确答案:AB"等 var correctMatch = containerText.match(/正确答案[::]\s*([A-E]+)/i); // 匹配"我的答案:A"、"你的答案:B"、"学生答案:C"、"已选:D"等 var userMatch = containerText.match(/(?:我的答案|你[的得]答案|学生答案|已选|选择答案|作答答案)[::]\s*([A-E]+)/i); // 判断题:匹配"正确答案:对/错"、"我的答案:对/错" var judgeCorrect = containerText.match(/正确答案[::]\s*(对|错|正确|错误|√|×)/i); var judgeUser = containerText.match(/(?:我的答案|你[的得]答案)[::]\s*(对|错|正确|错误|√|×)/i); if (correctMatch || userMatch || judgeCorrect || judgeUser) { var correctLetters = correctMatch ? correctMatch[1].toUpperCase().split('') : []; var userLetters = userMatch ? userMatch[1].toUpperCase().split('') : []; // 判断题特殊处理:匹配对/错对应 A/B 选项 var judgeCorrectLetter = ''; if (judgeCorrect) { var jv = judgeCorrect[1]; judgeCorrectLetter = (jv === '对' || jv === '正确' || jv === '√') ? 'A' : 'B'; correctLetters.push(judgeCorrectLetter); } var judgeUserLetter = ''; if (judgeUser) { var jv = judgeUser[1]; judgeUserLetter = (jv === '对' || jv === '正确' || jv === '√') ? 'A' : 'B'; userLetters.push(judgeUserLetter); } if (options.length > 0) { // 有DOM提取的选项,直接标注 options.forEach(function(opt) { if (correctLetters.indexOf(opt.letter) !== -1) opt.isCorrectAnswer = true; if (userLetters.indexOf(opt.letter) !== -1) opt.isUserAnswer = true; }); // 如果只有正确答案没有用户答案,且有对/错标记,把用户答案也设上 if (judgeCorrect && !judgeUser && judgeCorrectLetter) { options.forEach(function(opt) { if (opt.letter === judgeCorrectLetter) opt.isUserAnswer = opt.isUserAnswer || opt.isCorrectAnswer; }); } } else { // 无DOM选项,自己从文本提取 A. xxx B. xxx 模式并标注 var optPattern = /([A-Ea-e][\.\、\))\s]\s*)/g; var parts = containerText.split(optPattern); var textOpts = []; for (var i = 1; i < parts.length - 1; i += 2) { var letter = parts[i].trim().charAt(0).toUpperCase(); var txt = (parts[i + 1] || '').trim().substring(0, 80); if (txt && txt.length < 100 && /^[A-Ea-e]$/.test(letter)) { var opt = { letter: letter, text: txt, element: null, isSelected: false }; if (correctLetters.indexOf(letter) !== -1) opt.isCorrectAnswer = true; if (userLetters.indexOf(letter) !== -1) opt.isUserAnswer = true; textOpts.push(opt); } } // 如果文本提取到了足够选项且有答案标记,就用它 if (textOpts.length >= 2 && (correctLetters.length > 0 || userLetters.length > 0)) { options = textOpts; } } } } // 【修复v3.6.8】DOM答案检测——从容器中提取.stuAnswerContent/.rightAnswerContent等 // 不管前面哪个方案提取的选项,只要容器里有答案DOM就标注上去 if (container) { var _domStu = container.querySelector('.stuAnswerContent'); var _domRight = container.querySelector('.rightAnswerContent'); var _domMyBx = container.querySelector('.myAnswerBx .answerCon, .myAnswer .answerCon'); if (_domStu || _domRight || _domMyBx) { var _domAnsText = ''; if (_domStu) _domAnsText = _domStu.innerText.trim(); if (_domRight && !_domAnsText) _domAnsText = _domRight.innerText.trim(); if (_domMyBx && !_domAnsText) _domAnsText = _domMyBx.innerText.trim().replace(/我的答案[::]\s*/i, ''); var _domAnsLetters = _domAnsText.match(/[A-E]/gi); if (_domAnsLetters) { _domAnsLetters = _domAnsLetters.map(function(l){return l.toUpperCase();}); options.forEach(function(_o) { if (_domAnsLetters.indexOf(_o.letter) !== -1) { _o.isUserAnswer = true; _o.isCorrectAnswer = true; _o.isSelected = true; } }); } } } // 【修复v3.6.10】跳过填空题/简答题的判断题误判 // 【修复v3.6.10】跳过填空题/简答题的判断题误判(支持全角/半角括号) var _isFillOrEssay = container && /[((](填空[题]?|简答[题]?|论述[题]?|问答[题]?)[))]/.test(container.innerText || ''); // 【修复v3.6.8】判断题选项提取——无DOM选项但文本含对/错时,创建A=对 B=错 if (options.length === 0 && !_isFillOrEssay) { var _judgeCt = (container ? container.innerText || '' : ''); if (/[对错正确错误√×]/.test(_judgeCt)) { var _jCorr = _judgeCt.match(/正确答案[::]\s*(对|错|正确|错误|√|×)/i); var _jUser = _judgeCt.match(/(?:我的答案|你[的得]答案|学生答案|已选|选择答案|作答答案)[::]\s*(对|错|正确|错误|√|×)/i); if (_jCorr || _jUser) { var _jOptA = { letter:'A', text:'对', element:null, isSelected:false, isUserAnswer:false, isCorrectAnswer:false }; var _jOptB = { letter:'B', text:'错', element:null, isSelected:false, isUserAnswer:false, isCorrectAnswer:false }; if (_jCorr) { if (_jCorr[1]==='对'||_jCorr[1]==='正确'||_jCorr[1]==='√') { _jOptA.isCorrectAnswer=true; _jOptA.isSelected=true; } else { _jOptB.isCorrectAnswer=true; _jOptB.isSelected=true; } } if (_jUser) { if (_jUser[1]==='对'||_jUser[1]==='正确'||_jUser[1]==='√') { _jOptA.isUserAnswer=true; _jOptA.isSelected=true; } else { _jOptB.isUserAnswer=true; _jOptB.isSelected=true; } } options = [_jOptA, _jOptB]; } } } // 后处理:区分"我的答案(isUserAnswer)"和"正确答案(isCorrectAnswer)" options.forEach(function(opt) { if (opt.element) { var el = opt.element; if (opt.isUserAnswer === undefined) { opt.isUserAnswer = el.classList.contains('userAnswer') || el.classList.contains('cur') || el.classList.contains('checked') || el.classList.contains('check_answer') || el.classList.contains('check_answer_dx') || (el.closest && el.closest('.userAnswer, .cur, .checked, .check_answer, .check_answer_dx') !== null); } if (opt.isCorrectAnswer === undefined) { opt.isCorrectAnswer = el.classList.contains('bgright') || el.classList.contains('rightAnswer') || el.classList.contains('correct') || el.classList.contains('green') || (el.closest && el.closest('.bgright, .rightAnswer, .correct') !== null); } } else { if (opt.isUserAnswer === undefined) opt.isUserAnswer = !!opt.isSelected; if (opt.isCorrectAnswer === undefined) opt.isCorrectAnswer = !!opt.isSelected; } // 向后兼容 opt.isSelected = opt.isUserAnswer || opt.isCorrectAnswer; }); return options; }, detectByInputs() { const questions = []; // === 检测填空题(text inputs / textareas / contenteditable)=== const fillContainers = new Set(); document.querySelectorAll('input[type="text"], input:not([type]), textarea, [contenteditable="true"]').forEach(el => { if (el.offsetWidth === 0 && el.offsetHeight === 0) return; // 隐藏元素跳过 // 找最近的题目容器 let container = el.closest('[typename], .questionLi, .TiMu, .previewTiMu, .singleQuesId, .mark_item, .question, div.row, li.clearfix, .answerCard, .queBox, .queItem, [data-type]'); if (!container) return; // 不在已知题目容器中,跳过(防顶层页面误检) if (fillContainers.has(container)) return; fillContainers.add(container); // 提取题干 let titleText = ''; for (const sel of ['h3', 'h2', 'h4', '.title', '.Zy_TItle', '.fontLabel', '.mark_name', '.mark_title', '.qtContent', 'label', 'span.subject_type', 'strong']) { const titleEl = container.querySelector(sel); if (titleEl && titleEl.innerText.trim().length > 1) { titleText = titleEl.innerText.trim(); break; } } if (!titleText) { // 取容器内非input的文本作为题干 const clone = container.cloneNode(true); clone.querySelectorAll('input, textarea, select, button').forEach(n => n.remove()); const text = clone.innerText?.trim().substring(0, 100); if (text && text.length > 3) titleText = text; } if (!titleText) titleText = '填空题' + (fillContainers.size); titleText = titleText.replace(/^\d+[\.\、\)\]\s]+/, '').trim(); // 收集填空输入框 const inputs = Array.from(container.querySelectorAll('input[type="text"], input:not([type]), textarea, [contenteditable="true"]')) .filter(inp => inp.offsetWidth > 0); questions.push({ text: titleText, type: 'fill', questionId: container.getAttribute('data') || container.getAttribute('questionid') || '', options: inputs.map((inp, i) => ({ letter: String.fromCharCode(65 + i), text: inp.placeholder || `填空${i + 1}`, element: inp, isSelected: !!inp.value?.trim() })), container, index: questions.length }); }); // === 检测UEditor填空题(百度UEditor富文本编辑器)=== // 学习通填空题使用UEditor,每个空位是一个独立的编辑器实例 // 编辑器ID格式:answerEditor{questionId}{blankIndex}(blankIndex从1开始) document.querySelectorAll('[id^="answerEditor"]').forEach(edDiv => { // 找最近的题目容器 let container = edDiv.closest('[typename], .questionLi, .TiMu, .previewTiMu, .singleQuesId, .mark_item, .question, div.row, li.clearfix, .answerCard, .queBox, .queItem, [data-type]'); if (!container) return; if (fillContainers.has(container)) return; // 已通过input方式检测到,不重复 fillContainers.add(container); // 提取题干 let titleText = ''; for (const sel of ['h3', 'h2', 'h4', '.title', '.Zy_TItle', '.fontLabel', '.mark_name', '.mark_title', '.qtContent', 'label', 'span.subject_type', 'strong']) { const titleEl = container.querySelector(sel); if (titleEl && titleEl.innerText.trim().length > 1) { titleText = titleEl.innerText.trim(); break; } } if (!titleText) { const clone = container.cloneNode(true); clone.querySelectorAll('[id^="answerEditor"], script, style').forEach(n => n.remove()); const text = clone.innerText?.trim().substring(0, 100); if (text && text.length > 3) titleText = text; } if (!titleText) titleText = '填空题' + (fillContainers.size); titleText = titleText.replace(/^\d+[\.\、\)\]\s]+/, '').trim(); // 从editor ID提取questionId let qId = container.getAttribute('data') || container.getAttribute('questionid') || ''; if (!qId) { const idMatch = edDiv.id.match(/answerEditor(\d+)\d/); if (idMatch) qId = idMatch[1]; } if (!qId) { // 从容器内的script标签提取 const scriptTag = container.querySelector('script'); if (scriptTag) { const m = scriptTag.textContent.match(/UE\.getEditor\("answerEditor(\d+)\d"/); if (m) qId = m[1]; } } const blanks = container.querySelectorAll('[id^="answerEditor"]'); questions.push({ text: titleText, type: 'fill', questionId: qId, options: Array.from(blanks).map((ed, i) => ({ letter: String.fromCharCode(65 + i), text: `填空${i + 1}`, element: ed, isSelected: false })), container, index: questions.length }); }); // === 检测选择题(radio / checkbox 分组)=== const radioGroups = {}; const checkboxGroups = {}; document.querySelectorAll('input[type="radio"]').forEach(inp => { if (!inp.name) return; if (!radioGroups[inp.name]) radioGroups[inp.name] = []; radioGroups[inp.name].push(inp); }); document.querySelectorAll('input[type="checkbox"]').forEach(inp => { if (!inp.name) return; if (!checkboxGroups[inp.name]) checkboxGroups[inp.name] = []; checkboxGroups[inp.name].push(inp); }); const groups = []; for (const [name, inputs] of Object.entries(radioGroups)) { if (inputs.length >= 2) groups.push({ type: 'single', inputs, name }); } for (const [name, inputs] of Object.entries(checkboxGroups)) { if (inputs.length >= 2) groups.push({ type: 'multiple', inputs, name }); } if (groups.length === 0 && questions.length === 0) return []; if (questions.length > 0) log(`兜底填空检测: ${questions.length} 个填空`); if (groups.length > 0) log(`兜底选择题检测: ${groups.length} 个选项组`); groups.forEach((group, idx) => { const firstInput = group.inputs[0]; let container = firstInput.closest('[typename], .questionLi, .TiMu, .previewTiMu, div, li'); let titleText = ''; if (container) { for (const sel of ['h3', 'h2', 'h4', '.title', '.Zy_TItle', '.fontLabel', '.mark_name']) { const h = container.querySelector(sel); if (h) { titleText = h.innerText.trim(); break; } } } if (!titleText) titleText = '题目' + (idx + 1); titleText = titleText.replace(/^\d+[\.\、\)\]\s]+/, '').trim(); const options = group.inputs.map((inp, j) => { const letter = String.fromCharCode(65 + j); const label = inp.closest('label, .answerBg, li, div'); const text = label ? label.innerText.trim().substring(0, 80) : letter; return { letter, text, element: label || inp, isSelected: inp.checked }; }); const qId = group.name.match(/(\d+)/)?.[1] || ''; questions.push({ text: titleText, type: group.type, questionId: qId, options, container: container || document.body, index: idx }); }); return questions; }, getEnvInfo() { return { url: location.href, hostname: location.hostname, isTop: isTop(), questionLi: document.querySelectorAll('.questionLi').length, tiMu: document.querySelectorAll('.TiMu').length, previewTiMu: document.querySelectorAll('.previewTiMu').length, singleQuesId: document.querySelectorAll('.singleQuesId').length, typename: document.querySelectorAll('[typename]').length, answerBg: document.querySelectorAll('.answerBg').length, radioInput: document.querySelectorAll('input[type="radio"]').length, checkboxInput: document.querySelectorAll('input[type="checkbox"]').length, textarea: document.querySelectorAll('textarea').length, ueditor: document.querySelectorAll('[id^="answerEditor"]').length, fillInputs: document.querySelectorAll('input[type="text"], input:not([type])').length, fillTextareas: document.querySelectorAll('textarea').length, fontLabel: document.querySelectorAll('.fontLabel').length, markName: document.querySelectorAll('.mark_name').length, bodyTextLen: document.body?.innerText?.length || 0, title: document.title, pageType: Detectors.detectPageType() }; }, detectPageType() { const p = location.pathname, u = location.href; if (p.includes('/exam/test/reVersionTestStartNew') || u.includes('reVersionTestStartNew')) return 'exam'; if (p.includes('/work/dowork') || p.includes('/work/doHomeWork') || p.includes('/mooc2/work/') || p.includes('/mooc-ans/mooc2/work/')) return 'work'; if (document.querySelector('.questionLi') || document.querySelector('.singleQuesId')) return 'work'; if (document.querySelector('.TiMu') || document.querySelector('.previewTiMu')) return 'exam'; return 'unknown'; } }; // ========================================== // IndexedDB 题库(6.x 原版) // ========================================== const Bank = { db: null, async init() { if (this.db) return; return new Promise((resolve, reject) => { const req = indexedDB.open('SmartStudyDB', 1); req.onupgradeneeded = e => { const db = e.target.result; if (!db.objectStoreNames.contains('questions')) db.createObjectStore('questions', { keyPath: 'hash' }); }; req.onsuccess = e => { this.db = e.target.result; resolve(); }; req.onerror = e => reject(e.target.error); }); }, _hash(text) { let h = 5381; const s = text.trim().replace(/\s+/g, ' '); for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) & 0x7FFFFFFF; return h.toString(36); }, _clean(raw) { return raw.replace(/^\d+[\.\、\)\]\s]+/, '').replace(/\s+/g, ' ').trim(); }, async query(text) { if (!this.db) await this.init(); const hash = this._hash(this._clean(text)); return new Promise(r => { const req = this.db.transaction('questions', 'readonly').objectStore('questions').get(hash); req.onsuccess = () => r(req.result); req.onerror = () => r(null); }); }, async save(data) { if (!this.db) await this.init(); const q = this._clean(data.question); const record = { hash: this._hash(q), question: q, answer: data.answer, type: data.type || 'unknown', platform: 'chaoxing', timestamp: Date.now() }; return new Promise(r => { const tx = this.db.transaction('questions', 'readwrite'); tx.objectStore('questions').put(record); tx.oncomplete = () => r(true); tx.onerror = () => r(false); }); }, async getStats() { if (!this.db) await this.init(); return new Promise(r => { const req = this.db.transaction('questions', 'readonly').objectStore('questions').count(); req.onsuccess = () => r({ total: req.result }); req.onerror = () => r({ total: 0 }); }); }, async exportAll() { if (!this.db) await this.init(); return new Promise(r => { const req = this.db.transaction('questions', 'readonly').objectStore('questions').getAll(); req.onsuccess = () => r(req.result); req.onerror = () => r([]); }); }, async importData(records) { if (!this.db) await this.init(); let added = 0; for (const rec of records) { const existing = await this.query(rec.question || rec.text); if (!existing) added++; await this.save({ question: rec.question || rec.text, answer: rec.answer, type: rec.type }); } return { added, total: records.length }; } }; // ========================================== // 倍速控制(v3.6 重写 — 直接操作DOM,不依赖postMessage) // 所有 iframe 同源,可直接遍历 contentDocument 找到 video 元素 // ========================================== const Speed = { running: false, rate: 2, _timer: null, _findVideos(doc, depth) { if (depth === undefined) depth = 0; if (depth > 5) return []; const videos = []; try { doc.querySelectorAll('video, audio').forEach(v => videos.push(v)); doc.querySelectorAll('iframe').forEach(f => { try { const fd = f.contentDocument; if (fd && fd.readyState === 'complete') { videos.push(...this._findVideos(fd, depth + 1)); } } catch(e) {} }); } catch(e) {} return videos; }, start(rate = 2) { this.rate = rate; this.running = true; localStorage.setItem('zhixue_speed', rate); this._apply(rate); this._startPoll(); log(`倍速 ${rate}x 已启动`); }, stop() { this.running = false; localStorage.removeItem('zhixue_speed'); this._stopPoll(); this._findVideos(document).forEach(el => { try { el.playbackRate = 1; el.defaultPlaybackRate = 1; } catch (e) {} }); log('倍速已停止'); }, setRate(rate) { this.rate = rate; localStorage.setItem('zhixue_speed', rate); if (this.running) { this._apply(rate); this._startPoll(); } }, _apply(rate) { this._findVideos(document).forEach(el => { try { el.playbackRate = rate; el.defaultPlaybackRate = rate; } catch (e) {} }); }, _startPoll() { this._stopPoll(); this._timer = setInterval(() => { if (this.running) this._apply(this.rate); }, 800); }, _stopPoll() { if (this._timer) { clearInterval(this._timer); this._timer = null; } } }; // ========================================== // 自动刷课(v2.0 重写 — 任务点驱动,不依赖视频事件) // 核心思路参考 unrival/chaoxing-ai 等开源方案: // pan-yz 播放器不是标准