// ==UserScript== // @name 智学助手 - 学习通自动答题+倍速 // @namespace zhixue-assistant // @version 3.1.0 // @description 全自动刷课:倍速播放+自动下一节+AI答题+字体解密,支持学习通等平台(脚本猫版) // @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/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_notification // @connect api.zhixue.icu // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; /** * 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: '请求超时' }), }); }); }, // 登录 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 }), 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); } }, }); }); }, }; // ========================================== // 字体解密(v5.0.0 新增,脚本猫版适配) // 学习通使用 font-cxsecret 自定义字体加密题目和选项文字 // HTML源码中的Unicode字符是乱码,通过自定义字体渲染后才显示正确文字 // .innerText 拿到的是乱码Unicode,导致答案文本匹配永远失败 // 解密后替换DOM中的乱码字符,使 .innerText 返回正确汉字 // ========================================== const FontDecrypt = { _charMap: null, _styleChecked: false, _tableLoaded: false, async decrypt() { const hasEncrypted = document.querySelectorAll('.font-cxsecret').length > 0; if (!hasEncrypted && this._charMap) return true; if (this._charMap) { const replacedCount = this._replaceEncryptedText(this._charMap); if (replacedCount > 0) log(`增量字体解密: 替换${replacedCount}处新文本`); return true; } try { const styleEl = this._findFontStyle(); if (!styleEl) { log('未检测到加密字体(font-cxsecret),无需解密'); this._styleChecked = true; return true; } const fontMatch = styleEl.textContent.match(/base64,([\w\W]+?)'/); if (!fontMatch) { warn('找到加密字体样式但无法提取base64数据'); return false; } if (typeof Typr === 'undefined' || typeof md5 === 'undefined') { warn('Typr/md5 库未加载,无法解密字体'); return false; } const fontData = Typr.parse(this._base64ToUint8Array(fontMatch[1])); if (!fontData || !fontData[0]) { warn('TTF字体解析失败'); return false; } // 脚本猫版:从后端API加载字体映射表 let table; if (!this._tableLoaded) { table = await Api.loadFontTable(); if (table) { this._tableLoaded = true; } else { warn('字体映射表加载失败'); return false; } } else { // 用缓存 table = await Api.loadFontTable(); } if (!table) { warn('字体映射表为空'); return false; } const charMap = this._createCharMap(fontData[0], table); const mapCount = Object.keys(charMap).length; if (mapCount === 0) { warn('字符映射表为空,解密失败'); return false; } this._charMap = charMap; const replacedCount = this._replaceEncryptedText(charMap); log(`✅ 字体解密完成: 映射${mapCount}个字符, 替换${replacedCount}处文本`); return true; } catch (e) { err('字体解密异常:', e.message); return false; } }, _findFontStyle() { return Array.from(document.querySelectorAll('style')).find(s => s.textContent.includes('font-cxsecret')) || null; }, _base64ToUint8Array(base64) { const data = window.atob(base64); return new Uint8Array([...data].map(c => c.charCodeAt(0))); }, _createCharMap(font, table) { const charMap = {}; for (let i = 19968; i < 40870; i++) { try { const glyph = Typr.U.codeToGlyph(font, i); if (!glyph) continue; const path = Typr.U.glyphToPath(font, glyph); const pathHash = md5(JSON.stringify(path)).slice(24); if (table[pathHash]) { charMap[String.fromCharCode(i)] = String.fromCharCode(table[pathHash]); } } catch (e) { /* skip individual glyph errors */ } } return charMap; }, _replaceEncryptedText(charMap) { const elements = document.querySelectorAll('.font-cxsecret'); let count = 0; elements.forEach(el => { let changed = false; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); const textNodes = []; while (walker.nextNode()) textNodes.push(walker.currentNode); for (const node of textNodes) { let text = node.textContent; let nodeChanged = false; for (const [enc, dec] of Object.entries(charMap)) { if (text.includes(enc)) { text = text.split(enc).join(dec); nodeChanged = true; } } if (nodeChanged) { node.textContent = text; changed = true; } } if (changed) { el.classList.remove('font-cxsecret'); count++; } }); return count; } }; // ========================================== // 答案匹配引擎(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]' ]; 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) { // 按题干去重,避免嵌套容器导致一道题算多次 const titleKey = q.text.trim().substring(0, 50); if (!seenTitles.has(titleKey)) { seenTitles.add(titleKey); questions.push(q); } } }); } 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', 'h3', 'h2', 'h4', '.title', '.mark_name' ]; 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; } 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'; const questionId = container.getAttribute('data') || container.getAttribute('questionid') || container.id || ''; const options = Detectors.extractOptions(container); if (type === 'unknown') { type = Detectors.guessType(container, options); } return { text: titleText, type, questionId, options, container, index }; }, 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'; } 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) { const options = []; 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; } 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; } const lis = container.querySelectorAll('li.after, .options li, .num_option'); if (lis.length > 0) { lis.forEach((li, i) => { const letter = String.fromCharCode(65 + i); const text = li.innerText.trim().substring(0, 80); options.push({ letter, text, element: li, isSelected: false }); }); return options; } 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) return labelOpts; return options; }, detectByInputs() { const questions = []; 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) return []; 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, 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 }; } }; // ==========================================// 倍速控制(6.x 原版) // ========================================== const Speed = { running: false, rate: 1, interval: null, start(rate = 2) { this.rate = rate; this.running = true; Speed.applyRate(rate); if (this.interval) clearInterval(this.interval); this.interval = setInterval(() => { if (this.running) Speed.applyRate(rate); }, 3000); log(`倍速 ${rate}x 已启动`); }, stop() { this.running = false; this.rate = 1; if (this.interval) { clearInterval(this.interval); this.interval = null; } Speed.applyRate(1); log('倍速已停止'); }, setRate(rate) { this.rate = rate; if (this.running) Speed.applyRate(rate); }, applyRate(rate) { // 先更新全局速率(劫持器以此判断是否允许降速,必须在设视频之前更新) window.__ssSpeedRate = rate; // 当前窗口的视频直接设置 document.querySelectorAll('video, audio').forEach(el => { el.playbackRate = rate; el.defaultPlaybackRate = rate; }); // 同域 iframe 直接操作 document.querySelectorAll('iframe').forEach(f => { try { f.contentDocument?.querySelectorAll('video, audio').forEach(el => { el.playbackRate = rate; el.defaultPlaybackRate = rate; }); } catch (e) {} }); // 跨域 iframe 通过 postMessage 广播 document.querySelectorAll('iframe').forEach(f => { try { f.contentWindow.postMessage({ channel: 'ZHIXUE_COMMAND', type: 'SET_SPEED', rate }, '*'); } catch (e) {} }); if (!window.__ssSpeedPatched) { try { const origDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate'); if (origDesc?.set) { const origSet = origDesc.set; Object.defineProperty(HTMLMediaElement.prototype, 'playbackRate', { get: origDesc.get, set: function(v) { if (window.__ssSpeedRate && v < window.__ssSpeedRate) { origSet.call(this, window.__ssSpeedRate); return; } origSet.call(this, v); }, configurable: true }); window.__ssSpeedPatched = true; } } catch (e) {} } } }; // ========================================== // 自动刷课(v2.0 重写 — 任务点驱动,不依赖视频事件) // 核心思路参考 unrival/chaoxing-ai 等开源方案: // pan-yz 播放器不是标准