// ==UserScript== // @name 学习通/智慧树/雨课堂/青书自动刷课,考试,作业一站式助手 // @name:en Chaoxing Automatic Learning Tool[AI Answer][One-click start][Minimize run] // @description:en Support course task point recognition, video progress learning, document/reading/live streaming/discussion processing, as well as chapter quizzes, assignments, and exam page assistance for answering questions. // @description 支持学习通,青书,雨课堂,智慧树自动刷课考试作业; // @antifeature payment 脚本会请求第三方收费题库进行答题,您可以选择付费或停用答题功能。 // @namespace 奶龙 // @version 1.0.5 // @author 奶龙 // @match *://*.chaoxing.com/* // @match *://*.xueyinonline.com/* // @match *://*.yuketang.cn/* // @match *://*.xuetangx.com/* // @match *://*.zhihuishu.com/* // @match *://*.qingshuxuetang.com/* // @match *://mooc1.wljx.hfut.edu.cn/* // @match *://mooc1.hnust.edu.cn/* // @match *://stat2-ans.hnust.edu.cn/* // @match *://stat2-ans.istudy.szpu.edu.cn/* // @match *://mooc1.hnsyu.net/* // @match *://passport2.xust.edu.cn/* // @match *://stat2-ans.hnsyu.net/* // @match *://passport2.hnsyu.net/* // @match *://mooc1.gdhkmooc.com/* // @match *://stat2-ans.gdhkmooc.com/* // @match *://mooc1.zut.edu.cn/* // @match *://passport2.hncj.edu.cn/* // @match *://stat2-ans.zut.edu.cn/* // @match *://passport2.jxjyzx.xust.edu.cn/* // @match *://passport2.zut.edu.cn/* // @match *://stat2-ans.wljx.hfut.edu.cn/* // @match *://passport2.wljx.hfut.edu.cn/* // @match *://mooc1.hncj.edu.cn/* // @match *://stat2-ans.hncj.edu.cn/* // @match *://mooc1.qutjxjy.cn/* // @match *://stat2-ans.qutjxjy.cn/* // @match *://passport2.hnust.edu.cn/* // @match *://passport2.cqrspx.cn/* // @match *://passport2.gdhkmooc.com/* // @match *://passport2.qutjxjy.cn/* // @match *://mooc1.jxjyzx.xust.edu.cn/* // @match *://passport2.hnvist.cn/* // @match *://passport2.cugbonline.cn/* // @match *://stat2-ans.jxjyzx.xust.edu.cn/* // @match *://passport2.ynny.cn/* // @match *://passport2.ecnusole.com/* // @match *://mooc1.cqrspx.cn/* // @match *://stat2-ans.cqrspx.cn/* // @match *://mooc1.ynny.cn/* // @match *://stat2-ans.ynny.cn/* // @match *://mooc1.cugbonline.cn/* // @match *://stat2-ans.cugbonline.cn/* // @match *://mooc1.xust.edu.cn/* // @match *://stat2-ans.xust.edu.cn/* // @match *://mooc1.xynu.edu.cn/* // @match *://mooc1.ntu.edu.cn/* // @match *://stat2-ans.xynu.edu.cn/* // @match *://stat2-ans.ntu.edu.cn/* // @match *://mooc1.hnvist.cn/* // @match *://stat2-ans.hnvist.cn/* // @match *://passport2.xynu.edu.cn/* // @match *://passport2.ntu.edu.cn/* // @match *://mooc1.ecnusole.com/* // @match *://stat2-ans.ecnusole.com/* // @match *://mooc.s.ecust.edu.cn/* // @match *://webvpn.ahmu.edu.cn/* // @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 // @connect mooc1.wljx.hfut.edu.cn // @connect mooc1.chaoxing.com // @connect mooc1-1.chaoxing.com // @connect mooc1-api.chaoxing.com // @connect zhibo.chaoxing.com // @connect live.chaoxing.com // @connect groupweb.chaoxing.com // @connect k.chaoxing.com // @connect pstore-rk.chaoxing.com // @connect kshlsplay-rk.chaoxing.com // @connect ksrtmpplay-rk.chaoxing.com // @connect mooc1.hnust.edu.cn // @connect stat2-ans.hnust.edu.cn // @connect stat2-ans.istudy.szpu.edu.cn // @connect stat2-ans.chaoxing.com // @connect mooc1.hnsyu.net // @connect passport2.xust.edu.cn // @connect stat2-ans.hnsyu.net // @connect scriptcat.cn // @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 scriptcat.org // @connect passport2.jxjyzx.xust.edu.cn // @connect passport2.zut.edu.cn // @connect stat2-ans.wljx.hfut.edu.cn // @connect live-rk.chaoxing.com // @connect task.chaoxing.com // @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 mooc2-ans.chaoxing.com // @connect passport2.hnvist.cn // @connect passport2.cugbonline.cn // @connect passport2.chaoxing.com // @connect stat2-ans.jxjyzx.xust.edu.cn // @connect mooc1.xueyinonline.com // @connect passport2.ynny.cn // @connect passport2.ecnusole.com // @connect stat2-ans.xueyinonline.com // @connect static-rk.chaoxing.com // @connect passport2.xueyinonline.com // @connect lbapi-rk.chaoxing.com // @connect mooc1.cqrspx.cn // @connect stat2-ans.cqrspx.cn // @connect f1-rk.chaoxing.com // @connect mooc1-2.chaoxing.com // @connect mooc1.ynny.cn // @connect mqtt7-rk.chaoxing.com // @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 mobilelearn.chaoxing.com // @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 www.toptk.xyz // @connect toptk.xyz // @connect pay.ldxp.cn // @connect www.yuketang.cn // @connect yuketang.cn // @connect *.yuketang.cn // @connect fe-static-yuketang.yuketang.cn // @connect examination.xuetangx.com // @connect *.xuetangx.com // @connect zhihuishu.com // @connect *.zhihuishu.com // @connect onlineservice-api.zhihuishu.com // @connect appcomm-user.zhihuishu.com // @connect studyservice-api.zhihuishu.com // @connect studyvideoh5.zhihuishu.com // @connect studyh5.zhihuishu.com // @connect onlineweb.zhihuishu.com // @connect examh5.zhihuishu.com // @connect onlineexamh5new.zhihuishu.com // @connect degree.qingshuxuetang.com // @connect qingshuxuetang.com // @connect assets.qingshuxuetang.com // @connect studentexam-api.zhihuishu.com // @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 () { "use strict"; if (!isSupportedRunnerLocation(location.href)) 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, 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};} } 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.setUserStatus({ headline: '青书课程已准备好', currentTask: '正在读取作业和学习进度', etaText: '请稍候', tip: '使用当前青书登录会话调用平台原生接口。' }); 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(); // 雨课堂 studentLog 是 Vue 外壳 + iframe,页面内容经常晚于 document-end 才稳定。 // 这里补几轮轻量检查,避免只有点进 video-student 子页才出现启动入口。 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\/(?: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 || ''; // 青书作答/练习页:/{school}/Student/ExercisePaper?quizId=...;兜底看页面是否有题目容器 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 || '智慧树课程'; // Do not infer a different course from unrelated page links. return ctx; } // 从页面链接补充 courseId / clazzId / cpi 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 || '')) { ctx.platform = ctx.platform || 'qingshu'; ctx.siteHost = ctx.siteHost || location.origin; ctx.oldSiteHost = location.origin; ctx.siteHosts = [location.origin]; ctx.orgCode = ctx.orgCode || qingshuOrgCode(ctx.href || location.pathname); } if (/(^|\.)zhihuishu\.com$/i.test(location.hostname || '')) { // Do not import a previous platform's course IDs into a Zhihuishu dashboard. 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 = ctx.platform === 'zhihuishu' ? [] : (Array.isArray(saved.chapters) ? saved.chapters : []); const savedTitles = saved.chapterTitles || fromHash.chapterTitles || {}; this.state.chapterTitles = ctx.platform === 'zhihuishu' ? {} : (savedTitles && typeof savedTitles === 'object' ? savedTitles : {}); this.state.courseTaskSummary = ctx.platform === 'zhihuishu' ? 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)}`; // 雨课堂课程首页 studentLog 是最重要入口,不能因为一次 sessionStorage 记录就完全不再触发; // 否则用户必须点进 video-student 才能看到启动跳转。 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 { observer.observe(document.documentElement || document.body, { childList: true, subtree: true }); } 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, 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; } 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 || []); // 青书统一走 qsxt 接口:题干文字 + 题干图片 + 选项文字一并提交,复用同一套题库缓存; // 图文混合题(题干含文字与图片)由 qsxt 结合文字与图片综合作答,纯文字题亦复用该缓存。 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; // 同步原生页面状态供用户检查;DOM 选中不代表服务端已经保存。 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 = '保存本题'; // Lookup can take time. Recheck the exact attempt and question before writing anything. 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 = '核对保存结果'; // Retry only read-only confirmation, never another paid query or automatic repeat save. 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); // A selection on the page is only a visual mirror of the confirmed server save. 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; // One final read also covers a long user pause between save confirmation and hand-in. 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() { this.log('检测到考试页面,开始模拟用户填答', 'blue'); 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); if (fontBase64) this.log(`考试字体加密已识别:${fontBase64.length} chars`, 'gray'); const parsed = parseExamQuestions(document, { html, fontBase64 }); 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 > 0) { const clickedNext = clickNativeNextQuestion(document); if (clickedNext) this.log('已点击页面原生下一题/下一页,等待新题渲染', 'gray'); this.stopAutoAnswerWatcher(); } 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) { this.log(`${label} ${i + 1}/${parsed.questions.length}: \u672a\u627e\u5230\u7b54\u6848 ${autoLogQuestionText(q.question).slice(0, 40)} ${cleanAnswerError(ansRes.error)}`, 'orange'); appendAutoAnswerLog(`${rowPrefix}\uff1a\u6682\u672a\u627e\u5230\u7b54\u6848\uff0c\u5df2\u8df3\u8fc7`, '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; } // Keep the existing token; never silently replace a recharged token. 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) { 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.isZhihuishuCoursePage()) { const liveZhs = detectZhihuishuCourseEntryFromPage(ctx); Object.assign(ctx, zhihuishuContextFromLocation({ ...ctx, ...liveZhs, platform: 'zhihuishu' }), { platform: 'zhihuishu', courseName: liveZhs.courseName || ctx.courseName || document.title || '智慧树课程' }); const zhsOrigin = safeUrlOrigin(ctx.entryUrl || ctx.href || location.href) || location.origin; ctx.siteHost = zhsOrigin; ctx.oldSiteHost = zhsOrigin; ctx.siteHosts = [zhsOrigin]; } if (this.isQingshuCoursePage()) { Object.assign(ctx, qingshuContextFromLocation({ ...ctx, platform: 'qingshu' }), { platform: 'qingshu', courseName: ctx.courseName || document.title || '青书学堂课程' }); ctx.siteHost = location.origin; ctx.oldSiteHost = location.origin; ctx.siteHosts = [location.origin]; } 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) : `${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'); dashboardUrl.hash = 'cxRunnerDashboard=1&cxctx=' + payload; const url = dashboardUrl.href; const win = window.open(url, 'cx_runner_dashboard'); if (!win) alert('\u65b0\u9875\u9762\u88ab\u6d4f\u89c8\u5668\u62e6\u622a\uff0c\u8bf7\u5141\u8bb8\u5f39\u7a97\u540e\u91cd\u8bd5\u3002'); return win; }, 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.value = '1'; this.els.playbackSpeed.disabled = true; const setting = this.els.playbackSpeed.closest('.speed-setting'); const label = setting && setting.querySelector('label'); const note = setting && setting.querySelector('.speed-note'); if (label) label.textContent = '接口学习'; if (note) note.textContent = '青书按真实时间累计分数'; } 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(); if (act === 'refreshBalance') this.refreshRechargeBalance(); }); 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; } }, async refreshRechargeBalance() { const token = String(this.config.answerApiToken || '').trim(); if (!token) { this.setTokenStatus('请先绑定并保存原 Token。', 'orange'); return false; } try { const info = await this.refreshAnswerTokenInfo(token); if (String(this.config.answerApiToken || '').trim() !== token) return false; if (!info) { this.setTokenStatus('暂时无法读取原 Token 余额,请检查网络或 Token;未生成新 Token。', 'orange'); return false; } this.setTokenStatus(tokenStatusText(this.state.tokenInfo), this.state.tokenInfo.remainingCount === 0 ? 'orange' : 'green'); this.log('原 Token 余额已刷新:' + this.state.tokenInfo.remainingCount + ' 次;付款到账以服务器记录为准。', 'blue'); return true; } catch (_) { this.setTokenStatus('余额刷新失败,请稍后重试;原 Token 已保留。', 'orange'); return 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'); // Course-level parameters now come only from the directly readable new catalog. const url = chaoxingCoursePageUrl(ctx); const 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 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 || 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; } // 多个接口都能解析出目录时,保留信息更全的那份(能拿到带 ID 的锁定小节最有价值)。 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; // 锁定小节带 ID 时可以直接排队,靠顺序执行解锁;没有 ID 的才需要记数等重扫。 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 []; const url = chaoxingCoursePageUrl(ctx); const response = await request({ url, timeout: 20000, withCredentials: true, headers: { Referer: chaoxingCoursePageUrl(ctx) } }); assertChaoxingCourseResponse(response, url, ctx); const html = response.responseText || ''; const summary = parseCourseTaskSummary(html); if (summary && this.state.courseTaskSummary?.source !== 'stu-job-info') this.state.courseTaskSummary = summary; // This token is extracted from the NEW catalog for the existing resource protocol. 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(); // append=true 用于闯关课程新解锁小节的补扫:保留上一轮已完成计数,只把新任务点累加进来。 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; // 闯关课程必须串行:并发扫描时后面的小节还没解锁,cards 拿不到 mArg 会被误判成空章节。 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) { // cards 首页没有 mArg:章节未开放(闯关未解锁/需先完成前置任务点)。 // 任务点数现在无从得知,登记为待解锁;执行阶段按顺序做到它时会再拉一次。 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 || []; // 每次扫描前都刷新真实课程目录,避免独立页沿用旧缓存里的父级目录/100 个章节。 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() { 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) { // 首页就拿不到 mArg:章节大概率未开放(闯关未解锁 / 需先完成前置任务点 / 人脸认证拦截)。 // 这个判据与课程目录 HTML 版式无关,是识别锁定章节最可靠的兜底。 if (page === 0) { noContent = true; if (!opts.quiet) this.log(`chapter=${chapterId} 未找到 mArg,章节可能未开放`, 'orange'); } break; } applyCardsDomStatus(parsed, html); // Read static task markers only. Unknown resources are not rendered during discovery. 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; } // 多页 cards 的实际页面通常下一页无 mArg 或无 attachments,这里最多探测 20 页。 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.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()) { 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() { 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; } 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; } 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('目录中没有可确认的待办小节,未重复学习已完成章节'); 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'); // 闯关模式还有待解锁小节时,即使当前剩余为 0 也不能收工: // 后面的小节要等前置任务点完成才会开放,必须按顺序走一遍。 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(); 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'); }, 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); }; // Font-decoding diagnostics and answer transports are shared; chapter quizzes must not overlap. 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) 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), active: true, stopped: false, paused: false, entries: [], 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: '准备顺序学习', etaText: '请稍候', tip: '一次学习一门课程,处理结束后自动进入下一门。' }); this.updateActionButtons(); try { if (!await this.ensureAnswerToken()) throw new Error('请先绑定有效 Token'); if (this.scanPromise) await this.scanPromise.catch(() => {}); if (run.stopped) return; let courses; try { courses = await loadChaoxingAccountCourses(ctx); } catch (error) { if (run.stopped) return; this.log('暂时无法读取其他课程,本次只学习当前课程;请检查课程列表登录状态。', 'orange'); courses = [chaoxingCourseContext(ctx, ctx)]; } if (run.stopped) return; const selected = course => String(course.courseId) === String(ctx.courseId) && String(course.clazzId) === String(ctx.clazzId); if (!courses.some(selected)) courses.unshift(chaoxingCourseContext(ctx, ctx)); const seen = new Set(); courses = courses.filter(course => { const key = chaoxingCourseKey(course); if (course.unavailable || seen.has(key)) return false; seen.add(key); return true; }).sort((a, b) => Number(selected(b)) - Number(selected(a))); run.entries = courses.map(course => ({ key: chaoxingCourseKey(course), ctx: course, status: 'queued', worker: null, error: '' })); 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; 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 = this.publicErrorText(error); entry.status = run.stopped ? 'stopped' : 'failed'; if (!run.stopped) this.log('【' + entry.ctx.courseName + '】未完成:' + entry.error, 'orange'); } finally { if (entry.worker) { entry.worker.state.running = false; resetVideoFrame(entry.worker.videoFrameScope); } this.renderChaoxingCourseProgress(); } } }; // A single consumer is intentional: finish this course before starting the next. 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 (run.platform === 'zhihuishu') this.state.taskStats = { platform: 'zhihuishu', 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); } } }, mountChaoxingCourseControls(doc) { const ctx = this.state.current || this.dashboardContext || {}; if (ctx.platform && ctx.platform !== 'chaoxing') return; if (!ctx.courseId || !ctx.clazzId || doc.querySelector('[data-k="multiCourseControls"]')) return; 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'; box.innerHTML = '
当前课程处理结束后,自动进入下一门;同一时间只学习一门课。
'; 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}'; box.prepend(style); const footer = anchor.closest('.control-footer'); if (footer && footer.parentElement) footer.parentElement.insertBefore(box, footer); else anchor.parentElement.insertBefore(box, anchor); this.els.autoCourseQueue = box.querySelector('[data-k="autoCourseQueue"]'); this.els.courseRows = box.querySelector('[data-k="courseRows"]'); this.els.autoCourseQueue.checked = this.config.autoCourseQueue !== false; this.els.courseRows.hidden = !this.els.autoCourseQueue.checked; const change = () => { if (this.state.running) return; this.config.autoCourseQueue = this.els.autoCourseQueue.checked; this.els.courseRows.hidden = !this.config.autoCourseQueue; saveConfig(this.config); }; this.els.autoCourseQueue.addEventListener('change', change); }, 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 = Number(q.progress && q.progress.score || 0); const target = Math.max(1, Number(q.scoreDetail && q.scoreDetail.targetScore || 80)); const videoDone = score >= target ? 1 : 0; const total = assignments.length + 1; const completed = passed + videoDone; 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.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' }; const [assignments, progress, scoreDetail] = await Promise.all([ qingshuLoadAssignments(ctx), qingshuLoadStudyProgress(ctx), qingshuLoadScoreDetail(ctx).catch(() => ({ currentScore: 0, targetScore: 80, quizScore: 0, totalScore: 0 })) ]); if (!scoreDetail.currentScore && progress.score) scoreDetail.currentScore = progress.score; this.state.qingshu = { ready: true, ctx, assignments, progress, scoreDetail }; this.state.current.courseName = ctx.courseName; this.renderContext(this.state.current); this.updateQingshuStats(); const pendingWorks = assignments.filter(item => !qingshuApplyPassPolicy(item).passed).length; this.setUserStatus({ headline: `青书课程:${this.shortText(ctx.courseName, 42)}`, currentChapter: `课件学习 ${progress.score}/${scoreDetail.targetScore || 80} 分`, currentTask: `作业 ${assignments.length - pendingWorks}/${assignments.length} 已完成,剩余 ${pendingWorks}`, etaText: pendingWorks || progress.score < (scoreDetail.targetScore || 80) ? '等待开始' : '已完成', tip: '先学习课件,再处理未达 60 分的作业;低分作业重新查询答案,交卷后重新检查成绩。' }); this.log(`青书课程已读取:${ctx.courseName}`, 'green'); this.log(`青书课件进度:${progress.score}/${scoreDetail.targetScore || 80} 分${progress.studyTime ? `,学习时长 ${progress.studyTime}` : ''}`, progress.score >= (scoreDetail.targetScore || 80) ? 'green' : 'blue'); this.log(`青书作业:共 ${assignments.length} 个,已完成 ${assignments.length - pendingWorks} 个,待完成 ${pendingWorks} 个`, pendingWorks ? 'orange' : 'green'); assignments.forEach((item, i) => this.log(`青书作业 ${i + 1}/${assignments.length}:${this.shortText(item.title, 54)}(${qingshuAssignmentStatusText(item)})`, item.passed ? 'green' : 'blue')); return this.state.qingshu; })(); try { return await this.qingshuPreparePromise; } finally { this.qingshuPreparePromise = null; } }, async startQingshuCourse() { 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); if (this.config.doVideo !== false && Number(q.progress.score || 0) < Number(q.scoreDetail.targetScore || 80)) { await this.runQingshuStudy(q); } else { this.log(`青书课件学习已达标:${q.progress.score}/${q.scoreDetail.targetScore || 80} 分`, 'green'); } if (!this.state.stopped && this.config.doWork !== false) { await this.runQingshuAssignments(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 = Number(q.progress.score || 0); const target = Math.max(1, Number(q.scoreDetail.targetScore || 80)); 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 分钟重新读取分数。' }); 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 && 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) { if (attempt >= 3) throw e; this.log(`青书学习进度上报失败,准备重试 ${attempt}/3`, 'orange'); await this.sleepWithPause(10000 * attempt); } } if (!uploaded) break; uploads++; this.setUserStatus({ headline: '正在进行青书课件学习', currentChapter: `${score}/${target} 分`, currentTask: `已上报 ${uploads} 次,位置 ${formatClockSeconds(position)}`, etaText: '分数每 10 分钟刷新', tip: '青书按真实等待时间累计课件学习分数。' }); if (uploads % 5 === 0) { const progress = await qingshuLoadStudyProgress(ctx); score = Number(progress.score || 0); q.progress = progress; this.state.qingshu.progress = progress; 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.log(`青书学习记录结束上报失败:${e.message || e}`, 'orange'); } } 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('青书作业已全部达到 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; // 新轮次不沿用上一轮分数和 submitted 标记。 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); // 闯关课程扫描阶段可能因为还锁着而拿不到 mArg;执行阶段按目录顺序来, // 走到这一步时前面的小节已经做完,重试一次通常就能拿到内容。 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)} -> ${e.message || e}`, 'red'); } 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.taskStats.scanned) this.state.jobDone++; this.updateProgress(); } await this.sleepRandom(); } }, async runCourseWideTasks() { this.state.courseTasks = []; this.log('作业/考试已分离:请进入对应作业或考试页面后自动填答。', 'gray'); return; const jobs = (this.state.courseTasks || []).filter(a => isRunnableTask(a) && !(this.state.taskStats.scanned && this.state.taskStats.doneKeys[taskKey(a, 'course')])); if (!jobs.length) { this.setUserStatus({ headline: '课程级任务已检查完成', currentTask: '没有待处理的课程级作业或考试', etaText: '即将结束', tip: '系统会完成最后的进度刷新。' }); this.log('课程级作业/考试:无待处理任务', 'gray'); return; } this.setUserStatus({ headline: '正在处理课程级任务', currentChapter: '课程级任务', currentTask: `共 ${jobs.length} 个作业/考试任务`, etaText: '正在计算', tip: '如打开新页面,请按页面提示确认。' }); this.log(`课程级作业/考试任务数:${jobs.length}`, 'blue'); for (const jobData of jobs) { if (this.state.stopped) return; await this.waitIfPaused(); let ok = false; try { const context = { ...(this.state.current || {}), chapterId: '' }; this.setLearningTask(jobData, context, { chapter: '课程级任务', etaText: '正在计算', tip: '如打开新页面,请按页面提示确认。' }); if (jobData.type === 'assignment' && this.config.doWork) { await this.runAssignment(jobData, context); ok = true; } else if (jobData.type === 'exam' && this.config.doWork) { await this.runExam(jobData, context); ok = true; } else { this.log(`跳过课程级任务:type=${jobData.type}, name=${safeName(jobData)}`, 'gray'); } } catch (e) { this.setUserStatus({ headline: '课程级任务暂未完成,正在继续', currentChapter: '课程级任务', currentTask: `${this.taskTypeText(jobData)}:${this.shortText(safeName(jobData))}`, etaText: '稍后重试或进入下一项', tip: this.publicErrorText(e) }); this.log(`课程级任务异常:${safeName(jobData)} -> ${e.message || e}`, 'red'); } finally { if (ok) this.markTaskCompleted(jobData, 'course'); if (ok) this.setUserStatus({ headline: '课程级任务已完成', currentChapter: '课程级任务', currentTask: `${this.taskTypeText(jobData)}:${this.shortText(safeName(jobData))}`, etaText: '即将进入下一项', tip: '系统会继续处理后续任务。' }); if (ok || !this.state.taskStats.scanned) this.state.jobDone++; this.updateProgress(); } await this.sleepRandom(); } }, 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; // 执行阶段已经用真实 cards/iframe 状态确认该附件不再是待处理任务: // 可能是已完成、普通资源、空测验,按后端“无任务页面成功”逻辑结算。 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]; // 闯关课程里 total 只覆盖已解锁小节,新解锁的任务点完成时不能被旧 total 卡住。 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; // Only a nonexistent/unsupported legacy route permits the known native route. // Never repeat a rejected completion, timeout or ambiguous success response. 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) { // Transport errors contain the requested URL/body. Reading links and callbacks // carry per-student signatures: never propagate those raw errors into user logs. 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') { // GM already followed the native preview redirect into this material. // Reuse that response: do not require another link or open the material twice. 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; // Native indexV2.finishJob is the reading-link callback, not the exam/work submit API. const result = await readRequest({ url: plan.callbackUrl, headers: { Referer: plan.moduleUrl }, timeout: 30000 }); if (this.state.stopped) return false; if (!isReadCallbackAccepted(result)) return pending('平台尚未接受阅读链接回调,请在原课程页核对。'); } // Stay on the native HTTP request chain; do not substitute browser automation. // The native callback acknowledges this reading action; do not poll cards/isPassed. 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; } const workId = extractWorkId(jobData); if (!workId) { this.log(`work skip: missing workId, raw=${JSON.stringify({ jobid: jobData.jobid, type: jobData.type, property: jobData.property }).slice(0, 500)}`, 'orange'); return; } 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(`work task: ${safeName(jobData)} workId=${workId}`, '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 attempt of attempts) { try { const res = await request({ url: attempt.url, headers: attempt.headers, timeout: 30000 }); html = res.responseText || ''; usedUrl = res.finalUrl || attempt.url; if (html) { this.log(`work page loaded by ${attempt.name}`, 'gray'); break; } } catch (e) { this.log(`work page ${attempt.name} failed: ${e.message || e}`, 'orange'); } } if (!html) throw new Error('empty work page'); if (isWorkPageDoneLike(html, usedUrl)) { this.log(`章节测验已完成,跳过:${safeName(jobData)}`, 'green'); return; } if (!/submitData\s*\(/i.test(html) && !/singleQuesId|Py-mian1/i.test(html)) { this.log(`章节测验无需作答,跳过:${safeName(jobData)}`, 'green'); this.log(`work already done or no submitData: ${safeName(jobData)}`, 'gray'); return; } this.log(`work page loaded: ${usedUrl}`, 'gray'); await this.solveWorkHtml(jobData, context, html, usedUrl || workApiUrl, '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};这些题在题库里匹配不上、AI 也读不懂,会被跳过`, '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; } 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; 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 })); 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 剩余次数不足,已自动停止服务;请充值后继续。', 'orange'); this.setUserStatus({ headline: '答题剩余次数不足', currentTask: '已停止答题服务', etaText: '-', tip: '请点击“充值次数”完成充值后重新开始。' }); showRechargeTip('答题剩余次数不足,请充值后继续。'); this.log('Token 剩余次数不足,已自动停止服务;请充值后继续。', 'red'); throw new Error('Token 剩余次数不足,已停止'); } if (!ansRes.success || !ansRes.answer) { const why = fontIssue ? '题干乱码未还原,题库和 AI 都匹配不上' : '题库暂无答案'; 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'); 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 ? '缓存' : 'AI'};题干:${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); 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)); 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(`${label} skip submit: no answer filled`, 'orange'); return; } if (answered < parsed.questions.length) { this.log(`章节测验未提交:只填好 ${answered}/${parsed.questions.length} 题${failedList.length ? `,未完成 ${failedList.join('、')}` : ''};常见原因是题库答案与题型不符或该题暂无答案`, 'orange'); return; } await this.sleep(Math.max(1200, Number(this.config.answerRetryDelayMs || DEFAULT_CONFIG.answerRetryDelayMs || 1000))); this.log(`${label} submit by protocol: answered=${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(`${label} submitted: ${safeName(jobData)}`, 'green'); } 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.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); // 任务点总数以课程页汇总(已完成任务点 X/Y)为准:闯关课程只能扫到已解锁小节, // 用「已完成 + 剩余」当总数会把 40 个任务点显示成 2 个。 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'; // 只有在拿不到课程页汇总、又存在未解锁小节时才用「N+」表示还有未知数量。 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)); // 还有未解锁小节时,进度不能显示 100%,否则用户会以为课程已经刷完。 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; if (this.els.autoCourseQueue) this.els.autoCourseQueue.disabled = running; if (this.els.stopAction) this.els.stopAction.disabled = !running; if (this.els.startAction) { this.els.startAction.textContent = running ? (this.state.stopped ? '停止中...' : paused ? '已暂停' : '学习中...') : '开始自动刷课'; this.els.startAction.disabled = running; this.els.startAction.setAttribute('aria-disabled', running ? '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'}`; } 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 && (e.message || e) || ''); if (/Token|剩余次数|quota|insufficient/i.test(msg)) return '答题剩余次数不足,请点击“充值次数”后继续。'; if (/弹窗/.test(msg)) return msg; if (/字体数据|安全字体校验/.test(msg)) return '字体数据暂不可用,请使用 HTTPS 页面并联网后重试;加密题尚未处理。'; if (/未解析到章节|没有可扫描章节/.test(msg)) return '暂未读取到课程任务点,请回到课程页面后重试。'; return '当前任务暂时没有完成,稍后会继续处理后续任务。'; }, 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; } // 已脱敏、需要用户处理的故障诊断不能被当作内部 HTTP 调试信息隐藏。 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;} }; 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)) return false; // Function body holds the list so the startup guard is safe before const initialization. return /(^|\.)(chaoxing\.com|xueyinonline\.com|yuketang\.cn|xuetangx\.com|zhihuishu\.com|qingshuxuetang\.com)$/i.test(u.hostname) || runnerMirrorHosts().includes(u.hostname.toLowerCase()); } catch (_) { return false; } } function trustedParentOrigin(referrer) { if (!isSupportedRunnerLocation(referrer)) 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) || runnerMirrorHosts().includes(old.hostname))) 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$/i.test(host) || ["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","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","mooc1.jxjyzx.xust.edu.cn","stat2-ans.jxjyzx.xust.edu.cn","mooc1.xueyinonline.com","stat2-ans.xueyinonline.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"].includes(host); } function chaoxingCourseBase() { return 'https://mooc2-ans.chaoxing.com/mooc2-ans'; } function chaoxingCoursePageUrl(ctx = {}) { for (const key of ['courseId','clazzId','cpi']) if (!ctx[key]) throw new Error('缺少 ' + key + ',请从新版课程页重新进入'); 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 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 = {}) { // Only account/host information is shared. Never inherit another course's chapter or access tokens. 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(origin)); if (/^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 Set(); 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)) continue; seen.add(key); courses.push({ ...ctx, unavailable: ended }); } return courses; } async function loadChaoxingAccountCourses(base = {}) { // Same native student-course endpoint and parameters already used by backend/chaoxing/course.py. if (!isChaoxingCourseHost(location.hostname)) throw new Error('当前域名未启用账号课程读取'); const url = 'https://mooc2-ans.chaoxing.com/mooc2-ans/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: 'https://mooc2-ans.chaoxing.com/mooc2-ans/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 = 'https://mooc2-ans.chaoxing.com/mooc2-ans/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: 'https://mooc2-ans.chaoxing.com/mooc2-ans/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; 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'); 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 = {}) { 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', 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', 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'); // 剔除脚本/样式正文,避免把 Cloudflare 挑战脚本等噪声写进日志 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 (_) {} // 兜底:去掉