// ==UserScript== // @name 智慧树掌握度-最小链路(自动续跑-狂点轰炸版) // @namespace https://github.com/local/zhihuishu-min-chain // @version 1.0.0 // @description DOM 探测屏状态 + 页内控制面板;支持自定义 AI API(需使用有视觉能力的模型);任意界面可续跑,仅手动开始后执行。 // @match https://ai-smart-course-student-pro.zhihuishu.com/* // @match https://studentexamcomh5.zhihuishu.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect * // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const LOOP_KEY = 'zhs_loop'; const PANEL_POS_KEY = 'zhs_panel_pos'; const PANEL_COLLAPSED_KEY = 'zhs_panel_collapsed'; const THRESHOLD_KEY = 'zhs_threshold'; const RETRY_KEY_PREFIX = 'zhs_retry_'; const RETRY_MAX_KEY = 'zhs_retry_max'; const MAX_RETRIES = 4; const getPageUrlKey = () => { try { return new URL(window.location.href).pathname.replace(/\//g, '_').replace(/^_+|_+$/g, '') || 'root'; } catch { return 'unknown'; } }; const makeRetryKey = (index) => `${RETRY_KEY_PREFIX}${getPageUrlKey()}_${index}`; const makeRetryMaxKey = () => `${RETRY_MAX_KEY}_${getPageUrlKey()}`; const MAX_HOPS = 500; const ROUTE_SETTLE_MS = 200; const NAV_BACK_SEL = '[class*="w-[32px]"][class*="h-[32px]"].cursor-pointer'; const SCREENS = { LIST: 'LIST', DETAIL: 'DETAIL', PRE_QUIZ: 'PRE_QUIZ', QUIZ: 'QUIZ', RESULT: 'RESULT', UNKNOWN: 'UNKNOWN', }; // OpenAI 兼容 API 配置 (支持本地存储自定义) const getApiCfg = () => ({ baseUrl: GM_getValue('zhs_api_baseurl', ''), apiKey: GM_getValue('zhs_api_apikey', ''), model: GM_getValue('zhs_api_model', ''), maxTokens: GM_getValue('zhs_api_maxtokens', 2048), timeoutMs: GM_getValue('zhs_api_timeout', 120000), }); const saveMaxTokens = (val) => { const num = parseInt(val, 10); if (!Number.isNaN(num) && num >= 256 && num <= 8192) { GM_setValue('zhs_api_maxtokens', num); return true; } return false; }; const saveTimeout = (val) => { const num = parseInt(val, 10); if (!Number.isNaN(num) && num >= 10000 && num <= 300000) { GM_setValue('zhs_api_timeout', num); return true; } return false; }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const isLoopOn = () => { const date = Date.now(); return GM_getValue(LOOP_KEY, 0) >= date } const setLoopKey = (value = false) => { if(value && !unsafeWindow.__ZHS_STOP){ GM_setValue(LOOP_KEY, Date.now() + 1000 * 60 * 2); }else{ GM_setValue(LOOP_KEY, 0); } } const getThreshold = () => GM_getValue(THRESHOLD_KEY, 80); const parsePct = (el) => parseInt((el?.innerText || '').replace(/\D/g, ''), 10); const findLowPctProgress = (increase = false) => { const threshold = getThreshold(); const all = [...document.querySelectorAll('.el-progress--dashboard')]; updateRetryMax(all.length) for (let i = 0; i < all.length; i++) { const el = all[i]; const pct = parsePct(el); if (!Number.isNaN(pct) && pct < threshold && lowThanMaxRetry(i)) { if(increase)incRetryCount(i); return el; } } return null; }; const getRetryCount = (index) => GM_getValue(makeRetryKey(index), 0); const setRetryCount = (index, count) => GM_setValue(makeRetryKey(index), count); const incRetryCount = (index) => setRetryCount(index, getRetryCount(index) + 1); const resetRetryCounts = () => { const max = GM_getValue(makeRetryMaxKey(), 0); for (let i = 0; i < max; i++) { setRetryCount(i, 0); } }; const updateRetryMax = (newV) => { const current = GM_getValue(makeRetryMaxKey(), 0); const num = parseInt(newV, 10); if (!Number.isNaN(num) && num >= current) GM_setValue(makeRetryMaxKey(), num + 1); }; const lowThanMaxRetry = (i) => { return getRetryCount(i) <= MAX_RETRIES } const hasListWork = () => !!findLowPctProgress(); /** 按流程从后往前探测当前屏(SPA 路由不刷新) */ const detectScreen = () => { // RESULT · 成绩页:有正确率/得分图表(PRE_QUIZ 也有 backup-icon,不能用它判断) if (document.querySelector('.charts-rate')) return SCREENS.RESULT; // QUIZ · 答题页:题干已渲染(不用 reviewDone 探测,提交钮可能常驻) const q = document.querySelector('.questionContent'); if (q?.innerText?.trim()) return SCREENS.QUIZ; // PRE_QUIZ · 提升入口页:「提升 / 开始」按钮 if (document.querySelector('.improve-btn')) return SCREENS.PRE_QUIZ; // DETAIL · 知识点详情:「去提升」(也可能是 RESULT 退出链的落点,需结合 expectDetailForward) if (document.querySelector('.simplified-mastery__action')) return SCREENS.DETAIL; // LIST · 掌握度列表:环形进度条 const dash = document.querySelector('.el-progress--dashboard'); if (dash && /\d+/.test(dash.innerText || '')) return SCREENS.LIST; return SCREENS.UNKNOWN; }; const AI_CHAT = { maxAttempts: 3, timeoutMs: 120000, retryDelayMs: 1500, }; const AI_STATUS = { IDLE: 'idle', REQUESTING: 'requesting', RETRYING: 'retrying', SUCCESS: 'success', FAILED: 'failed', }; const createAIChatState = () => ({ status: AI_STATUS.IDLE, attempt: 0, lastRaw: '', lastError: null, }); const parseAnswerLetter = (raw) => { const match = (raw || '').match(/答案[::]\s*([A-Z])/i); return match ? match[1].toUpperCase() : null; }; const isValidQuizAnswer = (raw, optionCount) => { const all = [...(raw || '').matchAll(/答案[::]\s*([A-Z]+)/ig)]; const last = all[all.length - 1]; if (!last) return false; const letters = last[1].toUpperCase(); return [...letters].every(l => { const idx = l.charCodeAt(0) - 65; return idx >= 0 && idx < optionCount; }); }; const parseApiError = (res) => { let msg = ''; try { const body = JSON.parse(res.responseText); msg = body?.error?.message || body?.error?.msg || body?.message || body?.msg || body?.error || ''; if (typeof msg === 'object') msg = JSON.stringify(msg); } catch (_) { msg = res.responseText || ''; } return msg.slice(0, 300); }; const callAIOnce = (messages) => new Promise((resolve, reject) => { console.log(messages, 'messages'); const apiCfg = getApiCfg(); GM_xmlhttpRequest({ method: 'POST', url: `${apiCfg.baseUrl.replace(/\/$/, '')}/chat/completions`, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiCfg.apiKey}`, }, data: JSON.stringify({ model: apiCfg.model, messages, temperature: 0.2, max_tokens: apiCfg.maxTokens, }), timeout: apiCfg.timeoutMs, onload: (res) => { if (res.status < 200 || res.status >= 300) { const detail = parseApiError(res); reject(new Error(`AI HTTP ${res.status}${detail ? ' | ' + detail : ''}`)); return; } try { const data = JSON.parse(res.responseText); const text = data?.choices?.[0]?.message?.content; resolve(text.trim()); } catch (e) { reject(new Error(`AI 响应解析失败: ${e.message}`)); } }, onerror: () => reject(new Error('AI 网络错误')), ontimeout: () => reject(new Error('AI 请求超时')), }); }); /** * 统一 AI 对话:超时/网络错误自动重试;validate 不通过时带纠错提示重试 * @param {(attempt: number, state: object) => Array} buildMessages * @param {(raw: string) => boolean} validate */ const requestAI = async (buildMessages, validate) => { const chatState = createAIChatState(); for (let attempt = 1; attempt <= AI_CHAT.maxAttempts; attempt++) { chatState.attempt = attempt; chatState.status = attempt === 1 ? AI_STATUS.REQUESTING : AI_STATUS.RETRYING; try { const messages = buildMessages(attempt, chatState); chatState.memory = messages const raw = await callAIOnce(messages); console.log(`AI 响应: ${raw}`); chatState.lastRaw = raw; if (validate(raw)) { chatState.status = AI_STATUS.SUCCESS; return { raw, state: chatState }; } chatState.lastError = '答案格式不合规'; } catch (e) { chatState.lastError = e.message || String(e); } if (attempt < AI_CHAT.maxAttempts) { await sleep(AI_CHAT.retryDelayMs); } } chatState.status = AI_STATUS.FAILED; if (chatState.lastError === '答案格式不合规') { throw new Error(`AI 答案不合规,已重试 ${AI_CHAT.maxAttempts} 次: ${chatState.lastRaw}`); } throw new Error(`AI 请求失败,已重试 ${AI_CHAT.maxAttempts} 次: ${chatState.lastError}`); }; const click = (el) => { if (!el) return; el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: unsafeWindow })); }; /** * 核心原子函数:轮询点击指定 DOM 元素,直到该元素不存在(或函数返回 null) * @param {string|Function} selectorOrFn 选择器字符串或动态返回 DOM 的函数 * @param {number} timeout 最大超时时间(毫秒) * @param {number} step 点击轮询间隔(毫秒),100ms 保证极高灵敏度 */ const clickUntilGone = async (selectorOrFn, timeout = 15000, step = 200) => { const t0 = Date.now(); while (Date.now() - t0 < timeout) { if (unsafeWindow.__ZHS_STOP) return false; const el = typeof selectorOrFn === 'function' ? selectorOrFn() : document.querySelector(selectorOrFn); // 如果元素已经不存在了,直接判定成功,跳出并进入下一步 if (!el) return true; click(el); await sleep(step); } return false; }; // 标准异步等待函数(用于被动等待数据加载) const waitFor = async (fn, timeout = 30000, step = 100) => { const t0 = Date.now(); while (Date.now() - t0 < timeout) { if (unsafeWindow.__ZHS_STOP) return null; const v = fn(); if (v) return v; await sleep(step); } return null; }; const enlargeSmallImage = (imgEl, minTarget = 20) => new Promise((resolve) => { const w = imgEl.naturalWidth || imgEl.width || 0; const h = imgEl.naturalHeight || imgEl.height || 0; if (w > 10 && h > 10) { resolve(imgEl.src); return; } GM_xmlhttpRequest({ method: 'GET', url: imgEl.src, responseType: 'blob', onload: (resp) => { const blob = resp.response; const blobUrl = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { URL.revokeObjectURL(blobUrl); // 及时释放内存 const scale = minTarget / Math.min(img.width, img.height); const nw = Math.round(img.width * scale); const nh = Math.round(img.height * scale); const canvas = document.createElement('canvas'); canvas.width = nw; canvas.height = nh; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, nw, nh); console.log('url',canvas.toDataURL('image/png')); resolve(canvas.toDataURL('image/png')); }; img.onerror = () => { URL.revokeObjectURL(blobUrl); resolve(imgEl.src); }; img.src = blobUrl; }, onerror: () => resolve(imgEl.src), }); }); const getQuestionBlocks = async (root) => { if (!root) return []; const blocks = []; let imgIndex = 0; const pushText = (s) => { const t = (s || '').replace(/\s+/g, ' ').trim(); if (!t) return; const last = blocks[blocks.length - 1]; if (last && last.type === 'text') last.content += ' ' + t; else blocks.push({ type: 'text', content: t }); }; const walk = async (node) => { if (node.nodeType === 3) { pushText(node.textContent); return; } if (node.nodeType !== 1) return; if (/^(SCRIPT|STYLE)$/i.test(node.tagName)) return; if (node.classList?.contains('upload')) return; if (node.tagName === 'IMG') { if(node.src === 'https://hike-export.oss-cn-hangzhou.aliyuncs.com/p…20260130/fc9f26dc-8a16-44b9-b171-17a42641b0da.png'){//傻逼智慧树这个图片ai识别错误 pushText('x'); return; } const w = node.naturalWidth || node.width || 0; const h = node.naturalHeight || node.height || 0; if (w > 0 && h > 0) { imgIndex += 1; const src = await enlargeSmallImage(node); blocks.push({ type: 'image', index: imgIndex, src, alt: node.alt || '', }); } return; } if (node.tagName === 'BR') { pushText('\n'); return; } for (const child of node.childNodes) await walk(child); }; await walk(root); return blocks; }; const blocksToMarkdown = (blocks) => blocks.map((b) => (b.type === 'text' ? b.content : `[IMAGE:${b.index}]`)).join('\n'); const readQuestion = async () => { const root = document.querySelector('.questionContent'); const blocks = await getQuestionBlocks(root); unsafeWindow.__questionBlocks = blocks; const md = blocksToMarkdown(blocks); return blocks; }; const buildQuizMessages = (blocks, optLines, attempt, chatState, isMultiple = false) => { const memory = chatState.memory || []; const answerFmt = isMultiple ? '最后一行必须以"答案:X"的格式输出,X 为多个连续字母(对应所有正确选项,例如"ABC"表示选A、B、C三个选项)' : '最后一行必须以"答案:X"的格式输出,X 只能为单个字母(对应正确选项)'; if(!memory.length) { memory.push({ role: 'system', content: `你是一个专业的做题助手,你的任务是根据用户的题目,生成符合要求的选项,请逐步用平文本思考并选出正确答案的选项。${answerFmt}。`}) } if (attempt > 1 && chatState.lastRaw){ memory.push({ role: 'assistant', content: chatState.lastRaw, }) } if(memory.length < 2) { const content = [{ type: 'text', text: `题目:\n\n${blocksToMarkdown(blocks)}\n\n选项:\n${optLines.join('\n')}` }]; blocks .filter((b) => b.type === 'image') .forEach((b) => content.push({ type: 'image_url', image_url: { url: b.src } })); memory.push({ role: 'user', content }) } if (attempt > 1 ){ memory.push({ role: 'user', content: `\n\n你上次回答不合规(需以"答案:X"结尾且 X 为有效选项字母),有可能是因为回答太长截断。请你简短的总结上一次的回答思路(不超过3句话),按更短的链路继续上次的思路回答`, }) } return memory; }; const isMultipleChoice = () => !!document.querySelector('.el-checkbox-group.checkbox-view'); const getQuizOptions = () => { if (isMultipleChoice()) { return [...document.querySelectorAll('.el-checkbox-group.checkbox-view .el-checkbox')]; } return [...document.querySelectorAll('ul.radio-view li')]; }; const parseAnswerLetters = (raw) => { const all = [...(raw || '').matchAll(/答案[::]\s*([A-Z]+)/ig)]; const last = all[all.length - 1]; return last ? [...last[1].toUpperCase()] : []; }; const answerWithAI = async (blocks) => { const mc = isMultipleChoice(); const opts = getQuizOptions(); if (!opts.length) return null; const optLines = opts.map((opt, i) => `${String.fromCharCode(65 + i)}. ${opt.innerText.trim()}`); const { raw } = await requestAI( (attempt, chatState) => buildQuizMessages(blocks, optLines, attempt, chatState, mc), (raw) => isValidQuizAnswer(raw, opts.length), ); const letters = parseAnswerLetters(raw); for (const letter of letters) { const idx = letter.charCodeAt(0) - 65; const targetOpt = opts[idx]; if (!targetOpt) continue; if (mc) { if (!targetOpt.classList.contains('is-checked')) { click(targetOpt); await waitFor(() => targetOpt.classList.contains('is-checked') ? true : null, 3000, 50); } } else { const oldClass = targetOpt.className; await clickUntilGone(() => { return targetOpt.className === oldClass ? targetOpt : null; }, 3000); } } return raw; }; // 获取当前未答题目 const getMismatchNode = () => { const list = [...document.querySelectorAll('.custom-tree-answer-normal.no-answer')]; const sortChar = (document.querySelector('.letterSortNum')?.innerText || '').trim().charAt(0);//当前题号,避免打完没有更新 if (list.length >= 2) { for (let i = 1; i < list.length; i++) { const c1 = (list[i].innerText || '').trim().charAt(0); if (c1 !== sortChar) return list[i]; } } return null; }; async function runListHop() { if (!isLoopOn()) return false; // 等待掌握度列表面板重新加载并带有百分比数字 const hasDashboard = await waitFor(() => { const el = document.querySelector('.el-progress--dashboard'); return el && /\d+/.test(el.innerText || '') ? el : null; }, 30000); if (!hasDashboard) return false; if (!hasListWork()) { setLoopKey(false); return false; } const el = await waitFor(() => findLowPctProgress(true)); // 点击掌握度不足阈值的题目,跳过重试超限的,直到该目标在页面上消失 return clickUntilGone(() => findLowPctProgress()); } async function runDetailHop() { setLoopKey(true);//更新时间戳 // 从 LIST 点进 DETAIL 后:进入题目,点击「去提升」 return clickUntilGone('.simplified-mastery__action'); } async function runDetailExitHop() { setLoopKey(true);//更新时间戳 // DETAIL 退回 LIST:脚本首次进入、或 RESULT 退出链落点(小箭头实际路由仍到 DETAIL 的上一级) return clickUntilGone(NAV_BACK_SEL); } async function runPreQuizHop() { setLoopKey(true);//更新时间戳 // �提升 / 开始提升 / 开始」按钮,直到它消失 return clickUntilGone('.improve-btn',20000, 5000);//傻逼智慧树不做防抖 } /** QUIZ = 答题 + 提交(同一屏);是否提交由 getMismatchNode 判断,不用 reviewDone 是否存在 */ async function runQuizHop() { // 确保题目容器和选项文本被 JS 异步渲染出来 const isReady = await waitFor(() => { const q = document.querySelector('.questionContent'); if (!q || !q.innerText.trim()) return null; const mc = !!document.querySelector('.el-checkbox-group.checkbox-view'); const opts = mc ? document.querySelectorAll('.el-checkbox-group.checkbox-view .el-checkbox') : document.querySelectorAll('ul.radio-view li'); return opts.length > 0 && opts[0].innerText.trim() ? q : null; }, 30000); if (!isReady) return false; const oldText = isReady.innerText; // 备份当前题目文本,用于防错比对 panelNotify('quiz', { phase: 'start' }); try { const aiRaw = await answerWithAI(await readQuestion()); panelNotify('quiz', { phase: 'done', aiOutput: aiRaw }); } catch (e) { panelNotify('error', e?.message || 'AI 答题失败'); return false; } setLoopKey(true);//更新时间戳 // 侧栏还有未答题 → 切下一题(不能提交) if (getMismatchNode()) { return clickUntilGone(() => { const currentQ = document.querySelector('.questionContent'); if (!currentQ || currentQ.innerText !== oldText) return null; return getMismatchNode(); }); } panelNotify('hop', { screen: SCREENS.QUIZ, action: '提交作业' }); // 侧栏无未答题 → 提交(reviewDone 可能一直存在,仅作点击目标) setLoopKey(true);//更新时间戳 return clickUntilGone('.reviewDone.ZHIHUISHU_QZMD'); } async function runResultHop() { // RESULT · 成绩页:先点 backup-icon,再点小箭头(后者实际落到 DETAIL 而非 LIST) if (!document.querySelector('.charts-rate')) return false; const ok1 = await clickUntilGone('.backup-icon'); if (!ok1) return false; await sleep(ROUTE_SETTLE_MS); return clickUntilGone(NAV_BACK_SEL); } async function runOneHop(screen, expectDetailForward) { switch (screen) { case SCREENS.LIST: return runListHop(); case SCREENS.DETAIL: return expectDetailForward ? runDetailHop() : runDetailExitHop(); case SCREENS.PRE_QUIZ: return runPreQuizHop(); case SCREENS.QUIZ: return runQuizHop(); case SCREENS.RESULT: return runResultHop(); default: return false; } } const CHAIN_STEPS = [ { id: SCREENS.LIST, label: '列表' }, { id: SCREENS.DETAIL, label: '详情' }, { id: SCREENS.PRE_QUIZ, label: '提升入口' }, { id: SCREENS.QUIZ, label: '答题' }, { id: SCREENS.RESULT, label: '成绩' }, ]; const SCREEN_LABELS = { [SCREENS.LIST]: '掌握度列表', [SCREENS.DETAIL]: '知识点详情', [SCREENS.PRE_QUIZ]: '提升入口', [SCREENS.QUIZ]: '答题页', [SCREENS.RESULT]: '成绩页', [SCREENS.UNKNOWN]: '未识别页面', }; const hopActionLabel = (screen, expectDetailForward) => { switch (screen) { case SCREENS.LIST: return '选中低分题'; case SCREENS.DETAIL: return expectDetailForward ? '去提升' : '退回列表'; case SCREENS.PRE_QUIZ: return '开始提升'; case SCREENS.QUIZ: return '答题/切题'; case SCREENS.RESULT: return '退出成绩页'; default: return '未知操作'; } }; let panelCtx = null; const panelNotify = (event, detail) => { if (panelCtx) panelCtx.handle(event, detail); }; const createPanel = (handlers) => { const host = document.createElement('div'); host.id = 'zhs-panel-host'; host.style.cssText = 'all:initial;position:fixed;z-index:2147483646;'; document.body.appendChild(host); const shadow = host.attachShadow({ mode: 'closed' }); shadow.innerHTML = `