// ==UserScript== // @name 【题对对】学习通超高分多平台网课一站式自动化助手 // @description 支持学习通,智慧树,青书学院,雨课堂。 // @antifeature payment 脚本会请求第三方收费题库进行答题,您可以选择付费或停用答题功能。 // @namespace 奶龙 // @version 1.0.8 // @author 奶龙 // Learning routes only; runtime checks platform, page structure and course identity. // @match *://*/mooc2-ans/* // @match *://*/mooc-ans/* // @match *://*/mycourse/* // @match *://*/knowledge/* // @match *://*/work/* // @match *://*/mooc2/work/* // @match *://*/workHandle/* // @match *://*/exam-ans/* // @match *://*/ananas/* // @match *://webvpn.ahmu.edu.cn/http/* // @match *://webvpn.ahmu.edu.cn/https/* // @match *://*.yuketang.cn/v2/web/* // @match *://*.xuetangx.com/v2/web/* // @match *://examination.xuetangx.com/exam/* // @match *://examination.xuetangx.com/subject* // @match *://studyvideoh5.zhihuishu.com/stuStudy* // @match *://onlineexamh5new.zhihuishu.com/stuExamWeb.html* // @match *://onlineweb.zhihuishu.com/onlinestuh5* // @match *://*.qingshuxuetang.com/*/Student/* // @run-at document-end // @icon http://pan-yz.chaoxing.com/favicon.ico // @storageName 【题对对】 // @grant unsafeWindow // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_openInTab // Request permissions are independent of page matching; keep school API hosts. // @connect chaoxing.com // @connect xueyinonline.com // @connect yuketang.cn // @connect xuetangx.com // @connect zhihuishu.com // @connect qingshuxuetang.com // @connect self // @connect smjyxy.cn // @connect mooc1.wljx.hfut.edu.cn // @connect mooc1.hnust.edu.cn // @connect stat2-ans.hnust.edu.cn // @connect stat2-ans.istudy.szpu.edu.cn // @connect mooc1.hnsyu.net // @connect passport2.xust.edu.cn // @connect stat2-ans.hnsyu.net // @connect passport2.hnsyu.net // @connect mooc1.gdhkmooc.com // @connect stat2-ans.gdhkmooc.com // @connect mooc1.zut.edu.cn // @connect passport2.hncj.edu.cn // @connect stat2-ans.zut.edu.cn // @connect passport2.jxjyzx.xust.edu.cn // @connect passport2.zut.edu.cn // @connect stat2-ans.wljx.hfut.edu.cn // @connect passport2.wljx.hfut.edu.cn // @connect mooc1.hncj.edu.cn // @connect stat2-ans.hncj.edu.cn // @connect mooc1.qutjxjy.cn // @connect stat2-ans.qutjxjy.cn // @connect passport2.hnust.edu.cn // @connect passport2.cqrspx.cn // @connect passport2.gdhkmooc.com // @connect passport2.qutjxjy.cn // @connect mooc1.jxjyzx.xust.edu.cn // @connect passport2.hnvist.cn // @connect passport2.cugbonline.cn // @connect stat2-ans.jxjyzx.xust.edu.cn // @connect passport2.ynny.cn // @connect passport2.ecnusole.com // @connect mooc1.cqrspx.cn // @connect stat2-ans.cqrspx.cn // @connect mooc1.ynny.cn // @connect stat2-ans.ynny.cn // @connect mooc1.cugbonline.cn // @connect stat2-ans.cugbonline.cn // @connect mooc1.xust.edu.cn // @connect stat2-ans.xust.edu.cn // @connect mooc1.xynu.edu.cn // @connect mooc1.ntu.edu.cn // @connect stat2-ans.xynu.edu.cn // @connect stat2-ans.ntu.edu.cn // @connect mooc1.hnvist.cn // @connect stat2-ans.hnvist.cn // @connect passport2.xynu.edu.cn // @connect passport2.ntu.edu.cn // @connect mooc1.ecnusole.com // @connect stat2-ans.ecnusole.com // @connect mooc.s.ecust.edu.cn // @connect webvpn.ahmu.edu.cn // @connect toptk.xyz // @license 二开需获得原作者授权 // @compatible firefox // @compatible chrome // @compatible edge // @supportURL https://www.toptk.xyz/ // ==/UserScript== // GENERATED: npm run build:userscript; edit userscripts/src, not this file. (function runnerBoot(attempt) { "use strict"; if (!runnerPageCandidate(location.href)) return; if (!activateRunnerPage(location.href,document,typeof unsafeWindow!=="undefined"?unsafeWindow:window)) { if((attempt||0)<20)setTimeout(()=>runnerBoot((attempt||0)+1),500);return; } const $w = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const $d = document; const IS_TOP_WINDOW = (() => { try { return window.top === window; } catch (_) { return false; } })(); if (!IS_TOP_WINDOW) { publishFrameContext(); return; } const DEFAULT_ANSWER_API_URL = ['aHR0cHM6Ly93d3cudG9wdGsu', 'eHl6L2FwaS9hbnN3ZXI='].map(x => atob(x)).join(''); const RECHARGE_URL = 'https://www.toptk.xyz/recharge'; const DASHBOARD_CTX_KEY = 'cx-runner-dashboard-context'; const DEFAULT_CONFIG = { doVideo: true, doDocument: true, doWork: true, zhihuishuAutoSubmit: false, zhihuishuVideoQuiz: true, answerApiUrl: DEFAULT_ANSWER_API_URL, answerApiToken: '', speed: 1.0, stepSeconds: 25, requestDelayMinMs: 800, requestDelayMaxMs: 1800, answerDelayMinMs: 5000, answerDelayMaxMs: 5000, answerVerifyDelayMs: 1000, answerRetryDelayMs: 1500, retryTimes: 3, videoTaskRetryTimes: 3, videoTaskRetryDelayMs: 15000, scanConcurrency: 6, autoCourseQueue: true, qingshuAutoCourseQueue: true, qingshuAutoComment: true, cardsRenderWaitMs: 2500, announcementRefreshMs: 300000, unlockRescanRounds: 12, lockedChapterRetries: 3, lockedChapterRetryDelayMs: 5000, autoStart: false }; const CX_FONT_HASH_MAP = Object.create(null); const STORE_KEY = 'cx_runner_config_v1'; function zhsNativeVisible(element, viewport = false){if(!element ||!element.isConnected)return false;const view=element.ownerDocument.defaultView;for(let node=element;node&&node.nodeType===1;node=node.parentElement){const style=view.getComputedStyle(node);if(node.hidden||style.display==='none'||style.visibility==='hidden'||Number(style.opacity||'1')===0)return false;}const rect=element.getBoundingClientRect();if(rect.width<=0||rect.height<=0)return false;return !viewport || rect.bottom>0&&rect.right>0&&rect.toprow.current).length>1)throw new Error('智慧树当前播放项不唯一,已停止');return rows;} function zhsNativePlayer(doc){return doc.querySelector('video#vjs_container_html5_api')|| doc.querySelector('#vjs_container video.vjs-tech');} function zhsNativeRestriction(doc){const warning='检测到异常脚本,已停止。请关闭插件后重新进入视频页';const matches=element=>{if(!element||element.closest('#cx-zhs-native-status,#cx-runner-quick-start,#cx-runner-confirm,#cx-zhs-page-work')||!zhsNativeVisible(element,true))return false;const text=(element.textContent||'').replace(/\s+/g,'');return /检测到异常脚本|关闭插件后.{0,60}(?:视频|学习)/.test(text);};for(const dialog of doc.querySelectorAll('.el-message-box__wrapper,.el-dialog__wrapper,.dialog,[role="alertdialog"],[role="alert"],.vjs-modal-dialog')){if(matches(dialog))return warning;}if(doc.body){const walker=doc.createTreeWalker(doc.body,4);let node;while((node=walker.nextNode())){if(/异常脚本|关闭插件/.test(node.nodeValue||'')&&matches(node.parentElement))return warning;}}return '';} function zhsNativeBlocker(doc, allowVideoQuiz = false){for(const frame of doc.querySelectorAll('iframe')){let host='';try{host=new URL(frame.getAttribute('src')||'',doc.baseURI).hostname;}catch(_){}if(/captcha|yidun|turing\.captcha/i.test(host)&& zhsNativeVisible(frame,true))return '页面出现安全验证,请手动完成后继续';}for(const dialog of doc.querySelectorAll('.el-dialog__wrapper,.el-message-box__wrapper,.dialog')){if(!zhsNativeVisible(dialog,true))continue;if(allowVideoQuiz&&dialog.id==='playTopic-dialog')continue;const text=(dialog.textContent||'').trim();if(dialog.querySelector('input[type="radio"],input[type="checkbox"]')||/随堂|平时测试|安全验证|滑块|验证码/.test(text))return '页面出现验证或随堂题,请手动处理;脚本不作答';if(text)return '页面有提示窗口,请关闭后继续学习';}const question=doc.querySelector('.video-topic');if(question &&[...question.querySelectorAll('input[type="radio"],input[type="checkbox"]')].some(e=>zhsNativeVisible(e,true)))return '出现随堂题,已暂停;脚本不作答';if(doc.querySelector('#vjs_container.vjs-error')||zhsNativePlayer(doc)?.error)return '视频播放出错,请检查后继续学习';return '';} async function runZhsNativeVideoPage(doc, hooks ={}){const now=hooks.now||Date.now,sleep=hooks.sleep||(ms=>new Promise(resolve=>setTimeout(resolve,ms)));let rows=[],signature='',player=null,loadEpoch=0,loadListener=null,switching=null,seenKey='',lastPosition=-1,lastMovement=now(),waitingSince=now(),endSince=0;let boundPlayer=null,boundSrc='',boundEpoch=0;const initializedPlayers=new WeakSet(),advanced=new Set();let replayEndedOnce=hooks.replayEnded===true;let active=true,playPending=false,playError=null,lastAttempt=-Infinity,lastAttention='';const control=()=>hooks.control?.()||{};const pause=()=>{for(const media of new Set([player,zhsNativePlayer(doc)])){try{media?.pause();}catch(_){}}};const report=(state,message)=>{const current=rows.find(r=>r.current);const status={state,message,total:rows.length,completed:rows.filter(r=>r.finished).length,scope:hooks.autoAdvance?'page-videos':'current-video',currentFinished:!!current?.finished,playbackEnded:!!player?.ended,currentTitle:current?.title||'',currentTime:Number(player?.currentTime)||0,duration:Number.isFinite(player?.duration)?player.duration:0};hooks.status?.(status);return status;};const reset=()=>{waitingSince=lastMovement=now();if(switching)switching.since=now();};doc.addEventListener('visibilitychange',reset);const attention=message=>{pause();report('blocked',message);if(lastAttention!==message||!control().paused){lastAttention=message;hooks.attention?.(message);}};try{while(true){const command=control();if(doc.visibilityState==='hidden')reset();if(command.stopped)return report('stopped','学习已停止');if(hooks.pageValid&&!hooks.pageValid())return report('failed','课程页面已切换,学习已停止');if(doc.defaultView?.closed)return report('failed','课程页面已关闭');const restriction=zhsNativeRestriction(doc);if(restriction){pause();return report('restricted',restriction);}const blocker=zhsNativeBlocker(doc,!!hooks.answerQuiz);if(blocker){attention(blocker);waitingSince=now();lastMovement=now();await sleep(500);continue;}if(command.paused){pause();report('paused',lastAttention||'学习已暂停');waitingSince=now();lastMovement=now();endSince=0;playError=null;playPending=false;lastAttempt=-Infinity;await sleep(500);continue;}lastAttention='';const quiz=doc.getElementById('playTopic-dialog');if(hooks.answerQuiz&&quiz&&zhsNativeVisible(quiz,true)){pause();try{await hooks.answerQuiz(message=>report('answering',message));}catch(error){attention(error.message||'随堂题未处理成功,请手动核对');}waitingSince=now();lastMovement=now();lastAttempt=-Infinity;playError=null;playPending=false;await sleep(500);continue;}rows=zhsNativeCatalog(doc);if(!rows.length){if(now()-waitingSince>45000)attention('未读取到视频目录,请检查课程页是否加载完成');else report('loading','正在加载课程目录…');await sleep(500);continue;}const nextSignature=rows.map(r=>r.key).join('\n');if(signature&&signature!==nextSignature)return report('failed','视频目录发生变化,请重新开始,未继续点击');signature=nextSignature;const current=rows.find(r=>r.current);if(!current){attention('请先在原页面目录选择视频');await sleep(500);continue;}const nextPlayer=zhsNativePlayer(doc);if(nextPlayer!==player){if(player&&loadListener)player.removeEventListener('loadedmetadata',loadListener);player=nextPlayer;if(player&&!initializedPlayers.has(player)){player.muted=true;initializedPlayers.add(player);}loadEpoch++;loadListener=()=>{loadEpoch++;};player?.addEventListener('loadedmetadata',loadListener);playPending=false;playError=null;}if(switching&¤t.key!==seenKey&¤t.key!==switching.key)switching=null;if(!switching&&seenKey&¤t&¤t.key!==seenKey&&player===boundPlayer&&player?.currentSrc===boundSrc&&loadEpoch===boundEpoch)switching={key:current.key,player:boundPlayer,src:boundSrc,epoch:boundEpoch,since:now()};if(switching){const loaded=current?.key===switching.key&&player&&player.readyState>=2&&(player!==switching.player||player.currentSrc!==switching.src||loadEpoch>switching.epoch);if(!loaded){if(now()-switching.since>45000)attention('切换视频后仍未加载,请检查页面');else report('loading','正在加载视频…');await sleep(500);continue;}switching=null;waitingSince=now();lastMovement=now();lastPosition=-1;endSince=0;seenKey='';}if(!player||player.readyState<2||!Number.isFinite(player.duration)||player.duration<=0){if(now()-waitingSince>45000)attention('视频加载未完成,请检查网络或页面提示');else report('loading','正在加载视频…');await sleep(500);continue;}if(doc.visibilityState==='hidden'&&player.paused&&!player.ended){report('waiting','后台视频已暂停,可尝试画中画或分屏');await sleep(500);continue;}if(seenKey!==current.key){seenKey=current.key;lastPosition=player.currentTime;lastMovement=now();lastAttempt=-Infinity;playError=null;playPending=false;endSince=0;boundPlayer=player;boundSrc=player.currentSrc;boundEpoch=loadEpoch;}if(!player.ended)replayEndedOnce=false;if(player.ended&&!replayEndedOnce){if(!endSince)endSince=now();if(current.finished&&now()-endSince>=1500){const next=hooks.autoAdvance&&rows.slice(rows.indexOf(current)+1).find(r=>!r.finished);if(!next)return report('completed',hooks.autoAdvance?'后续视频已学完':'本节学习完成');if(advanced.has(next.key))return report('failed','下一节已尝试切换,请手动核对');report('loading','正在切换下一节:'+next.title);const element=next.element;element.scrollIntoView?.({block:'nearest'});if(control().paused||control().stopped||hooks.pageValid&&!hooks.pageValid()||zhsNativeRestriction(doc)||zhsNativeBlocker(doc,!!hooks.answerQuiz))continue;const fresh=zhsNativeCatalog(doc);if(fresh.map(r=>r.key).join('\n')!==signature||fresh.find(r=>r.current&&r.finished)?.key!==current.key||!player.ended||fresh.find(r=>r.key===next.key)?.finished)continue;if(!zhsNativeVisible(element,true)||element.closest('[disabled],[aria-disabled=true],.disabled,.is-disabled,.locked')||doc.defaultView.getComputedStyle(element).pointerEvents==='none'){attention('下一节不可播放,请检查课程目录');await sleep(500);continue;}switching={key:next.key,player,src:player.currentSrc,epoch:loadEpoch,since:now()};advanced.add(next.key);element.click();await sleep(500);continue;}if(now()-endSince>30000)attention('视频已结束,学习状态尚未更新,请核对后继续');else report('waiting','视频已结束,正在确认学习完成状态…');await sleep(500);continue;}endSince=0;if(player.currentTime>lastPosition+0.05){lastMovement=now();lastPosition=player.currentTime;}if(now()-lastMovement>90000){attention('视频长时间没有前进,请检查播放器后继续');await sleep(500);continue;}if(!player.paused){playError=null;playPending=false;}if(playError){attention('自动播放被阻止,请在原播放器手动点击播放后继续');await sleep(500);continue;}if(player.paused&&!playPending){if(lastAttempt!==-Infinity){if(now()-lastAttempt>=3000)attention('原播放器仍处于暂停状态,请手动播放后继续;不会自动反复启动');}else{lastAttempt=now();if(replayEndedOnce&&player.ended){lastPosition=-1;lastMovement=now();}replayEndedOnce=false;const currentPlayer=player;playPending=true;try{Promise.resolve(currentPlayer.play()).then(()=>{const c=control();if(!active||c.stopped||c.paused||currentPlayer!==player)currentPlayer.pause();}).catch(error=>{if(error?.name!=='AbortError')playError=error;}).finally(()=>{playPending=false;});}catch(error){playPending=false;playError=error;}}}report(player.paused?'loading':'playing',player.paused?'正在加载视频…':doc.visibilityState==='hidden'?'正在后台播放':'正在学习,请保持视频页打开');await sleep(500);}}finally{doc.removeEventListener('visibilitychange',reset);active=false;pause();if(player&&loadListener)player.removeEventListener('loadedmetadata',loadListener);}} function zhsNativeControls(doc, control, action){if(doc.getElementById('cx-zhs-native-status'))throw new Error('该课程页已有视频自动任务,请先停止旧任务');const ui=createStudyCard(doc,'cx-zhs-native-status','智慧树 · 当前页视频');let last={state:'loading'};const clock=s=>{s=Math.max(0,Math.floor(Number(s)||0));return Math.floor(s/60)+':'+String(s%60).padStart(2,'0');};ui.box.querySelector('[role=progressbar]').setAttribute('aria-label','当前视频播放进度');const pip=doc.createElement('button');pip.type='button';pip.textContent='画中画';pip.dataset.pip='';ui.box.querySelector('header').append(pip);pip.hidden=!doc.pictureInPictureEnabled;pip.title='切换标签前开启;网站仍可能暂停';pip.onclick=async()=>{try{const v=zhsNativePlayer(doc);if(!v||v.disablePictureInPicture)throw Error();if(doc.pictureInPictureElement)await doc.exitPictureInPicture();else await v.requestPictureInPicture();}catch(_){pip.textContent='请分屏';pip.title='画中画不可用,请单独开窗分屏';}};ui.start.onclick=()=>action(control().running===false?'start':control().paused?'resume':'pause');ui.stop.onclick=()=>action('stop');ui.update=status=>{last={...last,...status};const state=last.state;ui.phase(/blocked|failed|restricted/.test(state)?'error':state,last.message||'正在加载视频…',({idle:'待开始',playing:'正在学习',loading:'加载中',waiting:'等待确认',paused:'已暂停',blocked:'待处理',answering:'随堂练习',restricted:'已停止',stopped:'已停止',completed:'本节完成',failed:'待处理'})[state]);ui.box.querySelector('.zhs-title').textContent='智慧树 · '+(last.currentTitle||'当前页视频');ui.progress({total:last.duration||1,completed:last.currentTime||0});ui.count.textContent=clock(last.currentTime)+' / '+(last.duration?clock(last.duration):'--:--');const c=control();ui.start.hidden=false;ui.start.disabled=!!(c.running&&c.stopped);ui.start.textContent=c.running===false?'开始学习':c.paused?'继续':'暂停';ui.stop.hidden=c.running===false;};ui.update(last);return ui;} function zhsReadVideoQuiz(doc){const location=doc.defaultView?.location;if(location?.hostname!=='studyvideoh5.zhihuishu.com'||!/^\/stuStudy\/?$/.test(location.pathname)||!doc.querySelector('#vjs_container_html5_api'))return null;const box=doc.getElementById('playTopic-dialog');if(!box||!zhsNativeVisible(box,true))return null;const titles=[...box.querySelectorAll('.topic-title')].filter(e=>zhsNativeVisible(e));if(titles.length!==1)throw new Error('随堂练习题干不唯一,请手动处理');const title=titles[0],kind=(title.querySelector('.title-tit')?.textContent||'').trim();const type=/判断/.test(kind)?'判断题':/单选/.test(kind)?'单选题':/多选/.test(kind)?'多选题':'';const copy=title.cloneNode(true);copy.querySelector('.title-tit')?.remove();copy.querySelectorAll('.right,.wrong').forEach(e=>e.remove());const question=(copy.textContent||'').replace(/\s+/g,' ').trim();if(!type||!question||copy.querySelector('img,svg,math,canvas'))throw new Error('暂不自动处理此随堂题型或图片题,请手动处理');const scope=title.parentElement;const options=[...scope.querySelectorAll('.topic-list .topic-item')].map(element=>({label:(element.querySelector('.topic-option-item')?.textContent||'').replace(/[..、\s]/g,'').toUpperCase(), text:(element.querySelector('.item-topic')?.textContent||'').replace(/\s+/g,' ').trim(),element }));if(options.length<2||options.length>10||options.some(o=>!o.text||!/^[A-J]$/.test(o.label)||o.element.querySelector('.item-topic img'))||new Set(options.map(o=>o.label)).size!==options.length)throw new Error('随堂题选项无法完整识别,请手动处理');const page=(box.querySelector('.el-pager .active')?.textContent||'1').trim();const video=[...doc.querySelectorAll('li.video.current_play .hour,li.video.current_play .catalogue_title')].map(e=>e.textContent).join('|');const key=JSON.stringify([video,page,type,question,options.map(o=>[o.label,o.text])]);return {box,scope,type,question,options,page,key};} function zhsVideoQuizChoice(answer,quiz){const raw=String(answer??'').trim();if(!raw)return [];const normalized=raw.replace(/^(?:答案|正确答案)\s*[::]\s*/,'').trim();let labels=[];if(quiz.type==='判断题'&&/^(对|正确|是|true|t|yes|1|√|✓|错|错误|否|false|f|no|0|×|✗)$/i.test(normalized)){const yes=/^(对|正确|是|true|t|yes|1|√|✓)$/i.test(normalized);labels=quiz.options.filter(o=>(yes?/^(对|正确|是|true|√|✓)$/i:/^(错|错误|否|false|×|✗)$/i).test(o.text)).map(o=>o.label);}else if(/^[A-J](?:[\s,,、;;|#]*[A-J])*[.。]?$/i.test(normalized))labels=normalized.toUpperCase().match(/[A-J]/g)||[];else {const hits=quiz.options.filter(o=>o.text===normalized);if(hits.length===1)labels=[hits[0].label];}labels=[...new Set(labels)];if(labels.some(label=>!quiz.options.some(o=>o.label===label))||(!/多选/.test(quiz.type)&&labels.length!==1))return [];return labels;} function zhsVideoQuizAnswered(q){return [...q.scope.querySelectorAll('.answer,.topic-title .right,.topic-title .wrong')].some(e=>zhsNativeVisible(e)&&(/(?:正确答案|参考答案)[::]\s*\S|回答错误|回答错了|答错了/.test(e.textContent)||e.matches('.right,.wrong')));} async function answerZhsVideoQuiz(doc,hooks,attempts){const initial=zhsReadVideoQuiz(doc);if(!initial)return {handled:false};const url=doc.defaultView.location.href;const check=()=>{const c=hooks.control();if(c.stopped||c.paused||doc.defaultView.location.href!==url||hooks.pageValid&&!hooks.pageValid()||zhsNativeRestriction(doc)||zhsNativeBlocker(doc,true))throw Error('随堂练习已暂停、页面变化或出现验证,未继续');};const same=()=>{check();const q=zhsReadVideoQuiz(doc);if(!q||q.key!==initial.key)throw Error('随堂题已变化,未应用旧答案');return q;};const selected=q=>q.options.filter(o=>o.element.querySelector('input:checked')||o.element.querySelector('.topic-option-item.active')&&o.element.querySelector('.item-topic.active')).map(o=>o.label);const matches=(q,labels)=>selected(q).sort().join('')===labels.slice().sort().join('');const click=e=>{check();if(!e||!zhsNativeVisible(e,true)||e.closest('[disabled],[aria-disabled=true],.disabled,.is-disabled')||doc.defaultView.getComputedStyle(e).pointerEvents==='none')throw Error('答题控件不可用,请手动处理');e.click();};const submit=q=>{const nodes=[...q.box.querySelectorAll('button,.btn,.el-button,[role=button],input[type=submit]')].filter(e=>!e.closest('.topic-title,.topic-list,.answer,.el-pagination,.el-dialog__header')&&zhsNativeVisible(e)&&/^(提交|提交答案|确认答案|确定答案|确认提交)$/.test((e.value||e.textContent).replace(/\s+/g,'')));return nodes.filter(e=>!nodes.some(n=>n!==e&&e.contains(n)));};const wait=async test=>{for(let i=0;i<20;i++){const q=same();if(test(q))return q;await hooks.sleep(250);}throw Error('页面未确认本次操作,请手动核对;不会重复点击');};let q=same(),record=attempts.get(q.key);if(q.page!=='1'||q.box.querySelectorAll('.el-pager .number').length!==1||q.box.querySelector('.btn-next:not([disabled]):not(.disabled)'))throw Error('当前弹窗包含多题或分页不明确,请手动处理');if(!record){record={};attempts.set(q.key,record);}if(!zhsVideoQuizAnswered(q)){if(record.acted)throw Error('本题已尝试作答但未确认,请手动核对;不会重复点击');if(selected(q).length)throw Error('检测到已有选择,未覆盖手动答案');if(submit(q).length>1)throw Error('提交按钮不唯一,未自动作答');if(!record.labels?.length){if(record.queried)throw Error('本题已有查询记录,未重复扣次');record.queried=true;hooks.status('正在查询随堂练习答案');const result=await hooks.query(q);q=same();if(!result?.success)throw Error('题库未返回答案,请检查次数或手动处理');record.labels=zhsVideoQuizChoice(result.answer,q);if(!record.labels.length)throw Error('答案无法唯一匹配选项,未猜测');}if(zhsVideoQuizAnswered(q)||selected(q).length)throw Error('查询期间检测到手动作答,未覆盖');hooks.status('正在作答:'+record.labels.join('、'));const chosen=[];for(const label of record.labels){q=same();if(zhsVideoQuizAnswered(q)||!matches(q,chosen))throw Error('作答期间题目状态变化,已停止');record.acted=true;click(q.options.find(o=>o.label===label)?.element);chosen.push(label);q=await wait(q=>zhsVideoQuizAnswered(q)||selected(q).includes(label));}if(!zhsVideoQuizAnswered(q)){q=await wait(q=>{const bs=submit(q);if(bs.length>1)throw Error('提交按钮不唯一,请手动处理');return zhsVideoQuizAnswered(q)||bs.length===1&&!bs[0].closest('[disabled],[aria-disabled=true],.disabled,.is-disabled');});if(!zhsVideoQuizAnswered(q)){if(!matches(q,record.labels))throw Error('页面选择与答案不一致,未提交');const button=submit(q)[0];button.scrollIntoView?.({block:'nearest'});q=same();if(submit(q)[0]!==button||!matches(q,record.labels))throw Error('提交前题目状态变化,已停止');click(button);q=await wait(zhsVideoQuizAnswered);}}}await hooks.sleep(250);q=same();const feedback=q.scope.querySelector('.answer')?.textContent||'';const answer=zhsVideoQuizChoice(feedback,q);if(!zhsVideoQuizAnswered(q)||q.scope.querySelector('.topic-title .wrong')||/回答错误|回答错了|答错了/.test(feedback)||record.labels&&answer.length&&answer.sort().join('')!==record.labels.slice().sort().join(''))throw Error('页面未确认正确答案,请手动核对');if(record.closed)throw Error('已尝试关闭弹题,未重复点击');const buttons=[...q.box.querySelectorAll('.el-dialog__footer .btn,.el-dialog__footer button')].filter(e=>zhsNativeVisible(e,true)&&e.textContent.trim()==='关闭');if(buttons.length!==1)throw Error('关闭按钮不唯一,请手动处理');hooks.status('答题已确认,正在继续视频');record.closed=true;click(buttons[0]);for(let i=0;i<20;i++){check();if(!zhsReadVideoQuiz(doc)){await hooks.sleep(500);check();if(!zhsReadVideoQuiz(doc))return {handled:true,closed:true};}same();await hooks.sleep(250);}throw Error('弹题未关闭,请手动核对;不会重复点击');} function zhsWorkPageKind(loc) { if(!loc||loc.hostname!=='onlineexamh5new.zhihuishu.com'||loc.pathname!=='/stuExamWeb.html')return ''; const route=String(loc.hash||'').split('?')[0]; if(/^#\/webExamList\/dohomework\/[^/]+\/[^/]+\/[^/]+\/[^/]+\/[^/]+(?:\/[^/]*)?$/.test(route))return 'homework'; if(/^#\/webExamList\/doexamination\/[^/]+\/[^/]+\/[^/]+\/[^/]+\/[^/]+(?:\/[^/]*)?$/.test(route))return 'exam'; return ''; } function zhsInspectQuestionTree(root) { const report={text:'',elementCount:0,openShadowRoots:0,sameOriginFrames:0,inaccessibleFrames:0,loadingFrames:0,mediaNodes:0,slottedNodes:0,truncated:false}; const visited=new WeakSet();let visits=0; const hidden=e=>{if(e.hidden)return true;try{const s=e.ownerDocument.defaultView.getComputedStyle(e);return s.display==='none'||s.visibility==='hidden'||s.visibility==='collapse';}catch(_){return false;}}; const walk=(node,depth)=>{ if(!node||visited.has(node))return '';visited.add(node); if(++visits>3000||depth>60){report.truncated=true;return '';} if(node.nodeType===3)return node.nodeValue||''; if(node.nodeType===1){ if(/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(node.tagName)||hidden(node))return ''; report.elementCount++; if(node.tagName==='BR')return '\n'; if(/^(IMG|CANVAS|SVG|VIDEO)$/.test(node.tagName)){report.mediaNodes++;return '';} if(node.tagName==='IFRAME'){ try{const doc=node.contentDocument;if(!doc){report.inaccessibleFrames++;return '';} if(doc.readyState==='loading'||!doc.body){report.loadingFrames++;return '';} report.sameOriginFrames++;return walk(doc.body,depth+1); }catch(_){report.inaccessibleFrames++;return '';} } if(node.shadowRoot){report.openShadowRoots++;return walk(node.shadowRoot,depth+1);} if(node.tagName==='SLOT'&&node.assignedNodes){const assigned=node.assignedNodes({flatten:true});if(assigned.length){report.slottedNodes+=assigned.length;return assigned.map(n=>walk(n,depth+1)).join('');}} } const text=[...(node.childNodes||[])].map(n=>walk(n,depth+1)).join(''); return node.nodeType===1&&/^(P|DIV|LI|TR|SECTION|ARTICLE)$/.test(node.tagName)?'\n'+text+'\n':text; }; report.text=walk(root,0).replace(/\s+/g,' ').trim(); report.hasLayout=!!root&&zhsNativeVisible(root); // null shadowRoot is NOT evidence of a closed root: browser APIs intentionally hide it. report.state=report.truncated?'incomplete-tree':report.mediaNodes?'visual-content':report.inaccessibleFrames?'inaccessible-frame':report.loadingFrames?'frame-loading':report.text?'readable':report.hasLayout?'rendered-without-exposed-text':'not-rendered'; return report; } function zhsNativeQuestionText(stem, fields = []) { const empty = reason => ({text:'',reason}); if(!stem || !zhsNativeVisible(stem))return empty('题干容器尚未显示'); const doc=stem.ownerDocument,win=doc.defaultView,selection=win.getSelection?.(); if(doc.visibilityState!=='visible')return empty('请保持原作业标签页可见'); if(!selection || typeof selection.modify!=='function')return empty('浏览器不支持原生题干选择读取'); if(selection.toString())return empty('已有手动选区,暂不打扰'); const active=doc.activeElement; const editable=active?.matches('input,textarea,[contenteditable=true]') || active?.isContentEditable; // Native answer fields focused by our own fill flow can retain their caret. // Other editors (including the report panel) must never be disturbed. let caret=null; if(editable){ if(!fields.includes(active)||!active.matches('input,textarea')||typeof active.selectionStart!=='number')return empty('正在编辑其他区域,暂不读取'); caret=[active.selectionStart,active.selectionEnd,active.selectionDirection]; } const all=[stem,...stem.querySelectorAll('*')]; if(all.length>100)return empty('题干结构过大,未扩大选择范围'); if(all.some(n=>/^(IMG|SVG|CANVAS|IFRAME|VIDEO|AUDIO)$/.test(n.tagName)&&zhsNativeVisible(n)))return empty('题干包含非文字内容,未采用部分文本'); const candidates=all.filter(n=>zhsNativeVisible(n)&&!n.children.length&&!(n.textContent||'').trim()); if(!candidates.length || candidates.length>12)return empty('未找到唯一可检查的题干区域'); const saved=[],values=new Set();let outside=false; try{ for(let i=0;i!!n&&(n===stem||stem.contains(n)); if(text&&within(selection.anchorNode)&&within(selection.focusNode)){ if(text.length>8000)return empty('自动读取文字过长,结果未采用'); values.add(text.replace(/\s+/g,' ').trim()); }else if(text)outside=true; } return values.size===1?{text:[...values][0],reason:''}:empty(values.size?'题干边界返回不同文字,未拼接猜测':outside?'原生选区超出题干,结果已丢弃':'原生选择未取得题干文字'); }catch(_){return empty('原生题干读取失败,未更改页面限制');} finally{ try{selection.removeAllRanges();for(const range of saved)selection.addRange(range);}catch(_){} if(caret&&active.isConnected){try{active.setSelectionRange(...caret);}catch(_){}} } } function zhsRenderedText(node) { if(!node)return ''; return zhsInspectQuestionTree(node).text; } function zhsReadPageWork(doc, supplied=new Map()) { if(!zhsWorkPageKind(doc.defaultView.location))return null; const nodes=[...doc.querySelectorAll('.examPaper_subject')],shown=nodes.filter(e=>zhsNativeVisible(e)); if(shown.length!==1)throw new Error('当前可见题目不唯一或尚未加载,请留在原作答页'); const element=shown[0],anchor=element.querySelector('.subject_num span'),id=anchor?.id||'',number=Number(anchor?.textContent.trim()); const allIds=nodes.map(e=>e.querySelector('.subject_num span')?.id); if(!id||!Number.isInteger(number)||number<1||number>nodes.length||allIds.some(x=>!x)||new Set(allIds).size!==nodes.length)throw new Error('题号或题目总数无法确认,未继续'); const rawType=zhsRenderedText(element.querySelector('.subject_type')); const type=['多选题','单选题','判断题','填空题','简答题'].find(t=>rawType.includes(t))||''; if(!type)throw new Error('当前题型暂不支持,请手动处理'); const options=[...element.querySelectorAll('.subject_node .nodeLab')].map(row=>({ element:row.querySelector('.label'),input:row.querySelector('input[type=radio],input[type=checkbox]'), label:zhsRenderedText(row.querySelector('.mr10')).replace(/[..、\s]/g,'').toUpperCase(),text:zhsRenderedText(row.querySelector('.examquestions-answer')) })); const fields=[...element.querySelectorAll('textarea,input[type=text],[contenteditable=true]')].filter(e=>zhsNativeVisible(e)); if(/单选|多选|判断/.test(type)&&(options.length<2||options.some(o=>!o.element||!o.input||!o.text||!/^[A-J]$/.test(o.label))||new Set(options.map(o=>o.label)).size!==options.length))throw new Error('原页面选项结构不完整,未自动点击'); if(/填空|简答/.test(type)&&!fields.length)throw new Error('未找到原页面可编辑答题框'); const stem=element.querySelector('.subject_describe,.subject_describe-markdown'); if([...element.querySelectorAll('.subject_describe img,.subject_describe canvas,.subject_describe svg,.examquestions-answer img')].some(e=>zhsNativeVisible(e)))throw new Error('图片题需手动核对,未把不完整题干发送题库'); const binding=JSON.stringify([doc.defaultView.location.href,id,type,options.map(o=>[o.input?.value,o.label,o.text]),fields.length]); const rendering=zhsInspectQuestionTree(stem); if(rendering.truncated||rendering.mediaNodes||rendering.inaccessibleFrames)throw new Error('题干包含不可完整读取的结构('+rendering.state+'),未查询答案'); const fallback=!rendering.text&&!supplied.get(binding)?zhsNativeQuestionText(stem,fields):null; const question=rendering.text||supplied.get(binding)||fallback?.text||''; const questionSource=rendering.text?'dom':supplied.get(binding)?'manual':fallback?.text?'native-selection-modify':'unavailable'; const key=JSON.stringify([binding,question]); return {element,id,number,total:nodes.length,type,question,options,fields,binding,key,rendering,questionSource,readFailure:fallback?.reason||''}; } function zhsPageWorkAnswer(q,answer) { if(/单选|多选|判断/.test(q.type)){const labels=zhsVideoQuizChoice(answer,q);return labels.length?labels:null;} if(typeof answer!=='string'||!answer.trim())return null; const values=q.type==='简答题'||q.fields.length===1?[answer.trim()]:answer.split(answer.includes('#!#')?'#!#':'|').map(s=>s.trim()); return values.length===q.fields.length&&values.every(Boolean)?values:null; } function zhsPageOptionSelected(option) { if(option.input?.checked)return true; return option.input?.type==='radio'&&!!option.element.querySelector('.mr10.onChecked')&&!!option.element.querySelector('.examquestions-answer.onChecked'); } function zhsPageWorkFilled(q,value) { if(/单选|多选|判断/.test(q.type))return q.options.every(o=>zhsPageOptionSelected(o)===value.includes(o.label)); return q.fields.every((e,i)=>String('value' in e?e.value:e.textContent).trim()===value[i]); } async function runZhsPageWork(doc,hooks) { const startUrl=doc.defaultView.location.href,seen=new Set(),attempts=hooks.attempts||new Map();let processed=0; const check=()=>{if(hooks.stopped()||doc.defaultView.location.href!==startUrl||!zhsWorkPageKind(doc.defaultView.location))throw new Error('已停止或作答页面变化,未继续');const block=zhsNativeRestriction(doc)||zhsNativeBlocker(doc);if(block)throw new Error(block);}; const current=()=>{check();const q=zhsReadPageWork(doc,hooks.supplied);if(!q)throw new Error('请进入原作答页');return q;}; const button=text=>{const bs=[...doc.querySelectorAll('button')].filter(e=>!e.closest('#cx-zhs-page-work')&&zhsNativeVisible(e)&&text.test((e.textContent||'').trim())&&!e.disabled);if(bs.length!==1)throw new Error('原页面保存或翻题按钮不唯一,未继续点击');bs[0].scrollIntoView?.({block:'nearest'});if(!zhsNativeVisible(bs[0],true))throw new Error('原按钮当前不可见,未点击');return bs[0];}; for(let count=0;count<500;count++){ let q=current();if(seen.has(q.id))throw new Error('页面返回已处理题目,已停止防止重复写入'); hooks.progress?.({number:q.number,total:q.total,completed:q.number-1}); let value=null;const prior=attempts.get(q.key); if(prior){if(!prior.value)throw new Error('本题上次查询未成功,未自动重复扣次;请重新开始前核对题库');value=prior.value;if(!zhsPageWorkFilled(q,value))throw new Error('本题已尝试填写但原控件未完整确认,请手动核对后继续;未重复点击');} if(!value&&/单选|多选|判断/.test(q.type)){const checked=q.options.filter(o=>zhsPageOptionSelected(o)).map(o=>o.label);if(checked.length){if(q.type!=='多选题'&&checked.length!==1)throw new Error('原页面单选题存在多个选中项,未自动保存');value=checked;}} else if(!value&&q.fields.length){const values=q.fields.map(e=>String('value' in e?e.value:e.textContent).trim());if(values.some(Boolean)){if(values.some(v=>!v))throw new Error('当前题已有部分手动答案,未覆盖');value=values;}} if(!value){ if(!q.question){ const identity=q.binding;let previous='',stable=0;hooks.status('等待第 '+q.number+' 题原页面渲染完成…'); for(let wait=0;wait<20&&stable<2;wait++){ await hooks.sleep(250);const fresh=current(); if(fresh.binding!==identity)throw new Error('等待渲染时题目或选项已变化,未沿用旧题干');q=fresh; stable=q.question&&q.question===previous?stable+1:0;previous=q.question; } if(!q.question){hooks.missing(q);return {state:'missing-text',processed,total:q.total,rendering:q.rendering?.state};} if(stable<2)throw new Error('题干仍在变化,未查询不完整题目'); } if(q.questionSource==='native-selection-modify'){ await hooks.sleep(250);const stable=current(); if(stable.key!==q.key)throw new Error('自动读取的题干或选项已变化,未查询旧内容');q=stable; } if(q.options.some(o=>zhsPageOptionSelected(o))||q.fields.some(e=>String('value' in e?e.value:e.textContent).trim()))throw new Error('等待期间出现手动答案,未查询或覆盖'); hooks.status('第 '+q.number+'/'+q.total+' 题:查询现有题库'); const record={value:null};attempts.set(q.key,record); const result=await hooks.query(q);const fresh=current();if(fresh.key!==q.key)throw new Error('题干或选项已变化,未应用旧答案'); q=fresh;value=zhsPageWorkAnswer(q,result?.success?result.answer:null);record.value=value;if(!value)throw new Error('答案不能唯一匹配当前选项或填空数量,未猜测'); if(q.questionSource==='native-selection-modify'){ await hooks.sleep(250);const stable=current(); if(stable.key!==q.key)throw new Error('自动读取的题干或选项已变化,未查询旧内容');q=stable; } if(q.options.some(o=>zhsPageOptionSelected(o))||q.fields.some(e=>String('value' in e?e.value:e.textContent).trim()))throw new Error('查询期间检测到手动输入,未覆盖'); hooks.status('第 '+q.number+' 题:通过原控件填写'); if(q.options.length){for(const label of value){const live=current();if(live.key!==q.key)throw new Error('题目发生变化,已停止');const choice=live.options.find(o=>o.label===label);if(choice.input.disabled)throw new Error('原题选项已禁用,未填写');if(!zhsPageOptionSelected(choice))choice.element.click(); let accepted=false;for(let i=0;i<10;i++){await hooks.sleep(300);const updated=current();if(updated.key!==q.key)throw new Error('题目发生变化,已停止');if(zhsPageOptionSelected(updated.options.find(o=>o.label===label))){accepted=true;break;}} if(!accepted)throw new Error('原控件未接受本次选项点击,未点击后续选项或保存');}} else for(let i=0;i[e,zhsNativeVisible(e,true)?(e.textContent||'').trim():''])); button(/^(暂存作业|暂存试卷|保存答案)$/).click(); for(let i=0;i<30;i++){await hooks.sleep(500);check();if(current().key!==q.key||!zhsPageWorkFilled(current(),value))throw new Error('暂存期间答案发生变化,请在原页核对');const messages=[...doc.querySelectorAll('.el-message,.el-notification')].filter(e=>zhsNativeVisible(e,true)&&oldMessages.get(e)!==(e.textContent||'').trim());if(messages.some(e=>/^(?:答案|作业|试卷)?(?:暂存|保存)成功[!!。]?$/u.test((e.textContent||'').replace(/\s+/g,''))))return {state:'saved',processed,total:q.total};} return {state:'save-unconfirmed',processed,total:q.total}; } button(/^下一题$/).click();let moved=false; for(let i=0;i<30;i++){await hooks.sleep(500);check();const next=zhsReadPageWork(doc,hooks.supplied);if(next&&next.id!==q.id){if(next.number!==q.number+1||next.total!==q.total)throw new Error('翻页题号不连续,已停止');moved=true;break;}} if(!moved)throw new Error('原页面未切换下一题,未重复点击或假报保存成功'); } throw new Error('超过本轮题数上限'); } function zhsHomeworkReview(doc) { if(zhsWorkPageKind(doc.defaultView.location)!=='homework')throw new Error('此交卷入口只适用于当前作业,不处理考试或批阅页'); const titles=[...doc.querySelectorAll('h1')].filter(e=>zhsNativeVisible(e)); const title=titles.length===1?zhsRenderedText(titles[0]):''; const rows=[...doc.querySelectorAll('.examPaper_subject')],ids=new Set(),questions=[]; if(!title||!rows.length||rows.length>500)throw new Error('作业标题或题目总数无法确认,未交卷'); for(const [index,row]of rows.entries()){ const anchor=row.querySelector('.subject_num span'),id=anchor?.id; if(!id||ids.has(id)||Number(anchor.textContent)!==index+1)throw new Error('全卷题号不连续或重复,未交卷');ids.add(id); const type=['多选题','单选题','判断题','填空题','简答题'].find(t=>zhsRenderedText(row.querySelector('.subject_type')).includes(t)); const choices=[...row.querySelectorAll('.subject_node .nodeLab')].map(e=>({element:e.querySelector('.label'),input:e.querySelector('input[type=radio],input[type=checkbox]'),text:zhsRenderedText(e.querySelector('.examquestions-answer'))})); let answer,shape; if(/单选|多选|判断/.test(type||'')){ if(choices.length<2||choices.some(o=>!o.input||!o.element||!o.text)||new Set(choices.map(o=>o.input.value)).size!==choices.length)throw new Error('全卷选项信息不完整,未交卷'); answer=choices.filter(zhsPageOptionSelected).map(o=>o.input.value); if(!answer.length||type!=='多选题'&&answer.length!==1)throw new Error('第 '+(index+1)+' 题未完整作答,未交卷'); shape=choices.map(o=>[o.input.value,o.text]); }else if(type==='填空题'||type==='简答题'){ const fields=[...row.querySelectorAll('textarea,input[type=text],[contenteditable=true]')]; if(!fields.length||fields.some(e=>e.hidden||e.disabled||e.readOnly||e.closest('[contenteditable=true]')!==e&&e.parentElement?.closest('[contenteditable=true]')))throw new Error('全卷答题框无法确认,未交卷'); answer=fields.map(e=>String('value' in e?e.value:e.textContent).trim()); if(answer.some(s=>!s))throw new Error('第 '+(index+1)+' 题仍有空白,未交卷');shape=fields.length; }else throw new Error('全卷含未支持的题型,未交卷'); questions.push([id,type,zhsRenderedText(row.querySelector('.subject_describe')),shape,answer]); } const page=doc.defaultView.location.pathname+doc.defaultView.location.hash.split('?')[0]; return {title,total:rows.length,page,fingerprint:JSON.stringify([page,title,questions])}; } function zhsSubmissionMessages(doc) { const selector='.el-message,.el-notification,.el-message-box__wrapper,.el-dialog__wrapper,[role=alertdialog]'; return [...doc.querySelectorAll(selector)].filter(e=>!e.closest('#cx-zhs-page-work')&&zhsNativeVisible(e,true)); } async function zhsSubmitHomework(doc,hooks) { const stopped=()=>hooks.stopped?.(),baseline=zhsHomeworkReview(doc),initialHref=doc.defaultView.location.href; const review=()=>{if(stopped()||doc.defaultView.location.href!==initialHref)throw new Error('已停止或作业页面变化,未继续交卷');const block=zhsNativeRestriction(doc);if(block)throw new Error(block);const current=zhsHomeworkReview(doc);if(current.fingerprint!==baseline.fingerprint)throw new Error('作业题目或答案已变化,未继续交卷');}; review();const block=zhsNativeBlocker(doc);if(block)throw new Error(block); if(hooks.saved!==true&&hooks.readyToSubmit!==true)throw new Error('原站暂存尚未确认或填写流程未完成,未交卷'); const storage=hooks.storage||doc.defaultView.sessionStorage,key='cx-zhs-submit-v1:'+md5(baseline.page); let existing;try{existing=storage.getItem(key);}catch(_){throw new Error('无法保存交卷防重记录,未交卷');} if(existing)throw new Error('本标签已尝试提交此份作业,请先在原站复核,不会自动重复交卷'); if(typeof hooks.authorize!=='function'||await hooks.authorize({title:baseline.title,total:baseline.total})!==true)return {state:'submission-cancelled',total:baseline.total}; review();if(zhsNativeBlocker(doc))throw new Error('原页面出现提示,未提交'); const buttons=[...doc.querySelectorAll('button')].filter(e=>!e.closest('#cx-zhs-page-work')&&zhsNativeVisible(e)&&!e.disabled&&(e.textContent||'').trim()==='提交作业'); if(buttons.length!==1)throw new Error('提交作业按钮不唯一或不可用,未交卷'); buttons[0].scrollIntoView?.({block:'nearest'});if(!zhsNativeVisible(buttons[0],true))throw new Error('提交按钮未完整显示,未交卷'); const old=new Map(zhsSubmissionMessages(doc).map(e=>[e,(e.textContent||'').trim()])); const receipt=state=>storage.setItem(key,JSON.stringify({state,at:Date.now()})); // Store the attempt before any native click; reload and timeouts cannot retry it. try{if(storage.getItem(key))throw new Error('already attempted');receipt('attempted');}catch(_){throw new Error('无法保存交卷防重记录,未交卷');} let confirmed=false,confirmationNode=null; try{ hooks.status?.('正在通过原页面提交本份作业;不会重复点击');buttons[0].click(); for(let i=0;i<60;i++){ await hooks.sleep(500); if(stopped())throw new Error('提交后收到停止信号,请在原站核对结果'); if(doc.defaultView.location.href!==initialHref)throw new Error('提交后页面已跳转,请在原站核对结果'); const restriction=zhsNativeRestriction(doc);if(restriction)throw new Error(restriction); for(const frame of doc.querySelectorAll('iframe'))if(zhsNativeVisible(frame,true)&&/captcha|yidun/i.test(frame.src||''))throw new Error('原页面出现验证,停止交卷确认'); const messages=zhsSubmissionMessages(doc).filter(e=>old.get(e)!==(e.textContent||'').trim()); const positive=messages.find(e=>/^(?:作业)?提交成功[!!。]?$/.test((e.querySelector('.el-message__content,.el-notification__content,.el-message-box__message,.el-dialog__body')||e).textContent.replace(/\s+/g,''))); if(positive){ if(messages.some(e=>e!==positive&&/失败|未提交|验证|异常/.test(e.textContent)))throw new Error('提交反馈冲突,请在原站复核'); // Only a fresh native success signal on this same paper is accepted. const title=[...doc.querySelectorAll('h1')].find(e=>zhsNativeVisible(e)); if(zhsRenderedText(title)!==baseline.title)throw new Error('成功提示不属于当前作业,需人工核对'); receipt('confirmed');return {state:'submitted',title:baseline.title,total:baseline.total,persisted:false}; } const dialogs=messages.filter(e=>e.matches('.el-message-box__wrapper,.el-dialog__wrapper,[role=alertdialog]')); if(dialogs.length){ if(confirmed||dialogs.length!==1)throw new Error('提交后出现未识别提示,未继续点击'); const dialog=dialogs[0],text=(dialog.textContent||'').replace(/\s+/g,''); if(!/(?:^|提示)(?:确定|确认)(?:要)?提交(?:该|本份|本次)?作业[吗??]?/.test(text)||/未答|未作答|未完成|空白|验证码|人脸|验证|异常|失败/.test(text))throw new Error('原站要求核对或验证,未确认交卷'); const confirms=[...dialog.querySelectorAll('button')].filter(e=>zhsNativeVisible(e,true)&&!e.disabled&&/^(确定|确认提交|提交作业)$/.test((e.textContent||'').trim())); if(confirms.length!==1)throw new Error('交卷确认按钮不唯一,未继续'); // Inspect all other native overlays (especially CAPTCHA) without dismissing anything. for(const frame of doc.querySelectorAll('iframe'))if(zhsNativeVisible(frame,true)&&/captcha|yidun/i.test(frame.src||''))throw new Error('交卷需要验证,请在原页面完成'); review();confirmed=true;confirmationNode=dialog;old.set(dialog,(dialog.textContent||'').trim());confirms[0].click(); }else if(zhsNativeBlocker(doc)&&!(confirmed&&confirmationNode?.isConnected&&zhsNativeVisible(confirmationNode,true)&&!messages.length))throw new Error('原站出现交卷提示,需人工核对'); } throw new Error('未取得明确的原站提交成功提示,请复核;不会自动重交'); }catch(error){try{receipt('uncertain');}catch(_){}return {state:'submission-unconfirmed',message:error.message,total:baseline.total};} } function qingshuDiscussionIdentity(doc) { const text=[...doc.querySelectorAll('script:not([src])')].map(s=>s.textContent).join('\n'); const ids=[...text.matchAll(/var\s+STUDENT_UNACTIVATED_LIMIT_CACHE_KEY\s*=\s*['"]STUDENT_UNACTIVATED_LIMIT_CACHE_KEY['"]\s*\+\s*['"](\d+)['"]/g)]; if(ids.length!==1||!doc.querySelector('#postTable'))throw Error('青书讨论登录或页面状态未确认,请重新登录'); return ids[0][1]; } function qingshuReplyContract(doc,ctx,postId) { const scripts=[...doc.querySelectorAll('script:not([src])')].map(s=>s.textContent); const marker="$('.dis-reply-box .submit-reply')"; const blocks=scripts.filter(t=>t.includes(marker)); if(blocks.length!==1||!doc.querySelector('#replyContent')||!doc.querySelector('.dis-reply-box .submit-reply:not(.disabled)'))throw Error('青书评论表单未确认,未提交'); const text=blocks[0].slice(blocks[0].indexOf(marker),blocks[0].indexOf('function agreePost',blocks[0].indexOf(marker))); const value=name=>{const found=[...text.matchAll(new RegExp('var\\s+'+name+'\\s*=\\s*["\']([^"\']*)["\']','g'))];return found.length===1?found[0][1]:null;}; if(value('courseId')!==String(ctx.courseId)||value('teachplanCourseId')!==String(ctx.teachPlanCourseId)||value('postId')!==String(postId)||value('openSourceType')!==''||!/var\s+parentId\s*=\s*0\s*;/.test(text)||!/\$\.post\(\s*['"]Reply['"]/.test(text)||!/plainContent\.length\s*>\s*1000/.test(text)||/plainContent\.length\s*e.textContent.replace(/\s+/g,'')==='已学习')}; } async function qingshuAutoComment(app,q,env={}) { if(app.config.qingshuAutoComment===false)return; const request=env.request||qingshuApiRequest,score=env.score||qingshuLoadScoreDetail; const read=env.read||(key=>localStorage.getItem(key)),write=env.write||((key,value)=>localStorage.setItem(key,value)); const lock=env.lock||((key,run)=>typeof navigator!=='undefined'&&navigator.locks?navigator.locks.request(key,{ifAvailable:true},held=>held?run():null):Promise.reject(Error('浏览器不支持评论防重锁,未提交'))); const log=(text,color='blue')=>app.log('青书'+text,color); const href=location.href,ctx={...q.ctx}; const check=async()=>{await app.waitIfPaused();if(app.state.stopped||location.href!==href){const e=Error('青书评论已停止');e.name='AbortError';throw e;}}; ctx.beforeRequest=check; const get=async(endpoint,params={})=>{await check();return request(ctx,endpoint,{params});}; const doc=html=>new DOMParser().parseFromString(html,'text/html'); const indexUrl=qingshuDiscussionUrl(ctx); const index=async()=>qingshuDiscussionIdentity(doc((await get(indexUrl)).responseText)); const refresh=async()=>{await check();q.scoreDetail=await score(ctx);app.updateQingshuStats();return q.scoreDetail.discussion;}; let d=await refresh(); if(!d?.known||!d.required||d.complete){if(!d?.known)log('讨论分数未确认,未自动评论','orange');return;} await lock('cx-qingshu-comment:'+new URL(indexUrl).origin+'/'+ctx.orgCode,async()=>{ const uid=await index(); const study=doc((await get('Student/Course/CourseStudy',{courseId:ctx.courseId,teachPlanId:ctx.teachPlanId,periodId:ctx.periodId})).responseText); const mock=[...study.querySelectorAll('script:not([src])')].map(s=>s.textContent).join('\n').match(/var\s+isMockStudent\s*=\s*['"](true|false)['"]\s*===\s*['"]true['"]/); if(!mock||mock[1]!=='false')throw Error('青书正式账号权限未确认,未自动评论'); const limits=qingshuParseJson((await get('Student/StudentUnactivatedLimits')).responseText); if(!qingshuResponseOk(limits)||!(limits.data?.studentActivateStatus===1||Array.isArray(limits.data?.studentUnactivatedLimits)&&!limits.data.studentUnactivatedLimits.map(Number).includes(4)))throw Error('青书当前账号未开放讨论,未提交'); const key='cx-qingshu-comments-v1:'+JSON.stringify([new URL(indexUrl).origin,ctx.orgCode,uid,ctx.courseId,ctx.teachPlanCourseId,ctx.periodId]); const load=()=>{const text=read(key);const value=text?JSON.parse(text):{posts:{}};if(!value.posts||typeof value.posts!=='object'||Array.isArray(value.posts))throw Error('青书评论防重记录异常,未提交');return value;}; let ledger=load(); const save=()=>write(key,JSON.stringify(ledger)); if(ledger.pending){log('上次评论结果未确认,保留待核对,不自动重发','orange');return;} if(ledger.hold){if(d.score<=ledger.lastScore){log('已评论但分数尚未变化,本轮不再追加评论','orange');return;}delete ledger.hold;save();} let sent=0;const seen=new Set(); for(let offset=0;offset<30&&sent<12&&!d.complete;offset+=10){ const list=qingshuParseJson((await get('Student/Course/PostData',{courseId:ctx.courseId,order:'asc',offset,limit:10})).responseText); if(!Array.isArray(list.rows)||!Number.isFinite(list.total))throw Error('青书讨论列表格式异常,未提交'); for(const row of list.rows){ await check();if(sent>=12||d.complete)break; const id=String(row.id||'');if(!/^\d+$/.test(id)||String(row.courseId)!==String(ctx.courseId)||String(row.authorId)===uid||row.isBad!==false||seen.has(id))continue; seen.add(id);ledger=load();if(ledger.posts[id])continue; const params={courseId:ctx.courseId,teachplanCourseId:ctx.teachPlanCourseId,openSourceType:'',postId:id}; let detail=qingshuReplyContract(doc((await get('Student/Course/ReplyList',params)).responseText),ctx,id); if(detail.pages>5)continue; let duplicate=detail.duplicate; for(let page=2;page<=detail.pages&&!duplicate;page++)duplicate=qingshuReplyContract(doc((await get('Student/Course/ReplyList',{...params,page})).responseText),ctx,id).duplicate; if(duplicate){ledger.posts[id]='existing-text';save();continue;} if(await index()!==uid)throw Error('青书登录账号已变化,已停止自动评论'); d=await refresh();if(!d?.known||d.complete)return; const oldScore=d.score; const data=new URLSearchParams({...params,parentId:'0',contentTxt:'

已学习

'}); const res=await request(ctx,'Student/Course/Reply',{method:'POST',data:data.toString(),headers:{'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8'},onDispatch:async()=>{ await check();ledger=load();if(ledger.pending||ledger.hold||ledger.posts[id])throw Error('青书评论已有处理记录,未重复提交'); ledger.pending=id;ledger.lastScore=oldScore;ledger.posts[id]='attempted';save(); }}); const result=qingshuParseJson(res.responseText,'青书评论'); if(!qingshuResponseOk(result)){ledger=load();delete ledger.pending;ledger.hold=true;ledger.posts[id]='rejected';save();throw Error(result.message||'青书评论未被接受');} ledger=load();ledger.posts[id]='sent';delete ledger.pending;ledger.hold=true;save();sent++; log('已提交评论“已学习”,正在核对讨论分数','green'); d=await refresh();if(!d?.known||d.score<=oldScore){log('评论已提交,分数尚未增加;停止继续评论,保留待核对','orange');return;} ledger.hold=false;ledger.lastScore=d.score;save();log(qingshuDiscussionLabel(d),d.complete?'green':'blue'); } if(list.rows.length<10||offset+10>=list.total)break; } if(!d.complete)log('讨论本轮结束:没有更多可安全回复的讨论或已到本轮上限,保留待处理','orange'); }); } const App = { state: { running: false, paused: false, stopped: false, current: {}, chapters: [], pendingChapters: [], blockedChapters: {}, lockedChapterInfo: { lockedCount: 0, lockedLabels: [], knownJobCount: 0, hasCatalog: false }, chapterTitles: {}, courseTasks: [], courseTaskSummary: null, chapterIndex: 0, jobDone: 0, jobTotal: 0, taskStats: { total: 0, completed: 0, pending: 0, runnablePending: 0, scanned: false, doneKeys: {}, lockedChapters: 0, lockedLabels: [] }, tokenInfo: { remainingCount: null, totalCalls: null, successCount: null }, autoAnswer: { running: false, pending: false, completed: false, watcherStarted: false, timer: null, attempts: 0, filledKeys: {}, diagLogged: false, pageAnswered: 0, pageAlreadyAnswered: 0, pageTotal: 0 }, uiStatus: { headline: '等待开始', currentChapter: '-', currentTask: '-', etaText: '-', tip: '等待读取课程任务详情。' }, announcement: null }, config: { ...loadConfig(), doVideo: true, doDocument: true, doWork: true, scanConcurrency: 6 }, els: {}, init() { if (this.isZhihuishuPage?.()) { this.initZhihuishuPage(); return; } injectStyle(); if (this.isDashboardPage()) { this.loadDashboardContext(); this.createStandaloneDashboard(); this.log('独立页面已启动', 'green'); if (this.dashboardContext?.platform !== 'zhihuishu') this.ensureAnswerToken().catch(e => this.logErr(e)); if (this.dashboardContext && this.dashboardContext.platform === 'qingshu') { this.log('青书课程版 QS-1012-1:选课与讨论功能已加载。','green'); this.setUserStatus({ headline: '青书课程已准备好', currentTask: '准备中', etaText: '请稍候', tip: '' }); (this.config.qingshuAutoCourseQueue!==false?this.prepareQingshuCourses():this.prepareQingshuCourse()).catch(e => this.logErr(e)); } else { this.ensureAutoScan().catch(e => this.logErr(e)); } this.refreshAnnouncement().catch(() => {}); this.startAnnouncementLoop(); return; } this.bindFrameMessages(); this.bindHistoryHooks(); this.refreshContext(); this.log('Runner \u521d\u59cb\u5316\u5b8c\u6210', 'green'); this.scheduleAutoAnswerPage(); if (this.isAutoAnswerPage()) { this.ensureAnswerToken().catch(() => { refreshAutoAnswerLogStats(); }); } this.scheduleCoursePrompt(); let yktPromptTicks = 0; const yktPromptTimer = setInterval(() => { yktPromptTicks++; try { this.scheduleCoursePrompt(); } catch (_) {} if (yktPromptTicks >= 12 || !this.isYuketangCoursePage()) clearInterval(yktPromptTimer); }, 1000); if (this.config.autoStart && this.isCoursePage()) { setTimeout(() => this.start().catch(e => this.logErr(e)), 1200); } }, isAutoAnswerPage() { return this.isWorkAnswerPage() || this.isExamAnswerPage() || this.isYuketangExercisePage() || this.isXuetangxExamPage() || this.isZhihuishuHomeworkPage() || this.isQingshuAnswerPage(); }, isWorkAnswerPage() { if (!IS_TOP_WINDOW) return false; const href = location.href; const path = location.pathname || ''; if (/selectWorkQuestionYiPiYue/i.test(href)) return false; if (/^https?:\/\/mooc1-api\.chaoxing\.com\/mooc-ans\/mooc2\/work\/dowork(?:[/?#]|$)/i.test(href)) return true; if (/\/(?:mooc-ans\/)?work\/doHomeWork(?:New)?/i.test(path)) return true; if (/\/(?:mooc-ans\/)?mooc2\/work\/(?:task|dowork)/i.test(path)) return true; return false; }, isExamAnswerPage() { if (!IS_TOP_WINDOW) return false; const path = location.pathname || ''; if (/\/(?:mooc-ans\/)?work\/doHomeWork(?:New)?/i.test(path) || /\/(?:mooc-ans\/)?mooc2\/work\/(?:task|dowork)/i.test(path) || /\/(?:mooc-ans\/)?workHandle\/handle/i.test(path)) return false; if (/\/exam-ans\/exam\/test\/reVersionTestStartNew/i.test(path)) return true; if (/\/exam-ans\/exam\/test\/testStart/i.test(path)) return true; if (/\/exam-ans\/exam\/test\/reVersion[^/]*Test/i.test(path)) return true; return false; }, isYuketangPage() { return /(^|\.)yuketang\.cn$/i.test(location.hostname || ''); }, isYuketangExercisePage() { if (!this.isYuketangPage()) return false; const path = location.pathname || ''; return /\/v2\/web\/(?:cloud\/student\/exercise|exercise)\/\d+\/\d+\/\d+/i.test(path) || /\/v2\/web\/exam\/\d+\/\d+/i.test(path); }, isYuketangExamPage() { return this.isYuketangPage() && /\/v2\/web\/exam\/\d+\/\d+/i.test(location.pathname || ''); }, isXuetangxExamPage() { const host = location.hostname || ''; const path = location.pathname || ''; return /(^|\.)examination\.xuetangx\.com$/i.test(host) && (/^\/exam\/\d+/i.test(path) || /^\/subject(?:\/|$)/i.test(path)); }, isYuketangCoursePage() { if (!IS_TOP_WINDOW || !this.isYuketangPage()) return false; const path = location.pathname || ''; if (this.isYuketangExercisePage()) return true; return /\/v2\/web\/(?:studentLog|lms|xcloud\/video-student)\//i.test(path); }, isYuketangStudentLogPage() { return IS_TOP_WINDOW && this.isYuketangPage() && /\/v2\/web\/studentLog\/\d+/i.test(location.pathname || ''); }, isZhihuishuPage(){return /(^|\.)zhihuishu\.com$/i.test(location.hostname||'');}, isZhihuishuCoursePage(){return IS_TOP_WINDOW && location.hostname==='studyvideoh5.zhihuishu.com'&&/^\/stuStudy\/?$/.test(location.pathname);}, isZhihuishuHomeworkPage(){return false;}, isQingshuPage() { return /(^|\.)qingshuxuetang\.com$/i.test(location.hostname || ''); }, isQingshuCoursePage() { if (!IS_TOP_WINDOW || !this.isQingshuPage()) return false; const path = location.pathname || ''; return /\/Student\/Course\/?$/i.test(path) || /\/Student\/Course\/(?:CourseList|CourseShow|CourseStudy|CourseScore)/i.test(path) || (/(^|\/)Student\/Course\//i.test(path) && /courseId|teachPlanId|periodId|teachplanCourseId/i.test(location.search || '')); }, isQingshuAnswerPage() { if (!IS_TOP_WINDOW || !this.isQingshuPage()) return false; const path = location.pathname || ''; if (/\/Student\/(?:ExercisePaper|Exam|Exercise|DoPaper)/i.test(path)) return true; return !!document.querySelector('.quiz-container .question-detail-container, .paper-container .question-detail-container'); }, isCoursePage() { if (this.isZhihuishuPage?.()) return this.isZhihuishuCoursePage(); const href = location.href; if (this.isYuketangCoursePage()) return true; if (this.isZhihuishuCoursePage()) return true; if (this.isQingshuCoursePage()) return true; return /\/(mycourse|knowledge|mooc-ans|visit)\//i.test(href) || /courseId|courseid|clazzid|classId/i.test(location.search); }, bindHistoryHooks() { const refresh = () => setTimeout(() => { this.refreshContext(); this.scheduleAutoAnswerPage(); this.scheduleCoursePrompt(); }, 300); for (const name of ['pushState', 'replaceState']) { const raw = history[name]; history[name] = function () { const ret = raw.apply(this, arguments); refresh(); return ret; }; } window.addEventListener('popstate', refresh); window.addEventListener('hashchange', refresh); }, bindFrameMessages() { window.addEventListener('message', ev => { const data = ev.data || {}; const trustedContext = readTrustedFrameContext(ev, window, document, this.state.current || {}); if (!trustedContext) return; if (this.state.multiCourse && this.state.multiCourse.active) return; const f = trustedContext; this.state.frames = this.state.frames || []; if (!this.state.frames.some(x => x.href === f.href)) this.state.frames.push(f); const ctx = this.state.current || {}; for (const k of ['courseId', 'clazzId', 'cpi', 'chapterId', 'enc', 'openc']) { if (!ctx[k] && f[k]) ctx[k] = f[k]; } if (f.oldSiteHost) ctx.oldSiteHost = f.oldSiteHost; if (f.oldEnc) ctx.oldEnc = f.oldEnc; if ((!ctx.siteHost || ctx.siteHost === location.origin) && f.origin) { ctx.siteHost = normalizeSiteOrigin(f.origin, f.href); ctx.siteHosts = buildSiteHostCandidates(f.origin, f.href); } this.state.current = ctx; this.renderContext(ctx); this.log(`frame ctx: ${f.host} course=${f.courseId || '-'} clazz=${f.clazzId || '-'} chapter=${f.chapterId || '-'}`, 'gray'); }); }, refreshContext() { const fresh = this.collectContext(); let savedCtx = {}; try { const saved = JSON.parse(GM_getValue(DASHBOARD_CTX_KEY, '{}') || '{}'); savedCtx = saved.ctx || saved || {}; } catch (_) {} const prev = { ...savedCtx, ...(this.dashboardContext || {}), ...(this.state.current || {}) }; const ctx = { ...fresh }; for (const k of (fresh.platform === 'zhihuishu' ? [] : ['courseId', 'clazzId', 'cpi', 'chapterId', 'enc', 'openc', 'uid', 'fid', 'oldSiteHost', 'oldEnc', 'siteHost', 'teachPlanId', 'teachPlanCourseId', 'periodId', 'nodeId', 'orgCode'])) { if (!ctx[k] && prev[k]) ctx[k] = prev[k]; } if ((!ctx.siteHosts || !ctx.siteHosts.length) && prev.siteHosts) ctx.siteHosts = prev.siteHosts; this.state.current = ctx; try { const saved = JSON.parse(GM_getValue(DASHBOARD_CTX_KEY, '{}') || '{}'); const chapterTitles = this.state.chapterTitles && Object.keys(this.state.chapterTitles).length ? this.state.chapterTitles : (saved.chapterTitles || {}); GM_setValue(DASHBOARD_CTX_KEY, JSON.stringify({ ...saved, ctx, chapters: this.state.chapters && this.state.chapters.length ? this.state.chapters : (saved.chapters || []), chapterTitles, ts: Date.now() })); } catch (_) {} this.renderContext(ctx); return ctx; }, collectContext() { const q = new URLSearchParams(location.search); const fromQuery = (...names) => { for (const n of names) { const v = q.get(n); if (v !== null && v !== '') return v; } return ''; }; const ctx = { href: location.href, origin: location.origin, host: location.host, platform: this.isZhihuishuPage() ? 'zhihuishu' : (this.isYuketangPage() ? 'yuketang' : (this.isQingshuPage() ? 'qingshu' : '')), siteHost: normalizeSiteOrigin(location.origin, location.href), siteHosts: buildSiteHostCandidates(location.origin, location.href), courseId: fromQuery('courseId', 'courseid'), clazzId: fromQuery('clazzid', 'classId', 'classid', 'clazzId'), teachPlanId: fromQuery('teachPlanId', 'teachplanId', 'teachPlanID'), teachPlanCourseId: fromQuery('teachPlanCourseId', 'teachplanCourseId', 'teachingPlanCourseId'), periodId: fromQuery('periodId', 'periodID'), nodeId: fromQuery('nodeId', 'contentId', 'contentID'), orgCode: qingshuOrgCode(location.pathname), cpi: fromQuery('cpi'), chapterId: fromQuery('chapterId', 'knowledgeid', 'knowledgeId'), enc: fromQuery('enc'), openc: fromQuery('openc'), uid: detectUid(), fid: detectFid(), oldSiteHost: detectOldSiteHost(), oldEnc: detectOldEnc(), courseName: detectCourseName() }; if (this.isZhihuishuPage()) { Object.assign(ctx, detectZhihuishuCourseEntryFromPage(ctx)); ctx.entryUrl = ctx.entryUrl || location.href; ctx.courseName = ctx.courseName || document.title || '智慧树课程'; return ctx; } if (!ctx.courseId || !ctx.clazzId) { for (const a of Array.from($d.querySelectorAll('a[href]'))) { const href = a.href || ''; if (!ctx.courseId) ctx.courseId = pickQueryFromUrl(href, 'courseId', 'courseid') || ctx.courseId; if (!ctx.clazzId) ctx.clazzId = pickQueryFromUrl(href, 'clazzid', 'classId', 'classid') || ctx.clazzId; if (!ctx.cpi) ctx.cpi = pickQueryFromUrl(href, 'cpi') || ctx.cpi; if (!ctx.openc) ctx.openc = pickQueryFromUrl(href, 'openc') || ctx.openc; if (ctx.courseId && ctx.clazzId && ctx.cpi && ctx.openc) break; } } return ctx; }, isDashboardPage() { if (/(^|\.)zhihuishu\.com$/i.test(location.hostname || '')) return false; return window.name === 'cx_runner_dashboard' || /(?:\?|&)cxRunnerDashboard=1(?:&|$)/.test(location.search) || /cxRunnerDashboard=1/.test(location.hash); }, loadDashboardContext() { let saved = {}; try { saved = JSON.parse(GM_getValue(DASHBOARD_CTX_KEY, '{}') || '{}'); } catch (_) { saved = {}; } let fromHash = {}; try { const m = String(location.hash || '').match(/cxctx=([^&]+)/); if (m) fromHash = JSON.parse(decodeURIComponent(m[1])); } catch (_) {} const ctx = { ...(saved.ctx || saved || {}), ...(fromHash.ctx || fromHash || {}) }; if (/(^|\.)yuketang\.cn$/i.test(location.hostname || '')) { ctx.platform = ctx.platform || 'yuketang'; ctx.siteHost = location.origin; ctx.oldSiteHost = location.origin; ctx.siteHosts = [location.origin]; } if (/(^|\.)qingshuxuetang\.com$/i.test(location.hostname || '')) { const orgCode = qingshuOrgCode(location.pathname); const page = qingshuContextFromLocation({ href: location.href, siteHost: location.origin, orgCode }); const courseKeys = ['courseId', 'teachPlanId', 'teachPlanCourseId', 'periodId']; const belongsHere = value => { if (!value || typeof value !== 'object' || value.platform !== 'qingshu') return false; if (value.orgCode && value.orgCode !== orgCode) return false; let hasScope = value.orgCode === orgCode; try { if (value.href) { const href = new URL(value.href, location.href); if (href.origin !== location.origin || qingshuOrgCode(href.pathname) !== orgCode) return false; hasScope = true; } for (const host of [value.siteHost, value.oldSiteHost, ...(Array.isArray(value.siteHosts) ? value.siteHosts : [])].filter(Boolean)) { if (new URL(host, location.href).origin !== location.origin) return false; } } catch (_) { return false; } return hasScope && !courseKeys.some(key => page[key] && value[key] && String(page[key]) !== String(value[key])); }; const hashContext = fromHash.ctx || fromHash || {}; const storedContext = saved.ctx || saved || {}; const source = belongsHere(hashContext) ? hashContext : page.courseId && belongsHere(storedContext) ? storedContext : {}; const clean = { href: source.href || location.href, siteHost: location.origin, oldSiteHost: location.origin, orgCode }; for (const key of [...courseKeys, 'nodeId', 'courseName']) if (source[key]) clean[key] = source[key]; for (const key of courseKeys) if (page[key]) clean[key] = page[key]; if (new URL(location.href).searchParams.has('nodeId') || new URL(location.href).searchParams.has('contentId')) clean.nodeId = page.nodeId; for (const key of Object.keys(ctx)) delete ctx[key]; Object.assign(ctx, qingshuContextFromLocation(clean)); } if (/(^|\.)zhihuishu\.com$/i.test(location.hostname || '')) { const savedZhs = saved.ctx || saved || {}; const hashZhs = fromHash.ctx || fromHash || {}; for (const key of Object.keys(ctx)) delete ctx[key]; if (savedZhs.platform === 'zhihuishu') Object.assign(ctx, savedZhs); if (hashZhs.platform === 'zhihuishu') Object.assign(ctx, hashZhs); ctx.platform = 'zhihuishu'; ctx.entryUrl = ctx.entryUrl || ctx.href || location.href; const zhsOrigin = safeUrlOrigin(ctx.entryUrl) || location.origin; ctx.siteHost = zhsOrigin; ctx.oldSiteHost = zhsOrigin; ctx.siteHosts = [zhsOrigin]; } if (!ctx.siteHost) ctx.siteHost = normalizeSiteOrigin(location.origin, location.href); if (!ctx.siteHosts || !ctx.siteHosts.length) ctx.siteHosts = buildSiteHostCandidates(ctx.siteHost, ctx.siteHost); if (!ctx.oldSiteHost) ctx.oldSiteHost = detectOldSiteHost() || stripAppPrefix(ctx.siteHost) || location.origin; this.dashboardContext = { ...ctx }; this.state.current = { ...ctx }; this.state.chapters = ['zhihuishu', 'qingshu'].includes(ctx.platform) ? [] : (Array.isArray(saved.chapters) ? saved.chapters : []); const savedTitles = saved.chapterTitles || fromHash.chapterTitles || {}; this.state.chapterTitles = ['zhihuishu', 'qingshu'].includes(ctx.platform) ? {} : (savedTitles && typeof savedTitles === 'object' ? savedTitles : {}); this.state.courseTaskSummary = ['zhihuishu', 'qingshu'].includes(ctx.platform) ? null : (saved.courseTaskSummary || fromHash.courseTaskSummary || null); return ctx; }, createStandaloneDashboard() { if (this.isZhihuishuPage?.()) return false; const title = '\u8bfe\u7a0b'; document.title = title; document.head.innerHTML = `${title}`; document.body.innerHTML = `
课程学习助手

\u8bfe\u7a0b

\u6b63\u5728\u8bfb\u53d6\u8bfe\u7a0b\u4fe1\u606f
学习面板
任务控制
等待开始
已完成任务点0
任务点总数0
剩余任务点准备中
\u7b54\u9898\u5269\u4f59\u6b21\u6570-
\u8bfe\u7a0b\u4efb\u52a1\u8fdb\u5ea6正在准备
\u7528\u6237 Token
\u5145\u503c / \u552e\u540e\u4f7f\u7528\uff0c\u8bf7\u590d\u5236\u4fdd\u5b58
\u6b63\u5728\u68c0\u67e5 Token...
\u8bf4\u660e\uff1aToken \u662f\u7528\u6237\u8eab\u4efd\u548c\u7b54\u9898\u6b21\u6570\u51ed\u8bc1\uff0c\u672c\u9875\u9762\u4e0d\u63d0\u4f9b\u201c\u91cd\u65b0\u751f\u6210\u201d\uff0c\u907f\u514d\u8bef\u6362 Token \u5bfc\u81f4\u5145\u503c\u4e0d\u5230\u8d26\u3002
运行日志
`; this.bindDashboard(window); this.setUserStatus({ headline: '正在准备课程信息', currentChapter: '-', currentTask: '正在读取课程任务点进度', etaText: '请稍候', tip: '准备完成后可点击开始自动刷课。' }); this.updateProgress(); }, scheduleCoursePrompt() { if (this.isAutoAnswerPage()) return; if (this.isYuketangCoursePage()) { const quick = $d.getElementById('cx-runner-quick-start'); if (quick) quick.remove(); const confirm = $d.getElementById('cx-runner-confirm'); if (confirm) confirm.remove(); return; } if (!this.isCoursePage()) { const quick = $d.getElementById('cx-runner-quick-start'); if (quick) quick.remove(); return; } this.ensureCourseQuickStart(); if (window.name === 'cx_runner_dashboard' && !this.isZhihuishuPage?.()) return; const key = `cx-runner-prompt:${location.pathname}:${location.search.slice(0, 120)}`; if (this.isYuketangStudentLogPage()) { if (this.state.yuketangStudentLogPromptScheduled) return; this.state.yuketangStudentLogPromptScheduled = true; } else { if (sessionStorage.getItem(key)) return; sessionStorage.setItem(key, '1'); } setTimeout(() => { this.refreshContext(); if (this.isCoursePage()) this.showCoursePrompt(); }, 1200); }, ensureCourseQuickStart() { if (this.isZhihuishuPage?.()) return this.mountZhihuishuVideo(); if (window.name === 'cx_runner_dashboard') return; if (this.isYuketangCoursePage()) return; if ($d.getElementById('cx-runner-quick-start')) return; const btn = $d.createElement('button'); btn.id = 'cx-runner-quick-start'; btn.type = 'button'; btn.textContent = '开始刷课'; btn.title = '打开【题对对】独立刷课页面'; btn.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); this.openDashboardPage(true); }); ($d.body || $d.documentElement).appendChild(btn); }, showCoursePrompt() { if (this.isZhihuishuPage?.()) return this.mountZhihuishuVideo(); if ($d.getElementById('cx-runner-confirm')) return; const mask = $d.createElement('div'); mask.id = 'cx-runner-confirm'; mask.innerHTML = `
课程页面

\u68c0\u6d4b\u5230\u8bfe\u7a0b\u9875\u9762

\u662f\u5426\u5f00\u59cb\u81ea\u52a8\u5237\u8bfe\uff1f\u70b9\u51fb\u540e\u4f1a\u6253\u5f00\u72ec\u7acb\u9875\u9762\uff0c\u5c55\u793a\u8bfe\u7a0b\u4efb\u52a1\u548c\u8fd0\u884c\u65e5\u5fd7\u3002

`; $d.body.appendChild(mask); mask.addEventListener('click', async ev => { const act = ev.target && ev.target.dataset && ev.target.dataset.act; if (!act) return; if (act === 'no') { mask.remove(); return; } if (act === 'yes') { mask.remove(); this.openDashboardPage(true); } }); }, scheduleAutoAnswerPage() { if (window.name === 'cx_runner_dashboard') return; if (!this.isAutoAnswerPage()) { this.cleanupAutoAnswerUi(); return; } if (this.state.autoAnswer.watcherStarted) return; this.state.autoAnswer.watcherStarted = true; this.log('检测到答题页面,已开启自动填答监听', 'blue'); appendAutoAnswerLog('\u68c0\u6d4b\u5230\u7b54\u9898\u9875\u9762\uff0c\u5df2\u5f00\u542f\u81ea\u52a8\u586b\u7b54\u76d1\u542c', 'info'); const trigger = (delay = 500) => { if (this.state.autoAnswer.completed) return; clearTimeout(this.state.autoAnswer.timer); this.state.autoAnswer.timer = setTimeout(() => { if (this.state.autoAnswer.completed) return; this.autoAnswerCurrentPage().catch(e => this.logErr(e)); }, delay); }; trigger(900); const observer = new MutationObserver(() => { if (this.state.autoAnswer.completed) return; if (this.state.autoAnswer.running) { this.state.autoAnswer.pending = true; return; } if (!this.state.autoAnswer.pending) { this.state.autoAnswer.pending = true; trigger(800); } }); try { const options = { childList: true, subtree: true }; if (this.isExamAnswerPage()) Object.assign(options, { characterData: true, attributes: true, attributeFilter: ['data', 'id', 'value'] }); observer.observe(document.documentElement || document.body, options); } catch (_) {} this.state.autoAnswer.observer = observer; window.addEventListener('beforeunload', () => { try { observer.disconnect(); } catch (_) {} }); for (const ev of ['hashchange', 'popstate', 'pageshow']) { window.addEventListener(ev, () => trigger(700)); } }, cleanupAutoAnswerUi() { try { const aa = this.state.autoAnswer || {}; if (aa.timer) clearTimeout(aa.timer); if (aa.observer) { try { aa.observer.disconnect(); } catch (_) {} } const tip = document.getElementById('cx-runner-answer-tip'); if (tip) tip.remove(); this.state.autoAnswer = { ...aa, running: false, pending: false, completed: false, watcherStarted: false, timer: null, observer: null, attempts: 0, filledKeys: {}, diagLogged: false, examPageKey: '', examHandledPageKey: '', pageAnswered: 0, pageAlreadyAnswered: 0, pageTotal: 0 }; } catch (_) {} }, stopAutoAnswerWatcher() { try { const aa = this.state.autoAnswer || {}; aa.completed = true; aa.pending = false; aa.running = false; if (aa.timer) { clearTimeout(aa.timer); aa.timer = null; } if (aa.observer) { try { aa.observer.disconnect(); } catch (_) {} aa.observer = null; } appendAutoAnswerLog('答题流程已完成,已停止自动监听', 'ok'); } catch (_) {} }, async autoAnswerCurrentPage() { if (!this.isAutoAnswerPage()) return false; const aa = this.state.autoAnswer; if (aa.completed) return false; if (aa.running) { aa.pending = true; return false; } // Deduplicate before UI writes; keep observing AJAX/manual navigation. if (this.isExamAnswerPage() && aa.examHandledPageKey) { const html = document.documentElement && document.documentElement.outerHTML || ''; const current = parseExamQuestions(document, { html }); if (current.questions.map(pageQuestionKey).join('\n') === aa.examHandledPageKey) { aa.pending = false; return true; } } aa.running = true; aa.pending = false; aa.attempts++; try { ensureAutoAnswerLogPanel(); refreshAutoAnswerLogStats(); if (this.isQingshuAnswerPage()) return await this.autoAnswerQingshuPage(); if (!await this.ensureAnswerToken()) { this.log('未绑定有效答题 Token,当前页面自动填答已取消', 'orange'); appendAutoAnswerLog('未绑定有效答题 Token,当前页面自动填答已取消', 'warn'); return false; } if (this.isExamAnswerPage()) return await this.autoAnswerExamPage(); if (this.isXuetangxExamPage()) return await this.autoAnswerXuetangxExamPage(); if (this.isYuketangExercisePage()) return await this.autoAnswerYuketangExercisePage(); if (this.isZhihuishuHomeworkPage()) return await this.autoAnswerZhihuishuHomeworkPage(); if (this.isWorkAnswerPage()) return await this.autoAnswerWorkPage(); return false; } finally { const shouldRerun = aa.pending && !aa.completed; aa.running = false; aa.pending = false; if (shouldRerun && aa.attempts < 60) { clearTimeout(aa.timer); aa.timer = setTimeout(() => this.autoAnswerCurrentPage().catch(e => this.logErr(e)), 1200); } } }, async autoAnswerWorkPage() { this.log('检测到作业/测验页面,开始模拟用户填答', 'blue'); appendAutoAnswerLog('\u4f5c\u4e1a/\u6d4b\u9a8c\u9875\u9762\uff1a\u5f00\u59cb\u6a21\u62df\u7528\u6237\u586b\u7b54', 'info'); await waitForElement('.singleQuesId,.Py-mian1,input[id^="answer"],input[name^="answer"],textarea,[contenteditable="true"]', 10000).catch(() => null); const html = document.documentElement && document.documentElement.outerHTML || ''; const fontBase64 = extractCxsecretFont(html); await ensureCxFontMap(fontBase64); if (fontBase64) this.log(`页面字体加密已识别:${fontBase64.length} chars`, 'gray'); let parsed = parseWorkQuestions(document, { html, fontBase64 }); if (!parsed.questions.length) parsed = parseExamQuestions(document, { html, fontBase64 }); const overwriteExisting = shouldOverwriteExistingPageAnswers(document); this.log(`页面题目解析:${parsed.questions.length} 道`, parsed.questions.length ? 'green' : 'orange'); appendAutoAnswerLog(`\u9875\u9762\u9898\u76ee\u89e3\u6790\uff1a${parsed.questions.length} \u9053`, parsed.questions.length ? 'ok' : 'warn'); if (!parsed.questions.length) { if (!this.state.autoAnswer.diagLogged) { this.state.autoAnswer.diagLogged = true; this.log(`页面解析诊断:${diagnoseWorkPage(document)}`, 'orange'); } return false; } const result = await this.fillParsedQuestionsOnPage(parsed, '页面填答', (q, answer) => fillWorkAnswer(document, parsed.optionLis, q, answer), { overwriteExisting }); const completed = Number(result.completed || result.answered || 0); this.log(`作业/测验页面完成:${completed}/${parsed.questions.length},本次新填 ${result.answered || 0},已有作答 ${result.alreadyAnswered || 0}${completed ? ',请在页面确认后手动提交' : ',未填入答案,请检查悬浮窗原因'}`, completed ? 'green' : 'orange'); appendAutoAnswerLog(completed ? `作业/测验页面完成:${completed}/${parsed.questions.length},本次新填 ${result.answered || 0},已有作答 ${result.alreadyAnswered || 0}` : `作业/测验页面未成功填入答案:0/${parsed.questions.length},请检查未填原因`, completed ? 'ok' : 'warn'); if (completed > 0) this.stopAutoAnswerWatcher(); return completed > 0; }, async autoAnswerQingshuPage() { this.log('检测到青书学堂作答页面,开始自动答题', 'blue'); appendAutoAnswerLog('青书学堂页面:开始自动答题', 'info'); await waitForElement('.question-detail-container', 12000).catch(() => null); const parsed = parseQingshuQuestions(document); const quizId = new URL(location.href).searchParams.get('quizId'); const useNativeSave = /\/Student\/ExercisePaper\/?$/i.test(location.pathname) && !!quizId; const nativeCtx = useNativeSave ? qingshuContextFromLocation() : null; if (useNativeSave) { const detail = await qingshuLoadAssignmentDetail(nativeCtx, quizId); const policy = qingshuApplyPassPolicy(detail); if (policy.passed || policy.awaitingGrade || policy.requiresRestart) { const summary = policy.requiresRestart ? `青书作业 ${policy.score} 分未达 60 分,请先点击页面“重新答题”开启新一轮后再运行助手` : `青书作业:${qingshuAssignmentStatusText(detail)}`; this.log(summary, 'green'); appendAutoAnswerLog(summary, 'ok'); this.stopAutoAnswerWatcher(); return false; } appendAutoAnswerLog('作业页使用青书原生接口保存答案,交卷仍由你确认后操作。', 'info'); } this.state.autoAnswer.pageTotal = parsed.questions.length; this.state.autoAnswer.pageAnswered = 0; this.state.autoAnswer.pageAlreadyAnswered = 0; const typeCounts = parsed.questions.reduce((acc, q) => { acc[q.typeName || '未知题型'] = (acc[q.typeName || '未知题型'] || 0) + 1; return acc; }, {}); const imageCount = parsed.questions.filter(q => q.images && q.images.length).length; this.state.qingshuPageSummary = { total: parsed.questions.length, typeCounts, imageCount }; refreshAutoAnswerLogStats(); const typeText = Object.entries(typeCounts).map(([name, count]) => `${name} ${count}`).join('、'); this.log(`青书题目解析:${parsed.questions.length} 道${typeText ? `(${typeText};图片题 ${imageCount})` : ''}`, parsed.questions.length ? 'green' : 'orange'); appendAutoAnswerLog(`青书题目解析:${parsed.questions.length} 道${typeText ? `(${typeText};图片题 ${imageCount})` : ''}`, parsed.questions.length ? 'ok' : 'warn'); if (!parsed.questions.length) return false; const hasToken = await this.ensureAnswerToken().catch(() => false); const apiBase = this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl; const token = this.config.answerApiToken; let answered = 0; let skipped = 0; let consecutiveQueryFailures = 0; for (let i = 0; i < parsed.questions.length; i++) { if (this.state.stopped) break; const q = parsed.questions[i]; const rowPrefix = `青书 ${i + 1}/${parsed.questions.length}`; if (!q.options.length && q.typeN !== '2') { appendAutoAnswerLog(`${rowPrefix}:无选项,跳过(可能是主观题)`, 'muted'); skipped++; continue; } if (!hasToken) { appendAutoAnswerLog(`${rowPrefix}:需绑定 Token,未绑定已跳过`, 'warn'); skipped++; continue; } const options = qingshuAnswerApiOptions(q.options || []); const ansRes = await queryQingshuVisionApi(q.images, q.stem, q.typeName, options, apiBase, token, q); if (ansRes.insufficientQuota) { this.state.stopped = true; this.state.running = false; this.setTokenStatus('Token 剩余答题次数不足,已自动停止服务;请充值后继续。', 'orange'); appendAutoAnswerLog(`${rowPrefix}:Token 剩余次数不足,已停止`, 'err'); throw new Error('Token 剩余答题次数不足,已停止'); } if (!ansRes.success || !ansRes.answer) { const reason = qingshuDescribeAnswerFailure(ansRes, q, token); appendAutoAnswerLog(`${rowPrefix}:答案查询失败:${reason}`, 'warn'); skipped++; consecutiveQueryFailures = ansRes.serviceFailure ? consecutiveQueryFailures + 1 : 0; if (ansRes.fatal || consecutiveQueryFailures >= 3) { this.state.stopped = true; this.stopAutoAnswerWatcher(); throw new Error(`青书答案查询异常,已停止本轮答题:${reason}`); } await this.sleepRandom(this.config.answerDelayMinMs || 5000, this.config.answerDelayMaxMs || 5000); continue; } consecutiveQueryFailures = 0; if (ansRes.remainingCount != null) { this.applyAnswerTokenInfo(ansRes); this.setTokenStatus(tokenStatusText(this.state.tokenInfo), this.state.tokenInfo.remainingCount === 0 ? 'orange' : 'green'); } if (this.state.stopped) break; try { q.element && q.element.scrollIntoView && q.element.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {} await this.sleep(180); let filled = fillQingshuAnswer(document, q, ansRes.answer); if (!filled) { appendAutoAnswerLog(`${rowPrefix}:答案 ${answerResultForLog(ansRes)} 无法映射到选项`, 'warn'); skipped++; continue; } await this.sleep(Number(this.config.answerVerifyDelayMs || DEFAULT_CONFIG.answerVerifyDelayMs || 650)); let selected = verifyQingshuAnswerSelected(q, ansRes.answer); if (!selected) { await this.sleep(Number(this.config.answerRetryDelayMs || DEFAULT_CONFIG.answerRetryDelayMs || 1000)); fillQingshuAnswer(document, q, ansRes.answer); await this.sleep(Number(this.config.answerVerifyDelayMs || DEFAULT_CONFIG.answerVerifyDelayMs || 650)); selected = verifyQingshuAnswerSelected(q, ansRes.answer); } if (this.state.stopped) break; let saved = false; if (useNativeSave && selected) { try { await qingshuSaveQuestionAnswer(nativeCtx, quizId, q.qid, qingshuNativeAnswer(q, ansRes.answer)); saved = true; } catch (e) { appendAutoAnswerLog(`${rowPrefix}:保存失败:${qingshuUserErrorText(e.message || e, token)}`, 'err'); } } const completed = useNativeSave ? saved : selected; if (completed) answered++; else skipped++; this.state.autoAnswer.pageAnswered = answered; refreshAutoAnswerLogStats(); const status = saved ? '已保存答案' : (selected ? '已选择,尚未确认保存' : '未能同步页面选项'); appendAutoAnswerLog(`${rowPrefix}:${status} ${answerResultForLog(ansRes)}`, completed ? 'ok' : 'warn'); this.log(`${rowPrefix}: ${q.typeName} -> ${answerResultForLog(ansRes)} ${status}`, completed ? 'green' : 'orange'); await this.sleepRandom(this.config.answerDelayMinMs || 5000, this.config.answerDelayMaxMs || 5000); } const summary = `青书页面完成:${useNativeSave ? '接口保存' : '填答'} ${answered}/${parsed.questions.length},跳过 ${skipped}`; this.log(`${summary}${answered ? ',请确认后手动提交' : ''}`, answered ? 'green' : 'orange'); appendAutoAnswerLog(`${summary};交卷仍需手动确认`, answered ? 'ok' : 'warn'); if (answered > 0) this.stopAutoAnswerWatcher(); return answered > 0; }, async refreshQingshuCachedQuestion() { if (this.state.running || this.state.autoAnswer.running || this.state.qingshuCacheRefreshing) { this.log('请先停止正在运行的任务,再补答单题。', 'orange'); return false; } const quizId = new URL(location.href).searchParams.get('quizId'); if (!this.isQingshuAnswerPage() || !/\/Student\/ExercisePaper\/?$/i.test(location.pathname) || !quizId) { this.log('请先打开青书作业 ExercisePaper 页面;其他练习或考试页不自动保存、交卷。', 'orange'); return false; } const parsed = parseQingshuQuestions(document); if (!parsed.questions.length) return false; const value = window.prompt('请输入要重查并保存的题号(1-' + parsed.questions.length + ')。服务器确认整卷完整后会自动交卷。'); if (value === null) return false; const index = Number(String(value).trim()) - 1; if (!Number.isInteger(index) || index < 0 || index >= parsed.questions.length || !parsed.questions[index].qid) { this.log('题号或题目 ID 无效,未发送查题请求。', 'orange'); return false; } if (!window.confirm('将停止本页自动填答,重查第 ' + (index + 1) + ' 题并刷新服务端缓存,再保存到当前作业。青书每次查题扣2次(免扣期除外);重新核对服务器记录后,全部题目完整时自动交卷,否则保留草稿。是否继续?')) return false; this.stopAutoAnswerWatcher(); this.state.qingshuCacheRefreshing = true; this.state.running = true; this.state.paused = false; this.state.stopped = false; this.updateActionButtons(); let ctx; let work; const startedAt = Date.now(); const questionId = String(parsed.questions[index].qid); const publish = (message, color = 'blue') => { this.log(message, color, { publicDiagnostic: true }); appendAutoAnswerLog(message, color === 'orange' ? 'warn' : 'info'); }; const updateWork = detail => { if (work) Object.assign(work, qingshuMergeAssignmentState(work, detail)); this.updateQingshuStats(); }; let phase = '读取作业'; try { ctx = qingshuContextFromLocation(); work = (this.state.qingshu && this.state.qingshu.assignments || []).find(item => String(item.quizId) === String(quizId)); this.setUserStatus({ headline: '正在补答青书单题', currentTask: '核对当前作答记录', etaText: '请稍候', tip: '只重查所选题,其他题从服务器读取;完整后自动交卷。' }); let detail = await qingshuLoadAssignmentDetail(ctx, quizId); if (this.state.stopped) return false; if (!detail.userQuizId && !detail.passed && !detail.submitted) { detail = await qingshuStartAssignment(ctx, { quizId }, detail, () => this.state.stopped); if (this.state.stopped) return false; } qingshuAssertEditableAttempt(detail, quizId); qingshuServerAnswerState(detail); const userQuizId = detail.userQuizId; const question = detail.questions.find(item => String(item.questionId) === questionId); if (!question) throw new Error('页面题目与服务器记录不一致,请刷新页面后重试'); const snapshot = qingshuQuestionSnapshot(question); if (!await this.ensureAnswerToken()) return false; await this.waitIfPaused(); if (this.state.stopped) return false; phase = '重查本题'; const result = await queryQingshuVisionApi(question.images, question.stem, question.typeName, qingshuAnswerApiOptions(question.options), this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl, this.config.answerApiToken, { forceRefresh: true, stemHtml: question.stemHtml }); if (this.state.stopped) return false; if (!result.success || !result.answer) throw new Error('单题查询失败:' + qingshuDescribeAnswerFailure(result, question, this.config.answerApiToken)); const nativeAnswer = qingshuNativeAnswer(question, result.answer); if (!nativeAnswer) throw new Error('查询结果无法对应本题选项,未保存或交卷'); if (result.remainingCount != null) { this.applyAnswerTokenInfo(result); this.setTokenStatus(tokenStatusText(this.state.tokenInfo), this.state.tokenInfo.remainingCount === 0 ? 'orange' : 'green'); } await this.waitIfPaused(); if (this.state.stopped) return false; phase = '保存本题'; detail = await qingshuLoadAssignmentDetail(ctx, quizId); if (this.state.stopped) return false; qingshuAssertEditableAttempt(detail, quizId, userQuizId); const current = detail.questions.find(item => String(item.questionId) === questionId); if (!current || qingshuQuestionSnapshot(current) !== snapshot) throw new Error('重查期间题干或选项发生变化,未保存,请刷新页面后重试'); await qingshuSaveQuestionAnswer(ctx, quizId, questionId, nativeAnswer); if (this.state.stopped) return false; let verified = null; phase = '核对保存结果'; for (let attempt = 0; attempt < 3; attempt++) { if (attempt) await this.sleepWithPause(attempt * 500); await this.waitIfPaused(); if (this.state.stopped) return false; detail = await qingshuLoadAssignmentDetail(ctx, quizId); if (this.state.stopped) return false; qingshuAssertEditableAttempt(detail, quizId, userQuizId); const saved = detail.questions.find(item => String(item.questionId) === questionId); if (!saved || qingshuQuestionSnapshot(saved) !== snapshot) throw new Error('保存后题目内容发生变化,未自动交卷'); if (qingshuNativeAnswer(saved, saved.existingAnswer) === nativeAnswer) { verified = detail; break; } } if (!verified) throw new Error('本题答案尚未确认保存,未自动交卷'); const state = qingshuServerAnswerState(verified); try { fillQingshuAnswer(document, parsed.questions[index], nativeAnswer); } catch (_) {} this.state.autoAnswer.pageAnswered = 1; this.state.autoAnswer.pageAlreadyAnswered = Math.max(0, state.answers.length - 1); this.state.autoAnswer.pageTotal = state.total; refreshAutoAnswerLogStats(); publish('青书第 ' + (index + 1) + ' 题已保存;进度 ' + state.answers.length + '/' + state.total, 'green'); updateWork(verified); if (state.missing.length) { const missing = state.missing.slice(0, 10).map(number => '第 ' + number + ' 题').join('、'); publish('青书作业暂不交卷:仍缺 ' + missing + (state.missing.length > 10 ? '等 ' + state.missing.length + ' 题' : '') + ';已保留当前答案。', 'orange'); this.setUserStatus({ headline: '本题已保存,整卷仍有未完成题', currentTask: missing, etaText: '待补答', tip: '只补缺失题,不重复查询已经保存的答案。' }); return true; } await this.waitIfPaused(); if (this.state.stopped) return false; detail = await qingshuLoadAssignmentDetail(ctx, quizId); if (this.state.stopped) return false; qingshuAssertEditableAttempt(detail, quizId, userQuizId); const ready = qingshuServerAnswerState(detail); const finalQuestion = detail.questions.find(item => String(item.questionId) === questionId); if (!finalQuestion || qingshuQuestionSnapshot(finalQuestion) !== snapshot || qingshuNativeAnswer(finalQuestion, finalQuestion.existingAnswer) !== nativeAnswer) throw new Error('交卷前本题作答发生变化,未自动交卷'); if (ready.missing.length) throw new Error('交卷前核对仍有未保存答案,已保留草稿,不交卷'); phase = '交卷'; await qingshuSubmitAssignment(ctx, detail, ready.answers, Math.max(1, Math.round((Date.now() - startedAt) / 1000))); const submitted = qingshuApplyPassPolicy({ ...detail, submitted: true, statusCode: 2, statusKnown: true, statusLabel: '待批改', score: null }); updateWork(submitted); publish('青书作业已自动交卷:共 ' + ready.total + ' 题有有效作答。', 'green'); this.setUserStatus({ headline: '青书作业已交卷', currentTask: '正在核对成绩', etaText: '待出分', tip: '交卷成功不等于达标,仍以平台实际得分为准。' }); if (this.state.stopped) return true; phase = '核对成绩'; try { const graded = await qingshuLoadAssignmentDetail(ctx, quizId); if (this.state.stopped) return true; if (String(graded.userQuizId) !== String(userQuizId)) throw new Error('交卷后的作答记录已切换'); updateWork(graded); publish('青书交卷后状态:' + qingshuAssignmentStatusText(graded), graded.passed ? 'green' : 'orange'); this.setUserStatus({ headline: graded.passed ? '青书作业已达标' : '青书作业已交卷,尚未确认达标', currentTask: qingshuAssignmentStatusText(graded), etaText: graded.passed ? '已完成' : '待核对', tip: '仅明确达到 60 分才计为完成。' }); } catch (error) { publish('青书已交卷,但成绩尚未确认:' + qingshuUserErrorText(error && error.message || error, this.config.answerApiToken), 'orange'); } return true; } catch (error) { const reason = qingshuUserErrorText(error && error.message || error, this.config.answerApiToken); const message = phase === '交卷' ? '青书交卷结果未确认,不自动重复提交,请在平台检查:' + reason : '青书单题补答未完成(' + phase + '):' + reason; publish(message, 'orange'); this.setUserStatus({ headline: phase === '交卷' ? '交卷结果待核查' : '青书补答未完成', currentTask: reason, etaText: '等待处理', tip: '已确认保存的答案不会被清空;请根据具体原因处理。' }); return false; } finally { this.state.qingshuCacheRefreshing = false; this.state.running = false; this.state.paused = false; this.updateActionButtons(); } }, async autoAnswerYuketangExercisePage() { const isExam = this.isYuketangExamPage(); if (isExam && isYuketangExamIntroPage()) { const introKey = `yuketang-exam-intro:${location.pathname}:${location.search}`; if (this.state.autoAnswer.introLoggedKey !== introKey) { this.state.autoAnswer.introLoggedKey = introKey; this.log('检测到雨课堂考试说明页:不会自动点击“开始答题”;请手动开始后脚本再自动答题', 'orange'); appendAutoAnswerLog('雨课堂考试说明页:等待用户手动点击“开始答题”,不会自动消耗考试机会', 'warn'); } this.stopAutoAnswerWatcher(); return false; } this.log(`检测到雨课堂${isExam ? '考试' : '作业'}页面,开始通过接口拉取题目并作答`, 'blue'); appendAutoAnswerLog(`雨课堂${isExam ? '考试' : '作业'}页面:开始读取题目`, 'info'); const parsed = await loadYuketangExerciseQuestions(); this.state.autoAnswer.pageTotal = parsed.questions.length; this.state.autoAnswer.pageAnswered = 0; this.state.autoAnswer.pageAlreadyAnswered = 0; refreshAutoAnswerLogStats(); this.log(`雨课堂题目解析:${parsed.questions.length} 道`, parsed.questions.length ? 'green' : 'orange'); appendAutoAnswerLog(`雨课堂题目解析:${parsed.questions.length} 道`, parsed.questions.length ? 'ok' : 'warn'); if (!parsed.questions.length) return false; let answered = 0; let alreadyAnswered = 0; for (let i = 0; i < parsed.questions.length; i++) { const q = parsed.questions[i]; const rowPrefix = `雨课堂${isExam ? '考试' : ''} ${i + 1}/${parsed.questions.length}`; if (isYuketangProblemSubmitted(q.raw)) { alreadyAnswered++; this.state.autoAnswer.pageAlreadyAnswered = alreadyAnswered; this.state.autoAnswer.pageAnswered = answered + alreadyAnswered; appendAutoAnswerLog(`${rowPrefix}:已作答,跳过`, 'muted'); continue; } normalizeQuestionTypeMeta(q); appendAutoAnswerLog(`${rowPrefix}:正在查询答案(${q.typeName || '题目'})`, 'info'); const ansRes = await queryAnswerApi(q.question, q.typeName, q.options || [], this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl, this.config.answerApiToken, q); if (ansRes.insufficientQuota) { this.state.stopped = true; this.state.running = false; this.setTokenStatus('Token 剩余答题次数不足,已自动停止服务;请充值后继续。', 'orange'); appendAutoAnswerLog(`${rowPrefix}:Token 剩余次数不足,已停止`, 'err'); throw new Error('Token 剩余答题次数不足,已停止'); } if (!ansRes.success || !ansRes.answer) { appendAutoAnswerLog(`${rowPrefix}:未找到答案,已跳过`, 'warn'); this.log(`${rowPrefix}: 未找到答案 ${autoLogQuestionText(q.question).slice(0, 40)} ${cleanAnswerError(ansRes.error)}`, 'orange'); continue; } if (ansRes.remainingCount != null) { this.applyAnswerTokenInfo(ansRes); this.setTokenStatus(tokenStatusText(this.state.tokenInfo), this.state.tokenInfo.remainingCount === 0 ? 'orange' : 'green'); } const yktAnswer = normalizeYuketangAnswer(ansRes.answer, q); if (!yktAnswer.length) { appendAutoAnswerLog(`${rowPrefix}:答案无法映射到雨课堂选项:${answerResultForLog(ansRes)}`, 'warn'); continue; } appendAutoAnswerLog(`${rowPrefix}:${isExam ? '选择' : '提交'}答案 ${answerResultForLog(ansRes)}`, 'info'); if (isExam) { const filled = await fillYuketangExamAnswerOnPage(q, yktAnswer); if (filled) { answered++; this.state.autoAnswer.pageAnswered = answered + alreadyAnswered; refreshAutoAnswerLogStats(); appendAutoAnswerLog(`${rowPrefix}:已在考试页面选择答案,请最后手动提交`, 'ok'); this.log(`${rowPrefix}: ${q.typeName} -> ${answerResultForLog(ansRes)} 已选择`, 'green'); } else { appendAutoAnswerLog(`${rowPrefix}:页面未找到可选择控件,已跳过;答案 ${answerResultForLog(ansRes)}`, 'warn'); this.log(`${rowPrefix}: 页面选择失败 ${q.typeName} -> ${answerResultForLog(ansRes)}`, 'orange'); } } else { const submitRes = await submitYuketangProblemAnswer(parsed.context, q, yktAnswer); if (submitRes && submitRes.success) { answered++; this.state.autoAnswer.pageAnswered = answered + alreadyAnswered; refreshAutoAnswerLogStats(); appendAutoAnswerLog(`${rowPrefix}:提交成功${submitRes.isRight === false ? '(平台判定错误)' : ''}`, submitRes.isRight === false ? 'warn' : 'ok'); this.log(`${rowPrefix}: ${q.typeName} -> ${answerResultForLog(ansRes)} 已提交`, 'green'); } else { appendAutoAnswerLog(`${rowPrefix}:提交失败 ${cleanAnswerError(submitRes && submitRes.error)}`, 'warn'); this.log(`${rowPrefix}: 提交失败 ${cleanAnswerError(submitRes && submitRes.error)}`, 'orange'); } } await this.sleepRandom(this.config.answerDelayMinMs || DEFAULT_CONFIG.answerDelayMinMs, this.config.answerDelayMaxMs || DEFAULT_CONFIG.answerDelayMaxMs); } const completed = answered + alreadyAnswered; appendAutoAnswerLog(`雨课堂${isExam ? '考试' : ''}:本页完成 ${completed}/${parsed.questions.length} 题${isExam ? ',请确认后手动提交' : ''}`, completed ? 'ok' : 'warn'); if (completed > 0) this.stopAutoAnswerWatcher(); return completed > 0; }, async autoAnswerZhihuishuHomeworkPage(){this.stopAutoAnswerWatcher?.();return false;}, async autoAnswerExamPage() { await waitForElement('div.questionLi,.singleQuesId,.Py-mian1,input[name^="type"],input[id^="answer"],input[name^="answer"]', 12000).catch(() => null); const html = document.documentElement && document.documentElement.outerHTML || ''; const fontBase64 = extractCxsecretFont(html); await ensureCxFontMap(fontBase64); const parsed = parseExamQuestions(document, { html, fontBase64 }); const aa = this.state.autoAnswer; const pageKey = parsed.questions.map(pageQuestionKey).join('\n'); if (pageKey && pageKey === aa.examHandledPageKey) return true; if (pageKey && pageKey !== aa.examPageKey) { aa.examPageKey = pageKey; aa.examHandledPageKey = ''; aa.attempts = 1; // Retry limit is per question/page, not per 85-question paper. } this.log('检测到考试当前页,开始检查题目与已有作答', 'blue'); if (fontBase64) this.log(`考试字体加密已识别:${fontBase64.length} chars`, 'gray'); const overwriteExisting = shouldOverwriteExistingPageAnswers(document); this.log(`考试题目解析:${parsed.questions.length} 道`, parsed.questions.length ? 'green' : 'orange'); if (!parsed.questions.length) { if (!this.state.autoAnswer.diagLogged) { this.state.autoAnswer.diagLogged = true; this.log(`考试解析诊断:${diagnoseWorkPage(document)}`, 'orange'); } return false; } const result = await this.fillParsedQuestionsOnPage(parsed, '考试填答', (q, answer) => fillExamAnswer(document, q, answer), { overwriteExisting }); const completed = Number(result.completed || result.answered || 0); this.log(`考试页面完成:${completed}/${parsed.questions.length},本次新填 ${result.answered || 0},已有作答 ${result.alreadyAnswered || 0}${completed ? ',请确认后手动提交' : ',未填入答案,请检查悬浮窗原因'}`, completed ? 'green' : 'orange'); appendAutoAnswerLog(completed ? `考试页面完成:${completed}/${parsed.questions.length},本次新填 ${result.answered || 0},已有作答 ${result.alreadyAnswered || 0}` : `考试页面未成功填入答案:0/${parsed.questions.length},请检查未填原因`, completed ? 'ok' : 'warn'); if (completed === parsed.questions.length) { // Mark before clicking: native handlers may replace the question synchronously. aa.examHandledPageKey = pageKey; const clickedNext = clickNativeNextQuestion(document); if (clickedNext) { aa.pending = true; this.log('已点击页面原生下一题/下一页,等待新题渲染', 'gray'); appendAutoAnswerLog('当前页已处理,已点击下一题/下一页;等待新题加载,不代表整场考试完成', 'info'); } else { appendAutoAnswerLog('当前页已处理,未找到可用的下一题按钮;可手动翻题,交卷仍需手动确认', 'info'); } // Keep observing; the page key blocks duplicate clicks/queries. } return completed > 0; }, async autoAnswerXuetangxExamPage() { this.log('检测到雨课堂/学堂在线考试承载页,开始页面填答(不自动提交)', 'blue'); appendAutoAnswerLog('雨课堂考试承载页:开始解析页面题目,只自动选择答案,不自动提交', 'info'); await waitForElement('.exercise-item,[class*="problem"],[class*="question"],[data-problem-id],[data-question-id],textarea,input[type="radio"],input[type="checkbox"]', 12000).catch(() => null); const parsed = parseXuetangxExamQuestions(document); this.log(`雨课堂考试承载页解析:${parsed.questions.length} 道`, parsed.questions.length ? 'green' : 'orange'); appendAutoAnswerLog(`雨课堂考试承载页解析:${parsed.questions.length} 道`, parsed.questions.length ? 'ok' : 'warn'); if (!parsed.questions.length) { if (!this.state.autoAnswer.diagLogged) { this.state.autoAnswer.diagLogged = true; this.log(`考试承载页解析诊断:${diagnoseWorkPage(document)}`, 'orange'); } return false; } const result = await this.fillParsedQuestionsOnPage(parsed, '雨课堂考试填答', (q, answer) => fillXuetangxExamAnswer(document, q, answer), { overwriteExisting: false }); const completed = Number(result.completed || result.answered || 0); this.log(`雨课堂考试承载页完成:${completed}/${parsed.questions.length},请确认后手动提交`, completed ? 'green' : 'orange'); appendAutoAnswerLog(`雨课堂考试承载页完成:${completed}/${parsed.questions.length},请确认后手动提交`, completed ? 'ok' : 'warn'); if (completed > 0) this.stopAutoAnswerWatcher(); return completed > 0; }, async fillParsedQuestionsOnPage(parsed, label, fillFn, opts = {}) { let answered = 0; let alreadyAnswered = 0; this.state.autoAnswer.pageAnswered = 0; this.state.autoAnswer.pageAlreadyAnswered = 0; this.state.autoAnswer.pageTotal = parsed.questions.length; refreshAutoAnswerLogStats(); appendAutoAnswerLog(`${label}\uff1a\u5f00\u59cb\u81ea\u52a8\u586b\u7b54\uff0c\u5171 ${parsed.questions.length} \u9898`, 'info'); for (let i = 0; i < parsed.questions.length; i++) { const q = parsed.questions[i]; const qKey = pageQuestionKey(q); const rowPrefix = `${label} ${i + 1}/${parsed.questions.length}`; const alreadyFilledOnPage = !opts.overwriteExisting && isQuestionAlreadyFilled(document, q); if (alreadyFilledOnPage) { alreadyAnswered++; this.state.autoAnswer.pageAlreadyAnswered = alreadyAnswered; this.state.autoAnswer.pageAnswered = answered + alreadyAnswered; refreshAutoAnswerLogStats(); appendAutoAnswerLog(`${rowPrefix}\uff1a\u5df2\u6709\u4f5c\u7b54\uff0c\u5df2\u8df3\u8fc7`, 'muted'); continue; } normalizeQuestionTypeMeta(q); appendAutoAnswerLog(`${rowPrefix}\uff1a\u6b63\u5728\u67e5\u8be2\u7b54\u6848\uff08${q.typeName || '\u9898\u76ee'}\uff09`, 'info'); const apiOptions = (q.options || []).map(o => ({ label: o.label, content: o.content })); const ansRes = await queryAnswerApi(q.question, q.typeName, apiOptions, this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl, this.config.answerApiToken, q); if (ansRes.insufficientQuota) { this.state.stopped = true; this.state.running = false; this.setTokenStatus('Token \u5269\u4f59\u6b21\u6570\u4e0d\u8db3\uff0c\u5df2\u81ea\u52a8\u505c\u6b62\u670d\u52a1\uff1b\u8bf7\u5145\u503c\u540e\u7ee7\u7eed\u3002', 'orange'); this.log('Token \u5269\u4f59\u6b21\u6570\u4e0d\u8db3\uff0c\u5df2\u81ea\u52a8\u505c\u6b62\u670d\u52a1\uff1b\u8bf7\u5145\u503c\u540e\u7ee7\u7eed\u3002', 'red'); showRechargeTip('答题剩余次数不足,请充值后继续。'); appendAutoAnswerLog(`${rowPrefix}\uff1aToken \u5269\u4f59\u6b21\u6570\u4e0d\u8db3\uff0c\u5df2\u505c\u6b62`, 'err'); throw new Error('Token \u5269\u4f59\u6b21\u6570\u4e0d\u8db3\uff0c\u5df2\u505c\u6b62'); } if (!ansRes.success || !ansRes.answer) { const error = String(ansRes.error || ansRes.code || ansRes.message || ''); const reason = cleanAnswerError(error) || '答题接口未返回有效答案'; const failure = `${rowPrefix}:本次未取得答案(${reason}),本题未填入`; this.log(`${failure};题目:${autoLogQuestionText(q.question).slice(0, 40)}`, 'orange'); appendAutoAnswerLog(failure, 'warn'); continue; } if (ansRes.remainingCount != null) { this.applyAnswerTokenInfo(ansRes); this.setTokenStatus(tokenStatusText(this.state.tokenInfo), this.state.tokenInfo.remainingCount === 0 ? 'orange' : 'green'); } const answerForFill = normalizePageAnswerForFill(q, ansRes.answer); const answerForLog = answerForFill !== ansRes.answer ? `${answerForFill}(原始:${answerResultForLog(ansRes)})` : answerResultForLog(ansRes); if (effectiveQuestionTypeN(q) === '3' && judgeAnswerLooksDirty(ansRes.answer)) { const dirtyTip = `${rowPrefix}:题库答案与题型不符(判断题返回 ${answerResultForLog(ansRes)}),已按 ${judgeAnswerLabel(ansRes.answer) || '-'} 处理`; this.log(dirtyTip, 'orange'); appendAutoAnswerLog(dirtyTip, 'warn'); } appendAutoAnswerLog(`${rowPrefix}\uff1a\u5df2\u83b7\u53d6\u7b54\u6848 ${answerForLog}\uff0c\u6b63\u5728\u586b\u5165`, 'info'); try { q.element && q.element.scrollIntoView && q.element.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {} await this.sleep(180); let filled = !!(await fillFn(q, answerForFill)); await this.sleep(Number(this.config.answerVerifyDelayMs || DEFAULT_CONFIG.answerVerifyDelayMs || 650)); let saved = verifyQuestionAnswerSaved(document, q, answerForFill); appendAutoAnswerLog(`${rowPrefix}\uff1a${saved ? '\u5df2\u586b\u5165\u5e76\u4fdd\u5b58' : '\u672a\u786e\u8ba4\u4fdd\u5b58\uff0c\u51c6\u5907\u91cd\u8bd5'}`, saved ? 'ok' : 'warn'); if (!saved) { await this.sleep(Number(this.config.answerRetryDelayMs || DEFAULT_CONFIG.answerRetryDelayMs || 1000)); filled = !!(await fillFn(q, answerForFill)) || filled; await this.sleep(Number(this.config.answerVerifyDelayMs || DEFAULT_CONFIG.answerVerifyDelayMs || 650)); saved = verifyQuestionAnswerSaved(document, q, answerForFill); appendAutoAnswerLog(`${rowPrefix}\uff1a${saved ? '\u91cd\u8bd5\u540e\u5df2\u4fdd\u5b58' : '\u91cd\u8bd5\u540e\u4ecd\u672a\u4fdd\u5b58'}`, saved ? 'ok' : 'warn'); } if (saved) { await this.sleepRandom(this.config.answerDelayMinMs || DEFAULT_CONFIG.answerDelayMinMs, this.config.answerDelayMaxMs || DEFAULT_CONFIG.answerDelayMaxMs); saved = verifyQuestionAnswerSaved(document, q, answerForFill); if (!saved) { appendAutoAnswerLog(`${rowPrefix}\uff1a\u4fdd\u5b58\u72b6\u6001\u5f02\u5e38\uff0c\u6b63\u5728\u518d\u6b21\u91cd\u8bd5`, 'warn'); filled = !!(await fillFn(q, answerForFill)) || filled; await this.sleep(Number(this.config.answerVerifyDelayMs || DEFAULT_CONFIG.answerVerifyDelayMs || 1000)); saved = verifyQuestionAnswerSaved(document, q, answerForFill); appendAutoAnswerLog(`${rowPrefix}\uff1a${saved ? '\u518d\u6b21\u91cd\u8bd5\u540e\u5df2\u4fdd\u5b58' : '\u586b\u7b54\u5931\u8d25\uff0c\u8bf7\u624b\u52a8\u68c0\u67e5'}`, saved ? 'ok' : 'warn'); } } else { await this.sleepRandom(this.config.answerDelayMinMs || DEFAULT_CONFIG.answerDelayMinMs, this.config.answerDelayMaxMs || DEFAULT_CONFIG.answerDelayMaxMs); } const ok = saved; if (ok) { answered++; this.state.autoAnswer.pageAnswered = answered; refreshAutoAnswerLogStats(); this.state.autoAnswer.filledKeys[qKey] = true; } this.log(`${label} ${i + 1}/${parsed.questions.length}: ${q.typeName} -> ${answerForLog} ${ok ? '\u5df2\u586b\u5165' : '\u672a\u586b\u5165'}`, ok ? 'green' : 'orange'); appendAutoAnswerLog(`${rowPrefix}\uff1a${ok ? '\u5b8c\u6210' : '\u672a\u5b8c\u6210'}\uff0c\u7b54\u6848 ${answerForLog}`, ok ? 'ok' : 'warn'); } const completed = answered + alreadyAnswered; appendAutoAnswerLog( `${label}\uff1a\u672c\u9875\u5df2\u5b8c\u6210 ${completed}/${parsed.questions.length} \u9898` + `${alreadyAnswered ? `\uff0c\u5176\u4e2d ${alreadyAnswered} \u9898\u5df2\u6709\u4f5c\u7b54` : ''}` + `\uff0c\u8bf7\u786e\u8ba4\u540e\u624b\u52a8\u63d0\u4ea4`, completed ? 'ok' : 'warn' ); return { answered, alreadyAnswered, completed }; }, async ensureAnswerToken() { const apiUrl = this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl; let token = String(this.config.answerApiToken || '').trim(); if (token) { if (this.els.answerRemaining) this.els.answerRemaining.textContent = '\u8bfb\u53d6\u4e2d'; this.setTokenStatus('\u6b63\u5728\u68c0\u67e5 Token...', 'gray'); const info = await fetchAnswerTokenInfo(token, apiUrl); if (info && info.valid) { this.applyAnswerTokenInfo(info.data || info); this.setTokenStatus(tokenStatusText(this.state.tokenInfo), this.state.tokenInfo.remainingCount === 0 ? 'orange' : 'green'); this.renderContext(this.state.current || {}); return true; } this.state.tokenInfo = { ...(this.state.tokenInfo || {}), remainingCount: null, tokenVerifyFailed: true }; if (this.els.userToken) this.els.userToken.value = token; if (this.els.answerRemaining) this.els.answerRemaining.textContent = '-'; this.setTokenStatus(tokenStatusText(this.state.tokenInfo), 'orange'); return true; } return await this.regenerateAnswerToken(); }, applyAnswerTokenInfo(data = {}) { const stat = extractTokenStats(data); const clean = {}; for (const [k, v] of Object.entries(stat)) { if (v !== null && v !== undefined) clean[k] = v; } this.state.tokenInfo = { ...this.state.tokenInfo, ...clean, tokenVerifyFailed: false }; if (this.els.answerRemaining) { const remaining = this.state.tokenInfo && this.state.tokenInfo.remainingCount; this.els.answerRemaining.textContent = remaining == null ? '-' : String(remaining); if (remaining === 0) this.setUserStatus({ headline: '答题剩余次数不足', currentTask: '等待充值', etaText: '-', tip: '请点击“充值次数”完成充值后继续使用。' }); } refreshAutoAnswerLogStats(); return this.state.tokenInfo; }, async refreshAnswerTokenInfo(expectedToken = '') { const token = String(this.config.answerApiToken || '').trim(); if (!token || (expectedToken && expectedToken !== token)) return null; const refreshSequence = this.tokenRefreshSequence = (this.tokenRefreshSequence || 0) + 1; const info = await fetchAnswerTokenInfo(token, this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl); if (String(this.config.answerApiToken || '').trim() !== token || refreshSequence !== this.tokenRefreshSequence) return null; if (info && info.valid && Number.isFinite(extractTokenStats(info.data || info).remainingCount)) { this.applyAnswerTokenInfo(info.data || info); this.renderContext(this.state.current || {}); return info; } return null; }, async regenerateAnswerToken() { const apiUrl = this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl; this.setTokenStatus('\u6b63\u5728\u81ea\u52a8\u751f\u6210 Token...', 'gray'); const issued = await acquirePublicAnswerToken(apiUrl); if (issued && issued.token) { this.config.answerApiToken = issued.token; saveConfig(this.config); this.applyAnswerTokenInfo(issued.data || { remainingCount: 50 }); this.renderContext(this.state.current || {}); this.setTokenStatus(tokenStatusText(this.state.tokenInfo), 'green'); this.log(`\u5df2\u81ea\u52a8\u751f\u6210\u7528\u6237 Token\uff1a${maskToken(issued.token)}\uff0c\u540e\u7eed\u5145\u503c\u8bf7\u4f7f\u7528\u8be5 Token`, 'green'); return true; } this.setTokenStatus('\u81ea\u52a8\u751f\u6210 Token \u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u7b54\u9898\u670d\u52a1\u662f\u5426\u53ef\u8bbf\u95ee\uff1b\u5982\u5df2\u6709 Token\uff0c\u53ef\u5728\u4e0a\u65b9\u8f93\u5165\u540e\u7ed1\u5b9a\u3002', 'orange'); const reason = issued && issued.error ? `\uff1a${issued.error}` : ''; this.log(`\u81ea\u52a8\u751f\u6210 Token \u5931\u8d25${reason}\uff1b\u8bf7\u68c0\u67e5 /api/public-token \u670d\u52a1\u6216\u7f51\u7edc\uff1b\u4e5f\u53ef\u4ee5\u624b\u52a8\u7ed1\u5b9a\u5df2\u6709 Token\u3002`, 'orange'); return false; }, async bindManualAnswerToken() { const apiUrl = this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl; const input = this.els.manualToken || this.els.userToken; const token = String(input && input.value || '').trim(); if (!token) { this.setTokenStatus('\u8bf7\u5148\u8f93\u5165 Token', 'orange'); return false; } this.setTokenStatus('\u6b63\u5728\u9a8c\u8bc1 Token...', 'gray'); const info = await fetchAnswerTokenInfo(token, apiUrl); if (!info || !info.valid) { this.setTokenStatus('Token \u9a8c\u8bc1\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u540e\u91cd\u8bd5', 'orange'); return false; } this.config.answerApiToken = token; saveConfig(this.config); this.applyAnswerTokenInfo(info.data || info); this.renderContext(this.state.current || {}); this.setTokenStatus(tokenStatusText(this.state.tokenInfo), this.state.tokenInfo.remainingCount === 0 ? 'orange' : 'green'); this.log(`\u5df2\u7ed1\u5b9a\u7528\u6237 Token\uff1a${maskToken(token)}`, 'green'); return true; }, openDashboardPage(autoScan = false) { try { if (this.isZhihuishuPage?.()) return this.startZhihuishuCourse(); const ctx = { ...this.refreshContext(), ...(this.state.current || {}) }; if (this.isYuketangCoursePage()) { Object.assign(ctx, yuketangContextFromLocation({ platform: 'yuketang' }), { platform: 'yuketang', courseName: document.title || '雨课堂课程' }); ctx.siteHost = location.origin; ctx.oldSiteHost = location.origin; ctx.siteHosts = [location.origin]; } if (this.isQingshuCoursePage()) { Object.assign(ctx, qingshuContextFromLocation(/\/Course(?:\/CourseList)?\/?$/i.test(location.pathname)?{href:location.href,siteHost:location.origin,orgCode:qingshuOrgCode(location.pathname)}:{ ...ctx, platform: 'qingshu' }), { platform: 'qingshu', courseName: ctx.courseName || document.title || '青书学堂课程' }); ctx.siteHost = location.origin; ctx.oldSiteHost = location.origin; ctx.siteHosts = [location.origin]; } if(activateMirrorPage.owner){ctx.oldSiteHost=detectOldSiteHost()||location.origin;ctx.siteHost=chaoxingCourseBase();ctx.siteHosts=[ctx.siteHost];} const liveSummary = detectCourseTaskSummaryFromPage(); if (liveSummary) this.state.courseTaskSummary = liveSummary; ctx.oldSiteHost = ctx.oldSiteHost || detectOldSiteHost() || getOldStudyHost(ctx); ctx.siteHost = ctx.siteHost || normalizeSiteOrigin(location.origin, location.href); ctx.siteHosts = ctx.siteHosts && ctx.siteHosts.length ? ctx.siteHosts : buildSiteHostCandidates(ctx.siteHost, ctx.siteHost); if (!ctx.platform || ctx.platform === 'chaoxing') { ctx.platform = 'chaoxing'; ctx.siteHost = chaoxingCourseBase(); ctx.siteHosts = [chaoxingCourseBase()]; } this.dashboardContext = { ...ctx }; this.state.current = { ...ctx }; try { const chapterTitles = this.state.chapterTitles && Object.keys(this.state.chapterTitles).length ? this.state.chapterTitles : {}; GM_setValue(DASHBOARD_CTX_KEY, JSON.stringify({ ctx, chapters: this.state.chapters || [], chapterTitles, courseTaskSummary: this.state.courseTaskSummary || null, ts: Date.now() })); } catch (_) {} const base = String(ctx.oldSiteHost || stripAppPrefix(ctx.siteHost) || location.origin).replace(/\/$/, ''); const dashboardBase = ctx.platform === 'qingshu' ? `${String(ctx.siteHost || location.origin).replace(/\/$/, '')}/${ctx.orgCode || qingshuOrgCode(ctx.href || location.pathname)}/Student/Course/CourseList` : ctx.platform === 'chaoxing' ? chaoxingCoursePageUrl(ctx) : ctx.platform==='yuketang'?location.href:`${base}/`; const payload = encodeURIComponent(JSON.stringify({ ctx, chapterTitles: this.state.chapterTitles || {}, courseTaskSummary: this.state.courseTaskSummary || null })); const dashboardUrl = new URL(dashboardBase); dashboardUrl.searchParams.set('cxRunnerDashboard', '1'); if(ctx.platform==='qingshu')dashboardUrl.searchParams.set('cxRunnerLoad','QS-1012-1-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,8)); dashboardUrl.hash = 'cxRunnerDashboard=1&cxctx=' + payload; const url = dashboardUrl.href; let win;try{win=window.open(url,activateMirrorPage.owner?'_blank':'cx_runner_dashboard');}catch(_){} if(!win&&typeof GM_openInTab==='function')win=GM_openInTab(url,{active:true,insert:true}); if (!win) alert('\u65b0\u9875\u9762\u88ab\u6d4f\u89c8\u5668\u62e6\u622a\uff0c\u8bf7\u5141\u8bb8\u5f39\u7a97\u540e\u91cd\u8bd5\u3002'); return win; }catch(error){const message=/缺少 (courseId|clazzId|cpi)/.exec(String(error?.message));alert(message?'控制台无法打开:缺少 '+message[1]+',请从课程首页重新进入。':'控制台未能打开,请允许弹窗、检查脚本权限后重试。');return null;} }, bindDashboard(win) { const doc = win.document; this.els.root = doc.body; this.els.body = doc.body; this.els.log = doc.querySelector('[data-k="dashboardLog"]'); this.els.chaptersTotal = doc.querySelector('[data-k="chaptersTotal"]'); this.els.tasks = doc.querySelector('[data-k="tasksPending"]'); this.els.tasksTotal = doc.querySelector('[data-k="tasksTotal"]'); this.els.courseTitle = doc.querySelector('[data-k="courseTitle"]'); this.els.courseName = doc.querySelector('[data-k="courseName"]'); this.els.userToken = doc.querySelector('[data-k="userToken"]'); this.els.tokenStatus = doc.querySelector('[data-k="tokenStatus"]'); this.els.answerRemaining = doc.querySelector('[data-k="answerRemaining"]'); this.els.playbackSpeed = doc.querySelector('[data-k="playbackSpeed"]'); this.els.manualToken = this.els.userToken; this.els.progressBar = doc.querySelector('[data-k="progressBar"]'); this.els.progressText = doc.querySelector('[data-k="progressText"]'); this.els.runState = doc.querySelector('[data-k="runState"]'); this.els.userStatus = null; this.els.currentChapter = null; this.els.currentTask = null; this.els.etaText = null; this.els.userTip = null; this.els.announcementCard = doc.querySelector('[data-k="announcementCard"]'); this.els.announcementTitle = doc.querySelector('[data-k="announcementTitle"]'); this.els.announcementContent = doc.querySelector('[data-k="announcementContent"]'); this.els.announcementTag = doc.querySelector('[data-k="announcementTag"]'); this.els.announcementLink = doc.querySelector('[data-k="announcementLink"]'); this.els.startAction = doc.querySelector('[data-act="start"]'); this.els.pauseAction = doc.querySelector('[data-act="pause"]'); this.els.stopAction = doc.querySelector('[data-act="stop"]'); this.mountChaoxingCourseControls(doc); if (this.els.playbackSpeed) { const isQingshu = (this.state.current && this.state.current.platform === 'qingshu') || (this.dashboardContext && this.dashboardContext.platform === 'qingshu'); if (isQingshu) { this.els.playbackSpeed.closest('.speed-setting')?.remove(); this.els.playbackSpeed = null; } else { this.els.playbackSpeed.value = String(this.config.speed || 1); this.els.playbackSpeed.addEventListener('change', () => { const speed = Math.max(0.5, Math.min(2, Number(this.els.playbackSpeed.value || 1) || 1)); this.config.speed = Number(speed.toFixed(1)); this.els.playbackSpeed.value = String(this.config.speed); saveConfig(this.config); this.log(`倍速设置已保存:${this.config.speed}x`, 'blue'); }); } } doc.body.addEventListener('click', ev => { const act = ev.target && ev.target.dataset && ev.target.dataset.act; if (!act) return; if (act === 'start') this.start().catch(e => this.logErr(e)); if (act === 'pause') this.togglePause(); if (act === 'stop') this.stop(); if (act === 'copyToken') this.copyAnswerToken(); if (act === 'bindToken') this.bindManualAnswerToken().catch(e => this.logErr(e)); if (act === 'recharge') this.openRechargePage(); }); win.addEventListener('beforeunload', () => { if (this.state.multiCourse && this.state.multiCourse.active) this.stop(); if (this.announcementTimer) { clearInterval(this.announcementTimer); this.announcementTimer = null; } if (this.dashboardWin === win) this.dashboardWin = null; }); this.renderContext(this.state.current || this.refreshContext()); if ((this.state.current && this.state.current.platform === 'yuketang') || (this.dashboardContext && this.dashboardContext.platform === 'yuketang')) { loadYuketangCourseContext({ ...(this.dashboardContext || {}), ...(this.state.current || {}) }) .then(ctx => { this.dashboardContext = { ...(this.dashboardContext || {}), ...ctx, platform: 'yuketang' }; this.state.current = { ...(this.state.current || {}), ...ctx, platform: 'yuketang' }; this.renderContext(this.state.current); }) .catch(() => null); } this.renderAnnouncement(); this.updateActionButtons(); }, createPanel() { const old = $d.getElementById('cx-runner-panel'); if (old) old.remove(); const root = $d.createElement('div'); root.id = 'cx-runner-panel'; root.innerHTML = `
Chaoxing Runner
已完成任务点0
剩余任务点\u672a\u626b\u63cf

        
`; $d.body.appendChild(root); this.els.root = root; this.els.body = root.querySelector('.cxr-body'); this.els.log = root.querySelector('.cxr-log'); this.els.progress = null; this.els.chaptersTotal = root.querySelector('[data-k="chaptersTotal"]'); this.els.tasks = root.querySelector('[data-k="tasks"]'); this.els.startAction = root.querySelector('[data-act="start"]'); this.els.pauseAction = root.querySelector('[data-act="pause"]'); this.els.stopAction = root.querySelector('[data-act="stop"]'); this.mountChaoxingCourseControls(root.ownerDocument); for (const input of root.querySelectorAll('[data-cfg]')) { const key = input.dataset.cfg; if (input.type === 'checkbox') input.checked = !!this.config[key]; else input.value = this.config[key]; input.addEventListener('change', () => { this.config[key] = input.type === 'checkbox' ? input.checked : Number(input.value); saveConfig(this.config); this.log(`配置更新:${key}=${this.config[key]}`, 'gray'); }); } root.addEventListener('click', ev => { const act = ev.target && ev.target.dataset && ev.target.dataset.act; if (!act) return; if (act === 'min') this.els.body.classList.toggle('cxr-hidden'); if (act === 'diag') this.diagnosePage(); if (act === 'chapters') this.loadChapterList().catch(e => this.logErr(e)); if (act === 'cards') this.debugCards().catch(e => this.logErr(e)); if (act === 'start') this.start().catch(e => this.logErr(e)); if (act === 'pause') this.togglePause(); if (act === 'stop') this.stop(); if (act === 'clear') this.els.log.textContent = ''; }); makeDraggable(root, root.querySelector('.cxr-head')); }, diagnosePage() { const ctx = this.refreshContext(); const frames = Array.from(document.querySelectorAll('iframe')).map((f, i) => ({ i, id: f.id || '', name: f.name || '', src: f.src || f.getAttribute('src') || '', cls: f.className || '' })); const links = Array.from(document.querySelectorAll('a[href]')).slice(0, 60).map(a => ({ text: (a.textContent || '').trim().slice(0, 40), href: a.href })).filter(x => /course|clazz|chapter|knowledge|studentstudy|cards/i.test(x.href + x.text)); this.log(`diag URL: ${location.href}`, 'blue'); this.log(`diag title: ${document.title}`, 'blue'); this.log(`diag base candidates: ${(ctx.siteHosts || []).join(' | ')}`, 'blue'); this.log(`diag iframe count: ${frames.length} ${JSON.stringify(frames).slice(0, 900)}`, 'blue'); this.log(`diag matched links: ${JSON.stringify(links).slice(0, 900)}`, 'blue'); if (this.state.frames && this.state.frames.length) { this.log(`received frame reports: ${JSON.stringify(this.state.frames).slice(0, 900)}`, 'green'); } }, renderContext(ctx) { const set = (k, v) => { const el = this.els.root && this.els.root.querySelector(`[data-k="${k}"]`); if (el) { if ('value' in el) el.value = v || ''; else el.textContent = v || '-'; } }; const courseName = ctx.courseName || document.title || '\u8bfe\u7a0b'; set('courseTitle', courseName); set('courseName', courseName); set('userToken', this.config.answerApiToken || ''); set('answerRemaining', this.state.tokenInfo && this.state.tokenInfo.remainingCount != null ? String(this.state.tokenInfo.remainingCount) : '-'); set('tokenStatus', this.config.answerApiToken ? tokenStatusText(this.state.tokenInfo) : '\u5c1a\u672a\u751f\u6210 Token'); }, setTokenStatus(text, color = 'gray') { if (this.els.tokenStatus) { this.els.tokenStatus.textContent = text || ''; this.els.tokenStatus.className = `muted cxr-${color}`; } }, async openRechargePage() { if (this.state.rechargeOpening) return false; const apiUrl = this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl || RECHARGE_URL; let origin; try { origin = paymentSiteOrigin(apiUrl); } catch (_) { this.log('充值地址无效:请使用 HTTPS 题库地址。', 'orange'); return false; } const token = String(this.config.answerApiToken || '').trim(); const manualUrl = origin + '/recharge#bind-required=1'; const managerTab = typeof GM_openInTab === 'function'; let popup = null; if (!managerTab) { try { popup = window.open('about:blank', '_blank'); if (popup) { popup.opener = null; popup.document.body.textContent = '正在安全打开原 Token 充值页面…'; } } catch (_) {} if (!popup) { this.log('充值窗口被拦截,请允许弹窗后再试。', 'orange'); return false; } } const open = async url => { try { if (managerTab) await GM_openInTab(url, { active: true, insert: true }); else if (popup && !popup.closed) popup.location.replace(url); else { this.log('充值窗口已关闭,请重新点击充值。', 'orange'); return false; } return true; } catch (_) { this.log('无法打开充值页,请检查脚本管理器的新开页面权限。', 'orange'); return false; } }; if (!token) { await open(manualUrl); this.log('当前尚未绑定 Token。请先取得并保存原 Token,再在充值页绑定;付款不会自动生成新 Token。', 'orange'); return false; } this.state.rechargeOpening = true; this.setTokenStatus('正在为原 Token 创建安全充值会话…', 'gray'); try { const link = await requestPaymentCheckout(token, apiUrl); if (String(this.config.answerApiToken || '').trim() !== token) { if (popup && !popup.closed) popup.close(); this.log('Token 已变更,未打开旧 Token 的充值链接;请重新点击充值。', 'orange'); return false; } if (!await open(link.checkoutUrl)) return false; this.watchRechargeBalance(token); this.log('已打开原 Token 的支付宝充值页;付款后返回本页会重新读取余额,不更换 Token。', 'green'); return true; } catch (error) { if (String(this.config.answerApiToken || '').trim() !== token) { if (popup && !popup.closed) popup.close(); return false; } if (!await open(manualUrl)) return false; this.watchRechargeBalance(token); const reason = paymentDisplayError(error && error.message || error, token); this.log('自动绑定支付会话未完成:' + reason + '。已打开手动绑定页,请绑定原 Token 后选择支付宝套餐。', 'orange'); return false; } finally { this.state.rechargeOpening = false; } }, watchRechargeBalance(token) { if (this.paymentFocusHandler) window.removeEventListener('focus', this.paymentFocusHandler); const until = Date.now() + 60 * 60 * 1000; let lastCheck = 0; let checking = false; const handler = async () => { if (Date.now() > until || String(this.config.answerApiToken || '').trim() !== token) { window.removeEventListener('focus', handler); if (this.paymentFocusHandler === handler) this.paymentFocusHandler = null; return; } if (checking || Date.now() - lastCheck < 3000) return; checking = true; lastCheck = Date.now(); try { const baseline = this.state.tokenInfo && this.state.tokenInfo.remainingCount; const waits = [0, 2000, 3000, 10000, 15000, 30000]; for (let attempt = 0; attempt < waits.length; attempt++) { if (waits[attempt]) await this.sleep(waits[attempt]); if (Date.now() > until || String(this.config.answerApiToken || '').trim() !== token || (typeof document !== 'undefined' && document.hidden)) break; const info = await this.refreshAnswerTokenInfo(token); if (!info || String(this.config.answerApiToken || '').trim() !== token) continue; const remaining = this.state.tokenInfo.remainingCount; this.setTokenStatus(tokenStatusText(this.state.tokenInfo), remaining === 0 ? 'orange' : 'green'); const increased = Number.isFinite(baseline) && remaining > baseline; if (increased || attempt === waits.length - 1) this.log('已从服务器刷新原 Token 余额:' + String(remaining ?? '待确认') + ' 次;当前任务不会自动重启。', 'blue'); if (increased) break; } } catch (_) {} finally { checking = false; } }; this.paymentFocusHandler = handler; window.addEventListener('focus', handler); }, async copyAnswerToken() { const token = String(this.config.answerApiToken || '').trim(); if (!token) { alert('\u5f53\u524d\u8fd8\u6ca1\u6709 Token'); return; } try { await navigator.clipboard.writeText(token); this.log('Token \u5df2\u590d\u5236', 'green'); } catch (_) { this.setTokenStatus('\u81ea\u52a8\u590d\u5236\u5931\u8d25\uff0c\u8bf7\u624b\u52a8\u9009\u4e2d\u4e0a\u65b9 Token \u590d\u5236', 'orange'); } }, async loadCourseMiddleParams(ctx) { ctx = { ...(this.state.current || {}), ...(ctx || {}) }; requireParam(ctx.courseId, 'courseId'); requireParam(ctx.clazzId, 'clazzId'); const url = chaoxingCoursePageUrl(ctx); let res = await request({ url, timeout: 30000, withCredentials: true, headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', Referer: chaoxingCourseBase() + '/visit/interaction' } }); assertChaoxingCourseResponse(res, url, ctx); const frameUrl = chaoxingCatalogFrameUrl(res.responseText, url, ctx); if (frameUrl) { res = await request({ url: frameUrl, timeout: 30000, withCredentials: true, headers: { Accept: 'text/html', Referer: url } }); assertChaoxingCourseResponse(res, frameUrl, ctx); } const html = res.responseText || ''; const doc = new DOMParser().parseFromString(html, 'text/html'); const val = (...names) => { for (const name of names) { const el = doc.getElementById(name) || Array.from(doc.querySelectorAll('input')).find(x => x && x.name === name); const v = el && (el.value != null ? el.value : el.getAttribute('value')); if (v !== undefined && v !== null && String(v) !== '') return String(v); } return ''; }; return { html, url: res.finalUrl || frameUrl || url, enc: val('enc') || parseOldEncFromHtml(html), workEnc: val('workEnc'), examEnc: val('examEnc'), openc: val('openc') }; }, async loadCourseWideTasks() { const ctx = { ...this.refreshContext(), ...(this.dashboardContext || {}), ...(this.state.current || {}) }; const all = []; try { const assignments = await this.loadCourseAssignments(ctx); all.push(...assignments); this.log(`作业列表扫描:${assignments.length} 个`, assignments.length ? 'green' : 'gray'); } catch (e) { this.log(`作业列表扫描失败:${e.message || e}`, 'orange'); } try { const exams = await this.loadCourseExams(ctx); all.push(...exams); this.log(`考试列表扫描:${exams.length} 个`, exams.length ? 'green' : 'gray'); } catch (e) { this.log(`作业列表扫描失败:${e.message || e}`, 'orange'); } const seen = new Set(); const deduped = []; for (const task of all) { const key = taskKey(task, 'course'); if (seen.has(key)) continue; seen.add(key); deduped.push(task); } this.state.courseTasks = deduped; return deduped; }, async loadCourseAssignments(ctx) { requireParam(ctx.courseId, 'courseId'); requireParam(ctx.clazzId, 'clazzId'); const oldHost = getOldStudyHost(ctx); const mid = await this.loadCourseMiddleParams(ctx); const stuenc = mid.enc || ''; const workEnc = mid.workEnc || ''; if (!stuenc) throw new Error('课程中间页缺少 enc/stuenc'); const all = []; for (let pageNum = 1; pageNum <= 20; pageNum++) { const params = new URLSearchParams(); params.set('courseId', ctx.courseId); params.set('classId', ctx.clazzId); if (ctx.cpi) params.set('cpi', ctx.cpi); params.set('ut', 's'); params.set('t', String(Date.now())); params.set('stuenc', stuenc); if (workEnc) params.set('enc', workEnc); if (pageNum > 1) params.set('pageNum', String(pageNum)); const url = `${oldHost}/mooc2/work/list?${params.toString()}`; const res = await request({ url, headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', Referer: chaoxingCoursePageUrl(ctx) }, timeout: 30000 }); const html = res.responseText || ''; const parsed = parseAssignmentListHtml(html, { ...ctx, oldHost, pageNum }); all.push(...parsed.tasks); if (!parsed.hasMore || !parsed.tasks.length) break; } return all; }, async loadCourseExams(ctx) { requireParam(ctx.courseId, 'courseId'); requireParam(ctx.clazzId, 'clazzId'); const oldHost = getOldStudyHost(ctx); const mid = await this.loadCourseMiddleParams(ctx); const stuenc = mid.examEnc || ''; const encVal = mid.enc || ''; const openc = mid.openc || ''; if (!stuenc || !encVal || !openc) throw new Error(`课程中间页考试参数不完整 examEnc=${!!stuenc}, enc=${!!encVal}, openc=${!!openc}`); const params = new URLSearchParams(); params.set('courseid', ctx.courseId); params.set('clazzid', ctx.clazzId); if (ctx.cpi) params.set('cpi', ctx.cpi); params.set('ut', 's'); params.set('t', String(Date.now())); params.set('stuenc', stuenc); params.set('enc', encVal); params.set('openc', openc); const url = `${oldHost}/exam-ans/mooc2/exam/exam-list?${params.toString()}`; const res = await request({ url, headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', Referer: chaoxingCoursePageUrl(ctx) }, timeout: 30000 }); return parseExamListHtml(res.responseText || '', { ...ctx, oldHost, openc }); }, mergeChapterTitles(entries = []) { const map = { ...(this.state.chapterTitles || {}) }; for (const entry of entries || []) { const id = String(entry && entry.id || '').trim(); const title = cleanChapterTitle(entry && entry.title || ''); if (!id || !title) continue; if (!map[id] || String(map[id]).length < 2) map[id] = title; } this.state.chapterTitles = map; return map; }, applyOrderedCatalog(html, source = '') { const ordered = parseOrderedCatalogEntries(html); if (!ordered.entries.length) { this.log(`[目录诊断] ${source}: 未识别到有序目录;${catalogStructureProbe(html)}`, 'orange'); return null; } const prev = this.state.orderedCatalog; const better = !prev || (source === 'studentcourse' && !!parseCourseTaskSummary(html)) || ordered.lockedWithId > (prev.lockedWithId || 0) || (ordered.lockedWithId === (prev.lockedWithId || 0) && ordered.entries.length > prev.entries.length); this.mergeChapterTitles(ordered.entries); const todo = ordered.entries.filter(e => !e.finished).map(e => e.id); const pending = ordered.entries.filter(e => !e.finished && (e.unfinished || e.jobCount > 0)).map(e => e.id); this.log(`[目录诊断] ${source}: 小节=${ordered.entries.length}, 已完成=${ordered.finishedCount}, 待做=${ordered.unfinishedCount}, 锁定=${ordered.lockedCount}(带ID ${ordered.lockedWithId}), 待办=${todo.length}, 闯关=${ordered.gated ? '是' : '否'}`, 'blue'); if (ordered.nextChapterId || ordered.unfinishCount !== null) { this.log(`[目录诊断] ${source}: 平台自报 nextChapterId=${ordered.nextChapterId || '-'}, unfinishCount=${ordered.unfinishCount === null ? '-' : ordered.unfinishCount}`, 'gray'); } if (!better) return { ids: ordered.entries.map(e => e.id), ordered, todo, adopted: false }; this.state.orderedCatalog = { ...ordered, source }; this.state.orderedTodo = todo; this.state.pendingChapters = pending; if (ordered.lockedCount) { const withoutId = Math.max(0, ordered.lockedCount - ordered.lockedWithId); this.state.lockedChapterInfo = { lockedCount: withoutId, lockedLabels: [], knownJobCount: ordered.entries.reduce((sum, entry) => sum + Number(entry.jobCount || 0), 0), hasCatalog: true }; this.log(`闯关课程:${ordered.lockedCount} 个小节待解锁${ordered.lockedWithId ? `(${ordered.lockedWithId} 个已可排队,按顺序解锁)` : ''}${withoutId ? `(${withoutId} 个暂无入口,需解锁后重扫)` : ''}`, 'orange'); } return { ids: ordered.entries.map(e => e.id), ordered, todo, adopted: true }; }, async loadChapterList() { const ctx = { ...this.refreshContext(), ...(this.dashboardContext || {}), siteHost: chaoxingCourseBase(), siteHosts: [chaoxingCourseBase()] }; this.state.current = ctx; requireParam(ctx.courseId, 'courseId'); requireParam(ctx.clazzId, 'clazzId'); this.state.chapters = []; this.state.pendingChapters = []; this.state.blockedChapters = {}; this.state.orderedCatalog = null; this.state.orderedTodo = []; this.state.lockedChapterInfo = { lockedCount: 0, lockedLabels: [], knownJobCount: 0, hasCatalog: false }; this.state.courseTaskSummary = null; try { this.state.courseTaskSummary = await loadChaoxingTaskProgress(ctx); } catch (_) {} if (isCourseTaskSummaryComplete(this.state.courseTaskSummary)) return []; this.state.failureStage='读取课程页面'; const url = chaoxingCoursePageUrl(ctx); let response = await request({ url, timeout: 20000, withCredentials: true, headers: { Referer: chaoxingCoursePageUrl(ctx) } }); this.state.failureStage='核对课程页面'; assertChaoxingCourseResponse(response, url, ctx); const frameUrl = chaoxingCatalogFrameUrl(response.responseText, url, ctx); if (frameUrl) { this.state.failureStage='读取章节目录'; response = await request({ url: frameUrl, timeout: 20000, withCredentials: true, headers: { Referer: url } }); this.state.failureStage='核对课程页面'; assertChaoxingCourseResponse(response, frameUrl, ctx); } const html = response.responseText || ''; if(/]*>[^<]*(?:登录|login)/i.test(html))throw new Error('课程登录状态失效'); this.state.failureStage='解析章节目录'; const summary = parseCourseTaskSummary(html); if (summary && this.state.courseTaskSummary?.source !== 'stu-job-info') this.state.courseTaskSummary = summary; const resourceEnc = parseOldEncFromHtml(html); if (resourceEnc) ctx.oldEnc = resourceEnc; const ordered = this.applyOrderedCatalog(html, 'studentcourse'); let chapters; if (ordered) chapters = ordered.ids; else { const entries = parseChapterEntries(html); chapters = [...new Set(entries.map(entry => entry.id))]; this.mergeChapterTitles(entries); this.state.pendingChapters = parsePendingChapterIds(html); } if (!chapters.length) throw new Error('新版课程目录未读到可用小节,请检查登录或课程权限;未访问旧版课程页'); this.state.chapters = chapters; this.state.chapterIndex = 0; if (this.dashboardContext) this.dashboardContext = { ...this.dashboardContext, siteHost: ctx.siteHost, siteHosts: [...ctx.siteHosts], ...(resourceEnc ? { oldEnc: resourceEnc } : {}) }; if (!this.isCourseWorker) try { const saved = JSON.parse(GM_getValue(DASHBOARD_CTX_KEY, '{}') || '{}'); GM_setValue(DASHBOARD_CTX_KEY, JSON.stringify({ ...saved, ctx, chapters, chapterTitles: this.state.chapterTitles || {}, courseTaskSummary: this.state.courseTaskSummary, ts: Date.now() })); } catch (_) {} this.updateProgress(); return chapters; }, courseChapterPlan(chapters = []) { const entries = this.state.orderedCatalog && this.state.orderedCatalog.entries; if (entries && entries.length) return { chapters: entries.filter(entry => !entry.finished).map(entry => String(entry.id)), pendingOnly: !this.isGatedCourse() }; if (this.state.pendingChapters && this.state.pendingChapters.length) return { chapters: [...new Set(this.state.pendingChapters.map(String))], pendingOnly: !this.isGatedCourse() }; return { chapters: [...new Set(chapters.map(String))], pendingOnly: false }; }, chapterCatalogEntry(chapterId) { return this.state.orderedCatalog?.entries?.find(entry => String(entry.id) === String(chapterId)); }, lockedChapterCount() { return Math.max(0, Number(this.state.lockedChapterInfo && this.state.lockedChapterInfo.lockedCount || 0)); }, isGatedCourse() { if (this.state.orderedCatalog && this.state.orderedCatalog.gated) return true; if (this.lockedChapterCount() > 0) return true; return Object.keys(this.state.blockedChapters || {}).length > 0; }, lockedChapterTip() { const n = this.lockedChapterCount(); if (!n) return ''; return `另有 ${n} 个小节需完成前置任务点后才会解锁`; }, setTaskStatsFromSummary(summary) { const total = Math.max(0, Number(summary && summary.total || 0)); const done = Math.max(0, Math.min(total, Number(summary && summary.done || 0))); const pending = Math.max(0, total - done); const lockedCount = this.lockedChapterCount(); const stats = { authoritativeTotal: total > 0, total, completed: done, pending, runnablePending: pending, scanned: true, doneKeys: {}, runnableKeys: {}, byType: {}, lockedChapters: lockedCount, lockedLabels: (this.state.lockedChapterInfo && this.state.lockedChapterInfo.lockedLabels) || [] }; this.state.taskStats = stats; this.state.jobTotal = pending; this.state.jobDone = 0; this.updateProgress(); const lockedTip = this.lockedChapterTip(); this.setUserStatus({ headline: '课程任务点已准备好', currentChapter: `任务点总数 ${total}`, currentTask: `剩余任务点 ${pending}${lockedTip ? `(${lockedTip})` : ''}`, etaText: pending || lockedCount ? '点击开始后自动预计' : '已完成', tip: pending || lockedCount ? '点击“开始自动刷课”即可开始。' : '课程页显示任务点已完成,无需重复扫描。' }); this.log(`任务点识别完成:总数 ${total},已完成 ${done},剩余 ${pending}${lockedTip ? `;${lockedTip}` : ''}`, pending || lockedCount ? 'orange' : 'green'); return stats; }, async scanCourseTasks(chapters, opts = {}) { chapters = [...new Set(chapters.map(String))].filter(id => !this.chapterCatalogEntry(id)?.finished); const lockedCount = this.lockedChapterCount(); const prev = (opts && opts.append) ? (this.state.taskStats || {}) : null; const stats = { authoritativeTotal: !!(prev && prev.authoritativeTotal), total: prev ? Math.max(0, Number(prev.total || 0)) : 0, completed: prev ? Math.max(0, Number(prev.completed || 0)) : 0, pending: 0, runnablePending: prev ? Math.max(0, Number(prev.runnablePending || 0)) : 0, scanned: true, doneKeys: prev ? { ...(prev.doneKeys || {}) } : {}, runnableKeys: prev ? { ...(prev.runnableKeys || {}) } : {}, unresolvedKeys: prev ? { ...(prev.unresolvedKeys || {}) } : {}, byType: prev ? { ...(prev.byType || {}) } : {}, lockedChapters: lockedCount, lockedLabels: (this.state.lockedChapterInfo && this.state.lockedChapterInfo.lockedLabels) || [] }; const nativeSummary = this.state.courseTaskSummary; if (nativeSummary && Number(nativeSummary.total || 0) > 0) { stats.authoritativeTotal = true; stats.total = Number(nativeSummary.total); stats.completed = Math.min(stats.total, Math.max(Number(nativeSummary.done || 0), prev ? Number(prev.completed || 0) : 0)); } if (stats.authoritativeTotal) stats.pending = Math.max(0, stats.total - stats.completed); const pendingOnly = !!(opts && opts.pendingOnly); this.state.taskStats = stats; this.state.courseTasks = []; const totalChapters = chapters.length; const gated = this.isGatedCourse(); const concurrency = gated ? 1 : Math.max(1, Math.min(Number(this.config.scanConcurrency || 6), totalChapters, 10)); if (gated) this.log('检测到闯关课程特征,任务点识别改为串行,避免未解锁小节被误判', 'orange'); let next = 0; let scanned = 0; let loadedCardsCount = 0; this.setUserStatus({ headline: '正在准备课程任务点', currentChapter: '正在识别任务点', currentTask: '正在读取课程中的视频、文档和测验任务', etaText: '请稍候', tip: '这一步只展示任务点进度,不显示接口和调试信息。' }); const mergeCards = (chapterId, cards) => { for (const task of uniqueTaskAttachments(cards.attachments || [], chapterId)) mergeTaskPointEvidence(stats, task, chapterId); }; const worker = async () => { while (!this.state.stopped) { await this.waitIfPaused(); const i = next++; if (i >= totalChapters) break; const chapterId = chapters[i]; try { const cards = await this.loadCards(chapterId, { noDelay: true, quiet: true, skipRenderedStatus: true }); if (cards.noContent) { this.markChapterBlocked(chapterId); if (gated) { const restCount = Math.max(0, totalChapters - i - 1); if (restCount > 0) { next = totalChapters; this.log(`扫描到未解锁小节,后续 ${restCount} 个小节留待按顺序解锁后处理`, 'orange'); } } } else { mergeCards(chapterId, cards); loadedCardsCount++; } } catch (_) { } finally { scanned++; this.state.taskStats = stats; this.updateProgress(); this.setUserStatus({ headline: '正在准备课程任务点', currentChapter: '正在识别任务点', currentTask: scanned >= totalChapters ? '任务点识别即将完成' : '正在继续识别任务点', etaText: scanned >= totalChapters ? '即将完成' : '请稍候', tip: '准备完成后可开始自动学习。' }); } } }; await Promise.all(Array.from({ length: concurrency }, () => worker())); this.state.courseTasks = []; const summary = this.state.courseTaskSummary; const lockedNow = this.lockedChapterCount(); if (summary && Number(summary.total || 0) > 0) { stats.authoritativeTotal = true; stats.total = Number(summary.total); stats.completed = Math.min(stats.total, Math.max(Number(summary.done || 0), prev ? Number(prev.completed || 0) : 0)); stats.pending = Math.max(0, stats.total - stats.completed); } else if (prev && prev.authoritativeTotal) { stats.total = Number(prev.total || 0); stats.completed = Number(prev.completed || 0); stats.pending = Math.max(0, stats.total - stats.completed); } else { const catalogPending = Math.max(lockedNow > 0 && this.state.lockedChapterInfo?.hasCatalog ? Number(this.state.lockedChapterInfo.knownJobCount || 0) : 0, (this.state.orderedCatalog?.entries || []).reduce((sum, entry) => sum + (entry.finished ? 0 : Number(entry.jobCount || 0)), 0)); stats.pending = Math.max(0, stats.total - stats.completed, stats.runnablePending + Object.keys(stats.unresolvedKeys || {}).length, catalogPending); stats.total = Math.max(stats.total, stats.completed + stats.pending); } stats.lockedChapters = this.lockedChapterCount(); stats.lockedLabels = (this.state.lockedChapterInfo && this.state.lockedChapterInfo.lockedLabels) || []; this.state.taskStats = stats; this.state.jobTotal = stats.runnablePending; if (!prev) this.state.jobDone = 0; this.updateProgress(); const lockedTip = this.lockedChapterTip(); this.setUserStatus({ headline: '课程任务点已准备好', currentChapter: `任务点总数 ${stats.total || 0}`, currentTask: `剩余任务点 ${stats.pending || 0}${lockedTip ? `(${lockedTip})` : ''}`, etaText: '点击开始后自动预计', tip: '点击“开始自动刷课”即可开始。' }); this.log(`任务点识别完成:总数 ${stats.total || 0},已完成 ${stats.completed || 0},剩余 ${stats.pending || 0}${lockedTip ? `;${lockedTip}` : ''}`, stats.pending || stats.lockedChapters ? 'orange' : 'green'); this.log(`course tasks: total=${stats.total}, done=${stats.completed}, pending=${stats.pending}, locked=${stats.lockedChapters}`, 'green'); return stats; }, async scanCurrentCourse() { if (this.isZhihuishuPage?.() || this.state.current?.platform === 'zhihuishu' || this.dashboardContext?.platform === 'zhihuishu') return this.scanZhihuishuNativeCourse(); this.refreshContext(); let chapters = this.state.chapters || []; const loadedChapters = await this.loadChapterList(); if (loadedChapters.length) chapters = loadedChapters; const gated = this.isGatedCourse(); if (isCourseTaskSummaryComplete(this.state.courseTaskSummary) && !gated) { return this.setTaskStatsFromSummary(this.state.courseTaskSummary); } const plan = this.courseChapterPlan(chapters); chapters = plan.chapters; if (!chapters.length && !this.state.orderedCatalog?.entries?.length && this.state.current.chapterId) chapters = [this.state.current.chapterId]; if (!chapters.length) throw new Error('没有可扫描章节'); return this.scanCourseTasks(chapters, { pendingOnly: plan.pendingOnly }); }, ensureAutoScan() { const ctx = this.dashboardContext || this.state.current || {}; if (!this.isCourseWorker && this.config.autoCourseQueue !== false && (!ctx.platform || ctx.platform === 'chaoxing') && ctx.courseId && ctx.clazzId) return this.prepareChaoxingCourses(); if (this.scanPromise) return this.scanPromise; this.scanPromise = this.scanCurrentCourse().catch(e => { this.scanPromise = null; throw e; }); return this.scanPromise; }, async debugCards() { const ctx = this.refreshContext(); const chapterId = ctx.chapterId || this.state.chapters[0]; requireParam(ctx.courseId, 'courseId'); requireParam(ctx.clazzId, 'clazzId'); requireParam(chapterId, 'chapterId'); const cards = await this.loadCards(chapterId); this.log(`Cards 调试:chapter=${chapterId}, attachments=${cards.attachments.length}, reportUrl=${cards.reportUrl || '-'}`, 'green'); for (const a of cards.attachments) { this.log(`- ${a.type || '?'} ${a.job ? 'job' : 'no-job'} ${safeName(a)}`, 'gray'); } }, async loadCards(chapterId, opts = {}) { const ctx = this.state.current; const cpiPart = ctx.cpi ? `&cpi=${enc(ctx.cpi)}` : ''; const oldHost = getOldStudyHost(ctx); const oldEnc = ctx.oldEnc || ctx.enc || ''; let page = 0; let all = []; let reportUrl = ''; let fid = ctx.fid; let ktoken = ctx.ktoken || ''; let defaults = {}; let emptyPages = 0; let noContent = false; while (page < 20) { const cardUrl = `${oldHost}/mooc-ans/knowledge/cards?clazzid=${enc(ctx.clazzId)}&courseid=${enc(ctx.courseId)}&knowledgeid=${enc(chapterId)}&num=${page}&ut=s${cpiPart}&v=2025-0424-1038-3&mooc2=1&isMicroCourse=false&editorPreview=0`; const referer = `${oldHost}/mycourse/studentstudy?chapterId=${enc(chapterId)}&courseId=${enc(ctx.courseId)}&clazzid=${enc(ctx.clazzId)}${cpiPart}&enc=${enc(oldEnc || randomString())}&mooc2=1&hidetype=0&openc=${randomString()}`; const res = await request({ url: cardUrl, headers: { Referer: referer } }); const html = res.responseText || ''; const parsed = parseMArg(html) || parseCardsIframeFallback(html, oldHost); if (!parsed) { if (page === 0) { noContent = true; if (!opts.quiet) this.log(`chapter=${chapterId} 未找到 mArg,章节可能未开放`, 'orange'); } break; } applyCardsDomStatus(parsed, html); defaults = { ...defaults, ...(parsed.defaults || {}) }; reportUrl = parsed.defaults && parsed.defaults.reportUrl || reportUrl; fid = parsed.defaults && parsed.defaults.fid || fid; ktoken = parsed.defaults && parsed.defaults.ktoken || ktoken; const normalizedAttachments = (parsed.attachments || []).map(a => normalizeAttachment(a, parsed.defaults || {})).filter(Boolean); all = all.concat(normalizedAttachments); if (!parsed.attachments || parsed.attachments.length === 0) { emptyPages++; if (emptyPages >= 3) break; } else { emptyPages = 0; } page++; if (!opts.noDelay) await this.sleepRandom(); } if (fid) this.state.current.fid = fid; if (ktoken) this.state.current.ktoken = ktoken; return { attachments: all, reportUrl, fid, ktoken, defaults, noContent }; }, async touchChapterVisit(chapterId, opts = {}) { const ctx = this.state.current; const oldHost = getOldStudyHost(ctx); const cpiPart = ctx.cpi ? `&cpi=${enc(ctx.cpi)}` : ''; const oldEnc = ctx.oldEnc || ctx.enc || ''; const studyUrl = `${oldHost}/mycourse/studentstudy?chapterId=${enc(chapterId)}&courseId=${enc(ctx.courseId)}&clazzid=${enc(ctx.clazzId)}${cpiPart}&enc=${enc(oldEnc || randomString())}&mooc2=1&hidetype=0&openc=${randomString()}`; const ajaxUrl = `${oldHost}/mooc-ans/mycourse/studentstudyAjax?courseId=${enc(ctx.courseId)}&clazzid=${enc(ctx.clazzId)}&chapterId=${enc(chapterId)}${cpiPart}&verificationcode=&mooc2=1&toComputer=falseµTopicId=0&editorPreview=0&isPreviewVideo=false&videoWidth=0&videoHeight=0&targetVideoJobId=&cardIndex=0`; const validateUrl = `${oldHost}/mooc-ans/edit/validatejobcount?courseId=${enc(ctx.courseId)}&clazzid=${enc(ctx.clazzId)}&nodeid=${enc(chapterId)}`; const headers = { Referer: studyUrl }; let ok = false; try { await request({ url: ajaxUrl, headers }); ok = true; } catch (e) { if (!opts.quiet) this.log(`章节访问记录失败 studentstudyAjax: ${chapterId} -> ${e.message || e}`, 'orange'); } try { await request({ url: validateUrl, headers }); ok = true; } catch (e) { if (!opts.quiet) this.log(`章节访问校验失败 validatejobcount: ${chapterId} -> ${e.message || e}`, 'orange'); } return ok; }, async start() { if(this.qingshuDiscussionRefresh||this.qingshuModeLoading)return; if (this.state.running) { this.log('任务已在运行', 'orange'); return; } const platform = this.dashboardContext?.platform || this.state.current?.platform || (this.isZhihuishuPage?.() ? 'zhihuishu' : ''); if ((window.name === 'cx_runner_dashboard' || this.isDashboardPage()) && platform !== 'zhihuishu') { this.config.doVideo = true; this.config.doDocument = false; this.config.doWork = (this.dashboardContext && this.dashboardContext.platform === 'qingshu'); } if ((this.dashboardContext && this.dashboardContext.platform === 'qingshu') || (this.state.current && this.state.current.platform === 'qingshu') || this.isQingshuCoursePage()) { this.log('青书当前模式:'+(this.config.qingshuAutoCourseQueue!==false?'所选课程顺序学习':'仅当前课程')+';讨论状态已启用;请求间隔至少 10 秒。','blue'); if(!this.isCourseWorker&&this.config.qingshuAutoCourseQueue!==false)await this.startQingshuCourses();else await this.startQingshuCourse(); return; } if ((this.dashboardContext && this.dashboardContext.platform === 'yuketang') || (this.state.current && this.state.current.platform === 'yuketang') || this.isYuketangCoursePage()) { await this.startYuketangCourse(); return; } if ((this.dashboardContext && this.dashboardContext.platform === 'zhihuishu') || (this.state.current && this.state.current.platform === 'zhihuishu') || this.isZhihuishuCoursePage()) { await this.startZhihuishuCourse(); return; } if (!this.isCourseWorker && this.config.autoCourseQueue !== false) return this.startChaoxingCourses(); return this.startChaoxingSingleCourse(); }, async startChaoxingSingleCourse() { this.state.failureStage='检查课程参数'; if (!await this.ensureAnswerToken()) { this.setUserStatus({ headline: '请先绑定有效 Token', currentTask: '等待绑定 Token', etaText: '-', tip: 'Token 验证通过后再点击“开始自动刷课”。' }); this.log('未绑定有效答题 Token,已取消执行', 'orange'); return; } const ctx = { ...this.refreshContext(), ...(this.dashboardContext || {}) }; this.state.current = ctx; requireParam(ctx.courseId, 'courseId'); requireParam(ctx.clazzId, 'clazzId'); if (!ctx.uid) this.log('未检测到 uid,会继续尝试;若视频上报失败,请先登录/进入课程页。', 'orange'); this.state.running = true; this.state.paused = false; this.state.stopped = false; this.state.jobDone = 0; this.setUserStatus({ headline: '正在准备课程任务点', currentChapter: '-', currentTask: '正在整理视频、文档和测验进度', etaText: '请稍候', tip: '准备完成后会自动开始学习。' }); this.updateActionButtons(); let chapters = this.state.chapters || []; if (this.scanPromise) { await this.scanPromise; chapters = this.state.chapters || chapters; } this.state.failureStage='读取课程目录'; const loadedChapters = await this.loadChapterList(); chapters = loadedChapters; this.state.taskStats = { ...this.state.taskStats, scanned: false }; if (isCourseTaskSummaryComplete(this.state.courseTaskSummary)) { this.setTaskStatsFromSummary(this.state.courseTaskSummary); this.state.running = false; this.state.paused = false; this.updateActionButtons(); return; } this.state.failureStage='整理待办小节'; const plan = this.courseChapterPlan(chapters); chapters = plan.chapters; if (!chapters.length && !this.state.orderedCatalog?.entries?.length && ctx.chapterId) chapters = [ctx.chapterId]; if (!chapters.length) throw new Error('目录中没有可确认的待办小节,未重复学习已完成章节'); this.state.failureStage='扫描任务点'; await this.scanCourseTasks(chapters, { pendingOnly: plan.pendingOnly }); const st = this.state.taskStats || {}; const totalTasks = st.scanned ? Math.max(0, Number(st.total || 0)) : 0; const pendingTasks = st.scanned ? Number(st.pending || 0) : 0; const lockedTasksTip = this.lockedChapterTip(); this.setUserStatus({ headline: `开始处理剩余 ${pendingTasks} 个任务点`, currentChapter: `任务点总数 ${totalTasks}`, currentTask: `即将进入第一个待处理任务点${lockedTasksTip ? `(${lockedTasksTip})` : ''}`, etaText: '正在计算', tip: '学习开始后会自动进入下一个任务点。' }); this.log(`开始处理剩余任务点:${pendingTasks}/${totalTasks}${lockedTasksTip ? `;${lockedTasksTip}` : ''}`, 'green'); if (st.scanned && pendingTasks <= 0 && !this.isGatedCourse()) { this.setUserStatus({ headline: '课程任务点已全部完成', currentChapter: `已完成 ${Number(st.completed || 0)}/${totalTasks}`, currentTask: '没有剩余待处理任务点', etaText: '已完成', tip: '可以回到课程页面查看完成状态。' }); this.log(`课程任务点已全部完成:${Number(st.completed || 0)}/${totalTasks}`, 'green'); this.state.running = false; this.state.paused = false; this.updateProgress(); this.updateActionButtons(); return; } const maxRounds = Math.max(1, Math.min(50, Number(this.config.unlockRescanRounds || DEFAULT_CONFIG.unlockRescanRounds || 12))); const handledChapters = new Set(); let round = 0; while (!this.state.stopped) { round++; for (let i = 0; i < chapters.length; i++) { if (this.state.stopped) break; this.state.chapterIndex = i; this.updateProgress(); await this.waitIfPaused(); this.state.failureStage='处理小节任务'; await this.runChapter(chapters[i]); handledChapters.add(String(chapters[i])); } if (this.state.stopped) break; const lockedBefore = this.lockedChapterCount(); if (!lockedBefore) break; if (round >= maxRounds) { this.log(`已达到解锁重扫上限(${maxRounds} 轮),仍有 ${lockedBefore} 个小节未解锁`, 'orange'); break; } this.setUserStatus({ headline: '正在检查新解锁的小节', currentChapter: `第 ${round} 轮已完成`, currentTask: `仍有 ${lockedBefore} 个小节待解锁`, etaText: '请稍候', tip: '闯关课程需完成前置任务点后才会开放后续小节。' }); this.log(`第 ${round} 轮处理完毕,重新读取课程目录检查解锁情况`, 'blue'); await this.sleep(3000); let refreshed = []; try { refreshed = await this.loadChapterList(); } catch (e) { this.log(`重新读取课程目录失败:${e.message || e}`, 'orange'); break; } const nextTodo = (this.state.orderedTodo && this.state.orderedTodo.length) ? this.state.orderedTodo : refreshed; const retryList = nextTodo.filter(id => this.state.blockedChapters && this.state.blockedChapters[String(id)]); const newlyUnlocked = nextTodo.filter(id => !handledChapters.has(String(id))); const nextRound = [...new Set([...retryList, ...newlyUnlocked])]; const lockedAfter = this.lockedChapterCount(); if (!nextRound.length) { if (lockedAfter) this.log(`没有可继续处理的小节,仍有 ${lockedAfter} 个未解锁;可能需要先提交章节测验或等待教师开放`, 'orange'); break; } this.log(`下一轮待处理小节:${nextRound.length} 个(其中重试未解锁 ${retryList.length} 个,新发现 ${newlyUnlocked.length} 个)`, 'green'); if (newlyUnlocked.length) { await this.scanCourseTasks(newlyUnlocked, { pendingOnly: false, append: true }); } chapters = nextRound; } if (!this.state.stopped) { try { const latest = await loadChaoxingTaskProgress(this.state.current); if (latest) { this.state.courseTaskSummary = latest; Object.assign(this.state.taskStats, { total: latest.total, completed: latest.done, pending: latest.pending, authoritativeTotal: true }); } } catch (_) {} } this.state.running = false; this.state.paused = false; this.updateProgress(); const lockedLeft = this.lockedChapterCount(); const remainingLeft = Number(this.state.taskStats.pending || 0); this.setUserStatus({ headline: this.state.stopped ? '已停止学习' : (lockedLeft || remainingLeft ? '本轮结束,仍有任务未完成' : '课程学习已完成'), currentTask: this.state.stopped ? '已停止处理任务' : (lockedLeft || remainingLeft ? `剩余 ${remainingLeft} 个任务点,${lockedLeft} 个小节待解锁` : '全部可处理任务已完成'), etaText: this.state.stopped ? '-' : (lockedLeft || remainingLeft ? '仍有待处理任务' : '已完成'), tip: this.state.stopped ? '如需继续,请重新点击开始。' : (lockedLeft ? '未解锁小节可能需要先完成章节测验,或等待教师开放后重新开始。' : '可以回到课程页面查看完成情况。') }); this.log(this.state.stopped ? '已停止' : (lockedLeft || remainingLeft ? `执行结束(剩余 ${remainingLeft} 个任务点)` : '执行结束'), this.state.stopped || lockedLeft || remainingLeft ? 'orange' : 'green'); }, chaoxingCourseSelectionScope() { const ctx = { ...this.refreshContext(), ...(this.dashboardContext || {}) }; return JSON.stringify([location.href, detectUid() || ctx.uid || '', chaoxingCourseKey(ctx)]); }, async prepareChaoxingCourses(force = false) { if (this.state.running || this.isCourseWorker || this.config.autoCourseQueue === false) return; if (this.chaoxingCoursesLoading) return this.chaoxingCoursesLoading; const scope = this.chaoxingCourseSelectionScope(); const previous = this.state.chaoxingCourseSelection; if (!force && previous?.ready && previous.scope === scope) return previous; const ctx = { ...this.refreshContext(), ...(this.dashboardContext || {}) }; ctx.uid = detectUid() || ctx.uid || ''; const selection = { scope, loading: true, ready: false, entries: [], error: '' }; this.clearCourseQueueResult(); this.state.chaoxingCourseSelection = selection; this.setUserStatus({ headline: '正在读取课程列表', currentTask: '只读取课程,不启动学习', etaText: '请稍候', tip: '读取完成后请勾选需要的课程。' }); this.renderChaoxingCourseSelection(); this.updateActionButtons(); this.chaoxingCoursesLoading = (async () => { await Promise.resolve(); try { requireParam(ctx.courseId, 'courseId'); requireParam(ctx.clazzId, 'clazzId'); const courses = await loadChaoxingAccountCourses(ctx); if (selection !== this.state.chaoxingCourseSelection || scope !== this.chaoxingCourseSelectionScope() || this.state.running || this.config.autoCourseQueue === false) { selection.error = '页面或账号已变化,请重新读取课程'; return selection; } const seen = new Set(); selection.entries = courses.filter(course => { const key = chaoxingCourseKey(course); if (seen.has(key)) return false; seen.add(key); return true; }).sort((a, b) => Number(chaoxingCourseKey(b) === chaoxingCourseKey(ctx)) - Number(chaoxingCourseKey(a) === chaoxingCourseKey(ctx))) .map(course => ({ key: chaoxingCourseKey(course), ctx: course, unavailable: !!course.unavailable, selected: false })); selection.ready = true; this.setUserStatus({ headline: '课程列表已读取,请勾选课程', currentTask: '等待选择', etaText: '-', tip: '默认不选择任何课程,点击开始后只处理勾选项。' }); this.log('已读取 ' + selection.entries.length + ' 门课程,请勾选需要学习的课程;尚未开始学习。', 'green'); } catch (error) { selection.error = this.publicErrorText(error); this.log('账号课程列表读取失败:' + selection.error + ';未开始任何课程,请重新读取。', 'orange', { publicDiagnostic: true }); } finally { selection.loading = false; this.chaoxingCoursesLoading = null; this.renderChaoxingCourseSelection(); this.updateActionButtons(); } return selection; })(); return this.chaoxingCoursesLoading; }, createChaoxingCourseWorker(entry, run) { const parent = this; const worker = Object.fromEntries(Object.entries(this).filter(([, value]) => typeof value === 'function')); worker.isCourseWorker = true; worker.config = { ...this.config }; worker.state = freshChaoxingCourseState(entry.ctx); worker.state.tokenInfo = { ...(this.state.tokenInfo || {}) }; worker.dashboardContext = { ...entry.ctx }; worker.els = {}; worker.videoFrameScope = 'course:' + run.id + ':' + entry.key; let stopped = false, paused = false; Object.defineProperties(worker.state, { stopped: { enumerable: true, get: () => stopped || run.stopped, set: value => { stopped = !!value; } }, paused: { enumerable: true, get: () => paused || run.paused, set: value => { paused = !!value; } } }); worker.refreshContext = () => ({ ...worker.state.current }); worker.collectContext = worker.refreshContext; worker.renderContext = () => {}; worker.ensureAnswerToken = async () => !run.stopped; worker.log = (message, color, options) => parent.log('【' + entry.ctx.courseName + '】' + message, color, options); worker.logErr = error => worker.log(parent.publicErrorText(error), 'red'); worker.setUserStatus = patch => { worker.state.uiStatus = { ...worker.state.uiStatus, ...patch }; parent.renderChaoxingCourseProgress(); }; worker.renderUserStatus = () => {}; worker.updateProgress = () => parent.renderChaoxingCourseProgress(); worker.updateActionButtons = () => {}; worker.setTokenStatus = (...args) => parent.setTokenStatus(...args); const applyToken = worker.applyAnswerTokenInfo; worker.applyAnswerTokenInfo = info => { applyToken.call(worker, info); if (parent.config.answerApiToken === worker.config.answerApiToken) parent.applyAnswerTokenInfo(info); }; const work = worker.runWork; worker.runWork = async (...args) => { const previous = run.workGate; let release; run.workGate = new Promise(resolve => { release = resolve; }); await previous; try { await worker.waitIfPaused(); if (worker.state.stopped) throw new Error('已停止,未开始章节测验'); return await work.apply(worker, args); } finally { release(); } }; return worker; }, async startChaoxingCourses() { if (this.state.running || this.config.autoCourseQueue === false) return; const selection = this.state.chaoxingCourseSelection; const scope = this.chaoxingCourseSelectionScope(); if (!selection?.ready || selection.scope !== scope) { await this.prepareChaoxingCourses(true); return; } const courses = selection.entries.filter(entry => entry.selected && !entry.unavailable).map(entry => ({ ...entry.ctx })); if (!courses.length) { this.log('请至少勾选一门需要学习的课程,再点击开始。', 'orange'); this.renderChaoxingCourseSelection(); this.updateActionButtons(); return; } const ctx = { ...this.refreshContext(), ...(this.dashboardContext || {}) }; requireParam(ctx.courseId, 'courseId'); requireParam(ctx.clazzId, 'clazzId'); const run = { id: Date.now() + '-' + Math.random().toString(36).slice(2), platform: 'chaoxing', active: true, stopped: false, paused: false, entries: courses.map(course => ({ key: chaoxingCourseKey(course), ctx: course, status: 'queued', worker: null, error: '' })), workGate: Promise.resolve(), current: ctx }; this.state.multiCourse = run; this.state.running = true; this.state.stopped = false; this.state.paused = false; this.setUserStatus({ headline: '准备学习所选课程', currentTask: '已选择 ' + run.entries.length + ' 门', etaText: '请稍候', tip: '只处理已勾选课程,同一时间学习一门。' }); this.updateActionButtons(); try { if (!await this.ensureAnswerToken()) throw new Error('请先绑定有效 Token'); if (this.scanPromise) await this.scanPromise.catch(() => {}); if (run.stopped) return; this.log('本轮已选择 ' + run.entries.length + ' 门课程,将依次检查进度并跳过已完成课程。', 'green'); this.renderChaoxingCourseProgress(); let cursor = 0; const consume = async () => { while (!run.stopped) { await this.waitIfPaused(); if (run.stopped || cursor >= run.entries.length) return; if (scope !== this.chaoxingCourseSelectionScope()) { run.stopped = true; this.state.stopped = true; this.log('页面或账号已变化,已停止启动后续课程;请重新读取并选择。', 'orange'); return; } const entry = run.entries[cursor++]; entry.status = 'running'; try { entry.worker = this.createChaoxingCourseWorker(entry, run); this.renderChaoxingCourseProgress(); await entry.worker.startChaoxingSingleCourse(); const stats = entry.worker.state.taskStats || {}; entry.status = run.stopped || entry.worker.state.stopped ? 'stopped' : (stats.scanned && Number(stats.pending || 0) === 0 && Number(stats.completed || 0) >= Number(stats.total || 0) && !entry.worker.lockedChapterCount() ? 'completed' : 'blocked'); } catch (error) { entry.error = (entry.worker?.state.failureStage||'准备课程')+'失败:'+this.publicErrorText(error); entry.status = run.stopped ? 'stopped' : 'failed'; if (!run.stopped) this.log('【' + entry.ctx.courseName + '】未完成:' + entry.error, 'orange', {publicDiagnostic:true}); } finally { if (entry.worker) { entry.worker.state.running = false; resetVideoFrame(entry.worker.videoFrameScope); } this.renderChaoxingCourseProgress(); } } }; await consume(); this.log((run.stopped ? '顺序学习已停止' : '课程队列本轮结束') + ':已完成 ' + run.entries.filter(entry => entry.status === 'completed').length + '/' + run.entries.length + ' 门', run.stopped ? 'orange' : 'green'); } finally { for (const entry of run.entries) if (entry.status === 'queued') entry.status = 'stopped'; run.active = false; this.state.running = false; this.state.paused = false; this.scanPromise = null; this.state.taskStats = { ...this.state.taskStats, scanned: false }; this.renderChaoxingCourseProgress(); this.updateActionButtons(); } }, renderChaoxingCourseProgress() { const run = this.state.multiCourse; if (!run || !Array.isArray(run.entries)) return; const complete = run.entries.filter(entry => entry.status === 'completed').length; const running = run.entries.filter(entry => entry.status === 'running').length; const waiting = run.entries.filter(entry => entry.status === 'queued').length; const totals = run.entries.reduce((sum, entry) => { const stats = entry.worker && entry.worker.state.taskStats; if (stats && stats.scanned) { sum.done += Number(stats.completed || 0); sum.total += Math.max(Number(stats.total || 0), Number(stats.completed || 0) + Number(stats.pending || 0)); } else sum.unknown++; return sum; }, { done: 0, total: 0, unknown: 0 }); if (['zhihuishu','qingshu'].includes(run.platform)) this.state.taskStats = { platform: run.platform, total: totals.total, completed: totals.done, pending: Math.max(0, totals.total - totals.done), runnablePending: Math.max(0, totals.total - totals.done), scanned: run.entries.length > 0 && totals.unknown === 0 }; const ready = run.phase === 'ready'; const scanned = run.entries.filter(entry => entry.status === 'scanned').length; const title = (ready ? '账号课程(' : run.scanOnly ? '任务扫描(' : run.videoOnly ? '视频顺序学习(' : '顺序学习(') + run.entries.length + ' 门)'; if (this.els.courseTitle) this.els.courseTitle.textContent = title; if (this.els.courseName) this.els.courseName.textContent = title; if (this.els.chaptersTotal) this.els.chaptersTotal.textContent = String(totals.done); if (this.els.tasksTotal) this.els.tasksTotal.textContent = String(totals.total) + (totals.unknown ? '+' : ''); if (this.els.tasks) this.els.tasks.textContent = String(Math.max(0, totals.total - totals.done)) + (totals.unknown ? '+' : ''); if (this.els.progressBar) this.els.progressBar.style.width = (run.entries.length ? Math.round(complete * 100 / run.entries.length) : 0) + '%'; if (this.els.progressText) this.els.progressText.textContent = ready ? '已读取 ' + run.entries.length + ' 门课程,点击开始自动学习' : run.scanOnly ? '已扫描 ' + scanned + '/' + run.entries.length + ' 门 · 未提交学习记录' : (run.videoOnly ? '视频已完成 ' : '已完成 ') + complete + '/' + run.entries.length + ' 门 · 正在学习 ' + running + ' 门 · 排队 ' + waiting + ' 门'; this.state.uiStatus = { headline: ready ? '账号课程已自动读取,等待开始' : run.error ? run.error : run.attentionRequired ? '课程队列需要处理验证或登录状态' : run.scanOnly ? (run.active ? '正在扫描课程任务' : '课程任务扫描结束') : run.active ? (run.stopped ? '正在停止课程队列' : run.paused ? '课程队列已暂停' : '正在按顺序学习课程') : (run.stopped ? '已停止顺序学习' : (run.videoOnly ? '本轮视频已完成 ' : '本轮已完成 ') + complete + '/' + run.entries.length + ' 门'), currentChapter: '按课程列表顺序学习', currentTask: '当前学习 ' + running + ' 门,后续 ' + waiting + ' 门', etaText: run.active ? '当前课程结束后自动切换' : '-', tip: run.videoOnly ? '本轮只播放智慧树视频;作业考试未处理,进度由原生网页上报。' : ready ? '当前课程优先;无需填写课程编号,同一时间只学习一门课。' : '课程内仍按顺序处理;未解锁、未完成的课程不会记为完成。' }; this.renderUserStatus(); const container = this.els.courseRows; if (container) { const doc = container.ownerDocument; container.textContent = ''; const labels = { scanned: '已读取任务', queued: '排队中', running: '学习中', completed: run.videoOnly ? '视频已完成' : '已完成', blocked: '仍有待处理任务', failed: '未完成', stopped: '已停止' }; for (const entry of run.entries) { const row = doc.createElement('div'); row.className = 'cxr-course-row'; const name = doc.createElement('strong'); name.textContent = entry.ctx.courseName; const status = doc.createElement('span'); status.textContent = entry.status === 'running' && run.stopped ? '停止中' : entry.status === 'running' && run.paused ? '已暂停' : labels[entry.status]; const detail = doc.createElement('small'); const worker = entry.worker; detail.textContent = entry.error || (worker && worker.state.uiStatus?.currentTask) || (ready ? '点击开始后自动按顺序处理' : '等待上一门课程处理结束'); row.append(name, status, detail); container.appendChild(row); } } }, clearCourseQueueResult() { if (this.state.running || this.state.multiCourse?.active) return; if (this.state.multiCourse) this.state.multiCourse = null; this.state.taskStats = { ...this.state.taskStats, scanned: false, total: 0, completed: 0, pending: 0, runnablePending: 0 }; if (this.els.courseRows) this.els.courseRows.textContent = ''; for (const key of ['chaptersTotal', 'tasksTotal']) if (this.els[key]) this.els[key].textContent = '0'; if (this.els.progressBar) this.els.progressBar.style.width = '0%'; this.renderContext?.(this.dashboardContext || this.state.current || {}); }, renderChaoxingCourseSelection() { const root = this.els.chaoxingCoursePicker; if (!root) return; const qingshu = root.dataset.platform === 'qingshu'; const key = qingshu ? 'qingshuAutoCourseQueue' : 'autoCourseQueue'; const selection = qingshu ? this.state.qingshuCourseSelection : this.state.chaoxingCourseSelection; const loading = () => this.chaoxingCoursesLoading || this.qingshuQueuePrepare || this.qingshuModeLoading || this.qingshuDiscussionRefresh; const scope = () => qingshu ? this.qingshuCourseSelectionScope() : this.chaoxingCourseSelectionScope(); const prepare = () => qingshu ? this.prepareQingshuCourses(true) : this.prepareChaoxingCourses(true); root.hidden = this.config[key] === false; if (this.els.qingshuCourseModeHint) this.els.qingshuCourseModeHint.hidden = !qingshu || this.config[key] !== false; const entries = selection?.entries || [], doc = root.ownerDocument; root.textContent = ''; const toolbar = doc.createElement('div'); toolbar.className = 'cxr-course-tools'; const status = doc.createElement('div'); status.dataset.k = 'selectionCount'; status.setAttribute('role', 'status'); const count = entries.filter(e => e.selected && !e.unavailable).length; const stale = !!selection?.ready && selection.scope !== scope(); status.textContent = selection?.loading ? '正在读取全部课程,请稍候…' : selection?.error ? '读取失败:' + selection.error : stale ? '页面或课程信息已变化,请重新读取课程' : selection?.ready ? '已读取 ' + entries.length + ' 门,已勾选 ' + count + ' 门' : '请先读取课程列表'; root.append(status, toolbar); if (!this.state.multiCourse?.active && this.config[key] !== false) { if (this.els.progressText) this.els.progressText.textContent = status.textContent; if (this.els.tasks && !this.state.multiCourse) this.els.tasks.textContent = count ? '待检查' : '待选择'; } const editable = () => !this.state.running && !loading() && selection?.ready && selection.scope === scope(); for (const [action, title] of [['refresh', '重新读取课程'], ['all', '全选可用课程'], ['clear', '清空选择']]) { const button = doc.createElement('button'); button.type = 'button'; button.dataset.courseSelectAction = action; button.textContent = title; if (action === 'refresh') button.title = '重新读取会清空当前勾选'; button.addEventListener('click', () => { if (this.state.running || loading()) return; if (action === 'refresh') { prepare().catch(e => this.logErr(e)); return; } if (!editable()) { this.updateActionButtons(); return; } this.clearCourseQueueResult(); entries.forEach(entry => { entry.selected = action === 'all' && !entry.unavailable; }); const scroll = root.querySelector('.cxr-course-selection-list')?.scrollTop || 0; this.renderChaoxingCourseSelection(); this.updateActionButtons(); const nextList = root.querySelector('.cxr-course-selection-list'); if (nextList) nextList.scrollTop = scroll; root.querySelector('[data-course-select-action="' + action + '"]')?.focus({ preventScroll: true }); }); toolbar.append(button); } const list = doc.createElement('div'); list.className = 'cxr-course-selection-list'; root.append(list); for (const entry of entries) { const label = doc.createElement('label'); label.className = 'cxr-course-choice'; const input = doc.createElement('input'); input.type = 'checkbox'; input.dataset.courseKey = entry.key; input.dataset.unavailable = String(entry.unavailable); input.checked = entry.selected; const text = doc.createElement('span'); text.textContent = entry.ctx.courseName + (entry.unavailable ? '(' + (entry.error || '未开放或已结束,不可选择') + ')' : ''); input.addEventListener('change', () => { if (!editable() || entry.unavailable) { input.checked = entry.selected; this.updateActionButtons(); return; } this.clearCourseQueueResult(); entry.selected = input.checked; const selectedCount = entries.filter(e => e.selected && !e.unavailable).length; status.textContent = '已读取 ' + entries.length + ' 门,已勾选 ' + selectedCount + ' 门'; if (this.els.progressText) this.els.progressText.textContent = status.textContent; if (this.els.tasks && !this.state.multiCourse) this.els.tasks.textContent = selectedCount ? '待检查' : '待选择'; this.updateActionButtons(); }); label.append(input, text); list.append(label); } }, mountChaoxingCourseControls(doc) { const ctx = this.state.current || this.dashboardContext || {}; if (ctx.platform && !['chaoxing','qingshu'].includes(ctx.platform)) return; if (ctx.platform!=='qingshu'&&(!ctx.courseId||!ctx.clazzId)||doc.querySelector('[data-k="multiCourseControls"]'))return; const key=ctx.platform==='qingshu'?'qingshuAutoCourseQueue':'autoCourseQueue'; const anchor = this.els.startAction && this.els.startAction.parentElement; if (!anchor) return; const box = doc.createElement('section'); box.className = 'cxr-multicourse'; box.dataset.k = 'multiCourseControls'; const qingshu = ctx.platform === 'qingshu'; box.classList.add('cxr-course-picker'); box.innerHTML = '
先读取全部课程,再勾选需要学习的课程;仅处理所选课程,同一时间只学习一门。取消此选项则仅处理当前课程。
'; if(ctx.platform==='qingshu'){const badge=doc.createElement('div');badge.dataset.qingshuBuild='QS-1012-1';badge.textContent='青书课程版 QS-1012-1';box.prepend(badge);} const style = doc.createElement('style'); style.textContent = '.cxr-multicourse{margin:12px 0;padding:12px;border:1px solid #cbd5e1;border-radius:10px;color:#334155}.cxr-multicourse label{display:inline-flex;gap:6px;align-items:center;margin:0 12px 8px 0}.cxr-course-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 12px;padding:8px 0;border-top:1px solid #e2e8f0}.cxr-course-row small{grid-column:1/-1;overflow-wrap:anywhere}.cxr-multicourse [data-k=courseRows]{max-height:300px;overflow:auto}'; style.textContent += '.cxr-course-tools{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}.cxr-course-tools button{padding:5px 9px;cursor:pointer}.cxr-course-selection-list{max-height:260px;overflow:auto}.cxr-multicourse .cxr-course-choice{display:flex;margin:0;padding:8px 0;border-top:1px solid #e2e8f0}.cxr-course-choice span{overflow-wrap:anywhere}'; style.textContent += '.cxr-course-picker{min-width:0}.cxr-course-picker input[type=checkbox]{width:16px;min-width:16px;max-width:16px;height:16px;flex:0 0 16px;margin:0;padding:0;accent-color:#2563eb}.cxr-course-picker label{justify-content:flex-start;max-width:100%;text-align:left}.cxr-course-choice span{min-width:0}.cxr-course-picker .cxr-course-tools button{padding:6px 10px!important}'; style.textContent += '@media(max-width:860px){.dashboard-layout.cxr-select-layout{grid-template-columns:minmax(0,1fr)!important}.cxr-select-layout .wrap{min-width:0}.cxr-select-layout .token-actions{display:flex;flex-wrap:wrap;gap:8px;white-space:normal}}'; box.prepend(style); const footer = anchor.closest('.control-footer'); if (footer && footer.parentElement) footer.parentElement.insertBefore(box, footer); else anchor.parentElement.insertBefore(box, anchor); box.closest('.dashboard-layout')?.classList.add('cxr-select-layout'); this.els.autoCourseQueue = box.querySelector('[data-k="autoCourseQueue"]'); this.els.courseRows = box.querySelector('[data-k="courseRows"]'); this.els.chaoxingCoursePicker = box.querySelector('[data-k="chaoxingCoursePicker"]'); this.els.chaoxingCoursePicker.dataset.platform = qingshu ? 'qingshu' : 'chaoxing'; if (qingshu) { const hint = doc.createElement('div'); hint.dataset.k = 'qingshuCourseModeHint'; hint.append(doc.createTextNode('当前为“仅当前课程”模式。')); const choose = doc.createElement('button'); choose.type = 'button'; choose.dataset.k = 'qingshuSelectCourses'; choose.textContent = '选择其他课程'; choose.addEventListener('click', () => { if (this.state.running || this.qingshuDiscussionRefresh || this.qingshuModeLoading || this.qingshuQueuePrepare || this.chaoxingCoursesLoading) return; if (this.config.qingshuAutoCourseQueue === false && !this.els.autoCourseQueue.disabled) this.els.autoCourseQueue.click(); }); hint.append(choose); box.insertBefore(hint, this.els.chaoxingCoursePicker); this.els.qingshuCourseModeHint = hint; this.els.qingshuSelectCoursesButton = choose; } this.els.autoCourseQueue.checked = this.config[key] !== false; this.els.courseRows.hidden = !this.els.autoCourseQueue.checked; const change = () => { if (this.state.running||this.qingshuDiscussionRefresh||this.qingshuModeLoading||this.chaoxingCoursesLoading||this.qingshuQueuePrepare) return; this.config[key] = this.els.autoCourseQueue.checked; this.els.courseRows.hidden = !this.config[key]; saveConfig(this.config); this.renderChaoxingCourseSelection(); this.updateActionButtons(); if (!qingshu && this.config[key]) this.prepareChaoxingCourses(true).catch(e => this.logErr(e)); if(ctx.platform==='qingshu'){this.qingshuModeLoading=true;this.updateActionButtons();(this.config[key]?this.prepareQingshuCourses(true):this.prepareQingshuCourse(true)).catch(e=>this.logErr(e)).finally(()=>{this.qingshuModeLoading=false;this.updateActionButtons();});} }; this.els.autoCourseQueue.addEventListener('change', change); this.renderChaoxingCourseSelection(); this.updateActionButtons(); if (this.config[key] !== false) (qingshu ? this.prepareQingshuCourses() : this.prepareChaoxingCourses()).catch(e => this.logErr(e)); }, updateQingshuStats() { const q = this.state.qingshu || {}; const assignments = Array.isArray(q.assignments) ? q.assignments : []; const passed = assignments.filter(item => qingshuApplyPassPolicy(item).passed).length; const score = qingshuStudyScore(q); const target = Math.max(0, Number(q.scoreDetail?.targetScore ?? 80)); const videoDone = score >= target ? 1 : 0; const discussion=q.scoreDetail?.discussion; const total = q.coursePassed ? 1 : assignments.length + 1 + (discussion?.required?1:0); const completed = q.coursePassed ? 1 : passed + videoDone + (discussion?.required&&discussion.complete?1:0); this.state.taskStats = { total, completed, pending: Math.max(0, total - completed), runnablePending: Math.max(0, total - completed), scanned: true, doneKeys: {}, lockedChapters: 0, lockedLabels: [] }; this.state.jobTotal = total; this.state.jobDone = completed; this.renderQingshuDiscussion?.(); this.updateProgress(); }, async prepareQingshuCourse(force = false) { if (!force && this.state.qingshu && this.state.qingshu.ready) return this.state.qingshu; if (this.qingshuPreparePromise) return this.qingshuPreparePromise; this.qingshuPreparePromise = (async () => { this.setUserStatus({ headline: '正在读取青书课程', currentChapter: '青书学堂', currentTask: '准备中', etaText: '请稍候', tip: '' }); let ctx = await qingshuResolveCourseContext({ ...(this.dashboardContext || {}), ...(this.state.current || {}) }); this.dashboardContext = { ...(this.dashboardContext || {}), ...ctx, platform: 'qingshu' }; this.state.current = { ...(this.state.current || {}), ...ctx, platform: 'qingshu' }; if (qingshuCourseIsPassed(ctx.qingshuCourse)) { const q = this.state.qingshu = { ready: true, ctx, coursePassed: true, assignments: [], progress: {}, scoreDetail: {} }; this.renderContext(this.state.current); this.updateQingshuStats(); this.setUserStatus({ headline: '青书课程已通过', currentTask: '无需重复学习', etaText: '已完成', tip: '' }); return q; } const [assignments, progress, scoreDetail] = await Promise.all([ qingshuLoadAssignments(ctx), qingshuLoadStudyProgress(ctx), qingshuLoadScoreDetail(ctx) ]); this.state.qingshu = { ready: true, ctx, coursePassed: progress.coursePassed === true, assignments, progress, scoreDetail }; this.state.current.courseName = ctx.courseName; this.renderContext(this.state.current); this.updateQingshuStats(); this.setUserStatus({ headline: `青书课程:${this.shortText(ctx.courseName, 42)}`, currentChapter: `课件学习 ${qingshuStudyScore(this.state.qingshu)}/${scoreDetail.targetScore ?? 80} 分`, currentTask: '准备就绪', etaText: this.state.taskStats.pending ? '等待处理' : '已完成', tip: '准备完成,点击开始。' }); this.log(`青书课程已读取:${ctx.courseName}`, 'green'); return this.state.qingshu; })(); try { return await this.qingshuPreparePromise; } finally { this.qingshuPreparePromise = null; } }, async startQingshuCourse() { if(this.qingshuDiscussionRefresh||this.qingshuModeLoading)return; if (this.state.running) { this.log('任务已在运行', 'orange'); return; } this.state.running = true; this.state.paused = false; this.state.stopped = false; this.updateActionButtons(); try { const q = await this.prepareQingshuCourse(true); q.ctx = {...q.ctx, beforeRequest: async () => { await this.waitIfPaused(); if(this.state.stopped) { const e=new Error('青书请求已取消');e.name='AbortError';throw e; } }}; await this.waitIfPaused();if(this.state.stopped)return; if (q.coursePassed) { this.updateQingshuStats(); this.log('青书课程已通过,已跳过重复学习。', 'green'); return; } if (this.config.doVideo !== false && qingshuStudyScore(q) < Number(q.scoreDetail.targetScore ?? 80)) { await this.runQingshuStudy(q); } else { this.log(`青书课件学习已达标:${qingshuStudyScore(q)}/${q.scoreDetail.targetScore ?? 80} 分`, 'green'); } if (!this.state.stopped && !q.coursePassed && this.config.doWork !== false) { await this.waitIfPaused();if(this.state.stopped)return; await this.runQingshuAssignments(q); } if (!this.state.stopped && !q.coursePassed && this.config.qingshuAutoComment !== false) await this.runQingshuDiscussion?.(q); if (!this.state.stopped) await this.prepareQingshuCourse(true); const remaining = Number(this.state.taskStats && this.state.taskStats.pending || 0); this.setUserStatus({ headline: this.state.stopped ? '已停止青书任务' : (remaining ? '青书本轮处理结束,仍有未完成任务' : '青书课程处理完成'), currentTask: this.state.stopped ? '已停止' : `已重新读取课程状态,剩余 ${remaining} 项`, etaText: this.state.stopped ? '-' : (remaining ? '未全部完成' : '已完成'), tip: this.state.stopped ? '重新点击开始可继续。' : '可以回到青书课程页面检查分数和作业状态。' }); } finally { this.state.running = false; this.state.paused = false; this.updateActionButtons(); } }, async runQingshuStudy(q) { const ctx = q.ctx; let score = qingshuStudyScore(q); let target = Math.max(0, Number(q.scoreDetail.targetScore ?? 80)); if (q.coursePassed || score >= target) return; const records = Array.isArray(q.progress.records) ? q.progress.records : []; const recent = records[0] || {}; const contentId = String(recent.contentId || recent.nodeId || ctx.nodeId || 'kcjs_1_1'); this.setUserStatus({ headline: '正在进行青书课件学习', currentChapter: `${score}/${target} 分`, currentTask: '创建学习记录', etaText: '请稍候', tip: '每 2 分钟使用青书原生接口上报一次学习进度,每 10 分钟重新读取分数。' }); await this.waitIfPaused();if(this.state.stopped)return; const recordId = await qingshuBeginStudy(ctx, contentId); const intervalSeconds = 120; let position = 0; let uploads = 0; this.log(`青书课件学习开始:当前 ${score}/${target} 分,每 2 分钟上报一次`, 'green'); try { while (!this.state.stopped && !q.coursePassed && score < target) { await this.sleepWithPause(intervalSeconds * 1000); if (this.state.stopped) break; await this.waitIfPaused(); if (this.state.stopped) break; position += intervalSeconds; let uploaded = false; for (let attempt = 1; attempt <= 3 && !uploaded && !this.state.stopped; attempt++) { try { await qingshuContinueStudy(ctx, recordId, position, false); uploaded = true; } catch (e) { this.log(`青书学习进度上报失败(第 ${attempt}/3 次,位置 ${position} 秒):${qingshuUserErrorText(e?.message || e, this.config.answerApiToken)}${attempt < 3 ? ';稍后重试' : ';不再重试'}`, 'orange', { publicDiagnostic: true }); if (attempt >= 3) throw e; await this.sleepWithPause(10000 * attempt); } } if (!uploaded) break; uploads++; this.log(`青书学习进度:已上报 ${uploads} 次,位置 ${formatClockSeconds(position)}`, 'blue'); this.setUserStatus({ headline: '正在进行青书课件学习', currentChapter: `${score}/${target} 分`, currentTask: `已上报 ${uploads} 次,位置 ${formatClockSeconds(position)}`, etaText: '分数每 10 分钟刷新', tip: '青书按真实等待时间累计课件学习分数。' }); if (uploads % 5 === 0) { const [progress, scoreDetail] = await Promise.all([qingshuLoadStudyProgress(ctx), qingshuLoadScoreDetail(ctx)]); q.progress = progress; q.scoreDetail = scoreDetail; q.coursePassed = progress.coursePassed === true; score = qingshuStudyScore(q); target = Math.max(0, Number(scoreDetail.targetScore)); this.state.qingshu = q; this.updateQingshuStats(); this.log(`青书课件分数:${score}/${target} 分${progress.studyTime ? `,学习时长 ${progress.studyTime}` : ''}`, score >= target ? 'green' : 'blue'); } } } finally { try { await qingshuContinueStudy(ctx, recordId, position, true); } catch (e) { this.state.stopped=true;this.log('青书学习记录关闭未确认,停止课程队列:'+qingshuUserErrorText(e?.message||e,this.config.answerApiToken),'orange',{publicDiagnostic:true});throw e; } } if (score >= target) this.log(`青书课件学习完成:${score}/${target} 分`, 'green'); }, async runQingshuAssignments(q) { const pending = (q.assignments || []).filter(item => !qingshuApplyPassPolicy(item).passed); if (!pending.length) { this.log(q.assignments?.length?'青书作业已全部达到 60 分':'青书当前没有作业', 'green'); return; } let tokenChecked = false; let consecutiveQueryFailures = 0; this.log(`开始处理青书作业:${pending.length} 个待完成`, 'green'); for (let ai = 0; ai < pending.length; ai++) { if (this.state.stopped) break; await this.waitIfPaused(); if (this.state.stopped) break; const assignment = pending[ai]; const startedAt = Date.now(); let phase = '读取作业题目'; this.log(`青书作业队列 ${ai + 1}/${pending.length}:${assignment.title}`, 'blue'); try { this.setUserStatus({ headline: `正在完成青书作业:${this.shortText(assignment.title, 42)}`, currentChapter: `作业 ${ai + 1}/${pending.length}`, currentTask: '读取作业题目', etaText: '请稍候', tip: '每题通过青书原生保存接口写入;全部保存成功后才会交卷。' }); let detail = await qingshuLoadAssignmentDetail(q.ctx, assignment.quizId); if (this.state.stopped) break; let policy = qingshuMergeAssignmentState(assignment, detail); Object.assign(assignment, policy); this.updateQingshuStats(); if (policy.passed || policy.awaitingGrade) { this.log(`青书作业跳过:${assignment.title}(${qingshuAssignmentStatusText(assignment)})`, policy.passed ? 'green' : 'orange'); continue; } let requeryAnswers = policy.needsRedo || policy.requiresRestart; if (policy.requiresRestart) { try { detail = await qingshuReopenAssignment(q.ctx, assignment, detail); if (this.state.stopped) break; Object.assign(assignment, qingshuApplyPassPolicy({ ...detail, questions: undefined, raw: undefined })); } catch (e) { if (this.state.stopped || qingshuIsFatalTaskError(e)) throw e; this.log(`青书低分作业等待重做:${assignment.title},${e.message || e}`, 'orange'); continue; } } if (!detail.userQuizId) { phase = '初始化作答记录'; this.log(`青书作业尚无作答记录,正在进入原生作业页:${assignment.title}`, 'blue'); detail = await qingshuStartAssignment(q.ctx, assignment, detail, () => this.state.stopped); if (this.state.stopped) break; policy = qingshuMergeAssignmentState(assignment, detail); Object.assign(assignment, policy); this.updateQingshuStats(); if (policy.passed || policy.awaitingGrade || policy.requiresRestart) { this.log(`青书作业初始化后暂不处理:${assignment.title}(${qingshuAssignmentStatusText(assignment)})`, 'orange'); continue; } requeryAnswers = requeryAnswers || policy.needsRedo; } phase = '答题与保存'; if (requeryAnswers) this.log(`青书作业未达 60 分,重新作答:${assignment.title}`, 'orange'); if (!detail.userQuizId) throw new Error(`青书作业缺少 userQuizId:${assignment.title}`); if (!detail.questions.length) { this.log(`青书作业无题目,跳过:${assignment.title}`, 'orange'); continue; } const answeredQuestions = []; const failed = []; for (let qi = 0; qi < detail.questions.length; qi++) { if (this.state.stopped) break; await this.waitIfPaused(); if (this.state.stopped) break; const question = detail.questions[qi]; const label = `青书作业 ${ai + 1}/${pending.length},第 ${qi + 1}/${detail.questions.length} 题`; this.setUserStatus({ headline: `正在完成青书作业:${this.shortText(assignment.title, 42)}`, currentChapter: `作业 ${ai + 1}/${pending.length}`, currentTask: `第 ${qi + 1}/${detail.questions.length} 题(${question.typeName})`, etaSeconds: Math.max(5, (detail.questions.length - qi) * 6), tip: question.question ? `题干:${this.shortText(question.question, 70)}` : '图片题:正在识别题干并查询答案。' }); let nativeAnswer = requeryAnswers ? '' : qingshuNativeAnswer(question, question.existingAnswer); if (!nativeAnswer) { if (!tokenChecked) { if (!await this.ensureAnswerToken()) { this.state.stopped = true; throw new Error('未绑定有效答题 Token,无法处理青书作业'); } tokenChecked = true; } if (this.state.stopped) break; const options = qingshuAnswerApiOptions(question.options); const ansRes = await queryQingshuVisionApi(question.images, question.stem, question.typeName, options, this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl, this.config.answerApiToken, question); if (ansRes.insufficientQuota) { this.state.stopped = true; this.setTokenStatus('Token 剩余答题次数不足,已停止;请充值后继续。', 'orange'); throw new Error('Token 剩余答题次数不足,已停止'); } if (!ansRes.success || !ansRes.answer) { const reason = qingshuDescribeAnswerFailure(ansRes, question, this.config.answerApiToken); failed.push(`第 ${qi + 1} 题查询失败:${reason}`); this.log(`${label}:答案查询失败:${reason}`, 'orange', { publicDiagnostic: true }); consecutiveQueryFailures = ansRes.serviceFailure ? consecutiveQueryFailures + 1 : 0; if (ansRes.fatal || consecutiveQueryFailures >= 3) { this.state.stopped = true; const message = ansRes.fatal ? '暂时无法答题,已停止本轮任务' : '连续 3 题查询异常,已停止本轮任务'; this.setUserStatus({ headline: message, currentTask: reason, etaText: '等待处理', tip: '请处理提示的问题后重试;本份作业没有交卷。' }); throw new Error(`${message}:${reason}`); } await this.sleepRandom(this.config.answerDelayMinMs || 5000, this.config.answerDelayMaxMs || 5000); continue; } consecutiveQueryFailures = 0; if (ansRes.remainingCount != null) { this.applyAnswerTokenInfo(ansRes); this.setTokenStatus(tokenStatusText(this.state.tokenInfo), this.state.tokenInfo.remainingCount === 0 ? 'orange' : 'green'); } nativeAnswer = qingshuNativeAnswer(question, ansRes.answer); } if (!nativeAnswer) { failed.push(`第 ${qi + 1} 题答案无法映射`); this.log(`${label}:答案无法映射到青书选项`, 'orange'); continue; } await this.waitIfPaused(); if (this.state.stopped) break; try { await qingshuSaveQuestionAnswer(q.ctx, detail.quizId, question.questionId, nativeAnswer); answeredQuestions.push({ questionId: question.questionId, userAnswer: nativeAnswer, sequence: question.sequence || qi + 1 }); this.log(`${label}:已保存答案 ${nativeAnswer}`, 'green'); } catch (e) { if (this.state.stopped || qingshuIsFatalTaskError(e)) throw e; const reason = qingshuUserErrorText(e && e.message || e, this.config.answerApiToken); failed.push(`第 ${qi + 1} 题保存失败:${reason}`); this.log(`${label}:保存失败 ${reason}`, 'orange', { publicDiagnostic: true }); } await this.sleepRandom(this.config.answerDelayMinMs || 5000, this.config.answerDelayMaxMs || 5000); } if (this.state.stopped) break; if (failed.length || answeredQuestions.length !== detail.questions.length) { const title = qingshuUserErrorText(assignment.title, this.config.answerApiToken); const reasons = failed.slice(0, 4).join(';'); const more = failed.length > 4 ? `;另有 ${failed.length - 4} 题未完成,详见单题日志` : ''; this.log(`青书作业未提交:${title},已保存 ${answeredQuestions.length}/${detail.questions.length} 题${reasons ? `;${reasons}${more}` : ''}`, 'orange', { publicDiagnostic: true }); continue; } await this.waitIfPaused(); if (this.state.stopped) break; phase = '交卷'; const timeSpend = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); await qingshuSubmitAssignment(q.ctx, detail, answeredQuestions, timeSpend); Object.assign(assignment, qingshuApplyPassPolicy({ submitted: true, status: '已提交', statusLabel: '待批改', score: null })); this.updateQingshuStats(); this.log(`青书作业已提交:${assignment.title}(${answeredQuestions.length} 题),正在核对是否达到 60 分`, 'blue'); if (this.state.stopped) break; phase = '核对成绩'; try { const result = await qingshuLoadAssignmentDetail(q.ctx, assignment.quizId); Object.assign(assignment, qingshuMergeAssignmentState(assignment, result)); this.updateQingshuStats(); this.log(`青书作业提交后:${assignment.title}(${qingshuAssignmentStatusText(assignment)})`, assignment.passed ? 'green' : 'orange'); } catch (e) { if (this.state.stopped || qingshuIsFatalTaskError(e)) throw e; this.log(`青书交卷成功,但成绩尚未确认,不计入已完成:${e.message || e}`, 'orange'); } } catch (e) { const reason = qingshuUserErrorText(e && e.message || e, this.config.answerApiToken); if (this.state.stopped || qingshuIsFatalTaskError(e)) { this.state.stopped = true; this.setUserStatus({ headline: '青书作业队列已停止', currentTask: reason, etaText: '等待处理', tip: '请处理登录、Token 或接口异常后,再点击开始继续。' }); throw e; } assignment.lastError = reason; this.log(`青书作业本轮未完成:${assignment.title}(${phase}:${reason}),保留待处理${ai + 1 < pending.length ? ',继续下一份' : ''}`, 'orange'); } finally { this.updateQingshuStats(); if (!this.state.stopped && ai + 1 < pending.length) { this.log(`青书作业队列:正在进入下一份(${ai + 2}/${pending.length})`, 'blue'); } } } if (!this.state.stopped) { const remaining = (q.assignments || []).filter(item => !qingshuApplyPassPolicy(item).passed).length; this.log(`青书作业队列本轮处理结束:已检查 ${pending.length} 份,仍有 ${remaining} 份未达 60 分或待出分`, remaining ? 'orange' : 'green'); } }, async startYuketangCourse() { if (this.state.running) { this.log('任务已在运行', 'orange'); return; } this.state.running = true; this.state.paused = false; this.state.stopped = false; try { ensureAutoAnswerLogPanel(); appendAutoAnswerLog('雨课堂:开始读取视频任务', 'info'); this.setUserStatus({ headline: '正在准备雨课堂任务', currentTask: '读取课程目录和任务进度', etaText: '请稍候', tip: '独立页面处理:视频、讨论、文档/图文、PPT/cards;不处理作业/考试。' }); const ctx = await loadYuketangCourseContext({ ...(this.dashboardContext || {}), ...(this.state.current || {}) }); this.dashboardContext = { ...(this.dashboardContext || {}), ...ctx, platform: 'yuketang' }; this.state.current = { ...(this.state.current || {}), ...ctx, platform: 'yuketang' }; this.renderContext(this.state.current); this.log(`雨课堂上下文:classroom=${ctx.classroomId || '-'} university=${ctx.universityId || '-'} course=${ctx.courseId || '-'} sku=${ctx.skuId || '-'} courseware=${ctx.coursewareId || '-'}`, 'blue'); appendAutoAnswerLog(`雨课堂上下文:classroom=${ctx.classroomId || '-'} sku=${ctx.skuId || '-'} courseware=${ctx.coursewareId || '-'}`, 'info'); const catalog = await loadYuketangCatalog(ctx); const schedule = await loadYuketangSchedule(ctx).catch(() => ({})); let allLeaves = flattenYuketangLeaves(catalog); if (!allLeaves.length) { allLeaves = yuketangLeavesFromSchedule(schedule); this.log(`雨课堂目录为空,已使用进度接口 leaf_level_infos 兜底:${allLeaves.length} 个`, allLeaves.length ? 'blue' : 'orange'); } const typeCounts = {}; for (const leaf of allLeaves) { const k = String(leaf && leaf.leaf_type != null ? leaf.leaf_type : 'unknown'); typeCounts[k] = (typeCounts[k] || 0) + 1; } const sampleLeaves = allLeaves.slice(0, 8).map(x => `${x.id}:${x.leaf_type}:${cleanText(x.title || x.name || '').slice(0, 18)}`).join(' | '); const chapterCount = Array.isArray(catalog.content_info) ? catalog.content_info.length : (Array.isArray(catalog.course_chapter) ? catalog.course_chapter.length : 0); this.log(`雨课堂目录:来源=${catalog._source || 'unknown'},章节组=${chapterCount},叶子=${allLeaves.length},类型=${JSON.stringify(typeCounts)},样本=${sampleLeaves || '-'}`, allLeaves.length ? 'blue' : 'orange'); appendAutoAnswerLog(`雨课堂目录:叶子=${allLeaves.length},类型=${JSON.stringify(typeCounts)}`, allLeaves.length ? 'info' : 'warn'); const runnableTypes = new Set([0, 3, 4, 7, 10]); const leaves = allLeaves.filter(x => runnableTypes.has(Number(x.leaf_type))); const pending = leaves.filter(x => !isYuketangLeafDone(schedule, x)); this.log(`雨课堂任务:可处理 ${leaves.length},待处理 ${pending.length}(视频/讨论/文档/PPT)`, 'green'); appendAutoAnswerLog(`雨课堂任务:可处理 ${leaves.length},待处理 ${pending.length}`, pending.length ? 'info' : 'ok'); const initialCompleted = Math.max(0, leaves.length - pending.length); this.state.taskStats = { total: leaves.length, completed: initialCompleted, pending: pending.length, runnablePending: pending.length, scanned: true, doneKeys: {}, runnableKeys: {}, byType: typeCounts, platform: 'yuketang' }; this.state.jobTotal = pending.length; this.state.jobDone = 0; let failedCount = 0; this.updateProgress(); for (let i = 0; i < pending.length; i++) { if (this.state.stopped) break; await this.waitIfPaused(); const leaf = pending[i]; const title = cleanText(leaf.title || leaf.name || `leaf-${leaf.id}`); const type = Number(leaf.leaf_type); const kind = yuketangLeafKindName(type); this.setUserStatus({ headline: `雨课堂${kind} ${i + 1}/${pending.length}`, currentTask: title, etaText: '-', tip: '正在处理当前任务。' }); appendAutoAnswerLog(`雨课堂${kind} ${i + 1}/${pending.length}:${title}`, 'info'); let ok = false; if (type === 0) ok = await finishYuketangVideo(ctx, leaf, msg => this.log(msg, 'gray')); else if (type === 3 || type === 10) ok = await finishYuketangArticle(ctx, leaf, msg => this.log(msg, 'gray')); else if (type === 4) ok = await finishYuketangDiscussion(ctx, leaf, msg => this.log(msg, 'gray')); else if (type === 7) ok = await finishYuketangCards(ctx, leaf, schedule, msg => this.log(msg, 'gray')); if (ok) this.state.jobDone++; else failedCount++; const completedNow = Math.min(leaves.length, initialCompleted + this.state.jobDone); const remainNow = Math.max(0, pending.length - this.state.jobDone); this.state.taskStats = { ...(this.state.taskStats || {}), total: leaves.length, completed: completedNow, pending: remainNow, runnablePending: remainNow, scanned: true, platform: 'yuketang' }; this.updateProgress(); this.log(`雨课堂${kind}完成状态:${title} -> ${ok ? '成功' : '失败/跳过'}`, ok ? 'green' : 'orange'); appendAutoAnswerLog(`雨课堂${kind}:${ok ? '完成' : '失败/跳过'} ${title}`, ok ? 'ok' : 'warn'); await this.sleepRandom(800, 1600); } // Completion must come from the native schedule, never loop exhaustion. if (!this.state.stopped) { try { const finalSchedule = await loadYuketangSchedule(ctx); const confirmed = leaves.filter(leaf => isYuketangLeafDone(finalSchedule, leaf)).length; this.state.taskStats = { ...this.state.taskStats, completed: confirmed, pending: leaves.length - confirmed, runnablePending: leaves.length - confirmed }; this.state.jobDone = Math.max(0, confirmed - initialCompleted); } catch (_) { this.log('暂未取得最新进度,保留未完成任务', 'orange'); } this.updateProgress(); } const remaining = this.state.taskStats.pending; this.setUserStatus({ headline: this.state.stopped ? '已停止雨课堂任务' : (remaining ? '本轮结束,仍有未完成任务' : '雨课堂任务处理完成'), currentTask: remaining ? '剩余 ' + remaining + ' 个任务' : '流程结束', etaText: '-', tip: failedCount ? '部分任务未完成,请检查后重试。' : '以平台记录为准。' }); this.log(this.state.stopped ? '雨课堂已停止' : (remaining ? '雨课堂本轮结束(剩余 ' + remaining + ' 个任务)' : '雨课堂执行结束'), this.state.stopped || remaining ? 'orange' : 'green'); } finally { this.state.running = false; this.state.paused = false; this.updateActionButtons(); } }, async startZhihuishuCourse(){return this.runZhihuishuNativeCourse();}, async finishYuketangExerciseLeaf(ctx, leaf) { const parsed = await loadYuketangExerciseQuestions({ ...ctx, leafId: leaf.id }); if (!parsed.questions.length) return false; let okCount = 0; for (let i = 0; i < parsed.questions.length; i++) { const q = parsed.questions[i]; if (isYuketangProblemSubmitted(q.raw)) { okCount++; continue; } normalizeQuestionTypeMeta(q); const ansRes = await queryAnswerApi(q.question, q.typeName, q.options || [], this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl, this.config.answerApiToken, q); if (!ansRes.success || !ansRes.answer) { appendAutoAnswerLog(`雨课堂作业 ${i + 1}/${parsed.questions.length}:未找到答案`, 'warn'); continue; } const answer = normalizeYuketangAnswer(ansRes.answer, q); if (!answer.length) { appendAutoAnswerLog(`雨课堂作业 ${i + 1}/${parsed.questions.length}:答案无法映射`, 'warn'); continue; } const submitRes = await submitYuketangProblemAnswer(parsed.context, q, answer); if (submitRes && submitRes.success) okCount++; appendAutoAnswerLog(`雨课堂作业 ${i + 1}/${parsed.questions.length}:${submitRes && submitRes.success ? '提交成功' : '提交失败'}`, submitRes && submitRes.success ? 'ok' : 'warn'); await this.sleepRandom(this.config.answerDelayMinMs || DEFAULT_CONFIG.answerDelayMinMs, this.config.answerDelayMaxMs || DEFAULT_CONFIG.answerDelayMaxMs); } return okCount > 0; }, async runChapter(chapterId) { const catalogEntry = this.chapterCatalogEntry(chapterId); if (catalogEntry && catalogEntry.finished) return; const ctx = this.state.current; this.setUserStatus({ headline: `正在检查任务点:${this.chapterLabel(chapterId)}`, currentChapter: this.chapterLabel(chapterId), currentTask: '正在打开任务点内容', etaText: '请稍候', tip: '如果当前没有待处理任务,会自动进入下一项。' }); let cards = await this.loadCards(chapterId); if (cards.noContent) { const retries = Math.max(1, Math.min(6, Number(this.config.lockedChapterRetries || DEFAULT_CONFIG.lockedChapterRetries || 3))); const waitMs = Math.max(1000, Number(this.config.lockedChapterRetryDelayMs || DEFAULT_CONFIG.lockedChapterRetryDelayMs || 5000)); for (let attempt = 1; attempt <= retries && cards.noContent; attempt++) { this.setUserStatus({ headline: `等待小节解锁:${this.chapterLabel(chapterId)}`, currentChapter: this.chapterLabel(chapterId), currentTask: `第 ${attempt}/${retries} 次重试读取章节内容`, etaText: '请稍候', tip: '闯关课程需要前置任务点完成后才会开放本小节。' }); this.log(`小节内容未开放,${Math.round(waitMs / 1000)} 秒后重试(${attempt}/${retries}):${this.chapterLabel(chapterId)}`, 'orange'); await this.sleep(waitMs); if (this.state.stopped) return; cards = await this.loadCards(chapterId); } if (cards.noContent) { this.markChapterBlocked(chapterId); this.setUserStatus({ headline: `小节尚未解锁:${this.chapterLabel(chapterId)}`, currentChapter: this.chapterLabel(chapterId), currentTask: '章节内容未开放,已跳过', etaText: '继续下一项', tip: '可能需要先提交章节测验,或等教师开放。' }); this.log(`小节仍未开放,跳过:${this.chapterLabel(chapterId)}(请检查上一小节是否已完成、是否有未提交的章节测验)`, 'orange'); return; } this.clearChapterBlocked(chapterId); this.log(`小节已解锁,继续处理:${this.chapterLabel(chapterId)}`, 'green'); } const candidates = uniqueTaskAttachments(cards.attachments || [], chapterId).filter(a => isRunnableTask(a) && !(this.state.taskStats.scanned && this.state.taskStats.doneKeys[taskKey(a, chapterId)])); const selection = resolveChapterPendingTasks(candidates, catalogEntry && catalogEntry.jobCount); const jobs = selection.tasks; if (selection.conflict) { this.log(`小节待办识别与平台不一致,暂不重复执行:${this.chapterLabel(chapterId)}(平台 ${catalogEntry.jobCount} 个,识别 ${candidates.length} 个)`, 'orange'); return; } if (this.state.taskStats.scanned) this.registerLateTasks(chapterId, cards); if (!this.state.taskStats.scanned) this.state.jobTotal += jobs.length; this.updateProgress(); if (!jobs.length) { const cleared = this.markChapterNoRunnableTasks(chapterId, cards); this.setUserStatus({ headline: `${this.chapterLabel(chapterId)} 暂未执行`, currentChapter: this.chapterLabel(chapterId), currentTask: '未找到可确认的未完成任务点', etaText: '即将进入下一项', tip: '系统会自动继续处理后续任务点。' }); if (cleared) this.log(`已核对任务完成状态,跳过:${this.chapterLabel(chapterId)}`, 'green'); else if (catalogEntry && catalogEntry.jobCount > 0) this.log(`未确认可执行的任务点,已保留待处理:${this.chapterLabel(chapterId)}`, 'orange'); return; } await this.waitIfPaused(); if (this.state.stopped) return; await this.touchChapterVisit(chapterId, { quiet: true }); for (const jobData of jobs) { if (this.state.stopped) return; await this.waitIfPaused(); if (this.state.stopped || !isRunnableTask(jobData)) continue; let ok = false; try { const context = { ...ctx, ...(cards.defaults || {}), chapterId, reportUrl: cards.reportUrl, fid: cards.fid || ctx.fid, ktoken: cards.ktoken || ctx.ktoken }; this.log(`正在处理任务点:${this.taskTypeText(jobData)} - ${this.chapterLabel(chapterId)} - ${safeName(jobData)}`, 'blue'); this.setLearningTask(jobData, context, { etaText: '正在计算', tip: `任务类型:${this.taskTypeText(jobData)};任务名称:${this.shortText(safeName(jobData), 48)}` }); if ((jobData.type === 'video' || jobData.type === 'audio') && this.config.doVideo) { ok = await this.runVideo(jobData, context) !== false; } else if (jobData.type === 'document' || jobData.type === 'book') { ok = await this.runDocument(jobData, context) !== false; } else if (jobData.type === 'hyperlink') { ok = await this.runHyperlink(jobData, context) !== false; } else if (jobData.type === 'read') { ok = await this.runRead(jobData, context) !== false; } else if (jobData.type === 'discussion') { ok = await this.runDiscussion(jobData, context) !== false; } else if (jobData.type === 'live' && this.config.doVideo) { ok = await this.runLive(jobData, context) !== false; } else if (jobData.type === 'classtask' && this.config.doVideo) { ok = await this.runClassTask(jobData, context) !== false; } else if (jobData.type === 'workid') { ok = await this.runWork(jobData, context) !== false; } else if (jobData.type === 'assignment' && this.config.doWork) { ok = await this.runAssignment(jobData, context) !== false; } else if (jobData.type === 'exam' && this.config.doWork) { ok = await this.runExam(jobData, context) !== false; } else { this.log(`跳过任务:type=${jobData.type}, name=${safeName(jobData)}`, 'gray'); } } catch (e) { this.setUserStatus({ headline: '当前任务暂未完成,正在尝试继续', currentChapter: this.chapterLabel(chapterId), currentTask: `${this.taskTypeText(jobData)}:${this.shortText(safeName(jobData))}`, etaText: '稍后重试或进入下一项', tip: this.publicErrorText(e) }); this.log(`任务未完成:${safeName(jobData)} → ${this.publicErrorText(e)}`, 'red', { publicDiagnostic: true }); } finally { if (this.state.stopped) ok = false; if (ok) this.markTaskCompleted(jobData, chapterId); if (ok) this.setUserStatus({ headline: jobData.type === 'read' ? '本次阅读已处理' : '当前任务已完成', currentChapter: this.chapterLabel(chapterId), currentTask: `${this.taskTypeText(jobData)}:${this.shortText(safeName(jobData))}`, etaText: '即将进入下一项', tip: '系统会继续处理后续任务。' }); if (ok) this.state.jobDone++; this.updateProgress(); } await this.sleepRandom(); } }, async runCourseWideTasks() { this.state.courseTasks = []; this.log('作业/考试已分离:请进入对应作业或考试页面后自动填答。', 'gray'); // Course-wide work/exams are handled on their own pages, not in this queue. }, markChapterBlocked(chapterId) { const id = String(chapterId || ''); if (!id) return; if (!this.state.blockedChapters) this.state.blockedChapters = {}; if (this.state.blockedChapters[id]) return; this.state.blockedChapters[id] = true; const info = this.state.lockedChapterInfo || { lockedCount: 0, lockedLabels: [], knownJobCount: 0, hasCatalog: false }; const labels = Array.isArray(info.lockedLabels) ? info.lockedLabels.slice() : []; const label = this.chapterLabel(id); if (label && !labels.includes(label)) labels.push(label); this.state.lockedChapterInfo = { ...info, lockedCount: Number(info.lockedCount || 0) + 1, lockedLabels: labels }; const stats = this.state.taskStats; if (stats) { stats.lockedChapters = this.lockedChapterCount(); stats.lockedLabels = labels; } this.updateProgress(); }, clearChapterBlocked(chapterId) { const id = String(chapterId || ''); if (!id || !this.state.blockedChapters || !this.state.blockedChapters[id]) return; delete this.state.blockedChapters[id]; const info = this.state.lockedChapterInfo || {}; const label = this.chapterLabel(id); const labels = (Array.isArray(info.lockedLabels) ? info.lockedLabels : []).filter(x => x !== label); this.state.lockedChapterInfo = { ...info, lockedCount: Math.max(0, Number(info.lockedCount || 0) - 1), lockedLabels: labels }; const stats = this.state.taskStats; if (stats) { stats.lockedChapters = this.lockedChapterCount(); stats.lockedLabels = labels; } this.updateProgress(); }, registerLateTasks(chapterId, cards) { const stats = this.state.taskStats; if (!stats || !stats.scanned) return 0; let added = 0; for (const att of uniqueTaskAttachments(cards.attachments || [], chapterId)) if (mergeTaskPointEvidence(stats, att, chapterId)) added++; if (added) this.updateProgress(); return added; }, markChapterNoRunnableTasks(chapterId, cards) { const stats = this.state.taskStats; if (!stats || !stats.scanned || !stats.runnableKeys) return 0; let cleared = 0; const attachments = Array.isArray(cards && cards.attachments) ? cards.attachments : []; for (const att of attachments) { const key = taskKey(att, chapterId); if (!stats.runnableKeys[key] || !hasTaskPoint(att) || !isTaskPassed(att)) continue; this.markTaskCompleted(att, chapterId); cleared++; } if (cleared) this.updateProgress(); return cleared; }, markTaskCompleted(jobData, chapterId) { if (!hasTaskPoint(jobData) || (!isRunnableTask(jobData) && !isTaskPassed(jobData))) return; const stats = this.state.taskStats; if (!stats || !stats.scanned) return; const key = taskKey(jobData, chapterId); if (stats.doneKeys[key]) return; stats.doneKeys[key] = true; if (stats.unresolvedKeys) delete stats.unresolvedKeys[key]; const nextCompleted = (stats.completed || 0) + 1; const cap = Number(stats.total || 0); stats.completed = cap > 0 && (stats.authoritativeTotal || Number(stats.lockedChapters || 0) === 0) ? Math.min(cap, nextCompleted) : nextCompleted; if (stats.completed > Number(stats.total || 0)) stats.total = stats.completed; let decremented = false; if (stats.runnableKeys && stats.runnableKeys[key]) { delete stats.runnableKeys[key]; stats.runnablePending = Math.max(0, Number(stats.runnablePending || 0) - 1); decremented = true; } if (!decremented && Number(stats.runnablePending || 0) > 0) { stats.runnablePending = Math.max(0, Number(stats.runnablePending || 0) - 1); } stats.pending = stats.authoritativeTotal ? Math.max(0, stats.total - stats.completed) : Math.max(0, stats.total - stats.completed, Number(stats.runnablePending || 0) + Object.keys(stats.unresolvedKeys || {}).length); }, async runVideo(jobData, context) { if (this.state.stopped || !hasTaskPoint(jobData) || !hasExplicitPendingTaskState(jobData)) return false; jobData = normalizeAttachment(jobData) || jobData; if (!isRunnableTask(jobData)) return false; const name = safeName(jobData); const mediaText = mediaTaskText(jobData); const maxAttempts = Math.max(1, Math.min(10, Number(this.config.videoTaskRetryTimes || 3))); const baseDelay = Math.max(1000, Number(this.config.videoTaskRetryDelayMs || 15000)); let lastErr = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { await this.waitIfPaused(); if (this.state.stopped) return; try { if (attempt > 1) { // Re-read the same task on retry so acknowledged progress is not replayed. const currentCards = await this.loadCards(context.chapterId, { quiet: true, noDelay: true, skipRenderedStatus: true }); if (this.state.stopped) return false; const originalKey = taskKey(jobData, context.chapterId); const current = (currentCards.attachments || []).find(att => taskKey(att, context.chapterId) === originalKey); const oldObject = jobData.property?.objectid || jobData.objectid || ''; const newObject = current && (current.property?.objectid || current.objectid || ''); if (!current || (oldObject && newObject && oldObject !== newObject)) return false; jobData = normalizeAttachment(current) || current; if (isTaskPassed(jobData)) return true; if (!isRunnableTask(jobData)) return false; resetVideoFrame(this.videoFrameScope); this.setLearningTask(jobData, context, { headline: `正在重新学习${mediaText}:${this.shortText(name)}`, etaText: `第 ${attempt}/${maxAttempts} 次尝试`, tip: `${mediaText}重试:第 ${attempt}/${maxAttempts} 次;任务:${this.shortText(name, 48)}` }); this.log(`${mediaText}任务自动重试 ${attempt}/${maxAttempts}:${name}`, 'orange'); } if (await this.runVideoOnce(jobData, context, attempt, maxAttempts) === false) return false; return; } catch (e) { lastErr = e; const msg = e && e.message || String(e); if (e && e.retryable === false) { this.log(`${mediaText}任务暂未完成:${name} -> ${msg}`, 'orange'); throw e; } this.log(`${mediaText}任务失败 ${attempt}/${maxAttempts}:${name} -> ${msg}`, attempt >= maxAttempts ? 'red' : 'orange'); if (this.state.stopped) return; if (attempt < maxAttempts) { resetVideoFrame(this.videoFrameScope); const waitMs = Math.round(baseDelay * attempt); this.setLearningTask(jobData, context, { headline: `${mediaText}将自动重试:${this.shortText(name)}`, etaSeconds: Math.ceil(waitMs / 1000), tip: '正在等待短暂恢复后重试。' }); this.log(`${mediaText}任务将在 ${Math.round(waitMs / 1000)} 秒后重试:${name}`, 'gray'); await this.sleepWithPause(waitMs); } } } throw lastErr || new Error(`${mediaText}任务重试失败:${name}`); }, async runVideoOnce(jobData, context, attempt = 1, maxAttempts = 1) { if (this.state.stopped || !hasTaskPoint(jobData) || !hasExplicitPendingTaskState(jobData)) return false; jobData = normalizeAttachment(jobData) || jobData; if (!isRunnableTask(jobData)) return false; const objectid = jobData.property && jobData.property.objectid; requireParam(objectid, 'objectid'); requireParam(jobData.jobid, 'jobid'); const name = safeName(jobData); const mediaText = mediaTaskText(jobData); const fid = context.fid || detectFid() || '0'; const mediaHost = getOldStudyHost(context); const audioTask = isAudioAttachment(jobData); const mediaKind = audioTask ? 'audio' : 'video'; const statusUrl = `${mediaHost}/ananas/status/${enc(objectid)}?k=${enc(fid)}&flag=normal&_dc=${Date.now()}`; this.log(`当前${mediaText}任务:${this.chapterLabel(context.chapterId)} - ${name}`, 'blue'); const videoInfoRes = await videoRequest(statusUrl, mediaHost, { 'Accept': '*/*', 'X-Requested-With': 'XMLHttpRequest' }, { mediaType: mediaKind, frameScope: this.videoFrameScope }); const videoInfo = JSON.parse(videoInfoRes.responseText || '{}'); if (videoInfo.status && videoInfo.status !== 'success') throw new Error(`${mediaText} status=${videoInfo.status}`); const duration = Number(videoInfo.duration || jobData.property.duration || 0); const dtoken = videoInfo.dtoken; const uid = context.uid || detectUid(); const reportUrl = normalizeVideoReportUrl(context.reportUrl, mediaHost, context.cpi || uid); requireParam(duration, 'duration'); requireParam(dtoken, 'dtoken'); requireParam(reportUrl, 'reportUrl'); requireParam(uid, 'uid'); const dtype = audioTask ? 'Audio' : 'Video'; const originalAttDuration = Number(jobData.attDuration || (jobData.property && jobData.property.attDuration) || 0); const playbackSpeed = Math.max(0.5, Math.min(2, Number(this.config.speed || 1) || 1)); const startSeconds = audioTask ? 0 : videoResumeSeconds(jobData, duration); const urls = buildVideoReportUrls({ startSeconds, reportUrl, dtoken, clazzId: context.clazzId, uid, jobid: jobData.jobid, objectid: videoInfo.objectid || objectid, duration, courseId: context.courseId, otherInfo: cleanOtherInfo(jobData.otherInfo || ''), rt: audioTask ? '' : (jobData.property && jobData.property.rt || '0.9'), dtype, videoFaceCaptureEnc: jobData.videoFaceCaptureEnc || (jobData.property && jobData.property.videoFaceCaptureEnc) || '', attDuration: duration, originalAttDuration, attDurationEnc: jobData.attDurationEnc || (jobData.property && jobData.property.attDurationEnc) || '', speed: playbackSpeed, stepSeconds: 25 }); const initialEtaSeconds = Math.ceil(Math.max(0, duration - startSeconds) / playbackSpeed); this.setLearningTask(jobData, context, { headline: `正在学习${mediaText}:${this.shortText(name)}`, etaSeconds: initialEtaSeconds, tip: `${mediaText}时长:${duration} 秒;倍速:${playbackSpeed}x;当前尝试:${attempt}/${maxAttempts}` }); this.log(startSeconds > 0 ? `${mediaText}继续学习:目录 ${this.chapterLabel(context.chapterId)}|文件:${name},从平台记录 ${formatClockSeconds(startSeconds)} 继续,总时长 ${formatClockSeconds(duration)}` : `${mediaText}开始学习:目录 ${this.chapterLabel(context.chapterId)}|文件:${name},总时长 ${formatClockSeconds(duration)}`, 'green'); this.log(`${mediaText}\u4e0a\u62a5\u8ba1\u5212\uff1aduration=${duration}s\uff0cpackets=${urls.length}\uff0cstep=25s\uff0cspeed=${playbackSpeed}x\uff0cattempt=${attempt}/${maxAttempts}\uff0cdtype=${dtype}\uff0cobjectid=${videoInfo.objectid || objectid}\uff0cjobid=${jobData.jobid}`, 'blue'); if (!urls.length) { this.log(`${mediaText}\u4e0a\u62a5\u5305\u4e3a\u7a7a\uff1aduration=${duration}\uff0cdtoken=${dtoken ? 'yes' : 'no'}\uff0creportUrl=${reportUrl || '-'}\uff0cjobid=${jobData.jobid || '-'}\uff0cobjectid=${videoInfo.objectid || objectid || '-'}`, 'red'); throw new Error(`${mediaText}\u4e0a\u62a5\u5305\u4e3a\u7a7a\uff0c\u65e0\u6cd5\u63d0\u4ea4\u8fdb\u5ea6`); } if (audioTask) this.log(`\u97f3\u9891\u6309\u771f\u5b9e\u64ad\u653e\u5668 dtype=Audio \u4e0a\u62a5\uff1a\u9996\u5305 playingTime=${urls[0].playingTime}\uff0cisdrag=${urls[0].isdrag}`, 'blue'); let lastVideoResp = null; const mediaStartedAt = Date.now(); for (let i = 0; i < urls.length; i++) { await this.waitIfPaused(); if (this.state.stopped) return; const u = urls[i]; const rawPlayingTime = Math.max(0, Math.min(duration, Number(u.playingTime || 0))); const etaSeconds = Math.ceil(Math.max(0, duration - rawPlayingTime) / playbackSpeed); this.setLearningTask(jobData, context, { headline: `正在学习${mediaText}:${this.shortText(name)}`, etaSeconds, tip: `${mediaText}进度:${rawPlayingTime}/${duration} 秒;预计等待:${formatClockSeconds(etaSeconds)}` }); const plannedElapsedMs = Math.round((Math.max(0, rawPlayingTime - startSeconds) / playbackSpeed) * 1000); const plannedSendAt = mediaStartedAt + plannedElapsedMs; const waitMs = Math.max(0, plannedSendAt - Date.now()); if (waitMs > 0) { this.log(`video wait: ${Math.round(waitMs / 1000)}s before playingTime=${u.playingTime}`, 'gray'); const waitStartedAt = Date.now(); await this.sleepWithPause(waitMs); const actualWaitMs = Date.now() - waitStartedAt; if (this.state.stopped) return; } let ok = false; let lastPacketError = ''; for (let r = 0; r < this.config.retryTimes; r++) { const reportCandidates = [{ url: u.url, mediaType: mediaKind, dtype }]; for (const candidate of reportCandidates) { try { const reportUrlNow = candidate.url.replace(/([?&])_t=\d+/, `$1_t=${Date.now()}`); if (audioTask) this.log(`\u97f3\u9891\u4e0a\u62a5\u8bf7\u6c42\uff1apacket=${i + 1}/${urls.length}\uff0cretry=${r + 1}/${this.config.retryTimes}\uff0cdtype=${candidate.dtype}\uff0cmediaType=${candidate.mediaType}\uff0cplayingTime=${u.playingTime}`, 'gray'); const res = await videoRequest(reportUrlNow, mediaHost, { 'Accept': '*/*', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache', 'Content-Type': 'application/json' }, { mediaType: candidate.mediaType, frameScope: this.videoFrameScope }); const json = parseMediaProgressResponse(res); lastVideoResp = json; const passed = isTrueValue(json.isPassed); const reportedLearned = Math.max(0, Math.min(duration, Number(u.playingTime || 0))); const learned = passed ? duration : reportedLearned; const remain = Math.max(0, duration - learned); const etaRemain = Math.ceil(remain / playbackSpeed); const pct = passed ? 100 : (duration > 0 ? Math.max(0, Math.min(100, Math.round(learned * 100 / duration))) : 0); this.log(`${mediaText}进度:目录 ${this.chapterLabel(context.chapterId)}|文件:${this.shortText(name, 70)} 播放位置 ${formatClockSeconds(learned)} / ${formatClockSeconds(duration)}(${pct}%),预计等待 ${formatClockSeconds(etaRemain)}${playbackSpeed !== 1 ? `(${playbackSpeed}x)` : ''}`, passed ? 'green' : 'blue'); this.log(`video packet ${i + 1}/${urls.length}: playingTime=${u.playingTime}, isdrag=${u.isdrag}, dtype=${candidate.dtype}, mode=${res.requestMode || '-'}, isPassed=${json.isPassed}`, passed ? 'green' : 'gray'); if (audioTask) this.log(`音频上报成功:dtype=${candidate.dtype},mode=${res.requestMode || '-'},playingTime=${u.playingTime},isPassed=${json.isPassed}`, passed ? 'green' : 'blue'); ok = true; if (passed) { this.setLearningTask(jobData, context, { headline: `${mediaText}已完成:${this.shortText(name)}`, etaText: '已完成', tip: '即将进入下一个任务。' }); this.log(`${mediaText}学习完成:${name}`, 'green'); this.log(`video passed: ${name}`, 'green'); return; } break; } catch (e) { if (e && e.retryable === false) throw e; lastPacketError = `${candidate.dtype}: ${e.message || e}`; this.log(`${mediaText}包失败,重试 ${r + 1}/${this.config.retryTimes}${audioTask ? `,dtype=${candidate.dtype}` : ''}: ${e.message || e}`, 'orange'); } } if (ok) break; await this.sleep(1200); } if (!ok) throw new Error(`${mediaText}包连续失败:${name}${lastPacketError ? `;最后错误:${lastPacketError}` : ''}`); } this.log(`video upload finished: ${name} last=${JSON.stringify(lastVideoResp || {}).slice(0, 220)}`, 'gray'); if (!lastVideoResp || !isTrueValue(lastVideoResp.isPassed)) { // A full request sequence is not proof of completion. Do not replay the entire task blindly. const error = new Error('学习通尚未确认该任务完成,已保留待处理;请回到原课程页核对'); error.code = 'MEDIA_COMPLETION_UNCONFIRMED'; error.retryable = false; throw error; } this.log(`${mediaText}上报结束:${name}`, 'green'); this.setLearningTask(jobData, context, { headline: `${mediaText}已完成:${this.shortText(name)}`, etaText: '已完成', tip: '即将进入下一个任务。' }); }, async runDocument(jobData, context) { if (this.state.stopped || !hasTaskPoint(jobData) || !hasExplicitPendingTaskState(jobData)) return false; jobData = normalizeAttachment(jobData, context) || jobData; if (!isRunnableTask(jobData) || !['document', 'book'].includes(jobData.type)) return false; const label = jobData.type === 'book' ? '书籍' : '文档'; const name = safeName(jobData); const pending = reason => { this.setLearningTask(jobData, context, { headline: label + '未完成:' + this.shortText(name), etaText: '保留待处理', tip: reason }); this.log(label + '未完成,已保留待处理:' + name + ';' + reason, 'orange'); return false; }; const plan = buildDocumentTaskRequest(jobData, context, getOldStudyHost(context)); if (!plan) return pending('缺少任务凭证或课程参数,或课程已归档/关闭'); this.setLearningTask(jobData, context, { headline: '正在学习' + label + ':' + this.shortText(name), etaText: '正在处理', tip: '正在发送原生完成请求。' }); this.log(label + '任务:' + name, 'blue'); for (const url of [plan.url, plan.canonicalUrl]) { await this.waitIfPaused(); if (this.state.stopped) return false; let response; try { response = await request({ url, method: plan.method, headers: plan.headers, timeout: plan.timeout, withCredentials: plan.withCredentials, allowStatuses: plan.allowStatuses }); } catch (_) { if (this.state.stopped) return false; return pending('请求失败或超时,未确认完成;请检查网络和登录状态'); } if (this.state.stopped) return false; if (url === plan.url && [404, 405].includes(Number(response.status))) continue; const result = documentTaskResponseResult(response, plan); if (!result.accepted) return pending(result.reason); this.setLearningTask(jobData, context, { headline: label + '已完成:' + this.shortText(name), etaText: '已完成', tip: '平台完成接口已确认,即将进入下一项。' }); this.log(label + '完成:' + name, 'green'); return true; } return pending('完成接口暂不可用'); }, async runHyperlink(jobData, context) { if (this.state.stopped || !hasTaskPoint(jobData) || !hasExplicitPendingTaskState(jobData)) return false; jobData = normalizeAttachment(jobData) || jobData; if (!isRunnableTask(jobData)) return false; requireParam(jobData.jobid, 'jobid'); const mediaHost = getOldStudyHost(context); const jtokenPart = jobData.jtoken ? `&jtoken=${enc(jobData.jtoken)}` : ''; const url = `${mediaHost}/ananas/job/hyperlink?jobid=${enc(jobData.jobid)}&knowledgeid=${enc(context.chapterId)}&courseid=${enc(context.courseId)}&clazzid=${enc(context.clazzId)}${jtokenPart}`; this.setLearningTask(jobData, context, { headline: `正在学习链接:${this.shortText(safeName(jobData))}`, etaText: '约 1 分钟内', tip: '链接任务正在处理中。' }); this.log(`链接任务:${safeName(jobData)}`, 'blue'); const res = await request({ url }); let ok = false; try { const data = JSON.parse(res.responseText || '{}'); ok = data.status === true || data.status === 1 || data.result === 1 || data.success === true; } catch (_) { ok = /true|success|完成/i.test(String(res.responseText || '')); } if (!ok) throw new Error(`链接任务失败:${String(res.responseText || '').slice(0, 120)}`); this.setLearningTask(jobData, context, { headline: `链接已完成:${this.shortText(safeName(jobData))}`, etaText: '已完成', tip: '即将进入下一个任务。' }); this.log(`链接完成:${safeName(jobData)}`, 'green'); }, async runRead(jobData, context) { if (this.state.stopped || !hasTaskPoint(jobData) || !hasExplicitPendingTaskState(jobData)) return false; jobData = normalizeAttachment(jobData, context) || jobData; if (jobData.type !== 'read' || !isRunnableTask(jobData)) return false; const readId = extractReadId(jobData); requireParam(readId, 'readId'); const mediaHost = getOldStudyHost(context); const pending = tip => { this.setLearningTask(jobData, context, { headline: '阅读链接尚未处理成功', etaText: '保留待处理', tip }); this.log('阅读链接尚未处理成功,已保留待处理:' + safeName(jobData) + ';' + tip, 'orange'); return false; }; const plan = buildReadNavigationPlan(jobData, context, mediaHost, readId); if (!plan) return pending('缺少原生阅读入口参数,或课程已归档/关闭,请在原课程页点击阅读链接。'); const cpiPart = context.cpi ? '&cpi=' + enc(context.cpi) : ''; const referer = mediaHost + '/mycourse/studentstudy?chapterId=' + enc(context.chapterId) + '&courseId=' + enc(context.courseId) + '&clazzid=' + enc(context.clazzId) + cpiPart + '&mooc2=1'; const readRequest = async options => { try { return await request(options); } catch (error) { if (error && error.name === 'AbortError') { const aborted = new Error('阅读请求已取消'); aborted.name = 'AbortError'; throw aborted; } const status = String(error && error.message || '').match(/^HTTP\s+(\d{3})\b/); throw new Error('阅读请求失败' + (status ? '(HTTP ' + status[1] + ')' : '') + ';页面位置:' + describeReadNavigationUrl(options.url, mediaHost)); } }; const open = (url, ref) => readRequest({ url, headers: { Referer: ref, Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, timeout: 30000 }); this.setLearningTask(jobData, context, { headline: '正在打开阅读链接:' + this.shortText(safeName(jobData)), etaText: '正在跳转', tip: '走平台阅读入口,成功后直接继续,不等待任务点状态更新。' }); this.log('阅读任务:' + safeName(jobData), 'blue'); let target = plan.contentUrl; let page = null; let contentReferer = referer; if (plan.previewUrl) { await this.waitIfPaused(); if (this.state.stopped) return false; const preview = await open(plan.previewUrl, plan.moduleUrl); if (this.state.stopped) return false; if (!isReadPageAvailable(preview)) throw new Error('阅读入口未能正常打开,请回到课程页确认登录状态'); const entryUrl = preview.finalUrl || plan.previewUrl; const entry = resolveReadNavigationUrl(entryUrl, plan.previewUrl, readId); if (!entry) return pending('阅读入口跳转到尚未支持的位置:' + describeReadNavigationUrl(entryUrl, plan.previewUrl) + ';请反馈此提示,不需要提供完整链接或登录参数。'); if (entry.kind === 'content') { target = entry.url; page = preview; } else { contentReferer = entry.url; target = parseReadNavigationTarget(preview.responseText, entry.url, readId); if (!target) return pending('已进入阅读入口,但未识别到唯一的阅读链接;页面位置:' + describeReadNavigationUrl(entry.url, plan.previewUrl) + '。请提供该页阅读按钮跳转后的域名和路径(不含问号后的参数),以便适配实际链接。'); } } if (!page) { await this.waitIfPaused(); if (this.state.stopped) return false; page = await open(target, contentReferer); if (this.state.stopped) return false; } if (!isReadPageAvailable(page)) throw new Error('阅读页面未能正常打开,请回到课程页确认登录状态'); const finalContentUrl = page.finalUrl || target; const content = resolveReadNavigationUrl(finalContentUrl, target, readId); if (!content || content.kind !== 'content') return pending('阅读跳转未到达当前材料;页面位置:' + describeReadNavigationUrl(finalContentUrl, target) + ',请在原课程页核对。'); if (plan.callbackUrl) { await this.waitIfPaused(); if (this.state.stopped) return false; const result = await readRequest({ url: plan.callbackUrl, headers: { Referer: plan.moduleUrl }, timeout: 30000 }); if (this.state.stopped) return false; if (!isReadCallbackAccepted(result)) return pending('平台尚未接受阅读链接回调,请在原课程页核对。'); } const message = plan.callbackUrl ? '阅读链接回调成功,本次阅读已处理' : '旧版阅读链接已打开,本次阅读已处理'; this.setLearningTask(jobData, context, { headline: '本次阅读已处理:' + this.shortText(safeName(jobData)), etaText: '继续下一项', tip: '不等待任务点状态更新,继续处理后续任务。' }); this.log(message + ':' + safeName(jobData), 'green'); return true; }, async runDiscussion(jobData, context) { if (this.state.stopped || !hasTaskPoint(jobData) || !hasExplicitPendingTaskState(jobData)) return false; jobData = normalizeAttachment(jobData, context) || jobData; if (!isRunnableTask(jobData)) return false; const prop = jobData.property || {}; const oldHost = getOldStudyHost(context); const title = safeName(jobData) || '讨论任务'; const mtopicid = prop.mid || prop.mtopicid || prop.topicId || jobData.mid || jobData.mtopicid || ''; const jobid = jobData.jobid || prop.jobid || prop._jobid || ''; const courseId = context.courseId || prop.courseid || prop.courseId || ''; const clazzId = context.clazzId || context.classId || prop.clazzid || prop.clazzId || prop.classId || ''; requireParam(mtopicid, 'mtopicid'); requireParam(jobid, 'jobid'); requireParam(courseId, 'courseId'); requireParam(clazzId, 'clazzId'); this.setLearningTask(jobData, context, { headline: `正在参与讨论:${this.shortText(title)}`, etaText: '约 1 分钟内', tip: '正在进入讨论并提交学习记录。' }); this.log(`讨论任务:${title}`, 'blue'); const chapterParams = new URLSearchParams(); chapterParams.set('mtopicid', mtopicid); chapterParams.set('jobid', jobid); chapterParams.set('isPortal', 'false'); chapterParams.set('ut', 's'); chapterParams.set('clazzId', clazzId); chapterParams.set('enc', ''); chapterParams.set('courseid', courseId); chapterParams.set('isJob', 'true'); const chapterUrl = `${oldHost}/mooc-ans/bbscircle/chapter?${chapterParams.toString()}`; const chapterRes = await request({ url: chapterUrl, headers: { Referer: `${oldHost}/ananas/modules/insertbbs/index.html`, Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, timeout: 30000 }); const chapterHtml = chapterRes.responseText || ''; const discussionUrl = extractDiscussionUrl(chapterHtml); if (!discussionUrl) throw new Error('未找到讨论入口'); const ids = extractDiscussionIds(discussionUrl); requireParam(ids.bbsid, 'bbsid'); requireParam(ids.topicUuid, 'topicUuid'); this.setLearningTask(jobData, context, { headline: `正在参与讨论:${this.shortText(title)}`, etaText: '正在获取讨论内容', tip: '正在读取讨论主题。' }); const jumpRes = await request({ url: discussionUrl, headers: { Referer: `${oldHost}/`, Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, timeout: 30000 }); const jumpHtml = jumpRes.responseText || ''; const urlToken = extractDiscussionUrlToken(jumpHtml); let replyContent = '我认同上述观点,这个话题很有意义,值得深入思考和讨论。'; try { const replyParams = new URLSearchParams(); replyParams.set('bbsid', ids.bbsid); replyParams.set('uuid', ids.topicUuid); replyParams.set('tag', ''); replyParams.set('order', '2'); replyParams.set('lastValue', ''); replyParams.set('lastAuxValue', ''); const replyUrl = `https://groupweb.chaoxing.com/pc/invitation/getReplyList?${replyParams.toString()}`; const replyRes = await request({ url: replyUrl, headers: { Referer: discussionUrl, 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json, text/javascript, */*; q=0.01' }, timeout: 30000 }); const replyJson = JSON.parse(replyRes.responseText || '{}'); const datas = Array.isArray(replyJson.datas) ? replyJson.datas : []; const first = datas.find(x => x && x.content); if (first && first.content) replyContent = sanitizeDiscussionContent(first.content); } catch (e) { this.log(`讨论参考内容读取失败,使用默认回复:${e.message || e}`, 'orange'); } const postData = new URLSearchParams(); postData.set('courseId', courseId); postData.set('classId', clazzId); postData.set('replyId', '-1'); postData.set('uuid', createUuid()); postData.set('topic_content', encodeURIComponent(replyContent)); postData.set('anonymous', ''); postData.set('urlToken', urlToken || ''); postData.set('bbsid', ids.bbsid); const postUrl = `https://groupweb.chaoxing.com/pc/invitation/${enc(ids.topicUuid)}/addReplys`; this.setLearningTask(jobData, context, { headline: `正在提交讨论:${this.shortText(title)}`, etaText: '即将完成', tip: '正在提交讨论内容。' }); const postRes = await request({ method: 'POST', url: postUrl, data: postData.toString(), headers: { Referer: discussionUrl, Origin: 'https://groupweb.chaoxing.com', 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Accept: '*/*' }, timeout: 30000 }); let ok = true; let msg = ''; try { const result = JSON.parse(postRes.responseText || '{}'); ok = result.status !== false && result.result !== false && String(result.msg || '').indexOf('失败') < 0; msg = result.msg || result.message || ''; } catch (_) {} if (!ok) throw new Error(msg || '讨论提交失败'); this.setLearningTask(jobData, context, { headline: `讨论已完成:${this.shortText(title)}`, etaText: '已完成', tip: '即将进入下一个任务。' }); this.log(`讨论完成:${title}`, 'green'); }, async runLive(jobData, context) { if (this.state.stopped || !hasTaskPoint(jobData) || !hasExplicitPendingTaskState(jobData)) return false; jobData = normalizeAttachment(jobData, context) || jobData; if (!isRunnableTask(jobData)) return false; const prop = jobData.property || {}; const liveId = getLiveField(jobData, 'liveId') || getLiveField(jobData, 'liveid'); requireParam(liveId, 'liveId'); const liveTitle = safeName(jobData) || `直播${liveId}`; const liveHost = 'https://zhibo.chaoxing.com'; const oldHost = getOldStudyHost(context); const uid = context.uid || detectUid() || getLiveField(jobData, 'userId') || ''; const jobid = jobData.jobid || prop.jobid || prop._jobid || `live-${liveId}`; const params = new URLSearchParams(); params.set('courseId', context.courseId || prop.courseId || ''); params.set('classId', context.clazzId || context.classId || prop.classId || prop.clazzId || ''); params.set('knowledgeId', context.chapterId || prop.knowledgeId || prop.knowledgeid || ''); params.set('jobId', jobid); params.set('userId', uid); params.set('rt', prop.rt || jobData.rt || '0.9'); params.set('livesetenc', getLiveField(jobData, 'liveSetEnc') || getLiveField(jobData, 'livesetenc')); params.set('isjob', 'true'); params.set('watchingInCourse', '1'); params.set('customPara1', `${context.clazzId || context.classId || ''}_${context.courseId || ''}`); params.set('customPara2', getLiveField(jobData, 'authEnc') || getLiveField(jobData, 'authenc')); params.set('jobfs', '0'); params.set('isNotDrag', getLiveField(jobData, 'isNotDrag') || '1'); params.set('livedragenc', getLiveField(jobData, 'liveDragEnc') || getLiveField(jobData, 'livedragenc')); params.set('sw', '1'); params.set('ds', '1'); params.set('liveswdsenc', getLiveField(jobData, 'liveSwDsEnc') || getLiveField(jobData, 'liveswdsenc')); const livePageUrl = `${liveHost}/${enc(liveId)}?${params.toString()}`; this.setLearningTask(jobData, context, { headline: `正在准备直播:${this.shortText(liveTitle)}`, etaText: '请稍候', tip: '直播任务准备完成后会自动计时。' }); this.log(`直播任务:${liveTitle} liveId=${liveId}`, 'blue'); const pageRes = await request({ url: livePageUrl, headers: { Referer: `${oldHost}/ananas/modules/live/index.html?v=2023-1218-1127`, Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, timeout: 30000 }); const html = pageRes.responseText || ''; const parsed = parseLivePage(html); const streamName = parsed.streamName || getLiveField(jobData, 'streamName'); const vdoid = parsed.vdoid || getLiveField(jobData, 'vdoid'); const userId = parsed.userId || uid; const courseId = parsed.courseId || context.courseId || ''; const liveStatus = Number(parsed.liveStatus != null ? parsed.liveStatus : (getLiveField(jobData, 'liveStatus') || 4)); requireParam(streamName, 'streamName'); requireParam(vdoid, 'vdoid'); requireParam(userId, 'userId'); this.log(`直播页面解析:status=${liveStatus}, stream=${streamName}, vdoid=${vdoid}`, 'green'); let effectiveStatus = liveStatus; if (liveStatus !== 4) { const currentStatus = await this.checkLiveStatus(streamName, vdoid); if ([0, 1, 3, 4].includes(currentStatus) && currentStatus !== liveStatus) { this.log(`直播状态校正:页面=${liveStatus},接口=${currentStatus},按${liveStatusText(currentStatus)}处理`, 'orange'); effectiveStatus = currentStatus; } } if (effectiveStatus === 0) { throw new Error('直播尚未开始,不能标记为完成'); } await this.simulateLiveEnter(liveId); await this.uploadLiveWatchProgress({ liveId, streamName, vdoid, userId, courseId, isStart: true }); let duration = 0; let playbackUrl = parsed.videoUrl || ''; if (effectiveStatus === 4 && !playbackUrl) { try { const refreshRes = await request({ url: `${livePageUrl}&_=${Date.now()}`, headers: { Referer: `${oldHost}/ananas/modules/live/index.html?v=2023-1218-1127`, Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, timeout: 30000 }); playbackUrl = parseLivePage(refreshRes.responseText || '').videoUrl || ''; } catch (_) {} } if (effectiveStatus === 4 && playbackUrl) duration = await this.getM3u8Duration(playbackUrl, livePageUrl); if (!duration) duration = Number(prop.duration || prop.attDuration || 7200); const interval = 30000; const maxMs = Math.max(30000, duration * 1000); this.setLearningTask(jobData, context, { headline: `正在观看直播:${this.shortText(liveTitle)}`, etaSeconds: duration, tip: effectiveStatus === 4 ? '正在学习直播回看内容。' : '直播进行中,会持续记录观看进度。' }); const liveProgressKey = `live:${liveId}`; const liveProgressLabel = `${effectiveStatus === 4 ? '直播回看' : '直播观看'}:${this.shortText(liveTitle, 30)}`; this.log(`${liveProgressLabel} 开始,总时长 ${formatClockSeconds(duration)},每 30 秒上报一次`, 'green'); this.log(`直播开始:${liveTitle} mode=${effectiveStatus === 4 ? '回看' : liveStatusText(effectiveStatus)} duration=${duration}s interval=30s`, 'gray'); let elapsed = 0; while (elapsed < maxMs) { await this.sleepWithPause(interval); if (this.state.stopped) return; elapsed += interval; await this.uploadLiveWatchProgress({ liveId, streamName, vdoid, userId, courseId, isStart: false }); this.setLearningTask(jobData, context, { headline: `正在观看直播:${this.shortText(liveTitle)}`, etaSeconds: Math.max(0, duration - Math.round(elapsed / 1000)), tip: `直播进度:${Math.round(elapsed / 1000)}/${duration} 秒` }); this.logLongTaskProgress(liveProgressKey, liveProgressLabel, elapsed / 1000, duration); this.log(`直播进度:${liveTitle} ${Math.round(elapsed / 1000)}/${duration}s`, 'gray'); if (effectiveStatus !== 4) { const st = await this.checkLiveStatus(streamName, vdoid); if (st === 3 || st === 4) { this.clearLongTaskProgress(liveProgressKey); this.log(`${liveProgressLabel} 直播已结束,任务完成`, 'green'); this.log(`直播状态校正:结束 status=${st}`, 'gray'); return; } } } this.clearLongTaskProgress(liveProgressKey); this.setLearningTask(jobData, context, { headline: `直播已完成:${this.shortText(liveTitle)}`, etaText: '已完成', tip: '即将进入下一个任务。' }); this.log(`直播完成:${liveTitle}`, 'green'); }, async runClassTask(jobData, context) { if (this.state.stopped || !hasTaskPoint(jobData) || !hasExplicitPendingTaskState(jobData)) return false; jobData = normalizeAttachment(jobData, context) || jobData; if (!isRunnableTask(jobData)) return false; const prop = jobData.property || {}; const uuid = getClassTaskField(jobData, 'uuid'); if (!uuid && (prop.objectid || prop.objectId || jobData.objectid || jobData.objectId)) { this.log(`课堂回看缺少 uuid,但检测到媒体 objectId,改按普通视频处理:${safeName(jobData)}`, 'orange'); await this.runVideo({ ...jobData, type: 'video', property: { ...prop, module: `${prop.module || ''} ans-insertvideo` } }, context); return; } requireParam(uuid, 'uuid'); const title = safeName(jobData) || `课堂回看 ${uuid}`; const percent = Math.max(1, Math.min(100, Number(getClassTaskField(jobData, 'percent') || getClassTaskField(jobData, 'reviewPercent') || 90))); let viewLink = getClassTaskField(jobData, 'viewLink') || `https://k.chaoxing.com/res/look/index.html?uuid=${enc(uuid)}&_t=202311241720`; if (!/^https?:\/\//i.test(viewLink)) viewLink = `https://k.chaoxing.com${viewLink.startsWith('/') ? '' : '/'}${viewLink}`; const lookUrl = `https://k.chaoxing.com/res/look/index.html?uuid=${enc(uuid)}&_t=202311241720`; this.setLearningTask(jobData, context, { headline: `正在学习课堂回看:${this.shortText(title)}`, etaText: '正在计算', tip: '课堂回看任务正在处理中。' }); this.log(`课堂回看任务:${title} uuid=${uuid} percent=${percent}%`, 'blue'); const review = await this.fetchClassTaskReview(uuid, viewLink); const items = Array.isArray(review.data) ? review.data : []; if (!items.length) throw new Error(`课堂回看接口无数据:${String(JSON.stringify(review)).slice(0, 160)}`); let okCount = 0; for (let i = 0; i < items.length; i++) { const item = items[i] || {}; const dataKey = item.dataKey || prop.dataKey || ''; requireParam(dataKey, 'dataKey'); const duration = Math.floor(Number(item.duration || prop.duration || 0)); const start = Math.max(0, Math.floor(Number(item.userTime || item.userDuration || 0))); const target = duration ? Math.ceil(duration * percent / 100) : Math.max(start + 600, 600); const finalTime = Math.min(duration || target, Math.max(start, target)); this.setLearningTask(jobData, context, { headline: `正在学习课堂回看:${this.shortText(title)}`, etaSeconds: Math.max(0, finalTime - start), tip: `正在处理第 ${i + 1}/${items.length} 段回看。` }); this.log(`课堂回看 ${i + 1}/${items.length}:${this.shortText(title, 30)} 学到 ${formatClockSeconds(finalTime)}${duration ? ' / ' + formatClockSeconds(duration) : ''}`, 'blue'); this.log(`课堂回看 ${i + 1}/${items.length}: duration=${duration || '-'}s, userTime=${start}s, target=${finalTime}s`, 'gray'); const res = await this.uploadClassTaskReviewDot(uuid, dataKey, finalTime, lookUrl); if (res && Number(res.result) === 1) okCount++; this.log(`课堂回看上报:lastWatchTime=${finalTime}, result=${res && res.result}`, Number(res && res.result) === 1 ? 'green' : 'orange'); } const verify = await this.fetchClassTaskReview(uuid, lookUrl).catch(() => null); if (verify && Array.isArray(verify.data)) { const text = verify.data.map((x, i) => `${i + 1}:${x.userTime || 0}/${x.duration || 0}`).join(', '); this.log(`课堂回看校验:${text}`, 'green'); } if (!okCount) throw new Error('课堂回看上报未成功'); this.setLearningTask(jobData, context, { headline: `课堂回看已完成:${this.shortText(title)}`, etaText: '已完成', tip: '即将进入下一个任务。' }); this.log(`课堂回看完成:${title}`, 'green'); }, async fetchClassTaskReview(uuid, referer) { const url = `https://k.chaoxing.com/apis/chapter/getMeetReview4Job?crossOrigin=true&uuid=${enc(uuid)}`; const res = await request({ url, headers: { Referer: referer || `https://k.chaoxing.com/res/look/index.html?uuid=${enc(uuid)}` }, timeout: 20000 }); return JSON.parse(res.responseText || '{}'); }, async uploadClassTaskReviewDot(uuid, dataKey, lastWatchTime, referer) { const url = `https://k.chaoxing.com/apis/chapter/addReviewDot4Job?crossOrigin=true&uuid=${enc(uuid)}&dataKey=${enc(dataKey)}&lastWatchTime=${enc(Math.floor(Number(lastWatchTime) || 0))}`; const res = await request({ url, headers: { Referer: referer || `https://k.chaoxing.com/res/look/index.html?uuid=${enc(uuid)}`, 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json, text/javascript, */*; q=0.01' }, timeout: 20000 }); return JSON.parse(res.responseText || '{}'); }, async simulateLiveEnter(liveId) { const url = `https://zhibo.chaoxing.com/userLog/recordAction?operate=${enc('光标进入')}`; try { const res = await request({ url, headers: { Referer: `https://zhibo.chaoxing.com/${enc(liveId)}`, 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json, text/javascript, */*; q=0.01' }, timeout: 10000 }); this.log(`直播进入上报:HTTP ${res.status} ${String(res.responseText || '').slice(0, 80)}`, 'gray'); } catch (e) { this.log(`直播进入上报失败,继续:${e.message || e}`, 'orange'); } }, async uploadLiveWatchProgress({ liveId, streamName, vdoid, userId, courseId, isStart }) { const params = new URLSearchParams(); params.set('streamName', streamName); params.set('vdoid', vdoid); params.set('userId', userId); params.set('isStart', isStart ? '1' : '0'); params.set('t', String(Date.now())); params.set('courseId', courseId || ''); const url = `https://zhibo.chaoxing.com/saveTimePc?${params.toString()}`; const res = await request({ url, headers: { Referer: `https://zhibo.chaoxing.com/${enc(liveId)}`, 'X-Requested-With': 'XMLHttpRequest', Accept: '*/*' }, timeout: 10000 }); if (res.status < 200 || res.status >= 300) throw new Error(`live saveTimePc HTTP ${res.status}`); return res; }, async checkLiveStatus(streamName, vdoid) { try { const url = `https://live.chaoxing.com/liveAPI/getlivestatus?streamName=${enc(streamName)}&vdoid=${enc(vdoid)}`; const res = await request({ url, timeout: 10000 }); const json = JSON.parse(res.responseText || '{}'); return Number(json.livestatus != null ? json.livestatus : 4); } catch (_) { return 4; } }, async getM3u8Duration(url, referer) { try { const res = await request({ url, headers: referer ? { Referer: referer } : {}, timeout: 15000 }); let total = 0; for (const line of String(res.responseText || '').split(/\r?\n/)) { const m = line.match(/^#EXTINF:([\d.]+)/); if (m) total += Number(m[1] || 0); } return Math.floor(total); } catch (e) { this.log(`直播回看时长获取失败,使用默认时长:${e.message || e}`, 'orange'); return 0; } }, async runWork(jobData, context) { if (this.state.stopped || !hasTaskPoint(jobData) || !hasExplicitPendingTaskState(jobData)) return false; jobData = normalizeAttachment(jobData) || jobData; if (!isRunnableTask(jobData)) return false; if (isTaskPassed(jobData)) { this.log(`章节测验已完成,跳过:${safeName(jobData)}`, 'green'); return true; } const workId = extractWorkId(jobData); if (!workId) { this.log('章节测验未执行:缺少测验标识,请重新读取课程;任务仍保留待处理。', 'orange'); return false; } requireParam(context.courseId, 'courseId'); requireParam(context.clazzId, 'clazzId'); requireParam(context.chapterId, 'chapterId'); const oldHost = getOldStudyHost(context); const jobId = jobData.jobid || jobData._jobid || (jobData.property && (jobData.property._jobid || jobData.property.jobid)) || `work-${workId}`; const workEnc = jobData.enc || (jobData.property && (jobData.property.enc || jobData.property.workEnc)) || ''; const workApiUrl = buildWorkApiUrl(jobData, context, oldHost, workId, jobId, workEnc); const workHandleUrl = buildWorkHandleUrl(jobData, context, oldHost, workId, jobId, workEnc); this.setLearningTask(jobData, context, { headline: `正在准备章节测验:${this.shortText(safeName(jobData))}`, etaText: '请稍候', tip: '测验题目准备完成后会自动填写。' }); this.log(`正在读取章节测验:${safeName(jobData)}`, 'blue'); let html = '', usedUrl = ''; const cardsReferer = `${oldHost}/mooc-ans/knowledge/cards?clazzid=${enc(context.clazzId)}&courseid=${enc(context.courseId)}&knowledgeid=${enc(context.chapterId)}&num=1&ut=s${context.cpi ? `&cpi=${enc(context.cpi)}` : ''}&v=2025-0424-1038-3&mooc2=1&isMicroCourse=false&editorPreview=0`; const attempts = [ { name: 'api/work', url: workApiUrl, headers: { Referer: cardsReferer } }, { name: 'workHandle/handle', url: workHandleUrl, headers: { Referer: `${oldHost}/ananas/modules/work/index.html?v=2024-1212-1629&castscreen=0`, Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Sec-Fetch-Dest': 'iframe', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'same-origin', 'Upgrade-Insecure-Requests': '1' } } ]; for (const [index, attempt] of attempts.entries()) { if (this.state.stopped) return false; try { const origin = new URL(attempt.url).origin; const prefix = new URL(oldHost).pathname.replace(/\/$/, '') + '/mooc-ans/'; const pagePaths = new Set(['api/work', 'workHandle/handle', 'work/doHomeWork', 'work/doHomeWorkNew', 'work/selectWorkQuestionYiPiYue', 'mooc2/work/dowork', 'mooc2/work/task', 'mooc2/work/view'].map(path => prefix + path)); const checkedUrl = value => { const url = new URL(value, attempt.url); if (url.origin !== origin || url.username || url.password || !pagePaths.has(url.pathname)) throw new Error('章节测验跳转目标未通过校验,请从原课程页检查登录或验证提示'); url.hash = ''; return url.href; }; let nextUrl = checkedUrl(attempt.url), res; const visited = new Set(); for (let hop = 0; hop < 5; hop++) { await this.waitIfPaused(); if (this.state.stopped) return false; if (visited.has(nextUrl)) throw new Error('章节测验跳转出现循环,任务仍待处理'); visited.add(nextUrl); // Keep automatic redirects off. Only this same-origin read-only page chain can continue. res = await request({ url: nextUrl, headers: attempt.headers, timeout: 30000, redirect: 'manual' }); if (this.state.stopped) return false; if (res.finalUrl) checkedUrl(res.finalUrl); if (![301, 302, 303, 307, 308].includes(Number(res.status))) break; const locations = [...String(res.responseHeaders || '').matchAll(/^location:[ \t]*([^\r\n]*)/gim)]; if (locations.length !== 1 || !locations[0][1].trim()) throw new Error('章节测验跳转地址未读取到,请更新脚本管理器后重试'); if (hop === 4) throw new Error('章节测验跳转次数过多,任务仍待处理'); nextUrl = checkedUrl(new URL(locations[0][1].trim(), nextUrl).href); } if (this.state.stopped) return false; const candidate = res.responseText || ''; const candidateUrl = res.finalUrl || nextUrl; if (isWorkPageDoneLike(candidate, candidateUrl)) { this.log(`章节测验已完成,跳过:${safeName(jobData)}`, 'green'); return true; } // A non-empty login/error/shell page is not evidence of completion. const candidateDoc = new DOMParser().parseFromString(candidate, 'text/html'); if (!candidateDoc.querySelector('.singleQuesId, .TiMu, input[name^="answertype"], input[id^="answertype"]')) { throw new Error('章节测验页面未就绪:没有可作答题目,可能需要重新登录或稍后重试'); } html = candidate; usedUrl = candidateUrl; break; } catch (e) { if (this.state.stopped) return false; // Entry switching is internal; only report a failure once no fallback remains. if (index + 1 === attempts.length) this.log(`章节测验读取失败,本次未读取到题目,任务仍待处理:${this.publicErrorText(e)}`, 'orange', { publicDiagnostic: true }); } } if (!html) throw new Error('章节测验页面未就绪:两个入口均未读取到可作答题目'); return await this.solveWorkHtml(jobData, context, html, usedUrl, 'work'); }, async solveWorkHtml(jobData, context, html, usedUrl, label = 'work') { const frame = await createWorkFrame(html, usedUrl || location.href); try { const doc = frame.contentDocument; const win = frame.contentWindow; if (!doc || !win) throw new Error(`${label} iframe unavailable`); const workHtml = doc.documentElement && doc.documentElement.outerHTML || html || ''; const fontBase64 = extractCxsecretFont(workHtml || html || ''); await ensureCxFontMap(fontBase64); const pageEncrypted = /font-cxsecret/i.test(workHtml || html || ''); if (fontBase64) this.log(`${label} encrypted font detected: ${fontBase64.length} chars`, 'gray'); resetCxDecryptStats(); let parsed = parseWorkQuestions(doc, { html: workHtml, fontBase64 }); if (!parsed.questions.length && /singleQuesId|TiMu|answertype/i.test(html || '')) { const rawDoc = new DOMParser().parseFromString(html, 'text/html'); parsed = parseWorkQuestions(rawDoc, { html, fontBase64 }); parsed.optionLis = Array.from(doc.querySelectorAll('li[qid], .Zy_ulTop li, li[id-param]')); } // 题干字体加密的三种失败情形要分开报,否则只能看到笼统的「题库暂无答案」。 const decRep = cxDecryptReport(); let fontIssue = ''; if (pageEncrypted && !fontBase64) fontIssue = '页面有字体加密,但没能取到字体文件'; else if (decRep.fontBroken) fontIssue = '字体文件解析失败'; else if (pageEncrypted && decRep.replaced === 0) fontIssue = '字体已取到,但一个字都没能还原(本地字体表和这门课的字体不匹配)'; if (fontIssue) { this.log(`题干乱码未还原:${fontIssue};这些题暂时无法识别和查询,会被跳过`, 'red'); } else if (pageEncrypted) { this.log(`题干字体加密已还原 ${decRep.replaced} 个字(如 ${decRep.samples.slice(0, 3).join('、')})`, 'gray'); } this.log(`${label} questions parsed: ${parsed.questions.length}`, parsed.questions.length ? 'green' : 'orange'); if (!parsed.questions.length) { const diag = diagnoseWorkPage(doc); this.log(`章节测验无可用题目,跳过:${safeName(jobData)}`, 'orange'); this.log(`${label} parse diag: ${diag}`, 'orange'); return false; } this.setLearningTask(jobData, context, { headline: `正在完成章节测验:${this.shortText(safeName(jobData))}`, etaSeconds: Math.max(60, parsed.questions.length * 6), tip: `题目数量:${parsed.questions.length};当前进度:0/${parsed.questions.length}` }); let answered = 0; const failedList = []; for (let i = 0; i < parsed.questions.length; i++) { const q = parsed.questions[i]; await this.waitIfPaused(); if (this.state.stopped) return false; normalizeQuestionTypeMeta(q); // 面板日志用中文任务名 + 题干,方便用户核对是哪道题; // label 仍留给内部英文调试日志(那些会被 shouldHideDashboardLog 过滤掉)。 const rowLabel = `作业 ${i + 1}/${parsed.questions.length}`; const qBrief = `【${q.typeName || '题目'}】${this.shortText(autoLogQuestionText(q.question), 34)}`; this.setLearningTask(jobData, context, { headline: `正在完成章节测验:${this.shortText(safeName(jobData))}`, currentTask: `章节测验:第 ${i + 1}/${parsed.questions.length} 题`, etaSeconds: Math.max(10, (parsed.questions.length - i) * 6), tip: `题型:${q.typeName || '-'};题干:${this.shortText(q.question, 70)}` }); const apiOptions = q.options.map(o => ({ label: o.label, content: o.content })); this.log(`章节测验 ${i + 1}/${parsed.questions.length}:正在查询答案`, 'blue'); const ansRes = await queryAnswerApi(q.question, q.typeName, apiOptions, this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl, this.config.answerApiToken, { ...q, shouldStop: () => this.state.stopped }); if (this.state.stopped) return false; if (ansRes.insufficientQuota) { this.state.stopped = true; this.state.running = false; this.setTokenStatus('Token 剩余次数不足,已自动停止服务;请充值后继续。', 'orange'); this.setUserStatus({ headline: '答题剩余次数不足', currentTask: '已停止答题服务', etaText: '-', tip: '请点击“充值次数”完成充值后重新开始。' }); showRechargeTip('答题剩余次数不足,请充值后继续。'); this.log('Token 剩余次数不足,已自动停止服务;请充值后继续。', 'red'); throw new Error('Token 剩余次数不足,已停止'); } if (!ansRes.success || !ansRes.answer) { const why = fontIssue ? '题干乱码未还原,暂时无法识别和查询' : '本次未取得答案'; const stillGarbled = !!fontIssue; const cleanErr = cleanAnswerError(ansRes.error); this.setLearningTask(jobData, context, { headline: `章节测验本次未取得答案:第 ${i + 1}/${parsed.questions.length} 题`, currentTask: `本题未填入:${q.typeName || '-'}`, etaText: '等待下一题', tip: `题干:${this.shortText(q.question, 90)};原因:${cleanErr || why}` }); this.log(`${rowLabel}${qBrief} → ${why},已跳过${cleanErr && !stillGarbled ? `(${cleanErr})` : ''}`, 'orange', { publicDiagnostic: true }); failedList.push(`第 ${i + 1} 题(${q.typeName || '未知题型'})`); continue; } if (ansRes.remainingCount != null) { this.applyAnswerTokenInfo(ansRes); this.setTokenStatus(tokenStatusText(this.state.tokenInfo), this.state.tokenInfo.remainingCount === 0 ? 'orange' : 'green'); } this.setLearningTask(jobData, context, { headline: `已查询到答案:第 ${i + 1}/${parsed.questions.length} 题`, currentTask: `${q.typeName || '-'}:答案 ${answerResultForLog(ansRes)}`, etaSeconds: Math.max(8, (parsed.questions.length - i) * 6), tip: `来源:${ansRes.cached ? '缓存' : '在线查询'};题干:${this.shortText(q.question, 70)}` }); this.log(`${rowLabel}${qBrief} → 已获取答案 ${answerResultForLog(ansRes)},正在填入`, 'gray'); if (effectiveQuestionTypeN(q) === '3' && judgeAnswerLooksDirty(ansRes.answer)) { this.log(`${rowLabel}:题库答案与题型不符(判断题返回 ${answerResultForLog(ansRes)}),已按 ${judgeAnswerLabel(ansRes.answer) || '-'} 处理,建议核对题库该题`, 'orange'); } try { q.element && q.element.scrollIntoView && q.element.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {} await this.sleep(180); if (this.state.stopped) return false; let filled = fillWorkAnswer(doc, parsed.optionLis, q, ansRes.answer); await this.sleep(Number(this.config.answerVerifyDelayMs || DEFAULT_CONFIG.answerVerifyDelayMs || 650)); let saved = verifyQuestionAnswerSaved(doc, q, ansRes.answer); // 成功这一步不再刷屏:本题最后会有一条「完成,答案 X」。 // 只在没保存上的时候提示一下,让用户知道多等一轮的原因。 if (!saved) { this.log(`${rowLabel}:未确认保存,准备重试`, 'orange'); await this.sleep(Number(this.config.answerRetryDelayMs || DEFAULT_CONFIG.answerRetryDelayMs || 1000)); if (this.state.stopped) return false; filled = fillWorkAnswer(doc, parsed.optionLis, q, ansRes.answer) || filled; await this.sleep(Number(this.config.answerVerifyDelayMs || DEFAULT_CONFIG.answerVerifyDelayMs || 650)); saved = verifyQuestionAnswerSaved(doc, q, ansRes.answer); this.log(`${rowLabel}:${saved ? '重试后已保存' : '重试后仍未保存,请稍后手动检查'}`, saved ? 'green' : 'orange'); } const ok = saved; if (ok) answered++; else failedList.push(`第 ${i + 1} 题(${q.typeName || '未知题型'})`); this.setLearningTask(jobData, context, { headline: `${ok ? '已填入' : '未确认填入'}:第 ${i + 1}/${parsed.questions.length} 题`, currentTask: `${q.typeName || '-'}:答案 ${answerResultForLog(ansRes)};结果:${ok ? '已保存' : '未保存'}`, etaSeconds: Math.max(5, (parsed.questions.length - i - 1) * 6), tip: `当前完成:${answered}/${parsed.questions.length};题干:${this.shortText(q.question, 70)}` }); this.log(`${rowLabel}:${ok ? '完成' : '未完成'},答案 ${answerResultForLog(ansRes)}`, ok ? 'green' : 'orange'); await this.sleepRandom(this.config.answerDelayMinMs || DEFAULT_CONFIG.answerDelayMinMs, this.config.answerDelayMaxMs || DEFAULT_CONFIG.answerDelayMaxMs); } if (!answered) { this.log(`章节测验未提交:0/${parsed.questions.length} 题确认填入,任务仍保留待处理;请查看上方未填原因。`, 'orange'); return false; } if (answered < parsed.questions.length) { this.log(`章节测验未提交:只填好 ${answered}/${parsed.questions.length} 题${failedList.length ? `,未完成 ${failedList.join('、')}` : ''};常见原因是题库答案与题型不符或该题暂无答案`, 'orange'); return false; } await this.sleep(Math.max(1200, Number(this.config.answerRetryDelayMs || DEFAULT_CONFIG.answerRetryDelayMs || 1000))); if (this.state.stopped) return false; this.log(`章节测验已填入 ${answered}/${parsed.questions.length} 题,正在提交并确认结果`, 'blue'); const submitRes = await submitWorkFormByProtocol(doc); const submitText = String(submitRes.responseText || '').replace(/\s+/g, ' ').slice(0, 220); this.log(`${label} submit response: HTTP ${submitRes.status}, ${submitText || 'empty'}`, 'gray'); await this.sleep(2500); this.setLearningTask(jobData, context, { headline: `章节测验已完成:${this.shortText(safeName(jobData))}`, etaText: '已完成', tip: '即将进入下一个任务。' }); this.log(`章节测验提交已确认:${safeName(jobData)}`, 'green'); return true; } finally { setTimeout(() => frame.remove(), 3000); } }, async runAssignment(jobData, context) { jobData = normalizeAttachment(jobData) || jobData; const oldHost = getOldStudyHost(context); const url = buildAssignmentTaskUrl(jobData, context, oldHost); this.setLearningTask(jobData, context, { headline: `已打开作业页面:${this.shortText(safeName(jobData))}`, etaText: '等待页面填写', tip: '请在新打开的页面确认答案并按页面要求提交。' }); this.log(`作业任务:${safeName(jobData)} workId=${jobData.workId || jobData.workid || '-'},已改为打开页面模拟填答`, 'blue'); const win = window.open(url, `_cx_assignment_${jobData.workId || jobData.workid || Date.now()}`); if (!win) throw new Error('作业页面弹窗被拦截,请允许浏览器弹窗后重试'); this.log(`作业页面已打开:${url};进入页面后脚本会自动填答案,不再协议提交`, 'gray'); await this.sleep(800); }, async runExam(jobData, context) { jobData = normalizeAttachment(jobData) || jobData; const oldHost = getOldStudyHost(context); const examId = jobData.examId || jobData.id; const classId = jobData.classId || context.clazzId || context.classId || ''; const courseId = jobData.courseId || context.courseId || ''; const cpi = jobData.cpi || context.cpi || ''; requireParam(examId, 'examId'); requireParam(courseId, 'courseId'); requireParam(classId, 'classId'); this.setLearningTask(jobData, context, { headline: `已打开考试页面:${this.shortText(safeName(jobData))}`, etaText: '等待页面填写', tip: '请在新打开的页面确认答案并按页面要求提交。' }); this.log(`考试任务:${safeName(jobData)} examId=${examId},已改为打开页面模拟填答`, 'blue'); const notesParams = new URLSearchParams(); notesParams.set('courseId', courseId); notesParams.set('classId', classId); notesParams.set('examId', examId); if (cpi) notesParams.set('cpi', cpi); if (jobData.answerId) notesParams.set('answerId', jobData.answerId); const notesUrl = `${oldHost}/exam-ans/exam/test/examcode/examnotes?${notesParams.toString()}`; const win = window.open(notesUrl, `_cx_exam_${examId}`); if (!win) throw new Error('考试页面弹窗被拦截,请允许浏览器弹窗后重试'); this.log(`考试入口已打开:${notesUrl};进入考试答题页后脚本会自动填答案,不再协议保存/提交`, 'gray'); await this.sleep(800); }, togglePause() { const run = this.state.multiCourse; if (run && run.active) { if (run.stopped) return; run.paused = !run.paused; this.state.paused = run.paused; this.log(run.paused ? '已暂停课程队列' : '继续课程队列', run.paused ? 'orange' : 'green'); this.renderChaoxingCourseProgress(); this.updateActionButtons(); return; } if (!this.state.running) { this.state.paused = false; this.updateActionButtons(); return; } this.state.paused = !this.state.paused; if (!this.state.paused) { this.setUserStatus({ headline: '已继续运行', tip: '系统会从当前进度继续处理后续任务。' }); } this.log(this.state.paused ? '已暂停' : '继续运行', this.state.paused ? 'orange' : 'green'); this.updateActionButtons(); }, stop() { const run = this.state.multiCourse; if (run && run.active) { run.stopped = true; run.paused = false; } this.state.stopped = true; this.state.paused = false; this.log(run && run.active ? '正在停止课程队列,等待在途请求结束' : '收到停止信号', 'orange'); if (run && run.active) this.renderChaoxingCourseProgress(); this.updateActionButtons(); }, async waitIfPaused() { while (this.state.paused && !this.state.stopped) await this.sleep(300); }, updateProgress() { if (this.state.multiCourse && (this.state.multiCourse.active||this.state.multiCourse.platform==='qingshu'&&this.config.qingshuAutoCourseQueue!==false) && !this.isCourseWorker) { this.renderChaoxingCourseProgress(); this.updateActionButtons(); return; } const st = this.state.taskStats || {}; const doneCount = Number(st.completed || 0); const pendingCount = Number(st.pending || 0); const lockedCount = Number(st.lockedChapters || 0); const knownTotal = Math.max(0, Number(st.total || 0)); const totalKnown = knownTotal > 0; const effectiveTotal = st.scanned ? Math.max(knownTotal, doneCount + pendingCount) : 0; const remainingCount = st.scanned ? Math.max(pendingCount, Math.max(0, effectiveTotal - doneCount)) : 0; if (this.els.chaptersTotal) this.els.chaptersTotal.textContent = st.scanned ? String(doneCount) : '0'; if (this.els.tasksTotal) this.els.tasksTotal.textContent = st.scanned ? String(effectiveTotal) : '0'; if (this.els.tasks) this.els.tasks.textContent = st.scanned ? ((lockedCount && !totalKnown) ? `${remainingCount}+` : String(remainingCount)) : '准备中'; if (this.els.progressBar || this.els.progressText) { const total = effectiveTotal; const done = Math.max(0, Math.min(total, total - remainingCount)); const rawPct = st.scanned && total > 0 ? Math.round(done * 100 / total) : 0; const pct = Math.max(0, Math.min(lockedCount ? 99 : 100, rawPct)); if (this.els.progressBar) this.els.progressBar.style.width = `${pct}%`; if (this.els.progressText) this.els.progressText.textContent = st.scanned ? `${done}/${total}${lockedCount ? ` (+${lockedCount} 待解锁)` : ''} / ${pct}%` : '正在准备'; } const courseName = (this.state.current && this.state.current.courseName) || document.title || '\u8bfe\u7a0b'; if (this.els.courseTitle) this.els.courseTitle.textContent = courseName; if (this.els.courseName) this.els.courseName.textContent = courseName; this.updateActionButtons(); }, updateActionButtons() { const running = !!this.state.running; const paused = !!this.state.paused; const qingshuPicker = this.els.chaoxingCoursePicker?.dataset.platform === 'qingshu'; const selection = qingshuPicker ? this.state.qingshuCourseSelection : this.state.chaoxingCourseSelection; const selecting = !!this.els.chaoxingCoursePicker && this.config[qingshuPicker ? 'qingshuAutoCourseQueue' : 'autoCourseQueue'] !== false; const count = selection?.entries?.filter(e => e.selected && !e.unavailable).length || 0; const refreshing=!!(this.qingshuDiscussionRefresh||this.qingshuModeLoading||this.chaoxingCoursesLoading||this.qingshuQueuePrepare||selection?.loading); const scopeValid = !!selection?.ready && selection.scope === (qingshuPicker ? this.qingshuCourseSelectionScope() : this.chaoxingCourseSelectionScope()); const selectionRequired = selecting && (!scopeValid || !count); if (this.els.chaoxingCoursePicker) { for (const input of this.els.chaoxingCoursePicker.querySelectorAll('input[data-course-key]')) input.disabled = running || refreshing || !scopeValid || input.dataset.unavailable === 'true'; for (const button of this.els.chaoxingCoursePicker.querySelectorAll('button[data-course-select-action]')) button.disabled = running || refreshing || (button.dataset.courseSelectAction !== 'refresh' && (!scopeValid || !selection?.entries?.length)); if (selecting && selection?.ready && !scopeValid) { const message = '页面或课程信息已变化,请重新读取课程'; const status = this.els.chaoxingCoursePicker.querySelector('[data-k=selectionCount]'); if (status) status.textContent = message; if (!running && this.els.progressText) this.els.progressText.textContent = message; } } if (this.els.autoCourseQueue) this.els.autoCourseQueue.disabled = running||refreshing; if (this.els.qingshuSelectCoursesButton) this.els.qingshuSelectCoursesButton.disabled = running||refreshing; if (this.els.stopAction) this.els.stopAction.disabled = !running; if (this.els.startAction) { this.els.startAction.textContent = running ? (this.state.stopped ? '停止中...' : paused ? '已暂停' : '学习中...') : selecting ? (refreshing ? '正在读取课程…' : '开始所选课程(' + count + ')') : '开始自动刷课'; this.els.startAction.disabled = running||refreshing||selectionRequired; this.els.startAction.setAttribute('aria-disabled', running||refreshing||selectionRequired ? 'true' : 'false'); } if (this.els.pauseAction) { this.els.pauseAction.textContent = paused ? '继续运行' : '暂停'; this.els.pauseAction.disabled = !running; this.els.pauseAction.setAttribute('aria-disabled', !running ? 'true' : 'false'); } if (this.els.runState) { const text = running ? (this.state.stopped ? '停止中' : paused ? '已暂停' : '学习中') : (this.state.stopped ? '已停止' : '等待开始'); this.els.runState.textContent = text; this.els.runState.className = `run-state ${running ? (paused ? 'cxr-orange' : 'cxr-green') : 'cxr-gray'}`; } this.renderQingshuDiscussion?.(); if (paused) { this.setUserStatus({ headline: '已暂停,点击“继续运行”后会接着学习', tip: '暂停期间不会继续处理课程任务。' }); } this.renderUserStatus(); }, setUserStatus(patch = {}) { const prev = this.state.uiStatus || {}; this.state.uiStatus = { headline: '等待开始', currentChapter: '-', currentTask: '-', etaText: '-', tip: '等待读取课程任务详情。', ...prev, ...patch }; this.renderUserStatus(); }, renderUserStatus() { const s = this.state.uiStatus || {}; if (this.els.userStatus) this.els.userStatus.textContent = s.headline || '等待开始'; if (this.els.currentChapter) this.els.currentChapter.textContent = s.currentChapter || '-'; if (this.els.currentTask) this.els.currentTask.textContent = s.currentTask || '-'; if (this.els.etaText) this.els.etaText.textContent = s.etaText || '-'; if (this.els.userTip) this.els.userTip.textContent = s.tip || '等待读取课程任务详情'; }, startAnnouncementLoop() { const ms = Math.max(60000, Number(this.config.announcementRefreshMs || DEFAULT_CONFIG.announcementRefreshMs || 300000)); if (this.announcementTimer) clearInterval(this.announcementTimer); this.announcementTimer = setInterval(() => { this.refreshAnnouncement().catch(() => {}); }, ms); }, async refreshAnnouncement() { const data = await fetchAnnouncement(this.config.answerApiUrl || DEFAULT_CONFIG.answerApiUrl); this.state.announcement = data; this.renderAnnouncement(data); return data; }, renderAnnouncement(data = this.state.announcement) { const card = this.els.announcementCard; if (!card) return; const ann = data || {}; const content = String(ann.content || '').trim(); if (ann.enabled === false || !content) { card.className = 'card announcement-card'; return; } const rawType = String(ann.level || ann.scriptType || ann.script_type || ann.type || '').toLowerCase(); const typeMap = { update: 'success', maintenance: 'danger', warning: 'warning', info: 'info' }; const level = ['info', 'success', 'warning', 'danger'].includes(rawType) ? rawType : (typeMap[rawType] || 'info'); card.className = `card announcement-card show ${level}`; if (this.els.announcementTitle) this.els.announcementTitle.textContent = ann.title || '公告'; if (this.els.announcementContent) this.els.announcementContent.textContent = content; if (this.els.announcementTag) { const tagMap = { info: '通知', success: '更新', warning: '提醒', danger: '维护' }; this.els.announcementTag.textContent = tagMap[level] || '通知'; } if (this.els.announcementLink) { const href = String(ann.linkUrl || '').trim(); const text = String(ann.linkText || '').trim() || '查看详情'; if (/^https?:\/\//i.test(href)) { this.els.announcementLink.href = href; this.els.announcementLink.textContent = text; this.els.announcementLink.style.display = ''; } else { this.els.announcementLink.removeAttribute('href'); this.els.announcementLink.textContent = ''; this.els.announcementLink.style.display = 'none'; } } }, shortText(text, max = 42) { const s = String(text || '').replace(/\s+/g, ' ').trim(); return s.length > max ? `${s.slice(0, max)}...` : (s || '-'); }, chapterLabel(chapterId) { const chapters = this.state.chapters || []; const explicitId = chapterId != null && String(chapterId) !== ''; const index = Math.max(0, Math.min(chapters.length - 1, Number(this.state.chapterIndex || 0))); const id = explicitId ? String(chapterId) : String(chapters[index] || ''); const entries = (this.state.orderedCatalog && this.state.orderedCatalog.entries) || []; const entry = entries.find(item => String(item.id) === id); const title = (entry && entry.title) || (this.state.chapterTitles || {})[id] || ''; const number = entry && entry.number || ''; return title ? this.shortText([number, title].filter(Boolean).join(' '), 64) : '当前任务点'; }, taskTypeText(jobData = {}) { const type = String(jobData.type || '').toLowerCase(); if (type === 'video') return '视频'; if (type === 'audio') return '音频'; if (type === 'book') return '书籍'; if (type === 'document') return '文档'; if (type === 'hyperlink') return '链接'; if (type === 'read') return '阅读'; if (type === 'live') return '直播'; if (type === 'classtask') return '课堂任务'; if (type === 'workid' || type === 'assignment') return '章节测验'; if (type === 'exam') return '考试'; return '任务'; }, etaFromSeconds(seconds) { const n = Math.max(0, Math.ceil(Number(seconds || 0))); if (!n) return '即将完成'; const doneAt = new Date(Date.now() + n * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (n < 60) return `约 ${n} 秒(${doneAt})`; const min = Math.ceil(n / 60); return `约 ${min} 分钟(${doneAt})`; }, setLearningTask(jobData, context = {}, opts = {}) { const name = this.shortText(opts.name || safeName(jobData), opts.maxName || 42); const typeText = opts.typeText || this.taskTypeText(jobData); const chapter = opts.chapter || (context && context.chapterId ? this.chapterLabel(context.chapterId) : '课程任务'); const etaText = opts.etaText || (opts.etaSeconds != null ? this.etaFromSeconds(opts.etaSeconds) : '正在计算'); this.setUserStatus({ headline: opts.headline || `正在学习${typeText}:${name}`, currentChapter: chapter, currentTask: opts.currentTask || `${typeText}:${name}`, etaText, tip: opts.tip || `任务类型:${typeText};任务名称:${name}` }); }, logLongTaskProgress(key, label, currentSeconds, totalSeconds, force = false) { const store = this.state.longTaskProgressAt || (this.state.longTaskProgressAt = {}); const total = Math.max(0, Math.round(Number(totalSeconds) || 0)); const current = Math.min(total || Infinity, Math.max(0, Math.round(Number(currentSeconds) || 0))); const percent = total ? Math.min(100, Math.floor(current * 100 / total)) : 0; const bucket = Math.floor(percent / 20); const now = Date.now(); const last = store[key]; if (!force && last) { const idle = now - last.at; if (!(bucket !== last.bucket && idle >= 30000) && idle < 300000) return; } store[key] = { bucket, at: now }; const clock = total ? `${formatClockSeconds(current)} / ${formatClockSeconds(total)}` : formatClockSeconds(current); this.log(`${label} ${total ? percent + '%' : ''}(${clock})`.replace(/\s+/g, ' '), 'blue'); }, clearLongTaskProgress(key) { if (this.state.longTaskProgressAt) delete this.state.longTaskProgressAt[key]; }, publicErrorText(e) { const msg=String(e?.message||e||'').split(' body=')[0].replace(/https?:\/\/[^\s"'<>]+/gi,'[链接]'); const rules=[ ['CANCELLED',/^(?:已停止|已取消|请求已取消|AbortError)/,'操作已停止或取消,未继续执行。'], ['PERMISSION',/缺少请求权限|Refused to connect|permission|whitelist/i,'脚本缺少接口访问权限,请按域名授权提示处理后重试。'], ['ORIGIN',/未确认的学校接口地址/,'学校接口地址未通过校验,请反馈当前学校域名。'], ['TOKEN',/绑定.*Token|Token.*(?:无效|失效|验证失败)|MISSING_TOKEN|INVALID_TOKEN/i,'答题 Token 未绑定或无效,请重新绑定有效 Token。'], ['QUOTA',/剩余次数|次数不足|余额不足|INSUFFICIENT_QUOTA|quota/i,'答题剩余次数不足,请充值后继续。'], ['REDIRECT',/课程页发生跳转|登录状态失效|未登录|重新登录|登录页/i,'课程页面跳转或登录失效,请回原课程页重新登录。'], ['CONTEXT',/参数不匹配|缺少参数|缺少 (?:courseId|clazzId|cpi)|参数不完整/,'课程或任务参数缺失/不匹配,请从该课程首页重新进入。'], ['CATALOG',/课程目录.*未读到|未解析到章节|没有可扫描章节/,'页面未读到章节目录,可能是课程入口页或结构变化;请打开章节页后重试。'], ['PENDING',/没有可确认的待办小节/,'没有识别到可处理的小节,尚不能确认整门课程完成。'], ['WORK_SUBMIT',/章节测验提交(?:被拒绝|结果未确认)/,'章节测验提交未获确认,任务仍待处理;请进入原测验页核对后重试。'], ['WORK_REDIRECT',/章节测验跳转/,'章节测验页面跳转未完成,请检查原课程页登录/验证状态及脚本管理器版本;任务仍待处理。'], ['WORK_PAGE',/章节测验页面未就绪/,'章节测验未读到可作答题目,请检查登录状态或稍后重试;任务仍保留待处理。'], ['FONT',/字体数据|安全字体校验/,'字体数据暂不可用,请使用安全连接并联网重试。'], ['POPUP',/弹窗/,'新窗口被拦截,请允许弹窗后重试。'], ['TIMEOUT',/timeout|超时/i,'接口响应超时,请检查网络后重试。'], ['NETWORK',/Request error|Failed to fetch|NetworkError/i,'接口连接失败,请检查网络、登录状态及脚本权限。'] ]; let rule=rules.find(([,pattern])=>pattern.test(msg)); const status=/^HTTP (\d{3})(?::|\s|$)/.exec(msg)?.[1]; if(status)rule=['HTTP_'+status,null,({'401':'接口要求重新登录,请检查课程登录状态。','403':'接口拒绝访问,请检查课程权限或网站限制。','404':'课程接口不存在,当前学校路径可能不兼容。','429':'请求过于频繁,请稍后重试。'})[status]||'服务器响应异常,请稍后重试。']; if(!rule&&['TypeError','ReferenceError','SyntaxError'].includes(e?.name))rule=['SCRIPT',null,'脚本执行异常,请反馈本行和前面的阶段日志。']; return '[E_'+(rule?.[0]||'UNKNOWN')+'] '+(rule?.[2]||'任务异常退出,原因尚未识别;请反馈本行及前面的阶段日志。'); }, sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }, async sleepWithPause(ms) { const end = Date.now() + Math.max(0, ms); while (Date.now() < end && !this.state.stopped) { await this.waitIfPaused(); await this.sleep(Math.min(1000, end - Date.now())); } }, sleepRandom(minMs, maxMs) { const min = Number(minMs == null ? (this.config.requestDelayMinMs || 800) : minMs); const max = Number(maxMs == null ? (this.config.requestDelayMaxMs || 1800) : maxMs); const lo = Math.max(0, Math.min(min, max)); const hi = Math.max(lo, Math.max(min, max)); return this.sleep(Math.floor(lo + Math.random() * (hi - lo + 1))); }, logEvent(event = {}) { if (event.audience !== 'user') return; this.log(String(event.message || ''), event.color || 'gray', { publicDiagnostic: event.actionRequired === true }); }, log(msg, color = 'gray', options = {}) { if (String(msg || '').includes('青书')) { msg = qingshuPublicLogText(msg, this.config && this.config.answerApiToken); if (!msg) return; } if (!(options && options.publicDiagnostic === true) && shouldHideDashboardLog(msg)) { return; } const el = this.els && this.els.log; if (!el) return; const line = document.createElement('div'); line.className = `log-line ${String(color || 'gray').replace(/[^\w-]/g, '')}`; const t = new Date(); const hh = String(t.getHours()).padStart(2, '0'); const mm = String(t.getMinutes()).padStart(2, '0'); const ss = String(t.getSeconds()).padStart(2, '0'); line.textContent = `[${hh}:${mm}:${ss}] ${String(msg || '')}`; el.appendChild(line); while (el.children.length > 220) el.removeChild(el.firstChild); el.scrollTop = el.scrollHeight; }, logErr(e) { this.state.running = false; this.state.paused = false; this.setUserStatus({ headline: '任务处理遇到问题', currentTask: '已暂停处理', etaText: '-', tip: this.publicErrorText(e) }); this.updateActionButtons(); }, async runZhihuishuApiCourse(){this.log('请在视频页开始学习','orange');return false;}, async startZhihuishuCourses(options ={}){return options.scanOnly ? this.scanZhihuishuNativeCourse(): this.startZhihuishuCourse();}, async runZhihuishuNativeCourse(options ={}){if(!this.isZhihuishuCoursePage()){this.log('请先进入原来的视频播放页面,再开始','orange');return false;}const restriction=this.state.zhihuishuRestriction||zhsNativeRestriction(document);if(restriction){this.state.zhihuishuRestriction=restriction;this.state.stopped=true;this.zhihuishuLastError={kind:'restricted',message:restriction};this.setUserStatus({headline:'原站已拦截脚本,停止自动控制',currentTask:restriction,etaText:'已停止'});this.log(restriction,'orange');this.zhsVideoUi?.remove();return false;}if(this.state.running){if(this.state.paused&&!this.state.stopped){this.state.paused=false;this.updateActionButtons();}return false;}if(options.onlyHomework)return false;if(!this.config.doVideo){this.zhsVideoUi?.update({state:'failed',message:'请先开启视频学习开关'});this.setUserStatus({headline:'智慧树视频未启用',currentTask:'请开启视频学习开关',etaText:'未开始'});return false;}this.stopAutoAnswerWatcher?.();this.zhihuishuLastError=null;this.state.running=true;this.state.stopped=false;this.state.paused=false;this.state.multiCourse=null;this.state.current={platform:'zhihuishu',courseName:document.title||'当前课程'};this.updateActionButtons();const initialPage=location.href;const quizAttempts=new Map();let quizTokenReady=false;let ui=null,lastMessage='';const control=()=>({paused:this.state.paused,stopped:this.state.stopped});const action=type=>{if(type==='stop')this.state.stopped=true;else this.state.paused=type==='pause';if(this.state.stopped||this.state.paused){try{zhsNativePlayer(document)?.pause();}catch(_){}}this.updateActionButtons();ui?.update({state:this.state.stopped?'stopped':this.state.paused?'paused':'loading',message:this.state.stopped?'学习已停止':this.state.paused?'学习已暂停':'正在恢复学习…'});if(ui)ui.start.disabled=this.state.paused||this.state.stopped;};const closing=()=>{action('stop');ui?.remove();};window.addEventListener('pagehide',closing);try {document.getElementById('cx-runner-confirm')?.remove();ui=this.mountZhihuishuVideo();ui.action=action;ui.update({state:'loading',message:'正在加载视频…'});const result=await runZhsNativeVideoPage(document,{control,replayEnded:true,autoAdvance:true,sleep:ms=>this.sleep(ms),pageValid:()=>this.isZhihuishuCoursePage()&&location.href===initialPage, answerQuiz:this.config.zhihuishuVideoQuiz===false?null:status=>answerZhsVideoQuiz(document,{control,pageValid:()=>this.isZhihuishuCoursePage()&&location.href===initialPage,sleep:ms=>this.sleep(ms),status, query:async q=>{if(!quizTokenReady){if(!await this.ensureAnswerToken())return {success:false};quizTokenReady=true;}if(this.state.paused||this.state.stopped||location.href!==initialPage||zhsReadVideoQuiz(document)?.key!==q.key)return {success:false};const result=await queryAnswerApi(q.question,q.type,q.options.map(o=>o.label+'. '+o.text),this.config.answerApiUrl||DEFAULT_CONFIG.answerApiUrl,this.config.answerApiToken,{shouldStop:()=>this.state.paused||this.state.stopped||location.href!==initialPage||zhsReadVideoQuiz(document)?.key!==q.key});this.applyAnswerTokenInfo(result);return result;}},quizAttempts), attention:()=>action('pause'), status:status=>{if(status.state==='restricted'){this.state.zhihuishuRestriction=status.message;this.state.stopped=true;this.zhihuishuLastError={kind:'restricted',message:status.message};document.getElementById('cx-runner-quick-start')?.remove();}ui.update(status);this.state.taskStats={platform:'zhihuishu',videoOnly:true,total:status.total,completed:status.completed,pending:Math.max(0,status.total-status.completed),runnablePending:Math.max(0,status.total-status.completed),scanned:status.total>0};this.setUserStatus({headline:status.state==='restricted'?'原站已拦截脚本,停止自动控制':status.state==='blocked'?'视频等待手动处理':status.state==='paused'?'学习已暂停':status.state==='completed'?'本节学习完成':'智慧树视频学习',currentTask:status.currentTitle||'当前课程',etaText:status.duration?Math.floor(status.currentTime||0)+'/'+Math.floor(status.duration)+' 秒':'正在读取',tip:status.message});if(status.message&&status.message!==lastMessage){lastMessage=status.message;this.log(status.message,status.state==='blocked'?'orange':'blue');}this.updateProgress();}});if(this.state.stopped||result.state==='stopped')return false;if(result.state!=='completed')throw new Error(result.message||'当前视频页尚未完成,请检查播放器');this.log(result.message,'green');return {scope:result.scope,playbackEnded:result.playbackEnded,currentFinished:result.currentFinished,videos:result.total,completed:result.completed,pending:result.total-result.completed,homework:0,prepared:0,submitted:0};}catch(error){this.zhihuishuLastError={kind:'native',message:'学习已停止,请检查视频和课程目录'};this.log(this.zhihuishuLastError.message,'orange');ui?.update({state:'failed',message:this.zhihuishuLastError.message});return false;}finally{window.removeEventListener('pagehide',closing);this.state.running=false;this.state.paused=false;if(location.href!==initialPage||this.state.zhihuishuRestriction){ui?.remove();this.mountZhihuishuVideo();}else ui?.update({});this.updateActionButtons();}}, scanZhihuishuNativeCourse(){if(!this.isZhihuishuCoursePage()){this.log('请先进入原来的视频播放页面,再开始','orange');return false;}const rows=zhsNativeCatalog(document),completed=rows.filter(row=>row.finished).length;this.state.taskStats={platform:'zhihuishu',videoOnly:true,total:rows.length,completed,pending:rows.length-completed,runnablePending:rows.length-completed,scanned:rows.length>0};this.updateProgress();return {videos:rows.length,completed,pending:rows.length-completed,homework:0,prepared:0,submitted:0};}, initZhihuishuPage(){if(!IS_TOP_WINDOW)return;this.watchZhihuishuPageWork?.();const refresh=()=>{this.mountZhihuishuPageWork?.();document.getElementById('cx-runner-quick-start')?.remove();this.mountZhihuishuVideo();};refresh();window.addEventListener('popstate',refresh);window.addEventListener('hashchange',refresh);}, mountZhihuishuPageWork(){const exists=document.getElementById('cx-zhs-page-work');if(!zhsWorkPageKind(location)){exists?.disposeDrag?.();exists?.remove();return;}if(exists?.dataset.url===location.href)return;exists?.disposeDrag?.();exists?.remove();const ui=createStudyCard(document,'cx-zhs-page-work',zhsWorkPageKind(location)==='homework'?'智慧树 · 作业答完自动提交':'智慧树 · 考试仅填写,不自动交卷'),box=ui.box;box.dataset.url=location.href;ui.update=(phase,message)=>{ui.phase(phase,message,({waiting:'准备中',running:'进行中',stopped:'已停止',error:'待处理',done:'已完成'})[phase]);ui.start.hidden=!['stopped','error'].includes(phase);ui.start.disabled=!!this.state.zhsPageRestriction;ui.stop.hidden=phase==='done';};ui.update('waiting','题目加载后自动开始,无需点击。');if(this.zhsWorkStarted?.has(location.href))ui.update('stopped','本页面已运行或已停止,不会重复自动开始。');this.zhsPageUi=ui;this.zhsPageSupplied=new Map();this.zhsPageAttempts=new Map();ui.start.onclick=()=>this.startZhihuishuPageWork();ui.stop.onclick=()=>{this.zhsWorkStarted??=new Set();this.zhsWorkStarted.add(location.href);this.state.stopped=true;ui.update('stopped','已停止,不会自动重新开始。');};}, async startZhihuishuPageWork(options={}){if(this.state.running)return false;if(!zhsWorkPageKind(location))return false;this.zhsWorkStarted??=new Set();this.zhsWorkStarted.add(location.href);const restriction=this.state.zhsPageRestriction||zhsNativeRestriction(document);if(restriction){this.state.zhsPageRestriction=restriction;this.state.stopped=true;this.zhsPageUi?.update('error',restriction);return false;}this.mountZhihuishuPageWork();const ui=this.zhsPageUi;if(!ui)return false;const url=location.href,directSubmit=zhsWorkPageKind(location)==='homework'&&options.submitHomework!==false;if(this.zhsPageAttemptUrl!==url){this.zhsPageAttemptUrl=url;this.zhsPageAttempts=new Map();}this.state.running=true;this.state.stopped=false;let tokenReady=false;const stop=()=>{this.state.stopped=true;};window.addEventListener('pagehide',stop);ui.update('running','正在自动读取题目…');try {const result=await runZhsPageWork(document,{directSubmit,stopped:()=>this.state.stopped,sleep:ms=>this.sleep(ms),supplied:this.zhsPageSupplied,attempts:this.zhsPageAttempts, progress:ui.progress,status:message=>{ui.text.textContent=message;}, missing:q=>ui.update('error','第 '+q.number+' 题暂未读到完整题干。请检查原页面后点“继续”,不会猜题或提交。'), query:async q=>{if(!tokenReady){if(!await this.ensureAnswerToken())return {success:false};tokenReady=true;}const changed=()=>{try{return this.state.stopped||location.href!==url||zhsReadPageWork(document,this.zhsPageSupplied)?.key!==q.key;}catch(_){return true;}};if(changed())return {success:false};const r=await queryAnswerApi(q.question,q.type,q.options.map(o=>o.label+'. '+o.text),this.config.answerApiUrl||DEFAULT_CONFIG.answerApiUrl,this.config.answerApiToken,{shouldStop:changed});this.applyAnswerTokenInfo(r);return r;}});if(directSubmit&&result.state==='ready-to-submit'){const submission=await zhsSubmitHomework(document,{readyToSubmit:true,stopped:()=>this.state.stopped,sleep:ms=>this.sleep(ms),status:s=>{ui.text.textContent=s;},authorize:()=>directSubmit});ui.update(submission.state==='submitted'?'done':'error',submission.state==='submitted'?'原站已确认作业提交成功。':submission.message||'提交未确认,请在原站核对。');return submission;}if(result.state!=='missing-text')ui.update(result.state==='saved'?'done':'error',result.state==='saved'?'答案已保存,请在原站核对并手动交卷。':'原站未确认保存,请核对后继续。');return result;}catch(e){const restricted=zhsNativeRestriction(document);if(restricted)this.state.zhsPageRestriction=restricted;ui.update(this.state.stopped?'stopped':'error',this.state.stopped?'已停止,不会自动重新开始。':restricted||e.message);return false;}finally{window.removeEventListener('pagehide',stop);this.state.running=false;}}, watchZhihuishuPageWork(){if(location.hostname!=='onlineexamh5new.zhihuishu.com'||this.zhsWorkObserver)return;let timer=null,closed=false,previous=location.href;this.zhsWorkStarted??=new Set();const inspect=()=>{timer=null;if(closed)return;if(previous!==location.href){if(this.state.running)this.state.stopped=true;previous=location.href;}this.mountZhihuishuPageWork();if(!zhsWorkPageKind(location)||this.zhsWorkStarted.has(location.href)||document.visibilityState!=='visible')return;if(this.state.running){schedule();return;}const rows=[...document.querySelectorAll('.examPaper_subject')].filter(e=>zhsNativeVisible(e));if(rows.length!==1||!rows[0].querySelector('.subject_num span')?.textContent.trim()||!rows[0].querySelector('.subject_type')?.textContent.trim())return;if(!zhsNativeRestriction(document)){try{zhsReadPageWork(document,this.zhsPageSupplied);}catch(e){this.zhsPageUi.text.textContent=e.message;return;}} // One attempt per answer URL/document. Mutations after failure or Stop never restart it. this.zhsWorkStarted.add(location.href);this.startZhihuishuPageWork();};const schedule=()=>{if(!closed&&timer===null)timer=setTimeout(inspect,500);};this.zhsWorkObserver=new MutationObserver(records=>{if(records.some(r=>!r.target.closest?.('#cx-zhs-page-work')&&!r.target.parentElement?.closest('#cx-zhs-page-work')))schedule();});this.zhsWorkObserver.observe(document.documentElement,{childList:true,subtree:true,attributes:true,characterData:true});for(const event of ['hashchange','popstate','visibilitychange'])window.addEventListener(event,schedule);window.addEventListener('pagehide',()=>{closed=true;clearTimeout(timer);this.state.stopped=true;this.zhsWorkObserver.disconnect();this.zhsWorkObserver=null;for(const event of ['hashchange','popstate','visibilitychange'])window.removeEventListener(event,schedule);},{once:true});schedule();}, mountZhihuishuVideo(){let ui=this.zhsVideoUi;if(ui?.box.isConnected&&ui.url===location.href)return ui;if(ui?.box.isConnected){this.state.stopped=true;ui.remove();}if(this.state.running&&ui&&ui.url!==location.href)return;if(!this.isZhihuishuCoursePage()||this.state.zhihuishuRestriction||zhsNativeRestriction(document))return;ui=zhsNativeControls(document,()=>this.state,type=>{if(!this.state.running)this.startZhihuishuCourse();else ui.action?.(type);});ui.url=location.href;const remove=ui.remove;ui.remove=()=>{window.removeEventListener('pagehide',ui.remove);remove();};window.addEventListener('pagehide',ui.remove);if(!this.state.running)ui.update({state:'idle',message:'点击“开始学习”播放当前视频'});return this.zhsVideoUi=ui;}, qingshuCourseSelectionScope() { const ctx=this.dashboardContext||this.state.current||{}; return JSON.stringify([location.href,ctx.orgCode||'',ctx.courseId||'',ctx.teachPlanId||'',ctx.teachPlanCourseId||'',ctx.periodId||'']); }, async prepareQingshuCourses(force=false) { if(this.state.running||this.isCourseWorker||this.qingshuDiscussionRefresh||this.config.qingshuAutoCourseQueue===false)return; if(this.qingshuQueuePrepare)return this.qingshuQueuePrepare; const scope=this.qingshuCourseSelectionScope(),previous=this.state.qingshuCourseSelection; if(!force&&previous?.ready&&previous.scope===scope)return previous; const ctx={...(this.dashboardContext||this.state.current)},selection={scope,loading:true,ready:false,entries:[],error:''}; this.clearCourseQueueResult(); this.state.qingshuCourseSelection=selection; this.setUserStatus({headline:'正在读取青书课程列表',currentTask:'只读取课程,不启动学习',etaText:'请稍候',tip:'读取完成后请勾选需要学习的课程。'}); this.renderChaoxingCourseSelection();this.updateActionButtons(); this.qingshuQueuePrepare=(async()=>{ await Promise.resolve(); let left=false;const leave=()=>{left=true;};window.addEventListener('pagehide',leave); try{ qingshuCourseEntries([],ctx); const courses=await qingshuLoadCourseList({...ctx,siteHost:location.origin,orgCode:location.pathname.split('/')[1]}); if(left||scope!==this.qingshuCourseSelectionScope()||selection!==this.state.qingshuCourseSelection||this.state.running||this.config.qingshuAutoCourseQueue===false){selection.error='页面或机构已变化,请重新读取课程';return selection;} const entries=qingshuCourseEntries(courses,ctx); if(!entries.length)throw Error('未读取到青书账号课程,请检查登录状态'); selection.entries=entries.map(entry=>({...entry,selected:false,unavailable:entry.status==='blocked'})); selection.ready=true;selection.phase='ready'; this.setUserStatus({headline:'青书课程列表已读取,请勾选课程',currentTask:'等待选择',etaText:'-',tip:'默认不选择任何课程,点击开始后只处理勾选项。'}); this.log('青书已读取 '+entries.length+' 门课程,请勾选需要学习的课程;尚未开始学习。','green'); }catch(error){ selection.error=qingshuUserErrorText(error?.message||error,this.config.answerApiToken); this.log('青书课程列表读取失败:'+selection.error+';未开始任何课程,请重新读取。','orange',{publicDiagnostic:true}); }finally{ window.removeEventListener('pagehide',leave);selection.loading=false;this.qingshuQueuePrepare=null; this.renderChaoxingCourseSelection();this.updateActionButtons(); } return selection; })(); return this.qingshuQueuePrepare; }, async startQingshuCourses() { if(this.state.running||this.qingshuDiscussionRefresh||this.qingshuModeLoading||this.config.qingshuAutoCourseQueue===false)return; if(!this.isQingshuCoursePage())throw Error('请先打开青书课程页'); const selection=this.state.qingshuCourseSelection,scope=this.qingshuCourseSelectionScope(); if(!selection?.ready||selection.scope!==scope){await this.prepareQingshuCourses(true);return this.state.qingshuCourseSelection;} const entries=selection.entries.filter(entry=>entry.selected&&!entry.unavailable).map(entry=>({key:entry.key,ctx:{...entry.ctx},status:'queued',error:'',worker:null})); if(!entries.length){this.log('请至少勾选一门需要学习的青书课程,再点击开始。','orange');this.renderChaoxingCourseSelection();this.updateActionButtons();return selection;} const base={...(this.dashboardContext||this.state.current)},run={id:Date.now(),platform:'qingshu',active:true,stopped:false,paused:false,entries,workGate:Promise.resolve()}; this.state.multiCourse=run;this.state.running=true;this.state.stopped=false;this.state.paused=false;this.updateActionButtons(); const leave=()=>{run.stopped=true;run.paused=false;selection.ready=false;},changed=()=>{if(this.qingshuCourseSelectionScope()!==scope)leave();}; window.addEventListener('pagehide',leave);window.addEventListener('popstate',changed);window.addEventListener('hashchange',changed); try{ if(this.qingshuPreparePromise)await this.qingshuPreparePromise.catch(()=>{}); if(run.stopped)return run; qingshuCourseEntries([],base); changed();if(run.stopped)return run; this.log('青书本轮已选择 '+run.entries.length+' 门课程,只处理已勾选课程,逐门顺序处理。','green');this.renderChaoxingCourseProgress(); for(const entry of run.entries){ await this.waitIfPaused();changed();if(run.stopped)break; if(entry.status==='blocked')continue; entry.status='running'; let worker;try{ worker=this.createChaoxingCourseWorker(entry,run);entry.worker=worker; const wait=worker.waitIfPaused;worker.waitIfPaused=async()=>{await wait.call(worker);changed();}; worker.ensureAnswerToken=async()=>{await worker.waitIfPaused();if(worker.state.stopped)return false;const ok=await this.ensureAnswerToken();return ok&&!worker.state.stopped;}; worker.sleepWithPause=async ms=>{let left=ms;while(left>0&&!worker.state.stopped){await worker.waitIfPaused();if(worker.state.stopped)break;const step=Math.min(left,1000),before=Date.now();await worker.sleep(step);changed();if(!worker.state.paused)left-=Math.min(step,Math.max(1,Date.now()-before));}}; this.renderChaoxingCourseProgress(); await worker.startQingshuCourse();changed(); const stats=worker.state.taskStats;entry.status=run.stopped||worker.state.stopped?'stopped':stats?.scanned&&stats.pending===0&&stats.completed>=stats.total?'completed':'blocked'; if(worker.state.stopped)run.stopped=true; }catch(error){ entry.error=qingshuUserErrorText(error?.message||error,this.config.answerApiToken);entry.status=run.stopped?'stopped':'failed'; if(worker?.state.stopped||qingshuIsFatalTaskError(error)||/验证码|人脸|身份验证|次数不足|Token|QUOTA|MISSING_TOKEN|页面已变化|机构信息不匹配/i.test(String(error?.message||error).replace(/https?:\/\/[^\s]+/g,''))){run.stopped=true;run.attentionRequired=true;} this.log('【'+entry.ctx.courseName+'】青书未完成:'+entry.error,'orange',{publicDiagnostic:true}); }finally{if(worker)worker.state.running=false;this.renderChaoxingCourseProgress();} } }catch(error){run.error='青书课程队列:'+qingshuUserErrorText(error?.message||error,this.config.answerApiToken);this.log(run.error,'orange',{publicDiagnostic:true});} finally{ window.removeEventListener('pagehide',leave);window.removeEventListener('popstate',changed);window.removeEventListener('hashchange',changed); for(const e of run.entries)if(e.status==='queued')e.status='stopped'; run.active=false;this.state.running=false;this.state.paused=false;this.state.stopped=run.stopped; this.dashboardContext=base;this.state.current={...base}; this.log('青书课程队列本轮结束:已完成 '+run.entries.filter(e=>e.status==='completed').length+'/'+run.entries.length+' 门',run.entries.length&&run.entries.every(e=>e.status==='completed')?'green':'orange');this.renderChaoxingCourseProgress();this.updateActionButtons(); } return run; }, renderQingshuDiscussion() { if(this.isCourseWorker||!this.els?.startAction)return; const doc=this.els.startAction.ownerDocument;doc.querySelector('#cx-qingshu-discussion')?.remove(); }, async refreshQingshuDiscussion(entry) { if(this.state.running||this.qingshuDiscussionRefresh||this.qingshuModeLoading||this.qingshuQueuePrepare)return false; const run=this.state.multiCourse,target=entry?.worker||this,q=target.state.qingshu; if(!q?.ctx||entry&&(!run?.entries.includes(entry)||!['blocked','completed'].includes(entry.status)))return false; const href=location.href;let left=false;const leave=()=>{left=true;}; const current=()=>!left&&location.href===href&&target.state.qingshu===q&&(!entry||this.state.multiCourse===run); this.qingshuDiscussionRefresh=true;window.addEventListener('pagehide',leave);this.updateActionButtons();if(entry)this.renderChaoxingCourseProgress(); try{ qingshuDiscussionUrl(q.ctx); const [scoreDetail,progress,assignments]=await Promise.all([qingshuLoadScoreDetail(q.ctx),qingshuLoadStudyProgress(q.ctx),qingshuLoadAssignments(q.ctx)]); if(!current())return false; target.state.qingshu={...q,scoreDetail,progress,assignments,coursePassed:progress.coursePassed===true,discussionRefreshError:false};target.updateQingshuStats(); target.state.uiStatus={...target.state.uiStatus,currentTask:'已重新核对,剩余 '+target.state.taskStats.pending+' 项'}; if(entry){const st=target.state.taskStats;entry.status=st.scanned&&st.pending===0&&st.completed>=st.total?'completed':'blocked';} this.log('【'+q.ctx.courseName+'】青书'+qingshuDiscussionLabel(scoreDetail.discussion),'blue');return true; }catch(_){ if(current()){q.scoreDetail={...q.scoreDetail,discussion:{known:false,required:true,complete:false,score:null,target:null}};q.discussionRefreshError=true;target.updateQingshuStats();if(entry)entry.status='blocked';this.log('青书讨论状态刷新失败,请检查登录后重试。','orange');}return false; }finally{window.removeEventListener('pagehide',leave);this.qingshuDiscussionRefresh=false;this.updateActionButtons();if(entry&&this.state.multiCourse===run)this.renderChaoxingCourseProgress();} }, async runQingshuDiscussion(q) { try{await qingshuAutoComment(this,q);} catch(error){if(qingshuIsFatalTaskError(error))throw error;if(error.name!=='AbortError')this.log('青书自动评论未完成:'+qingshuUserErrorText(error),'orange');} } }; function runnerMirrorHosts() { return ["mooc1.wljx.hfut.edu.cn","mooc1.hnust.edu.cn","stat2-ans.hnust.edu.cn","stat2-ans.istudy.szpu.edu.cn","mooc1.hnsyu.net","passport2.xust.edu.cn","stat2-ans.hnsyu.net","passport2.hnsyu.net","mooc1.gdhkmooc.com","stat2-ans.gdhkmooc.com","mooc1.zut.edu.cn","passport2.hncj.edu.cn","stat2-ans.zut.edu.cn","passport2.jxjyzx.xust.edu.cn","passport2.zut.edu.cn","stat2-ans.wljx.hfut.edu.cn","passport2.wljx.hfut.edu.cn","mooc1.hncj.edu.cn","stat2-ans.hncj.edu.cn","mooc1.qutjxjy.cn","stat2-ans.qutjxjy.cn","passport2.hnust.edu.cn","passport2.cqrspx.cn","passport2.gdhkmooc.com","passport2.qutjxjy.cn","mooc1.jxjyzx.xust.edu.cn","passport2.hnvist.cn","passport2.cugbonline.cn","stat2-ans.jxjyzx.xust.edu.cn","passport2.ynny.cn","passport2.ecnusole.com","mooc1.cqrspx.cn","stat2-ans.cqrspx.cn","mooc1.ynny.cn","stat2-ans.ynny.cn","mooc1.cugbonline.cn","stat2-ans.cugbonline.cn","mooc1.xust.edu.cn","stat2-ans.xust.edu.cn","mooc1.xynu.edu.cn","mooc1.ntu.edu.cn","stat2-ans.xynu.edu.cn","stat2-ans.ntu.edu.cn","mooc1.hnvist.cn","stat2-ans.hnvist.cn","passport2.xynu.edu.cn","passport2.ntu.edu.cn","mooc1.ecnusole.com","stat2-ans.ecnusole.com","mooc.s.ecust.edu.cn","webvpn.ahmu.edu.cn"]; } function isSupportedRunnerLocation(href) { try { const u = new URL(href); if (!/^https?:$/.test(u.protocol)||u.username||u.password||u.port) return false; if(activateMirrorPage.origins?.has(u.origin))return true; return !!activateMirrorPage.platform&&runnerPlatform(u.hostname)===activateMirrorPage.platform&&!u.port; } catch (_) { return false; } } function trustedParentOrigin(referrer) { if (!isSupportedRunnerLocation(referrer)&&!(activateMirrorPage.owner&&isMirrorCandidate(referrer)&&mirrorRelatedOrigin(new URL(referrer).origin,activateMirrorPage.owner))) return ''; try { return new URL(referrer).origin; } catch (_) { return ''; } } function readTrustedFrameContext(event, ownerWindow, ownerDocument, active = {}) { if (!event || !event.source || event.source === ownerWindow || !isSupportedRunnerLocation(event.origin)) return null; const data = event.data; if (!data || data.type !== 'cx-runner-frame-context' || !data.ctx || typeof data.ctx !== 'object' || Array.isArray(data.ctx)) return null; const frames = Array.from(ownerDocument.querySelectorAll('iframe[src]')); if (!frames.some(frame => { try { return frame.contentWindow === event.source && new URL(frame.src, ownerDocument.baseURI).origin === event.origin; } catch (_) { return false; } })) return null; const input = data.ctx; if (typeof input.href !== 'string' || input.href.length > 8192) return null; let href; try { href = new URL(input.href); } catch (_) { return null; } if (href.origin !== event.origin || !isSupportedRunnerLocation(href.href)) return null; const ctx = { href: href.href, origin: href.origin, host: href.host }; for (const key of ['courseId','clazzId','cpi','chapterId','enc','openc','oldEnc']) { const value = input[key]; if (value == null || value === '') continue; if (typeof value !== 'string' || value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) return null; if (['courseId','clazzId','cpi'].includes(key) && active[key] && String(active[key]) !== value) return null; ctx[key] = value; } if (typeof input.title === 'string') ctx.title = input.title.slice(0,200); if (input.oldSiteHost) { let old; try { old = new URL(input.oldSiteHost); } catch (_) { return null; } if (!isSupportedRunnerLocation(old.href) || !(/(^|\.)(chaoxing\.com|xueyinonline\.com)$/i.test(old.hostname)||activateMirrorPage.origins?.has(old.origin))) return null; if (old.username || old.password || old.pathname !== '/' || old.search || old.hash) return null; ctx.oldSiteHost = old.origin; } return ctx; } function shouldHideDashboardLog(msg) { const s = String(msg || ''); if (!s) return true; if (/https?:\/\//i.test(s)) return true; if (/\bHTTP\s+\d{3}\b|body=\d{3}|api\/work|workHandle\/handle/i.test(s)) return true; if (/work page|work task:|work skip:|work already|work answer|work submit|submitData|submit response|submit by protocol|questions parsed|parse diag|encrypted font|字体加密|chars\b/i.test(s)) return true; if (/video start:|video wait:|video packet|video passed:|video upload finished|playingTime=|isdrag=|isPassed=|packets=|step=|speed=|originalAttDuration/i.test(s)) return true; if (/请求章节列表|章节接口|章节接口失败|selected api base|Cards 调试|frame ctx|diag |mArg|scan\s+\d+\/\d+|scan failed|fast scan|course tasks:/i.test(s)) return true; if (/直播进入上报|直播页面解析|直播状态校正|直播进度|直播回看时长获取失败|课堂回看上报|课堂回看校验/i.test(s)) return true; if (/文档返回:HTTP|阅读返回:HTTP|readId=|liveId=|uuid=|percent=|duration=|target=|lastWatchTime=/i.test(s)) return true; if (/处理章节:\d+|章节\s+\d+\s+任务数|章节\s+\d+\s+无待处理任务/i.test(s)) return true; if (/Runner 初始化完成|独立页面已启动|配置更新:|Token 已复制/i.test(s)) return true; // 课程目录解析与闯关调度属于内部诊断,用户看不懂也不需要看: // 这些信息在“任务控制”卡片里已经有对应的进度/剩余任务点展示。 if (/\[目录诊断\]|闯关课程|扫描到未解锁小节|任务点识别完成|已定位剩余任务点所在小节|下一轮待处理小节|轮处理完毕|小节解锁后新增/.test(s)) return true; return false; } function isChaoxingCourseHost(hostname) { const host = String(hostname || '').toLowerCase(); return /(^|\.)(chaoxing\.com|xueyinonline\.com)$/i.test(host)||[...(activateMirrorPage.origins||[])].some(o=>new URL(o).hostname===host); } function chaoxingCourseBase() { return activateMirrorPage.owner?activateMirrorPage.owner+detectAppPrefix(location.href):'https://mooc2-ans.chaoxing.com/mooc2-ans'; } function chaoxingCoursePageUrl(ctx = {}) { for (const key of ['courseId','clazzId','cpi']) if (!ctx[key]) throw new Error('缺少 ' + key + ',请从新版课程页重新进入'); if(activateMirrorPage.owner){ const current=new URL(location.href),q=new URLSearchParams(current.search.toLowerCase()); if(current.origin===activateMirrorPage.owner&&/\/mycourse\/(stu|studentcourse)$/.test(current.pathname)&&q.get('courseid')===String(ctx.courseId)&&(q.get('clazzid')||q.get('classid'))===String(ctx.clazzId)&&q.get('cpi')===String(ctx.cpi)){ current.hash='';current.searchParams.delete('cxRunnerDashboard');return current.href; } } const url = new URL(chaoxingCourseBase() + '/mycourse/studentcourse'); url.search = new URLSearchParams({ courseid: String(ctx.courseId), clazzid: String(ctx.clazzId), cpi: String(ctx.cpi), ut: 's' }).toString(); return url.href; } function chaoxingCatalogFrameUrl(html, pageUrl, ctx) { const page = new URL(pageUrl); if (!/\/mycourse\/stu$/.test(page.pathname)) return ''; const doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); const frames = [...doc.querySelectorAll('iframe[src]')].filter(frame => { if (frame.id === 'frame_content-zj') return true; try { return /\/mycourse\/studentcourse$/.test(new URL(frame.getAttribute('src'), page).pathname); } catch (_) { return false; } }); const menus = [...doc.querySelectorAll('[dataname="zj"] a[data-url]')]; if (!frames.length && !menus.length) return ''; if (!frames.length && menus.length !== 1) throw Error('课程目录导航入口不唯一,已停止读取'); if (frames.length > 1) throw Error('课程目录 iframe 入口不唯一,已停止读取'); const target = new URL(frames.length ? frames[0].getAttribute('src') : menus[0].getAttribute('data-url'), page); if (target.origin !== page.origin || target.username || target.password || target.hash || target.pathname !== page.pathname.replace(/\/stu$/, '/studentcourse')) throw Error('课程目录 iframe 地址或参数不匹配,已停止读取'); if (!frames.length) { if (target.search) throw Error('课程目录导航参数不匹配,已停止读取'); const input = id => { const values = [...doc.querySelectorAll('input[id="'+id+'"]')].map(el => el.getAttribute('value') || ''); if (!values.length || !values[0] || values.some(v => v !== values[0])) throw Error('课程目录导航参数不匹配,已停止读取'); return values[0]; }; for (const [name,key] of [['courseid','courseId'],['clazzid','clazzId'],['cpi','cpi']]) { const value = input(name); if (value !== String(ctx[key])) throw Error('课程目录导航参数不匹配,已停止读取'); target.searchParams.set(name, value); } target.searchParams.set('ut','s'); target.searchParams.set('t',input('t')); target.searchParams.set('stuenc',input('enc')); } for (const [name,key] of [['courseid','courseId'],['clazzid','clazzId'],['cpi','cpi']]) { const values = [...target.searchParams].filter(([k]) => k.toLowerCase() === name).map(([,v]) => v); if (values.length !== 1 || values[0] !== String(ctx[key])) throw Error('课程目录 iframe 参数不匹配,已停止读取'); } return target.href; } function assertChaoxingCourseResponse(response, requestedUrl, ctx) { const target = new URL(response.finalUrl || requestedUrl); const expected = new URL(requestedUrl); if (target.origin !== expected.origin || target.pathname !== expected.pathname) throw new Error('新版课程页发生跳转,请检查登录状态;不会切换到旧版页面'); for (const [param, key] of [['courseid','courseId'],['clazzid','clazzId'],['cpi','cpi']]) { const value = target.searchParams.get(param); if (value != null && value !== String(ctx[key])) throw new Error('新版课程页参数不匹配,已停止读取'); } } function chaoxingCourseKey(ctx = {}) { return [ctx.courseId || '', ctx.clazzId || '', ctx.cpi || ''].map(String).join(':'); } function chaoxingCourseContext(course = {}, base = {}) { const fallback = getOldStudyHost(base); let origin = fallback; try { const link = new URL(course.courseLink || course.href || '', fallback); const known = new Set([base.siteHost, base.oldSiteHost, ...(base.siteHosts || [])].filter(Boolean).map(value => new URL(value).origin)); if (/^https?:$/.test(link.protocol) && !link.username && !link.password && (/(^|\.)chaoxing\.com$/i.test(link.hostname) || known.has(link.origin))) { origin = link.origin; } } catch (_) {} const siteHost = chaoxingCourseBase(); const study = new URL(stripAppPrefix(activateMirrorPage.owner?fallback:origin)); if (!activateMirrorPage.owner&&/^mooc2-ans\./i.test(study.hostname)) study.hostname = study.hostname.replace(/^mooc2-ans\./i, 'mooc1.'); return { platform: 'chaoxing', courseId: String(course.courseId || ''), clazzId: String(course.clazzId || ''), cpi: String(course.cpi || ''), courseName: String(course.courseName || course.name || '课程 ' + course.courseId), uid: String(base.uid || ''), fid: String(base.fid || ''), siteHost, siteHosts: [siteHost], oldSiteHost: study.origin, href: siteHost + '/mycourse/studentcourse?courseid=' + encodeURIComponent(course.courseId || '') + '&clazzid=' + encodeURIComponent(course.clazzId || '') + '&cpi=' + encodeURIComponent(course.cpi || '') }; } function parseChaoxingAccountCourses(html, base = {}) { const doc = new DOMParser().parseFromString(String(html || ''), 'text/html'), courses = [], seen = new Map(); for (const card of doc.querySelectorAll('.course.learnCourse')) { const value = name => String((card.querySelector('input.' + name) || {}).value || '').trim(); const courseId = value('courseId'), clazzId = value('clazzId'), cpi = value('curPersonId'); if (![courseId, clazzId, cpi].every(id => /^\d+$/.test(id))) continue; const name = card.querySelector('.course-name'); const link = card.querySelector('a.color1[href]'); const unavailable = card.querySelector('.not-open-tip'); const ended = !!unavailable && /课程已结束|未开课|尚未开课|暂未开放/.test(unavailable.textContent || ''); const ctx = chaoxingCourseContext({ courseId, clazzId, cpi, courseName: name && (name.getAttribute('title') || name.textContent), courseLink: link && link.getAttribute('href') }, base); const key = chaoxingCourseKey(ctx); if (seen.has(key)) { if (ended) seen.get(key).unavailable = true; continue; } const course = { ...ctx, unavailable: ended }; seen.set(key, course); courses.push(course); } return courses; } async function loadChaoxingAccountCourses(base = {}) { if (!isChaoxingCourseHost(location.hostname)) throw new Error('当前域名未启用账号课程读取'); const url = chaoxingCourseBase()+'/visit/courselistdata'; const result = await request({ method: 'POST', url, timeout: 30000, withCredentials: true, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', Referer: chaoxingCourseBase()+'/visit/interaction' }, data: new URLSearchParams({ courseType: '1', courseFolderId: '0', query: '', pageHeader: '-1', single: '0', superstarClass: '0' }).toString() }); const courses = parseChaoxingAccountCourses(result.responseText, base); if (!courses.length) throw new Error('未读取到账号课程列表,请检查登录状态'); return courses; } function parseChaoxingTaskProgress(payload, ctx = {}) { if (!payload || payload.status !== true || !Array.isArray(payload.jobArray)) return null; const matches = payload.jobArray.filter(row => String(row.clazzId) === String(ctx.clazzId)); if (matches.length !== 1) return null; const row = matches[0]; if (row.jobCount == null || row.jobFinishCount == null) return null; const total = Number(row.jobCount), done = Number(row.jobFinishCount); if (!Number.isSafeInteger(total) || !Number.isSafeInteger(done) || total <= 0 || done < 0 || done > total) return null; return { total, done, pending: total - done, complete: done === total, source: 'stu-job-info', courseId: String(ctx.courseId), clazzId: String(ctx.clazzId), cpi: String(ctx.cpi), checkedAt: Date.now() }; } async function loadChaoxingTaskProgress(ctx = {}) { if (!ctx.clazzId || !ctx.cpi || !isChaoxingCourseHost(location.hostname)) return null; const url = chaoxingCourseBase()+'/mycourse/stu-job-info?clazzPersonStr=' + encodeURIComponent(ctx.clazzId + '_' + ctx.cpi); const response = await request({ url, timeout: 20000, withCredentials: true, headers: { 'X-Requested-With': 'XMLHttpRequest', Referer: chaoxingCourseBase()+'/visit/interaction' } }); try { return parseChaoxingTaskProgress(JSON.parse(response.responseText || '{}'), ctx); } catch (_) { return null; } } function freshChaoxingCourseState(ctx = {}) { return { running: false, paused: false, stopped: false, current: { ...ctx }, chapters: [], pendingChapters: [], blockedChapters: {}, lockedChapterInfo: { lockedCount: 0, lockedLabels: [], knownJobCount: 0, hasCatalog: false }, chapterTitles: {}, courseTasks: [], courseTaskSummary: null, chapterIndex: 0, jobDone: 0, jobTotal: 0, taskStats: { total: 0, completed: 0, pending: 0, runnablePending: 0, scanned: false, doneKeys: {}, lockedChapters: 0, lockedLabels: [] }, tokenInfo: {}, autoAnswer: {}, uiStatus: { headline: '等待开始', currentChapter: '-', currentTask: '-', etaText: '-' } }; } function loadConfig() { try { const saved = JSON.parse(GM_getValue(STORE_KEY, '{}')); delete saved.zhihuishuAutoSubmit; // Homework/exams are disabled for the native-video phase. delete saved.zhihuishuCourse; // Legacy manual course binding is no longer used. delete saved.answerApiUrl; // 不在用户侧配置/展示接口地址 if (typeof saved.autoCourseQueue !== 'boolean') saved.autoCourseQueue = saved.parallelCourses !== false; if(typeof saved.qingshuAutoCourseQueue!=='boolean')saved.qingshuAutoCourseQueue=true; delete saved.parallelCourses; delete saved.courseConcurrency; return { ...DEFAULT_CONFIG, ...saved }; } catch (_) { return { ...DEFAULT_CONFIG }; } } function saveConfig(config) { const copy = { ...config }; delete copy.zhihuishuAutoSubmit; delete copy.zhihuishuCourse; delete copy.answerApiUrl; delete copy.parallelCourses; delete copy.courseConcurrency; GM_setValue(STORE_KEY, JSON.stringify(copy)); } function ensureVideoCookies() { const safeSet = (k, v) => { try { document.cookie = `${k}=${v == null ? '' : String(v)}; path=/`; } catch (_) {} }; safeSet('videojs_id', Math.floor(Math.random() * 0x98967F)); safeSet('source', ''); safeSet('writenote', 'yes'); safeSet('spaceRoleId', '3'); safeSet('tl', '1'); } function buildVideoCookie() { ensureVideoCookies(); const parts = []; const seen = new Set(); const add = (k, v) => { if (!k || seen.has(k)) return; seen.add(k); parts.push(`${k}=${v == null ? '' : String(v)}`); }; for (const part of String(document.cookie || '').split(';')) { const p = part.trim(); if (!p) continue; const idx = p.indexOf('='); add(idx >= 0 ? p.slice(0, idx) : p, idx >= 0 ? p.slice(idx + 1) : ''); } return parts.join('; '); } const videoFrames = new Map(); let videoFrameSequence = 0; function resetVideoFrame(scope) { for (const [key, state] of videoFrames) { if (scope != null && state.scope !== String(scope)) continue; try { state.frame.remove(); } catch (_) {} videoFrames.delete(key); } } function getVideoFrame(mediaHost, mediaType = 'video', scope = 'default') { const src = videoIndexUrl(mediaHost, mediaType), scopeKey = String(scope || 'default');assertMirrorRequest(src); const key = JSON.stringify([scopeKey, src]), existing = videoFrames.get(key); if (existing && existing.frame && existing.frame.isConnected && existing.frame.contentWindow) return existing.ready.then(() => existing.frame); const frame = document.createElement('iframe'); frame.id = 'cx-runner-video-frame-' + (++videoFrameSequence); frame.src = src; frame.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;border:0;opacity:0;pointer-events:none;'; const ready = new Promise(resolve => { let done = false; const finish = () => { if (done) return; done = true; setTimeout(resolve, 200); }; frame.onload = finish; setTimeout(finish, 2000); }); document.documentElement.appendChild(frame); videoFrames.set(key, { src, scope: scopeKey, frame, ready }); return ready.then(() => frame); } function videoIndexUrl(mediaHost, mediaType = 'video') { const host = String(mediaHost || location.origin).replace(/\/$/, ''); if (String(mediaType || '').toLowerCase() === 'audio') { return `${host}/ananas/modules/audio/index_new.html?v=2024-1107-1820`; } return `${host}/ananas/modules/video/index.html?v=2025-1226-1913`; } async function videoRequest(url, mediaHost, headers, opts = {}) { assertMirrorRequest(url);assertMirrorRequest(mediaHost||location.origin); ensureVideoCookies(); const target = new URL(url, location.href); const host = String(mediaHost || location.origin).replace(/\/$/, ''); const mediaType = String(opts.mediaType || 'video').toLowerCase() === 'audio' ? 'audio' : 'video'; const indexUrl = videoIndexUrl(host, mediaType); const sameVideoOrigin = target.origin === new URL(host, location.href).origin; const isMultimediaLog = /\/multimedia\/log\//i.test(target.pathname || ''); const errors = []; if (sameVideoOrigin) { try { const frame = await getVideoFrame(host, mediaType, opts.frameScope || 'default'); const fw = frame.contentWindow; if (!fw || !fw.fetch) throw new Error(`${mediaType} iframe unavailable`); const res = await fw.fetch(url, { method: 'GET', credentials: 'include',...((activateMirrorPage.owner||activateMirrorPage.platform)?{redirect:'error'}:{}), cache: 'no-store', referrer: indexUrl, referrerPolicy: 'strict-origin-when-cross-origin', headers: headers || {} }); const text = await res.text(); const response = { status: res.status, statusText: res.statusText, responseText: text, finalUrl: res.url, responseHeaders: '', requestMode: 'iframe-fetch' }; if (res.status >= 200 && res.status < 400) return response; if (isMultimediaLog && res.status === 403) return response; throw new Error(`iframe-fetch HTTP ${res.status}: body=${compactResponseText(text)}`); } catch (e) { errors.push(e && e.message || String(e)); } try { const fetcher = ($w && $w.fetch) ? $w.fetch.bind($w) : fetch.bind(window); const res = await fetcher(url, { method: 'GET', credentials: 'include',...((activateMirrorPage.owner||activateMirrorPage.platform)?{redirect:'error'}:{}), cache: 'no-store', referrer: indexUrl, referrerPolicy: 'strict-origin-when-cross-origin', headers: headers || {} }); const text = await res.text(); const response = { status: res.status, statusText: res.statusText, responseText: text, finalUrl: res.url, responseHeaders: '', requestMode: 'page-fetch' }; if (res.status >= 200 && res.status < 400) return response; if (isMultimediaLog && res.status === 403) return response; throw new Error(`page-fetch HTTP ${res.status}: body=${compactResponseText(text)}`); } catch (e) { errors.push(e && e.message || String(e)); } } const gmHeaders = { ...(headers || {}), Referer: indexUrl }; try { const res = await request({ url, headers: gmHeaders, withCredentials: true, timeout: 30000, allowStatuses: isMultimediaLog ? [403] : [] }); res.requestMode = 'gm-credentials'; return res; } catch (e) { errors.push(e && e.message || String(e)); } try { const res = await request({ url, headers: gmHeaders, cookie: buildVideoCookie(), withCredentials: true, timeout: 30000, allowStatuses: isMultimediaLog ? [403] : [] }); res.requestMode = 'gm-cookie'; return res; } catch (e) { errors.push(e && e.message || String(e)); } throw new Error(errors.join(' | ')); } function compactResponseText(text, max = 500) { const raw = String(text || ''); try { const doc = new DOMParser().parseFromString(raw, 'text/html'); doc.querySelectorAll('script, style, noscript').forEach(el => el.remove()); const title = (doc.querySelector('title') && doc.querySelector('title').textContent || '').trim(); const body = (doc.body && doc.body.textContent || '').replace(/\s+/g, ' ').trim(); const combined = [title, body].filter(Boolean).join(' | '); if (combined) return combined.slice(0, max); } catch (_) {} return raw .replace(//gi, ' ') .replace(//gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/window\.__CF\$cv\$params[\s\S]*/i, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, max); } function cleanAnswerError(err) { const raw = String(err || '').replace(/https?:\/\/[^\s"'<>]+/gi, '[接口地址]').trim(); if (!raw) return ''; const http = raw.match(/^HTTP\s*(\d{3})\b/i)?.[1]; if (http === '402' || /^(?:INSUFFICIENT_QUOTA)$|"(?:code|error)"\s*:\s*"INSUFFICIENT_QUOTA"/i.test(raw)) return '答题次数不足'; if (http === '429' || /^(?:RATE_LIMIT(?:_EXCEEDED)?|CONCURRENCY_LIMIT_EXCEEDED)$|"(?:code|error)"\s*:\s*"(?:RATE_LIMIT(?:_EXCEEDED)?|CONCURRENCY_LIMIT_EXCEEDED)"|请求过于频繁/i.test(raw)) return '请求过于频繁'; if (/timeout|超时/i.test(raw)) return '请求超时'; if (/Cannot (?:POST|GET)/i.test(raw)) return '接口未部署(路由404)'; if (/__CF\$cv\$params|challenge-platform|cdn-cgi/i.test(raw)) return '请求被拦截(Cloudflare)'; if (/INVALID_AI_ANSWER|(?:\bAI\b|人工智能|模型|DeepSeek).*格式/i.test(raw)) return '返回的答案格式不符合要求'; if (http) return '接口请求失败(HTTP ' + http + ')'; if (/missing token/i.test(raw)) return '未绑定 Token'; if (raw === 'INVALID_ANSWER_RESPONSE') return '答题接口返回格式异常'; if (raw === 'EMPTY_ANSWER') return '答题接口未返回有效答案'; if (/^(?:stopped|请求已取消)$/i.test(raw)) return '页面已变化或已停止,本次结果未采用'; if (/^Request error:/i.test(raw)) return '网络请求失败,请检查连接'; if (/\bAI\b|人工智能|模型|DeepSeek|(?:^|_)AI(?:_|$)/i.test(raw)) return '答题服务返回异常'; return raw.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [已隐藏]') .replace(/\b(token|userToken|password|enc)["']?\s*[:=]\s*["']?[^\s,;"'}]+/gi, '$1=[已隐藏]').split(/\r?\n/)[0].slice(0, 80); } function request(options) { return new Promise((resolve, reject) => { const url = options.url; const signal = options.signal; let handle, settled = false; const finish = (fn, value) => { if (settled) return; settled = true; if (signal) signal.removeEventListener('abort', abort); fn(value); }; const abort = () => { const error = new Error('请求已取消'); error.name = 'AbortError'; finish(reject, error); if (handle) handle.abort(); }; if (signal && signal.aborted) { abort(); return; } if (signal) signal.addEventListener('abort', abort, { once: true }); try { assertMirrorRequest(url); handle = GM_xmlhttpRequest({ ...(options.redirect === 'manual' ? { redirect: 'manual' } : (activateMirrorPage.owner||activateMirrorPage.platform)?{redirect:'error'}:{}), method: options.method || 'GET', url, data: options.data, headers: options.headers || {}, cookie: options.cookie, anonymous: false, withCredentials: options.withCredentials !== false, timeout: options.timeout || 20000, responseType: options.responseType || 'text', onload: res => { try{assertMirrorRequest(res.finalUrl||url);}catch(e){finish(reject,e);return;} const allowStatuses = Array.isArray(options.allowStatuses) ? options.allowStatuses.map(Number) : []; if (res.status >= 200 && res.status < 400 || allowStatuses.includes(Number(res.status))) finish(resolve, res); else finish(reject, new Error('HTTP ' + res.status + ': ' + url + ' body=' + compactResponseText(res.responseText))); }, onerror: err => {const mapped=mirrorRequestError(err,url);finish(reject,mapped instanceof Error?mapped:new Error('Request error: '+url+' '+JSON.stringify(err)));}, ontimeout: () => finish(reject, new Error('Request timeout: ' + url)), onabort: () => { const error = new Error('请求已取消'); error.name = 'AbortError'; finish(reject, error); } }); if (signal && signal.aborted && handle) handle.abort(); } catch (error) { finish(reject, mirrorRequestError(error,url)); } }); } function detectAppPrefix(hrefOrPath) { try { const u=new URL(hrefOrPath||location.href,location.href),route=activateMirrorPage.owner===u.origin?mirrorPageRoute(u.href):{path:u.pathname,gateway:''},path=route.path; const m = path.match(/^\/(mooc2-ans|mooc-ans)(?=\/)/i); return route.gateway+(m?`/${m[1]}`:''); } catch (_) { const m = String(hrefOrPath || '').match(/^\/(mooc2-ans|mooc-ans)(?=\/)/i); return m ? `/${m[1]}` : ''; } } function stripAppPrefix(base) { return String(base || '').replace(/\/(?:mooc2-ans|mooc-ans)$/i, ''); } function studentStudyAjaxBase(base) { if (/\/(?:mooc2-ans|mooc-ans)$/i.test(String(base || ''))) return base; return String(base || '').replace(/\/$/, '') + '/mooc-ans'; } function getOldStudyHost(ctx) { const host=String(ctx&&ctx.oldSiteHost||detectOldSiteHost()||(activateMirrorPage.owner?activateMirrorPage.owner+(activateMirrorPage.gateway||''):'')||(activateMirrorPage.platform?location.origin:'https://mooc1.chaoxing.com')).replace(/\/$/,'');assertMirrorRequest(host);return host; } function cleanOtherInfo(otherInfo) { return String(otherInfo || '') .replace(/%26courseId%3D[^%&]*/ig, '') .replace(/&courseId=[^&]*/ig, '') .replace(/^courseId=[^&]*&?/ig, ''); } function detectOldSiteHost() { if(activateMirrorPage.gateway)return activateMirrorPage.owner+activateMirrorPage.gateway; try { if ($w.ServerHost?.mooc1Domain){const u=new URL($w.ServerHost.mooc1Domain);if(!u.username&&!u.password&&!u.port&&u.pathname==='/'&&!u.search&&!u.hash&&isSupportedRunnerLocation(u.href)&&!/(yuketang|xuetangx|zhihuishu|qingshuxuetang)\./.test(u.hostname))return u.origin;} } catch (_) {} for (const s of Array.from(document.scripts)) { const src = s.src || ''; const m = src.match(/^(https?:\/\/mooc1[^/]*\.chaoxing\.com)\//i); if (m) return m[1]; } return ''; } function detectOldEnc() { try { const m = String($w.toOld || '').match(/var enc = "([^"]+)/); if (m) return m[1]; } catch (_) {} const html = document.documentElement.innerHTML; return parseOldEncFromHtml(html); } function parseOldEncFromHtml(html) { const s = String(html || ''); const m = s.match(/var\s+enc\s*=\s*["']([^"']+)/i) || s.match(/oldEnc\s*[:=]\s*["']([^"']+)/i); return m ? m[1] : ''; } function normalizeSiteOrigin(origin, href) { try { const u = new URL(origin); const prefix = detectAppPrefix(href); if (prefix) return u.origin + prefix; return u.origin; } catch (_) { return origin; } } function buildSiteHostCandidates(origin, href) { const out = []; const add = v => { if (v && !out.includes(v)) out.push(v); }; const normalized = normalizeSiteOrigin(origin, href); if(activateMirrorPage.owner){assertMirrorRequest(normalized);add(normalized);add(stripAppPrefix(normalized));add(detectOldSiteHost());return out;} add(normalized); add(stripAppPrefix(normalized)); try { const u = new URL(stripAppPrefix(normalized)); if (/^mooc2-ans\./i.test(u.hostname)) { const raw = u.hostname; u.hostname = raw.replace(/^mooc2-ans\./i, 'mooc1.'); add(u.origin); u.hostname = raw.replace(/^mooc2-ans\./i, 'mooc1-1.'); add(u.origin); u.hostname = raw.replace(/^mooc2-ans\./i, 'mooc1-2.'); add(u.origin); } if (/^stat2-ans\./i.test(u.hostname)) { const raw = u.hostname; u.hostname = raw.replace(/^stat2-ans\./i, 'mooc1.'); add(u.origin); } } catch (_) {} return out; } function parseChapters(html) { return parseChapterEntries(html).map(x => x.id); } function parseCourseTaskSummary(html = '') { const text = String(html || '').replace(/<[^>]+>/g, ' ').replace(/ | /gi, ' ').replace(/\s+/g, ' '); const m = text.match(/已完成任务点\s*[::]\s*(\d+)\s*\/\s*(\d+)/); if (!m) return null; const done = Number(m[1] || 0); const total = Number(m[2] || 0); if (!Number.isFinite(done) || !Number.isFinite(total) || total <= 0) return null; return { done, total, complete: done >= total, source: 'studentcourse' }; } function detectCourseTaskSummaryFromPage() { const read = doc => parseCourseTaskSummary(doc && doc.documentElement && doc.documentElement.innerText || doc && doc.body && doc.body.innerText || ''); try { const own = read(document); if (own) return { ...own, source: 'page' }; } catch (_) {} try { for (const frame of Array.from(document.querySelectorAll('iframe'))) { try { const found = read(frame.contentDocument); if (found) return { ...found, source: 'iframe' }; } catch (_) {} } } catch (_) {} return null; } function isCourseTaskSummaryComplete(summary) { if (!summary) return false; const done = Number(summary.done || 0); const total = Number(summary.total || 0); return Number.isFinite(done) && Number.isFinite(total) && total > 0 && done >= total; } function parsePendingChapterIds(html = '') { const raw = String(html || ''), ordered = parseOrderedCatalogEntries(raw); if (ordered.entries.length) return ordered.entries.filter(entry => !entry.finished && (entry.unfinished || entry.jobCount > 0)).map(entry => entry.id); const doc = new DOMParser().parseFromString(raw, 'text/html'), ids = new Set(); for (const el of doc.querySelectorAll('[id^="cur"]')) { const match = String(el.id || '').match(/^cur(\d+)$/); if (!match || el.querySelector('[id^="cur"]')) continue; const local = el.outerHTML || ''; if (/未完成|待完成|未开始|进行中|icon_not|ans-job-unfinish|orange/i.test(local) && !/已完成|icon_Completed|icon_yiwanc|ans-job-finished/i.test(local)) ids.add(match[1]); } return [...ids]; } const LOCK_TEXT_RE = /需完成之前|才可解锁|才能解锁|未开放|尚未开放|待解锁|完成上一?[章节]|按顺序学习|闯关/i; const LOCK_CLASS_RE = /(?:^|[\s"'])lock(?:ed)?(?:$|[\s"'])|icon[_-]lock|icon-chuangguan|ans-job-lock|catalog_lock|chapter_lock|notopen|not_open/i; const DONE_CLASS_RE = /openlock|\bblue\b|icon_yiwanc|icon_Completed|ans-job-finished|jobFinish/i; const TODO_CLASS_RE = /orange|jobCount|knowledgeJobCount|catalog_jindu|jobUnfinishCount|icon_not|ans-job-unfinish/i; const CUR_ID_RE = /^cur(\d+)$/; const LEAF_CLASS_RE = /^(?:leveltwo|levelthree|ncells)$/i; const CATALOG_CONTAINER_SEL = '.menulist-menu-title, .posCatalog_select, .chapter_item, .leveltwo, .levelthree, .ncells'; const STATE_NODE_SEL = '.roundpointStudent, .catalog_state, em, i'; const UNIT_HEADER_SEL = '.catalog_num, .catalog_upDown, .chapter_Thats_bnt, .firstNode'; const CHAPTER_NUM_SEL = '.posCatalog_sbar, .catalog_sbar, .chapterNumber, .hideChapterNumber, .catalog_num'; function catalogNodeTitle(node) { if (!node) return ''; const direct = node.getAttribute && node.getAttribute('title'); if (direct) return direct; let clone = node; try { clone = node.cloneNode(true); for (const num of Array.from(clone.querySelectorAll(CHAPTER_NUM_SEL))) num.remove(); } catch (_) { clone = node; } return clone.textContent || ''; } function parseOrderedCatalogEntries(html = '') { const result = { entries: [], gated: false, finishedCount: 0, unfinishedCount: 0, lockedCount: 0, lockedWithId: 0, layout: '', nextChapterId: '', unfinishCount: null }; let doc; try { doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); } catch (_) { return result; } // 侧边栏版式里有 , // 这是超星自己算好的「下一个该学的小节」和「未完成任务点数」,可直接用于校验。 try { const studyState = doc.querySelector('#_studystate'); const raw = studyState ? String(studyState.getAttribute('value') || '') : ''; const nextId = raw.match(/nextChapterId\s*:\s*(\d+)/i); const unfin = raw.match(/unfinishCount\s*:\s*(\d+)/i); if (nextId) result.nextChapterId = nextId[1]; if (unfin) result.unfinishCount = Number(unfin[1]); } catch (_) {} // 小节容器候选,覆盖各校自建版式。 // .leveltwo 内部本身含 h3、.ncells 内部含 h4,所以标题类节点只在不被容器包含时才独立计入, // 否则同一个小节会被数两遍。 const containers = Array.from(doc.querySelectorAll(CATALOG_CONTAINER_SEL)); const headings = Array.from(doc.querySelectorAll('h2, h3, h4, h5')) .filter(el => !(el.closest && el.closest(CATALOG_CONTAINER_SEL))); const candidates = [...containers, ...headings]; const seen = new Set(); for (const el of candidates) { const raw = el.innerHTML || ''; if (!raw) continue; const cls = String(el.className || ''); const domId = String(el.id || ''); // id 可能在容器本身(.chapter_item#curNNN),也可能在内部标题上(.ncells > h4#curNNN) let id = domId.match(CUR_ID_RE)?.[1] || ''; if (!id) { const inner = el.querySelector('[id^="cur"]'); id = String((inner && inner.id) || '').match(CUR_ID_RE)?.[1] || ''; } if (!id) { // .leveltwo/.levelthree 版式的 id 只出现在 href 的 chapterId 参数里。 const anchor = el.querySelector('a[href]'); const href = (anchor && anchor.getAttribute('href')) || ''; id = href.match(/[?&](?:chapterId|knowledgeid|knowledgeId)=(\d+)/i)?.[1] || ''; } if (!id) { const onclickText = raw.match(/toOld\(\s*['"]?\d+['"]?\s*,\s*['"]?(\d+)['"]?/i) || raw.match(/getTeacherAjax\(\s*['"]?\d+['"]?\s*,\s*['"]?\d+['"]?\s*,\s*['"]?(\d+)['"]?/i); if (onclickText) id = onclickText[1]; } // 状态判定:文案 + class 双通道,覆盖三种目录版式的不同标记 const classBlob = [cls, ...Array.from(el.querySelectorAll(STATE_NODE_SEL)).map(n => String(n.className || ''))].join(' '); const lockHint = LOCK_TEXT_RE.test(raw) || LOCK_CLASS_RE.test(classBlob); // 一级章节标题也用 .chapter_item / h2.levelfirst 渲染,但它不是可学习的小节, // 不排除会把一级章节误计成「锁定小节」。 if (!id && (el.querySelector(UNIT_HEADER_SEL) || /\blevelfirst\b/i.test(cls))) continue; // 去重提前:.leveltwo 与内层 .levelthree 会命中同一个小节, // 放在后面去重会让同一个锁定小节把 lockedCount 加两次。 if (id && seen.has(id)) continue; if (lockHint) { result.gated = true; result.lockedCount++; // 版式 B/C 的锁定小节仍带 chapterId,可以入队靠顺序执行; // 版式 A 拿不到 ID,只能计数,等解锁后重新拉目录才会出现。 if (id) result.lockedWithId++; else continue; } else if (!id) { // 有小节结构但既无 ID 也无锁定文案:闯关版式的锁定小节就是这样(href="javascript:;")。 const isLeafLike = LEAF_CLASS_RE.test(cls.trim()) || CUR_ID_RE.test(domId) || /\bchapter_item\b/.test(cls); if (isLeafLike && el.querySelector('a')) { result.gated = true; result.lockedCount++; } continue; } seen.add(id); const text = cleanText(el.textContent || ''); const stateBox = el.querySelector('.catalog_task, .roundpointStudent, .catalog_state, .catalog_points_yi, .icon_Completed, .prevTips, .icon'); const finished = !lockHint && !!(DONE_CLASS_RE.test(classBlob) || (stateBox && (/已完成/.test(stateBox.textContent || '') || DONE_CLASS_RE.test(stateBox.outerHTML || '')))); let jobCount = 0; for (const input of Array.from(el.querySelectorAll('input.knowledgeJobCount,input.jobUnfinishCount'))) { const n = Number(input.getAttribute('value') || input.value || 0); if (Number.isFinite(n) && n > 0) jobCount = Math.max(jobCount, n); } // Read the badge and tooltip independently: concatenating both turns 1 + "1个待完成" into 11. const countNodes = stateBox ? Array.from(stateBox.querySelectorAll('.bntHoverTips,.prevHoverTips,.orangeNew,.catalog_points_yi')) : []; if (!countNodes.length && stateBox) countNodes.push(stateBox); for (const node of countNodes) { const pendingCount = String(node.textContent || '').trim().match(/^(\d+)\s*个待完成任务点|^(\d+)$/); if (pendingCount) jobCount = Math.max(jobCount, Number(pendingCount[1] || pendingCount[2])); } const unfinished = !lockHint && !finished && (jobCount > 0 || TODO_CLASS_RE.test(classBlob) || /未完成|待完成|未开始|待完成任务点/.test(text)); const title = cleanChapterTitle( (el.getAttribute && el.getAttribute('title')) || catalogNodeTitle(el.querySelector('.articlename')) || catalogNodeTitle(el.querySelector('.posCatalog_name')) || catalogNodeTitle(el.querySelector('.catalog_name')) || catalogNodeTitle(el.querySelector('a[title]')) || catalogNodeTitle(el.querySelector('a')) || text || '' ); if (finished) result.finishedCount++; if (unfinished) result.unfinishedCount++; const numberNode = el.querySelector(CHAPTER_NUM_SEL); const number = numberNode ? cleanText(numberNode.textContent || '') : ''; result.entries.push({ id, title, number, finished, unfinished, jobCount, locked: lockHint }); } // 兜底:存在既非已完成、也无未完成标记的小节,说明状态未知,按闯关处理更安全。 if (!result.gated) { result.gated = result.entries.some(e => !e.finished && !e.unfinished && !e.jobCount); } result.layout = result.entries.length ? 'ordered' : ''; return result; } function catalogStructureProbe(html = '') { const raw = String(html || ''); let doc = null; try { doc = new DOMParser().parseFromString(raw, 'text/html'); } catch (_) {} const count = sel => { try { return doc ? doc.querySelectorAll(sel).length : 0; } catch (_) { return 0; } }; const parts = [ `len=${raw.length}`, `leveltwo=${count('.leveltwo')}`, `levelthree=${count('.levelthree')}`, `units=${count('.units')}`, `chapter_item=${count('.chapter_item')}`, `posCatalog=${count('.posCatalog_select')}`, `curId=${(raw.match(/id=["']cur\d+["']/g) || []).length}`, `chapterIdHref=${(raw.match(/[?&](?:chapterId|knowledgeid)=\d+/gi) || []).length}`, `knowledgeJobCount=${(raw.match(/knowledgeJobCount/g) || []).length}`, `openlock=${(raw.match(/openlock/g) || []).length}`, `lockWord=${(raw.match(/才可解锁|需完成之前|未开放/g) || []).length}` ]; return parts.join(', '); } function parseChapterEntries(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const entries = []; const add = (id, title) => { id = String(id || '').trim(); if (!/^\d+$/.test(id)) return; entries.push({ id, title: cleanChapterTitle(title || '') }); }; const leafNodes = Array.from(doc.querySelectorAll('.posCatalog_select[id^="cur"]')); if (leafNodes.length) { for (const el of leafNodes) { const m = String(el.id || '').match(/^cur(\d+)/); if (m) add(m[1], getChapterTitleFromElement(el)); } const out = []; const seen = new Set(); for (const entry of entries) { if (seen.has(entry.id)) continue; seen.add(entry.id); out.push(entry); } return out; } const nodes = [ ...doc.getElementsByClassName('menulist-menu-title'), ...doc.getElementsByClassName('posCatalog_select'), ...doc.querySelectorAll('h2,h3,h4,h5,a,[id^="cur"]') ]; for (const el of nodes) { const id = el.id || ''; const raw = el.outerHTML || el.innerHTML || ''; const title = getChapterTitleFromElement(el); const m1 = id.match(/^cur(\d+)/); if (m1 && /jobUnfinishCount|未完成|orange|任务点|icon_Completed|icon_not\u5f00\u59cb\u81ea\u52a8\u5b8c\u6210ed/i.test(raw)) add(m1[1], title); const href = el.getAttribute && el.getAttribute('href'); const kid = href && pickQueryFromUrl(href, 'chapterId', 'knowledgeid', 'knowledgeId'); if (kid) add(kid, title); } // 兜底:直接在 HTML 中找 cur123 / chapterId=123 for (const m of html.matchAll(/id=["']cur(\d+)["']/g)) { const el = doc.getElementById(`cur${m[1]}`); add(m[1], getChapterTitleFromElement(el)); } for (const m of html.matchAll(/[?&](?:chapterId|knowledgeid|knowledgeId)=(\d+)/g)) add(m[1], ''); const out = []; const pos = new Map(); for (const entry of entries) { if (!pos.has(entry.id)) { pos.set(entry.id, out.length); out.push(entry); } else if (entry.title && !out[pos.get(entry.id)].title) { out[pos.get(entry.id)].title = entry.title; } } return out; } function getChapterTitleFromElement(el) { if (!el) return ''; const attrTitle = cleanChapterTitle(el.getAttribute && (el.getAttribute('title') || el.getAttribute('data-title') || el.getAttribute('aria-label')) || ''); const selectors = [ '.catalog_name', '.catalog-name', '.articlename', '.articleName', '.chapter_name', '.chapterName', '.knowledgeName', '.knowledge-name', '.posCatalog_name', '.menulist-menu-title', 'h2', 'h3', 'h4', 'h5', 'a[title]', 'a' ]; for (const sel of selectors) { let node = null; try { node = el.matches && el.matches(sel) ? el : el.querySelector && el.querySelector(sel); } catch (_) { node = null; } if (!node) continue; const text = cleanChapterTitle((node.getAttribute && node.getAttribute('title')) || node.textContent || node.innerHTML || ''); if (text) return text; } return attrTitle || cleanChapterTitle(el.textContent || el.innerHTML || ''); } function cleanChapterTitle(text) { let s = cleanText(text || ''); s = s .replace(/jobUnfinishCount\s*\d*/ig, ' ') .replace(/任务点\s*\d*/g, ' ') .replace(/(?:未完成|已完成|待完成|未开始|进行中|已开放|未开放|点击进入|展开|收起)/g, ' ') .replace(/\b(?:null|undefined)\b/ig, ' ') .replace(/\s+/g, ' ') .trim(); if (!s || /^\d+$/.test(s)) return ''; return s.length > 90 ? s.slice(0, 90).trim() : s; } function formatClockSeconds(seconds) { seconds = Math.max(0, Math.floor(Number(seconds || 0))); const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; const pad = n => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`; } function parseMArg(html) { let m = html.match(/mArg\s*=\s*(\{[\s\S]*?\})\s*;\s*}\s*catch\s*\(e\)/); if (!m) m = html.match(/mArg\s*=\s*(\{[\s\S]*?\})\s*;/); if (!m) return null; try { return JSON.parse(m[1]); } catch (e) { // 部分页面会包含不可见字符,做一次宽松清洗。 const cleaned = m[1].replace(/[\u0000-\u001f\u007f]/g, ''); return JSON.parse(cleaned); } } function parseCardsIframeFallback(html, oldHost) { const doc = new DOMParser().parseFromString(html, 'text/html'); const attachments = []; for (const iframe of Array.from(doc.querySelectorAll('iframe[data]'))) { const raw = iframe.getAttribute('data') || ''; let data; try { data = JSON.parse(raw.replace(/"/g, '"')); } catch (_) { continue; } const src = iframe.getAttribute('src') || ''; const jobid = iframe.getAttribute('jobid') || data.jobid || data._jobid || ''; const wrap = iframe.closest('.ans-attach-ct,.ans-job-finished,.ans-job-unfinished,.ans-job-unfinish,.videoContainer') || iframe.parentElement; const className = String(((wrap && wrap.className) || '') + ' ' + (iframe.className || '')); const structuredBlob = [ src, className, data.module, data.type, data.jobid, data._jobid, data.objectid, data.objectId, data.mid, data.otherInfo ].filter(v => v !== undefined && v !== null && v !== '').join(' '); const fallbackName = String(data.name || data.title || data.filename || ''); const moduleDocLike = /\/pdf\/|\/ppt\/|\/document\/|ans-insertdoc|insertdoc|\.(?:pptx?|pdf|docx?|xlsx?)(?:\b|$)/i.test(structuredBlob) || (!structuredBlob && /\.(?:pptx?|pdf|docx?|xlsx?)(?:\b|$)/i.test(fallbackName)); const moduleVideoLike = /\/video\/|ans-insertvideo|\.(?:mp4|m4v|flv|avi|mov|wmv|vob)(?:\b|$)/i.test(structuredBlob) || (!structuredBlob && /\.(?:mp4|m4v|flv|avi|mov|wmv|vob)(?:\b|$)/i.test(fallbackName)); const moduleAudioLike = /insertaudio|audio|\.(?:mp3|wav|m4a|aac|flac)(?:\b|$)/i.test(structuredBlob) || (!structuredBlob && /\.(?:mp3|wav|m4a|aac|flac)(?:\b|$)/i.test(fallbackName)); let module = 'unknown'; if (/\b(?:insertbook|readingbook|innerbook)\b/i.test(structuredBlob)) module = 'book'; else if (moduleDocLike) module = 'document'; else if (/insertbbs|ans-insertbbs|\bdiscuss(?:ion)?\b|bbscircle|groupweb\.chaoxing/i.test(structuredBlob)) module = 'discussion'; else if (/insertclasstask|classTask|chapterTask|k\.chaoxing\.com\/res\/look|k\.chaoxing\.com\/[A-Za-z0-9]+/i.test(structuredBlob)) module = 'classtask'; else if (/insertlive|\/live\/|zhibo\.chaoxing|liveId/i.test(structuredBlob)) module = 'live'; else if (/inserthyperlink|hyperlink|insertlink|\blink\b/i.test(structuredBlob)) module = 'hyperlink'; else if (moduleVideoLike) module = 'video'; else if (moduleAudioLike) module = 'audio'; else if (/\/course\/\d+\.html|insertreadV2|read/i.test(structuredBlob)) module = 'read'; if (!jobid && module === 'unknown') continue; attachments.push({ job: !!jobid || module === 'classtask', jobid, type: module === 'book' ? 'book' : module === 'video' ? 'video' : (module === 'audio' ? 'audio' : (module === 'read' ? 'read' : (module === 'live' ? 'live' : (module === 'classtask' ? 'classtask' : (module === 'discussion' ? 'discussion' : (module === 'hyperlink' ? 'hyperlink' : 'document')))))), otherInfo: data.otherInfo || data.mid || '', property: { ...data, objectid: data.objectid || data.objectId, objectId: data.objectId || data.objectid, name: data.name || data.title || data.bookname, module, rt: data.rt || '0.9', fastforward: data.fastforward }, jtoken: data.jtoken || data.jobToken || '' }); } if (!attachments.length) return null; return { defaults: { reportUrl: `${String(oldHost || 'https://mooc1.chaoxing.com').replace(/\/$/, '')}/mooc-ans/multimedia/log/a`, fid: '' }, attachments }; } function applyCardsDomStatus(parsed, html = '') { if (!parsed || !Array.isArray(parsed.attachments) || !parsed.attachments.length) return parsed; const statusMap = parseCardsDomStatusMap(html); if (!statusMap || !statusMap.size) return parsed; return applyCardsStatusMap(parsed, statusMap); } function applyCardsStatusMap(parsed, statusMap) { if (!parsed || !Array.isArray(parsed.attachments) || !parsed.attachments.length) return parsed; if (!statusMap || !statusMap.size) return parsed; for (const att of parsed.attachments) { const prop = att && att.property || {}; const keys = [ att && att.jobid, att && att._jobid, att && att.jobId, prop.jobid, prop._jobid, prop.jobId, att && att.objectid, att && att.objectId, prop.objectid, prop.objectId, att && att.mid, prop.mid ].filter(Boolean).map(String); let hit = null; for (const key of keys) { hit = statusMap.get(key); if (hit) break; } if (!hit) continue; att._domTaskStatus = hit.status; att._domTaskClass = hit.className; att.property = { ...(att.property || {}) }; att.property._domTaskStatus = hit.status; att.property._domTaskClass = hit.className; if (hit.status === 'finished') { att.isPassed = true; att.property.isPassed = true; att.property.statusStr = att.property.statusStr || '已完成'; } else if (hit.status === 'unfinished') { att.isPassed = false; att.property.isPassed = false; att.property.statusStr = att.property.statusStr || '未完成'; if (att.job === undefined || att.job === null || att.job === '') att.job = true; } } return parsed; } function shouldProbeRenderedCardsStatus(parsed) { if (!parsed || !Array.isArray(parsed.attachments) || !parsed.attachments.length) return false; return parsed.attachments.some(att => { if (!att) return false; const prop = att.property || {}; const values = [ att.isPassed, prop.isPassed, att.passed, prop.passed, att.complete, prop.complete, att.completed, prop.completed, att.status, prop.status, att.statusStr, prop.statusStr, att._domTaskStatus, prop._domTaskStatus ]; return !values.some(v => v !== undefined && v !== null && v !== ''); }); } async function loadCardsDomStatusMapByIframe(url, waitMs = 1200) { const empty = new Map(); if (!url || typeof document === 'undefined' || !document.body) return empty; return new Promise(resolve => { const iframe = document.createElement('iframe'); let done = false; const finish = () => { if (done) return; done = true; let map = empty; try { map = parseCardsDomStatusMapFromDocument(iframe.contentDocument, { includeNone: true }); } catch (_) { map = empty; } try { iframe.remove(); } catch (_) {} resolve(map || empty); }; iframe.onload = () => setTimeout(finish, Math.max(300, Number(waitMs || 1200))); iframe.style.cssText = 'position:absolute;left:-99999px;top:-99999px;width:900px;height:640px;opacity:0;pointer-events:none;'; iframe.setAttribute('aria-hidden', 'true'); iframe.src = url; document.body.appendChild(iframe); setTimeout(finish, Math.max(2500, Number(waitMs || 1200) + 3500)); }); } function parseCardsDomStatusMap(html = '') { const map = new Map(); let doc; try { doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); } catch (_) { return map; } return parseCardsDomStatusMapFromDocument(doc); } function parseCardsDomStatusMapFromDocument(doc, opts = {}) { const map = new Map(); if (!doc) return map; const includeNone = !!(opts && opts.includeNone); const put = (key, value) => { key = String(key || '').trim(); if (key && !map.has(key)) map.set(key, value); }; for (const iframe of Array.from(doc.querySelectorAll('iframe[data]'))) { const raw = iframe.getAttribute('data') || ''; let data = {}; try { data = JSON.parse(raw.replace(/"/g, '"')); } catch (_) { data = {}; } const wrap = iframe.closest('.ans-attach-ct,.videoContainer,.ans-job-finished,.ans-job-unfinished,.ans-job-unfinish') || iframe.parentElement; const className = String(((wrap && wrap.className) || '') + ' ' + (iframe.className || '')); const hasTaskIdentity = !!( iframe.getAttribute('jobid') || data.jobid || data._jobid || data.jobId ); let status = ''; if (/\bans-job-finished\b|jobFinish|icon_Completed/i.test(className)) status = 'finished'; if (/\bans-job-unfinished\b|\bans-job-unfinish\b|jobUnfinish|icon_not/i.test(className)) status = 'unfinished'; if (!status && includeNone && !hasTaskIdentity && /\bans-attach-ct\b|ans-attach-online|ans-insert/i.test(className)) status = 'none'; if (!status) continue; const value = { status, className }; [ iframe.getAttribute('jobid'), iframe.getAttribute('objectid'), iframe.getAttribute('mid'), data.jobid, data._jobid, data.jobId, data.objectid, data.objectId, data.mid, data.name, data.title ].forEach(k => put(k, value)); } return map; } function isAudioAttachment(att) { if (!att) return false; const prop = att.property || {}; const text = [ att.type, att.module, att.name, att.title, prop.type, prop.module, prop.name, prop.title, att._domTaskClass, prop._domTaskClass ].filter(v => v !== undefined && v !== null && v !== '').join(' '); return /insertaudio|audio|voice|音频|\.(?:mp3|wav|m4a|aac|flac)(?:\b|$)/i.test(text); } function mediaTaskText(att) { return isAudioAttachment(att) ? '音频' : '视频'; } function normalizeAttachment(att, defaults = {}) { if (!att) return null; const out = { ...att }; const prop = { ...(att.property || {}) }; const topLevelJobid = out.jobid || out._jobid || out.jobId || out.JobId || ''; const propJobid = prop.jobid || prop._jobid || prop.jobId || prop.JobId || ''; const classText = String(out._domTaskClass || prop._domTaskClass || out.className || prop.className || ''); const nameText = String(prop.name || prop.title || out.name || out.title || ''); const moduleText = [ prop.module, prop.type, out.module, out.type, classText ].filter(v => v !== undefined && v !== null && v !== '').join(' '); const fallbackText = [moduleText, nameText].filter(Boolean).join(' '); let type = out.type || ''; const documentLike = /document|ans-insertdoc|insertdoc|pdf|ppt|word|office|\.(?:pptx?|pdf|docx?|xlsx?)(?:\b|$)/i.test(moduleText) || (!moduleText && /\.(?:pptx?|pdf|docx?|xlsx?)(?:\b|$)/i.test(nameText)); const audioLike = /insertaudio|audio|voice|\u97f3\u9891|\.(?:mp3|wav|m4a|aac|flac)(?:\b|$)/i.test(moduleText) || (!moduleText && /\.(?:mp3|wav|m4a|aac|flac)(?:\b|$)/i.test(nameText)); const videoLike = /video|ans-insertvideo|\.(?:mp4|m4v|flv|avi|mov|wmv|vob)(?:\b|$)/i.test(moduleText) || (!moduleText && /\.(?:mp4|m4v|flv|avi|mov|wmv|vob)(?:\b|$)/i.test(nameText)); if (/workid|ans-work-module|workA|doHomeWork/i.test(moduleText) || prop.workid || out.workid) type = 'workid'; else if (/insertbbs|ans-insertbbs|\bdiscuss(?:ion)?\b|bbscircle|groupweb\.chaoxing/i.test(moduleText)) type = 'discussion'; // Native book modules may arrive with the generic top-level type document. // Match them before document/read so readingbook does not become a reading task. else if (/\b(?:insertbook|readingbook|innerbook)\b/i.test(moduleText)) type = 'book'; else if (documentLike) type = 'document'; else if (audioLike) type = 'audio'; else if (videoLike) type = 'video'; else if (/insertclasstask|classTask|chapterTask|k\.chaoxing\.com\/res\/look|k\.chaoxing\.com\/[A-Za-z0-9]+/i.test(moduleText) || prop.uuid && prop.viewLink) type = 'classtask'; else if (/insertlive|\blive\b|zhibo|liveId/i.test(moduleText)) type = 'live'; else if (/insertreadV2|insertread|read/i.test(moduleText)) type = 'read'; else if (/inserthyperlink|hyperlink|insertlink|\blink\b/i.test(moduleText)) type = 'hyperlink'; else if (/insertbook|book|readingbook/i.test(moduleText)) type = 'book'; else if (!type && /\.(?:pptx?|pdf|docx?|xlsx?)(?:\b|$)/i.test(fallbackText)) type = 'document'; else if (!type && /\.(?:mp4|m4v|flv|avi|mov|wmv|vob)(?:\b|$)/i.test(fallbackText)) type = 'video'; else if (!type && /\.(?:mp3|wav|m4a|aac|flac)(?:\b|$)/i.test(fallbackText)) type = 'audio'; // 有些课程标题里带“课堂回看/直播”,但真实资源是普通 mp4 或 PPT。 // 如果缺少课堂回看 uuid/viewLink,就不要强行走 classtask;有 objectId/jobid 的 mp4 按普通视频处理。 if (type === 'classtask' && !extractClassTaskUuidFromAttachment(out, prop)) { if (documentLike) { type = 'document'; } else if (videoLike || prop.objectid || prop.objectId || out.objectid || out.objectId) { type = 'video'; } out.type = type; } out.type = type; out.jobid = topLevelJobid || propJobid; out.property = { ...prop, module: prop.module || moduleText, objectid: prop.objectid || prop.objectId || out.objectid || out.objectId || '', objectId: prop.objectId || prop.objectid || out.objectId || out.objectid || '', name: prop.name || prop.title || prop.bookname || out.name || out.title || '', rt: prop.rt || out.rt || '0.9', _from: prop._from || out._from || defaults._from || '', // V2 reading uses jobid and _jobid as distinct preview parameters. _jobid: prop._jobid || propJobid }; // Keep the original task-point evidence across repeated normalization. if (typeof out._explicitJobTrue !== 'boolean') out._explicitJobTrue = [out.job, prop.job, out.isJob, prop.isJob, out._job, prop._job].some(isTrueValue); if (typeof out._explicitJobFalse !== 'boolean') out._explicitJobFalse = [out.job, prop.job, out.isJob, prop.isJob, out._job, prop._job].some(isFalseValue); const point = taskPointStatus(out); if (point === 'point') out.job = true; else if (point === 'none') out.job = false; else delete out.job; out.objectId = out.objectId || out.objectid || prop.objectId || prop.objectid || ''; out.objectid = out.objectid || out.objectId || prop.objectid || prop.objectId || ''; out._from = out._from || prop._from || defaults._from || ''; out.otherInfo = out.otherInfo || prop.otherInfo || prop.mid || out.mid || ''; out.jtoken = out.jtoken || prop.jtoken || prop.jobToken || out.jobToken || ''; return out; } function isCourseTask(att) { if (!att) return false; return hasTaskPoint(att); } function taskPointStatus(att) { if (!att) return 'unknown'; const prop = att.property || {}; const dom = String(att._domTaskStatus || prop._domTaskStatus || '').toLowerCase(); if (dom === 'none') return 'none'; if (dom === 'finished' || dom === 'unfinished') return 'point'; const flags = [att.job, prop.job, att.isJob, prop.isJob, att._job, prop._job]; const no = typeof att._explicitJobFalse === 'boolean' ? att._explicitJobFalse : flags.some(isFalseValue); const yes = typeof att._explicitJobTrue === 'boolean' ? att._explicitJobTrue : flags.some(isTrueValue); if (no) return 'none'; return yes ? 'point' : 'unknown'; } function hasTaskPoint(att) { if (!att || att.type === 'assignment' || att.type === 'exam') return false; return taskPointStatus(att) === 'point'; } function isScanTaskPoint(att) { return hasTaskPoint(att); } function isRunnableTask(att) { if (!hasTaskPoint(att) || !hasExplicitPendingTaskState(att)) return false; return /^(workid|video|audio|document|book|read|live|classtask|discussion|hyperlink)$/.test(String(att.type || '')); } function isTaskPassed(att) { if (!att) return false; const prop = att.property || {}; const domStatus = String(att._domTaskStatus || prop._domTaskStatus || '').toLowerCase(); if (domStatus === 'finished') return true; if (domStatus === 'unfinished') return false; const values = [ att.isPassed, prop.isPassed, att.passed, prop.passed, att.complete, prop.complete, att.completed, prop.completed, att.isCompleted, prop.isCompleted, att.finish, prop.finish, att.finished, prop.finished, att.isFinish, prop.isFinish, att.hasFinished, prop.hasFinished ]; if (values.some(isTrueValue)) return true; const status = taskStatusText(att); const negative = hasNegativeTaskStatus(status); if (hasPositiveTaskStatus(status) && !negative) return true; return false; } function taskStatusText(att) { const prop = att && att.property || {}; return String([ att && att.status, prop.status, att && att.statusStr, prop.statusStr, att && att.jobStatus, prop.jobStatus, att && att.jobstatus, prop.jobstatus, att && att.completeStatus, prop.completeStatus, att && att.progress, prop.progress, att && att.result, prop.result, att && att.state, prop.state ].filter(v => v !== undefined && v !== null).join(' ')).replace(/\s+/g, ''); } function hasPositiveTaskStatus(status) { const value = String(status || ''); return /已完成|已提交|已批阅|待批阅|已结束|查看成绩|查看答案|最终成绩|^(?:完成|通过)$/i.test(value) || /(?:^|[^\d.+-])100(?:\.0+)?%(?!\d)/.test(value); } function hasNegativeTaskStatus(status) { const value = String(status || ''); return /未完成|待完成|待提交|未提交|未开始|未通过|进行中|需重做|重做|未观看|未学习/i.test(value) || [...value.matchAll(/(?:^|[^\d.+-])(\d+(?:\.\d+)?)%(?!\d)/g)].some(match => Number(match[1]) >= 0 && Number(match[1]) < 100); } function isWorkPageDoneLike(html = '', usedUrl = '') { const raw = String(usedUrl || '') + ' ' + String(html || ''); if (/selectWorkQuestionYiPiYue|YiPiYue|\/mooc-ans\/mooc2\/work\/view|\/mooc2\/work\/view/i.test(raw)) return true; if (/已批阅|已完成|已提交|待批阅|已保存|查看答案|查看成绩|最终成绩|正确答案|参考答案|标准答案/i.test(raw)) { if (!/未完成|待完成|未提交|尚未提交|未保存|继续作答/i.test(raw)) return true; } try { const u = new URL(String(usedUrl || ''), location.href); const path = u.pathname || ''; if (/selectWorkQuestionYiPiYue|\/mooc2\/work\/view/i.test(path)) return true; // reEdit=1/2 通常表示已提交后的再次编辑入口;课程自动流程里不应该重复填答/提交。 if (/doHomeWorkNew|dowork|mooc2\/work\/task/i.test(path) && u.searchParams.has('reEdit')) return true; } catch (_) {} return false; } function hasExplicitPendingTaskState(att) { if (!hasTaskPoint(att) || isTaskPassed(att)) return false; // 平台 job=true 本身表示待完成;首次未学习时可以没有 isPassed 字段。 if (att.job === true) return true; const prop = att.property || {}; if (String(att._domTaskStatus || prop._domTaskStatus || '').toLowerCase() === 'unfinished') return true; if (hasNegativeTaskStatus(taskStatusText(att))) return true; return [att.isPassed, prop.isPassed, att.passed, prop.passed, att.complete, prop.complete, att.completed, prop.completed, att.isCompleted, prop.isCompleted, att.finish, prop.finish, att.finished, prop.finished, att.isFinish, prop.isFinish, att.hasFinished, prop.hasFinished].some(isFalseValue); } function resolveChapterPendingTasks(candidates, remaining) { const tasks = candidates.filter(isRunnableTask), count = Number(remaining); if (Number.isSafeInteger(count) && count > 0 && tasks.length > count) return { tasks: [], conflict: true }; // A chapter's aggregate count cannot prove that an unmarked resource is a task point. return { tasks, conflict: false }; } function mergeTaskPointEvidence(stats, att, chapterId) { if (!hasTaskPoint(att)) return false; stats.doneKeys = stats.doneKeys || {}; stats.runnableKeys = stats.runnableKeys || {}; stats.unresolvedKeys = stats.unresolvedKeys || {}; stats.byType = stats.byType || {}; const key = taskKey(att, chapterId); const known = !!(stats.doneKeys[key] || stats.runnableKeys[key] || stats.unresolvedKeys[key]); if (stats.doneKeys[key]) return false; if (!known) { if (!stats.authoritativeTotal) stats.total = Number(stats.total || 0) + 1; stats.byType[att.type || 'unknown'] = Number(stats.byType[att.type || 'unknown'] || 0) + 1; } delete stats.runnableKeys[key]; delete stats.unresolvedKeys[key]; if (isTaskPassed(att)) { stats.doneKeys[key] = true; if (!stats.authoritativeTotal) stats.completed = Number(stats.completed || 0) + 1; } else if (isRunnableTask(att)) stats.runnableKeys[key] = true; else stats.unresolvedKeys[key] = true; stats.runnablePending = Object.keys(stats.runnableKeys).length; stats.pending = stats.authoritativeTotal ? Math.max(0, stats.total - stats.completed) : Math.max(0, stats.total - stats.completed, stats.runnablePending + Object.keys(stats.unresolvedKeys).length); return !known; } function taskKey(att, chapterId) { const prop = att && att.property || {}; const job = att && (att.jobid || att._jobid || att.jobId) || prop.jobid || prop._jobid || prop.jobId; if (job) return [chapterId || '', 'job', String(job)].join('|'); const resource = att && (att.workid || att.workId || att.examId || att.answerId || att.objectid || att.objectId || att.mid) || prop.workid || prop.workId || prop.examId || prop.answerId || prop.objectid || prop.objectId || prop.mid; return [chapterId || '', att && att.type || '', resource || safeName(att)].join('|'); } function uniqueTaskAttachments(attachments = [], chapterId = '') { const tasks = new Map(); for (const att of attachments) { const key = taskKey(att, chapterId), previous = tasks.get(key); if (!previous || isTaskPassed(att)) tasks.set(key, att); } return [...tasks.values()]; } function extractReadId(att) { if (!att) return ''; const prop = att.property || {}; const direct = att.objectId || att.objectid || prop.objectId || prop.objectid || prop.readid || prop.readId || att.readid || att.readId || ''; if (direct) return String(direct); const jobid = String(att.jobid || prop.jobid || prop._jobid || ''); const m = jobid.match(/read[-_](\d+)/i); return m ? m[1] : ''; } function buildDocumentTaskRequest(task, context, mediaHost, now = Date.now()) { if (!task || !['document', 'book'].includes(task.type)) return null; if (String(context.isFiled) === '1' || String(context.state) === '1') return null; const prop = task.property || {}; const scalar = value => typeof value === 'string' ? value.trim() : typeof value === 'number' && Number.isFinite(value) ? String(value) : ''; const fields = { jobid: scalar(task.jobid), knowledgeid: scalar(context.chapterId || context.knowledgeid), courseid: scalar(context.courseId || context.courseid), clazzid: scalar(context.clazzId || context.clazzid), jtoken: scalar(task.jtoken) }; if (Object.values(fields).some(value => !value)) return null; const book = task.type === 'book'; // Preserve a native micro-topic when supplied; otherwise use the backend's exact defaults. const microTopicId = task.microTopicId ?? prop.microTopicId ?? (book ? '' : 'undefined'); const params = new URLSearchParams({ ...fields, checkMicroTopic: 'true', microTopicId: String(microTopicId), _dc: String(now) }); if (context.isEngineNode !== undefined) params.set('courseEngineInfo', String(context.isEngineNode) === '1' ? 'true' : 'false'); const url = new URL(book ? '/ananas/job' : '/ananas/job/document', mediaHost); const canonical = new URL(book ? '/mooc-ans/job/book' : '/mooc-ans/job/document', mediaHost); url.search = params.toString(); canonical.search = params.toString(); const headers = { Accept: '*/*', 'Accept-Language': book ? 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7' : 'zh-CN,zh;q=0.9', 'Cache-Control': 'no-cache' }; if (book) { headers.Pragma = 'no-cache'; headers.Referer = new URL('/ananas/modules/innerbook/index.html?v=2024-0826-2042', mediaHost).href; headers['X-Requested-With'] = 'XMLHttpRequest'; } return { url: url.href, canonicalUrl: canonical.href, headers, method: 'GET', timeout: book ? 10000 : 30000, withCredentials: true, allowStatuses: [404, 405] }; } function documentTaskResponseResult(response, plan) { const status = Number(response && response.status); if (!(status >= 200 && status < 300)) return { accepted: false, reason: Number.isFinite(status) ? '请求未成功(响应码 ' + status + ')' : '未收到有效响应' }; if (response.finalUrl) { try { const original = new URL(plan.url), final = new URL(response.finalUrl, original); const nativeHost = host => /^(?:mooc1(?:-[12])?|mooc2-ans)\.chaoxing\.com$/i.test(host); const sameOrigin = final.origin === original.origin; const mirror = final.protocol === original.protocol && !final.port && !original.port && nativeHost(final.hostname) && nativeHost(original.hostname); const paths = [original.pathname, new URL(plan.canonicalUrl).pathname]; if (final.username || final.password || (!sameOrigin && !mirror) || !paths.includes(final.pathname)) return { accepted: false, reason: '完成接口发生异常跳转,请检查课程登录状态' }; for (const key of ['jobid', 'knowledgeid', 'courseid', 'clazzid']) { if (final.searchParams.has(key) && final.searchParams.getAll(key).some(value => value !== original.searchParams.get(key))) return { accepted: false, reason: '完成接口返回了不匹配的任务信息' }; } } catch (_) { return { accepted: false, reason: '完成接口地址无效' }; } } try { const data = JSON.parse(String(response.responseText || '').replace(/^\uFEFF/, '')); if (data && !Array.isArray(data) && (data.status === true || data.status === 1)) return { accepted: true, reason: '' }; return { accepted: false, reason: '平台未确认完成,请检查任务是否开放或任务凭证是否有效' }; } catch (_) { // Do not copy response bodies or signed task URLs into visible logs. const login = /]*>\s*(?:用户登录|登录|温馨提示)\s*<\/title>|没有此页面访问权限/i.test(String(response.responseText || '')); return { accepted: false, reason: login ? '登录已失效或无权访问该任务,请重新进入课程' : '平台未返回有效的完成结果' }; } } function buildReadNavigationPlan(task, context, mediaHost, readId) { const prop = task.property || {}; const jobid = String(task.jobid || prop.jobid || ''); const module = String(prop.module || task.module || ''); const fromV2 = task._fromV2_ || prop._fromV2_ || context._fromV2_ || ''; const legacy = /insertread(?!v2)\b/i.test(module) && !/readv2/i.test(module); const content = new URL('/mooc-ans/course/' + encodeURIComponent(readId) + '.html', mediaHost); content.search = new URLSearchParams({ _from_: prop._from || task._from || context._from || '', rtag: '', _fromV2_: fromV2 }).toString(); if (String(context.isFiled) === '1' || String(context.state) === '1') return null; // Do not send a V2 completion callback for explicitly legacy modules. if (legacy) return { previewUrl: '', contentUrl: content.href, callbackUrl: '' }; const jtoken = task.jtoken || prop.jtoken || ''; const djobid = prop._jobid || task._jobid || ''; const courseid = context.courseId || context.courseid || ''; const clazzid = context.clazzId || context.clazzid || ''; const knowledgeid = context.chapterId || context.knowledgeid || ''; if (!jobid || !jtoken || !djobid || !courseid || !clazzid || !knowledgeid || prop.jobid && String(prop.jobid) !== jobid) return null; const preview = new URL('/mooc-ans/readv2job/preview', mediaHost); preview.search = new URLSearchParams({ jobid, djobid, type: 'read', knowledgeid, ut: 's', isphone: 'false', clazzId: clazzid, courseid, enc: task.enc || prop.enc || '', utenc: context.utEnc || context.utenc || '', _fromV2_: fromV2 }).toString(); const callback = new URL('/mooc-ans/job/readv2', mediaHost); callback.search = new URLSearchParams({ jobid, knowledgeid, courseid, clazzid, jtoken, checkMicroTopic: 'true', microTopicId: task.microTopicId ?? prop.microTopicId ?? 0, courseEngineInfo: String(context.isEngineNode) === '1' ? 'true' : 'false' }).toString(); return { previewUrl: preview.href, contentUrl: content.href, callbackUrl: callback.href, moduleUrl: new URL('/ananas/modules/read/indexV2.html', mediaHost).href }; } function isReadPageAvailable(response) { if (!(Number(response.status) >= 200 && Number(response.status) < 300)) return false; const html = String(response.responseText || ''); if (!html.trim()) return false; if (/\/(?:login|error-page)(?:[/?#.]|$)/i.test(String(response.finalUrl || ''))) return false; return !/]*>\s*(?:用户登录|登录|温馨提示|访问受限)\s*<\/title>|您长时间没有操作|没有此页面访问权限|请尝试重新进入课程/i.test(html); } function resolveReadNavigationUrl(raw, referenceUrl, readId) { try { const reference = new URL(referenceUrl), url = new URL(raw, reference); if (!/^https?:$/.test(url.protocol) || url.username || url.password) return null; if (reference.protocol === 'https:' && url.protocol !== 'https:') return null; const courseHost = host => /^(?:mooc1(?:-[12])?|mooc2-ans)\.chaoxing\.com$/i.test(host); const sameHost = url.hostname === reference.hostname && url.port === reference.port; const nativeMirror = !url.port && !reference.port && courseHost(url.hostname) && courseHost(reference.hostname); if (!sameHost && !nativeMirror) return null; // Reverse proxies may add/remove the existing Chaoxing application prefix. const route = url.pathname.replace(/^\/(?:mooc2-ans|mooc-ans)(?=\/)/i, ''); const referenceRoute = reference.pathname.replace(/^\/(?:mooc2-ans|mooc-ans)(?=\/)/i, ''); const portalPattern = /^\/zt\/portal\/([A-Za-z0-9_-]{1,128}={0,2})\/?$/; const portal = decodeURIComponent(route).match(portalPattern); const referencePortal = decodeURIComponent(referenceRoute).match(portalPattern); let kind = ''; if (portal) { // Topic readers use an opaque portal ID, not the attachment's numeric readId. // Discover that ID only from a native task entry. Once selected, pin it across redirects. if (referencePortal) { if (portal[1] !== referencePortal[1]) return null; } else if (!/^\/(?:readv2job\/preview|coursedata\/readjobv2\/show)\/?$/.test(referenceRoute)) return null; kind = 'content'; } else if (route === '/course/' + encodeURIComponent(readId) + '.html') { if (referencePortal) return null; kind = 'content'; } // Native preview redirects here on the user's live course (reported 2026-10-01). // This is still an entry page: opening it alone does not mean the reading link was followed. else if (/^\/(?:readv2job\/preview|coursedata\/readjobv2\/show)\/?$/.test(route)) { kind = 'preview'; const expected = new Map([...reference.searchParams].map(([key, value]) => [key.toLowerCase(), value])); for (const [key, value] of url.searchParams) { const name = key.toLowerCase(); if (['jobid', 'djobid', 'knowledgeid', 'courseid', 'clazzid'].includes(name) && expected.has(name) && value !== expected.get(name)) return null; } } if (!kind) return null; url.hash = ''; return { url: url.href, kind }; } catch (_) { return null; } } function describeReadNavigationUrl(raw, referenceUrl) { try { const url = new URL(raw, referenceUrl); if (!/^https?:$/.test(url.protocol)) return '非网页地址'; // Never include query strings, fragments, userinfo or long path tokens in user logs. const path = url.pathname.replace(/(\/zt\/portal\/)[^/]+/, '$1[专题]').split('/').map(part => part.length > 24 ? '[标识]' : part).join('/'); return (url.host + path).slice(0, 150); } catch (_) { return '无法识别的地址'; } } function parseReadNavigationTarget(html, previewUrl, readId) { const doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); const base = new URL(previewUrl); const targets = new Set(); const add = raw => { const resolved = resolveReadNavigationUrl(raw, base.href, readId); // Only numeric material or a portal target supplied by the native task entry. // A unique portal ID is pinned when its response redirects; never arbitrary destinations. if (resolved && resolved.kind === 'content') targets.add(resolved.url); }; const addLiterals = text => { // Read literal URLs only; never execute the page's JS or expand dynamic expressions. const source = String(text || ''); for (const match of source.matchAll(/(["'])([^"'\r\n]+)\1/g)) { if (/\+\s*$/.test(source.slice(0, match.index)) || /^\s*\+/.test(source.slice(match.index + match[0].length))) continue; if (/^(?:https?:\/\/|\/|\.\.\/|\.\/)/i.test(match[2]) && !match[2].includes('\\')) add(match[2]); } }; for (const link of doc.querySelectorAll('a[href]')) { const href = link.getAttribute('href') || ''; // Some native links put the literal reading URL inside a javascript: handler. // Extract data with the same strict URL checks; do not execute that handler. if (/^\s*javascript:/i.test(href)) addLiterals(href.replace(/^\s*javascript:/i, '')); else add(href); } for (const element of doc.querySelectorAll('[onclick]')) addLiterals(element.getAttribute('onclick')); for (const script of doc.querySelectorAll('script:not([src])')) addLiterals(script.textContent); // An ambiguous/missing link requires the native page, not a guessed content URL. return targets.size === 1 ? [...targets][0] : ''; } function isReadCallbackAccepted(response) { if (!(Number(response.status) >= 200 && Number(response.status) < 300)) return false; try { const data = JSON.parse(response.responseText || ''); return data.status === true || data.status === 1; } catch (_) { return false; } } function normalizeVideoReportUrl(reportUrl, mediaHost, cpi) { let base = String(reportUrl || '').trim(); const host = String(mediaHost || 'https://mooc1.chaoxing.com').replace(/\/$/, ''); if (!base) base = `${host}/mooc-ans/multimedia/log/a`; if (base.startsWith('/')) base = host + base; base = base.replace(/\/$/, ''); if (!/\/mooc-ans\/multimedia\/log\/a(?:\/|$)/i.test(base) && /\/multimedia\/log\/a$/i.test(base)) { base = base.replace(/\/multimedia\/log\/a$/i, '/mooc-ans/multimedia/log/a'); } if (/\/mooc-ans\/multimedia\/log\/a$/i.test(base) && cpi) base += `/${cpi}`; return base; } function videoResumeSeconds(jobData, duration) { // Native video/index.js converts the server's headOffset from milliseconds to seconds. // Do not infer watched progress from attDuration, a filename, or a client estimate. const raw = jobData && (jobData.headOffset ?? jobData.property?.headOffset); if (raw == null || raw === '') return 0; const offsetMs = Number(raw), total = Number(duration); if (!Number.isFinite(offsetMs) || !Number.isFinite(total) || total <= 0 || offsetMs < 0 || offsetMs > total * 1000) return 0; return Math.floor(offsetMs / 1000); } function buildVideoReportUrls(p) { const list = []; const step = Math.max(1, Math.round(Number(p.stepSeconds || 25))); const speed = Math.max(0.1, Number(p.speed || 1)); const duration = Math.max(1, Math.round(Number(p.duration || 0))); const isAudioReport = String(p.dtype || '').toLowerCase() === 'audio'; const original = Math.round(Number(p.originalAttDuration || 0)); const signAttDuration = isAudioReport ? duration * 1000 : (original > 0 ? original * 1000 : duration); const reportAttDuration = duration; const pushPacket = (playTime, isdrag, waitBeforeMs) => { const clipTime = `0_${duration}`; const raw = `[${p.clazzId}][${p.uid}][${p.jobid}][${p.objectid}][${playTime * 1000}][d_yHJ!$pdA~5][${signAttDuration}][${clipTime}]`; const sign = md5(raw); const query = [ `clazzId=${enc(p.clazzId)}`, `playingTime=${enc(playTime)}`, `duration=${enc(duration)}`, `clipTime=${enc(clipTime)}`, `objectId=${enc(p.objectid)}`, `otherInfo=${enc(cleanOtherInfo(p.otherInfo || ''))}`, `courseId=${enc(p.courseId || '')}`, `jobid=${enc(p.jobid)}`, `userid=${enc(p.uid)}`, `isdrag=${enc(isdrag)}`, 'view=pc', `enc=${enc(sign)}`, `rt=${enc(isAudioReport ? '' : (p.rt || '0.9'))}`, ...(!isAudioReport ? [`videoFaceCaptureEnc=${enc(p.videoFaceCaptureEnc || '')}`] : []), `dtype=${enc(p.dtype || 'Video')}`, `_t=${Date.now()}`, ...(isAudioReport ? ['courseEngineInfo=false'] : [ `attDuration=${enc(reportAttDuration)}`, `attDurationEnc=${enc(p.attDurationEnc || '')}` ]) ].join('&'); list.push({ playingTime: playTime, isdrag, waitBeforeMs, url: `${String(p.reportUrl).replace(/\/$/, '')}/${p.dtoken}?${query}` }); }; const requestedStart = Number(p.startSeconds || 0); const start = Number.isFinite(requestedStart) && requestedStart >= 0 && requestedStart <= duration ? Math.floor(requestedStart) : 0; pushPacket(start, '3', 0); let current = start; while (current < duration) { const next = Math.min(current + step, duration); pushPacket(next, '3', Math.round(((next - current) / speed) * 1000)); current = next; } pushPacket(duration, '4', 0); return list; } function detectUid() { const candidates = [ $w._uid, $w.uid, $w.userId, $w.userid, getCookie('UID'), getCookie('uid'), getCookie('_uid'), localStorage.getItem('UID'), localStorage.getItem('uid') ]; for (const v of candidates) if (v && /^\d+$/.test(String(v))) return String(v); const html = $d.documentElement.innerHTML; const m = html.match(/(?:uid|userId|userid)['"]?\s*[:=]\s*['"]?(\d+)/i); return m ? m[1] : ''; } function detectFid() { const candidates = [$w.fid, $w._fid, getCookie('fid'), getCookie('FID')]; for (const v of candidates) if (v) return String(v); const html = $d.documentElement.innerHTML; const m = html.match(/(?:fid)['"]?\s*[:=]\s*['"]?(\d+)/i); return m ? m[1] : ''; } function detectCourseName() { const t = ($d.querySelector('.course-name,.courseName,h1') || {}).textContent || $d.title || ''; return t.trim(); } function pickQueryFromUrl(url, ...names) { try { const u = new URL(url, location.href); for (const n of names) { const v = u.searchParams.get(n); if (v) return v; } } catch (_) {} return ''; } function getCookie(name) { const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : ''; } function requireParam(v, name) { if (v === undefined || v === null || v === '' || Number.isNaN(v)) throw new Error(`缺少参数:${name}`); } function isTrueValue(v) { return v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true'; } function isFalseValue(v) { if (v === undefined || v === null || v === '') return false; return v === false || v === 0 || v === '0' || String(v).toLowerCase() === 'false'; } function enc(v) { return encodeURIComponent(String(v)); } function randomString() { return Math.random().toString(36).slice(2) + Date.now().toString(36); } function createUuid() { if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID(); return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } function safeName(jobData) { return (jobData && jobData.property && (jobData.property.name || jobData.property.title || jobData.property.bookname)) || jobData.type || 'unknown'; } function extractDiscussionUrl(html) { const raw = String(html || ''); try { const doc = new DOMParser().parseFromString(raw, 'text/html'); const topic = doc.querySelector('#topicMainDiv.PublicCardBox[data], #topicMainDiv[data], .PublicCardBox[data]'); const data = topic && topic.getAttribute('data'); if (data) return new URL(data, 'https://groupweb.chaoxing.com/').href; const link = Array.from(doc.querySelectorAll('a[href],iframe[src]')) .map(el => el.getAttribute('href') || el.getAttribute('src') || '') .find(u => /groupweb\.chaoxing\.com|\/bbs\/[^/]+\/[^/]+\/replysList/i.test(u)); if (link) return new URL(link, 'https://groupweb.chaoxing.com/').href; } catch (_) {} const m = raw.match(/]*\sdata=["']([^"']+)["']/i) || raw.match(/id=["']topicMainDiv["'][^>]*\sdata=["']([^"']+)["']/i) || raw.match(/https?:\/\/groupweb\.chaoxing\.com\/[^"'\s<>]+replysList[^"'\s<>]*/i); const url = m && (m[1] || m[0]); return url ? decodeHtmlAttr(url) : ''; } function extractDiscussionIds(url) { const text = String(url || ''); const bbsid = (text.match(/\/bbs\/([^/?#]+)/i) || [])[1] || pickQueryFromUrl(text, 'bbsid'); const topicUuid = (text.match(/\/([^/?#]+)\/replysList/i) || [])[1] || pickQueryFromUrl(text, 'uuid'); return { bbsid, topicUuid }; } function extractDiscussionUrlToken(html) { const text = String(html || ''); const patterns = [ /urlToken:\s*['"]([^'"]+)['"]/i, /["']urlToken["']\s*:\s*['"]([^'"]+)['"]/i, /urlToken\s*=\s*['"]([^'"]+)['"]/i, /name=["']urlToken["'][^>]*value=["']([^"']+)["']/i ]; for (const re of patterns) { const m = text.match(re); if (m) return m[1]; } return ''; } function sanitizeDiscussionContent(content) { const raw = String(content || '').trim(); if (!raw) return ''; try { const doc = new DOMParser().parseFromString(raw, 'text/html'); const text = (doc.body && doc.body.textContent || '').replace(/\s+/g, ' ').trim(); if (text) return text; } catch (_) {} return raw.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); } function decodeHtmlAttr(text) { const div = document.createElement('div'); div.innerHTML = String(text || ''); return div.textContent || div.innerText || String(text || ''); } function extractWorkId(jobData) { const prop = jobData && jobData.property || {}; const candidates = [ jobData && jobData.workId, jobData && jobData.workid, prop.workId, prop.workid, jobData && jobData.jobid, jobData && jobData._jobid, prop.jobid, prop._jobid ]; for (let v of candidates) { if (v === undefined || v === null || v === '') continue; v = String(v).trim(); const q = v.match(/[?&]workId=([A-Za-z0-9_-]{3,})/i); if (q) return q[1]; if (v.startsWith('work-')) v = v.slice(5); const m = v.match(/^([A-Za-z0-9_-]{3,})$/); if (m) return m[1]; } return ''; } const MOBILE_UA = 'Mozilla/5.0 (Linux; Android 11.0.1; ASUS_I005DA Build/LMY48Z; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.100 Mobile Safari/537.36 com.chaoxing.mobile/ChaoXingStudy_6_3.2_android_phone_205_1'; const WORK_UTENC = '4b409e6fdf59b33587faba886e18d3bd'; function buildWorkApiUrl(jobData, context, oldHost, workId, jobId, workEnc) { const prop = jobData && jobData.property || {}; const originJobId = prop._jobid || jobData._jobid || (String(jobId || '').startsWith('work-') ? jobId : `work-${workId}`); const realJobId = prop.jobid && !String(prop.jobid).startsWith('work-') ? prop.jobid : ''; const type = prop.worktype === 'workB' ? 'b' : ''; const params = new URLSearchParams(); params.set('api', '1'); params.set('workId', workId); params.set('jobid', realJobId); params.set('originJobId', originJobId); params.set('needRedirect', 'true'); params.set('skipHeader', 'true'); params.set('knowledgeid', context.chapterId || ''); if (context.ktoken) params.set('ktoken', context.ktoken); if (context.cpi) params.set('cpi', context.cpi); params.set('ut', 's'); params.set('clazzId', context.clazzId || ''); params.set('type', type); if (workEnc) params.set('enc', workEnc); params.set('utenc', context.utenc || WORK_UTENC); params.set('mooc2', '1'); params.set('courseid', context.courseId || ''); return `${oldHost}/mooc-ans/api/work?${params.toString()}`; } function buildWorkHandleUrl(jobData, context, oldHost, workId, jobId, workEnc) { const prop = jobData && jobData.property || {}; const originJobId = prop._jobid || jobData._jobid || (String(jobId || '').startsWith('work-') ? jobId : `work-${workId}`); const cleanWorkId = String(workId || '').replace(/^work-/i, ''); const type = prop.worktype === 'workB' ? 'b' : ''; const params = new URLSearchParams(); params.set('workId', cleanWorkId); params.set('courseid', context.courseId || ''); params.set('knowledgeid', context.chapterId || ''); params.set('userid', ''); params.set('ut', 's'); params.set('classId', context.clazzId || ''); params.set('jobid', originJobId || `work-${cleanWorkId}`); params.set('type', type); params.set('isphone', 'false'); params.set('submit', 'false'); if (workEnc) params.set('enc', workEnc); params.set('utenc', context.utenc || WORK_UTENC); if (context.cpi) params.set('cpi', context.cpi); if (context.ktoken) params.set('ktoken', context.ktoken); params.set('mooc2', '1'); params.set('skipHeader', 'true'); params.set('workExtInfoEnc', 'null'); params.set('oriNodeId', '0'); params.set('originJobId', originJobId || `work-${cleanWorkId}`); params.set('teacherPreview', '0'); params.set('fromType', ''); return `${oldHost}/mooc-ans/workHandle/handle?${params.toString()}`; } function textFrom(el, selector = '') { const node = selector ? el.querySelector(selector) : el; return cleanText(node && (node.innerHTML || node.textContent) || ''); } function splitJsArgs(src) { const args = []; let cur = '', quote = '', esc = false, depth = 0; for (const ch of String(src || '')) { if (esc) { cur += ch; esc = false; continue; } if (ch === '\\') { cur += ch; esc = true; continue; } if (quote) { if (ch === quote) quote = ''; cur += ch; continue; } if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; } if (ch === '(' || ch === '[' || ch === '{') depth++; if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1); if (ch === ',' && depth === 0) { args.push(cur.trim()); cur = ''; } else cur += ch; } if (cur.trim() || src) args.push(cur.trim()); return args.map(x => x.replace(/^['"]|['"]$/g, '').replace(/\\(['"])/g, '$1')); } function parseAssignmentListHtml(html, ctx = {}) { const doc = new DOMParser().parseFromString(html || '', 'text/html'); const oldHost = String(ctx.oldHost || getOldStudyHost(ctx)).replace(/\/$/, ''); const tasks = []; const nodes = [ ...doc.querySelectorAll('li[onclick*="goTask"], li[data*="workId"], li[data*="workid"], a[onclick*="goTask"], a[href*="workId"], a[href*="workid"]') ]; const seenNodes = new Set(); for (const node of nodes) { if (seenNodes.has(node)) continue; seenNodes.add(node); try { const holder = node.closest('li') || node.closest('.right-content') || node; const rawData = holder.getAttribute('data') || node.getAttribute('data') || node.getAttribute('href') || ''; const onclick = holder.getAttribute('onclick') || node.getAttribute('onclick') || ''; let rawUrl = rawData; if (!rawUrl || /^javascript:/i.test(rawUrl)) { const m = onclick.match(/goTask\s*\(([^)]*)\)/i); const args = m ? splitJsArgs(m[1]) : []; rawUrl = args.find(x => /workId=|workid=|mooc2\/work|doHomeWork|work\/task/i.test(x)) || ''; } if (!rawUrl) continue; let url; try { url = new URL(rawUrl, oldHost).href; } catch (_) { continue; } const workId = pickQueryFromUrl(url, 'workId', 'workid') || (url.match(/workId=([A-Za-z0-9_-]+)/i) || [])[1] || ''; const answerId = pickQueryFromUrl(url, 'answerId', 'answerid', 'workAnswerId') || ''; const workEnc = pickQueryFromUrl(url, 'enc', 'workEnc') || ''; if (!workId) continue; const name = textFrom(holder, 'p.overHidden2,.overHidden2,.work-title,.title,h3,h4,a') || cleanText(holder.textContent || '').slice(0, 80) || `??_${workId}`; const status = textFrom(holder, 'p.status,.status,.state,.tip') || ''; tasks.push({ type: 'assignment', job: true, name, title: name, status, workId, answerId, enc: workEnc, url, courseId: ctx.courseId || '', clazzId: ctx.clazzId || ctx.classId || '', classId: ctx.clazzId || ctx.classId || '', cpi: ctx.cpi || '', property: { module: 'assignment', name, status, workId, answerId, enc: workEnc } }); } catch (_) {} } const seen = new Set(); const deduped = tasks.filter(t => { const key = `${t.workId}|${t.answerId}|${t.url}`; if (seen.has(key)) return false; seen.add(key); return true; }); const pageText = cleanText(doc.body && doc.body.textContent || ''); const hasMore = /next/i.test(pageText) || (pageText.includes('下一页') && !/disabled|noPage|不可用/i.test(html)); return { tasks: deduped, hasMore }; } function buildAssignmentTaskUrl(jobData, context = {}, oldHost = 'https://mooc1.chaoxing.com') { const raw = jobData.url || jobData.href || ''; if (raw && !/^javascript:/i.test(raw)) { try { return new URL(raw, oldHost).href; } catch (_) {} } const workId = jobData.workId || jobData.workid || extractWorkId(jobData); requireParam(workId, 'workId'); const params = new URLSearchParams(); params.set('courseId', jobData.courseId || context.courseId || ''); params.set('classId', jobData.classId || jobData.clazzId || context.clazzId || context.classId || ''); if (jobData.cpi || context.cpi) params.set('cpi', jobData.cpi || context.cpi); params.set('workId', workId); params.set('answerId', jobData.answerId || jobData.workAnswerId || '0'); if (jobData.enc) params.set('enc', jobData.enc); return `${String(oldHost).replace(/\/$/, '')}/mooc-ans/mooc2/work/task?${params.toString()}`; } function parseExamListHtml(html, ctx = {}) { const doc = new DOMParser().parseFromString(html || '', 'text/html'); const tasks = []; const nodes = Array.from(doc.querySelectorAll('div[onclick*="goTest"], a[onclick*="goTest"]')); for (const node of nodes) { try { const onclick = node.getAttribute('onclick') || ''; const m = onclick.match(/goTest\s*\(([^)]*)\)/i); if (!m) continue; const args = splitJsArgs(m[1]); const courseId = args[0] || ctx.courseId || ''; const examId = args[1] || ''; const answerId = args[2] || ''; const endTime = args[3] || ''; const classId = args[4] || ctx.clazzId || ctx.classId || ''; const isAvailable = !/false|0/i.test(String(args[5] || 'true')); const token = args[6] || ''; if (!examId) continue; const holder = node.closest('.right-content') || node.closest('li') || node.parentElement || node; const name = textFrom(holder, 'p.overHidden2,.overHidden2,.exam-title,.title,h3,h4,a') || cleanText(holder.textContent || '').slice(0, 80) || `??_${examId}`; const status = textFrom(holder, 'p.status,.status,.state,.tip') || ''; tasks.push({ type: 'exam', job: true, name, title: name, status, examId, id: examId, answerId, courseId, classId, clazzId: classId, cpi: ctx.cpi || '', openc: ctx.openc || '', endTime, token, isAvailable, property: { module: 'exam', name, status, examId, answerId, courseId, classId } }); } catch (_) {} } const seen = new Set(); return tasks.filter(t => { const key = `${t.examId}|${t.answerId}|${t.classId}`; if (seen.has(key)) return false; seen.add(key); return true; }); } function extractExamStartUrl(html, baseUrl, oldHost) { const doc = new DOMParser().parseFromString(html || '', 'text/html'); const candidates = []; const btn = doc.querySelector('#startBtn[data], a#startBtn[data], [data*="reVersionTestStartNew"]'); if (btn) candidates.push(btn.getAttribute('data')); for (const a of Array.from(doc.querySelectorAll('a[href], form[action]'))) { candidates.push(a.getAttribute('href') || a.getAttribute('action')); } const text = html || ''; for (const m of text.matchAll(/['"]([^'"]*reVersionTestStartNew[^'"]*)['"]/ig)) candidates.push(m[1]); for (const raw of candidates) { if (!raw || !/reVersionTestStartNew|testStart|start/i.test(raw)) continue; try { return new URL(raw.replace(/&/g, '&'), baseUrl || oldHost || 'https://mooc1.chaoxing.com').href; } catch (_) {} } return ''; } function parseExamStartInfo(doc, url, defaults = {}) { const qp = (() => { try { return new URL(url, location.href).searchParams; } catch (_) { return new URLSearchParams(); } })(); const val = (...names) => { for (const name of names) { const el = doc.getElementById(name) || doc.getElementsByName(name)[0]; const v = el && (el.value != null ? el.value : el.getAttribute('value')); if (v !== undefined && v !== null && String(v) !== '') return String(v); } return ''; }; const testPaperId = qp.get('tId') || qp.get('testPaperId') || val('testPaperId', 'examRelationId', 'tId') || defaults.testPaperId || defaults.examId || ''; const testUserRelationId = qp.get('id') || qp.get('testUserRelationId') || val('testUserRelationId', 'examRelationAnswerId', 'id') || defaults.testUserRelationId || defaults.answerId || ''; const questionId = val('questionId') || (doc.querySelector('input[name^="type"]') && String(doc.querySelector('input[name^="type"]').name || '').replace(/^type/, '')) || ''; return { oldHost: defaults.oldHost || getOldStudyHost(defaults), courseId: qp.get('courseId') || defaults.courseId || '', classId: qp.get('classId') || defaults.classId || defaults.clazzId || '', cpi: qp.get('cpi') || val('cpi') || defaults.cpi || '', openc: qp.get('openc') || val('openc') || defaults.openc || '', enc: val('enc') || qp.get('enc') || defaults.enc || '', examId: defaults.examId || testPaperId, answerId: defaults.answerId || testUserRelationId, testPaperId, testUserRelationId, paperId: val('paperId') || testPaperId, examCreateUserId: val('examCreateUserId'), userId: val('userId') || detectUid() || '', remainTime: val('remainTime') || '7200', encRemainTime: val('encRemainTime') || '', encLastUpdateTime: val('encLastUpdateTime') || val('enterPageTime') || String(Date.now()), enterPageTime: val('enterPageTime') || String(Date.now()), randomOptions: val('randomOptions') || 'false', examsystem: val('examsystem') || '', start: val('start') || '0', questionId, questionScore: val('questionScore') || (questionId && val(`score${questionId}`)) || '1.0', questionType: questionId ? val(`type${questionId}`) : '', questionTypeName: questionId ? val(`typeName${questionId}`) : '' }; } function formEncodeObject(obj) { const p = new URLSearchParams(); for (const [k, v] of Object.entries(obj || {})) { if (v === undefined || v === null) continue; p.append(k, String(v)); } return p.toString(); } function parsePipeEnc(dataStr) { const text = String(dataStr || ''); if (!text || !text.includes('|')) return {}; const parts = text.split('|'); return { encLastUpdateTime: parts[0] || '', remainTime: parts[1] || '', enc: parts[parts.length - 1] || '' }; } async function submitExamTempStart(info, opts = {}) { if (!info.questionId) return { success: true }; const oldHost = opts.oldHost || info.oldHost || 'https://mooc1.chaoxing.com'; const params = new URLSearchParams(); Object.entries({ classId: info.classId, courseId: info.courseId, cpi: info.cpi, testPaperId: info.testPaperId, testUserRelationId: info.testUserRelationId, tempSave: 'true', pos: 'bc77281ec682d906c203d57ad9', rd: String(Math.random()), value: '(1913|22)', qid: info.questionId, _edt: Date.now(), version: '1', view: 'json', _csign: '0', _signcode: 'undefined', _signc: 'undefined', _signe: 'undefined', _signk: 'undefined', _cxcid: 'undefined', _cxtime: 'undefined', _signt: 'undefined' }).forEach(([k, v]) => params.set(k, String(v == null ? '' : v))); const qid = info.questionId; const data = { courseId: info.courseId, paperId: info.paperId || info.testPaperId, testPaperId: info.testPaperId, examCreateUserId: info.examCreateUserId || '', feedbackEnc: '', testUserRelationId: info.testUserRelationId, tId: info.testPaperId, subCount: '', remainTime: info.remainTime || '7200', encRemainTime: info.encRemainTime || '', encLastUpdateTime: info.encLastUpdateTime || info.enterPageTime || String(Date.now()), tempSave: 'true', timeOver: 'false', type: '0', classId: info.classId, enc: info.enc || '', examsystem: info.examsystem || '', start: info.start || '0', userId: info.userId || '', randomOptions: info.randomOptions || 'false', cpi: info.cpi || '', openc: info.openc || '', enterPageTime: info.enterPageTime || String(Date.now()), questionId: qid, questionScore: info.questionScore || '1.0', [`type${qid}`]: info.questionType || '0', [`score${qid}`]: info.questionScore || '1.0', exitdtime: '0', monitorforcesubmit: '0', answeredView: '0', paperGroupId: '0', [`typeName${qid}`]: info.questionTypeName || '单选题', [`answer${qid}`]: '' }; const res = await request({ method: 'POST', url: `${oldHost}/exam-ans/exam/test/reVersionSubmitTestNew?${params.toString()}`, data: formEncodeObject(data), headers: { Accept: 'application/json, text/javascript, */*; q=0.01', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', Referer: opts.referer || `${oldHost}/exam-ans/exam/test/reVersionTestStartNew` }, timeout: 30000 }); let json = {}; try { json = JSON.parse(res.responseText || '{}'); } catch (_) {} const parsed = parsePipeEnc(json.data || ''); return { success: true, ...parsed, raw: json }; } async function fetchExamPreview(info, opts = {}) { const oldHost = opts.oldHost || info.oldHost || 'https://mooc1.chaoxing.com'; const params = new URLSearchParams(); params.set('courseId', info.courseId || ''); params.set('classId', info.classId || ''); params.set('start', '0'); if (info.cpi) params.set('cpi', info.cpi); params.set('examRelationId', info.testPaperId || info.examId || ''); params.set('examRelationAnswerId', info.testUserRelationId || info.answerId || ''); params.set('newMooc', 'true'); if (info.openc) params.set('openc', info.openc); params.set('monitorStatus', '0'); params.set('monitorOp', '-1'); params.set('remainTimeParam', info.remainTime || '7200'); params.set('relationAnswerLastUpdateTime', info.encLastUpdateTime || String(Date.now())); if (info.enc) params.set('enc', info.enc); const url = `${oldHost}/exam-ans/mooc2/exam/preview?${params.toString()}`; const res = await request({ url, headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, timeout: 30000 }); return { html: res.responseText || '', url: res.finalUrl || url }; } function parseExamQuestions(doc, meta = {}) { const fontBase64 = meta.fontBase64 || extractCxsecretFont(meta.html || ''); const hasEncryptedFont = !!fontBase64 || /font-cxsecret/i.test(meta.html || ''); const typeNameMap = { '0': '单选题', '1': '多选题', '2': '填空题', '3': '判断题', '4': '简答题' }; const questions = []; for (const div of Array.from(doc.querySelectorAll('div.questionLi'))) { try { let qid = div.getAttribute('data') || (div.id || '').replace(/^sigleQuestionDiv_|^q_/, ''); if (!qid) { const inp = div.querySelector('input[name^="type"],input[name^="answer"],input[id^="answer"]'); const m = inp && String(inp.name || inp.id || '').match(/(?:type|answer|answers)(\d{3,})/); if (m) qid = m[1]; } if (!qid) continue; const typeMeta = readQuestionTypeFromDom(div, qid); let typeN = typeMeta.typeN || '0'; const score = (div.querySelector(`input[name="score${qid}"]`) || {}).value || '1.0'; const titleNode = div.querySelector('h3.mark_name div') || div.querySelector('h3.mark_name') || div.querySelector('div.stem') || div.querySelector('div.subject') || div; const clone = titleNode.cloneNode(true); clone.querySelectorAll('.answerBg,.stem_answer,ul,li,input,.marking_answer,.analysis,.answer_p').forEach(n => n.remove()); const rawQuestion = cleanText(clone.innerHTML || clone.textContent || ''); const question = stripQuestionPrefix(fontBase64 ? safeDecryptCxsecretText(rawQuestion, fontBase64) : rawQuestion, typeNameMap[typeN] || ''); if (!question) continue; const options = []; const optionNodes = Array.from(div.querySelectorAll('div.clearfix.answerBg, div.answerBg, div.stem_answer, li')) .filter(opt => !(opt.matches && opt.matches('div.stem_answer') && opt.querySelector('.answerBg,[role="radio"],[role="checkbox"]'))); for (const opt of optionNodes) { const marker = opt.querySelector('span[data]') || opt.querySelector('.num_option,.num_option_dx,em,i'); let label = marker && (marker.getAttribute('data') || marker.textContent || '').trim().replace(/[\u3001.\uFF0E\uFF0C,:\uFF1A\s]+$/g, ''); if (!/^[A-Z]$/i.test(label || '')) { const lm = cleanText(opt.textContent || '').match(/^([A-Z])\s*[\u3001.\uFF0E\uFF0C,:\uFF1A]/i); if (lm) label = lm[1]; } const contentNode = opt.querySelector('div.answer_p') || opt.querySelector('p') || opt; const raw = cleanText(contentNode.innerHTML || contentNode.textContent || '').replace(/^[A-Z]\s*[\u3001.\uFF0E\uFF0C,:\uFF1A]?\s*/i, '').trim(); const content = fontBase64 ? safeDecryptCxsecretText(raw, fontBase64) : raw; if (label && content && !options.some(o => o.label === label && o.content === content)) options.push({ label, content, raw }); } if (typeN === '3' && !options.length) options.push({ label: 'A', content: '\u5bf9' }, { label: 'B', content: '\u9519' }); questions.push({ question, rawQuestion, typeN, typeName: typeNameMap[typeN] || '单选题', qid, id: qid, score, questionId: `answer${qid}`, options, element: div, typeSource: typeMeta.source || '', fontBase64, hasEncryptedFont, htmlContent: hasEncryptedFont ? (div.outerHTML || '') : '' }); } catch (_) {} } if (!questions.length) return parseWorkQuestions(doc, meta); return { questions, optionLis: [] }; } function xuetangxVisible(el) { if (!el) return false; try { const style = el.ownerDocument.defaultView.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; return !!(el.offsetParent || el.getClientRects().length); } catch (_) { return !!(el.offsetParent || el.getClientRects().length); } } function xuetangxText(el) { return cleanText((el && (el.innerText || el.textContent)) || ''); } function xuetangxQuestionTypeFromText(text = '', container = null) { const t = String(text || ''); if (/多选|Multiple/i.test(t)) return { typeN: '1', typeName: '多选题' }; if (/填空|Blank|Completion/i.test(t)) return { typeN: '2', typeName: '填空题' }; if (/判断|True\s*\/?\s*False|Judge/i.test(t)) return { typeN: '3', typeName: '判断题' }; if (/简答|问答|主观|Short|Essay/i.test(t)) return { typeN: '4', typeName: '简答题' }; if (container && container.querySelector('textarea,[contenteditable="true"]')) return { typeN: '4', typeName: '简答题' }; if (container && container.querySelector('input[type="checkbox"]')) return { typeN: '1', typeName: '多选题' }; return { typeN: '0', typeName: '单选题' }; } function parseXuetangxOptionRows(container) { const selectors = [ '.el-radio', '.el-checkbox', '.list-unstyled-radio li', '.list-unstyled-checkbox li', '[class*="option"]', '[class*="choice"]', '[role="radio"]', '[role="checkbox"]', 'label', 'li' ].join(','); const rows = Array.from(container.querySelectorAll(selectors)).filter(xuetangxVisible); const options = []; const seen = new Set(); for (const row of rows) { const text = xuetangxText(row); const input = row.matches('input[type="radio"],input[type="checkbox"]') ? row : row.querySelector('input[type="radio"],input[type="checkbox"]'); const inputValue = input ? String(input.value || '').trim() : ''; if ((!text && !/^(true|false)$/i.test(inputValue)) || text.length > 600) continue; let label = ''; const visualLabelNode = row.querySelector('.radioInput,.checkboxInput,[class*="radioInput"],[class*="checkboxInput"]'); const visualLabel = cleanText(visualLabelNode && (visualLabelNode.innerText || visualLabelNode.textContent) || '').replace(/\s+/g, ''); if (/^[A-Z]$/i.test(visualLabel)) label = visualLabel.toUpperCase(); const dataLabel = row.getAttribute('data-option') || row.getAttribute('data-label') || row.getAttribute('option') || ''; if (!label && /^[A-Z]$/i.test(dataLabel)) label = dataLabel.toUpperCase(); if (!label) { const m = text.match(/^\s*([A-Z])\s*[\.\u3001、.::\)]?\s*(.+)$/i); if (m) label = m[1].toUpperCase(); } if (!label && /^(true|false)$/i.test(inputValue)) label = /^true$/i.test(inputValue) ? 'A' : 'B'; if (!label) continue; const contentNode = row.querySelector('.radioText,.checkboxText,[class*="radioText"],[class*="checkboxText"]'); const boolValue = input && /^(true|false)$/i.test(String(input.value || '').trim()) ? String(input.value || '').trim().toLowerCase() : ''; const fallbackContent = boolValue === 'true' ? '正确' : (boolValue === 'false' ? '错误' : text); const content = cleanText((contentNode ? xuetangxText(contentNode) : fallbackContent) .replace(new RegExp(`^\\s*${label}\\s*[\\.\\u3001、.::\\)]?\\s*`, 'i'), '')); const key = `${label}|${content}`; if (!content || seen.has(key)) continue; seen.add(key); options.push({ label, key: boolValue || inputValue || label, content, element: row }); } if (!options.length && /判断|正确|错误|对|错/.test(xuetangxText(container))) { options.push({ label: 'A', key: 'A', content: '正确' }, { label: 'B', key: 'B', content: '错误' }); } return options; } function parseXuetangxExamQuestions(doc) { const selectors = [ '[data-problem-id]', '[data-question-id]', '.exercise-item', '[class*="problem-item"]', '[class*="problem_content"]', '[class*="problem-content"]', '[class*="question-item"]', '[class*="question_content"]', '[class*="question-content"]', '[class*="exam-question"]' ].join(','); let candidates = Array.from(doc.querySelectorAll(selectors)).filter(xuetangxVisible); if (!candidates.length) { candidates = Array.from(doc.querySelectorAll('[class*="problem"],[class*="question"]')) .filter(el => xuetangxVisible(el) && xuetangxText(el).length >= 8 && xuetangxText(el).length <= 4000); } candidates = candidates.filter(el => { const nested = candidates.some(other => other !== el && el.contains(other) && xuetangxText(other).length >= 8); return !nested; }); const questions = []; const seen = new Set(); for (const el of candidates) { const fullText = xuetangxText(el); if (!fullText || /开始考试|考试须知|诚信|交卷|提交试卷/.test(fullText) && !/[A-D]\s*[\.、.]/.test(fullText)) continue; const options = parseXuetangxOptionRows(el); const itemTypeText = xuetangxText(el.querySelector('.item-type,[class*="item-type"]')) || fullText; const typeMeta = xuetangxQuestionTypeFromText(itemTypeText, el); const stemClone = el.cloneNode(true); stemClone.querySelectorAll('.item-type,[class*="item-type"],.list-unstyled-radio,.list-unstyled-checkbox,[class*="option"],[class*="choice"],label,li,input,textarea,button,[role="radio"],[role="checkbox"],[contenteditable="true"]').forEach(n => n.remove()); let question = stripQuestionPrefix(xuetangxText(stemClone) || fullText, typeMeta.typeName); question = question .replace(/^\s*第?\s*\d+\s*[题、..:]?\s*/i, '') .replace(/^\s*\d+\s*[..、]\s*(单选题|多选题|判断题|填空题|简答题|问答题|主观题)\s*(\([^)]*\))?\s*/i, '') .trim(); if (!question || question.length < 4) continue; const qid = el.getAttribute('data-problem-id') || el.getAttribute('data-question-id') || el.id || `xuetangx_${questions.length + 1}`; const optionKey = (options || []).map(o => `${o.label}:${normalizeSpaces(o.content || '')}`).join('|').slice(0, 220); const key = `${typeMeta.typeN}|${normalizeSpaces(question).slice(0, 120)}|${optionKey}`; if (seen.has(key)) continue; seen.add(key); questions.push({ question, rawQuestion: question, typeN: typeMeta.typeN, typeName: typeMeta.typeName, qid, id: qid, questionId: qid, options, element: el, platform: 'xuetangx_exam' }); } return { questions, optionLis: [] }; } function zhihuishuRouteParams() { const hash = decodeURIComponent(location.hash || ''); const m = hash.match(/\/webExamList\/(?:dohomework|doexamination|checkHomework)\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/([^/?#]+)/i); return m ? { recruitId: m[1], stuExamId: m[2], examId: m[3], courseId: m[4], schoolId: m[5] } : {}; } function zhihuishuQuestionTypeMeta(raw = {}) { const id = raw && raw.questionType && raw.questionType.id != null ? Number(raw.questionType.id) : Number(raw.questionType || 0); const name = raw && raw.questionType && raw.questionType.name || raw.typeName || ''; if (id === 2 || /多选/.test(name)) return { typeN: '1', typeName: '多选题' }; if (id === 3 || /判断/.test(name)) return { typeN: '3', typeName: '判断题' }; if (id === 4 || /填空/.test(name)) return { typeN: '2', typeName: '填空题' }; if (id === 5 || /简答|问答|主观/.test(name)) return { typeN: '2', typeName: '简答题' }; return { typeN: '0', typeName: /单选/.test(name) ? name : '单选题' }; } function judgeAnswerLabel(answer) { const raw = String(answer == null ? '' : answer).trim(); if (!raw) return ''; const low = raw.replace(/[\s,,、;;#??]+/g, '').toLowerCase(); if (['a', 't', 'true', '1', 'yes', 'y'].includes(low)) return 'A'; if (['b', 'f', 'false', '0', 'no', 'n'].includes(low)) return 'B'; const hitRight = /对|正确|是|√|✓/.test(raw); const hitWrong = /错|错误|否|×|✗/.test(raw); if (hitRight && !hitWrong) return 'A'; if (hitWrong && !hitRight) return 'B'; const first = (raw.toUpperCase().match(/正确|错误|[ABTF对错是否√✓×✗]/) || [])[0] || ''; if (['A', 'T', '对', '是', '正确', '√', '✓'].includes(first)) return 'A'; if (['B', 'F', '错', '否', '错误', '×', '✗'].includes(first)) return 'B'; return ''; } function judgeAnswerLooksDirty(answer) { const raw = String(answer == null ? '' : answer); const compact = raw.replace(/[\s,,、;;#??]+/g, '').toUpperCase(); if (/^[A-G]{2,}$/.test(compact)) return true; return /对|正确/.test(raw) && /错|错误/.test(raw); } function normalizeExamAnswer(answer, q) { const raw = String(answer || '').trim(); const compact = raw.replace(/[\s,\uFF0C\u3001#;\uFF1B]+/g, '').toUpperCase(); const typeN = effectiveQuestionTypeN(q); if (typeN === '2') return raw; if (typeN === '3' && /^[AB]$/i.test(compact)) return compact.toUpperCase(); if (typeN === '3') { for (const o of q.options || []) { if (compact && compact === String(o.label || '').toUpperCase()) return /错|错误|否|×/.test(o.content || '') ? 'false' : 'true'; } const judged = judgeAnswerLabel(raw); if (judged) return judged === 'B' ? 'false' : 'true'; return 'true'; } if (/^[A-Z]+$/.test(compact)) return typeN === '1' ? [...new Set(compact.split(''))].join('') : compact[0]; const labels = []; const parts = raw.split(/#!#|[#,\uFF0C\u3001;\uFF1B]/).map(x => x.trim()).filter(Boolean); const searchParts = parts.length ? parts : [raw]; for (const part of searchParts) { const clean = cleanText(part).replace(/\s/g, ''); for (const o of q.options || []) { const c = cleanText(o.content || '').replace(/\s/g, ''); if (c && (clean === c || clean.includes(c) || c.includes(clean))) labels.push(String(o.label || '').toUpperCase()); } } const out = [...new Set(labels)].join(''); return out || raw; } function questionTypeNameFromN(typeN) { return typeN === '1' ? '多选题' : typeN === '2' ? '填空题' : typeN === '3' ? '判断题' : '单选题'; } function questionTypeNFromText(text) { const s = String(text || ''); if (/多选/.test(s)) return '1'; if (/填空|简答/.test(s)) return '2'; if (/判断/.test(s)) return '3'; if (/单选/.test(s)) return '0'; return ''; } function normalizeQuestionTypeValue(value) { const s = String(value == null ? '' : value).trim(); if (['0', '1', '2', '3'].includes(s)) return s; if (s === '4') return '2'; return questionTypeNFromText(s); } function readQuestionTypeFromDom(root, qid = '') { if (!root || !root.querySelector) return { typeN: '', source: '' }; const doc = root.ownerDocument || (root.nodeType === 9 ? root : document); const id = String(qid || '').trim(); const scoped = []; const addScoped = selector => { try { scoped.push(...Array.from(root.querySelectorAll(selector))); } catch (_) {} }; if (id) { addScoped(`#answertype${cssEscape(id)},[name="answertype${cssEscape(id)}"],#type${cssEscape(id)},[name="type${cssEscape(id)}"]`); try { const byDoc = [ doc.getElementById(`answertype${id}`), doc.getElementsByName(`answertype${id}`)[0], doc.getElementById(`type${id}`), doc.getElementsByName(`type${id}`)[0] ].filter(Boolean); scoped.unshift(...byDoc.filter(el => root === el || root.contains(el))); } catch (_) {} } addScoped('input[id^="answertype"],input[name^="answertype"],input[id^="type"],input[name^="type"]'); let hiddenType = ''; let hiddenSource = ''; for (const inp of [...new Set(scoped)]) { const key = String(inp.id || inp.name || ''); if (!/^(answertype|type)\d+/i.test(key)) continue; const typeN = normalizeQuestionTypeValue(inp.value || inp.getAttribute('value')); if (typeN) { hiddenType = typeN; hiddenSource = key; break; } } const optionRows = Array.from(root.querySelectorAll('.answerBg,[role="radio"],[role="checkbox"],input[type="radio"],input[type="checkbox"],.num_option,.num_option_dx')); const hasCheckbox = optionRows.some(el => (el.getAttribute && (el.getAttribute('qtype') === '1' || el.getAttribute('role') === 'checkbox')) || (el.matches && el.matches('input[type="checkbox"],.num_option_dx')) || (el.classList && el.classList.contains('num_option_dx')) ); if (hasCheckbox) return { typeN: '1', source: 'dom:checkbox/qtype=1' }; if (hiddenType) return { typeN: hiddenType, source: `dom:${hiddenSource}` }; const tiMu = (root.matches && root.matches('.TiMu') ? root : null) || root.querySelector('.TiMu[data]'); const tiMuType = normalizeQuestionTypeValue(tiMu && tiMu.getAttribute('data')); if (tiMuType) return { typeN: tiMuType, source: 'dom:.TiMu[data]' }; const hasRadio = optionRows.some(el => (el.getAttribute && (el.getAttribute('qtype') === '0' || el.getAttribute('role') === 'radio')) || (el.matches && el.matches('input[type="radio"],.num_option')) || (el.classList && el.classList.contains('num_option')) ); if (hasRadio) return { typeN: '0', source: 'dom:radio/qtype=0' }; // 题型只认权威来源(隐藏域 answertype/type、.TiMu[data]、radio/checkbox 结构), // 不从题干文本猜;拿不到就返回空,交给调用方按 q.typeN / q.typeName 处理。 return { typeN: '', source: '' }; } function effectiveQuestionTypeN(q = {}, fallbackTypeName = '') { if (q && q.platform === 'xuetangx_exam' && q.typeN != null) { const directXuetangx = String(q.typeN); if (['0', '1', '2', '3', '4'].includes(directXuetangx)) return directXuetangx === '4' ? '2' : directXuetangx; } const qid = getParsedQuestionId(q); const domType = q && q.element ? readQuestionTypeFromDom(q.element, qid) : null; if (domType && domType.typeN) return domType.typeN; const direct = q && q.typeN != null ? String(q.typeN) : ''; if (['0', '1', '2', '3'].includes(direct)) return direct; const textType = questionTypeNFromText(fallbackTypeName || q.typeName || q.questionType || ''); if (textType) return textType; return '0'; } function normalizeQuestionTypeMeta(q = {}, fallbackTypeName = '') { const typeN = effectiveQuestionTypeN(q, fallbackTypeName); q.typeN = typeN; const wantedName = questionTypeNameFromN(typeN); if (!q.typeName || q.typeName !== wantedName) q.typeName = wantedName; return typeN; } function answerResultForLog(ansRes) { return String(ansRes && ansRes.answer || '').trim(); } function normalizePageAnswerForFill(q = {}, answer = '') { const raw = String(answer || '').trim(); if (q && q.platform === 'xuetangx_exam') { const options = q.options || []; const hasBoolOptions = options.some(o => /^(true|false)$/i.test(String(o.key || '')) || /正确|错误|对|错/.test(String(o.content || ''))); const typeN = effectiveQuestionTypeN(q); if (typeN === '3' || hasBoolOptions) { if (/^(正确|对|是|true|t|√|1|A)$/i.test(raw)) return 'A'; if (/^(错误|错|否|false|f|×|0|B)$/i.test(raw)) return 'B'; } } return answer; } async function submitExamSingleAnswer(info, q, answer, questionIndex, opts = {}) { const typeN = normalizeQuestionTypeMeta(q); const oldHost = opts.oldHost || info.oldHost || 'https://mooc1.chaoxing.com'; const qid = q.qid || q.id; const params = new URLSearchParams(); Object.entries({ classId: info.classId, courseId: info.courseId, cpi: info.cpi, testPaperId: info.testPaperId, testUserRelationId: info.testUserRelationId, tempSave: 'true', pos: 'ce3617c48e998a571f03d912ea', rd: String(Math.random()), value: '(385|220)', qid, _edt: Date.now(), version: '1', view: 'json', _csign: '0', _signcode: 'undefined', _signc: 'undefined', _signe: 'undefined', _signk: 'undefined', _cxcid: 'undefined', _cxtime: 'undefined', _signt: 'undefined' }).forEach(([k, v]) => params.set(k, String(v == null ? '' : v))); const data = { courseId: info.courseId, testPaperId: info.testPaperId, examCreateUserId: info.examCreateUserId || '', feedbackEnc: '', testUserRelationId: info.testUserRelationId, classId: info.classId, type: '0', remainTime: info.remainTime || '7200', tempSave: 'true', timeOver: 'false', encRemainTime: info.encRemainTime || '', encLastUpdateTime: info.encLastUpdateTime || String(Date.now()), enc: info.enc || '', userId: info.userId || '', cpi: info.cpi || '', examRelationId: info.testPaperId, enterPageTime: info.enterPageTime || String(Date.now()), exitdtime: '0', monitorforcesubmit: '0', [`type${qid}`]: typeN || '0', [`typeName${qid}`]: q.typeName || '单选题', start: String(Math.max(0, Number(questionIndex || 1) - 1)), questionId: qid }; const normalized = normalizeExamAnswer(answer, q); if (typeN === '2') { const blanks = String(normalized).split(/#!#|[|;]/).map(x => x.trim()).filter(Boolean); const fill = blanks.length ? blanks : [String(normalized)]; fill.forEach((v, i) => { data[`answerEditor${qid}${i + 1}`] = v; }); data[`blankNum${qid}`] = fill.map((_, i) => i + 1).join(',') + ','; } else if (typeN === '1') { data[`answers${qid}`] = normalized; } else { data[`answer${qid}`] = normalized; } const url = `https://mooc1-api.chaoxing.com/exam-ans/exam-ans/exam/test/preview-save?${params.toString()}`; const res = await request({ method: 'POST', url, data: formEncodeObject(data), headers: { Accept: 'application/json, text/javascript, */*; q=0.01', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', Origin: oldHost, Referer: opts.previewUrl || `${oldHost}/exam-ans/mooc2/exam/preview?courseId=${enc(info.courseId)}&classId=${enc(info.classId)}` }, timeout: 30000 }); let json = {}; try { json = JSON.parse(res.responseText || '{}'); } catch (_) {} if ((json.status && json.status !== 'success') || json.success === false) return { success: false, error: json.msg || json.message || 'preview-save failed', raw: json }; const parsed = parsePipeEnc(json.data || ''); return { success: true, enc: parsed.enc || info.enc, encLastUpdateTime: parsed.encLastUpdateTime || info.encLastUpdateTime, realRemainTime: String(json.realRemainTime || parsed.remainTime || ''), raw: json }; } async function finalSubmitExam(info, opts = {}) { const oldHost = opts.oldHost || info.oldHost || 'https://mooc1.chaoxing.com'; const params = new URLSearchParams(); Object.entries({ classId: info.classId, courseId: info.courseId, testPaperId: info.testPaperId, testUserRelationId: info.testUserRelationId, cpi: info.cpi, version: '1', tempSave: 'false', pos: '3bb7410f70d3611d170388387f', rd: String(Math.random()), value: '(980|477)', qid: '', _edt: Date.now(), view: 'json', _csign: '0', _signcode: 'undefined', _signc: 'undefined', _signe: 'undefined', _signk: 'undefined', _cxcid: 'undefined', _cxtime: 'undefined', _signt: 'undefined' }).forEach(([k, v]) => params.set(k, String(v == null ? '' : v))); const data = { courseId: info.courseId, testPaperId: info.testPaperId, examCreateUserId: info.examCreateUserId || '', feedbackEnc: '', testUserRelationId: info.testUserRelationId, classId: info.classId, type: '0', remainTime: info.remainTime || '7200', tempSave: 'false', timeOver: 'false', encRemainTime: info.encRemainTime || '', encLastUpdateTime: info.encLastUpdateTime || String(Date.now()), enc: info.enc || '', userId: info.userId || '', cpi: info.cpi || '', examRelationId: info.testPaperId, enterPageTime: info.enterPageTime || String(Date.now()), exitdtime: '0', monitorforcesubmit: '0' }; const res = await request({ method: 'POST', url: `${oldHost}/exam-ans/exam/test/reVersionSubmitTestNew?${params.toString()}`, data: formEncodeObject(data), headers: { Accept: 'application/json, text/javascript, */*; q=0.01', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', Referer: opts.previewUrl || `${oldHost}/exam-ans/mooc2/exam/preview?courseId=${enc(info.courseId)}&classId=${enc(info.classId)}` }, timeout: 30000 }); const text = String(res.responseText || ''); try { const json = JSON.parse(text || '{}'); if (json.status === 'error' || json.success === false) throw new Error(json.msg || json.message || 'exam final submit rejected'); } catch (e) { if (/exam final submit rejected|10分钟内不允许提交|不允许提交|失败|错误|error/i.test(String(e.message || e) + text)) throw e; } return res; } function createWorkFrame(html, baseUrl) { return new Promise((resolve, reject) => { let box = document.getElementById('cx-runner-workframes'); if (!box) { box = document.createElement('div'); box.id = 'cx-runner-workframes'; box.style.cssText = 'position:fixed;left:-99999px;top:-99999px;width:1px;height:1px;overflow:hidden;'; document.body.appendChild(box); } const iframe = document.createElement('iframe'); iframe.setAttribute('translate', 'no'); iframe.setAttribute('frameborder', '0'); iframe.style.cssText = 'width:1px;height:1px;'; let srcdoc = String(html || '') .replaceAll(/alert\((.*?)\)/ig, 'void(0)') .replaceAll(/confirm\((.*?)\)/ig, 'true') .replaceAll('ua = "pc"', 'ua = "app"') .replaceAll(/
/ig, '') .replaceAll(/<\/br>/ig, ''); if (baseUrl && !/]*)>/i, ``); } iframe.onload = () => setTimeout(() => resolve(iframe), 800); iframe.onerror = () => reject(new Error('work iframe load failed')); box.appendChild(iframe); iframe.srcdoc = srcdoc; setTimeout(() => resolve(iframe), 2500); }); } function waitForElement(selector, timeout = 10000, root = document) { return new Promise((resolve, reject) => { const hit = () => { try { return root.querySelector(selector); } catch (_) { return null; } }; const first = hit(); if (first) return resolve(first); const start = Date.now(); const timer = setInterval(() => { const el = hit(); if (el) { clearInterval(timer); resolve(el); } else if (Date.now() - start >= timeout) { clearInterval(timer); reject(new Error(`wait element timeout: ${selector}`)); } }, 250); }); } function sleep(ms = 0) { return new Promise(resolve => setTimeout(resolve, Math.max(0, Number(ms || 0)))); } function parseWorkQuestions(doc, meta = {}) { const questions = []; const typeNameMap = { '0': '\u5355\u9009\u9898', '1': '\u591a\u9009\u9898', '2': '\u586b\u7a7a\u9898', '3': '\u5224\u65ad\u9898' }; const fontBase64 = meta.fontBase64 || extractCxsecretFont(meta.html || ''); const hasEncryptedFont = !!fontBase64 || /font-cxsecret/i.test(meta.html || ''); const newQuestionEls = Array.from(doc.querySelectorAll('.singleQuesId')); if (newQuestionEls.length) { const optionLis = Array.from(doc.querySelectorAll('li[qid], .Zy_ulTop li, .answerBg[qid]')); for (const el of newQuestionEls) { try { let qid = el.getAttribute('data') || el.dataset && el.dataset.data || ''; if (!qid || !/^\d{3,}$/.test(String(qid))) { const ansInput = el.querySelector('input[id^="answer"],input[name^="answer"]'); const m = ansInput && String(ansInput.id || ansInput.name || '').match(/answer(?:type)?(\d{3,})/); if (m) qid = m[1]; } const tiMu = el.querySelector('.TiMu'); const typeMeta = readQuestionTypeFromDom(el, qid); let typeN = typeMeta.typeN || ''; if (!qid || !['0', '1', '2', '3'].includes(typeN)) continue; const titleClone = (el.querySelector('.Zy_TItle') || el.querySelector('.Zy_TItle_new') || el.querySelector('h3.mark_name') || el.querySelector('.mark_name') || tiMu || el).cloneNode(true); titleClone.querySelectorAll('li,.Zy_ulTop,.stem_answer,.answerBg,.answer_p,.fl,.fr,.marking_answer,.marking_icon,.newZy_TItle').forEach(n => n.remove()); const rawQuestion = cleanText(titleClone.innerHTML || titleClone.textContent || ''); const question = stripQuestionPrefix(fontBase64 ? safeDecryptCxsecretText(rawQuestion, fontBase64) : rawQuestion, typeNameMap[typeN] || ''); if (!question) continue; const options = []; let inputList = []; if (typeN === '0' || typeN === '1' || typeN === '3') { const optionNodes = Array.from(el.querySelectorAll('li[qid], .Zy_ulTop li, .answerBg[qid], .answerBg, li')); for (const li of optionNodes) { const marker = li.querySelector('span[data]') || li.querySelector('.num_option,.num_option_dx') || li.querySelector('i.fl,i'); const value = marker && (marker.getAttribute('data') || marker.textContent || '').trim().replace(/[、..\uff0e\uff0c,::\s]+$/g, ''); const visualLabel = marker && (marker.textContent || '').trim(); let label = value || visualLabel; if (!/^[A-Z]$/i.test(label || '')) { const lm = cleanText(li.textContent || '').match(/^([A-Z])\s*/i); if (lm) label = lm[1]; } const contentEl = li.querySelector('a.fl.after') || li.querySelector('a.after') || li.querySelector('.answer_p') || li; const raw = cleanText(contentEl.innerHTML || contentEl.textContent || ''); const rawContent = raw.replace(/^[A-Z]\s*[、..\uff0e\uff0c,::]?\s*/i, '').trim(); const content = fontBase64 ? safeDecryptCxsecretText(rawContent, fontBase64) : rawContent; if (label && content) options.push({ label, content, raw: rawContent, encryptedContent: rawContent, visualLabel }); } if (typeN === '3' && !options.length) options.push({ label: 'A', content: '\u5bf9' }, { label: 'B', content: '\u9519' }); } else if (typeN === '2') { inputList = Array.from(el.querySelectorAll('input.answerInput,textarea,[contenteditable="true"]')); } questions.push({ question, rawQuestion, typeN, typeName: typeNameMap[typeN], qid, questionId: `answer${qid}`, options, inputList, element: el, typeSource: typeMeta.source || '', fontBase64, hasEncryptedFont, htmlContent: hasEncryptedFont ? (el.outerHTML || '') : '' }); } catch (_) { } } // 解析不出来就返回空,由上层记诊断日志并跳过,不从渲染文本里猜题目和选项。 return { questions, optionLis }; } const questionEls = Array.from(doc.getElementsByClassName('Py-mian1')); const optionLis = Array.from(doc.getElementsByTagName('li')).filter(li => li.getAttribute('id-param')); for (const el of questionEls) { try { const inputEls = Array.from(el.querySelectorAll('input')); let questionId = ''; let typeN = ''; let blankCount = 0; for (const inp of inputEls) { const id = inp.id || ''; const name = inp.getAttribute('name') || ''; if (name.includes('tiankongsize') && inp.value) blankCount = Number(inp.value || 0); if (id.includes('answer')) { if (id.includes('answertype')) { typeN = inp.value || typeN; if (/^answertype\d+/.test(id)) questionId = id.replace('answertype', 'answer'); } else if (/answer(s)?\d+/.test(id)) { questionId = id.replace('answers', 'answer'); } } } const typeMeta = readQuestionTypeFromDom(el, questionId.replace(/^answer/, '')); if (typeMeta.typeN) typeN = typeMeta.typeN; if (!['0', '1', '2', '3'].includes(typeN) || !questionId) continue; const titleEl = el.getElementsByClassName('Py-m1-title fs16')[0] || el.querySelector('.Py-m1-title'); const rawQuestion = cleanText(titleEl ? titleEl.innerHTML : ''); const question = stripQuestionPrefix(fontBase64 ? safeDecryptCxsecretText(rawQuestion, fontBase64) : rawQuestion, typeNameMap[typeN] || ''); if (!question) continue; const typeName = typeNameMap[typeN]; const options = []; let inputList = []; if (typeN === '0' || typeN === '1') { const lis = Array.from((el.getElementsByClassName('answerList')[0] || el).getElementsByTagName('li')); for (const li of lis) { const em = li.querySelector('em'); const raw = cleanText(li.innerHTML); const label = em ? (em.getAttribute('id-param') || em.textContent || '').trim() : raw.slice(0, 1); const rawContent = raw.replace(/^[A-Z]\.?\s*/i, '').trim(); const content = fontBase64 ? safeDecryptCxsecretText(rawContent, fontBase64) : rawContent; if (label && content) options.push({ label, content, raw }); } } else if (typeN === '3') { options.push({ label: 'A', content: '\u5bf9' }, { label: 'B', content: '\u9519' }); } else if (typeN === '2') { inputList = Array.from(el.querySelectorAll('input.answerInput')); if (!inputList.length && blankCount) { for (const iframe of Array.from(el.querySelectorAll('iframe[id^="ueditor_"]'))) { const d = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document); const p = d && d.querySelector('p'); if (p) inputList.push(p); } } } questions.push({ question, rawQuestion, typeN, typeName, questionId, options, inputList, element: el, typeSource: typeMeta.source || '', fontBase64, hasEncryptedFont, htmlContent: hasEncryptedFont ? (el.outerHTML || '') : '' }); } catch (_) {} } return { questions, optionLis }; } function getLiveField(jobData, key) { const prop = jobData && jobData.property || {}; const lower = String(key || '').toLowerCase(); const candidates = [ jobData && jobData[key], prop && prop[key], jobData && jobData[lower], prop && prop[lower] ]; for (const v of candidates) { if (v !== undefined && v !== null && v !== '') return String(v); } return ''; } function getClassTaskField(jobData, key) { const prop = jobData && jobData.property || {}; const lower = String(key || '').toLowerCase(); const candidates = [ jobData && jobData[key], prop && prop[key], jobData && jobData[lower], prop && prop[lower], prop && prop.clazzTask && prop.clazzTask[key], prop && prop.classTask && prop.classTask[key] ]; for (const v of candidates) { if (v !== undefined && v !== null && v !== '') return String(v); } if (lower === 'uuid') return extractClassTaskUuidFromAttachment(jobData, prop); return ''; } function extractClassTaskUuidFromAttachment(jobData, propArg = null) { const prop = propArg || (jobData && jobData.property) || {}; const direct = [ jobData && jobData.uuid, prop && prop.uuid, prop && prop.clazzTask && prop.clazzTask.uuid, prop && prop.classTask && prop.classTask.uuid ].find(v => v !== undefined && v !== null && v !== ''); if (direct) return String(direct); const blob = [ jobData && jobData.viewLink, prop && prop.viewLink, jobData && jobData.url, prop && prop.url, jobData && jobData.href, prop && prop.href, jobData && jobData.otherInfo, prop && prop.otherInfo, jobData && jobData.module, prop && prop.module, jobData && jobData.data, prop && prop.data ].filter(Boolean).map(v => typeof v === 'string' ? v : JSON.stringify(v)).join(' '); return pickQueryFromUrl(blob, 'uuid') || ((blob.match(/\/res\/look\/index\.html\?[^"'\s<>]*uuid=([^&"'\s<>]+)/i) || [])[1] || '') || ((blob.match(/\/res\/look\/(?:index\.html)?\/?([A-Za-z0-9_-]{8,})(?:[/?#]|$)/i) || [])[1] || ''); } function liveStatusText(status) { const n = Number(status); if (n === 0) return '未开始'; if (n === 1) return '直播中'; if (n === 3) return '已中断'; if (n === 4) return '回看'; return `状态${status}`; } function parseLivePage(html) { const s = String(html || ''); const out = {}; const pickStr = (name) => { const re = new RegExp(`(?:var\\s+)?${name}\\s*=\\s*['"]([^'"]+)['"]`, 'i'); const m = s.match(re); return m ? m[1] : ''; }; const pickNum = (name) => { const re = new RegExp(`(?:var\\s+)?${name}\\s*=\\s*['"]?(\\d+)['"]?`, 'i'); const m = s.match(re); return m ? m[1] : ''; }; out.streamName = pickStr('streamName'); out.vdoid = pickStr('vdoid'); out.userId = pickStr('userId'); out.uInfo = pickStr('uInfo'); out.courseId = pickNum('courseId'); out.liveStatus = pickNum('liveStatus'); out.liveId = pickNum('liveId'); out.knowledgeId = pickStr('knowledgeId') || pickNum('knowledgeId'); out.classId = pickStr('classId') || pickNum('classId'); const urlMatch = s.match(/urls\s*=\s*['"]([^'"]+\.m3u8[^'"]*)['"]/i) || s.match(/['"]([^'"]+\.m3u8[^'"]*)['"]/i); if (urlMatch) out.videoUrl = urlMatch[1]; return out; } function paymentSiteOrigin(apiUrl) { const url = new URL(apiUrl || DEFAULT_ANSWER_API_URL); if (url.username || url.password || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw new Error('invalid payment site'); return url.origin; } function paymentDisplayError(value, token = '') { let text = String(value || '支付服务暂时不可用'); if (token) text = text.split(token).join('[已隐藏]'); return text.replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]').replace(/Bearer\s+[^\s"',;]+/gi, 'Bearer [已隐藏]').replace(/\s+/g, ' ').slice(0, 160); } async function requestPaymentCheckout(token, apiUrl) { const origin = paymentSiteOrigin(apiUrl); const response = await request({ method: 'POST', url: origin + '/api/pay/checkout-sessions', data: '{}', headers: { 'Content-Type': 'application/json', 'X-User-Token': token }, timeout: 15000, allowStatuses: [400, 401, 403, 404, 429, 500, 502, 503] }); let json; try { json = JSON.parse(response.responseText || ''); } catch (_) { throw new Error('充值接口未返回有效响应,请部署新版支付服务'); } if (!json.success || !json.data) throw new Error(paymentDisplayError(json.message || '充值会话创建失败', token)); const url = new URL(json.data.checkoutUrl); const fragment = new URLSearchParams(url.hash.slice(1)); if (url.origin !== origin || url.pathname !== '/recharge' || url.username || url.password || url.search || !/^[a-f0-9]{64}$/.test(fragment.get('ticket') || '') || !/^[a-f0-9]{32}$/.test(fragment.get('checkout') || '') || url.href.includes(token) || (url.hash.match(/ticket=/g) || []).length !== 1) throw new Error('支付链接未通过校验,请核对支付站点配置'); return { checkoutUrl: url.href, expiresAt: json.data.expiresAt }; } function answerApiVerifyUrls(apiUrl) { const urls = []; try { const u = new URL(apiUrl, location.href); const base = `${u.origin}`; urls.push(`${base}/api/verify-token`); urls.push(`${base}/api/user/verify-token`); } catch (_) {} return [...new Set(urls)]; } function answerApiPublicTokenUrls(apiUrl) { const urls = []; try { const u = new URL(apiUrl, location.href); const base = `${u.origin}`; urls.push(`${base}/api/public-token`); urls.push(`${base}/api/user/public-token`); } catch (_) {} return [...new Set(urls)]; } function answerApiAnnouncementUrls(apiUrl) { const urls = []; try { const u = new URL(apiUrl, location.href); const base = `${u.origin}`; urls.push(`${base}/api/announcement`); } catch (_) {} try { const u = new URL(DEFAULT_ANSWER_API_URL, location.href); urls.push(`${u.origin}/api/announcement`); } catch (_) {} urls.push('https://www.toptk.xyz/api/announcement'); return [...new Set(urls)]; } async function fetchAnnouncement(apiUrl) { for (const url of answerApiAnnouncementUrls(apiUrl)) { try { const res = await request({ method: 'GET', url, headers: { 'Accept': 'application/json' }, timeout: 10000, withCredentials: false }); const json = JSON.parse(res.responseText || '{}'); const data = json && (json.data || json.announcement || json); if (data && json.success !== false) return data; } catch (_) {} } return null; } function randomDeviceId() { try { const arr = new Uint8Array(16); crypto.getRandomValues(arr); return Array.from(arr).map(n => n.toString(16).padStart(2, '0')).join(''); } catch (_) { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 14)}`; } } function getOrCreatePublicTokenDeviceId() { const key = 'cx_runner_public_token_device_id'; try { let id = localStorage.getItem(key) || ''; if (!/^[a-zA-Z0-9_-]{12,128}$/.test(id)) { id = randomDeviceId(); localStorage.setItem(key, id); } return id; } catch (_) { return randomDeviceId(); } } function collectPublicTokenDeviceInfo() { const nav = navigator || {}; const scr = screen || {}; let timezone = ''; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (_) {} return { platform: nav.platform || '', language: nav.language || '', languages: Array.isArray(nav.languages) ? nav.languages.slice(0, 4).join(',') : '', screen: `${scr.width || 0}x${scr.height || 0}x${scr.colorDepth || 0}`, timezone, hardwareConcurrency: nav.hardwareConcurrency || '', deviceMemory: nav.deviceMemory || '', touchPoints: nav.maxTouchPoints || 0, vendor: nav.vendor || '', scriptVersion: (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '' }; } async function acquirePublicAnswerToken(apiUrl) { const errors = []; const deviceId = getOrCreatePublicTokenDeviceId(); const deviceInfo = collectPublicTokenDeviceInfo(); for (const url of answerApiPublicTokenUrls(apiUrl)) { try { const res = await request({ method: 'POST', url, data: JSON.stringify({ source: 'chaoxing-userscript', deviceId, deviceInfo, clientVersion: deviceInfo.scriptVersion || '' }), headers: { 'Content-Type': 'application/json' }, timeout: 12000 }); const json = JSON.parse(res.responseText || '{}'); const token = String(json.token || (json.data && (json.data.userToken || json.data.token)) || '').trim(); // /api/public-token 本身就是发放接口:只要返回成功且带 token,就直接保存。 // 之前这里强制二次调用 /api/verify-token,若部署侧 verify 路由不同步会导致“已发放但脚本误判失败”。 if (json && json.success !== false && token) { return { token, data: { ...json, ...(json.data || {}) } }; } errors.push(`${url}: ${json && json.message ? json.message : '未返回有效 token'}`); } catch (e) { errors.push(`${url}: ${e && e.message ? e.message : e}`); } } return { token: '', error: errors.join(' | ') }; } function maskToken(token) { token = String(token || ''); if (token.length <= 16) return token || '-'; return `${token.slice(0, 8)}...${token.slice(-6)}`; } async function verifyAnswerToken(token, apiUrl) { const info = await fetchAnswerTokenInfo(token, apiUrl); return !!(info && info.valid); } async function fetchAnswerTokenInfo(token, apiUrl) { token = String(token || '').trim(); if (!token || token.length < 16) return null; for (const url of answerApiVerifyUrls(apiUrl)) { try { const res = await request({ method: 'POST', url, data: JSON.stringify({ userToken: token }), headers: { 'Content-Type': 'application/json', 'X-User-Token': token }, timeout: 12000 }); const json = JSON.parse(res.responseText || '{}'); if (json && json.success !== false) { return { valid: true, data: { ...json, ...(json.data || {}) }, raw: json }; } } catch (_) {} } return null; } function extractTokenStats(data = {}) { const d = data || {}; const nested = d.data || {}; const user = d.userInfo || d.user || nested.userInfo || nested.user || {}; const pickNum = (...vals) => { for (const v of vals) { if (v === null || v === undefined || v === '') continue; const n = Number(v); if (Number.isFinite(n)) return n; } return null; }; return { remainingCount: pickNum(d.remainingCount, nested.remainingCount, user.remainingCount, d.quota?.remaining, nested.quota?.remaining), totalCalls: pickNum(d.totalCalls, nested.totalCalls, user.totalCalls), successCount: pickNum(d.successCount, nested.successCount, user.successCount) }; } function tokenStatusText(info = {}) { const remaining = info && info.remainingCount; if (info && info.tokenVerifyFailed) return 'Token \u5df2\u4fdd\u7559\uff0c\u4f46\u6682\u65f6\u65e0\u6cd5\u8bfb\u53d6\u5269\u4f59\u6b21\u6570\uff1b\u8bf7\u68c0\u67e5\u7b54\u9898\u63a5\u53e3\u6216\u624b\u52a8\u91cd\u65b0\u7ed1\u5b9a\u3002'; if (remaining === 0) return 'Token \u5269\u4f59\u7b54\u9898\u6b21\u6570\u4e3a 0\uff0c\u8bf7\u70b9\u51fb\u201c\u5145\u503c\u6b21\u6570\u201d\u540e\u7ee7\u7eed\u4f7f\u7528\u3002'; if (remaining !== null && remaining !== undefined) return `Token \u5df2\u7ed1\u5b9a\uff0c\u5269\u4f59\u7b54\u9898\u6b21\u6570\uff1a${remaining}`; return 'Token \u5df2\u7ed1\u5b9a\uff0c\u8bf7\u590d\u5236\u4fdd\u5b58\u6216\u7528\u4e8e\u5145\u503c\u3002'; } function yuketangSameOriginDocuments() { const docs = [document]; for (const frame of Array.from(document.querySelectorAll('iframe'))) { try { if (frame.contentDocument && frame.contentDocument.body) docs.push(frame.contentDocument); } catch (_) {} } return docs; } function zhihuishuContextFromLocation(overrides = {}) { const q = new URLSearchParams(location.search || ''); const hashText = String(location.hash || ''); const pick = (...names) => { for (const n of names) { const v = overrides[n] || q.get(n) || pickQueryFromUrl(overrides.entryUrl || overrides.href || location.href, n); if (v) return v; } return ''; }; const mRecruit = hashText.match(/recruitAndCourseId=([^&]+)/i) || String(location.href || '').match(/recruitAndCourseId=([^&]+)/i); const recruitAndCourseId = overrides.recruitAndCourseId || (mRecruit && decodeURIComponent(mRecruit[1])) || pick('recruitAndCourseId') || ''; const entryUrl = overrides.entryUrl || buildZhihuishuStudyUrl(recruitAndCourseId) || overrides.href || location.href; const origin = safeUrlOrigin(entryUrl) || overrides.siteHost || location.origin; return { ...overrides, platform: 'zhihuishu', href: overrides.href || location.href, entryUrl, recruitAndCourseId, courseId: overrides.courseId || pick('courseId', 'courseid'), recruitId: overrides.recruitId || pick('recruitId', 'recruitid'), siteHost: overrides.siteHost || origin, oldSiteHost: overrides.oldSiteHost || origin, siteHosts: overrides.siteHosts || [origin], courseName: overrides.courseName || detectCourseName() || document.title || '智慧树课程' }; } function safeUrlOrigin(url) { try { return url ? new URL(url, location.href).origin : ''; } catch (_) { return ''; } } function buildZhihuishuStudyUrl(recruitAndCourseId = '') { const secret = String(recruitAndCourseId || '').trim(); if (!secret) return ''; return `https://studyvideoh5.zhihuishu.com/stuStudy?recruitAndCourseId=${encodeURIComponent(secret)}`; } function detectZhihuishuCourseEntryFromPage(overrides = {}) { const out = { ...overrides, platform: 'zhihuishu' }; const href = String(overrides.href || location.href || ''); const q = new URLSearchParams(location.search || ''); let recruitAndCourseId = overrides.recruitAndCourseId || q.get('recruitAndCourseId') || pickQueryFromUrl(href, 'recruitAndCourseId') || ''; if (!recruitAndCourseId) { try { const vm = Array.from(document.querySelectorAll('*')) .map(el => el && el.__vue__) .find(v => v && v.$data && Array.isArray(v.$data.sharingData) && v.$data.sharingData.length); const rows = vm && vm.$data && vm.$data.sharingData || []; const matching = rows.filter(c => (!overrides.courseId || String(c.courseId) === String(overrides.courseId)) && (!overrides.recruitId || String(c.recruitId) === String(overrides.recruitId))); const course = matching.length === 1 && (overrides.courseId || overrides.recruitId || rows.length === 1) ? matching[0] : null; if (course) { recruitAndCourseId = course.secret || recruitAndCourseId; out.courseName = out.courseName || course.courseName || ''; out.courseId = out.courseId || course.courseId || ''; out.recruitId = out.recruitId || course.recruitId || ''; out.progress = out.progress || course.progress || ''; out.teacherName = out.teacherName || course.teacherName || ''; } } catch (_) {} } if (!recruitAndCourseId && /studyvideoh5\.zhihuishu\.com/i.test(location.hostname || '')) { recruitAndCourseId = q.get('recruitAndCourseId') || ''; } if (recruitAndCourseId) { out.recruitAndCourseId = recruitAndCourseId; out.entryUrl = buildZhihuishuStudyUrl(recruitAndCourseId); } else if (/studyvideoh5\.zhihuishu\.com/i.test(location.hostname || '') && /\/stuStudy/i.test(location.pathname || '')) { out.entryUrl = location.href; } if (!out.courseName) out.courseName = detectCourseName() || document.title || '智慧树课程'; return out; } function isYuketangExamIntroPage() { if (!/\/v2\/web\/exam\/\d+\/\d+/i.test(location.pathname || '')) return false; const docs = yuketangSameOriginDocuments(); const hasQuestionUi = docs.some(doc => doc.querySelector( '.container-problem,.exam-aside,[class*="problem-content"],[class*="exercise-item"],[class*="problem-item"]' )); if (hasQuestionUi) return false; const text = docs.map(doc => doc.body && doc.body.innerText || '').join('\n'); return /开始答题/.test(text) && /考试须知|考生须知|试卷分数|考试限时|诚信/.test(text); } function yuketangContextFromLocation(overrides = {}) { const m = String(location.pathname || '').match(/\/v2\/web\/(?:cloud\/student\/exercise|exercise)\/(\d+)\/(\d+)\/(\d+)/i); const exam = String(location.pathname || '').match(/\/v2\/web\/exam\/(\d+)\/(\d+)/i); const studentLog = String(location.pathname || '').match(/\/v2\/web\/studentLog\/(\d+)/i); const lms = String(location.pathname || '').match(/\/v2\/web\/(?:lms|xcloud\/video-student)\/(\d+)(?:\/([^/?#]+))?/i); const proLms = String(location.pathname || '').match(/\/pro\/lms\/[^/]+\/(\d+)\//i); const cookieClassroom = getCookie('classroom_id') || getCookie('classroomId'); const universityId = getCookie('university_id') || getCookie('uv_id') || new URLSearchParams(location.search || '').get('university_id') || ''; const search = new URLSearchParams(location.search || ''); const classroomId = overrides.classroomId || (m && m[1]) || (exam && exam[1]) || (studentLog && studentLog[1]) || (lms && lms[1]) || (proLms && proLms[1]) || cookieClassroom; if (!classroomId) throw new Error('无法解析雨课堂 classroom_id'); return { classroomId, leafId: overrides.leafId || (m && m[2]) || search.get('leaf_id') || (lms && lms[2]) || '', skuId: overrides.skuId || (m && m[3]) || '', examExerciseId: overrides.examExerciseId || (exam && exam[2]) || '', courseId: overrides.courseId || '', coursewareId: overrides.coursewareId || '', userId: overrides.userId || '', courseName: overrides.courseName || '', universityId: overrides.universityId || universityId, csrfToken: getCookie('csrftoken') || '' }; } function yuketangHeaders(ctx = {}, extra = {}) { const universityId = ctx.universityId || getCookie('university_id') || getCookie('uv_id') || ''; const classroomId = ctx.classroomId || getCookie('classroom_id') || getCookie('classroomId') || ''; const headers = { 'accept': 'application/json, text/plain, */*', 'x-csrftoken': ctx.csrfToken || getCookie('csrftoken') || '', 'classroom-id': classroomId, 'university-id': universityId, 'uv-id': universityId, 'xt-agent': 'web', 'xtbz': 'ykt', 'referer': location.href }; for (const [k, v] of Object.entries(extra || {})) if (v !== undefined && v !== null && v !== '') headers[k] = v; return headers; } async function loadYuketangJson(url, ctx = {}, opts = {}) { const res = await request({ method: opts.method || 'GET', url, data: opts.data, headers: yuketangHeaders(ctx, opts.headers || {}), withCredentials: true, timeout: opts.timeout || 30000 }); try { const json = JSON.parse(res.responseText || '{}'); if (json && typeof json === 'object') { try { Object.defineProperty(json, '__raw', { value: res.responseText || '', enumerable: false, configurable: true }); } catch (_) {} } return json; } catch (e) { throw new Error(`雨课堂接口返回非 JSON:${compactResponseText(res.responseText)}`); } } function yuketangOrigin() { if (/(^|\.)yuketang\.cn$/i.test(location.hostname || '')) return location.origin; return 'https://www.yuketang.cn'; } function normalizeYuketangTypeName(content = {}) { const problemType = String(content.ProblemType ?? content.problem_type ?? content.problemType ?? '').trim(); if (problemType === '5') return '多选题'; if (problemType === '6') return '判断题'; if (problemType === '4') return '填空题'; if (problemType === '2' || problemType === '3') return '简答题'; if (problemType === '1') return '单选题'; const text = String(content.TypeText || content.type_text || content.Type || '').trim(); if (/多选|Multiple|Multi/i.test(text)) return '多选题'; if (/判断|Judgement|Judge|True/i.test(text)) return '判断题'; if (/填空|Blank|Completion/i.test(text)) return '填空题'; if (/简答|问答|Short|Essay/i.test(text)) return '简答题'; return '单选题'; } function yuketangQuestionTextFromBody(body = '') { return cleanText(String(body || '') .replace(//ig, ' ') .replace(//ig, ' ') .replace(/]*alt=["']?([^"'>]+)["']?[^>]*>/ig, '$1')); } function parseYuketangProblem(problem = {}, index = 0) { const content = problem.content || problem.problem || {}; const typeName = normalizeYuketangTypeName(content); const rawOptions = content.Options || content.options || []; const questionIndex = Number(problem.index || problem.no || problem.number || content.index || content.no || content.number || index + 1) || index + 1; const options = rawOptions.map((o, i) => { const key = o.key != null ? String(o.key) : String.fromCharCode(65 + i); const label = /^[A-Z]$/i.test(key) ? key.toUpperCase() : String.fromCharCode(65 + i); return { label, key, content: cleanText(o.value != null ? o.value : (o.content || o.text || '')) }; }); return { question: yuketangQuestionTextFromBody(content.Body || content.body || content.Title || content.title || ''), typeName, questionId: problem.problem_id || content.ProblemID || content.problem_id || content.id, qid: problem.problem_id || content.ProblemID || content.problem_id || content.id, index: questionIndex, problemType: String(content.ProblemType ?? content.problem_type ?? content.problemType ?? ''), options, raw: problem, platform: 'yuketang' }; } async function loadYuketangCourseContext(overrides = {}) { const base = yuketangContextFromLocation(overrides || {}); const loadLogs = async actype => { const logsUrl = `${yuketangOrigin()}/v2/api/web/logs/learn/${enc(base.classroomId)}?actype=${enc(actype)}&page=0&offset=20&sort=-1`; const logs = await loadYuketangJson(logsUrl, base, { headers: { 'x-client': 'web' } }); return logs && logs.data && Array.isArray(logs.data.activities) ? logs.data.activities : []; }; let activities = await loadLogs(15).catch(() => []); if (!activities.length) activities = await loadLogs(-1).catch(() => []); const courseware = activities.find(a => a && a.courseware_id) || {}; const skuId = base.skuId || (courseware.content && courseware.content.sku_id) || ''; const coursewareId = base.coursewareId || courseware.courseware_id || ''; let courseId = base.courseId || ''; let userId = base.userId || ''; let courseName = base.courseName || ''; try { const list = await loadYuketangJson(`${yuketangOrigin()}/v2/api/web/courses/list?identity=2`, base, { headers: { 'x-client': 'web' } }); const courses = list && list.data && Array.isArray(list.data.list) ? list.data.list : []; const matched = courses.find(c => String(c.classroom_id) === String(base.classroomId)); if (matched) { courseId = courseId || (matched.course && matched.course.id); courseName = cleanText((matched.course && matched.course.name) || matched.name || courseName || ''); base.universityId = base.universityId || (matched.course && matched.course.university_id) || ''; } } catch (_) {} // 新版 /pro/lms/.../studycontent 页面不一定依赖旧 courseware_id; // 目录改走 /mooc-api/v1/lms/learn/course/chapter?cid=classroom_id。 if (!coursewareId && !base.classroomId) throw new Error('未解析到雨课堂 classroom_id'); return { ...base, skuId, coursewareId, courseId, userId, courseName }; } async function loadYuketangCatalog(ctx = {}) { const proUrl = `${yuketangOrigin()}/mooc-api/v1/lms/learn/course/chapter?cid=${enc(ctx.classroomId)}`; const pro = await loadYuketangJson(proUrl, ctx, { headers: { 'x-client': 'web' } }); if (!pro || pro.success === false) throw new Error(`雨课堂新版课程目录读取失败:${pro && (pro.msg || pro.message) || 'unknown'}`); return { ...(pro.data || {}), _source: 'course_chapter' }; } async function loadYuketangSchedule(ctx = {}) { try { const proUrl = `${yuketangOrigin()}/mooc-api/v1/lms/learn/course/schedule?cid=${enc(ctx.classroomId)}`; const pro = await loadYuketangJson(proUrl, ctx, { headers: { 'x-client': 'web' } }); if (pro && pro.success !== false && pro.data) return pro.data; } catch (_) {} return {}; } function flattenYuketangLeaves(catalog = {}) { const out = []; const visit = node => { if (!node || typeof node !== 'object') return; if (node.id && node.leaf_type !== undefined) out.push(node); if (Array.isArray(node.leaf_list)) { for (const leaf of node.leaf_list) if (leaf && leaf.id) out.push(leaf); } for (const key of ['content_info', 'section_list', 'course_chapter', 'section_leaf_list', 'children', 'child_list', 'chapters', 'sections']) { const arr = node[key]; if (Array.isArray(arr)) arr.forEach(visit); } }; visit(catalog); const seen = new Set(); return out.filter(x => { const key = `${x.id}|${x.leaf_type}`; if (seen.has(key)) return false; seen.add(key); return true; }); } function yuketangLeafKindName(type) { const map = { 0: '视频', 1: '音频', 3: '文档/图文', 4: '讨论', 7: 'PPT', 10: '公告/图文' }; return map[Number(type)] || `任务${type}`; } function isYuketangScheduleCompleteValue(v) { if (v === undefined || v === null || v === '') return false; if (typeof v === 'number' || typeof v === 'string') return Number(v) >= 1; if (typeof v !== 'object') return false; const done = Number(v.done ?? v.finished ?? v.completed_count ?? 0); const total = Number(v.total ?? v.count ?? 0); if (total > 0 && done >= total) return true; if (Number(v.schedule ?? v.rate ?? v.progress ?? v.completed ?? 0) >= 1) return true; if (Number(v.status || 0) >= 1 && /已完成|完成/i.test(String(v.status_text || v.text || ''))) return true; return false; } function isYuketangLeafDone(schedule = {}, leaf = {}) { const infos = schedule && Array.isArray(schedule.leaf_level_infos) ? schedule.leaf_level_infos : []; const row = infos.find(x => String(x.id) === String(leaf.id)); if (row && isYuketangScheduleCompleteValue(row.schedule)) return true; const maps = [schedule.leaf_schedules, schedule.leafSchedules, schedule.schedules]; for (const map of maps) { if (!map || typeof map !== 'object') continue; const v = map[String(leaf.id)] ?? map[leaf.id]; if (isYuketangScheduleCompleteValue(v)) return true; } return false; } function yuketangLeavesFromSchedule(schedule = {}) { const infos = schedule && Array.isArray(schedule.leaf_level_infos) ? schedule.leaf_level_infos : []; const fromOld = infos .filter(x => x && x.id) .map(x => ({ id: x.id, leaf_type: x.leaf_type, title: [x.leaf_chapter_title, x.leaf_level_title].filter(Boolean).join(' - '), schedule: x.schedule })); if (fromOld.length) return fromOld; const map = schedule.leaf_schedules || schedule.leafSchedules || {}; return Object.entries(map || {}).map(([id, v]) => ({ id, leaf_type: v && typeof v === 'object' ? v.leaf_type : undefined, title: v && typeof v === 'object' ? (v.name || v.title || '') : '', schedule: v && typeof v === 'object' ? (v.schedule || v.rate || v.progress) : v })).filter(x => x.id); } function getYuketangLeafSchedule(schedule = {}, leaf = {}) { const infos = schedule && Array.isArray(schedule.leaf_level_infos) ? schedule.leaf_level_infos : []; const row = infos.find(x => String(x.id) === String(leaf.id)); if (row) return row.schedule ?? row; const maps = [schedule.leaf_schedules, schedule.leafSchedules, schedule.schedules]; for (const map of maps) { if (!map || typeof map !== 'object') continue; const v = map[String(leaf.id)] ?? map[leaf.id]; if (v !== undefined) return v; } return leaf.schedule || {}; } function deepFind(obj, names = []) { const wanted = new Set(names.map(x => String(x).toLowerCase())); const seen = new Set(); const walk = v => { if (!v || typeof v !== 'object' || seen.has(v)) return ''; seen.add(v); for (const [k, val] of Object.entries(v)) { if (wanted.has(String(k).toLowerCase()) && val !== undefined && val !== null && val !== '') return val; } for (const val of Object.values(v)) { const got = walk(val); if (got !== '') return got; } return ''; }; return walk(obj); } async function loadYuketangLeafInfo(ctx = {}, leafId = '') { const useCtx = { ...ctx, leafId: leafId || ctx.leafId }; const leafUrl = `${yuketangOrigin()}/mooc-api/v1/lms/learn/leaf_info/${enc(useCtx.classroomId)}/${enc(useCtx.leafId)}/`; const leafJson = await loadYuketangJson(leafUrl, useCtx); if (!leafJson || leafJson.success === false) throw new Error(`雨课堂 leaf_info 失败:${leafJson && (leafJson.msg || leafJson.message) || 'unknown'}`); return leafJson.data || {}; } async function loadYuketangExerciseQuestions(inputCtx) { const ctx = inputCtx ? yuketangContextFromLocation(inputCtx) : yuketangContextFromLocation(); const leafUrl = `${yuketangOrigin()}/mooc-api/v1/lms/learn/leaf_info/${enc(ctx.classroomId)}/${enc(ctx.leafId)}/`; const leafJson = await loadYuketangJson(leafUrl, ctx); if (!leafJson || leafJson.success === false) throw new Error(`雨课堂 leaf_info 失败:${leafJson && (leafJson.msg || leafJson.message) || 'unknown'}`); const leaf = leafJson.data || {}; const leafTypeId = ctx.examExerciseId || (leaf.content_info && (leaf.content_info.leaf_type_id || leaf.content_info.id)) || leaf.leaf_type_id; if (!leafTypeId) throw new Error('雨课堂 leaf_info 缺少 leaf_type_id'); const exerciseUrl = `${yuketangOrigin()}/mooc-api/v1/lms/exercise/get_exercise_list/${enc(leafTypeId)}/`; const exerciseJson = await loadYuketangJson(exerciseUrl, ctx); if (!exerciseJson || exerciseJson.success === false) throw new Error(`雨课堂题目接口失败:${exerciseJson && (exerciseJson.msg || exerciseJson.message) || 'unknown'}`); const data = exerciseJson.data || {}; const problems = Array.isArray(data.problems) ? data.problems : []; const questions = problems.map((problem, index) => parseYuketangProblem(problem, index)).filter(q => q.question && q.qid); return { context: { ...ctx, leafTypeId, exerciseId: data.exercise_id || leafTypeId }, questions, raw: exerciseJson }; } async function finishYuketangArticle(ctx = {}, leaf = {}, logger = null) { const statusUrl = `${yuketangOrigin()}/mooc-api/v1/lms/learn/user_article_finish_status/${enc(leaf.id)}/`; const status = await loadYuketangJson(statusUrl, ctx).catch(() => ({})); if (status && status.data && Number(status.data.finish || 0) >= 1) return true; const url = `${yuketangOrigin()}/mooc-api/v1/lms/learn/user_article_finish/${enc(leaf.id)}/?cid=${enc(ctx.classroomId)}&sid=${enc(ctx.skuId)}`; const json = await loadYuketangJson(url, ctx); const after = await loadYuketangJson(statusUrl, ctx).catch(() => ({})); if (logger) { const raw = json && json.__raw ? compactResponseText(json.__raw).slice(0, 300) : ''; const finish = after && after.data ? after.data.finish : ''; logger(`雨课堂文档/图文完成:leaf=${leaf.id} finish=${finish}${raw ? ` raw=${raw}` : ''}`); } return !!(json && json.success !== false) || !!(after && after.data && Number(after.data.finish || 0) >= 1); } async function finishYuketangDiscussion(ctx = {}, leaf = {}, logger = null) { const url = `${yuketangOrigin()}/v/discussion/v2/unit/discussion/?classroom_id=${enc(ctx.classroomId)}&sku_id=${enc(ctx.skuId)}&leaf_id=${enc(leaf.id)}&topic_type=4&channel=xt`; const json = await loadYuketangJson(url, ctx); const data = json && json.data || {}; if (Number(data.user_comment_num || 0) > 0) { if (logger) logger(`雨课堂讨论已评论:leaf=${leaf.id} topic=${data.id || data.topic_id || '-'} user_comment_num=${data.user_comment_num}`); return true; } const topicId = data.id || data.topic_id; if (!topicId) { if (logger) { const raw = json && json.__raw ? compactResponseText(json.__raw).slice(0, 500) : ''; logger(`雨课堂讨论缺少 topic_id:leaf=${leaf.id}${raw ? ` raw=${raw}` : ''}`); } return false; } const body = JSON.stringify({ to_user: data.user_id || 0, topic_id: topicId, content: { text: '好', upload_images: [], accessory_list: [] } }); const post = await loadYuketangJson(`${yuketangOrigin()}/v/discussion/v2/comment/`, ctx, { method: 'POST', data: body, headers: { 'content-type': 'application/json;charset=UTF-8', 'origin': yuketangOrigin() } }); if (logger) { const raw = post && post.__raw ? compactResponseText(post.__raw).slice(0, 500) : ''; logger(`雨课堂讨论评论:leaf=${leaf.id} topic=${topicId}${raw ? ` raw=${raw}` : ''}`); } return !!(post && (post.success !== false) && (post.code === 0 || post.data)); } async function loadYuketangLeafLevelInfo(ctx = {}, leafId = '') { const url = `${yuketangOrigin()}/edu_admin/leaf_level_info/?leaf_level_id=${enc(leafId)}&no_loading=false`; const json = await loadYuketangJson(url, ctx, { headers: { 'x-client': 'web' } }); return json && json.data || {}; } async function finishYuketangCards(ctx = {}, leaf = {}, schedule = {}, logger = null) { const row = getYuketangLeafSchedule(schedule, leaf); if (isYuketangScheduleCompleteValue(row)) return true; const detail = await loadYuketangLeafLevelInfo(ctx, leaf.id).catch(() => ({})); const cardsId = detail.activity_id || detail.courseware_id || deepFind(detail, ['activity_id', 'cards_id', 'courseware_id']) || leaf.id; const userId = ctx.userId || detail.user_id || deepFind(detail, ['user_id', 'userId']); const courseId = ctx.courseId || detail.course_id || deepFind(detail, ['course_id', 'courseId']); const skuId = ctx.skuId || detail.sku_id || deepFind(detail, ['sku_id', 'skuId']); let total = Number(row && typeof row === 'object' ? (row.total || row.count || row.slide_total) : 0); if (!Number.isFinite(total) || total <= 0) total = Number(leaf.slide_total || leaf.total || 1); total = Math.max(1, Math.min(80, Math.floor(total))); let sq = Math.floor(Date.now() / 1000) % 1000; const pg = `${leaf.id}_${Math.floor(1048576 * (1 + Math.random())).toString(36)}`; const now = Date.now(); const heart_data = []; for (let slide = 1; slide <= total; slide++) { heart_data.push({ i: 5, et: 'videoend', p: 'web', n: '', lob: 'ykt', cp: 1, fp: 0, tp: 0, sp: 1, ts: String(Math.floor(now - (total - slide) * 1000)), u: Number(userId) || userId || '', uip: '', c: Number(courseId) || courseId || '', v: Number(leaf.id) || leaf.id, skuid: Number(skuId) || skuId || '', classroomid: String(ctx.classroomId), cc: '', d: 1, pg, sq: ++sq, t: 'ykt_cards', cards_id: Number(cardsId) || cardsId || 0, slide, v_url: '' }); } const json = await loadYuketangJson(`${yuketangOrigin()}/video-log/heartbeat/`, { ...ctx, courseId, userId }, { method: 'POST', data: JSON.stringify({ heart_data }), headers: { 'content-type': 'application/json', 'origin': yuketangOrigin(), 'x-requested-with': 'XMLHttpRequest' }, timeout: 30000 }); if (logger) { const raw = json && json.__raw ? compactResponseText(json.__raw).slice(0, 500) : ''; logger(`雨课堂PPT/cards心跳:leaf=${leaf.id} cards=${cardsId} slides=${total}${raw ? ` raw=${raw}` : ''}`); } return json && json.success !== false; } async function getYuketangVideoProgress(ctx = {}, leaf = {}, info = {}) { const courseId = ctx.courseId || info.course_id || info.courseId || deepFind(info, ['course_id', 'courseId']); const userId = ctx.userId || info.user_id || info.userId || deepFind(info, ['user_id', 'userId']); const url = `${yuketangOrigin()}/video-log/get_video_watch_progress/?cid=${enc(courseId || '')}&user_id=${enc(userId || '')}&classroom_id=${enc(ctx.classroomId)}&video_type=video&vtype=rate&video_id=${enc(leaf.id)}&snapshot=1&no_loading=true`; const json = await loadYuketangJson(url, { ...ctx, courseId, userId }); const row = json && json.data && json.data[String(leaf.id)] || json && json[String(leaf.id)] || {}; return { row, courseId, userId }; } async function finishYuketangVideo(ctx = {}, leaf = {}, logger = null) { const info = await loadYuketangLeafInfo(ctx, leaf.id).catch(() => ({})); const progress = await getYuketangVideoProgress(ctx, leaf, info).catch(() => ({ row: {}, courseId: ctx.courseId || info.course_id, userId: ctx.userId || info.user_id })); const row = progress.row || {}; if (Number(row.completed || 0) >= 1 || Number(row.rate || 0) >= 0.995) return true; const duration = Math.max(1, Number(row.video_length || deepFind(info, ['duration', 'video_length', 'length', 'd']) || 60)); const start = Math.max(0, Number(row.last_point || row.watch_length || 0)); const courseId = progress.courseId || ctx.courseId || info.course_id || deepFind(info, ['course_id', 'courseId']); const userId = progress.userId || ctx.userId || info.user_id || deepFind(info, ['user_id', 'userId']); const skuId = ctx.skuId || info.sku_id || info.skuId || deepFind(info, ['sku_id', 'skuId']); const cc = deepFind(info, ['cc', 'video_id', 'file_id', 'media_id']) || ''; let sq = Math.floor(Date.now() / 1000) % 1000; let cp = start; let virtualTs = Date.now() - Math.max(60000, Math.ceil(Math.max(0, duration - start)) * 1000); let sentPlaying = false; const pg = `${leaf.id}_${Math.floor(1048576 * (1 + Math.random())).toString(36)}`; const buildHeartEvent = (et, point, interval = 5) => { virtualTs += Math.max(1, interval) * 1000; return { i: 5, et, p: 'web', n: '', lob: 'ykt', cp: Number(point.toFixed(1)), fp: 0, tp: Number(Math.floor(start)), sp: 1, ts: String(Math.floor(virtualTs)), u: Number(userId) || userId || '', uip: '', c: Number(courseId) || courseId || '', v: Number(leaf.id) || leaf.id, skuid: Number(skuId) || skuId || '', classroomid: String(ctx.classroomId), cc: String(cc || ''), d: Number(duration.toFixed(1)), pg, sq: ++sq, t: 'video', cards_id: 0, slide: 0, v_url: '' }; }; const sendHeartBatch = async (batch, labelPoint) => { const body = JSON.stringify({ heart_data: batch }); const json = await loadYuketangJson(`${yuketangOrigin()}/video-log/heartbeat/`, { ...ctx, courseId, userId }, { method: 'POST', data: body, headers: { 'content-type': 'application/json', 'origin': yuketangOrigin(), 'x-requested-with': 'XMLHttpRequest' }, timeout: 30000 }); if (logger) { const code = json && (json.code ?? json.errcode ?? json.status ?? ''); const msg = json && (json.msg || json.message || json.detail || ''); const raw = json && json.__raw ? compactResponseText(json.__raw).slice(0, 500) : ''; logger(`雨课堂视频心跳:${leaf.id} ${Math.floor(labelPoint)}/${Math.floor(duration)} ${code !== '' ? `code=${code}` : '已返回'}${msg ? `,${compactResponseText(msg)}` : ''}${raw ? `,raw=${raw}` : ''}`); } return json; }; while (cp < duration - 1) { const batch = []; for (let i = 0; i < 12 && cp < duration - 1; i++) { const step = 5 + Math.random() * 1.2; cp = Math.min(duration, cp + step); const et = sentPlaying ? 'heartbeat' : 'playing'; batch.push(buildHeartEvent(et, cp, step)); sentPlaying = true; } await sendHeartBatch(batch, cp); await new Promise(resolve => setTimeout(resolve, 500)); } await sendHeartBatch([buildHeartEvent('videoend', duration, 1)], duration).catch(() => null); const after = await getYuketangVideoProgress({ ...ctx, courseId, userId }, leaf, info).catch(() => ({ row: {} })); const afterRow = after.row || {}; return Number(afterRow.completed || 0) >= 1 || Number(afterRow.rate || 0) >= 0.95 || cp >= duration - 1; } function isYuketangProblemSubmitted(problem = {}) { const user = problem.user || {}; const status = problem.submission_status; return !!(status || Number(user.my_count || 0) > 0 || Array.isArray(user.my_answer) && user.my_answer.length); } function normalizeYuketangAnswer(answer, q = {}) { const text = String(answer || '').trim(); if (!text) return []; const opts = q.options || []; const byLabel = new Map(opts.map(o => [String(o.label || '').toUpperCase(), o])); const byKey = new Map(opts.map(o => [String(o.key || '').toLowerCase(), o])); const byContent = new Map(opts.map(o => [normalizeSpaces(o.content).toLowerCase(), o])); const type = q.typeName || ''; if (/判断/.test(type)) { if (/^(正确|对|是|true|t|√|1|A)$/i.test(text)) return [byKey.get('true') ? 'true' : (opts[0] && opts[0].key || 'true')]; if (/^(错误|错|否|false|f|×|0|B)$/i.test(text)) return [byKey.get('false') ? 'false' : (opts[1] && opts[1].key || 'false')]; } if (/填空|简答/.test(type)) return text.split(/#!#|[|;;]/).map(x => x.trim()).filter(Boolean); const parts = /多选/.test(type) && /^[A-Z]+$/i.test(text.replace(/\s+/g, '')) ? text.replace(/\s+/g, '').split('') : text.split(/[#|,,;;\s]+/).filter(Boolean); const mapped = []; for (const part of parts) { const p = String(part || '').trim(); const opt = byLabel.get(p.toUpperCase()) || byKey.get(p.toLowerCase()) || byContent.get(normalizeSpaces(p).toLowerCase()); if (opt) mapped.push(opt.key); } return Array.from(new Set(mapped)); } function normalizeYuketangVisibleText(text = '') { return cleanText(String(text || '').replace(/\s+/g, '')); } function yuketangDispatchInput(el) { try { el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('blur', { bubbles: true })); } catch (_) {} } function isYuketangElementVisible(el) { if (!el) return false; try { const style = el.ownerDocument.defaultView.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; return !!(el.offsetParent || el.getClientRects().length); } catch (_) { return !!(el.offsetParent || el.getClientRects().length); } } function yuketangExamQuestionRoots(doc) { const selectors = [ '.container-problem', '[class*="container-problem"]', '[class*="problem-content"]', '[class*="problem-item"]', '[class*="exercise-item"]' ].join(','); const roots = Array.from(doc.querySelectorAll(selectors)).filter(isYuketangElementVisible); if (roots.length) return roots; const fallback = Array.from(doc.querySelectorAll('[class*="problem"],[class*="exercise"]')).filter(isYuketangElementVisible); return fallback.length ? fallback : []; } function yuketangOptionClickable(el) { return el.closest('label,li,button,[role="radio"],[role="checkbox"],[class*="option"],[class*="answer"],[class*="choice"]') || el; } function clickYuketangExamProblemIndex(doc, index) { if (!index) return false; const idx = String(index); const roots = Array.from(doc.querySelectorAll('.exam-aside,[class*="exam-aside"],[class*="question-card"],[class*="answer-card"],[class*="problem-nav"]')) .filter(isYuketangElementVisible); for (const root of roots) { const nodes = Array.from(root.querySelectorAll('li,div,span,a,button')); const hit = nodes.find(el => isYuketangElementVisible(el) && normalizeYuketangVisibleText(el.innerText || el.textContent) === idx); if (hit) { try { hit.click(); return true; } catch (_) {} } } return false; } async function fillYuketangExamAnswerOnPage(q = {}, answer = []) { const answers = Array.isArray(answer) ? answer.map(x => String(x)) : [String(answer || '')]; let changed = false; const qIndex = q.index || (q.raw && (q.raw.index || q.raw.no || q.raw.number)); for (const doc of yuketangSameOriginDocuments()) clickYuketangExamProblemIndex(doc, qIndex); await new Promise(resolve => setTimeout(resolve, 120)); const docs = yuketangSameOriginDocuments(); const typeName = String(q.typeName || ''); if (/填空|简答|问答|主观|计算/.test(typeName)) { const text = answers.join(' '); for (const doc of docs) { const roots = yuketangExamQuestionRoots(doc); const inputs = roots.flatMap(root => Array.from(root.querySelectorAll('textarea,input[type="text"],input:not([type]),[contenteditable="true"]'))) .filter(isYuketangElementVisible); const targets = inputs.slice(0, Math.max(1, answers.length)); for (let i = 0; i < targets.length; i++) { const el = targets[i]; const value = answers.length > 1 ? (answers[i] || '') : text; try { if (el.isContentEditable) el.textContent = value; else el.value = value; yuketangDispatchInput(el); changed = true; } catch (_) {} } } return changed; } for (const ans of answers) { const opt = (q.options || []).find(o => String(o.key) === ans || String(o.label).toUpperCase() === ans.toUpperCase() || String(o.content).trim() === ans.trim() ); const wantedTexts = [ opt && opt.content, opt && opt.label, opt && opt.key, ans ].filter(Boolean).map(normalizeYuketangVisibleText).filter(Boolean); let clicked = false; for (const doc of docs) { const containers = yuketangExamQuestionRoots(doc); for (const root of containers) { const nodes = Array.from(root.querySelectorAll('label,li,div,span,p,a,button')); const hit = nodes.find(el => { if (!isYuketangElementVisible(el)) return false; const text = normalizeYuketangVisibleText(el.innerText || el.textContent); if (!text) return false; return wantedTexts.some(w => w && (text === w || text.includes(w))); }); if (!hit) continue; const clickable = yuketangOptionClickable(hit); try { clickable.click(); changed = true; clicked = true; break; } catch (_) {} } if (clicked) break; } } return changed; } async function submitYuketangProblemAnswer(ctx = {}, q = {}, answer = []) { const problemType = String(q.problemType || (q.raw && q.raw.content && q.raw.content.ProblemType) || ''); const answerArr = Array.isArray(answer) ? answer : [answer]; const payload = { classroom_id: Number(ctx.classroomId) || ctx.classroomId, problem_id: Number(q.qid || q.questionId) }; // 雨课堂官方提交逻辑: // ProblemType 1/6:answer=[单个答案字符串] // ProblemType 5:answer=[多选答案数组] // ProblemType 4:answers={空序号:答案} // ProblemType 2/3:answer=主观内容 if (problemType === '4' || /填空/.test(q.typeName || '')) { payload.answers = {}; answerArr.forEach((x, i) => { if (String(x || '').trim()) payload.answers[String(i + 1)] = String(x).trim(); }); } else if (problemType === '1' || problemType === '6' || /单选|判断/.test(q.typeName || '')) { payload.answer = [String(answerArr[0] || '')]; } else if (problemType === '2' || problemType === '3' || /简答/.test(q.typeName || '')) { payload.answer = answerArr.length === 1 ? String(answerArr[0] || '') : answerArr.join('\n'); } else { payload.answer = answerArr; } const data = JSON.stringify(payload); try { const json = await loadYuketangJson(`${yuketangOrigin()}/mooc-api/v1/lms/exercise/problem_apply/`, ctx, { method: 'POST', data, headers: { 'content-type': 'application/json;charset=UTF-8', 'origin': yuketangOrigin() } }); return { success: json && json.success !== false, isRight: json && json.data && (json.data.is_right ?? json.data.is_correct), raw: json, error: json && (json.msg || json.message) || '' }; } catch (e) { return { success: false, error: e && e.message || String(e) }; } } async function queryAnswerApi(question, questionType, options, apiUrl, token, extra = {}) { token = String(token || '').trim(); if (!token) return { success: false, error: 'missing token' }; const body = { question: stripQuestionPrefix(question, questionType), questionType }; if (options && options.length) body.options = options; const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); const stopped = stats => ({ success: false, error: 'stopped', ...stats }); for (let attempt = 0; attempt < 4; attempt++) { if (extra.shouldStop?.()) return stopped(); let result, limited = false; try { const res = await request({ method: 'POST', url: apiUrl, data: JSON.stringify(body), headers: { 'Content-Type': 'application/json', 'X-User-Token': token }, timeout: 60000, allowStatuses: [400, 401, 402, 403, 404, 409, 422, 429, 500, 502, 503, 504] }); const status = Number(res.status) || 200; let json; try { json = JSON.parse(res.responseText || '{}'); } catch (_) { json = null; } const stats = object(json) ? extractTokenStats(json) : {}; if (extra.shouldStop?.()) return stopped(stats); const code = object(json) && ['string', 'number'].includes(typeof json.code) ? String(json.code) : ''; const error = object(json) && typeof json.error === 'string' ? json.error : ''; const detail = (code !== '1' ? code : '') || error || (object(json) && (json.message || json.msg)) || ''; // Error metadata can contain data/answer fields. HTTP and explicit failure win. const rejected = status >= 400 || !object(json) || json.success === false || (!!error && json.success !== true) || (code && code !== '1' && json.success !== true); if (rejected) { const quota = status === 402 || code === 'INSUFFICIENT_QUOTA' || error === 'INSUFFICIENT_QUOTA'; limited = status === 429 || /^(?:RATE_LIMIT(?:_EXCEEDED)?|CONCURRENCY_LIMIT_EXCEEDED)$/.test(code || error); result = { success: false, status, code, error: quota ? 'INSUFFICIENT_QUOTA' : status >= 400 ? 'HTTP ' + status + (detail ? ': ' + String(detail) : '') : String(detail || 'INVALID_ANSWER_RESPONSE'), ...stats }; if (quota) { result.insufficientQuota = true; return result; } } else { let answer = json.answer ?? (object(json.data) ? (json.data.answer ?? json.data.value) : json.data); // Retain false/0; never stringify arbitrary objects or raw JSON as an answer. for (let depth = 0; depth < 3; depth++) { if (typeof answer === 'string' && answer.trim().startsWith('{')) { try { answer = JSON.parse(answer); } catch (_) { answer = null; } } else if (object(answer)) answer = answer.answer ?? answer.value; else break; } const valid = typeof answer === 'string' || typeof answer === 'boolean' || (typeof answer === 'number' && Number.isFinite(answer)); const answerText = valid ? String(answer).trim() : ''; if (!answerText) return { success: false, error: 'EMPTY_ANSWER', status, ...stats }; return { success: true, answer: answerText, cached: !!json.cached, ...stats }; } } catch (error) { if (extra.shouldStop?.()) return stopped(); const message = error && error.message || String(error); // Do not classify incidental digits in URLs, request IDs or durations as HTTP codes. const status = Number(message.match(/^HTTP\s+(\d{3})\b/i)?.[1]) || 0; const quota = status === 402 || message === 'INSUFFICIENT_QUOTA'; limited = status === 429 || /^(?:RATE_LIMIT(?:_EXCEEDED)?|CONCURRENCY_LIMIT_EXCEEDED)$/.test(message); result = { success: false, error: quota ? 'INSUFFICIENT_QUOTA' : message, status }; if (quota) { result.insufficientQuota = true; return result; } } if (!limited || attempt === 3) return result; if (extra.shouldStop?.()) return stopped(); await new Promise(resolve => setTimeout(resolve, 2500 + attempt * 2500)); } } async function queryQingshuVisionApi(images, stem, questionType, options, apiUrl, token, extra = {}) { token = String(token || '').trim(); if (!token) return qingshuAnswerFailure({ code: 'MISSING_TOKEN', message: '未绑定答题 Token' }); let url; try { // 必须是完整接口地址,避免把 Token 发送到当前青书站点的相对路径。 const u = new URL(String(apiUrl || DEFAULT_ANSWER_API_URL).trim()); if (!/^https?:$/.test(u.protocol) || u.username || u.password) throw new Error('invalid API URL'); let endpoint = u.pathname.replace(/\/+$/, ''); // qsxt 是 answer 路由的子路径;不能把 /api/answer 中的 /answer 去掉。 if (!endpoint) endpoint = '/api/answer/qsxt'; else if (/\/api(?:\/qsxt)?$/i.test(endpoint)) endpoint = endpoint.replace(/\/api(?:\/qsxt)?$/i, '/api/answer/qsxt'); else if (!/\/qsxt$/i.test(endpoint)) endpoint += '/qsxt'; url = `${u.origin}${endpoint}`; } catch (_) { return qingshuAnswerFailure({ code: 'INVALID_API_URL', message: '答题接口地址格式错误' }); } const body = { images: (Array.isArray(images) ? images : []).filter(Boolean), stem: String(stem || '').trim(), questionType }; if (typeof extra.stemHtml === 'string') body.stemHtml = extra.stemHtml; if (options && options.length) body.options = options; if (extra.forceRefresh === true) body.forceRefresh = true; if (!body.images.length && !body.stem) { return qingshuAnswerFailure({ code: 'MISSING_QUESTION', message: '未解析到题干文字或图片,未发送查题请求', endpoint: url }); } if (!questionType) return qingshuAnswerFailure({ code: 'MISSING_QUESTION_TYPE', message: '未解析到题型,未发送查题请求', endpoint: url }); for (let attempt = 0; attempt < 4; attempt++) { let failure; try { const res = await request({ method: 'POST', url, data: JSON.stringify(body), headers: { 'Content-Type': 'application/json', 'X-User-Token': token }, // 保留非 2xx 的 JSON 错误码和说明,不再只留下“HTTP 请求失败”。 allowStatuses: [400, 401, 402, 403, 404, 408, 409, 413, 415, 422, 429, 500, 502, 503, 504], timeout: 120000 }); const status = Number(res.status || 200); let json; try { json = JSON.parse(res.responseText || ''); } catch (_) { failure = qingshuAnswerFailure({ code: 'NON_JSON_RESPONSE', status, endpoint: url, message: '接口返回的不是 JSON,可能是错误页面或拦截页面' }); } if (!failure) { if (!json || typeof json !== 'object' || Array.isArray(json)) { failure = qingshuAnswerFailure({ code: 'INVALID_RESPONSE', status, endpoint: url, message: '接口响应结构异常' }); } else if (status >= 400 || json.success === false || (json.error && !json.answer)) { failure = qingshuAnswerFailure({ code: json.code || (typeof json.error === 'string' ? json.error : 'ANSWER_API_ERROR'), status, endpoint: url, message: json.message || json.msg || (typeof json.error === 'object' ? json.error.message : json.error) || '接口请求失败', token }); } else { let answer = json.answer ?? json.data?.answer ?? json.data ?? ''; if (typeof answer === 'string' && answer.trim().startsWith('{')) { try { answer = JSON.parse(answer).answer ?? ''; } catch (_) { answer = ''; } } if (answer && typeof answer === 'object') answer = answer.answer ?? answer.value ?? ''; const answerText = typeof answer === 'string' || typeof answer === 'number' || typeof answer === 'boolean' ? String(answer).trim() : ''; const cache = json.cache || json.data?.cache; if (answerText && (body.forceRefresh || (body.options || []).some(option => option.images && option.images.length)) && Number(cache && cache.version || 0) < 2) { return qingshuAnswerFailure({ code: 'QSXT_SERVER_UPGRADE_REQUIRED', status, endpoint: url, message: '图文选项或强制刷新需要部署新版青书服务端,本次未采用旧接口答案', token }); } if (answerText && (typeof body.stemHtml === 'string' || (body.options || []).some(option => typeof option.html === 'string')) && Number(cache && cache.htmlVersion || 0) < 1) { return qingshuAnswerFailure({ code: 'QSXT_SERVER_UPGRADE_REQUIRED', status, endpoint: url, message: '完整图文收录需要更新青书服务端,本次未采用忽略 HTML 的旧接口答案', token }); } if (answerText) return { success: true, answer: answerText, cached: !!json.cached, cache, ...extractTokenStats(json) }; failure = qingshuAnswerFailure({ code: 'EMPTY_ANSWER', status, endpoint: url, message: '接口响应中没有有效答案字段', token }); } } } catch (e) { const msg = e && e.message || String(e); const status = Number((msg.match(/HTTP\s*(\d{3})/i) || [])[1] || 0); failure = qingshuAnswerFailure({ code: /timeout|超时/i.test(msg) ? 'REQUEST_TIMEOUT' : 'REQUEST_FAILED', status, endpoint: url, message: msg, token }); } if (failure.rateLimited && attempt < 3) { await new Promise(resolve => setTimeout(resolve, 2500 + attempt * 2500)); continue; } return failure; } return qingshuAnswerFailure({ code: 'RATE_LIMIT_RETRY_EXHAUSTED', endpoint: url, message: '请求过于频繁,重试后仍未恢复' }); } function qingshuOrgCode(pathOrUrl = '') { let path = String(pathOrUrl || ''); try { path = new URL(path, location.href).pathname; } catch (_) {} const m = path.match(/^\/([^/?#]+)\/Student(?:\/|$)/i); return m ? decodeURIComponent(m[1]) : 'sntcm'; } function qingshuAnswerErrorText(value, token = '') { let text = String(value ?? ''); if (token) text = text.split(token).join('[已隐藏]'); return text.replace(/(?:Bearer\s+)[A-Za-z0-9._~+\/-]+/gi, 'Bearer [已隐藏]') .replace(/((?:X-User-Token|userToken|access_token|api[_-]?key|token)["']?\s*[:=]\s*["']?)[^\s"',;&}]+/gi, '$1[已隐藏]') .replace(/https?:\/\/[^\s<>"']+/gi, '[URL]') .replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 180); } function qingshuAnswerFailure({ code = '', status = 0, message = '', endpoint = '', token = '' } = {}) { const text = qingshuAnswerErrorText(message, token); const rawCode = String(code || 'ANSWER_API_ERROR'); const safeCode = /^[A-Z][A-Z0-9_]{1,63}$/.test(rawCode) ? rawCode : 'ANSWER_API_ERROR'; const signature = `${safeCode} ${text}`; const insufficientQuota = status === 402 || /INSUFFICIENT_QUOTA|次数不足|余额不足/i.test(signature); const rateLimited = status === 429 || /RATE_LIMIT|请求过于频繁|并发.*限制/i.test(signature); const fatal = insufficientQuota || [401, 403, 404].includes(status) || /MISSING_TOKEN|INVALID_API_URL|INVALID_TOKEN|TOKEN_EXPIRED|TOKEN_INVALID|QSXT_SERVER_UPGRADE_REQUIRED/i.test(signature); const notFound = !fatal && !rateLimited && status < 400 && ( /^(?:ANSWER_NOT_FOUND|NO_ANSWER|NO_MATCH)$/.test(safeCode) || (safeCode === 'ANSWER_API_ERROR' && /题库.*(?:无匹配|没有)|未找到答案/.test(text)) ); return { success: false, error: text || safeCode, code: safeCode, httpStatus: status || undefined, endpoint, insufficientQuota, rateLimited, fatal, serviceFailure: !notFound }; } function qingshuPublicLogText(value, token = '') { let text = String(value || ''); if (!text.includes('青书')) return text; if (/青书(?:课件进度:|作业:共|课程讨论:|课件学习开始:|课件分数:)/.test(text)) return ''; if (/本次重新识别|服务端缓存命中|并发识别结果|已强制刷新本题缓存|尚未逐题核验|按原规则计次|缓存未保存|正在查询答案/.test(text)) return ''; if (token) text = text.split(String(token)).join(''); return text.replace(/https?:\/\/[^\s<>"']+/gi, '') .replace(/Bearer\s+[^\s,,;;]+/gi, '') .replace(/\b(?:X-User-Token|userToken|access_token|api[_-]?key|token)["']?\s*[:=]\s*(?:\[[^\]]*\]|[^\s,,;;]+)?/gi, '') .replace(/\[(?:URL|已隐藏)\]/g, '') .replace(/\bHTTP\s*\d{3}\b/gi, '') .replace(/\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g, '') .replace(/(题干\s*\d+\s*字,图片\s*\d+\s*张,选项\s*\d+\s*个)/g, '') .replace(/\[\s*\/?\s*\]/g, '') .replace(/[;;]\s*接口(?:地址)?\s*[::]?\s*$/g, '') .replace(/接口已保存/g, '已保存答案') .replace(/接口保存失败/g, '保存失败') .replace(/接口/g, '服务') .replace(/\s+/g, ' ').trim(); } function qingshuUserErrorText(value, token = '') { const text = qingshuPublicLogText('青书 ' + qingshuAnswerErrorText(value, token), token); return text.replace(/^青书\s*/, '').trim() || '操作未完成,请稍后重试'; } function qingshuDescribeAnswerFailure(result, _question = {}, token = '') { if (result.insufficientQuota) return '剩余次数不足,请充值后重试'; if (result.rateLimited) return '请求较多,请稍后重试'; const messages = { MISSING_TOKEN: '请先绑定答题 Token', INVALID_TOKEN: '答题 Token 无效,请重新绑定', EMPTY_ANSWER: '未找到可用答案', ANSWER_NOT_FOUND: '未找到匹配答案', QSXT_SERVER_UPGRADE_REQUIRED: '请更新答题服务后重试', INVALID_API_URL: '答题设置有误,请检查后重试', NON_JSON_RESPONSE: '暂时无法获取答案,请稍后重试', INVALID_RESPONSE: '暂时无法获取答案,请稍后重试' }; return messages[result.code] || qingshuUserErrorText(result.error || result.message || '暂时无法获取答案,请稍后重试', token); } function qingshuContextFromLocation(base = {}) { let u; try { u = new URL(base.href || location.href, location.href); } catch (_) { u = new URL(location.href); } const get = (...names) => { for (const name of names) { const value = u.searchParams.get(name); if (value !== null && value !== '') return value; } return ''; }; const ctx = { ...base, platform: 'qingshu', href: base.href || u.href, siteHost: base.siteHost || u.origin, oldSiteHost: base.oldSiteHost || u.origin, siteHosts: [base.siteHost || u.origin], orgCode: base.orgCode || qingshuOrgCode(u.pathname), courseId: base.courseId || get('courseId', 'courseid'), teachPlanId: base.teachPlanId || get('teachPlanId', 'teachplanId'), teachPlanCourseId: base.teachPlanCourseId || get('teachPlanCourseId', 'teachplanCourseId', 'teachingPlanCourseId'), periodId: base.periodId || get('periodId'), nodeId: base.nodeId || get('nodeId', 'contentId') || 'kcjs_1_1', courseName: base.courseName || detectCourseName() || document.title || '青书学堂课程' }; if (!ctx.courseId || !ctx.teachPlanId || !ctx.periodId || !ctx.teachPlanCourseId) { for (const a of Array.from(document.querySelectorAll('a[href]'))) { const href = a.href || ''; let link; try { link = new URL(href, u.href); } catch (_) { continue; } if (link.origin !== u.origin || qingshuOrgCode(link.pathname) !== ctx.orgCode) continue; const linkCourseId = pickQueryFromUrl(href, 'courseId', 'courseid'); const linkPlanId = pickQueryFromUrl(href, 'teachPlanCourseId', 'teachplanCourseId', 'teachingPlanCourseId'); if (!ctx.courseId || !linkCourseId || String(linkCourseId) !== String(ctx.courseId)) continue; if (ctx.teachPlanCourseId && linkPlanId && String(linkPlanId) !== String(ctx.teachPlanCourseId)) continue; if (!ctx.teachPlanId) ctx.teachPlanId = pickQueryFromUrl(href, 'teachPlanId', 'teachplanId') || ctx.teachPlanId; if (!ctx.teachPlanCourseId) ctx.teachPlanCourseId = pickQueryFromUrl(href, 'teachPlanCourseId', 'teachplanCourseId', 'teachingPlanCourseId') || ctx.teachPlanCourseId; if (!ctx.periodId) ctx.periodId = pickQueryFromUrl(href, 'periodId') || ctx.periodId; } } return ctx; } function qingshuApiBase(ctx = {}) { const origin = String(ctx.siteHost || ctx.origin || 'https://degree.qingshuxuetang.com').replace(/\/$/, ''); return `${origin}/${ctx.orgCode || 'sntcm'}`; } function qingshuRequestHeaders(ctx = {}, referer = '') { return { 'Accept': '*/*', 'X-Requested-With': 'XMLHttpRequest', 'Device-Trace-Id-QS': randomDeviceId(), 'Referer': referer || ctx.href || `${qingshuApiBase(ctx)}/Student/Course/CourseStudy` }; } function qingshuParseJson(text, label = '青书接口') { try { return JSON.parse(String(text || '')); } catch (_) { throw new Error(`${label}返回内容不是 JSON`); } } function qingshuResponseOk(json) { if (!json || typeof json !== 'object') return false; if (Object.prototype.hasOwnProperty.call(json, 'hr')) return String(json.hr) === '0'; if (Object.prototype.hasOwnProperty.call(json, 'code')) return String(json.code) === '0'; if (Object.prototype.hasOwnProperty.call(json, 'success')) return json.success === true; return String(json.message || '').trim() === '成功'; } function qingshuMultipart(fields = {}) { const boundary = `----CxRunner${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`; let body = ''; for (const [key, value] of Object.entries(fields)) { body += `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value == null ? '' : value}\r\n`; } body += `--${boundary}--\r\n`; return { body, contentType: `multipart/form-data; boundary=${boundary}` }; } function createQingshuRequestLimiter(env = {}) { const send = env.send || (options => request(options)); const now = env.now || (() => Date.now()); const wait = env.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))); const lanes = new Map(); const readStore = env.read || (key => { try { return localStorage.getItem(key); } catch (_) { return null; } }); const writeStore = env.write || ((key, value) => { try { localStorage.setItem(key, value); } catch (_) {} }); const lock = env.lock || ((key, run) => typeof navigator !== 'undefined' && navigator.locks ? navigator.locks.request(key, run) : run()); return options => { const origin = new URL(options.url).origin, key = 'cx-qingshu-request-cooldown:' + origin; let lane = lanes.get(origin); if (!lane) { lane = { tail: Promise.resolve(), next: 0, strikes: 0 }; lanes.set(origin, lane); } const check = async () => { if (options.signal?.aborted) { const error = new Error('青书请求已取消'); error.name = 'AbortError'; throw error; } await options.beforeSend?.(); }; const run = async () => { try { const cached = JSON.parse(readStore(key) || '{}'); if (Number.isFinite(cached.next) && cached.next > lane.next) { lane.next = cached.next; lane.strikes = Number(cached.strikes) || 0; } } catch (_) {} for (;;) { await check(); const remaining = lane.next - now(); if (remaining <= 0) break; await wait(Math.min(1000, remaining)); } let cooldown = 10000; try { await check(); const { beforeSend, onDispatch, ...raw } = options; await onDispatch?.(); const res = await send({ ...raw, allowStatuses: [...new Set([...(raw.allowStatuses || []), 429])] }); if (Number(res.status) === 429) { lane.strikes++; const value = String(res.responseHeaders || '').match(/^retry-after:\s*(.+)$/im)?.[1]?.trim(); const retry = value && /^\d+$/.test(value) ? Number(value) * 1000 : value && /GMT$/i.test(value) ? Date.parse(value) - now() : 0; cooldown = Math.max(Math.min(120000, 30000 * 2 ** Math.min(lane.strikes - 1, 2)), Number.isFinite(retry) ? retry : 0); const error = new Error('青书请求过于频繁(429),后续请求至少等待 ' + Math.ceil(cooldown / 1000) + ' 秒;本次未自动重发'); error.httpStatus = 429; error.retryAfterMs = cooldown; throw error; } lane.strikes = 0; return res; } finally { lane.next = now() + cooldown; writeStore(key, JSON.stringify({ next: lane.next, strikes: lane.strikes })); } }; const promise = lane.tail.then(() => lock(key, run)); lane.tail = promise.catch(() => {}); return promise; }; } async function qingshuApiRequest(ctx, endpoint, opts = {}) { const rawEndpoint = String(endpoint || ''); const target = new URL(rawEndpoint, qingshuApiBase(ctx) + '/'); for (const [key, value] of new URLSearchParams(opts.params || {})) target.searchParams.set(key, value); if (!target.searchParams.has('_t')) target.searchParams.set('_t', String(Date.now())); const url = target.href; const headers = { ...qingshuRequestHeaders(ctx, opts.referer), ...(opts.headers || {}) }; let data = opts.data; if (opts.json !== undefined) { headers['Content-Type'] = 'application/json'; data = JSON.stringify(opts.json); } else if (opts.form) { const multipart = qingshuMultipart(opts.form); headers['Content-Type'] = multipart.contentType; data = multipart.body; } qingshuApiRequest.paced ||= createQingshuRequestLimiter(); return qingshuApiRequest.paced({ beforeSend: opts.beforeSend || ctx.beforeRequest, onDispatch: opts.onDispatch, signal: opts.signal, method: opts.method || 'GET', url, headers, data, timeout: opts.timeout || 30000, withCredentials: true }); } function qingshuCourseIsPassed(course = {}) { return String(course.learnStatusName || '').trim() === '已通过' && (course.learnStatus == null || course.learnStatus === '' || String(course.learnStatus) === '1'); } function qingshuCourseFromRaw(raw = {}) { return { courseId: String(raw.courseId || raw.id || ''), courseName: String(raw.courseName || raw.name || '青书学堂课程'), teachPlanCourseId: String(raw.teachingPlanCourseId || raw.teachPlanCourseId || raw.teachplanCourseId || ''), teachPlanId: String(raw.teachPlanId || raw.classId || ''), periodId: String(raw.periodId || ''), learnStatus: raw.learnStatus, learnStatusName: raw.learnStatusName || '', score: raw.score, allowLearn: ![false,0,'0','false'].includes(raw.allowLearn), raw }; } async function qingshuLoadCourseList(ctx) { const res = await qingshuApiRequest(ctx, 'Student/Course/CourseData', { method: 'POST' }); const json = qingshuParseJson(res.responseText, '青书课程列表'); if (!qingshuResponseOk(json)) throw new Error(json.message || '读取青书课程列表失败'); const rows = Array.isArray(json.data) ? json.data : []; return rows.map(qingshuCourseFromRaw).filter(c => c.courseId); } async function qingshuResolveCourseContext(base = {}) { let ctx = qingshuContextFromLocation(base); const courses = await qingshuLoadCourseList(ctx); const matches = courses.filter(c => (!ctx.courseId || c.courseId === String(ctx.courseId)) && (!ctx.teachPlanId || c.teachPlanId === String(ctx.teachPlanId)) && (!ctx.teachPlanCourseId || c.teachPlanCourseId === String(ctx.teachPlanCourseId)) && (!ctx.periodId || c.periodId === String(ctx.periodId))); const course = matches.length === 1 ? matches[0] : null; if (!course) throw new Error('无法唯一确定当前青书课程,请先进入具体课程页面再开始'); if (!course.allowLearn && !qingshuCourseIsPassed(course)) throw new Error('当前青书课程不允许学习'); if (course) { ctx = { ...ctx, courseId: course.courseId || ctx.courseId, courseName: course.courseName || ctx.courseName, teachPlanId: course.teachPlanId || ctx.teachPlanId, teachPlanCourseId: course.teachPlanCourseId || ctx.teachPlanCourseId, periodId: course.periodId || ctx.periodId, qingshuCourse: course, qingshuCourses: courses }; } if (!ctx.courseId || !ctx.teachPlanId || !ctx.teachPlanCourseId || !ctx.periodId) { throw new Error('当前课程缺少 courseId / teachPlanId / teachPlanCourseId / periodId'); } return ctx; } function qingshuScoreNumber(value) { const m = String(value == null ? '' : value).match(/-?\d+(?:\.\d+)?/); return m ? Number(m[0]) : 0; } async function qingshuLoadStudyProgress(ctx) { const res = await qingshuApiRequest(ctx, 'Student/Course/GetStudyRecordAndScore', { params: { teachPlanCourseId: ctx.teachPlanCourseId, periodId: ctx.periodId, _: Date.now() - 100 } }); const json = qingshuParseJson(res.responseText, '青书学习进度'); if (!qingshuResponseOk(json)) throw new Error(json.message || '读取青书学习进度失败'); const data = json.data || {}; return { score: qingshuScoreNumber(data.studyScoreStr || data.score), studyTime: String(data.studyTimeStr || data.studyTime || ''), learnStatus: String(data.learnStatusName || data.learnStatus || ''), coursePassed: qingshuCourseIsPassed(data), records: Array.isArray(data.studyRecordList) ? data.studyRecordList : [], raw: data }; } function qingshuDiscussionScore(doc) { const visible=e=>{for(let n=e;n?.nodeType===1;n=n.parentElement)if(n.hidden||/display\s*:\s*none|visibility\s*:\s*hidden/i.test(n.getAttribute('style')||''))return false;return true;}; const rows=[...doc.querySelectorAll('tr')].filter(r=>visible(r)&&[...r.children].some(c=>c.textContent.replace(/\s+/g,'')==='课程讨论')); const unknown={known:false,required:true,complete:false,score:null,target:null}; if(rows.length!==1)return unknown; const m=rows[0].lastElementChild?.textContent.replace(/\s+/g,'').match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/); if(!m)return unknown;const score=Number(m[1]),target=Number(m[2]); if(!Number.isFinite(score)||!Number.isFinite(target)||score>target||target>10000)return unknown; return {known:true,required:target>0,complete:score>=target,score,target}; } function qingshuDiscussionLabel(d) { return !d?.known?'课程讨论:状态未确认':!d.required?'课程讨论:不计分':'课程讨论:'+d.score+'/'+d.target+' 分'+(d.complete?'(已达标)':'(待参与)'); } function qingshuDiscussionUrl(ctx) { const origin=new URL(location.href).origin,org=location.pathname.split('/')[1],site=new URL(ctx.siteHost||origin); if(!/(^|\.)qingshuxuetang\.com$/i.test(location.hostname)||ctx.orgCode!==org||site.origin!==origin||site.username||site.password||site.pathname!=='/'||site.search||site.hash||![ctx.courseId,ctx.teachPlanCourseId,org].every(x=>/^[-\w]+$/.test(String(x||''))))throw Error('课程讨论入口信息不完整或不匹配'); const url=new URL('/'+org+'/Student/Course/PostList',origin);url.search=new URLSearchParams({courseId:ctx.courseId,teachplanCourseId:ctx.teachPlanCourseId,postId:''}).toString();return url.href; } function appendQingshuDiscussion(container,q,refresh,busy) { const d=q?.scoreDetail?.discussion;if(!d)return; const doc=container.ownerDocument,box=doc.createElement('div');box.dataset.k='discussionControls';box.style.cssText='grid-column:1/-1;margin:8px 0;font-size:13px'; const text=doc.createElement('div');text.textContent=qingshuDiscussionLabel(d)+(q.discussionRefreshError?';刷新失败,请检查登录后重试。':'');box.append(text); if(d.required){ try{const link=doc.createElement('a');link.textContent='查看课程讨论';link.href=qingshuDiscussionUrl(q.ctx);link.target='_blank';link.rel='noopener noreferrer';link.style.marginRight='12px';box.append(link);}catch(_){text.textContent+=';请从原课程页进入讨论。';} } const button=doc.createElement('button');button.type='button';button.dataset.k='refreshDiscussion';button.textContent=busy?'等待当前操作结束':'刷新课程状态';button.disabled=!!busy;button.onclick=()=>refresh();box.append(button);container.append(box); } async function qingshuLoadScoreDetail(ctx) { const res = await qingshuApiRequest(ctx, 'Student/Course/CourseScore', { params: { courseId: ctx.courseId, teachplanCourseId: ctx.teachPlanCourseId, _: Date.now() - 100 }, headers: { Accept: 'text/html,*/*' } }); const doc = new DOMParser().parseFromString(res.responseText || '', 'text/html'); const rows=[...doc.querySelectorAll('tr')].filter(row=>[...row.children].some(cell=>cleanText(cell.textContent)==='课件学习')); const study=doc.querySelector('#studySummaryTbl');let current,target; if(rows.length){ if(rows.length!==1)throw new Error('青书课件评分规则不唯一,请核对成绩页'); const score=cleanText(rows[0].lastElementChild?.textContent||'').match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/); if(!score)throw new Error('青书课件评分规则无法读取,请核对成绩页'); current=Number(score[1]);target=Number(score[2]); }else{ const title=cleanText(study?.querySelector('.title-class-score')?.textContent||''); if(!study||!title.match(/\d/))throw new Error('青书课件评分规则未加载,请刷新成绩页后重试'); current=qingshuScoreNumber(study.querySelector('.study-login')?.textContent||0);target=qingshuScoreNumber(title); } if(!Number.isFinite(target)||target<0||current<0||current>target)throw new Error('青书课件评分数值异常,未开始学习'); const quiz = qingshuScoreNumber(doc.querySelector('#quizSummaryTbl .study-login') && doc.querySelector('#quizSummaryTbl .study-login').textContent || 0); const total = qingshuScoreNumber(doc.querySelector('.score') && doc.querySelector('.score').textContent || 0); return { currentScore: current, targetScore: target, quizScore: quiz, totalScore: total, discussion:qingshuDiscussionScore(doc) }; } function qingshuStudyScore(q) { const score = q.scoreDetail?.currentScore; return typeof score === 'number' && Number.isFinite(score) ? score : Number(q.progress?.score || 0); } function qingshuApplyPassPolicy(state = {}) { const score = state.score == null || state.score === '' ? null : Number(state.score); const validScore = Number.isFinite(score) ? score : null; const passed = validScore !== null && validScore >= 60; const awaitingGrade = !passed && !!state.submitted && (validScore === null || /^(?:待批改|待批阅|待阅卷|批改中)/.test(state.statusLabel || '')); return { ...state, score: validScore, passed, awaitingGrade, needsRedo: !passed && !awaitingGrade && validScore !== null && validScore < 60, requiresRestart: !passed && !awaitingGrade && !!state.submitted }; } function qingshuAssignmentState({ text = '', statusText = '', actionText = '', userQuiz = {} } = {}) { const value = cleanText(text); const field = label => { const match = value.match(new RegExp(`(?:${label})\s*[::]\s*(.*?)(?=(?:总分|及格分|状态|作业状态|得分|提交时间|批改时间|开始时间|截止时间)\s*[::]|$)`)); return cleanText(match && match[1] || ''); }; const rawStatus = cleanText(userQuiz.statusName || userQuiz.statusDesc || (userQuiz.status ?? '')); const status = (rawStatus || statusText || field('作业状态|状态')).replace(/^(?:作业)?状态\s*[::]\s*/, '').trim(); const statusCode = /^\d+$/.test(status) ? Number(status) : null; const names = { 0: '未开始', 1: '答题中', 2: '已提交', 4: '已批改' }; const statusName = statusCode === null ? status : (names[statusCode] || '状态待确认'); const submittedAt = cleanText(userQuiz.submitTime || userQuiz.submittedTime || field('提交时间')); const gradedAt = cleanText(userQuiz.gradeTime || userQuiz.markTime || field('批改时间')); const scoreValue = userQuiz.score ?? userQuiz.userScore ?? field('得分'); const scoreMatch = String(scoreValue ?? '').trim().match(/^-?\d+(?:\.\d+)?(?=分|\s|$)/); const score = scoreMatch ? Number(scoreMatch[0]) : null; const hasTime = time => /^(?:\d{4}|\d{2})[-/年]\d{1,2}[-/月]\d{1,2}/.test(time) || /^\d{10,13}$/.test(time); const completedStatus = /^(?:已批改|已批阅|已阅卷|已提交|已交卷|已完成|待批改|待批阅|待阅卷|批改中)(?:$|\s|[((,,;;])/; const pendingStatus = /^(?:未提交|未完成|未开始|未作答|答题中|作答中|已退回|退回重做|待重做)(?:$|\s|[((,,;;])/; const completedByStatus = completedStatus.test(statusName); const explicitlyPending = pendingStatus.test(statusName) || /^(?:开始答题|继续答题)$/.test(cleanText(actionText)); const completedAction = /^(?:重新答题|重新作答|重做|已批改|已提交|已完成|已批阅|已交卷|查看成绩|查看答案)$/.test(cleanText(actionText)); const submitted = completedByStatus || (!explicitlyPending && (hasTime(submittedAt) || hasTime(gradedAt) || completedAction)); return qingshuApplyPassPolicy({ submitted, statusCode, statusKnown: !!status, statusLabel: statusName || (submitted ? '已提交' : (/继续/.test(actionText) ? '答题中' : '未提交')), score, submittedAt, gradedAt }); } function qingshuMergeAssignmentState(item, detail) { const hasStatus = detail.statusKnown || detail.statusCode != null || detail.submitted === true; return qingshuApplyPassPolicy({ ...item, submitted: hasStatus ? detail.submitted : !!item.submitted, statusCode: hasStatus ? detail.statusCode : item.statusCode, statusKnown: hasStatus || item.statusKnown, statusLabel: hasStatus ? detail.statusLabel : item.statusLabel, score: detail.score ?? item.score, submittedAt: detail.submittedAt || item.submittedAt || '', gradedAt: detail.gradedAt || item.gradedAt || '' }); } function qingshuAssignmentStatusText(item) { const policy = qingshuApplyPassPolicy(item); const state = item.statusLabel || item.status || (item.submitted ? '已提交' : '未提交'); const score = policy.score !== null ? `,得分 ${policy.score} 分` : ''; if (policy.passed) return `${state}${score};已达 60 分,跳过`; if (policy.awaitingGrade) return `${state}${score};待出分,暂不重做,不计入完成`; if (policy.needsRedo) return `${state}${score};未达 60 分,${policy.requiresRestart ? '需要重新答题' : '重新查询答案并继续答题'}`; return `${state}${score};${item.status || '待答题'}`; } function qingshuAssignmentId(button) { const match = (button.getAttribute('onclick') || '').match(/gotoExercisePaper\(['"]([^'"]+)/i); if (match) return match[1]; try { const url = new URL(button.getAttribute('href') || '', location.href); return /\/Student\/ExercisePaper\/?$/i.test(url.pathname) ? url.searchParams.get('quizId') || '' : ''; } catch (_) { return ''; } } function qingshuAssignmentRow(button, quizId) { let row = button.parentElement; for (let parent = row; parent && parent.tagName !== 'BODY' && parent.tagName !== 'HTML'; parent = parent.parentElement) { const ids = Array.from(parent.querySelectorAll('[onclick*="gotoExercisePaper"],a[href*="ExercisePaper"]')).map(qingshuAssignmentId).filter(Boolean); if (ids.some(id => id !== quizId)) break; row = parent; if (parent.matches('.row,tr,li,.assignment-item,.work-item')) break; } return row; } async function qingshuLoadAssignments(ctx) { const res = await qingshuApiRequest(ctx, 'Student/Course/AssignmentList', { params: { courseId: ctx.courseId, teachplanCourseId: ctx.teachPlanCourseId, teachPlanId: ctx.teachPlanId, periodId: ctx.periodId, _: Date.now() - 100 }, headers: { Accept: 'text/html,*/*' } }); const doc = new DOMParser().parseFromString(res.responseText || '', 'text/html'); const assignments = new Map(); for (const button of Array.from(doc.querySelectorAll('[onclick*="gotoExercisePaper"],a[href*="ExercisePaper"]'))) { const quizId = qingshuAssignmentId(button); if (!quizId) continue; const row = qingshuAssignmentRow(button, quizId); const title = cleanText((row && row.querySelector('h4,.title,.name') || {}).textContent || '') || '未命名作业'; const status = cleanText(button.textContent || ''); const statusText = cleanText(row && row.querySelector('.assignment-status,.work-status,.status')?.textContent || ''); const metadata = row && row.cloneNode(true); if (metadata) metadata.querySelectorAll('h4,.title,.name,a,button,script,style').forEach(el => el.remove()); const state = qingshuAssignmentState({ text: metadata && metadata.textContent || '', statusText, actionText: status }); let retakeUrl = ''; let startUrl = ''; const href = button.getAttribute('href'); if (href && !/^(?:javascript:|#)/i.test(href)) { try { const url = qingshuAssignmentEntryUrl(ctx, quizId, href); if (/^(?:重新答题|重新作答|重做)$/.test(status)) retakeUrl = url; else if (/^(?:开始|继续|进入)(?:答题|作答|作业)$/.test(status)) startUrl = url; } catch (_) { } } const item = { quizId, title, status, ...state, retakeUrl, startUrl }; const previous = assignments.get(quizId); if (!previous) assignments.set(quizId, item); else assignments.set(quizId, { ...(previous.passed || (previous.submitted && !item.submitted) ? previous : item), retakeUrl: previous.retakeUrl || item.retakeUrl, startUrl: previous.startUrl || item.startUrl }); } return Array.from(assignments.values()); } function qingshuContentText(value) { return String(value == null ? '' : value).replace(/\r\n?/g, '\n').trim(); } function qingshuResolveImageUrl(value, ctx = {}) { const src = String(value || '').trim(); if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(src)) return src; try { const url = new URL(src, ctx.href || ctx.siteHost || location.href); return src && /^https?:$/.test(url.protocol) ? url.href : ''; } catch (_) { return ''; } } function qingshuExtractImages(html, ctx = {}) { const doc = html && typeof html.querySelectorAll === 'function' ? html : new DOMParser().parseFromString(String(html || ''), 'text/html'); const images = []; const elements = Array.from(doc.querySelectorAll('img,[style*="background"]')); if (doc.matches && doc.matches('img,[style*="background"]')) elements.unshift(doc); for (const el of elements) { if (String(el.tagName).toLowerCase() === 'img') { const candidates = ['data-src', 'data-original', 'data-echo', 'src'].map(name => qingshuResolveImageUrl(el.getAttribute(name), ctx)).filter(Boolean); const current = qingshuResolveImageUrl(el.currentSrc || el.src, ctx); if (current) candidates.push(current); const src = candidates.find(value => /^https?:/i.test(value)) || candidates[0]; if (src) images.push(src); } const style = el.getAttribute('style') || ''; const pattern = /url\(\s*(['"]?)([^'"\)]+)\1\s*\)/gi; let match; while ((match = pattern.exec(style))) { const src = qingshuResolveImageUrl(match[2], ctx); if (src) images.push(src); } } return images; } function qingshuContentHtml(content, ctx = {}, allImages = []) { const isElement = content && typeof content.cloneNode === 'function'; const root = isElement ? content.cloneNode(true) : new DOMParser().parseFromString(String(content || ''), 'text/html').body; const elements = Array.from(root.querySelectorAll('img,[style*="background"]')); if (root.matches && root.matches('img,[style*="background"]')) elements.unshift(root); let index = 0; const picture = () => { const img = root.ownerDocument.createElement('img'); img.setAttribute('data-qs-image-index', String(index++)); return img; }; for (const element of elements) { const sources = qingshuExtractImages(element.cloneNode(false), ctx); if (String(element.tagName).toLowerCase() === 'img') { if (!sources.length) { element.remove(); continue; } for (const name of ['src', 'srcset', 'data-src', 'data-original', 'data-echo']) element.removeAttribute(name); element.setAttribute('data-qs-image-index', String(index++)); let previous = element; for (let i = 1; i < sources.length; i++) { const img = picture(); previous.after(img); previous = img; } } else { const first = element.firstChild; for (const _source of sources) element.insertBefore(picture(), first); } } while (index < allImages.length) root.appendChild(picture()); return isElement ? root.outerHTML : root.innerHTML; } function qingshuAnswerApiOptions(options = []) { return options.map(option => ({ label: option.label, content: option.content, ...(typeof option.html === 'string' ? { html: option.html } : {}), ...(option.images && option.images.length ? { images: option.images.slice() } : {}) })); } function qingshuNormalizeQuestion(raw = {}, index = 0, ctx = {}) { const description = String(raw.description || raw.questionText || raw.content || ''); const typeText = String(raw.typeDesc || raw.questionType || raw.typeName || ''); const typeMeta = qingshuTypeFromText(typeText); const options = (Array.isArray(raw.options) ? raw.options : []).map((value, i) => { const opt = typeof value === 'string' ? { description: value } : (value || {}); const html = String(opt.description || opt.content || opt.text || opt.name || ''); const images = qingshuExtractImages(html, ctx); for (const value of [opt.imageUrl, opt.image, opt.optionImage]) { const src = qingshuResolveImageUrl(value, ctx); if (src && !images.includes(src)) images.push(src); } return { label: String(opt.label || opt.caption || opt.key || opt.value || String.fromCharCode(65 + i)).trim().toUpperCase(), content: qingshuContentText(new DOMParser().parseFromString(html, 'text/html').body.textContent || ''), html: qingshuContentHtml(html, ctx, images), ...(images.length ? { images } : {}) }; }).filter(opt => opt.label); const images = qingshuExtractImages(description, ctx); for (const value of [raw.imageUrl, raw.image, raw.questionImage]) { const src = qingshuResolveImageUrl(value, ctx); if (src && !images.includes(src)) images.push(src); } const textDoc = new DOMParser().parseFromString(description, 'text/html'); textDoc.querySelectorAll('img').forEach(el => el.remove()); const stem = qingshuContentText(textDoc.body ? textDoc.body.textContent : ''); return { questionId: String(raw.questionId || raw.id || ''), question: stem, stem, stemHtml: qingshuContentHtml(description, ctx, images), images, typeN: typeMeta.typeN, typeName: typeMeta.typeName, options, sequence: Number(raw.sequence || index + 1), existingAnswer: String(raw.userAnswer ?? raw.studentAnswer ?? '').trim(), raw }; } async function qingshuLoadAssignmentDetail(ctx, quizId) { const res = await qingshuApiRequest(ctx, 'Student/DetailData', { params: { quizId } }); const json = qingshuParseJson(res.responseText, '青书作业详情'); if (!qingshuResponseOk(json)) throw new Error(json.message || '读取青书作业详情失败'); const data = json.data || {}; const userQuiz = data.userQuiz || {}; const rows = data.paperDetail && Array.isArray(data.paperDetail.questions) ? data.paperDetail.questions : (Array.isArray(data.questions) ? data.questions : []); return { quizId: String(quizId), userQuizId: String(userQuiz.userQuizId || data.userQuizId || ''), ...qingshuAssignmentState({ userQuiz }), questions: rows.map((row, i) => qingshuNormalizeQuestion(row, i, ctx)), raw: data }; } function qingshuAssignmentEntryUrl(ctx, quizId, href = '') { const target = new URL(href || 'Student/ExercisePaper', qingshuApiBase(ctx) + '/'); if (!href) target.searchParams.set('quizId', String(quizId)); if (!quizId || target.username || target.password || target.origin !== new URL(qingshuApiBase(ctx)).origin || target.pathname.replace(/\/$/, '') !== '/' + ctx.orgCode + '/Student/ExercisePaper' || target.searchParams.getAll('quizId').length !== 1 || target.searchParams.get('quizId') !== String(quizId)) { throw new Error('作业入口链接与当前学校或作业不匹配'); } return target.href; } function qingshuIsFatalTaskError(error) { const status = Number(error && (error.httpStatus || error.status)); const message = String(error && error.message || error || ''); return !!(error && error.fatal) || [401, 403].includes(status) || /HTTP\s*(?:401|403)\b|(?:登录|登陆).*(?:过期|失效|超时)|(?:请|需要).*(?:登录|登陆)|未(?:登录|登陆)/i.test(message); } async function qingshuStartAssignment(ctx, item, detail, isStopped = () => false) { const policy = qingshuMergeAssignmentState(item, detail); if (policy.passed || policy.awaitingGrade || policy.submitted) throw new Error('作业已达标、已交卷或待出分,不能使用首次作答入口'); if (String(detail.quizId) !== String(item.quizId)) throw new Error('作答记录与当前作业不匹配'); if (detail.userQuizId || isStopped()) return detail; const target = qingshuAssignmentEntryUrl(ctx, item.quizId, item.startUrl); await qingshuApiRequest(ctx, target, { headers: { Accept: 'text/html,*/*' } }); if (isStopped()) return detail; const started = await qingshuLoadAssignmentDetail(ctx, item.quizId); if (isStopped()) return started; if (String(started.quizId) !== String(item.quizId)) throw new Error('新作答记录与当前作业不匹配'); if (started.passed || started.submitted) return started; if (!started.userQuizId || !started.statusKnown) throw new Error('开启作业后仍未取得有效作答记录(userQuizId),本份保留待处理'); return started; } async function qingshuReopenAssignment(ctx, item, detail) { const policy = qingshuMergeAssignmentState(item, detail); if (policy.passed || policy.awaitingGrade) throw new Error('作业已达标或正在等待成绩,不开启重做'); if (!policy.submitted) return detail; if (!item.retakeUrl) throw new Error('这份低分作业已交卷,未取得平台原生重做链接;请先在青书页面点击“重新答题”,再运行助手'); const target = new URL(item.retakeUrl); if (target.origin !== new URL(qingshuApiBase(ctx)).origin || target.pathname !== `/${ctx.orgCode}/Student/ExercisePaper` || target.searchParams.get('quizId') !== item.quizId) { throw new Error('重做链接与当前作业不匹配'); } await qingshuApiRequest(ctx, target.href, { headers: { Accept: 'text/html,*/*' } }); const reopened = await qingshuLoadAssignmentDetail(ctx, item.quizId); if (reopened.submitted || !reopened.userQuizId || !reopened.statusKnown) throw new Error('平台未确认作业已重新开放,本轮不覆盖旧交卷'); return reopened; } async function qingshuSaveQuestionAnswer(ctx, quizId, questionId, answer) { if (!quizId || !questionId || !String(answer ?? '').trim()) throw new Error('青书保存答案缺少 quizId / questionId / answer'); const res = await qingshuApiRequest(ctx, 'Svc/SaveQuestionAnswer', { method: 'POST', json: { quizId, questionAnswers: [{ questionId, answer }] } }); const json = qingshuParseJson(res.responseText, '青书保存答案'); if (!qingshuResponseOk(json)) throw new Error(json.message || '青书保存答案失败'); return json; } function qingshuAssertEditableAttempt(detail, quizId, userQuizId = '') { if (!detail || String(detail.quizId) !== String(quizId)) throw new Error('服务器作业与当前页面不匹配,未保存或交卷'); const policy = qingshuApplyPassPolicy(detail); if (policy.passed || policy.submitted || policy.awaitingGrade) throw new Error('这份作业已达标或已交卷,不能重复补答;需要重做时请先使用平台原生入口'); if (!detail.userQuizId) throw new Error('未取得有效作答记录,不能保存或交卷'); if (userQuizId && String(detail.userQuizId) !== String(userQuizId)) throw new Error('作答记录已切换,已停止,避免把答案写入另一轮作业'); const draft = detail.statusCode === 0 || detail.statusCode === 1 || /^(?:未提交|未完成|未开始|未作答|答题中|作答中|已退回|退回重做|待重做)(?:$|\s|[((,,;;])/.test(detail.statusLabel || ''); if (!detail.statusKnown || !draft) throw new Error('平台未明确确认当前作业可作答,未保存或交卷'); return detail; } function qingshuQuestionSnapshot(question) { const image = value => { try { const url = new URL(value, location.href); if (/(^|\.)amazonaws\.com(?:\.cn)?$/i.test(url.hostname)) { for (const key of Array.from(url.searchParams.keys())) { if (/^(?:X-Amz-(?:Algorithm|Credential|Date|Expires|SignedHeaders|Signature|Security-Token)|AWSAccessKeyId|Signature|Expires)$/i.test(key)) url.searchParams.delete(key); } } url.searchParams.sort(); return url.href; } catch (_) { return String(value || ''); } }; return JSON.stringify([question.questionId, question.typeN, question.stem || question.question || '', question.stemHtml ?? null, (question.images || []).map(image), (question.options || []).map(option => [option.label, option.content || '', option.html ?? null, (option.images || []).map(image)])]); } function qingshuServerAnswerState(detail) { const questions = Array.isArray(detail.questions) ? detail.questions : []; const ids = questions.map(question => String(question.questionId || '')); if (!questions.length || ids.some(id => !id) || new Set(ids).size !== ids.length) throw new Error('服务器题目列表缺失或重复,无法核对整卷,未自动交卷'); const answers = [], missing = []; questions.forEach((question, index) => { const rawAnswer = question.raw && (question.raw.userAnswer ?? question.raw.studentAnswer); const supportedShape = rawAnswer == null || ['string', 'number', 'boolean'].includes(typeof rawAnswer); const answer = supportedShape ? qingshuNativeAnswer(question, question.existingAnswer) : ''; if (!answer) missing.push(question.sequence || index + 1); else answers.push({ questionId: question.questionId, userAnswer: answer, sequence: question.sequence || index + 1 }); }); return { answers, missing, total: questions.length }; } async function qingshuSubmitAssignment(ctx, detail, answeredQuestions, timeSpend) { if (detail.submitted) throw new Error('青书作业已提交或已批改,已阻止重复交卷'); const expected = (detail.questions || []).map(q => String(q.questionId || '')); const answers = Array.isArray(answeredQuestions) ? answeredQuestions : []; const ids = answers.map(q => String(q.questionId || '')); if (!detail.quizId || !detail.userQuizId || !expected.length || expected.some(id => !id) || new Set(expected).size !== expected.length || new Set(ids).size !== expected.length || answers.length !== expected.length || ids.some(id => !expected.includes(id)) || answers.some(q => !String(q.userAnswer ?? '').trim())) { throw new Error('青书作业题目或答案不完整,已阻止交卷'); } const res = await qingshuApiRequest(ctx, 'Student/ExercisePaper', { method: 'POST', json: { quizId: detail.quizId, quizType: 'exercise', userQuizId: detail.userQuizId, status: 2, timeSpend: Math.max(1, Math.round(Number(timeSpend) || 1)), questions: answeredQuestions } }); const json = qingshuParseJson(res.responseText, '青书提交作业'); if (!qingshuResponseOk(json)) throw new Error(json.message || '青书提交作业失败'); return json; } async function qingshuBeginStudy(ctx, contentId) { const res = await qingshuApiRequest(ctx, 'Student/Course/UploadStudyRecordBegin', { method: 'POST', json: { classId: ctx.teachPlanId, courseId: ctx.courseId, contentId: contentId || ctx.nodeId || 'kcjs_1_1', contentType: 11, periodId: ctx.periodId, position: 0, detectId: null } }); const json = qingshuParseJson(res.responseText, '青书开始学习'); if (!qingshuResponseOk(json)) throw new Error(json.message || '青书开始学习失败'); const recordId = json.data && typeof json.data === 'object' ? (json.data.recordId || json.data.id) : json.data; if (!recordId) throw new Error('青书开始学习未返回 recordId'); return String(recordId); } async function qingshuContinueStudy(ctx, recordId, position, end = false) { const res = await qingshuApiRequest(ctx, 'Student/Course/UploadStudyRecordContinue', { method: 'POST', beforeSend: end ? () => {} : undefined, form: { recordId, end: end ? 'true' : 'false', position: Math.max(0, Math.round(position)), timeOutConfirm: 'true' } }); const json = qingshuParseJson(res.responseText, '青书学习进度上报'); if (!qingshuResponseOk(json)) throw new Error(json.message || '青书学习进度上报失败'); return json; } function qingshuTypeFromText(text) { const t = String(text || ''); if (/多选/.test(t)) return { typeN: '1', typeName: '多选题' }; if (/填空/.test(t)) return { typeN: '2', typeName: '填空题' }; if (/判断/.test(t)) return { typeN: '3', typeName: '判断题' }; if (/单选/.test(t)) return { typeN: '0', typeName: '单选题' }; if (/简答|问答|论述|名词解释|计算/.test(t)) return { typeN: '4', typeName: t.trim() }; return { typeN: 'unknown', typeName: t.trim() || '未知题型' }; } function parseQingshuQuestions(doc) { const questions = []; const containers = Array.from((doc || document).querySelectorAll('.question-detail-container')); for (const container of containers) { const typeText = (container.querySelector('.question-detail-type-desc') || {}).textContent || ''; const { typeN, typeName } = qingshuTypeFromText(typeText); const descEl = container.querySelector('.question-detail-description') || container.querySelector('.detail-description-content'); const images = descEl ? qingshuExtractImages(descEl) : []; let stemText = ''; if (descEl) { const clone = descEl.cloneNode(true); clone.querySelectorAll('img').forEach(el => el.remove()); stemText = qingshuContentText(clone.textContent || ''); } const options = []; const optEls = Array.from(container.querySelectorAll('.question-detail-option')); optEls.forEach((optEl, idx) => { const input = optEl.querySelector('input.option-input, input[type="radio"], input[type="checkbox"]'); const preview = optEl.querySelector('.option-input-preview'); let label = (input && input.value) || (preview && preview.getAttribute('content')) || ''; label = String(label || '').trim().toUpperCase(); if (!label) label = String.fromCharCode(65 + idx); const description = optEl.querySelector('.option-description'); const content = qingshuContentText(description && description.textContent || ''); const images = qingshuExtractImages(description || optEl); options.push({ label, content, html: description ? qingshuContentHtml(description, {}, images) : qingshuContentHtml(content, {}, images), ...(images.length ? { images } : {}) }); }); const qid = container.id || ''; questions.push({ question: stemText, stem: stemText, stemHtml: descEl ? qingshuContentHtml(descEl, {}, images) : '', images, typeN, typeName, options, qid, element: container }); } return { questions }; } function qingshuTruthValue(value) { if (/^(?:T|TRUE|Y|YES|1|对|正确|是|√|✓)$/i.test(value)) return true; if (/^(?:F|FALSE|N|NO|0|错|错误|不正确|否|×|✗)$/i.test(value)) return false; return null; } function resolveQingshuLabels(answer, q) { const raw = String(answer ?? '').trim(); if (!raw) return []; const options = q.options || []; const optionLabels = options.map(o => o.label); const finish = labels => { const unique = [...new Set(labels)]; if (unique.some(label => !optionLabels.includes(label))) return []; if (q.typeN !== '1' && unique.length !== 1) return []; return optionLabels.filter(label => unique.includes(label)); }; const literalLabels = raw.replace(/^(?:参考答案|正确答案|答案)\s*[::]\s*/i, '').toUpperCase(); if (q.typeN !== '3' && /^[A-Z](?:[\s,,、;;||#]*[A-Z])*$/.test(literalLabels)) { const labels = literalLabels.match(/[A-Z]/g) || []; if (labels.every(label => optionLabels.includes(label))) return finish(labels); } const exact = options.find(o => answerMatchKey(o.content) === answerMatchKey(raw)); if (exact) return [exact.label]; if (q.typeN === '3') { const truth = qingshuTruthValue(raw); if (truth !== null) { const hit = options.find(o => qingshuTruthValue(String(o.content || '').trim()) === truth); return hit ? [hit.label] : []; } } const parts = raw.split(/\s*(?:\||||;|;|、|,|,|#|\n)\s*/).filter(Boolean); const hits = parts.map(part => options.find(o => answerMatchKey(o.content) === answerMatchKey(part))); if (hits.length && hits.every(Boolean)) return finish(hits.map(o => o.label)); const letters = raw.replace(/^(?:参考答案|正确答案|答案)\s*[::]\s*/i, '').toUpperCase(); if (!/^[A-Z](?:[\s,,、;;||#]*[A-Z])*$/.test(letters)) return []; return finish(letters.match(/[A-Z]/g) || []); } function qingshuNativeAnswer(q, answer) { const raw = String(answer ?? '').trim(); if (!raw || !q) return ''; if (q.typeN === '2' || q.typeN === '4') return raw; if (!['0', '1', '3'].includes(q.typeN) || !q.options?.length) return ''; return resolveQingshuLabels(raw, q).join(''); } function fillQingshuAnswer(doc, q, answer) { const labels = resolveQingshuLabels(answer, q); if (!labels.length || !q.element) return false; for (const optEl of Array.from(q.element.querySelectorAll('.question-detail-option'))) { const input = optEl.querySelector('input.option-input, input[type="radio"], input[type="checkbox"]'); if (!input) continue; const wanted = labels.includes(String(input.value || '').trim().toUpperCase()); if (input.checked !== wanted && (wanted || input.type === 'checkbox')) input.click(); } return verifyQingshuAnswerSelected(q, answer); } function verifyQingshuAnswerSelected(q, answer) { const labels = resolveQingshuLabels(answer, q); if (!labels.length || !q.element) return false; const selected = Array.from(q.element.querySelectorAll('input.option-input, input[type="radio"], input[type="checkbox"]')) .filter(input => input.checked).map(input => String(input.value || '').trim().toUpperCase()); return selected.length === labels.length && labels.every(label => selected.includes(label)); } async function submitWorkFormByProtocol(doc) { const form = doc.forms && (doc.forms.form1 || doc.querySelector('form#form1') || doc.querySelector('form')); if (!form) throw new Error('work form not found'); const readField = (name, fallback = '') => { if (!name) return fallback; const els = Array.from(form.elements || []); let el = els.find(e => e && e.name === name) || doc.getElementById(name) || doc.getElementsByName(name)[0]; if (!el && name === 'classId') el = doc.getElementsByName('clazzId')[0]; const v = el && (el.value != null ? el.value : el.getAttribute && el.getAttribute('value')); return v == null || v === '' ? fallback : String(v); }; const setIf = (params, key, value) => { const v = value == null ? '' : String(value); if (!params.has(key) || params.get(key) === '') params.set(key, v); }; const rawAction = form.getAttribute('action') || form.action; if (!rawAction) throw new Error('work form action not found'); const baseHref = (doc.querySelector('base[href]') || {}).href || (doc.URL && doc.URL !== 'about:srcdoc' ? doc.URL : '') || location.href; let actionUrl; try { actionUrl = new URL(rawAction, baseHref && !/^about:/i.test(baseHref) ? baseHref : 'https://mooc1.chaoxing.com'); } catch (_) { actionUrl = new URL(String(rawAction).startsWith('/') ? rawAction : `/mooc-ans/work/${rawAction}`, 'https://mooc1.chaoxing.com'); } if (!/addStudentWorkNewWeb/i.test(actionUrl.pathname)) { actionUrl = new URL('/mooc-ans/work/addStudentWorkNewWeb' + actionUrl.search, actionUrl.origin || 'https://mooc1.chaoxing.com'); } // POST /mooc-ans/work/addStudentWorkNewWeb:参数走 query, // 表单隐藏字段 + answerwqbid + 每题的 answer/answertype 走 body。 const query = new URLSearchParams(actionUrl.search); const body = new URLSearchParams(); for (const el of Array.from(form.elements || [])) { if (!el || el.disabled) continue; const tag = String(el.tagName || '').toLowerCase(); const type = String(el.type || '').toLowerCase(); const name = el.name || ''; if (!name) continue; if (['button', 'submit', 'reset', 'file', 'image'].includes(type)) continue; if ((type === 'checkbox' || type === 'radio') && !el.checked) continue; if (tag === 'select' && el.multiple) { for (const opt of Array.from(el.options || [])) if (opt.selected) body.append(name, opt.value); } else { body.append(name, el.value == null ? '' : String(el.value)); } } const questionIds = Array.from(doc.querySelectorAll('.questionLi[data], .singleQuesId[data], [class*="QuesId"][data]')) .map(el => el.getAttribute('data')) .filter(id => /^\d{6,}$/.test(String(id || ''))); for (const el of Array.from(doc.querySelectorAll('input[id^="answer"], input[name^="answer"]'))) { const id = String(el.id || el.name || '').match(/answer(?:type)?(\d{6,})/); if (id) questionIds.push(id[1]); } const answerwqbid = [...new Set(questionIds)].join(',') + (questionIds.length ? ',' : ''); const classId = query.get('_classId') || readField('classId') || readField('clazzId'); const courseId = query.get('courseid') || readField('courseId'); const encWork = readField('enc_work') || query.get('token'); const totalQuestionNum = query.get('totalQuestionNum') || readField('totalQuestionNum') || (questionIds.length ? String([...new Set(questionIds)].length) : ''); const workId = query.get('workid') || readField('workId') || readField('workRelationId') || readField('oldWorkId'); const cpi = query.get('cpi') || readField('cpi'); const jobid = query.get('jobid') || readField('jobid'); const knowledgeid = query.get('knowledgeid') || readField('knowledgeid'); setIf(query, '_classId', classId); setIf(query, 'courseid', courseId); setIf(query, 'token', encWork); setIf(query, 'totalQuestionNum', totalQuestionNum); setIf(query, 'workid', workId); setIf(query, 'cpi', cpi); setIf(query, 'jobid', jobid); setIf(query, 'knowledgeid', knowledgeid); setIf(query, 'testmooc2', '1'); setIf(query, 'originJobId', jobid); setIf(query, 'ua', 'pc'); setIf(query, 'formType', 'post'); setIf(query, 'saveStatus', '1'); setIf(query, 'pos', 'fff464ab92471da187001052c1'); setIf(query, 'rd', String(Math.random())); setIf(query, 'value', '(NaN|NaN)'); setIf(query, 'wid', workId); setIf(query, '_edt', String(Date.now())); setIf(query, 'version', '1'); const setBody = (key, value) => setIf(body, key, value); setBody('pyFlag', readField('pyFlag')); setBody('courseId', courseId); setBody('classId', classId); setBody('api', '1'); setBody('workAnswerId', readField('workAnswerId') || readField('answerId')); setBody('answerId', readField('answerId') || readField('workAnswerId')); setBody('totalQuestionNum', totalQuestionNum); setBody('fullScore', readField('fullScore') || '100.0'); setBody('knowledgeid', knowledgeid || '0'); setBody('oldSchoolId', readField('oldSchoolId')); setBody('oldWorkId', readField('oldWorkId') || jobid); setBody('jobid', jobid); setBody('workRelationId', readField('workRelationId') || workId); setBody('enc', readField('enc')); setBody('standardEnc', readField('standardEnc')); setBody('enc_work', encWork); setBody('userId', readField('userId')); setBody('cpi', cpi); setBody('workTimesEnc', readField('workTimesEnc')); setBody('randomOptions', readField('randomOptions') || 'false'); setBody('isAccessibleCustomFid', readField('isAccessibleCustomFid') || '0'); setBody('mooc2', readField('mooc2') || '1'); if (answerwqbid) body.set('answerwqbid', answerwqbid); for (const qid of [...new Set(questionIds)]) { const type = body.get(`answertype${qid}`) || readField(`answertype${qid}`); const answer = body.get(`answer${qid}`) || readField(`answer${qid}`); if (type) body.set(`answertype${qid}`, type); if (answer) body.set(`answer${qid}`, answer); } const missingAnswers = [...new Set(questionIds)].filter(qid => { const type = body.get(`answertype${qid}`) || readField(`answertype${qid}`); if (type === '2') return false; // Fill-in fields can be answerEditor{id}{n}. return !(body.get(`answer${qid}`) || readField(`answer${qid}`)); }); if (missingAnswers.length) throw new Error(`work answers missing: ${missingAnswers.slice(0, 5).join(',')}`); actionUrl.search = query.toString(); const referer = (doc.querySelector('base[href]') || {}).href || (doc.URL && doc.URL !== 'about:srcdoc' ? doc.URL : actionUrl.href); const res = await request({ method: 'POST', url: actionUrl.href, data: body.toString(), headers: { 'Accept': 'application/json, text/javascript, */*; q=0.01', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Referer': referer, 'X-Requested-With': 'XMLHttpRequest' }, timeout: 30000 }); const text = String(res.responseText || ''); if (res.status && (res.status < 200 || res.status >= 300)) throw new Error(`work submit HTTP ${res.status}`); // Parse first, then validate: a rejection must not be swallowed by the JSON catch. let json; try { json = JSON.parse(text); } catch (_) {} if (json && typeof json === 'object') { if (json.status === false || json.success === false) throw new Error('章节测验提交被拒绝,任务仍待处理'); if (json.status === true || json.success === true) return res; } // Do not mistake an empty response, HTML page or a message containing "success" for acceptance. if (/^(?:success|成功|提交成功|保存成功)[!!。.]?$/i.test(text.trim())) return res; throw new Error('章节测验提交结果未确认,任务仍待处理'); } function isCxOptionSelected(option) { if (!option) return false; if (option.getAttribute && option.getAttribute('aria-checked') === 'true') return true; if (option.matches && option.matches('.check_answer,.check_answer_dx')) return true; if (option.matches && option.matches('li.Hover')) return true; if (isNativeChoiceInput(option) && option.checked) return true; if (option.querySelector && option.querySelector('input[type="radio"]:checked,input[type="checkbox"]:checked')) return true; return !!(option.querySelector && option.querySelector('.check_answer,.check_answer_dx')); } function isNativeChoiceInput(el) { return !!(el && el.matches && el.matches('input[type="radio"],input[type="checkbox"]')); } function getQuestionOwner(node) { return node && node.closest && node.closest('.questionLi,.singleQuesId,.Py-mian1,.TiMu') || null; } function getQuestionOwnerQid(node) { const owner = getQuestionOwner(node); if (!owner) return ''; let qid = owner.getAttribute('data') || ''; if (!qid && owner.id) { const m = String(owner.id).match(/(?:sigleQuestionDiv_|q_)?(\d{3,})/); if (m) qid = m[1]; } if (!qid) { const inp = owner.querySelector('input[name^="answer"],input[id^="answer"],input[name^="answers"],input[id^="answers"]'); const m = inp && String(inp.name || inp.id || '').match(/answers?(\d{3,})/); if (m) qid = m[1]; } return qid || ''; } function readOptionLabelFromNode(node) { if (!node) return ''; const host = node.closest && node.closest('li,label,div.answerBg,div.stem_answer,[role="radio"],[role="checkbox"]') || node; const native = isNativeChoiceInput(node) ? node : (host.querySelector && host.querySelector('input[type="radio"],input[type="checkbox"]')); const marker = host.querySelector && (host.querySelector('span[data],.num_option_dx,.num_option,em[id-param],em,i') || null); let raw = marker && (marker.getAttribute('data') || marker.getAttribute('id-param') || marker.textContent || '').trim() || (native && native.value || '') || (node.getAttribute && (node.getAttribute('data') || node.getAttribute('id-param') || node.getAttribute('value')) || '') || ''; let label = String(raw || '').trim().replace(/[、..\uFF0E,,::\s]+$/g, '').toUpperCase(); if (/^(TRUE|T|YES|Y|1|对|正确|是|√|✓)$/i.test(label)) return 'A'; if (/^(FALSE|F|NO|N|0|错|错误|否|×|✗)$/i.test(label)) return 'B'; if (/^[A-Z]$/.test(label)) return label; const text = cleanText(host.textContent || node.textContent || ''); let m = text.match(/^([A-Z])(?:\s|[、..\uFF0E,,::])/i); if (!m) m = text.match(/^\s*([A-Z])\b/i); return m ? m[1].toUpperCase() : ''; } function findNativeChoiceInputsForQuestion(doc, q) { if (!doc || !q) return []; const qid = getParsedQuestionId(q); const names = []; if (q.questionId) names.push(String(q.questionId)); if (qid) names.push(`answer${qid}`, `answers${qid}`); const out = []; for (const name of [...new Set(names.filter(Boolean))]) { try { out.push(...Array.from(doc.getElementsByName(name))); } catch (_) {} try { const byId = doc.getElementById(name); if (byId) out.push(byId); } catch (_) {} } const container = findQuestionContainer(doc, q) || q.element; if (container && container.querySelectorAll) out.push(...Array.from(container.querySelectorAll('input[type="radio"],input[type="checkbox"]'))); return [...new Set(out.filter(isNativeChoiceInput))].filter(inp => { const ownerQid = getQuestionOwnerQid(inp); return !qid || !ownerQid || ownerQid === qid || String(inp.name || inp.id || '').includes(qid); }); } function applyNativeChoiceInputs(doc, q, labels) { const inputs = findNativeChoiceInputsForQuestion(doc, q); if (!inputs.length) return 0; const typeN = effectiveQuestionTypeN(q); let wantLabels = [...new Set((labels || []).map(x => String(x || '').toUpperCase()).filter(Boolean))]; if (typeN !== '1') wantLabels = wantLabels.slice(0, 1); const want = new Set(wantLabels); if (!want.size) return 0; let hits = 0; for (const input of inputs) { const label = readOptionLabelFromNode(input); const hit = want.has(label); const shouldCheck = typeN === '1' ? hit : hit; const changed = input.checked !== shouldCheck; input.checked = shouldCheck; if (shouldCheck) input.setAttribute && input.setAttribute('checked', 'checked'); else input.removeAttribute && input.removeAttribute('checked'); const li = input.closest && input.closest('li'); if (li && li.classList) li.classList.toggle('Hover', shouldCheck); if (changed || shouldCheck) dispatchInputEvents(input); if (shouldCheck) hits++; } return hits; } function markCxOptionSelected(option, isMulti = false) { if (!option) return; try { // .stem_answer 是选项列表容器,不是真正的选项行。 // 如果误传入父容器,改为使用其中已经标记的真实 .answerBg 选项行,避免把父容器 aria 标成选中。 if (option.matches && option.matches('.stem_answer')) { const checkedRow = option.querySelector('.answerBg .check_answer,.answerBg .check_answer_dx'); option = checkedRow && checkedRow.closest && checkedRow.closest('.answerBg,[role="radio"],[role="checkbox"]') || null; if (!option) return; } const nativeInput = isNativeChoiceInput(option) ? option : (option.querySelector && option.querySelector('input[type="radio"],input[type="checkbox"]')); if (nativeInput) { if (!isMulti) { const scope = getQuestionOwner(nativeInput) || nativeInput.ownerDocument || document; const peers = nativeInput.name && scope.querySelectorAll ? Array.from(scope.querySelectorAll(`input[name="${cssEscape(nativeInput.name)}"]`)) : [nativeInput]; for (const peer of peers) { if (!isNativeChoiceInput(peer)) continue; peer.checked = peer === nativeInput; if (peer === nativeInput) peer.setAttribute && peer.setAttribute('checked', 'checked'); else peer.removeAttribute && peer.removeAttribute('checked'); const li = peer.closest && peer.closest('li'); if (li && li.classList) li.classList.toggle('Hover', peer === nativeInput); } } else { nativeInput.checked = true; nativeInput.setAttribute && nativeInput.setAttribute('checked', 'checked'); const li = nativeInput.closest && nativeInput.closest('li'); if (li && li.classList) li.classList.add('Hover'); } dispatchInputEvents(nativeInput); } if (!isMulti) { const qid = option.getAttribute && option.getAttribute('qid'); const list = option.closest && option.closest('.stem_answer'); if (list) { list.removeAttribute && list.removeAttribute('aria-checked'); list.removeAttribute && list.removeAttribute('aria-pressed'); } if (qid && option.ownerDocument) { for (const id of [`answer${qid}`, `answertype${qid}`]) { const hidden = option.ownerDocument.getElementById(id) || option.ownerDocument.getElementsByName(id)[0]; if (hidden) { hidden.removeAttribute && hidden.removeAttribute('aria-checked'); hidden.removeAttribute && hidden.removeAttribute('aria-pressed'); } } } const scope = option.closest && (option.closest('.questionLi,.singleQuesId,.Py-mian1,.TiMu') || option.parentElement); const selector = qid ? `.answerBg[qid="${cssEscape(qid)}"],[role="radio"][qid="${cssEscape(qid)}"],[role="checkbox"][qid="${cssEscape(qid)}"]` : '.answerBg,[role="radio"]'; let peers = scope ? Array.from(scope.querySelectorAll(selector)) : []; if ((!peers.length || peers.length === 1) && qid && option.ownerDocument) peers = Array.from(option.ownerDocument.querySelectorAll(selector)); for (const peer of peers) { if (peer === option) continue; peer.setAttribute && peer.setAttribute('aria-checked', 'false'); peer.setAttribute && peer.setAttribute('aria-pressed', 'false'); const marker = peer.querySelector && (peer.querySelector('.num_option,.num_option_dx,span[data]')); if (marker && marker.classList) marker.classList.remove('check_answer', 'check_answer_dx'); const input = peer.querySelector && peer.querySelector('input[type="radio"],input[type="checkbox"]'); if (input) input.checked = false; } } option.setAttribute && option.setAttribute('aria-checked', 'true'); option.setAttribute && option.setAttribute('aria-pressed', 'true'); const marker = option.querySelector && (option.querySelector(isMulti ? '.num_option_dx' : '.num_option') || option.querySelector('span[data]')); if (marker && marker.classList) marker.classList.add(isMulti ? 'check_answer_dx' : 'check_answer'); } catch (_) {} } function syncCxSelectionDomState(doc, q, labels) { const typeN = effectiveQuestionTypeN(q); if (!doc || !q || typeN === '2') return; const qid = getParsedQuestionId(q); const container = findQuestionContainer(doc, q); if (!container) return; const want = new Set((labels || []).map(x => String(x || '').toUpperCase()).filter(Boolean)); if (!want.size) return; try { container.querySelectorAll('.stem_answer').forEach(el => { el.removeAttribute && el.removeAttribute('aria-checked'); el.removeAttribute && el.removeAttribute('aria-pressed'); }); if (qid) { for (const id of [`answer${qid}`, `answertype${qid}`]) { const hidden = doc.getElementById(id) || Array.from(doc.getElementsByName(id) || []).find(el => !isNativeChoiceInput(el)); if (hidden) { hidden.removeAttribute && hidden.removeAttribute('aria-checked'); hidden.removeAttribute && hidden.removeAttribute('aria-pressed'); } } } applyNativeChoiceInputs(doc, q, [...want]); const rows = Array.from(container.querySelectorAll('.answerBg,[role="radio"],[role="checkbox"]')); for (const row of rows) { const rowQid = row.getAttribute && row.getAttribute('qid'); if (qid && rowQid && rowQid !== qid) continue; const marker = row.querySelector && (row.querySelector('.num_option_dx,.num_option,span[data],em[id-param],i')); const label = readOptionLabelFromNode(row); if (!/^[A-Z]$/.test(label)) continue; const hit = want.has(label); row.setAttribute && row.setAttribute('aria-checked', hit ? 'true' : 'false'); row.setAttribute && row.setAttribute('aria-pressed', hit ? 'true' : 'false'); if (marker && marker.classList) { const checkClass = typeN === '1' ? 'check_answer_dx' : 'check_answer'; const otherClass = typeN === '1' ? 'check_answer' : 'check_answer_dx'; marker.classList.toggle(checkClass, hit); if (!hit) marker.classList.remove(otherClass); } } } catch (_) {} } function fillWorkAnswer(doc, optionLis, q, answer) { const typeN = normalizeQuestionTypeMeta(q); const ans = String(answer || '').replace(/[\s,?#]+/g, '').trim(); if (!ans) return false; if (typeN === '2') { const parts = String(answer).split(/#!#|[;?]/).map(x => x.trim()).filter(Boolean); const fill = parts.length ? parts : [String(answer).trim()]; if (!q.inputList || !q.inputList.length) return false; q.inputList.forEach((inp, i) => { setElementValue(inp, fill[i] || fill[0] || ''); }); return true; } let labels = []; if (typeN === '3') { // 判断题统一走 judgeAnswerLabel:题库返回 "AB" 这类多选式脏答案时也能落到 A/B,不再整题填不上。 const judged = judgeAnswerLabel(answer); if (judged) labels = [judged]; } else if (/^[A-Z]+$/i.test(ans)) { labels = ans.toUpperCase().split(''); if (String(typeN) !== '1') labels = labels.slice(0, 1); } if (!labels.length) { const ansKey = answerMatchKey(cleanText(answer)); for (const o of q.options) { const c = answerMatchKey(cleanText(o.content)); if (c && ansKey && (ansKey.includes(c) || c.includes(ansKey))) labels.push(String(o.label).toUpperCase()); } } if (!labels.length && typeN === '0') { // 单选题兜底:答案为 A 但上游解析/全角字符导致正则未命中时,尝试首字符。 const first = ans.replace(/[^A-Za-z]/g, '').slice(0, 1).toUpperCase(); if (first) labels = [first]; } if (!labels.length) return false; const qid = getParsedQuestionId(q); const pickNonChoiceAnswerInput = (...names) => { for (const name of names.filter(Boolean)) { const byId = doc.getElementById(name); if (byId && !isNativeChoiceInput(byId)) return byId; const byName = Array.from(doc.getElementsByName(name || '') || []).find(el => !isNativeChoiceInput(el)); if (byName) return byName; } return null; }; let input = pickNonChoiceAnswerInput(q.questionId, qid ? `answer${qid}` : '', qid ? `answers${qid}` : ''); let uniqueLabels = [...new Set(labels.map(x => String(x).toUpperCase()))]; if (String(typeN) !== '1' && uniqueLabels.length > 1) uniqueLabels = uniqueLabels.slice(0, 1); if (input) { if (typeN === '0') setElementValue(input, uniqueLabels[0]); else if (typeN === '3') setElementValue(input, uniqueLabels.includes('FALSE') || uniqueLabels.includes('B') ? 'false' : 'true'); else setElementValue(input, uniqueLabels.join('')); } const win = doc.defaultView || {}; let clicked = applyNativeChoiceInputs(doc, q, uniqueLabels); for (const li of optionLis || []) { let sameQuestion = false; const liQid = li.getAttribute('qid'); if (liQid && q.qid) sameQuestion = liQid === q.qid; if (!sameQuestion) { const ownerQid = getQuestionOwnerQid(li); if (ownerQid && qid) sameQuestion = ownerQid === qid; } if (!sameQuestion) { const native = li.querySelector && li.querySelector('input[name^="answer"],input[id^="answer"],input[name^="answers"],input[id^="answers"]'); const m = native && String(native.name || native.id || '').match(/answers?(\d{3,})/); if (m && qid) sameQuestion = m[1] === qid; } if (!sameQuestion && li.getAttribute('id-param')) sameQuestion = li.getAttribute('id-param') === String(q.questionId || '').replace('answer', '').replace('s', ''); if (!sameQuestion) continue; const marker = li.querySelector('span[data]') || li.querySelector('em') || li.querySelector('.num_option,.num_option_dx'); const rawLabel = marker && (marker.getAttribute('data') || marker.getAttribute('id-param') || marker.textContent || '').trim(); const visualLabel = marker && (marker.textContent || '').trim(); const normalized = String(rawLabel || readOptionLabelFromNode(li) || '').toUpperCase(); const visual = String(visualLabel || '').toUpperCase(); const hit = uniqueLabels.includes(normalized) || uniqueLabels.includes(visual) || (typeN === '3' && input && ((input.value === 'true' && (normalized === 'TRUE' || visual === 'A')) || (input.value === 'false' && (normalized === 'FALSE' || visual === 'B')))); if (!hit) continue; const isMulti = String(q.typeN) === '1' || li.getAttribute('qtype') === '1' || li.getAttribute('role') === 'checkbox' || !!(marker && marker.classList && marker.classList.contains('num_option_dx')) || !!(li.querySelector && li.querySelector('input[type="checkbox"]')); if (isCxOptionSelected(li)) { // 有些学习通页面只给内部 span 加 check_answer,但外层选项行 aria 仍是 false。 // 这里即使已经选中,也同步外层状态,避免保存/复检和视觉状态不一致。 markCxOptionSelected(li, isMulti); clicked++; continue; } try { if (isMulti && typeof win.addMultipleChoice === 'function') win.addMultipleChoice(li); else if (!isMulti && typeof win.addChoice === 'function') win.addChoice(li); else dispatchMouseSequence(li, win); } catch (_) {} markCxOptionSelected(li, isMulti); clicked++; if (!isMulti) break; } if (!input && clicked && q.qid) { input = doc.getElementById(`answer${q.qid}`) || doc.getElementsByName(`answer${q.qid}`)[0]; if (input && !input.value) { if (typeN === '3') input.value = uniqueLabels.includes('FALSE') || uniqueLabels.includes('B') ? 'false' : 'true'; else input.value = String(typeN) === '1' ? uniqueLabels.join('') : uniqueLabels[0]; } } if (!clicked && q.element) clicked += clickOptionsInContainer(doc, q.element, uniqueLabels, q); syncCxSelectionDomState(doc, q, uniqueLabels); return !!(clicked || (input && input.value)); } function fillExamAnswer(doc, q, answer) { const typeN = normalizeQuestionTypeMeta(q); const qid = q && (q.qid || q.id || String(q.questionId || '').replace(/\D+/g, '')); if (!qid) return false; const container = findQuestionContainer(doc, q); if (typeN === '2') { const parts = String(answer || '').split(/#!#|[|;;]/).map(x => x.trim()).filter(Boolean); const fill = parts.length ? parts : [String(answer || '').trim()]; const inputs = []; for (let i = 1; i <= Math.max(fill.length, 10); i++) { inputs.push(...Array.from(doc.querySelectorAll(`[name="answerEditor${qid}${i}"],#answerEditor${qid}${i},[id="answerEditor${qid}${i}"]`))); } if (container) inputs.push(...Array.from(container.querySelectorAll('input.answerInput,textarea,[contenteditable="true"],iframe[id^="ueditor_"]'))); let wrote = 0; const uniq = [...new Set(inputs.filter(Boolean))]; uniq.forEach((el, i) => { if (el.tagName === 'IFRAME') { try { const d = el.contentDocument || (el.contentWindow && el.contentWindow.document); const target = d && (d.body || d.querySelector('p')); if (target) { setElementValue(target, fill[i] || fill[0] || ''); wrote++; } } catch (_) {} } else { setElementValue(el, fill[i] || fill[0] || ''); wrote++; } }); return wrote > 0; } let normalized = normalizeExamAnswer(answer, q); let labels = []; if (typeN === '3') { const n = String(normalized || '').toLowerCase(); if (n === 'true') { const opt = (q.options || []).find(o => /对|正确|是/i.test(o.content || '')); labels = [String(opt && opt.label || 'A').toUpperCase()]; } else if (n === 'false') { const opt = (q.options || []).find(o => /错|错误|否/i.test(o.content || '')); labels = [String(opt && opt.label || 'B').toUpperCase()]; } else if (/^[AB]$/i.test(String(normalized || ''))) labels = [String(normalized).toUpperCase()]; } else { labels = String(normalized || '').toUpperCase().replace(/[^A-Z]/g, '').split(''); } labels = [...new Set(labels.filter(Boolean))]; if (!labels.length) return false; const value = typeN === '1' ? labels.join('') : labels[0]; const hiddenNames = typeN === '1' ? [`answers${qid}`, `answer${qid}`] : [`answer${qid}`, `answers${qid}`]; for (const name of hiddenNames) { for (const el of Array.from(doc.querySelectorAll(`[name="${cssEscape(name)}"],#${cssEscape(name)}`))) setElementValue(el, value); } let clicked = 0; if (container) clicked += clickOptionsInContainer(doc, container, labels, q); if (!clicked) clicked += clickOptionsInContainer(doc, doc, labels, q); return clicked > 0 || hiddenNames.some(name => Array.from(doc.querySelectorAll(`[name="${cssEscape(name)}"],#${cssEscape(name)}`)).some(el => el.value)); } function normalizeXuetangxExamAnswerLabels(answer, q = {}) { const raw = String(answer || '').trim(); const typeN = effectiveQuestionTypeN(q); if (typeN === '2' || typeN === '4') return []; const options = q.options || []; const hasBoolOptions = options.some(o => /^(true|false)$/i.test(String(o.key || '')) || /正确|错误|对|错/.test(String(o.content || ''))); if (/^(true|t)$/i.test(raw)) { const opt = options.find(o => /正确|对|是|true/i.test(o.content || '') || /^true$/i.test(String(o.key || ''))); return [String(opt && opt.label || 'A').toUpperCase()]; } if (/^(false|f)$/i.test(raw)) { const opt = options.find(o => /错误|错|否|false/i.test(o.content || '') || /^false$/i.test(String(o.key || ''))); return [String(opt && opt.label || 'B').toUpperCase()]; } if (typeN === '3' || hasBoolOptions) { if (/^(正确|对|是|true|t|√|1|A)$/i.test(raw)) { const opt = options.find(o => /正确|对|是|true/i.test(o.content || '')); return [String(opt && opt.label || 'A').toUpperCase()]; } if (/^(错误|错|否|false|f|×|0|B)$/i.test(raw)) { const opt = options.find(o => /错误|错|否|false/i.test(o.content || '')); return [String(opt && opt.label || 'B').toUpperCase()]; } } const direct = raw.replace(/[\s,,、#;;|]+/g, '').toUpperCase(); if (/^[A-Z]+$/.test(direct)) return [...new Set(direct.split(''))]; const parts = raw.split(/[#|,,;;\n]+/).map(x => normalizeSpaces(x).toLowerCase()).filter(Boolean); const labels = []; for (const part of parts) { const opt = options.find(o => String(o.label || '').toLowerCase() === part || normalizeSpaces(o.content || '').toLowerCase() === part || normalizeSpaces(o.content || '').toLowerCase().includes(part) ); if (opt) labels.push(String(opt.label).toUpperCase()); } return [...new Set(labels)]; } function getXuetangxVisualChoiceLabel(row) { const node = row && row.querySelector && row.querySelector('.radioInput,.checkboxInput,[class*="radioInput"],[class*="checkboxInput"]'); const text = cleanText(node && (node.innerText || node.textContent) || '').replace(/\s+/g, ''); return /^[A-Z]$/i.test(text) ? text.toUpperCase() : ''; } function findXuetangxChoiceByVisualLabel(container, label, isMultiChoice = false) { const selector = isMultiChoice ? '.el-checkbox,label.el-checkbox,.list-unstyled-checkbox li' : '.el-radio,label.el-radio,.el-checkbox,label.el-checkbox,.list-unstyled-radio li,.list-unstyled-checkbox li'; return Array.from(container.querySelectorAll(selector)) .find(row => getXuetangxVisualChoiceLabel(row) === String(label || '').toUpperCase()) || null; } function setXuetangxChoiceDomChecked(row, checked = true) { if (!row) return; const label = row.matches && row.matches('label.el-radio,label.el-checkbox') ? row : row.querySelector && row.querySelector('label.el-radio,label.el-checkbox') || row; const uiInput = label.querySelector && label.querySelector('.el-radio__input,.el-checkbox__input'); if (checked) { label.classList && label.classList.add('is-checked'); uiInput && uiInput.classList && uiInput.classList.add('is-checked'); } else { label.classList && label.classList.remove('is-checked'); uiInput && uiInput.classList && uiInput.classList.remove('is-checked'); } } function sleepXuetangx(ms = 300) { return new Promise(resolve => setTimeout(resolve, Math.max(0, Number(ms || 0)))); } function getXuetangxChoiceInput(row) { return row && row.querySelector && row.querySelector('input[type="radio"],input[type="checkbox"]') || null; } function dispatchXuetangxChoiceClick(row) { if (!row) return false; const label = row.matches && row.matches('label.el-radio,label.el-checkbox') ? row : row.querySelector && row.querySelector('label.el-radio,label.el-checkbox') || row; const win = label.ownerDocument && label.ownerDocument.defaultView || window; try { label.focus && label.focus(); } catch (_) {} for (const type of ['pointerdown', 'mousedown', 'mouseup', 'click']) { try { const Ctor = type.startsWith('pointer') && win.PointerEvent ? win.PointerEvent : win.MouseEvent; label.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, view: win })); } catch (_) {} } return true; } async function setXuetangxChoicesByLabels(container, labels = [], isMultiChoice = false) { const want = new Set(labels.map(x => String(x || '').toUpperCase()).filter(Boolean)); const rows = Array.from(container.querySelectorAll(isMultiChoice ? '.el-checkbox,label.el-checkbox' : '.el-radio,label.el-radio,.el-checkbox,label.el-checkbox')); let matched = 0; const uniqueRows = [...new Set(rows)]; for (const row of uniqueRows) { const label = getXuetangxVisualChoiceLabel(row); if (!label || want.has(label)) continue; const input = getXuetangxChoiceInput(row); if (isMultiChoice && input && input.checked) { dispatchXuetangxChoiceClick(row); await sleepXuetangx(350); } setXuetangxChoiceDomChecked(row, false); } for (const row of uniqueRows) { const label = getXuetangxVisualChoiceLabel(row); if (!label) continue; const checked = want.has(label); if (!checked) continue; matched++; const input = getXuetangxChoiceInput(row); // 真实保存依赖 Vue/ElementUI 的 change/click 链路,单独加 is-checked 只会改视觉,不会保存。 if (!input || !input.checked) { dispatchXuetangxChoiceClick(row); await sleepXuetangx(450); } setXuetangxChoiceDomChecked(row, true); if (!isMultiChoice) break; } return matched; } function isXuetangxChoiceSelected(row) { if (!row) return false; return row.getAttribute('aria-checked') === 'true' || /\b(is-checked|selected|active|checked)\b/i.test(String(row.className || '')) || !!(row.querySelector && row.querySelector('.el-radio__input.is-checked,.el-checkbox__input.is-checked')); } async function setXuetangxBooleanChoice(container, boolValue) { const value = String(boolValue || '').toLowerCase(); if (!/^(true|false)$/.test(value)) return false; let matched = false; const rows = Array.from(container.querySelectorAll('.el-radio,label.el-radio,.el-checkbox,label.el-checkbox')); for (const row of rows) { const input = getXuetangxChoiceInput(row); const rowValue = input ? String(input.value || '').trim().toLowerCase() : ''; if (!/^(true|false)$/.test(rowValue)) continue; const checked = rowValue === value; if (checked) { if (!input.checked) { dispatchXuetangxChoiceClick(row); await sleepXuetangx(450); } setXuetangxChoiceDomChecked(row, true); matched = true; } else { setXuetangxChoiceDomChecked(row, false); } } return matched; } async function fillXuetangxExamAnswer(doc, q, answer) { const container = q && q.element; if (!container) return false; const typeN = normalizeQuestionTypeMeta(q); const isMultiChoice = typeN === '1' || /多选/.test(String(q.typeName || '')) || !!container.querySelector('input[type="checkbox"]'); if (typeN === '2' || typeN === '4') { const parts = String(answer || '').split(/#!#|[|;;]/).map(x => x.trim()).filter(Boolean); const fill = parts.length ? parts : [String(answer || '').trim()]; const inputs = Array.from(container.querySelectorAll('textarea,input[type="text"],input:not([type]),[contenteditable="true"]')).filter(xuetangxVisible); let wrote = 0; inputs.slice(0, Math.max(1, fill.length)).forEach((el, i) => { setElementValue(el, fill[i] || fill[0] || ''); wrote++; }); q._xuetangxFilled = wrote > 0; return q._xuetangxFilled; } const labels = normalizeXuetangxExamAnswerLabels(answer, q); const hasBoolInputs = !!container.querySelector('input[type="radio"][value="true"],input[type="radio"][value="false"],input[type="checkbox"][value="true"],input[type="checkbox"][value="false"]'); if (hasBoolInputs && labels.length) { const ok = labels.includes('A') ? await setXuetangxBooleanChoice(container, 'true') : (labels.includes('B') ? await setXuetangxBooleanChoice(container, 'false') : false); q._xuetangxFilled = ok; return ok; } if (!labels.length) { const raw = String(answer || '').trim(); const boolValue = /^(正确|对|是|true|t|√|1|A)$/i.test(raw) ? 'true' : (/^(错误|错|否|false|f|×|0|B)$/i.test(raw) ? 'false' : ''); if (boolValue) { const ok = await setXuetangxBooleanChoice(container, boolValue); q._xuetangxFilled = ok; return ok; } return false; } const matched = isMultiChoice ? await setXuetangxChoicesByLabels(container, labels, true) : await setXuetangxChoicesByLabels(container, labels.slice(0, 1), false); q._xuetangxFilled = matched > 0; return q._xuetangxFilled; } function pageQuestionKey(q) { return [ q && (q.qid || q.id || ''), q && (q.questionId || ''), cleanText(q && q.question || '').slice(0, 60), q && q.typeN || '', (q && q.options || []).map(o => `${o.label}:${cleanText(o.content || '')}`).join('|').slice(0, 120) ].join('||'); } function getParsedQuestionId(q) { return q && (q.qid || q.id || String(q.questionId || '').replace(/\D+/g, '')) || ''; } function findAnswerInputs(doc, q) { const qid = getParsedQuestionId(q); const names = []; if (q && q.questionId) names.push(String(q.questionId)); if (qid) names.push(`answer${qid}`, `answers${qid}`); const out = []; for (const name of [...new Set(names.filter(Boolean))]) { try { const byId = doc.getElementById(name); if (byId && !isNativeChoiceInput(byId)) out.push(byId); } catch (_) {} try { out.push(...Array.from(doc.getElementsByName(name)).filter(el => !isNativeChoiceInput(el))); } catch (_) {} try { out.push(...Array.from(doc.querySelectorAll(`#${cssEscape(name)},[name="${cssEscape(name)}"]`)).filter(el => !isNativeChoiceInput(el))); } catch (_) {} } return [...new Set(out.filter(Boolean))]; } function expectedAnswerLabels(q, answer) { const typeN = effectiveQuestionTypeN(q); if (!q || typeN === '2') return []; const raw = String(answer || '').trim(); const compact = raw.replace(/[\s,\uFF0C\u3001#;\uFF1B??]+/g, '').toUpperCase(); let normalized = normalizeExamAnswer(answer, q); let labels = []; if (typeN === '3') { // 校验口径必须和 fillWorkAnswer / fillExamAnswer 完全一致,否则答案填进去了却被判成“未保存”。 const judged = judgeAnswerLabel(raw) || judgeAnswerLabel(normalized); if (judged) labels = [judged]; } else if (/^[A-Z]+$/.test(String(normalized || '').toUpperCase().replace(/[^A-Z]/g, ''))) { labels = String(normalized || '').toUpperCase().replace(/[^A-Z]/g, '').split(''); } else if (/^[A-Z]+$/.test(compact)) { labels = compact.split(''); } if (!labels.length) { const ansKey = answerMatchKey(cleanText(raw)); for (const o of q.options || []) { const c = answerMatchKey(cleanText(o.content || '')); if (c && ansKey && (ansKey.includes(c) || c.includes(ansKey))) labels.push(String(o.label || '').toUpperCase()); } } if (!labels.length && typeN === '0') { const first = compact.replace(/[^A-Z]/g, '').slice(0, 1); if (first) labels = [first]; } return [...new Set(labels.filter(Boolean))]; } const CX_COMPAT_CHAR_RE = /[\u2e80-\u2ef3\u2f00-\u2fd5\u3038]/g; const CX_COMPAT_CHAR_MAP = { '\u2e9f': '母', '\u2ea0': '民', '\u2ec1': '虎', '\u2ec5': '见', '\u2ec6': '角', '\u2ec9': '贝', '\u2ecb': '车', '\u2ed3': '长', '\u2ed4': '门', '\u2ed8': '青', '\u2ed9': '韦', '\u2eda': '页', '\u2edb': '风', '\u2edc': '飞', '\u2edd': '食', '\u2ee2': '马', '\u2ee3': '骨', '\u2ee4': '鬼', '\u2ee5': '鱼', '\u2ee6': '鸟', '\u2ee7': '卤', '\u2ee8': '麦', '\u2ee9': '黄', '\u2eec': '齐', '\u2eee': '齿', '\u2ef0': '龙', '\u2ef3': '龟', '\u2f3e': '户', '\u3038': '十' }; function normalizeCompatChars(text) { const raw = String(text == null ? '' : text); if (!raw) return ''; return raw.replace(CX_COMPAT_CHAR_RE, ch => { const fixed = CX_COMPAT_CHAR_MAP[ch]; if (fixed) return fixed; if (ch >= '\u2f00' && ch <= '\u2fd5') { const nfkc = ch.normalize('NFKC'); if (nfkc.length === 1) return nfkc; } return ch; }); } function answerMatchKey(text) { return normalizeCompatChars(text) .replace(/[\s\u3000]/g, '') .replace(/[《》「」『』【】()()\[\]、,,。.;;::!!??"'“”]/g, '') .toLowerCase(); } function sortedLetters(s) { return String(s || '').toUpperCase().replace(/[^A-Z]/g, '').split('').sort().join(''); } function answerValueCandidates(q, answer) { const labels = expectedAnswerLabels(q, answer); const normalized = normalizeExamAnswer(answer, q); const candidates = [String(answer || '').trim(), String(normalized || '').trim(), labels.join('')].filter(Boolean); if (q && effectiveQuestionTypeN(q) === '3') { const first = labels[0]; if (first === 'A') candidates.push('A', 'true', 'TRUE', '1', '对', '正确'); if (first === 'B') candidates.push('B', 'false', 'FALSE', '0', '错', '错误'); } return [...new Set(candidates.map(x => String(x).trim()).filter(Boolean))]; } function savedValueMatchesAnswer(q, value, answer) { const val = String(value == null ? '' : value).trim(); if (!val) return false; const typeN = effectiveQuestionTypeN(q); if (!q || typeN === '2') return true; const labels = expectedAnswerLabels(q, answer); const candidates = answerValueCandidates(q, answer); const valLower = val.toLowerCase(); if (candidates.some(c => String(c).trim().toLowerCase() === valLower)) return true; if (typeN === '1') { const want = sortedLetters(labels.join('')); const got = sortedLetters(val); return !!want && got === want; } const gotLetters = sortedLetters(val); return !!labels.length && gotLetters === sortedLetters(labels.join('')); } function selectedOptionLabels(doc, q) { const container = findQuestionContainer(doc, q); if (!container) return []; const nodes = Array.from(container.querySelectorAll('.check_answer,.check_answer_dx,.Hover,[aria-checked="true"],[aria-pressed="true"],input[type="radio"]:checked,input[type="checkbox"]:checked')); const labels = []; for (const node of nodes) { const host = node.closest && node.closest('li,label,div.answerBg,div.stem_answer,[role="radio"],[role="checkbox"]') || node; const label = readOptionLabelFromNode(node) || readOptionLabelFromNode(host); if (/^[A-Z]$/.test(label)) labels.push(label); } return [...new Set(labels)]; } function nativeCheckedOptionLabels(doc, q) { const container = findQuestionContainer(doc, q); if (!container) return { labels: [], hasInput: false }; const inputs = Array.from(container.querySelectorAll('input[type="radio"],input[type="checkbox"]')); const checked = inputs.filter(inp => inp.checked); const labels = []; for (const input of checked) { const host = input.closest && input.closest('li,label,div.answerBg,div.stem_answer,[role="radio"],[role="checkbox"]') || input.parentElement || input; const label = readOptionLabelFromNode(input) || readOptionLabelFromNode(host); if (/^[A-Z]$/.test(label)) labels.push(label); } return { labels: [...new Set(labels)], hasInput: inputs.length > 0 }; } function verifyQuestionAnswerSaved(doc, q, answer) { if (!doc || !q) return false; if (q.platform === 'xuetangx_exam') return verifyXuetangxExamQuestionSaved(q, answer); const qid = getParsedQuestionId(q); const typeN = effectiveQuestionTypeN(q); if (typeN === '2') { const parts = String(answer || '').split(/#!#|[|;;??]/).map(x => x.trim()).filter(Boolean); const container = findQuestionContainer(doc, q); const inputs = [...(q.inputList || [])]; if (container) inputs.push(...Array.from(container.querySelectorAll('input.answerInput,textarea,[contenteditable="true"]'))); const values = [...new Set(inputs.filter(Boolean))].map(el => 'value' in el ? el.value : (el.innerText || el.textContent || '')).map(x => String(x || '').trim()).filter(Boolean); return parts.length ? parts.every(part => values.some(v => v.includes(part) || part.includes(v))) : values.length > 0; } for (const input of findAnswerInputs(doc, q)) { if (savedValueMatchesAnswer(q, input.value, answer)) return true; } const labels = expectedAnswerLabels(q, answer); if (!labels.length) return false; const selected = selectedOptionLabels(doc, q); if (typeN === '1') return labels.every(x => selected.includes(x)); return selected.includes(labels[0]); } function verifyXuetangxExamQuestionSaved(q, answer) { const container = q && q.element; if (!container) return !!(q && q._xuetangxFilled); const typeN = effectiveQuestionTypeN(q); const statusNode = container.querySelector('.status,[class*="status"]'); const statusText = cleanText(statusNode && (statusNode.innerText || statusNode.textContent) || ''); const hasQuestionStatusArea = !!container.querySelector('.item-type,[class*="item-type"]'); const savedByStatus = !!(statusNode && (/\bsuccess\b/i.test(String(statusNode.className || '')) || /答案保存成功|保存成功|已保存/.test(statusText))); if (typeN === '2' || typeN === '4') { const values = Array.from(container.querySelectorAll('textarea,input[type="text"],input:not([type]),[contenteditable="true"]')) .map(el => 'value' in el ? el.value : (el.innerText || el.textContent || '')) .map(x => String(x || '').trim()) .filter(Boolean); if (hasQuestionStatusArea) return values.length > 0 && savedByStatus; return values.length > 0 || !!q._xuetangxFilled; } const labels = normalizeXuetangxExamAnswerLabels(answer, q); if (!labels.length) return hasQuestionStatusArea ? savedByStatus : !!q._xuetangxFilled; const selected = []; const isMultiChoice = typeN === '1' || /多选/.test(String(q.typeName || '')) || !!container.querySelector('input[type="checkbox"]'); for (const opt of q.options || []) { const el = opt.element; if (!el) continue; const marked = isXuetangxChoiceSelected(el); if (marked) selected.push(String(opt.label || '').toUpperCase()); } for (const label of labels) { const row = findXuetangxChoiceByVisualLabel(container, label, isMultiChoice); if (!row) continue; const marked = isXuetangxChoiceSelected(row); if (marked) selected.push(String(label).toUpperCase()); } const selectedUnique = [...new Set(selected)]; const selectedOk = isMultiChoice ? labels.every(x => selectedUnique.includes(x)) : selectedUnique.includes(labels[0]); return hasQuestionStatusArea ? (selectedOk && savedByStatus) : (selectedOk || !!q._xuetangxFilled); } function answerDebugValue(v) { const s = String(v == null ? '' : v).replace(/\s+/g, ' ').trim(); if (!s) return ''; return s.length > 24 ? `${s.slice(0, 24)}...` : s; } function shouldOverwriteExistingPageAnswers(doc = document) { try { const heads = Array.from(doc.querySelectorAll('.infoHead,.mark_title,.marking_head,.workTestInfo')) .map(el => cleanText(el.textContent || '')) .join(' '); const bodyText = cleanText(((doc.body || doc.documentElement).textContent) || '').slice(0, 2000); const text = `${heads} ${bodyText}`; return /未达到及格线|请重做|重做|重新作答|重新提交|再次作答/i.test(text); } catch (_) { return false; } } function isQuestionAlreadyFilled(doc, q) { const container = findQuestionContainer(doc, q); if (!container) return false; if (q && q.platform === 'xuetangx_exam') { const typeN = effectiveQuestionTypeN(q); const statusNode = container.querySelector('.status,[class*="status"]'); const statusText = cleanText(statusNode && (statusNode.innerText || statusNode.textContent) || ''); const saved = !!(statusNode && (/\bsuccess\b/i.test(String(statusNode.className || '')) || /答案保存成功|保存成功|已保存/.test(statusText))); if (typeN === '2' || typeN === '4') { const hasValue = Array.from(container.querySelectorAll('textarea,input[type="text"],input:not([type]),[contenteditable="true"]')) .some(el => String(('value' in el ? el.value : (el.innerText || el.textContent || '')) || '').trim()); return saved && hasValue; } const hasChoice = Array.from(container.querySelectorAll('label.el-radio,label.el-checkbox,.el-radio,.el-checkbox')) .some(row => { const label = getXuetangxVisualChoiceLabel(row); if (!label) return false; const input = getXuetangxChoiceInput(row); return !!(input && input.checked) || isXuetangxChoiceSelected(row); }); return saved && hasChoice; } const text = cleanText(container.textContent || ''); if (/\b已填|已选择|已作答|已回答|已完成|正确答案|参考答案/i.test(text)) { return true; } const qid = q && (q.qid || q.id || String(q.questionId || '').replace(/\D+/g, '')); if (!qid) return false; if (effectiveQuestionTypeN(q) === '2') { const inputs = Array.from(container.querySelectorAll('input,textarea,[contenteditable="true"]')); return inputs.some(el => { const v = 'value' in el ? el.value : (el.innerText || el.textContent || ''); return String(v || '').trim() !== ''; }); } const nativeChoices = findNativeChoiceInputsForQuestion(doc, q); if (nativeChoices.length) return nativeChoices.some(inp => inp.checked); const input = [doc.getElementById(q.questionId), doc.getElementById(`answer${qid}`), ...Array.from(doc.getElementsByName(q.questionId) || []), ...Array.from(doc.getElementsByName(`answer${qid}`) || [])] .find(el => el && !isNativeChoiceInput(el)); if (input && String(input.value || '').trim()) return true; if (container.querySelector('.check_answer,.check_answer_dx,[aria-checked="true"]')) return true; const checked = container.querySelector('input[type="radio"]:checked,input[type="checkbox"]:checked'); if (checked) return true; return false; } function findQuestionContainer(doc, q) { const qid = q && (q.qid || q.id || String(q.questionId || '').replace(/\D+/g, '')); if (q && q.element && q.element.ownerDocument === doc) return q.element; if (!qid) return null; const direct = doc.querySelector(`div.questionLi[data="${cssEscape(qid)}"],.singleQuesId[data="${cssEscape(qid)}"],#sigleQuestionDiv_${cssEscape(qid)},#q_${cssEscape(qid)},[id-param="${cssEscape(qid)}"]`); if (direct) return direct; const byId = doc.getElementById(`answer${qid}`); const byName = doc.getElementsByName(`answer${qid}`)[0] || doc.getElementsByName(`answers${qid}`)[0]; const anchor = byId || byName; if (!anchor) return null; return anchor.closest('.questionLi,.singleQuesId,.Py-mian1,.TiMu') || anchor.closest('li,label,div') || null; } function clickOptionsInContainer(doc, container, labels, q = {}) { const typeN = normalizeQuestionTypeMeta(q); const want = new Set((labels || []).map(x => String(x || '').toUpperCase())); if (!want.size || !container) return 0; const nodes = Array.from(container.querySelectorAll('li,div.answerBg,div.clearfix.answerBg,div.stem_answer,label,.option,[role="radio"],[role="checkbox"],a[onclick],span[data],em[id-param],i,input[type="radio"],input[type="checkbox"]')); const win = doc.defaultView || {}; let clicked = 0; for (const node of nodes) { // .stem_answer 是包住全部选项的父容器,不能当作可点击选项。 // 否则选 A 时会命中父容器里的第一个 A,导致父容器 aria=true、真实选项行 aria=false。 if (node.matches && node.matches('div.stem_answer') && node.querySelector('.answerBg,[role="radio"],[role="checkbox"]')) continue; const marker = node.querySelector && (node.querySelector('span[data]') || node.querySelector('.num_option,.num_option_dx,em,i')) || (node.matches && node.matches('span[data]') ? node : null); const rawLabel = marker && (marker.getAttribute('data') || marker.getAttribute('id-param') || marker.textContent || '').trim() || (node.getAttribute && (node.getAttribute('data') || node.getAttribute('id-param') || '') || ''); let label = String(rawLabel || readOptionLabelFromNode(node) || '').trim().replace(/[、..\uFF0E,,::\s]+$/g, '').toUpperCase(); if (!want.has(label)) continue; const clickable = node.closest && node.closest('li,label,div.answerBg,div.stem_answer,[role="radio"],[role="checkbox"],a[onclick]') || node; const optionMarker = clickable.querySelector && (clickable.querySelector('.num_option_dx,.num_option,span[data],em[id-param],i')) || marker; const isMulti = typeN === '1' || clickable.getAttribute('qtype') === '1' || clickable.getAttribute('role') === 'checkbox' || !!(optionMarker && optionMarker.classList && optionMarker.classList.contains('num_option_dx')) || !!(clickable.querySelector && clickable.querySelector('input[type="checkbox"]')); if (isCxOptionSelected(clickable)) { // 有些学习通页面只给内部 span 加 check_answer,但外层选项行 aria 仍是 false。 // 这里即使已经选中,也同步外层状态,避免保存/复检和视觉状态不一致。 markCxOptionSelected(clickable, isMulti); clicked++; if (!isMulti) break; continue; } try { if (isMulti && typeof win.addMultipleChoice === 'function') win.addMultipleChoice(clickable); else if (!isMulti && typeof win.addChoice === 'function') win.addChoice(clickable); else dispatchMouseSequence(clickable, win); } catch (_) { try { clickable.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: win })); } catch (_) {} } markCxOptionSelected(clickable, isMulti); const input = clickable.querySelector && clickable.querySelector('input[type="radio"],input[type="checkbox"]'); if (input) { input.checked = true; dispatchInputEvents(input); } clicked++; if (typeN !== '1') break; } syncCxSelectionDomState(doc, q, labels); return clicked; } function setElementValue(el, value) { if (!el) return; const v = value == null ? '' : String(value); try { el.focus && el.focus(); } catch (_) {} if ('value' in el) { try { const proto = Object.getPrototypeOf(el); const desc = proto && Object.getOwnPropertyDescriptor(proto, 'value'); if (desc && desc.set) desc.set.call(el, v); else el.value = v; } catch (_) { el.value = v; } } else if (el.isContentEditable || el.getAttribute && el.getAttribute('contenteditable') === 'true') { el.innerHTML = escapeHtml(v); el.textContent = v; } else { el.textContent = v; } dispatchInputEvents(el); } function dispatchInputEvents(el) { const win = el && el.ownerDocument && el.ownerDocument.defaultView || window; for (const type of ['input', 'change', 'blur']) { try { el.dispatchEvent(new win.Event(type, { bubbles: true })); } catch (_) {} } } function dispatchMouseSequence(el, win = window) { if (!el) return; for (const type of ['pointerdown', 'mousedown', 'mouseup', 'click']) { try { const Ctor = type.startsWith('pointer') && win.PointerEvent ? win.PointerEvent : win.MouseEvent; el.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, view: win })); } catch (_) {} } } function cssEscape(s) { if (window.CSS && CSS.escape) return CSS.escape(String(s)); return String(s).replace(/["\\#.;:[\],>+~*^$|=]/g, '\\$&'); } function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); } function isStandaloneDashboardRuntime() { return window.name === 'cx_runner_dashboard' || /(?:\?|&)cxRunnerDashboard=1(?:&|$)/.test(location.search || '') || /cxRunnerDashboard=1/.test(location.hash || ''); } function shouldShowAutoAnswerLogPanel() { if (isStandaloneDashboardRuntime()) return false; try { return !!(App && typeof App.isAutoAnswerPage === 'function' && App.isAutoAnswerPage()); } catch (_) { return false; } } function ensureAutoAnswerLogPanel() { try { if (!document.body) return null; let root = document.getElementById('cx-runner-auto-answer-log'); if (!shouldShowAutoAnswerLogPanel()) { if (root) root.remove(); return null; } if (!root) { root = document.createElement('div'); root.id = 'cx-runner-auto-answer-log'; root.style.cssText = [ 'position:fixed', 'right:18px', 'top:18px', 'z-index:2147483647', 'width:340px', 'max-width:calc(100vw - 36px)', 'background:#fff', 'color:#172033', 'border:1px solid #d8e4f2', 'border-radius:12px', 'box-shadow:0 12px 30px rgba(18,45,80,.16)', 'font:13px/1.5 Arial,"Microsoft YaHei",sans-serif', 'overflow:hidden' ].join(';'); root.innerHTML = `
【题对对】答题助手
等待解析题目...
`; document.body.appendChild(root); makeDraggable(root, root.querySelector('[data-k="head"]')); const body = root.querySelector('[data-k="body"]'); const btn = root.querySelector('[data-act="min"]'); if (btn && body) { btn.addEventListener('click', () => { const hidden = body.style.display === 'none'; body.style.display = hidden ? '' : 'none'; btn.textContent = hidden ? '收起' : '展开'; }); } const tokenInput = root.querySelector('[data-k="tokenInput"]'); const tokenStatus = root.querySelector('[data-k="tokenStatus"]'); const bindBtn = root.querySelector('[data-act="bind-token"]'); if (tokenInput && bindBtn) { try { const saved = App && App.config && App.config.answerApiToken; if (saved) tokenInput.value = saved; } catch (_) {} const doBind = async () => { if (!(App && typeof App.bindManualAnswerToken === 'function')) return; App.els = App.els || {}; App.els.manualToken = tokenInput; App.els.userToken = tokenInput; App.els.tokenStatus = tokenStatus; bindBtn.disabled = true; try { await App.bindManualAnswerToken(); } catch (_) {} bindBtn.disabled = false; refreshAutoAnswerLogStats(); }; bindBtn.addEventListener('click', doBind); tokenInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doBind(); }); } } return root; } catch (_) { return null; } } function stripAutoLogQuestionIds(text) { return String(text || '') .replace(/\s*#\d{4,}\b/g, '') .replace(/\s{2,}/g, ' ') .trim(); } function autoLogQuestionText(text) { return stripAutoLogQuestionIds(cleanText(text || '')); } function refreshAutoAnswerLogStats() { try { const root = ensureAutoAnswerLogPanel(); if (!root) return; const stats = root.querySelector('[data-k="stats"]'); const aa = App && App.state && App.state.autoAnswer || {}; const total = Number(aa.pageTotal || 0); const answered = Number(aa.pageAnswered || 0); const already = Number(aa.pageAlreadyAnswered || 0); const newly = Math.max(0, answered - already); const tokenInfo = App && App.state && App.state.tokenInfo || {}; const remaining = tokenInfo && tokenInfo.remainingCount; const tokenText = remaining === null || remaining === undefined ? '\u7b54\u9898\u5269\u4f59\uff1a\u8bfb\u53d6\u4e2d' : `\u7b54\u9898\u5269\u4f59\uff1a${remaining}`; if (stats) { const pageText = total ? (answered ? `\u5f53\u524d\u9875\u9762\uff1a\u5b8c\u6210 ${answered}/${total} \u9898\uff08\u672c\u6b21\u65b0\u586b ${newly}\uff0c\u5df2\u6709\u4f5c\u7b54 ${already}\uff09` : `\u5f53\u524d\u9875\u9762\uff1a\u89e3\u6790 ${total} \u9898\uff0c\u4f46\u5c1a\u672a\u6210\u529f\u586b\u5165\u7b54\u6848`) : '\u6b63\u5728\u89e3\u6790\u5f53\u524d\u4f5c\u4e1a/\u6d4b\u9a8c\u9875\u9762...'; stats.textContent = `${pageText}\uff1b${tokenText}`; } } catch (_) {} } function appendAutoAnswerLog(text = '', type = 'info') { if (String(text || '').includes('青书')) { text = qingshuPublicLogText(text); if (!text) return; } try { const root = ensureAutoAnswerLogPanel(); if (!root) return; const rows = root.querySelector('[data-k="rows"]'); if (!rows) return; const line = document.createElement('div'); const colorMap = { ok: '#168a4a', warn: '#b7791f', error: '#c53030', info: '#2b5fb8' }; line.style.cssText = `padding:4px 2px;color:${colorMap[type] || colorMap.info};word-break:break-word;border-bottom:1px dashed #edf2f7`; line.textContent = `[${new Date().toLocaleTimeString()}] ${stripAutoLogQuestionIds(text)}`; rows.appendChild(line); while (rows.children.length > 80) rows.removeChild(rows.firstChild); rows.scrollTop = rows.scrollHeight; refreshAutoAnswerLogStats(); } catch (_) {} } function showAutoAnswerTip(text, opts = {}) { try { let el = document.getElementById('cx-runner-answer-tip'); if (!el) { el = document.createElement('div'); el.id = 'cx-runner-answer-tip'; el.style.cssText = 'position:fixed;right:18px;top:18px;z-index:2147483647;background:#fff;border:1px solid #2b8cff;border-radius:12px;box-shadow:0 10px 28px rgba(20,70,120,.16);padding:12px 14px;color:#172033;font:14px/1.5 Arial,\"Microsoft YaHei\",sans-serif;max-width:360px;'; document.body.appendChild(el); } el.innerHTML = `
${escapeHtml(text)}
${opts.recharge ? '' : ''}`; const btn = el.querySelector('[data-cx-recharge]'); if (btn) btn.onclick = () => { try { if (App && App.openRechargePage) App.openRechargePage(); else window.open(RECHARGE_URL, '_blank', 'noopener,noreferrer'); } catch (_) { location.href = RECHARGE_URL; } }; setTimeout(() => { try { el.remove(); } catch (_) {} }, 12000); } catch (_) {} } function showRechargeTip(text = '答题剩余次数不足,请充值后继续。') { showAutoAnswerTip(text, { recharge: true }); } function clickNativeNextQuestion(doc) { const win = doc.defaultView || window; // A container's textContent includes every descendant. Never treat a plain // div/span containing "下一题" as the navigation control or click it twice. const candidates = Array.from(doc.querySelectorAll('a,button,input[type="button"],[role="button"]')); for (const el of candidates) { const txt = cleanText(el.tagName === 'INPUT' ? el.value : el.textContent || '').toLowerCase(); if (!/^(?:下一题|下一页|next(?:\s+(?:question|page))?)[\s>›»→]*$/i.test(txt)) continue; // Do not submit an exam (including implicit form-submit buttons) while advancing. if (el.matches('[type="submit"],[type="reset"]') || (el.tagName === 'BUTTON' && el.form && el.type !== 'button')) continue; let unavailable = false; for (let node = el; node; node = node.parentElement) { const style = win.getComputedStyle(node); if (node.hidden || node.hasAttribute('inert') || node.matches(':disabled,[disabled],[aria-disabled="true"],[aria-hidden="true"],.disabled,.disable,.is-disabled') || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || style.opacity === '0' || style.pointerEvents === 'none') { unavailable = true; break; } } if (unavailable || typeof el.click !== 'function') continue; try { el.click(); return true; // Click dispatched, not proof that the next question loaded. } catch (_) {} } return false; } const FONT_MAP_SPEC = {"schemaVersion":1,"version":"1","sha256":"425e4dbc344ca7744b6381cc735e180ec69ed682843cb124c3a4df0c5558ce6c","bytes":1212768,"entries":29584,"url":"https://www.toptk.xyz/runner-assets/fonts/425e4dbc344ca7744b6381cc735e180ec69ed682843cb124c3a4df0c5558ce6c.json"}; let cxFontMapReady = false; let cxFontMapLoadPromise = null; function createVerifiedFontMapLoader(spec, deps) { let cachedMap = null; let pending = null; async function validate(raw) { if (typeof raw !== 'string' || raw.length > spec.bytes) throw new Error('字体数据格式无效'); const bytes = new TextEncoder().encode(raw); if (bytes.length !== spec.bytes || await deps.sha256(bytes) !== spec.sha256) throw new Error('字体数据校验失败'); const value = JSON.parse(raw); if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).length !== spec.entries) throw new Error('字体数据格式无效'); const map = Object.create(null); for (const [key, char] of Object.entries(value)) { if (!/^[a-f0-9]{32}$/.test(key) || typeof char !== 'string' || char.length < 1 || char.length > 4) throw new Error('字体数据格式无效'); map[key] = char; } return Object.freeze(map); } return { async load() { if (cachedMap) return cachedMap; if (pending) return pending; pending = (async () => { try { const stored = await deps.read(); if (stored && stored.schemaVersion === spec.schemaVersion && stored.sha256 === spec.sha256) { try { cachedMap = await validate(stored.data); return cachedMap; } catch (_) {} } } catch (_) {} // A broken storage backend must not prevent a fresh verified download. const raw = await deps.fetchText(spec.url, spec.bytes); const map = await validate(raw); // Persist only after successful validation; failed downloads never poison an old cache. try { await deps.write({ schemaVersion: spec.schemaVersion, sha256: spec.sha256, data: raw }); } catch (_) {} cachedMap = map; return map; })(); try { return await pending; } finally { pending = null; } } }; } function fetchFontMapText(url, maxBytes) { return new Promise((resolve, reject) => { let handle, settled = false, oversized = false; const fail = () => { if (settled) return; settled = true; reject(new Error('字体数据暂不可用,请联网后重试;本题尚未处理')); }; handle = GM_xmlhttpRequest({ method: 'GET', url, anonymous: true, withCredentials: false, headers: { Accept: 'application/json' }, timeout: 15000, onprogress(event) { if (event.loaded > maxBytes + 1024) { oversized = true; if (handle) handle.abort(); fail(); } }, onload(response) { if (settled) return; const mime = String(response.responseHeaders || '').match(/^content-type:\s*([^;\r\n]+)/im); if (response.status !== 200 || !mime || !/^application\/json$/i.test(mime[1].trim()) || (response.finalUrl && response.finalUrl !== url) || typeof response.responseText !== 'string' || response.responseText.length > maxBytes) { fail(); return; } settled = true; resolve(response.responseText); }, onerror: fail, ontimeout: fail, onabort: fail }); if (oversized && handle) handle.abort(); }); } async function ensureCxFontMap(fontBase64) { if (!fontBase64 || cxFontMapReady) return; if (!cxFontMapLoadPromise) { cxFontMapLoadPromise = (async () => { const loader = createVerifiedFontMapLoader(FONT_MAP_SPEC, { read: async () => { const raw = GM_getValue('cx-font-map-cache-v1', ''); return raw ? JSON.parse(raw) : null; }, write: async value => GM_setValue('cx-font-map-cache-v1', JSON.stringify(value)), fetchText: fetchFontMapText, sha256: async bytes => { if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error('当前页面不支持安全字体校验,请使用 HTTPS 页面'); const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes); return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join(''); } }); Object.assign(CX_FONT_HASH_MAP, await loader.load()); cxFontMapReady = true; })(); } try { await cxFontMapLoadPromise; } finally { if (!cxFontMapReady) cxFontMapLoadPromise = null; } } const cxFontDecoderCache = new Map(); const cxDecryptStats = { replaced: 0, inFont: 0, samples: [], fontBroken: false, noFont: false }; function resetCxDecryptStats() { cxDecryptStats.replaced = 0; cxDecryptStats.inFont = 0; cxDecryptStats.samples = []; cxDecryptStats.fontBroken = false; cxDecryptStats.noFont = false; } function cxDecryptReport() { return { ...cxDecryptStats, samples: cxDecryptStats.samples.slice(0, 8) }; } function decryptCxsecretText(text, fontBase64) { if (!text) return text || ''; if (!fontBase64) { cxDecryptStats.noFont = true; return text; } let decoder = cxFontDecoderCache.get(fontBase64); if (decoder === undefined) { try { decoder = buildCxFontDecoder(fontBase64); } catch (_) { decoder = null; } if (cxFontDecoderCache.size >= 16) cxFontDecoderCache.delete(cxFontDecoderCache.keys().next().value); cxFontDecoderCache.set(fontBase64, decoder); } if (!decoder) { cxDecryptStats.fontBroken = true; return text; } let out = ''; for (const ch of String(text)) { const r = decoder.decodeChar(ch); if (r.inFont) cxDecryptStats.inFont++; if (r.char && r.char !== ch) { cxDecryptStats.replaced++; if (cxDecryptStats.samples.length < 8) cxDecryptStats.samples.push(`${ch}→${r.char}`); out += r.char; continue; } out += r.char || ch; } return out; } function safeDecryptCxsecretText(text, fontBase64) { if (fontBase64 && !cxFontMapReady) throw new Error('字体数据尚未就绪,本题暂不处理'); try { return decryptCxsecretText(text, fontBase64); } catch (_) { return text || ''; } } function buildCxFontDecoder(fontBase64) { const bytes = base64ToUint8Array(fontBase64); const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const tables = parseTtfTables(dv); const head = reqTable(tables, 'head'); const maxp = reqTable(tables, 'maxp'); const loca = reqTable(tables, 'loca'); const glyf = reqTable(tables, 'glyf'); const cmap = reqTable(tables, 'cmap'); const indexToLocFormat = i16(dv, head.offset + 50); const numGlyphs = u16(dv, maxp.offset + 4); const locaOffsets = parseLoca(dv, loca.offset, numGlyphs, indexToLocFormat); const cmapLookup = parseCmap(dv, cmap.offset); const glyphHashCache = new Map(); return { decodeChar(ch) { const cp = ch.codePointAt(0); const gid = cmapLookup(cp); if (!gid && gid !== 0) return { char: '', inFont: false }; let hash = glyphHashCache.get(gid); if (hash === undefined) { hash = glyphMd5(dv, glyf.offset, locaOffsets, gid); glyphHashCache.set(gid, hash || ''); } if (!hash) return { char: '', inFont: false }; const mapped = normalizeCompatChars(CX_FONT_HASH_MAP[hash] || ''); return { char: mapped, inFont: true }; } }; } function base64ToUint8Array(b64) { const bin = atob(String(b64 || '').replace(/\s+/g, '')); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } function parseTtfTables(dv) { const numTables = u16(dv, 4); const tables = {}; for (let i = 0; i < numTables; i++) { const p = 12 + i * 16; const tag = tag4(dv, p); tables[tag] = { checksum: u32(dv, p + 4), offset: u32(dv, p + 8), length: u32(dv, p + 12) }; } return tables; } function reqTable(tables, name) { if (!tables[name]) throw new Error(`TTF table missing: ${name}`); return tables[name]; } function parseLoca(dv, offset, numGlyphs, fmt) { const arr = []; for (let i = 0; i <= numGlyphs; i++) arr.push(fmt === 0 ? u16(dv, offset + i * 2) * 2 : u32(dv, offset + i * 4)); return arr; } function parseCmap(dv, cmapOffset) { const numTables = u16(dv, cmapOffset + 2); let chosen12 = 0, chosen4 = 0; for (let i = 0; i < numTables; i++) { const rec = cmapOffset + 4 + i * 8; const sub = cmapOffset + u32(dv, rec + 4); const fmt = u16(dv, sub); const platform = u16(dv, rec); if (fmt === 12 && !chosen12) chosen12 = sub; if (fmt === 4 && !chosen4 && (platform === 3 || !chosen4)) chosen4 = sub; } if (chosen12) return parseCmap12(dv, chosen12); if (chosen4) return parseCmap4(dv, chosen4); throw new Error('unsupported cmap format'); } function parseCmap12(dv, off) { const nGroups = u32(dv, off + 12); const groups = []; for (let i = 0; i < nGroups; i++) { const p = off + 16 + i * 12; groups.push({ start: u32(dv, p), end: u32(dv, p + 4), glyph: u32(dv, p + 8) }); } return cp => { let lo = 0, hi = groups.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1, g = groups[mid]; if (cp < g.start) hi = mid - 1; else if (cp > g.end) lo = mid + 1; else return g.glyph + (cp - g.start); } return 0; }; } function parseCmap4(dv, off) { const segCount = u16(dv, off + 6) / 2; const endOff = off + 14; const startOff = endOff + segCount * 2 + 2; const deltaOff = startOff + segCount * 2; const rangeOff = deltaOff + segCount * 2; const segs = []; for (let i = 0; i < segCount; i++) { segs.push({ end: u16(dv, endOff + i * 2), start: u16(dv, startOff + i * 2), delta: i16(dv, deltaOff + i * 2), range: u16(dv, rangeOff + i * 2), rangePos: rangeOff + i * 2 }); } return cp => { cp &= 0xFFFF; for (const s of segs) { if (cp < s.start || cp > s.end) continue; if (s.range === 0) return (cp + s.delta) & 0xFFFF; const glyphAddr = s.rangePos + s.range + 2 * (cp - s.start); if (glyphAddr + 2 > dv.byteLength) return 0; const glyph = u16(dv, glyphAddr); return glyph ? ((glyph + s.delta) & 0xFFFF) : 0; } return 0; }; } function glyphMd5(dv, glyfOffset, locaOffsets, gid) { if (gid < 0 || gid + 1 >= locaOffsets.length) return ''; const start = glyfOffset + locaOffsets[gid]; const end = glyfOffset + locaOffsets[gid + 1]; if (end <= start) return ''; const contours = i16(dv, start); if (contours <= 0) return ''; const endPts = []; for (let i = 0; i < contours; i++) endPts.push(u16(dv, start + 10 + i * 2)); const nPoints = endPts[endPts.length - 1] + 1; let p = start + 10 + contours * 2; const instructionLength = u16(dv, p); p += 2 + instructionLength; const flags = []; while (flags.length < nPoints) { const flag = u8(dv, p++); flags.push(flag); if (flag & 0x08) { const repeat = u8(dv, p++); for (let i = 0; i < repeat; i++) flags.push(flag); } } const xs = [], ys = []; let x = 0, y = 0; for (let i = 0; i < nPoints; i++) { const f = flags[i]; let dx = 0; if (f & 0x02) dx = u8(dv, p++) * ((f & 0x10) ? 1 : -1); else if (!(f & 0x10)) { dx = i16(dv, p); p += 2; } x += dx; xs.push(x); } for (let i = 0; i < nPoints; i++) { const f = flags[i]; let dy = 0; if (f & 0x04) dy = u8(dv, p++) * ((f & 0x20) ? 1 : -1); else if (!(f & 0x20)) { dy = i16(dv, p); p += 2; } y += dy; ys.push(y); } let sig = ''; for (let i = 0; i < nPoints; i++) sig += String(xs[i]) + String(ys[i]) + String(flags[i] & 1 ? 1 : 0); return md5(sig); } function u8(dv, o) { return dv.getUint8(o); } function u16(dv, o) { return dv.getUint16(o, false); } function i16(dv, o) { return dv.getInt16(o, false); } function u32(dv, o) { return dv.getUint32(o, false); } function tag4(dv, o) { return String.fromCharCode(u8(dv, o), u8(dv, o + 1), u8(dv, o + 2), u8(dv, o + 3)); } function cleanText(html) { const div = document.createElement('div'); div.innerHTML = String(html || '').replace(/]*alt=["']?([^"'>]+)["']?[^>]*>/ig, '$1'); return normalizeSpaces(div.textContent || div.innerText || ''); } function normalizeSpaces(text) { return String(text || '') .replace(/[\u00a0\u200b\u200c\u200d\ufeff]/g, ' ') .replace(/\s+/g, ' ') .trim(); } function stripQuestionPrefix(text, typeName = '') { return normalizeSpaces(text); } function extractCxsecretFont(html) { const s = String(html || ''); const patterns = [ /font-cxsecret[^}]*?src\s*:\s*url\(['"]?data:application\/font-ttf[^;]*;base64,([A-Za-z0-9+/=]+)['"]?\)/is, /font-cxsecret[^}]*?src\s*:\s*url\(['"]?data:font\/ttf[^;]*;base64,([A-Za-z0-9+/=]+)['"]?\)/is, /font-cxsecret[^}]*?base64,([A-Za-z0-9+/=]{100,})/is ]; for (const re of patterns) { const m = s.match(re); if (m && m[1]) return m[1].trim(); } return ''; } function diagnoseWorkPage(doc) { try { const counts = { title: doc.title || '', url: doc.URL || '', singleQuesId: doc.querySelectorAll('.singleQuesId').length, TiMu: doc.querySelectorAll('.TiMu').length, answertype: doc.querySelectorAll('input[id^="answertype"],input[name^="answertype"]').length, answer: doc.querySelectorAll('input[id^="answer"],input[name^="answer"]').length, forms: doc.forms ? doc.forms.length : 0 }; const text = (doc.body && doc.body.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 240); return `${JSON.stringify(counts)} text=${text}`; } catch (e) { return `diag failed: ${e.message || e}`; } } function injectStyle() { GM_addStyle(` #cx-runner-panel{position:fixed;right:18px;bottom:18px;width:380px;z-index:2147483647;background:#15171c;color:#e6edf3;border:1px solid #3d444d;border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.35);font:13px/1.4 Arial,"Microsoft Yahei",sans-serif;overflow:hidden} #cx-runner-panel .cxr-head{cursor:move;display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:#24292f;font-weight:700} #cx-runner-panel button{margin:2px;padding:3px 7px;border:1px solid #57606a;border-radius:5px;background:#2d333b;color:#e6edf3;cursor:pointer} #cx-runner-panel button:hover{background:#3d444d} #cx-runner-panel .cxr-body{padding:8px} #cx-runner-panel .cxr-hidden{display:none} #cx-runner-panel .cxr-row{display:flex;gap:8px;margin:3px 0} #cx-runner-panel .cxr-row b{width:70px;color:#8b949e} #cx-runner-panel .cxr-row span{flex:1;word-break:break-all} #cx-runner-panel .cxr-actions{margin:6px 0} #cx-runner-panel .cxr-check{margin-right:8px;color:#c9d1d9} #cx-runner-panel input[type=number]{width:52px;background:#0d1117;color:#e6edf3;border:1px solid #3d444d;border-radius:4px} #cx-runner-panel .cxr-log{height:230px;overflow:auto;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:6px;white-space:pre-wrap;margin:7px 0 0} #cx-runner-panel .cxr-red{color:#ff7b72} #cx-runner-panel .cxr-green{color:#7ee787} #cx-runner-panel .cxr-blue{color:#79c0ff} #cx-runner-panel .cxr-orange{color:#ffa657} #cx-runner-panel .cxr-gray{color:#8b949e} #cx-runner-panel .cxr-white{color:#e6edf3} #cx-runner-quick-start{position:fixed;right:22px;bottom:22px;z-index:2147483646;border:0;border-radius:999px;background:linear-gradient(135deg,#2563eb,#06b6d4);color:#fff;font:700 14px/1.2 Arial,"Microsoft Yahei",sans-serif;padding:12px 18px;box-shadow:0 12px 28px rgba(37,99,235,.35);cursor:pointer} #cx-runner-quick-start:hover{filter:brightness(1.06);transform:translateY(-1px)} #cx-runner-confirm{position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,.32);display:flex;align-items:center;justify-content:center;font:14px/1.6 Arial,"Microsoft Yahei",sans-serif} #cx-runner-confirm .cxr-confirm-card{width:420px;max-width:calc(100vw - 40px);background:#fff;color:#172033;border:1px solid #e5e7eb;border-radius:16px;box-shadow:0 16px 40px rgba(15,23,42,.16);padding:24px} #cx-runner-confirm .cxr-confirm-copy{position:relative} #cx-runner-confirm .cxr-confirm-badge{display:inline-flex;margin:0 0 12px;padding:4px 10px;border-radius:999px;background:#eef6ff;color:#1f65d8;border:1px solid #d7e8ff;font-size:12px;font-weight:700;letter-spacing:.02em} #cx-runner-confirm h3{margin:0 0 8px;font-size:22px;line-height:1.25;font-weight:800;color:#111827} #cx-runner-confirm p{margin:0 0 20px;color:#64748b;font-size:14px} #cx-runner-confirm .cxr-confirm-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap} #cx-runner-confirm button{border:0;border-radius:10px;padding:10px 16px;cursor:pointer;font-weight:700;transition:background .15s ease,border-color .15s ease} #cx-runner-confirm button[data-act="yes"]{background:#2563eb;color:#fff} #cx-runner-confirm button[data-act="yes"]:hover{background:#1d4ed8} #cx-runner-confirm button[data-act="no"]{background:#fff;color:#475569;border:1px solid #d1d5db} #cx-runner-confirm button[data-act="no"]:hover{background:#f8fafc;border-color:#cbd5e1} `); } function makeDraggable(root,handle){ if(!root||!handle) return; if(root.dataset.cxrDragBound==='1') return; root.dataset.cxrDragBound='1'; let down=false,sx=0,sy=0,sl=0,st=0,pid=null; handle.style.cursor='move'; handle.style.userSelect='none'; handle.style.touchAction='none'; const isInteractive=el => !!(el&&el.closest&&el.closest('button,a,input,textarea,select,[contenteditable="true"],[data-no-drag]')); const point=ev =>{ const t=ev.touches&&ev.touches[0]||ev.changedTouches&&ev.changedTouches[0]||ev; return { x: Number(t.clientX||0),y: Number(t.clientY||0) }; }; const start=ev =>{ if(down) return; if(ev.button!=null&&ev.button!==0) return; if(isInteractive(ev.target)) return; const p=point(ev); down=true; pid=ev.pointerId!=null?ev.pointerId:null; sx=p.x; sy=p.y; const rect=root.getBoundingClientRect(); sl=rect.left; st=rect.top; root.style.position='fixed'; root.style.left=rect.left+'px'; root.style.top=rect.top+'px'; root.style.right='auto'; root.style.bottom='auto'; root.style.margin='0'; try{handle.setPointerCapture&&pid!=null&&handle.setPointerCapture(pid);} catch (_){} ev.preventDefault(); ev.stopPropagation(); }; const move=ev =>{ if(!down||(ev.pointerId!=null&&pid!=null&&ev.pointerId!==pid)) return; const p=point(ev); fit(sl+p.x-sx,st+p.y-sy); ev.preventDefault(); ev.stopPropagation(); }; const end=ev =>{ if(!down||(ev?.pointerId!=null&&pid!=null&&ev.pointerId!==pid)) return; down=false; try{handle.releasePointerCapture&&pid!=null&&handle.releasePointerCapture(pid);} catch (_){} pid=null; ev&&ev.stopPropagation&&ev.stopPropagation(); }; const fit=(x,y)=>{ if(!root.isConnected||!root.style.top)return; const r=root.getBoundingClientRect(); root.style.left=Math.max(0,Math.min(x??r.left,window.innerWidth-r.width))+'px'; root.style.top=Math.max(0,Math.min(y??r.top,window.innerHeight-r.height))+'px'; }; const resize=()=>fit(),observer=window.ResizeObserver?new window.ResizeObserver(resize):null; observer?.observe(root); const bindings=[ [handle,'pointerdown mousedown touchstart',start], [document,'pointermove mousemove touchmove',move], [document,'pointerup mouseup touchend pointercancel touchcancel',end], [handle,'lostpointercapture',end],[window,'blur',end],[window,'resize',resize],[window,'pagehide',dispose] ],options={capture:true,passive:false}; for(const [target,events,fn] of bindings)for(const event of events.split(' '))target.addEventListener(event,fn,options); function dispose(){ end();observer?.disconnect();delete root.dataset.cxrDragBound; for(const [target,events,fn] of bindings)for(const event of events.split(' '))target.removeEventListener(event,fn,true); } return dispose; } function createStudyCard(doc,id,title) { const box=doc.createElement('aside');box.id=id;box.setAttribute('aria-label','学习助手'); box.innerHTML=`
学习助手准备中
`.replaceAll('&','#'+id); const get=s=>box.querySelector(s),ui={box,text:get('.zhs-text'),badge:get('.zhs-badge'),stop:get('[data-stop]'),start:get('[data-retry]'),count:get('[data-count]')}; get('.zhs-title').textContent=title; ui.phase=(phase,message,badge)=>{box.dataset.phase=phase;ui.text.textContent=message;ui.badge.textContent=badge;}; ui.progress=({number,total,completed=0})=>{get('[data-count]').textContent='第 '+number+' / '+total+' 题';const percent=Math.min(100,Math.max(0,Math.round(completed/total*100)));get('.zhs-fill').style.width=percent+'%';get('[role=progressbar]').setAttribute('aria-valuenow',String(percent));}; get('[data-fold]').onclick=()=>{const body=get('[data-body]');body.hidden=!body.hidden;get('[data-fold]').textContent=body.hidden?'+':'−';get('[data-fold]').setAttribute('aria-expanded',String(!body.hidden));get('[data-fold]').setAttribute('aria-label',body.hidden?'展开学习助手':'折叠学习助手');}; doc.body.append(box);box.disposeDrag=makeDraggable(box,get('header')); ui.remove=()=>{box.disposeDrag?.();box.remove();};return ui; } function md5(input) { function add32(a, b) { return (a + b) & 0xFFFFFFFF; } function cmn(q, a, b, x, s, t) { a = add32(add32(a, q), add32(x, t)); return add32((a << s) | (a >>> (32 - s)), b); } function ff(a, b, c, d, x, s, t) { return cmn((b & c) | ((~b) & d), a, b, x, s, t); } function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & (~d)), a, b, x, s, t); } function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t); } function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | (~d)), a, b, x, s, t); } function md5cycle(x, k) { let [a, b, c, d] = x; a = ff(a, b, c, d, k[0], 7, -680876936); d = ff(d, a, b, c, k[1], 12, -389564586); c = ff(c, d, a, b, k[2], 17, 606105819); b = ff(b, c, d, a, k[3], 22, -1044525330); a = ff(a, b, c, d, k[4], 7, -176418897); d = ff(d, a, b, c, k[5], 12, 1200080426); c = ff(c, d, a, b, k[6], 17, -1473231341); b = ff(b, c, d, a, k[7], 22, -45705983); a = ff(a, b, c, d, k[8], 7, 1770035416); d = ff(d, a, b, c, k[9], 12, -1958414417); c = ff(c, d, a, b, k[10], 17, -42063); b = ff(b, c, d, a, k[11], 22, -1990404162); a = ff(a, b, c, d, k[12], 7, 1804603682); d = ff(d, a, b, c, k[13], 12, -40341101); c = ff(c, d, a, b, k[14], 17, -1502002290); b = ff(b, c, d, a, k[15], 22, 1236535329); a = gg(a, b, c, d, k[1], 5, -165796510); d = gg(d, a, b, c, k[6], 9, -1069501632); c = gg(c, d, a, b, k[11], 14, 643717713); b = gg(b, c, d, a, k[0], 20, -373897302); a = gg(a, b, c, d, k[5], 5, -701558691); d = gg(d, a, b, c, k[10], 9, 38016083); c = gg(c, d, a, b, k[15], 14, -660478335); b = gg(b, c, d, a, k[4], 20, -405537848); a = gg(a, b, c, d, k[9], 5, 568446438); d = gg(d, a, b, c, k[14], 9, -1019803690); c = gg(c, d, a, b, k[3], 14, -187363961); b = gg(b, c, d, a, k[8], 20, 1163531501); a = gg(a, b, c, d, k[13], 5, -1444681467); d = gg(d, a, b, c, k[2], 9, -51403784); c = gg(c, d, a, b, k[7], 14, 1735328473); b = gg(b, c, d, a, k[12], 20, -1926607734); a = hh(a, b, c, d, k[5], 4, -378558); d = hh(d, a, b, c, k[8], 11, -2022574463); c = hh(c, d, a, b, k[11], 16, 1839030562); b = hh(b, c, d, a, k[14], 23, -35309556); a = hh(a, b, c, d, k[1], 4, -1530992060); d = hh(d, a, b, c, k[4], 11, 1272893353); c = hh(c, d, a, b, k[7], 16, -155497632); b = hh(b, c, d, a, k[10], 23, -1094730640); a = hh(a, b, c, d, k[13], 4, 681279174); d = hh(d, a, b, c, k[0], 11, -358537222); c = hh(c, d, a, b, k[3], 16, -722521979); b = hh(b, c, d, a, k[6], 23, 76029189); a = hh(a, b, c, d, k[9], 4, -640364487); d = hh(d, a, b, c, k[12], 11, -421815835); c = hh(c, d, a, b, k[15], 16, 530742520); b = hh(b, c, d, a, k[2], 23, -995338651); a = ii(a, b, c, d, k[0], 6, -198630844); d = ii(d, a, b, c, k[7], 10, 1126891415); c = ii(c, d, a, b, k[14], 15, -1416354905); b = ii(b, c, d, a, k[5], 21, -57434055); a = ii(a, b, c, d, k[12], 6, 1700485571); d = ii(d, a, b, c, k[3], 10, -1894986606); c = ii(c, d, a, b, k[10], 15, -1051523); b = ii(b, c, d, a, k[1], 21, -2054922799); a = ii(a, b, c, d, k[8], 6, 1873313359); d = ii(d, a, b, c, k[15], 10, -30611744); c = ii(c, d, a, b, k[6], 15, -1560198380); b = ii(b, c, d, a, k[13], 21, 1309151649); a = ii(a, b, c, d, k[4], 6, -145523070); d = ii(d, a, b, c, k[11], 10, -1120210379); c = ii(c, d, a, b, k[2], 15, 718787259); b = ii(b, c, d, a, k[9], 21, -343485551); x[0] = add32(a, x[0]); x[1] = add32(b, x[1]); x[2] = add32(c, x[2]); x[3] = add32(d, x[3]); } function md5blk(s) { const md5blks = []; for (let i = 0; i < 64; i += 4) md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) + (s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24); return md5blks; } function md51(s) { s = unescape(encodeURIComponent(s)); const n = s.length; const state = [1732584193, -271733879, -1732584194, 271733878]; let i; for (i = 64; i <= n; i += 64) md5cycle(state, md5blk(s.substring(i - 64, i))); s = s.substring(i - 64); const tail = Array(16).fill(0); for (i = 0; i < s.length; i++) tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3); tail[i >> 2] |= 0x80 << ((i % 4) << 3); if (i > 55) { md5cycle(state, tail); tail.fill(0); } tail[14] = n * 8; md5cycle(state, tail); return state; } const hexChr = '0123456789abcdef'.split(''); function rhex(n) { let s = ''; for (let j = 0; j < 4; j++) s += hexChr[(n >> (j * 8 + 4)) & 0x0F] + hexChr[(n >> (j * 8)) & 0x0F]; return s; } return md51(input).map(rhex).join(''); } function parseMediaProgressResponse(response = {}) { const fail = (code, message, retryable = false) => { const error = new Error(message); error.code = code; error.retryable = retryable; error.status = Number(response.status) || 0; throw error; }; const status = Number(response.status); if (status === 401) fail('MEDIA_LOGIN_REQUIRED', '学习通登录状态已失效,请重新登录后继续'); if (status === 403) fail('MEDIA_REQUEST_REJECTED', '学习通未接受本次进度,请回到原课程页检查提示后重试'); if (status === 429 || status === 408 || status >= 500) fail('MEDIA_TEMPORARY_FAILURE', '学习通暂时无法接收进度,稍后重试', true); if (!Number.isInteger(status) || status < 200 || status >= 300) fail('MEDIA_INVALID_RESPONSE', '未取得有效的学习进度结果,请重新进入课程后重试'); const raw = String(response.responseText || ''); let json; try { json = JSON.parse(raw); } catch (_) { if (/用户登录|请先登录|登录已失效|passport2[^<]*login/i.test(raw)) fail('MEDIA_LOGIN_REQUIRED', '学习通登录状态已失效,请重新登录后继续'); fail('MEDIA_INVALID_RESPONSE', '未取得有效的学习进度结果,请重新进入课程后重试'); } if (!json || Array.isArray(json) || typeof json !== 'object' || json.success === false || json.error === true || (typeof json.error === 'string' && json.error)) fail('MEDIA_REQUEST_REJECTED', '学习通未接受本次进度,请回到原课程页检查提示后重试'); if (!Object.prototype.hasOwnProperty.call(json, 'isPassed')) fail('MEDIA_INVALID_RESPONSE', '未取得有效的学习进度结果,请重新进入课程后重试'); const value = json.isPassed; if (![true, false, 1, 0, 'true', 'false', '1', '0'].includes(value)) fail('MEDIA_INVALID_RESPONSE', '未取得有效的学习进度结果,请重新进入课程后重试'); return { ...json, isPassed: value === true || value === 1 || value === 'true' || value === '1' }; } function publishFrameContext() { const q = new URLSearchParams(location.search); const get = (...names) => { for (const n of names) { const v = q.get(n); if (v) return v; } return ''; }; const frameTarget = trustedParentOrigin(document.referrer); if (!frameTarget) return; window.parent.postMessage({ type: 'cx-runner-frame-context', ctx: { href: location.href, origin: location.origin, host: location.host, courseId: get('courseId', 'courseid'), clazzId: get('clazzid', 'classId', 'classid', 'clazzId'), cpi: get('cpi'), chapterId: get('chapterId', 'knowledgeid', 'knowledgeId'), enc: get('enc'), openc: get('openc'), title: document.title, oldSiteHost: window.ServerHost && window.ServerHost.mooc1Domain || '', oldEnc: String(window.toOld || '').match(/var enc = "([^"]+)/)?.[1] || '' } }, frameTarget); return; } function registerRunnerMenu() { if(typeof GM_registerMenuCommand==='function'&&window.top===window.self&&/(^|\.)zhihuishu\.com$/i.test(location.hostname)){ GM_registerMenuCommand('智慧树:作业答完提交/考试原页填写',()=>App.startZhihuishuPageWork()); GM_registerMenuCommand('智慧树:当前页开始/继续播放视频',()=>App.startZhihuishuCourse().catch(()=>App.log('智慧树启动失败,请检查登录状态','orange'))); GM_registerMenuCommand('智慧树:切换视频随堂题自动作答',()=>{App.config.zhihuishuVideoQuiz=App.config.zhihuishuVideoQuiz===false;saveConfig(App.config);if(App.state.running)App.stop();alert('视频随堂题自动作答已'+(App.config.zhihuishuVideoQuiz?'开启(使用题库次数,遇到拦截停止)':'关闭')+',下次开始生效;不影响作业考试。');}); } if (typeof GM_registerMenuCommand === 'function' && window.top === window.self && /(^|\.)qingshuxuetang\.com$/i.test(location.hostname)) { GM_registerMenuCommand('青书:重查本题并保存(完整后自动交卷,每次扣2次,免扣期除外)', () => { App.refreshQingshuCachedQuestion().catch(error => App.log('青书单题补答失败:' + qingshuAnswerErrorText(error && error.message || error, App.config.answerApiToken), 'orange', { publicDiagnostic: true })); }); } } function isMirrorCandidate(href) { try {const u=new URL(href),route=mirrorPageRoute(href); if(runnerPlatform(u.hostname)&&runnerPlatform(u.hostname)!=='chaoxing')return false; const host=runnerPlatform(u.hostname)==='chaoxing'||runnerMirrorHosts().includes(u.hostname)||/^(?:mooc\d+(?:-[a-z0-9]+)?|stat\d+-ans)\.(?:[a-z0-9-]+\.)+[a-z]{2,}$/i.test(u.hostname); return /^https?:$/.test(u.protocol)&&!u.username&&!u.password&&!u.port&&host&&!/(?:^|\.)(?:chaoxing\.com|xueyinonline\.com|yuketang\.cn|xuetangx\.com|zhihuishu\.com|qingshuxuetang\.com)\./i.test(u.hostname)&&!/(?:^|\.)(?:localhost|local|internal|lan)$/i.test(u.hostname)&&/^\/(?:(?:mooc2?-ans\/)?(?:mycourse|knowledge|work|mooc2\/work|workHandle)\/|exam-ans\/|ananas\/)/i.test(route.path); }catch(_){return false;} } function mirrorRelatedOrigin(value,base) { try { const u=new URL(value),b=new URL(base);if(u.username||u.password||u.port||!/^https?:$/.test(u.protocol)||b.protocol==='https:'&&u.protocol!=='https:')return ''; if(u.pathname!=='/'||u.search||u.hash)return ''; if(u.origin===b.origin||/(^|\.)(chaoxing\.com|xueyinonline\.com)$/i.test(u.hostname))return u.origin; const role=/^(?:mooc\d+(?:-[a-z0-9]+)?|stat\d+-ans|passport\d+)\./i; return role.test(u.hostname)&&role.test(b.hostname)&&u.hostname.replace(role,'')===b.hostname.replace(role,'')?u.origin:''; }catch(_){return '';} } function activateMirrorPage(href,doc,view) { if(!isMirrorCandidate(href))return false; try {const u=new URL(href),q=new URLSearchParams(u.search.toLowerCase()); const id=names=>{const values=[...q].filter(([k])=>names.some(n=>n.toLowerCase()===k)).map(([,v])=>v);for(const n of names)for(const e of doc.querySelectorAll('input[id="'+n+'"],input[name="'+n+'"]'))if(e.value)values.push(e.value);return new Set(values).size===1?values[0]:'';}; const course=id(['courseId','courseid']),clazz=id(['clazzid','classId','classid','clazzId']);if(!/^\d+$/.test(course||'')||!/^\d+$/.test(clazz||''))return false; const config=view?.ServerHost||{},origins=new Set([u.origin]),gateway=mirrorPageRoute(href).gateway;let declared=false; for(const key of ['mooc1Domain','mooc2Domain','stat2Domain','stat2AnsDomain']){const origin=mirrorRelatedOrigin(config[key],u.origin);if(origin){declared=true;if(!gateway)origins.add(origin);}} const scripts=[...doc.scripts].some(e=>{try{const v=new URL(e.getAttribute('src'),u.href);return /\/(ananas|mooc2?-ans)\//.test(v.pathname)&&!!mirrorRelatedOrigin(v.origin,u.origin);}catch(_){return false;}}); if(!doc.querySelector('[dataname="zj"],#coursetree,#chapterList,#iframe,#frame_content,#ZyBottom,.TiMu,.posCatalog_select,.headRight,.navshow,.ans-attach-ct,.ans-job-icon,#video,.questionLi')||(!scripts&&!declared))return false; for(const frame of doc.querySelectorAll('iframe[src]')){try{const v=new URL(frame.getAttribute('src'),u.href),p=new URLSearchParams(v.search.toLowerCase());if(isMirrorCandidate(v.href)&&p.get('courseid')===course&&(p.get('clazzid')||p.get('classid'))===clazz){const origin=mirrorRelatedOrigin(v.origin,u.origin);if(origin)origins.add(origin);}}catch(_){}} activateMirrorPage.origins=origins;activateMirrorPage.owner=u.origin;activateMirrorPage.gateway=gateway;activateMirrorPage.platform='chaoxing';return true; }catch(_){return false;} } function assertMirrorRequest(href) { if(!activateMirrorPage.owner&&!activateMirrorPage.platform)return; const u=new URL(href,location.href); if(activateMirrorPage.platform&&runnerPageCandidate(location.href)!==activateMirrorPage.platform)throw Error('当前页面已变化,停止请求'); if(activateMirrorPage.gateway&&u.origin===activateMirrorPage.owner&&u.pathname!==activateMirrorPage.gateway&&!u.pathname.startsWith(activateMirrorPage.gateway+'/'))throw Error('学校代理路径未通过校验'); if(!/^https?:$/.test(u.protocol)||u.username||u.password||u.port||location.protocol==='https:'&&u.protocol!=='https:'||!(activateMirrorPage.origins.has(u.origin)||/^https:$/.test(u.protocol)&&(/(^|\.)toptk\.xyz$/.test(u.hostname)||runnerPlatform(u.hostname)===(activateMirrorPage.platform||'chaoxing'))))throw Error('已阻止未确认的学校接口地址'); } function mirrorRequestError(error,href) { if(!activateMirrorPage.owner&&!activateMirrorPage.platform||!/Refused to connect|permission|not.*whitelist|not.*allowed|not.*permitted/i.test(String(error?.message||error?.error||error)))return error; const host=new URL(href,location.href).hostname,message='当前平台接口缺少请求权限,请在脚本头部添加:\n// @connect '+host+'\n保存并刷新页面。'; if(mirrorRequestError.last!==host){mirrorRequestError.last=host;alert(message);}return Error(message); } function mirrorPageRoute(href) { const u=new URL(href); const gateway=runnerMirrorHosts().includes(u.hostname)&&/^webvpn\./i.test(u.hostname)?u.pathname.match(/^\/https?\/[a-z0-9]+(?=\/)/i)?.[0]||'':''; return {gateway,path:u.pathname.slice(gateway.length)}; } function runnerPlatform(host) { const h=String(host||'').toLowerCase(); if(/(^|\.)(chaoxing\.com|xueyinonline\.com)$/.test(h))return 'chaoxing'; if(/(^|\.)(yuketang\.cn|xuetangx\.com)$/.test(h))return 'yuketang'; if(/(^|\.)zhihuishu\.com$/.test(h))return 'zhihuishu'; if(/(^|\.)qingshuxuetang\.com$/.test(h))return 'qingshu'; return ''; } function runnerPageCandidate(href) { try {const u=new URL(href);if(!/^https?:$/.test(u.protocol)||u.username||u.password||u.port)return ''; if(isMirrorCandidate(href))return 'chaoxing'; const p=runnerPlatform(u.hostname),r=u.pathname; if(p==='zhihuishu'&&((u.hostname==='studyvideoh5.zhihuishu.com'&&/^\/stuStudy\/?$/.test(r))||(u.hostname==='onlineexamh5new.zhihuishu.com'&&r==='/stuExamWeb.html'&&/^#\/webExamList(?:[/?]|$)/.test(u.hash))||(u.hostname==='onlineweb.zhihuishu.com'&&/^\/onlinestuh5\/?$/.test(r))))return p; if(p==='yuketang'&&(/^\/v2\/web\/(?:studentLog|lms|xcloud\/video-student|exercise|cloud\/student\/exercise|exam)\/\d+(?:\/|$)/i.test(r)||/^\/(?:exam\/\d+(?:\/|$)|subject(?:\/|$))/.test(r)&&u.hostname==='examination.xuetangx.com'))return p; if(p==='qingshu'&&/^\/[^/]+\/Student\/(?:Course(?:\/(?:CourseList|CourseShow|CourseStudy|CourseScore))?|ExercisePaper|Exam|Exercise|DoPaper)(?:\/|$)/i.test(r))return p; return ''; }catch(_){return '';} } function activateRunnerPage(href,doc,view) { const platform=runnerPageCandidate(href);if(!platform)return false; if(platform==='chaoxing')return activateMirrorPage(href,doc,view); try{const u=new URL(href),values=[...u.searchParams]; const id=pattern=>{const a=values.filter(([k])=>pattern.test(k));for(const e of doc.querySelectorAll('input[id],input[name]')){const key=e.name||e.id;if(pattern.test(key)&&e.value)a.push([key,e.value]);}return a.length>0&&a.every(([k,v])=>/^[A-Za-z0-9_+%/=.-]{1,512}$/.test(v)&&a.filter(([name])=>name.toLowerCase()===k.toLowerCase()).every(([,value])=>value===v));}; let valid=false; if(platform==='zhihuishu'){ if(u.pathname.startsWith('/stuStudy'))valid=id(/^recruitAndCourseId$/i)&&!!doc.querySelector('#vjs_container video,#vjs_container_html5_api')&&!!doc.querySelector('li.video .catalogue_title'); else if(u.pathname==='/stuExamWeb.html')valid=(id(/^recruitId$/i)||/^#\/webExamList\/(?:dohomework|doexamination|checkHomework)\/[^/]+\/[^/]+\/[^/]+\/[^/]+\/[^/]+/.test(u.hash))&&!!doc.querySelector('.examPaper_subject,.exam-list,.examList,.work-list'); else valid=!!doc.querySelector('.course-list .course-item,.courseList .courseItem,.sharing-course-list'); } if(platform==='yuketang')valid=(/\/\d+(?:\/|$)/.test(u.pathname)||id(/^(examId|subjectId|classroomId)$/i))&&!!doc.querySelector('.student-log,.course-info,.lms-container,.video-player,.container-problem,.exam-aside,.subject-item,.exam-main,.problem-item,.container-subject,[data-problem-id],[data-question-id],.exercise-item,.container-problem'); if(platform==='qingshu'){ const list=/\/(?:Course|CourseList)\/?$/i.test(u.pathname); const course=/\/Course\/(?:CourseShow|CourseStudy|CourseScore)\/?$/i.test(u.pathname); const liveCourse=course&&id(/^courseId$/i)&&!!doc.querySelector('#learnPlanArea')&&!!doc.querySelector('#courseMenu,#learnPlanArea a#startStudy'); const liveList=list&&!!doc.querySelector('#currentCourseDiv .course[courseid],#allCourseDiv .course[courseid]'); const player=/\/CourseShow\/?$/i.test(u.pathname)&&id(/^courseId$/i)&&id(/^nodeId$/i)&&!!doc.querySelector('#playerContainer video,video#videoContainer')&&[...doc.querySelectorAll('a[href]')].some(a=>{try{const link=new URL(a.getAttribute('href'),href);return link.origin===u.origin&&link.pathname===u.pathname.replace(/CourseShow\/?$/i,'CourseStudy')&&link.searchParams.get('courseId')===u.searchParams.get('courseId');}catch(_){return false;}}); valid=liveCourse||liveList||player||(list||id(/^(courseId|teachPlanId|periodId|teachPlanCourseId|quizId|examId)$/i))&&!!doc.querySelector('.course-list,.course-item,.course-content,#studyPlayer,.question-detail-container'); } if(!valid&&platform==='yuketang')valid=[...doc.querySelectorAll('iframe[src]')].some(frame=>{try{const child=new URL(frame.getAttribute('src'),href);const parentId=u.pathname.match(/\/(?:studentLog|lms)\/(\d+)/)?.[1],childId=child.pathname.match(/\/(?:lms|video-student)\/(\d+)/)?.[1];return !!parentId&&parentId===childId&&runnerPageCandidate(child.href)===platform&&child.protocol===u.protocol;}catch(_){return false;}}); if(!valid)return false; activateMirrorPage.origins=new Set([u.origin]);activateMirrorPage.platform=platform;activateMirrorPage.owner='';activateMirrorPage.gateway='';return true; }catch(_){return false;} } function qingshuCourseEntries(courses,base) { const site=new URL(location.href),org=site.pathname.split('/')[1]; if(!/(^|\.)qingshuxuetang\.com$/i.test(site.hostname)||!org||base.orgCode&&base.orgCode!==org)throw Error('青书机构信息不匹配,请重新进入课程页'); const keys=['courseId','teachPlanId','teachPlanCourseId','periodId'],entries=[],seen=new Map(); for(const course of courses){ const ctx={platform:'qingshu',siteHost:site.origin,oldSiteHost:site.origin,siteHosts:[site.origin],orgCode:org,courseName:String(course.courseName||'未命名课程')}; for(const k of keys)ctx[k]=String(course[k]||''); const key=JSON.stringify(keys.map(k=>ctx[k])); const error=keys.some(k=>!/^[-\w]+$/.test(ctx[k]))?'课程信息不完整,未开始':course.allowLearn===false?'课程尚未开放或不允许学习':''; if(seen.has(key)){const prior=seen.get(key);if(error){prior.status='blocked';prior.error=error;}continue;} const url=new URL('/'+org+'/Student/Course/CourseStudy',site.origin);for(const k of keys)url.searchParams.set(k,ctx[k]);ctx.href=url.href; const entry={key,ctx,status:error?'blocked':'queued',error,worker:null};seen.set(key,entry);entries.push(entry); } const current=entries.filter(e=>base.courseId&&keys.every(k=>!base[k]||String(base[k])===e.ctx[k])); if(current.length===1){const entry=current[0];if(/^[-\w]+$/.test(base.nodeId||''))entry.ctx.nodeId=base.nodeId;entries.splice(entries.indexOf(entry),1);entries.unshift(entry);} return entries; } registerRunnerMenu(); App.init(); })();