// ==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 = `
掌握度链路 Vision AI Control Panel
首次使用请先填入接口地址、密钥和模型名。
建议优先使用带视觉能力的模型。纯文本模型遇到题目图片时更容易失败。
${CHAIN_STEPS.map((s) => `
${s.label}
`).join('')}
已停止 循环关 当前:— API:—
`; const wrap = shadow.getElementById('wrap'); const fab = shadow.getElementById('fab'); const dragHandle = shadow.getElementById('drag-handle'); const stepsEl = shadow.getElementById('steps'); const runDot = shadow.getElementById('run-dot'); const tagRun = shadow.getElementById('tag-run'); const tagLoop = shadow.getElementById('tag-loop'); const tagScreen = shadow.getElementById('tag-screen'); const tagApi = shadow.getElementById('tag-api'); const logEl = shadow.getElementById('log'); const btnStart = shadow.getElementById('btn-start'); const btnStop = shadow.getElementById('btn-stop'); const btnCollapse = shadow.getElementById('btn-collapse'); const btnSettings = shadow.getElementById('btn-settings'); const settingsPanel = shadow.getElementById('settings-panel'); const inpBaseUrl = shadow.getElementById('inp-baseurl'); const inpApiKey = shadow.getElementById('inp-apikey'); const inpModel = shadow.getElementById('inp-model'); const inpMaxTokens = shadow.getElementById('inp-maxtokens'); const inpTimeout = shadow.getElementById('inp-timeout'); const inpThreshold = shadow.getElementById('inp-threshold'); const btnSaveSettings = shadow.getElementById('btn-save-settings'); const btnResetRetry = shadow.getElementById('btn-reset-retry'); const logs = []; let running = false; let currentScreen = SCREENS.UNKNOWN; const fmtTime = () => { const d = new Date(); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; }; const renderLogs = () => { logEl.innerHTML = logs.length ? logs .map( (l) => `
${l.t}${l.m}
`, ) .join('') : '
等待任务开始…
'; logEl.scrollTop = logEl.scrollHeight; }; const addLog = (msg, err = false) => { logs.push({ t: fmtTime(), m: msg, err }); if (logs.length > 30) logs.shift(); renderLogs(); }; const applyPos = (offsetX = 0) => { wrap.style.right = `${offsetX}px`; wrap.style.top = '0'; wrap.style.left = 'auto'; wrap.style.bottom = 'auto'; wrap.style.height = '100vh'; wrap.style.width = '28vw'; }; const savePos = (offsetX) => { GM_setValue(PANEL_POS_KEY, { x: Math.round(offsetX) }); }; const setCollapsed = (collapsed) => { wrap.classList.toggle('collapsed', collapsed); GM_setValue(PANEL_COLLAPSED_KEY, collapsed); }; const updateSteps = (screen) => { const idx = CHAIN_STEPS.findIndex((s) => s.id === screen); stepsEl.querySelectorAll('.step').forEach((el, i) => { el.classList.remove('active', 'done'); if (idx < 0) return; if (i < idx) el.classList.add('done'); else if (i === idx) el.classList.add('active'); }); }; const loadSettingsInputs = () => { inpBaseUrl.value = GM_getValue('zhs_api_baseurl', ''); inpApiKey.value = GM_getValue('zhs_api_apikey', ''); inpModel.value = GM_getValue('zhs_api_model', ''); inpMaxTokens.value = GM_getValue('zhs_api_maxtokens', 2048); inpTimeout.value = GM_getValue('zhs_api_timeout', 120000); inpThreshold.value = GM_getValue(THRESHOLD_KEY, 80); }; const refreshApiStatus = () => { const cfg = getApiCfg(); const modelLabel = cfg.model ? cfg.model.split('-')[0] : '未配置'; const baseUrlShort = cfg.baseUrl ? cfg.baseUrl.replace(/^https?:\/\//, '').replace(/\/$/, '') : '未配置'; const keyLabel = cfg.apiKey ? `${cfg.apiKey.slice(0, 8)}...` : '未配置'; tagApi.textContent = `API:${modelLabel}`; tagApi.title = `BaseURL: ${baseUrlShort}\nKey: ${keyLabel}`; }; const refreshStatus = () => { const loop = isLoopOn(); running = !!unsafeWindow.__ZHS_CHAIN_RUNNING; runDot.classList.toggle('running', running); tagRun.textContent = running ? '运行中' : '已停止'; tagRun.className = `tag ${running ? 'on' : 'off'}`; tagLoop.textContent = loop ? '循环开' : '循环关'; tagLoop.className = `tag ${loop ? 'on' : 'off'}`; tagScreen.textContent = `当前:${SCREEN_LABELS[currentScreen] || currentScreen}`; refreshApiStatus(); updateSteps(currentScreen); }; const handle = (event, detail) => { switch (event) { case 'init': addLog('待命,点击「开始/继续」启动'); refreshStatus(); break; case 'start': addLog('已开始'); refreshStatus(); break; case 'stop': addLog('已停止'); refreshStatus(); break; case 'screen': if (detail) currentScreen = detail; refreshStatus(); break; case 'hop': if (detail?.action) addLog(`${SCREEN_LABELS[detail.screen] || detail.screen} → ${detail.action}`); else if (detail?.screen) addLog(`${SCREEN_LABELS[detail.screen] || detail.screen} → ${hopActionLabel(detail.screen, detail.expectDetailForward)}`); refreshStatus(); break; case 'quiz': if (detail?.phase === 'start') addLog('AI 答题中…'); else if (detail?.phase === 'done') addLog(`AI 答题完成 | ${detail.aiOutput || ''}`); break; case 'error': addLog(detail || '发生错误', true); refreshStatus(); break; case 'done': addLog('本轮结束'); refreshStatus(); break; default: break; } }; const setupDrag = (handleEl, onTap) => { handleEl.addEventListener('mousedown', (e) => { if (e.button !== 0) return; e.preventDefault(); const startX = e.clientX; const saved = GM_getValue(PANEL_POS_KEY, { x: 0 }); const startOffsetX = typeof saved?.x === 'number' ? saved.x : 0; let moved = false; const onMove = (ev) => { const deltaX = startX - ev.clientX; if (Math.abs(deltaX) > 3) moved = true; const maxOffset = Math.max(window.innerWidth - 72, 0); const nextOffset = Math.min(Math.max(startOffsetX + deltaX, 0), maxOffset); applyPos(nextOffset); }; const onUp = (ev) => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); const deltaX = startX - ev.clientX; const maxOffset = Math.max(window.innerWidth - 72, 0); const nextOffset = Math.min(Math.max(startOffsetX + deltaX, 0), maxOffset); savePos(nextOffset); if (!moved && onTap) onTap(); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }; const savedPos = GM_getValue(PANEL_POS_KEY, { x: 0 }); applyPos(typeof savedPos?.x === 'number' ? savedPos.x : 0); setCollapsed(GM_getValue(PANEL_COLLAPSED_KEY, false)); setupDrag(dragHandle); setupDrag(fab, () => setCollapsed(false)); loadSettingsInputs(); btnStart.addEventListener('click', () => handlers.onStart()); btnStop.addEventListener('click', () => handlers.onStop()); btnCollapse.addEventListener('click', () => setCollapsed(true)); btnSettings.addEventListener('click', () => { const isOpen = settingsPanel.classList.toggle('open'); if (isOpen) loadSettingsInputs(); }); btnSaveSettings.addEventListener('click', () => { GM_setValue('zhs_api_baseurl', inpBaseUrl.value.trim()); GM_setValue('zhs_api_apikey', inpApiKey.value.trim()); GM_setValue('zhs_api_model', inpModel.value.trim()); saveMaxTokens(inpMaxTokens.value); saveTimeout(inpTimeout.value); const threshold = parseInt(inpThreshold.value, 10); if (!Number.isNaN(threshold) && threshold >= 0 && threshold <= 100) { GM_setValue(THRESHOLD_KEY, threshold); } settingsPanel.classList.remove('open'); addLog('API 配置已保存'); refreshApiStatus(); }); btnResetRetry.addEventListener('click', () => { resetRetryCounts(); addLog('做题次数已重置'); }); return { handle, refreshStatus, setScreen: (s) => { currentScreen = s; refreshStatus(); } }; }; async function runFromHere() { if (unsafeWindow.__ZHS_CHAIN_RUNNING) return; unsafeWindow.__ZHS_CHAIN_RUNNING = true; panelNotify('start'); try { let hops = 0; // false = DETAIL 上应点返回(首次进入 / RESULT 退出链落点);true = 从 LIST 点进,应点「去提升」 let expectDetailForward = false; while (hops < MAX_HOPS && isLoopOn() && !unsafeWindow.__ZHS_STOP) { hops += 1; let screen = detectScreen(); if (screen === SCREENS.UNKNOWN) { const found = await waitFor(() => (detectScreen() !== SCREENS.UNKNOWN ? true : null), 15000); if (!found) { panelNotify('error', '未识别页面,停止'); break; } screen = detectScreen(); if (screen === SCREENS.UNKNOWN) { panelNotify('error', '未识别页面,停止'); break; } } panelNotify('screen', screen); const progressed = await runOneHop(screen, expectDetailForward); if (!progressed) { panelNotify('error', `${SCREEN_LABELS[screen] || screen}:本步未推进`); break; } panelNotify('hop', { screen, expectDetailForward }); if (screen === SCREENS.LIST && progressed) expectDetailForward = true; if (screen === SCREENS.DETAIL && expectDetailForward && progressed) expectDetailForward = false; await sleep(ROUTE_SETTLE_MS); if (detectScreen() === SCREENS.LIST && !hasListWork()) { setLoopKey(false); panelNotify('hop', { screen: SCREENS.LIST, action: '无待刷题目,关闭循环' }); break; } } } finally { unsafeWindow.__ZHS_CHAIN_RUNNING = false; panelNotify('done'); } } function startChain() { unsafeWindow.__ZHS_STOP = false; setLoopKey(true); runFromHere(); } function stopChain() { unsafeWindow.__ZHS_STOP = true; setLoopKey(false); panelNotify('stop'); } GM_registerMenuCommand('最小链路:开始/继续', startChain); GM_registerMenuCommand('最小链路:停止', stopChain); GM_registerMenuCommand('设置 API 配置', () => { const url = prompt('输入 API Base URL(如 https://dashscope.aliyuncs.com/compatible-mode/v1):', GM_getValue('zhs_api_baseurl', '')); if (url !== null) GM_setValue('zhs_api_baseurl', url.trim()); const key = prompt('输入 API Key:', GM_getValue('zhs_api_apikey', '')); if (key !== null) GM_setValue('zhs_api_apikey', key.trim()); const model = prompt('输入 Model Name(如 qwen-vl-plus,qwen3.6-flash-2026-04-16):', GM_getValue('zhs_api_model', '')); if (model !== null) GM_setValue('zhs_api_model', model.trim()); const maxTokens = prompt('输入 Max Tokens(默认 2048):', GM_getValue('zhs_api_maxtokens', 2048)); if (maxTokens !== null) saveMaxTokens(maxTokens); const timeout = prompt('输入 Timeout (ms)(默认 120000):', GM_getValue('zhs_api_timeout', 120000)); if (timeout !== null) saveTimeout(timeout); }); panelCtx = createPanel({ onStart: startChain, onStop: stopChain }); panelNotify('init'); panelNotify('screen', detectScreen()); const idleRefreshTimer = setInterval(() => { if (!unsafeWindow.__ZHS_CHAIN_RUNNING) { panelNotify('screen', detectScreen()); } }, 2000); window.addEventListener('beforeunload', () => clearInterval(idleRefreshTimer)); waitFor(() => (detectScreen() !== SCREENS.UNKNOWN ? true : null), 15000).then(() => { if (isLoopOn() && !unsafeWindow.__ZHS_STOP) runFromHere(); }); })();