// ==UserScript== // @name 布吉岛雨课堂导出工具 - 无图试卷AI定向作答版 // @namespace https://github.com/itkdm // @version 0.1.0 // @description 仅针对无图试卷,支持指定题号或整卷发送给 AI,并直接提交这些题目的答案 // @match https://examination.xuetangx.com/* // @match https://www.doubao.com/* // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @connect examination.xuetangx.com // @connect api.deepseek.com // @connect chatgpt.com // @connect chat.openai.com // @run-at document-start // ==/UserScript== (function () { 'use strict'; const HOST_EXAM = 'https://examination.xuetangx.com'; const DEEPSEEK_API_URL = 'https://api.deepseek.com/chat/completions'; const DEEPSEEK_MODEL = 'deepseek-chat'; const AI_MODE_API = 'api'; const AI_MODE_DOUBAO_WEB = 'doubao_web'; const AI_MODE_CHATGPT_TOKEN = 'chatgpt_token'; const BRIDGE_PROVIDER_KEY = 'doubao'; const BRIDGE_PROVIDER_NAME = '豆包'; const CHATGPT_PROVIDER_KEY = 'chatgpt'; const CHATGPT_PROVIDER_NAME = 'ChatGPT'; const CHATGPT_API_URL = 'https://chatgpt.com/backend-api/f/conversation'; const CHATGPT_MODEL = 'gpt-5-4-thinking'; const BRIDGE_JOB_KEY = 'yktAiBridge:job'; const BRIDGE_RESULT_KEY = 'yktAiBridge:result'; const PAGE_CAPTURE_EVENT = '__ykt_ai_doubao_capture__'; const CHATGPT_PAGE_CAPTURE_EVENT = '__ykt_ai_chatgpt_capture__'; const CHATGPT_DEBUG_KEY = 'yktAiBridge:chatgpt:debug'; function nowIso() { return new Date().toISOString(); } function safeParse(text) { try { return JSON.parse(text); } catch { return null; } } function isExamPage() { return location.hostname === 'examination.xuetangx.com' && /\/exam\/|\/result\/|exam_id=/.test(location.href); } function isDoubaoPage() { return location.hostname === 'www.doubao.com'; } function isChatGptPage() { return location.hostname === 'chatgpt.com' || location.hostname === 'chat.openai.com'; } function templateKeyFor(providerKey) { return `yktAiBridge:${providerKey}:template`; } function stateKeyFor(providerKey) { return `yktAiBridge:${providerKey}:state`; } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function toast(msg, ms = 3000) { let el = document.getElementById('__ykt_ai_subset_toast__'); if (!el) { el = document.createElement('div'); el.id = '__ykt_ai_subset_toast__'; el.style.cssText = [ 'position:fixed', 'right:18px', 'bottom:18px', 'z-index:999999', 'max-width:360px', 'padding:10px 12px', 'border-radius:10px', 'background:rgba(0,0,0,.82)', 'color:#fff', 'font-size:13px', 'line-height:1.45', 'box-shadow:0 8px 24px rgba(0,0,0,.18)' ].join(';'); document.body.appendChild(el); } el.textContent = msg; el.style.display = 'block'; setTimeout(() => { el.style.display = 'none'; }, ms); } function makeBtn(text, onClick) { const btn = document.createElement('button'); btn.textContent = text; btn.style.cssText = [ 'display:block', 'width:100%', 'margin:8px 0', 'padding:10px 12px', 'border:0', 'border-radius:10px', 'cursor:pointer', 'background:#2563eb', 'color:#fff', 'font-size:14px', 'font-weight:600' ].join(';'); btn.addEventListener('click', onClick); return btn; } function makePanel() { if (!document.body) { const retry = document.createElement('div'); retry.id = '__ykt_ai_subset_panel_retry__'; return retry; } let panel = document.getElementById('__ykt_ai_subset_panel__'); if (panel) return panel; panel = document.createElement('div'); panel.id = '__ykt_ai_subset_panel__'; panel.style.cssText = [ 'position:fixed', 'right:16px', 'top:50%', 'transform:translateY(-50%)', 'z-index:999999', 'width:260px', 'padding:16px', 'border-radius:16px', 'background:#f8fbff', 'border:1px solid #cfe0ff', 'box-shadow:0 10px 30px rgba(37,99,235,.15)', 'font-size:13px', 'color:#1f2937' ].join(';'); panel.innerHTML = [ '
无图试卷 AI 定向作答
', '
支持指定题号定向作答,也支持整卷自动作答,不会自动交卷。
', '
', '', '
' ].join(''); document.body.appendChild(panel); return panel; } function setStatus(text) { const el = document.getElementById('__ykt_ai_subset_status__'); if (el) el.textContent = text || ''; } function getSubmitSettings() { const baseDelaySeconds = Number(GM_getValue('submitBaseDelaySeconds', 3)); const jitterSeconds = Number(GM_getValue('submitJitterSeconds', 1)); return { baseDelaySeconds: Number.isFinite(baseDelaySeconds) && baseDelaySeconds >= 0 ? baseDelaySeconds : 3, jitterSeconds: Number.isFinite(jitterSeconds) && jitterSeconds >= 0 ? jitterSeconds : 1 }; } function saveSubmitSettings(baseDelaySeconds, jitterSeconds) { GM_setValue('submitBaseDelaySeconds', baseDelaySeconds); GM_setValue('submitJitterSeconds', jitterSeconds); } function fillSubmitSettingsForm() { const settings = getSubmitSettings(); const baseInput = document.getElementById('__ykt_submit_base__'); const jitterInput = document.getElementById('__ykt_submit_jitter__'); if (baseInput) baseInput.value = String(settings.baseDelaySeconds); if (jitterInput) jitterInput.value = String(settings.jitterSeconds); } function toggleSettingsPanel(show) { const el = document.getElementById('__ykt_ai_subset_settings__'); if (!el) return; el.style.display = show ? 'block' : 'none'; if (show) fillSubmitSettingsForm(); } function configureSubmitSettings() { fillSubmitSettingsForm(); toggleSettingsPanel(true); } function bindSettingsEvents(panel) { const saveBtn = panel.querySelector('#__ykt_submit_save__'); const cancelBtn = panel.querySelector('#__ykt_submit_cancel__'); if (saveBtn) { saveBtn.addEventListener('click', () => { const baseInput = panel.querySelector('#__ykt_submit_base__'); const jitterInput = panel.querySelector('#__ykt_submit_jitter__'); const baseDelaySeconds = Number(baseInput?.value || 0); const jitterSeconds = Number(jitterInput?.value || 0); if (!Number.isFinite(baseDelaySeconds) || baseDelaySeconds < 0) { toast('固定间隔必须是大于等于 0 的数字', 5000); return; } if (!Number.isFinite(jitterSeconds) || jitterSeconds < 0) { toast('随机抖动必须是大于等于 0 的数字', 5000); return; } saveSubmitSettings(baseDelaySeconds, jitterSeconds); toggleSettingsPanel(false); toast(`提交设置已保存:${baseDelaySeconds}s + 随机 ${jitterSeconds}s`); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => toggleSettingsPanel(false)); } } function getNextSubmitDelayMs() { const settings = getSubmitSettings(); const jitter = settings.jitterSeconds > 0 ? Math.random() * settings.jitterSeconds : 0; return Math.round((settings.baseDelaySeconds + jitter) * 1000); } function refreshPageAfterDelay(delayMs = 1500) { setStatus(`作答已结束,${(delayMs / 1000).toFixed(1)} 秒后自动刷新页面`); toast(`作答已结束,${(delayMs / 1000).toFixed(1)} 秒后自动刷新页面`, 4000); setTimeout(() => { try { location.reload(); } catch (err) { console.error('location.reload failed', err); } setTimeout(() => { try { history.go(0); } catch (err) { console.error('history.go(0) failed', err); } }, 600); setTimeout(() => { try { location.href = location.href; } catch (err) { console.error('location.href refresh failed', err); } }, 1200); }, delayMs); } function htmlToText(html) { if (!html) return ''; const doc = new DOMParser().parseFromString(html, 'text/html'); return (doc.body.textContent || '') .replace(/\u00A0/g, ' ') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); } function escapeHtml(text) { return String(text || '') .replace(/&/g, '&') .replace(//g, '>'); } function parseJsonFromText(text) { const raw = String(text || '').trim(); if (!raw) throw new Error('模型响应为空'); try { return JSON.parse(raw); } catch {} const fenced = raw.match(/```json\s*([\s\S]*?)```/i) || raw.match(/```\s*([\s\S]*?)```/i); if (fenced) { const inner = fenced[1].trim(); try { return JSON.parse(inner); } catch {} } const candidates = []; for (let start = 0; start < raw.length; start++) { if (raw[start] !== '{') continue; let depth = 0; let inString = false; let escaped = false; for (let end = start; end < raw.length; end++) { const ch = raw[end]; if (inString) { if (escaped) { escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === '"') { inString = false; } continue; } if (ch === '"') { inString = true; continue; } if (ch === '{') depth += 1; if (ch === '}') { depth -= 1; if (depth === 0) { const snippet = raw.slice(start, end + 1); candidates.push(snippet); break; } } } } for (let i = candidates.length - 1; i >= 0; i--) { const snippet = candidates[i]; try { const parsed = JSON.parse(snippet); if (Array.isArray(parsed?.answers)) return parsed; } catch {} } throw new Error('无法从模型响应中解析 JSON'); } function normalizeText(s) { return String(s || '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function maskKey(key) { const s = String(key || ''); if (s.length <= 8) return '***'; return `${s.slice(0, 4)}...${s.slice(-4)}`; } function normalizeChatGptAccessToken(token) { return String(token || '').trim().replace(/^Bearer\s+/i, '').trim(); } function getChatGptCurlProfile() { return safeParse(GM_getValue('chatgptCurlProfile', '')); } function saveChatGptCurlProfile(profile) { GM_setValue('chatgptCurlProfile', JSON.stringify(profile)); } function deleteChatGptCurlProfile() { GM_deleteValue('chatgptCurlProfile'); } function parseChatGptCurlCommand(rawText) { const text = String(rawText || '') .replace(/\^\r?\n\s*/g, ' ') .replace(/\^/g, '') .trim(); if (!/backend-api\/f\/conversation|backend-api\/conversation/i.test(text)) { throw new Error('未识别到 ChatGPT conversation cURL'); } const urlMatch = text.match(/curl\s+"([^"]+)"/i); const headers = {}; const headerRe = /-H\s+"([^"]+)"/gi; let m; while ((m = headerRe.exec(text))) { const line = m[1]; const idx = line.indexOf(':'); if (idx <= 0) continue; const key = line.slice(0, idx).trim().toLowerCase(); const value = line.slice(idx + 1).trim(); headers[key] = value; } const cookieMatch = text.match(/-b\s+"([^"]*)"/i); const dataMatch = text.match(/--data-raw\s+"([\s\S]*)"$/i); let body = dataMatch ? dataMatch[1] : ''; if (!body) throw new Error('cURL 中未找到 --data-raw'); body = body .replace(/\\"/g, '"') .replace(/\\r\\n/g, '\n') .replace(/\\n/g, '\n') .replace(/^"\s*/, '') .replace(/\s*"$/, '') .trim(); return { captured_at: nowIso(), url: urlMatch ? urlMatch[1] : CHATGPT_API_URL, headers, cookies: cookieMatch ? cookieMatch[1] : '', body }; } function importChatGptCurlProfile(interactive = true) { const raw = prompt('请粘贴 ChatGPT 成功请求的完整 cURL', ''); if (raw == null) return null; const profile = parseChatGptCurlCommand(raw); saveChatGptCurlProfile(profile); const auth = normalizeChatGptAccessToken(profile.headers?.authorization || ''); if (auth) GM_setValue('chatgptAccessToken', auth); if (interactive) { toast('已导入 ChatGPT 请求画像', 5000); } return profile; } function getAiModeLabel(mode) { if (mode === AI_MODE_DOUBAO_WEB) return '豆包网页登录态'; if (mode === AI_MODE_CHATGPT_TOKEN) return 'ChatGPT 手动模式'; return 'API'; } function getAiConfig() { return { mode: GM_getValue('aiMode', AI_MODE_API).trim() || AI_MODE_API, apiUrl: GM_getValue('aiApiUrl', DEEPSEEK_API_URL).trim() || DEEPSEEK_API_URL, apiKey: GM_getValue('aiApiKey', '').trim(), model: GM_getValue('aiModel', DEEPSEEK_MODEL).trim() || DEEPSEEK_MODEL, chatgptAccessToken: normalizeChatGptAccessToken(GM_getValue('chatgptAccessToken', '')), chatgptModel: GM_getValue('chatgptModel', CHATGPT_MODEL).trim() || CHATGPT_MODEL, chatgptCurlProfile: safeParse(GM_getValue('chatgptCurlProfile', '')) }; } function ensureAiConfig() { const config = getAiConfig(); if (config.mode === AI_MODE_DOUBAO_WEB) { return config; } if (config.mode === AI_MODE_CHATGPT_TOKEN) { return config; } if (!config.apiUrl) { throw new Error('未配置 AI 接口 URL'); } if (!config.apiKey) { throw new Error('未配置 AI API Key'); } if (!config.model) { throw new Error('未配置 AI 模型名称'); } return config; } function configureAiSettings() { const current = getAiConfig(); const modeRaw = prompt( [ '请选择 AI 模式:', '1. API 模式', '2. 豆包网页登录态', '3. ChatGPT 手动模式', `当前:${getAiModeLabel(current.mode)}` ].join('\n'), current.mode === AI_MODE_DOUBAO_WEB ? '2' : current.mode === AI_MODE_CHATGPT_TOKEN ? '3' : '1' ); if (modeRaw == null) return; const modeText = String(modeRaw).trim().toLowerCase(); let nextMode = AI_MODE_API; if (modeText === '2' || modeText === AI_MODE_DOUBAO_WEB) nextMode = AI_MODE_DOUBAO_WEB; else if (modeText === '3' || modeText === AI_MODE_CHATGPT_TOKEN) nextMode = AI_MODE_CHATGPT_TOKEN; GM_setValue('aiMode', nextMode); if (nextMode === AI_MODE_DOUBAO_WEB) { renderExamPagePanel(); toast('已切换到豆包网页登录态。请先在 www.doubao.com 页面发送一条成功消息,捕获模板后再回到考试页作答。', 7000); return; } if (nextMode === AI_MODE_CHATGPT_TOKEN) { renderExamPagePanel(); toast('已切换到 ChatGPT 手动模式。点击“打开ChatGPT”提问,拿到 JSON 后再点“导入答案”。', 7000); return; } const apiUrl = prompt('请输入 AI 接口 URL', current.apiUrl || DEEPSEEK_API_URL); if (apiUrl == null) return; const apiKey = prompt( `请输入 AI API Key\n当前:${current.apiKey ? maskKey(current.apiKey) : '未设置'}`, current.apiKey || '' ); if (apiKey == null) return; const model = prompt('请输入模型名称', current.model || DEEPSEEK_MODEL); if (model == null) return; const next = { apiUrl: apiUrl.trim(), apiKey: apiKey.trim(), model: model.trim() }; if (!next.apiUrl || !next.apiKey || !next.model) { toast('URL、API Key、模型名称都不能为空', 5000); return; } GM_setValue('aiApiUrl', next.apiUrl); GM_setValue('aiApiKey', next.apiKey); GM_setValue('aiModel', next.model); renderExamPagePanel(); toast(`AI 配置已保存:${next.model} / ${maskKey(next.apiKey)}`); } function getBridgeTemplate(providerKey = BRIDGE_PROVIDER_KEY) { return safeParse(GM_getValue(templateKeyFor(providerKey), '')); } function saveBridgeTemplate(providerKey, template) { GM_setValue(templateKeyFor(providerKey), JSON.stringify(template)); } function deleteBridgeTemplate(providerKey = BRIDGE_PROVIDER_KEY) { GM_deleteValue(templateKeyFor(providerKey)); } function getBridgeResult() { return safeParse(GM_getValue(BRIDGE_RESULT_KEY, '')); } function setBridgeResult(result) { GM_setValue(BRIDGE_RESULT_KEY, JSON.stringify({ ...result, finished_at: nowIso() })); } function clearBridgeResult() { GM_deleteValue(BRIDGE_RESULT_KEY); } function setChatGptDebug(info) { GM_setValue(CHATGPT_DEBUG_KEY, JSON.stringify({ ...info, updated_at: nowIso() })); } function getChatGptDebug() { return safeParse(GM_getValue(CHATGPT_DEBUG_KEY, '')); } function setBridgeOnline(providerKey, online) { GM_setValue(stateKeyFor(providerKey), JSON.stringify({ online: !!online, updated_at: nowIso() })); } function getBridgeState(providerKey = BRIDGE_PROVIDER_KEY) { return safeParse(GM_getValue(stateKeyFor(providerKey), '')); } function gmRequest(method, url, options = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, headers: options.headers || {}, data: options.data, responseType: options.responseType || 'text', timeout: options.timeout || 60000, withCredentials: options.withCredentials ?? true, onload: resolve, onerror: () => reject(new Error(`${method} ${url} failed`)), ontimeout: () => reject(new Error(`${method} ${url} timeout`)) }); }); } async function fetchJson(url) { const resp = await fetch(url, { credentials: 'include' }); if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); return await resp.json(); } function getExamIdFromUrl() { const u = new URL(location.href); const fromQuery = u.searchParams.get('exam_id'); if (fromQuery) return fromQuery; const m1 = u.pathname.match(/\/exam\/(\d+)/); if (m1) return m1[1]; const m2 = u.pathname.match(/\/result\/(\d+)/); if (m2) return m2[1]; const m3 = u.href.match(/exam_id=(\d+)/); return m3 ? m3[1] : null; } async function fetchResultsWithFallback(examId) { const urls = [ `${HOST_EXAM}/exam_room/cache_results?exam_id=${examId}`, `${HOST_EXAM}/exam_room/problem_results?exam_id=${examId}` ]; const errors = []; for (const url of urls) { try { const data = await fetchJson(url); if (data?.errcode === 0) return data; errors.push(`${url}: ${data?.errmsg || 'unknown error'}`); } catch (err) { errors.push(`${url}: ${err?.message || err}`); } } throw new Error(`结果接口都失败了:${errors.join(' | ')}`); } function extractAnsweredProblemIds(resultsData) { const list = resultsData?.problem_results || resultsData?.cache_results || resultsData?.results || []; return list .filter(item => { const result = item?.result; if (Array.isArray(result)) return result.length > 0; if (result && typeof result === 'object') { const content = String(result.content || '').trim(); const files = Array.isArray(result.attachments?.filelist) ? result.attachments.filelist : []; return !!content || files.length > 0; } return false; }) .map(item => Number(item.problem_id)) .filter(Number.isFinite); } function normalizeExam(coverData, paperData, resultsData, examId) { const resultList = resultsData?.problem_results || resultsData?.cache_results || resultsData?.results || []; const resultMap = new Map(resultList.map(item => [String(item.problem_id), item])); const questions = (paperData?.problems || []).map((problem, idx) => { const pid = String(problem.problem_id ?? problem.ProblemID ?? ''); const resultItem = resultMap.get(pid) || {}; return { index: idx + 1, problem_id: pid, type: problem.Type || '', type_text: problem.TypeText || problem.Type || '', question: htmlToText(problem.Body || ''), body_html: problem.Body || '', options: (problem.Options || []).map(option => ({ key: String(option.key || ''), text: htmlToText(option.value || '') })), allow_results: Array.isArray(problem.AllowResults) ? problem.AllowResults : [], blank_count: Array.isArray(problem.Blanks) ? problem.Blanks.length : 0, my_result_raw: resultItem.result, my_answer: Array.isArray(resultItem.result) ? resultItem.result : [], score: problem.Score ?? problem.score ?? null }; }); return { exam_id: String(examId), title: coverData?.title || paperData?.title || '', show_answer: !!coverData?.show_answer, answered_problem_ids: extractAnsweredProblemIds(resultsData), questions }; } async function getExamData() { const examId = getExamIdFromUrl(); if (!examId) throw new Error('当前页面未识别出 exam_id'); const cover = await fetchJson(`${HOST_EXAM}/exam_room/cover?exam_id=${examId}`); if (cover?.errcode !== 0) throw new Error(`cover err: ${cover?.errmsg || 'unknown'}`); const paper = await fetchJson(`${HOST_EXAM}/exam_room/show_paper?exam_id=${examId}`); if (paper?.errcode !== 0) throw new Error(`show_paper err: ${paper?.errmsg || 'unknown'}`); const results = await fetchResultsWithFallback(examId); return { examId: String(examId), normalized: normalizeExam(cover.data, paper.data, results.data, examId) }; } function buildAiQuestionsPayload(questions) { return questions.map(q => ({ index: q.index, problem_id: q.problem_id, type: q.type, type_text: q.type_text, question: q.question, options: q.options, blank_count: q.blank_count, allow_results: q.allow_results })); } function buildAiPromptPayload(exam, selectedQuestions) { return { exam_id: exam.exam_id, title: exam.title, question_count: selectedQuestions.length, questions: buildAiQuestionsPayload(selectedQuestions) }; } function buildStructuredPrompt(exam, selectedQuestions) { return [ '你是考试答题助手。', '只针对用户给出的题目返回结构化 JSON,不要输出额外说明。', '返回格式必须是:{"answers":[...]}。', '每个 answer 都必须包含 index、problem_id、type、confidence。', '单选题、判断题使用 answer_keys 数组,例如 ["A"]。', '多选题使用 answer_keys 数组,例如 ["A","C"]。', '填空题使用 blank_answers 数组。', '主观题使用 answer_text 字段,给出可直接提交的纯文本答案,不要包含 HTML。', '如果把握不高,也要给出你认为最可能的答案,并降低 confidence。', '', JSON.stringify(buildAiPromptPayload(exam, selectedQuestions)) ].join('\n'); } function isDoubaoChatTemplate(method, url, headers, bodyText) { if (String(method || 'GET').toUpperCase() !== 'POST') return false; const urlText = String(url || ''); if (!/\/chat\/completion(?:[/?]|$)/i.test(urlText)) return false; const contentType = String(headers?.['content-type'] || ''); if (!/json/i.test(contentType)) return false; if (!bodyText || bodyText.length < 8) return false; return /"messages"\s*:/.test(bodyText); } function isChatGptChatTemplate(method, url, headers, bodyText) { if (String(method || 'GET').toUpperCase() !== 'POST') return false; const urlText = String(url || ''); if (!/\/backend-api(?:\/f)?\/conversation(?:[/?]|$)/i.test(urlText)) return false; const contentType = String(headers?.['content-type'] || ''); if (!/json/i.test(contentType)) return false; if (!bodyText || bodyText.length < 8) return false; return /"messages"\s*:/.test(bodyText) && /"action"\s*:\s*"next"/.test(bodyText); } function sanitizeBridgeHeaders(headers) { const blocked = new Set(['content-length', 'host', 'origin', 'referer', 'cookie']); const out = {}; Object.keys(headers || {}).forEach(key => { if (!blocked.has(String(key).toLowerCase())) out[key] = headers[key]; }); if (!out.accept) out.accept = 'application/json, text/event-stream'; if (!out['content-type']) out['content-type'] = 'application/json'; return out; } function injectDoubaoCaptureScript() { if (window.__yktAiDoubaoCaptureInjected__) return; window.__yktAiDoubaoCaptureInjected__ = true; const script = document.createElement('script'); script.textContent = ` (() => { if (window.__yktAiDoubaoCaptureHooked__) return; window.__yktAiDoubaoCaptureHooked__ = true; const EVENT = ${JSON.stringify(PAGE_CAPTURE_EVENT)}; function normalizeHeaders(headersLike) { const out = {}; if (!headersLike) return out; if (headersLike instanceof Headers) { headersLike.forEach((value, key) => { out[String(key).toLowerCase()] = String(value); }); return out; } if (Array.isArray(headersLike)) { headersLike.forEach(([key, value]) => { out[String(key).toLowerCase()] = String(value); }); return out; } Object.keys(headersLike).forEach(key => { out[String(key).toLowerCase()] = String(headersLike[key]); }); return out; } function isChatTemplate(method, url, headers, bodyText) { if (String(method || 'GET').toUpperCase() !== 'POST') return false; const urlText = String(url || ''); if (!/\\/chat\\/completion(?:[/?]|$)/i.test(urlText)) return false; const contentType = String(headers['content-type'] || ''); if (!/json/i.test(contentType)) return false; if (!bodyText || bodyText.length < 8) return false; return /"messages"\\s*:/.test(bodyText); } function emit(payload) { window.postMessage({ source: EVENT, payload }, '*'); } function logCandidate(tag, method, url, bodyText) { const payload = { tag, method, url, bodyLength: String(bodyText || '').length, hasMessages: /"messages"\\s*:/.test(String(bodyText || '')), hasActionNext: /"action"\\s*:\\s*"next"/.test(String(bodyText || '')) }; console.log('[ykt-ai-chatgpt] candidate', payload); window.postMessage({ source: EVENT, payload: { debug: payload } }, '*'); } async function getBodyText(input, init) { if (typeof init?.body === 'string') return init.body; if (input && typeof input.clone === 'function') { try { return await input.clone().text(); } catch {} } return ''; } const rawFetch = window.fetch; window.fetch = async function(input, init = {}) { const url = typeof input === 'string' ? input : input?.url || ''; const method = init?.method || input?.method || 'GET'; const headers = normalizeHeaders(init?.headers || input?.headers); const bodyText = await getBodyText(input, init); const resp = await rawFetch.apply(this, arguments); try { if (resp.ok && isChatTemplate(method, url, headers, bodyText)) { emit({ source: 'page-fetch', request: { method: 'POST', url, headers, body: bodyText } }); } } catch (err) { console.warn('[ykt-ai-doubao] fetch capture failed', err); } return resp; }; })(); `; document.documentElement.appendChild(script); script.remove(); } function injectChatGptCaptureScript() { if (window.__yktAiChatGptCaptureInjected__) return; window.__yktAiChatGptCaptureInjected__ = true; const script = document.createElement('script'); script.textContent = ` (() => { if (window.__yktAiChatGptCaptureHooked__) return; window.__yktAiChatGptCaptureHooked__ = true; const EVENT = ${JSON.stringify(CHATGPT_PAGE_CAPTURE_EVENT)}; window.postMessage({ source: EVENT, payload: { debug: { stage: 'hook-installed', tag: 'bootstrap', method: 'GET', url: location.href, bodyLength: 0, hasMessages: false, hasActionNext: false } } }, '*'); function normalizeHeaders(headersLike) { const out = {}; if (!headersLike) return out; if (headersLike instanceof Headers) { headersLike.forEach((value, key) => { out[String(key).toLowerCase()] = String(value); }); return out; } if (Array.isArray(headersLike)) { headersLike.forEach(([key, value]) => { out[String(key).toLowerCase()] = String(value); }); return out; } Object.keys(headersLike).forEach(key => { out[String(key).toLowerCase()] = String(headersLike[key]); }); return out; } function isChatTemplate(method, url, headers, bodyText) { if (String(method || 'GET').toUpperCase() !== 'POST') return false; const urlText = String(url || ''); if (!/\\/backend-api(?:\\/f)?\\/conversation(?:[/?]|$)/i.test(urlText)) return false; const contentType = String(headers['content-type'] || ''); if (!/json/i.test(contentType)) return false; if (!bodyText || bodyText.length < 8) return false; return /"messages"\\s*:/.test(bodyText) && /"action"\\s*:\\s*"next"/.test(bodyText); } function emit(payload) { window.postMessage({ source: EVENT, payload }, '*'); } async function getBodyText(input, init) { if (typeof init?.body === 'string') return init.body; if (input && typeof input.clone === 'function') { try { return await input.clone().text(); } catch {} } return ''; } const rawFetch = window.fetch; window.fetch = async function(input, init = {}) { const url = typeof input === 'string' ? input : input?.url || ''; const method = init?.method || input?.method || 'GET'; const headers = normalizeHeaders(init?.headers || input?.headers); const bodyText = await getBodyText(input, init); if (/\\/backend-api(?:\\/f)?\\/conversation/i.test(String(url || ''))) { logCandidate('fetch', method, url, bodyText); } const resp = await rawFetch.apply(this, arguments); try { if (resp.ok && isChatTemplate(method, url, headers, bodyText)) { emit({ source: 'page-fetch', request: { method: 'POST', url, headers, body: bodyText } }); } } catch (err) { console.warn('[ykt-ai-chatgpt] fetch capture failed', err); } return resp; }; const rawOpen = XMLHttpRequest.prototype.open; const rawSend = XMLHttpRequest.prototype.send; const rawSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function(method, url) { this.__yktAiChatGptMethod__ = method; this.__yktAiChatGptUrl__ = url; this.__yktAiChatGptHeaders__ = {}; return rawOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function(key, value) { this.__yktAiChatGptHeaders__ = this.__yktAiChatGptHeaders__ || {}; this.__yktAiChatGptHeaders__[String(key).toLowerCase()] = String(value); return rawSetHeader.apply(this, arguments); }; XMLHttpRequest.prototype.send = function(body) { const method = this.__yktAiChatGptMethod__ || 'GET'; const url = this.__yktAiChatGptUrl__ || ''; const headers = this.__yktAiChatGptHeaders__ || {}; const bodyText = typeof body === 'string' ? body : ''; if (/\\/backend-api(?:\\/f)?\\/conversation/i.test(String(url || ''))) { logCandidate('xhr', method, url, bodyText); } this.addEventListener('load', () => { try { if (this.status >= 200 && this.status < 300 && isChatTemplate(method, url, headers, bodyText)) { emit({ source: 'page-xhr', request: { method: 'POST', url, headers, body: bodyText } }); } } catch (err) { console.warn('[ykt-ai-chatgpt] xhr capture failed', err); } }); return rawSend.apply(this, arguments); }; })(); `; document.documentElement.appendChild(script); script.remove(); } function mergeStreamText(current, chunk) { const next = String(chunk || ''); if (!next) return current; if (!current) return next; if (current.endsWith(next)) return current; if (next.startsWith(current)) return next; const maxOverlap = Math.min(current.length, next.length); for (let size = maxOverlap; size > 0; size--) { if (current.endsWith(next.slice(0, size))) { return current + next.slice(size); } } return current + next; } function extractTextFromBlocks(blocks) { if (!Array.isArray(blocks)) return ''; let out = ''; for (const block of blocks) { const value = block?.content?.text_block?.text; if (typeof value === 'string' && value) { out = mergeStreamText(out, value); } } return out; } function decodeLooseJsonString(value) { const text = String(value || ''); let out = ''; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch !== '\\') { out += ch; continue; } const next = text[i + 1]; if (next == null) { out += '\\'; continue; } i += 1; if (next === 'n') out += '\n'; else if (next === 'r') out += '\r'; else if (next === 't') out += '\t'; else if (next === '"' || next === '\\' || next === '/') out += next; else out += next; } return out; } function extractLooseField(rawData, fieldName) { const source = String(rawData || ''); const marker = `"${fieldName}":"`; const start = source.indexOf(marker); if (start < 0) return ''; let i = start + marker.length; let escaped = false; let out = ''; while (i < source.length) { const ch = source[i]; if (escaped) { out += `\\${ch}`; escaped = false; i += 1; continue; } if (ch === '\\') { escaped = true; i += 1; continue; } if (ch === '"') break; out += ch; i += 1; } return decodeLooseJsonString(out); } function parseDoubaoSseText(rawText) { const text = String(rawText || ''); const chunks = text.split(/\n\s*\n/); let assembledText = ''; const debugEvents = []; for (const chunk of chunks) { const eventMatch = chunk.match(/(?:^|\n)event:\s*([^\n]+)/); const dataMatch = chunk.match(/(?:^|\n)data:\s*([\s\S]*)$/); if (!dataMatch) continue; const eventName = String(eventMatch?.[1] || '').trim(); const rawData = dataMatch[1].trim(); const payload = safeParse(rawData); if (eventName === 'STREAM_MSG_NOTIFY') { const textPart = extractTextFromBlocks(payload?.content?.content_block); if (textPart) { assembledText = mergeStreamText(assembledText, textPart); debugEvents.push({ event: eventName, text: textPart }); } const ttsPart = payload?.content?.tts_content; if (typeof ttsPart === 'string' && ttsPart) { assembledText = mergeStreamText(assembledText, ttsPart); debugEvents.push({ event: `${eventName}:tts`, text: ttsPart }); } continue; } if (eventName === 'CHUNK_DELTA') { const deltaPart = (payload && typeof payload.text === 'string') ? payload.text : extractLooseField(rawData, 'text'); if (deltaPart) { assembledText = mergeStreamText(assembledText, deltaPart); debugEvents.push({ event: eventName, text: deltaPart }); } continue; } if (eventName === 'STREAM_CHUNK') { const patchOps = Array.isArray(payload?.patch_op) ? payload.patch_op : []; if (patchOps.length) { for (const item of patchOps) { const textPart = extractTextFromBlocks(item?.patch_value?.content_block); if (textPart) { assembledText = mergeStreamText(assembledText, textPart); debugEvents.push({ event: eventName, text: textPart }); } const ttsPart = item?.patch_value?.tts_content; if (typeof ttsPart === 'string' && ttsPart) { assembledText = mergeStreamText(assembledText, ttsPart); debugEvents.push({ event: `${eventName}:tts`, text: ttsPart }); } } } else { const looseTts = extractLooseField(rawData, 'tts_content'); if (looseTts) { assembledText = mergeStreamText(assembledText, looseTts); debugEvents.push({ event: `${eventName}:tts`, text: looseTts }); } } continue; } if (!payload || typeof payload !== 'object') continue; if (eventName === 'FULL_MSG_NOTIFY') { if (Number(payload?.message?.user_type) === 1) continue; const textPart = extractTextFromBlocks(payload?.message?.content_block); if (textPart) { assembledText = mergeStreamText(assembledText, textPart); debugEvents.push({ event: eventName, text: textPart }); } } } return { text: String(assembledText || '').trim(), events: debugEvents }; } function parseChatGptSseText(rawText) { const text = String(rawText || ''); const chunks = text.split(/\n\s*\n/); let assembledText = ''; const debugEvents = []; for (const chunk of chunks) { const dataMatches = [...chunk.matchAll(/(?:^|\n)data:\s*([^\n]*)/g)]; for (const match of dataMatches) { const rawData = String(match[1] || '').trim(); if (!rawData || rawData === '[DONE]' || rawData === '"v1"') continue; const payload = safeParse(rawData); if (!payload || typeof payload !== 'object') continue; const ops = Array.isArray(payload?.v) ? payload.v : (payload?.o === 'patch' && Array.isArray(payload.v) ? payload.v : []); if (!ops.length) continue; for (const op of ops) { const path = String(op?.p || ''); const opType = String(op?.o || ''); const value = typeof op?.v === 'string' ? op.v : ''; if (!/\/message\/content\/parts\/0$/i.test(path)) continue; if (!value) continue; if (opType === 'append') { assembledText = mergeStreamText(assembledText, value); debugEvents.push({ event: 'delta:append', text: value }); } else if (opType === 'replace') { assembledText = value; debugEvents.push({ event: 'delta:replace', text: value }); } } } } return { text: String(assembledText || '').trim(), events: debugEvents }; } function replacePromptInDoubaoBody(rawBody, promptText) { const body = safeParse(rawBody); if (!body) throw new Error('豆包模板 body 不是有效 JSON'); const prompt = String(promptText || '').trim(); if (!prompt) throw new Error('prompt 不能为空'); const messages = Array.isArray(body.messages) ? body.messages : []; const target = messages[messages.length - 1]; if (!target) throw new Error('豆包模板缺少 messages'); const blocks = Array.isArray(target.content_block) ? target.content_block : []; const textBlock = blocks.find(item => item?.content?.text_block?.text != null); if (!textBlock) throw new Error('豆包模板缺少 text_block.text'); textBlock.content.text_block.text = prompt; target.local_message_id = crypto.randomUUID(); if (body.option && typeof body.option === 'object') { body.option.create_time_ms = Date.now(); body.option.unique_key = crypto.randomUUID(); body.option.start_seq = 0; } if (body.client_meta && typeof body.client_meta === 'object') { body.client_meta.last_message_index = null; } return JSON.stringify(body); } function replacePromptInChatGptBody(rawBody, promptText) { const body = safeParse(rawBody); if (!body) throw new Error('ChatGPT 模板 body 不是有效 JSON'); const prompt = String(promptText || '').trim(); if (!prompt) throw new Error('prompt 不能为空'); const messages = Array.isArray(body.messages) ? body.messages : []; const userMessage = [...messages].reverse().find(item => item?.author?.role === 'user') || messages[messages.length - 1]; if (!userMessage?.content?.parts?.length) { throw new Error('ChatGPT 模板缺少 user content.parts'); } userMessage.id = crypto.randomUUID(); userMessage.content.parts = [prompt]; if (userMessage.metadata && typeof userMessage.metadata === 'object') { userMessage.metadata.request_id = crypto.randomUUID(); userMessage.metadata.turn_exchange_id = crypto.randomUUID(); userMessage.metadata.turn_trace_id = crypto.randomUUID(); } if (typeof userMessage.create_time === 'number') { userMessage.create_time = Date.now() / 1000; } if (typeof body.parent_message_id === 'string' && !body.parent_message_id) { body.parent_message_id = 'client-created-root'; } body.action = 'next'; return JSON.stringify(body); } async function replayDoubaoTemplate(promptText) { const template = getBridgeTemplate(BRIDGE_PROVIDER_KEY); if (!template?.request?.url) throw new Error('当前没有豆包模板,请先去豆包页发送一条成功消息'); const body = replacePromptInDoubaoBody(template.request.body, promptText); const requestUrl = new URL(template.request.url, location.origin).toString(); const resp = await fetch(requestUrl, { method: template.request.method || 'POST', credentials: 'include', headers: template.request.headers || {}, body }); const text = await resp.text(); if (!resp.ok) throw new Error(`豆包 HTTP ${resp.status}: ${text.slice(0, 500)}`); const parsed = parseDoubaoSseText(text); const answerText = parsed.text; if (!answerText) throw new Error('豆包返回成功,但未解析出回答文本'); return { status: resp.status, request_url: requestUrl, answer_text: answerText, sse_events: parsed.events, body_preview: text.slice(0, 20000), raw_sse: text }; } async function replayChatGptTemplate(promptText) { const template = getBridgeTemplate(CHATGPT_PROVIDER_KEY); if (!template?.request?.url) throw new Error('当前没有 ChatGPT 模板,请先去 chatgpt.com 页面发送一条成功消息'); const body = replacePromptInChatGptBody(template.request.body, promptText); const requestUrl = new URL(template.request.url, location.origin).toString(); const resp = await fetch(requestUrl, { method: template.request.method || 'POST', credentials: 'include', headers: template.request.headers || {}, body }); const text = await resp.text(); if (!resp.ok) throw new Error(`ChatGPT HTTP ${resp.status}: ${text.slice(0, 500)}`); const parsed = parseChatGptSseText(text); const answerText = parsed.text; if (!answerText) throw new Error('ChatGPT 返回成功,但未解析出回答文本'); return { status: resp.status, request_url: requestUrl, answer_text: answerText, sse_events: parsed.events, body_preview: text.slice(0, 20000) }; } async function handleDoubaoBridgeJob(job) { if (!job || job.provider_key !== BRIDGE_PROVIDER_KEY) return; try { const result = await replayDoubaoTemplate(job.prompt); setBridgeResult({ job_id: job.job_id, ok: true, provider_key: BRIDGE_PROVIDER_KEY, provider_name: BRIDGE_PROVIDER_NAME, result }); } catch (err) { console.error(err); setBridgeResult({ job_id: job.job_id, ok: false, provider_key: BRIDGE_PROVIDER_KEY, provider_name: BRIDGE_PROVIDER_NAME, error: err?.message || String(err) }); } } async function handleChatGptBridgeJob(job) { if (!job || job.provider_key !== CHATGPT_PROVIDER_KEY) return; try { const result = await replayChatGptTemplate(job.prompt); setBridgeResult({ job_id: job.job_id, ok: true, provider_key: CHATGPT_PROVIDER_KEY, provider_name: CHATGPT_PROVIDER_NAME, result }); } catch (err) { console.error(err); setBridgeResult({ job_id: job.job_id, ok: false, provider_key: CHATGPT_PROVIDER_KEY, provider_name: CHATGPT_PROVIDER_NAME, error: err?.message || String(err) }); } } async function sendDoubaoBridgeJob(promptText) { clearBridgeResult(); const job = { job_id: `job_${Date.now()}`, provider_key: BRIDGE_PROVIDER_KEY, prompt: promptText, created_at: nowIso() }; GM_setValue(BRIDGE_JOB_KEY, JSON.stringify(job)); const started = Date.now(); while (Date.now() - started < 90000) { await sleep(800); const result = getBridgeResult(); if (result && result.job_id === job.job_id) return result; } throw new Error('等待豆包桥接结果超时'); } async function askApiForSubset(exam, selectedQuestions) { const config = ensureAiConfig(); const payload = { model: config.model, temperature: 0.2, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: [ '你是考试答题助手。', '只针对用户给出的题目返回结构化 JSON,不要输出额外说明。', '返回格式必须是:{"answers":[...]}。', '每个 answer 都必须包含 index、problem_id、type、confidence。', '单选题、判断题使用 answer_keys 数组,例如 ["A"]。', '多选题使用 answer_keys 数组,例如 ["A","C"]。', '填空题使用 blank_answers 数组。', '主观题使用 answer_text 字段,给出可直接提交的纯文本答案,不要包含 HTML。', '如果把握不高,也要给出你认为最可能的答案,并降低 confidence。' ].join('\n') }, { role: 'user', content: JSON.stringify(buildAiPromptPayload(exam, selectedQuestions)) } ] }; const resp = await gmRequest('POST', config.apiUrl, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` }, data: JSON.stringify(payload), timeout: 120000, withCredentials: false }); if (resp.status < 200 || resp.status >= 300) { throw new Error(`AI HTTP ${resp.status}: ${(resp.responseText || '').trim()}`); } const data = JSON.parse(resp.responseText); const content = data?.choices?.[0]?.message?.content || ''; const parsed = parseJsonFromText(content); const answers = Array.isArray(parsed?.answers) ? parsed.answers : []; if (!answers.length) throw new Error('AI 未返回 answers 数组'); console.log('AI answers', answers); const output = { exported_at: new Date().toISOString(), exam_id: exam.exam_id, mode: config.mode, model: config.model, selected_indexes: selectedQuestions.map(q => q.index), answers }; GM_setValue('lastDeepSeekSubsetAnswers', JSON.stringify(output)); return output; } async function askDoubaoWebForSubset(exam, selectedQuestions) { const promptText = buildStructuredPrompt(exam, selectedQuestions); const bridgeResult = await sendDoubaoBridgeJob(promptText); if (!bridgeResult?.ok) { throw new Error(bridgeResult?.error || '豆包网页登录态桥接失败'); } const content = bridgeResult?.result?.answer_text || ''; const parsed = parseJsonFromText(content); const answers = Array.isArray(parsed?.answers) ? parsed.answers : []; if (!answers.length) throw new Error('豆包网页登录态未返回 answers 数组'); console.log('Doubao answers', answers); return { exported_at: new Date().toISOString(), exam_id: exam.exam_id, mode: AI_MODE_DOUBAO_WEB, model: BRIDGE_PROVIDER_NAME, selected_indexes: selectedQuestions.map(q => q.index), answers, bridge_result: bridgeResult.result }; } function buildChatGptConversationPayload(promptText, model) { return { action: 'next', messages: [ { id: crypto.randomUUID(), author: { role: 'user' }, content: { content_type: 'text', parts: [promptText] }, metadata: {}, recipient: 'all' } ], parent_message_id: 'client-created-root', model: model || CHATGPT_MODEL, conversation_mode: { kind: 'primary_assistant' }, supported_encodings: ['v1'], supports_buffering: true, timezone: 'Asia/Shanghai', timezone_offset_min: -480, thinking_effort: 'standard' }; } function buildChatGptPayloadFromTemplate(rawBody, promptText, model) { let body; try { body = JSON.parse(String(rawBody || '{}')); } catch (err) { throw new Error(`ChatGPT 请求画像中的 body 不是合法 JSON:${err?.message || err}`); } body.action = 'next'; body.model = model || body.model || CHATGPT_MODEL; body.supported_encodings = Array.isArray(body.supported_encodings) && body.supported_encodings.length ? body.supported_encodings : ['v1']; body.supports_buffering = true; body.timezone = body.timezone || 'Asia/Shanghai'; body.timezone_offset_min = Number.isFinite(Number(body.timezone_offset_min)) ? Number(body.timezone_offset_min) : -480; body.parent_message_id = body.parent_message_id || 'client-created-root'; if (!Array.isArray(body.messages) || !body.messages.length) { return buildChatGptConversationPayload(promptText, model); } const userMessage = [...body.messages].reverse().find(item => item?.author?.role === 'user') || body.messages[body.messages.length - 1]; if (!userMessage.content || userMessage.content.content_type !== 'text') { userMessage.content = { content_type: 'text', parts: [promptText] }; } else { userMessage.content.parts = [promptText]; } userMessage.id = crypto.randomUUID(); userMessage.create_time = Date.now() / 1000; userMessage.metadata = userMessage.metadata && typeof userMessage.metadata === 'object' ? userMessage.metadata : {}; if (userMessage.metadata.request_id) userMessage.metadata.request_id = crypto.randomUUID(); if (userMessage.metadata.turn_exchange_id) userMessage.metadata.turn_exchange_id = crypto.randomUUID(); if (userMessage.metadata.turn_trace_id) userMessage.metadata.turn_trace_id = crypto.randomUUID(); return body; } function buildChatGptHeaders(config, profile) { const headers = { ...(profile?.headers || {}) }; const auth = normalizeChatGptAccessToken(config.chatgptAccessToken || headers.authorization || ''); if (auth) headers.authorization = `Bearer ${auth}`; headers.accept = headers.accept || 'text/event-stream'; headers['content-type'] = 'application/json'; headers['accept-language'] = headers['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8'; headers.origin = headers.origin || 'https://chatgpt.com'; headers.referer = headers.referer || 'https://chatgpt.com/'; headers['x-openai-target-path'] = headers['x-openai-target-path'] || '/backend-api/f/conversation'; headers['x-openai-target-route'] = headers['x-openai-target-route'] || '/backend-api/f/conversation'; return headers; } async function fetchChatGptSessionAccessToken() { const resp = await fetch('/api/auth/session', { credentials: 'include', cache: 'no-store' }); if (!resp.ok) { throw new Error(`/api/auth/session HTTP ${resp.status}`); } const data = await resp.json(); const accessToken = normalizeChatGptAccessToken(data?.accessToken || ''); if (!accessToken) { throw new Error('当前会话未返回 accessToken'); } return accessToken; } async function syncChatGptSessionToken(interactive = true) { try { const accessToken = await fetchChatGptSessionAccessToken(); GM_setValue('chatgptAccessToken', accessToken); if (interactive) { toast(`已同步 ChatGPT AccessToken:${maskKey(accessToken)}`, 5000); } return accessToken; } catch (err) { if (interactive) { toast(`同步 ChatGPT 会话失败:${err?.message || err}`, 6000); } throw err; } } async function askChatGptTokenForSubset(exam, selectedQuestions) { const config = ensureAiConfig(); const promptText = buildStructuredPrompt(exam, selectedQuestions); const profile = config.chatgptCurlProfile || getChatGptCurlProfile(); const payload = buildChatGptPayloadFromTemplate(profile?.body, promptText, config.chatgptModel); const headers = buildChatGptHeaders(config, profile); if (profile?.cookies) { headers.cookie = profile.cookies; } const resp = await gmRequest('POST', profile?.url || CHATGPT_API_URL, { headers, data: JSON.stringify(payload), timeout: 120000, withCredentials: true }); if (resp.status < 200 || resp.status >= 300) { throw new Error(`ChatGPT HTTP ${resp.status}: ${(resp.responseText || '').trim()}`); } const parsedStream = parseChatGptSseText(resp.responseText || ''); const content = parsedStream?.text || ''; const parsed = parseJsonFromText(content); const answers = Array.isArray(parsed?.answers) ? parsed.answers : []; if (!answers.length) throw new Error('ChatGPT AccessToken 未返回 answers 数组'); console.log('ChatGPT answers', answers); return { exported_at: new Date().toISOString(), exam_id: exam.exam_id, mode: AI_MODE_CHATGPT_TOKEN, model: config.chatgptModel, selected_indexes: selectedQuestions.map(q => q.index), answers }; } async function askAiForSubset(exam, selectedQuestions) { const config = ensureAiConfig(); if (config.mode === AI_MODE_DOUBAO_WEB) { return await askDoubaoWebForSubset(exam, selectedQuestions); } return await askApiForSubset(exam, selectedQuestions); } function parseIndexInput(raw, max) { const text = String(raw || '').trim(); if (!text) return []; const out = new Set(); for (const chunk of text.split(',')) { const part = chunk.trim(); if (!part) continue; if (/^\d+$/.test(part)) { const n = Number(part); if (n >= 1 && n <= max) out.add(n); continue; } const range = part.match(/^(\d+)\s*-\s*(\d+)$/); if (range) { let start = Number(range[1]); let end = Number(range[2]); if (start > end) [start, end] = [end, start]; for (let i = start; i <= end; i++) { if (i >= 1 && i <= max) out.add(i); } } } return [...out].sort((a, b) => a - b); } function findQuestionsByIndexes(exam, indexes) { const questionMap = new Map(exam.questions.map(q => [q.index, q])); return indexes.map(index => questionMap.get(index)).filter(Boolean); } function buildSubmissionResult(question, answer) { const type = normalizeText(answer?.type || question.type || question.type_text); if (type.includes('shortanswer') || type.includes('主观')) { const text = String(answer?.answer_text || '').trim(); if (!text) throw new Error(`第 ${question.index} 题缺少主观题 answer_text`); const html = text .split(/\n+/) .map(line => line.trim()) .filter(Boolean) .map(line => `

${escapeHtml(line)}

`) .join('') || '

'; return { content: `
${html}
`, attachments: { filelist: [] } }; } if (Array.isArray(answer?.blank_answers) && answer.blank_answers.length) { return answer.blank_answers.map(item => String(item ?? '')); } if (Array.isArray(answer?.answer_keys) && answer.answer_keys.length) { return answer.answer_keys.map(item => String(item)); } throw new Error(`第 ${question.index} 题缺少可提交答案`); } async function submitAnswer(examId, answeredProblemIds, question, answer) { const payload = { exam_id: Number(examId), record: [...new Set(Array.from(answeredProblemIds).map(Number).filter(Number.isFinite))], results: [ { problem_id: Number(question.problem_id), result: buildSubmissionResult(question, answer), time: Date.now() } ] }; const resp = await fetch(`${HOST_EXAM}/exam_room/answer_problem`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) throw new Error(`answer_problem HTTP ${resp.status}`); const data = await resp.json(); if (data?.errcode !== 0) { throw new Error(`answer_problem err: ${data?.errmsg || 'unknown'}`); } answeredProblemIds.add(Number(question.problem_id)); } async function solveQuestions(indexes) { const { examId, normalized } = await getExamData(); const selectedQuestions = findQuestionsByIndexes(normalized, indexes); if (!selectedQuestions.length) throw new Error('没有选中有效题目'); const config = ensureAiConfig(); setStatus(`正在请求 AI:第 ${selectedQuestions.map(q => q.index).join(', ')} 题(${getAiModeLabel(config.mode)})`); const aiOutput = await askAiForSubset(normalized, selectedQuestions); const answerMap = new Map(aiOutput.answers.map(item => [String(item.problem_id), item])); const answeredProblemIds = new Set(normalized.answered_problem_ids); let success = 0; let failed = 0; const failureDetails = []; for (let i = 0; i < selectedQuestions.length; i++) { const question = selectedQuestions[i]; const answer = answerMap.get(String(question.problem_id)) || aiOutput.answers.find(item => Number(item.index) === question.index); if (!answer) { failed += 1; failureDetails.push(`第 ${question.index} 题:AI 未返回答案`); continue; } try { setStatus(`正在提交第 ${question.index} 题`); await submitAnswer(examId, answeredProblemIds, question, answer); success += 1; } catch (err) { failed += 1; const reason = err?.message || String(err); failureDetails.push(`第 ${question.index} 题:${reason}`); console.error('提交题目失败', { question, answer, err }); } if (i < selectedQuestions.length - 1) { const delayMs = getNextSubmitDelayMs(); setStatus(`第 ${question.index} 题已处理,等待 ${(delayMs / 1000).toFixed(1)} 秒后继续`); await sleep(delayMs); } } const summary = `本次完成:成功 ${success} 题,失败 ${failed} 题`; if (failureDetails.length) { const detailText = failureDetails.slice(0, 3).join(';'); setStatus(`${summary}。${detailText}`); toast(`${summary}。${detailText}`, 8000); } else { setStatus(summary); toast(summary, 5000); } refreshPageAfterDelay(1500); } async function solveSelectedQuestions() { try { const { normalized } = await getExamData(); const raw = prompt(`输入题号,支持 1,3,5-7。当前共 ${normalized.questions.length} 题。`, ''); if (raw == null) return; const indexes = parseIndexInput(raw, normalized.questions.length); if (!indexes.length) throw new Error('未解析出有效题号'); await solveQuestions(indexes); } catch (err) { console.error(err); setStatus(''); toast(`指定题作答失败:${err.message}`, 5000); } } async function solveAllQuestions() { try { const { normalized } = await getExamData(); const indexes = normalized.questions.map(q => q.index); if (!indexes.length) throw new Error('当前试卷没有可用题目'); await solveQuestions(indexes); } catch (err) { console.error(err); setStatus(''); toast(`全自动作答失败:${err.message}`, 5000); } } async function exportSelectedQuestionsJson() { try { const { normalized } = await getExamData(); const raw = prompt(`导出哪些题?输入题号,支持 1,3,5-7。当前共 ${normalized.questions.length} 题。`, ''); if (raw == null) return; const indexes = parseIndexInput(raw, normalized.questions.length); if (!indexes.length) throw new Error('未解析出有效题号'); const selectedQuestions = findQuestionsByIndexes(normalized, indexes); const payload = { exam_id: normalized.exam_id, title: normalized.title, selected_indexes: indexes, questions: buildAiQuestionsPayload(selectedQuestions) }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `selected_questions_${normalized.exam_id}_${indexes.join('_')}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); toast(`已导出 ${indexes.length} 题 JSON`); } catch (err) { console.error(err); setStatus(''); toast(`导出失败:${err.message}`, 5000); } } async function openChatGptWithSelectedPrompt() { try { const { normalized } = await getExamData(); const raw = prompt(`导出哪些题到 ChatGPT?输入题号,支持 1,3,5-7。当前共 ${normalized.questions.length} 题。`, ''); if (raw == null) return; const indexes = parseIndexInput(raw, normalized.questions.length); if (!indexes.length) throw new Error('未解析出有效题号'); const selectedQuestions = findQuestionsByIndexes(normalized, indexes); const promptText = buildStructuredPrompt(normalized, selectedQuestions); await navigator.clipboard.writeText(promptText); const encodedPrompt = encodeURIComponent(promptText); const canUseQuery = encodedPrompt.length <= 1800; const targetUrl = canUseQuery ? `https://chatgpt.com/?q=${encodedPrompt}` : 'https://chatgpt.com/'; if (canUseQuery) { setStatus(`已打开 ChatGPT 并附带 ${indexes.length} 题 Prompt。若未自动带入,可直接粘贴剪贴板内容。`); toast(`已打开 ChatGPT 并附带 ${indexes.length} 题 Prompt`, 5000); } else { setStatus(`题目内容较长,已复制 ${indexes.length} 题 Prompt 并打开 ChatGPT 首页。请在 ChatGPT 中直接粘贴。`); toast(`Prompt 过长,已复制到剪贴板并打开 ChatGPT 首页`, 5000); } window.open(targetUrl, '_blank', 'noopener'); } catch (err) { console.error(err); setStatus(''); toast(`打开 ChatGPT 失败:${err.message}`, 5000); } } async function importManualAnswersAndSubmit() { try { const { normalized } = await getExamData(); const raw = prompt('请粘贴 AI 返回的 JSON,格式必须是 {"answers":[...]}', ''); if (raw == null) return; const parsed = parseJsonFromText(raw); const answers = Array.isArray(parsed?.answers) ? parsed.answers : []; if (!answers.length) throw new Error('未解析到 answers 数组'); const indexes = [...new Set(answers.map(item => Number(item.index)).filter(Number.isFinite))]; const selectedQuestions = findQuestionsByIndexes(normalized, indexes); if (!selectedQuestions.length) throw new Error('导入答案里没有可匹配的题号'); const aiOutput = { exported_at: new Date().toISOString(), exam_id: normalized.exam_id, mode: 'manual_import', model: 'manual_import', selected_indexes: indexes, answers }; const answerMap = new Map(aiOutput.answers.map(item => [String(item.problem_id), item])); const answeredProblemIds = new Set(normalized.answered_problem_ids); let success = 0; let failed = 0; const failureDetails = []; for (let i = 0; i < selectedQuestions.length; i++) { const question = selectedQuestions[i]; const answer = answerMap.get(String(question.problem_id)) || aiOutput.answers.find(item => Number(item.index) === question.index); if (!answer) { failed += 1; failureDetails.push(`第${question.index}题:未找到对应答案`); continue; } try { setStatus(`正在提交第${question.index}题`); await submitAnswer(normalized.exam_id, answeredProblemIds, question, answer); success += 1; } catch (err) { failed += 1; failureDetails.push(`第${question.index}题:${err?.message || err}`); } if (i < selectedQuestions.length - 1) { const delayMs = getNextSubmitDelayMs(); setStatus(`第${question.index}题已处理,等待 ${(delayMs / 1000).toFixed(1)} 秒后继续`); await sleep(delayMs); } } const summary = `本次完成:成功 ${success} 题,失败 ${failed} 题`; if (failureDetails.length) { const detailText = failureDetails.slice(0, 3).join(';'); setStatus(`${summary}。${detailText}`); toast(`${summary}。${detailText}`, 8000); } else { setStatus(summary); toast(summary, 5000); } refreshPageAfterDelay(1500); } catch (err) { console.error(err); setStatus(''); toast(`导入答案失败:${err.message}`, 5000); } } function exportBridgeTemplate(providerKey = BRIDGE_PROVIDER_KEY, providerName = BRIDGE_PROVIDER_NAME) { const template = getBridgeTemplate(providerKey); if (!template) { toast(`当前还没有${providerName}模板`, 4000); return; } const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${providerKey}_template_${Date.now()}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); toast(`已导出${providerName}模板`); } function updateProviderStatus(providerKey, providerName) { const template = getBridgeTemplate(providerKey); setStatus(template ? `当前已有${providerName}模板:${template.request.url}` : `当前还没有${providerName}模板。请先手动发送一条成功消息。`); } function initDoubaoPage() { setBridgeOnline(BRIDGE_PROVIDER_KEY, true); window.addEventListener('message', event => { const data = event.data; if (!data || data.source !== PAGE_CAPTURE_EVENT || !data.payload?.request) return; const req = data.payload.request; if (!isDoubaoChatTemplate(req.method, req.url, req.headers || {}, req.body || '')) return; const template = { provider_key: BRIDGE_PROVIDER_KEY, provider_name: BRIDGE_PROVIDER_NAME, captured_at: nowIso(), page_url: location.href, source: data.payload.source || 'page-fetch', request: { method: 'POST', url: req.url, headers: sanitizeBridgeHeaders(req.headers || {}), body: req.body } }; saveBridgeTemplate(BRIDGE_PROVIDER_KEY, template); updateProviderStatus(BRIDGE_PROVIDER_KEY, BRIDGE_PROVIDER_NAME); toast('已捕获豆包成功请求模板', 3500); }); GM_addValueChangeListener(BRIDGE_JOB_KEY, async (_, __, newValue, remote) => { if (!remote || !newValue) return; const job = safeParse(newValue); await handleDoubaoBridgeJob(job); }); const panel = makePanel(); panel.querySelector('#__ykt_ai_subset_actions__').innerHTML = ''; panel.querySelector('#__ykt_ai_subset_actions__').appendChild(makeBtn('导出豆包模板', () => exportBridgeTemplate(BRIDGE_PROVIDER_KEY, BRIDGE_PROVIDER_NAME))); panel.querySelector('#__ykt_ai_subset_actions__').appendChild(makeBtn('清除豆包模板', () => { deleteBridgeTemplate(BRIDGE_PROVIDER_KEY); updateProviderStatus(BRIDGE_PROVIDER_KEY, BRIDGE_PROVIDER_NAME); toast('已清除豆包模板'); })); updateProviderStatus(BRIDGE_PROVIDER_KEY, BRIDGE_PROVIDER_NAME); } function initChatGptPage() { setBridgeOnline(CHATGPT_PROVIDER_KEY, true); setChatGptDebug({ stage: 'waiting-capture', tag: 'idle', method: '-', url: location.href, bodyLength: 0, hasMessages: false, hasActionNext: false }); window.addEventListener('message', event => { const data = event.data; if (!data || data.source !== CHATGPT_PAGE_CAPTURE_EVENT) return; if (data.payload?.debug) { const debug = data.payload.debug; setChatGptDebug(debug); const summary = `${debug.stage || 'candidate'} ${debug.tag} ${debug.method} ${debug.bodyLength}B / messages=${debug.hasMessages} / action=next=${debug.hasActionNext}`; setStatus(`ChatGPT 捕获调试:${summary}`); return; } if (!data.payload?.request) return; const req = data.payload.request; if (!isChatGptChatTemplate(req.method, req.url, req.headers || {}, req.body || '')) return; const template = { provider_key: CHATGPT_PROVIDER_KEY, provider_name: CHATGPT_PROVIDER_NAME, captured_at: nowIso(), page_url: location.href, source: data.payload.source || 'page-fetch', request: { method: 'POST', url: req.url, headers: sanitizeBridgeHeaders(req.headers || {}), body: req.body } }; saveBridgeTemplate(CHATGPT_PROVIDER_KEY, template); updateProviderStatus(CHATGPT_PROVIDER_KEY, CHATGPT_PROVIDER_NAME); toast('已捕获 ChatGPT 成功请求模板', 3500); }); GM_addValueChangeListener(BRIDGE_JOB_KEY, async (_, __, newValue, remote) => { if (!remote || !newValue) return; const job = safeParse(newValue); await handleChatGptBridgeJob(job); }); const panel = makePanel(); panel.querySelector('#__ykt_ai_subset_actions__').innerHTML = ''; panel.querySelector('#__ykt_ai_subset_actions__').appendChild(makeBtn('导出ChatGPT模板', () => exportBridgeTemplate(CHATGPT_PROVIDER_KEY, CHATGPT_PROVIDER_NAME))); panel.querySelector('#__ykt_ai_subset_actions__').appendChild(makeBtn('清除ChatGPT模板', () => { deleteBridgeTemplate(CHATGPT_PROVIDER_KEY); updateProviderStatus(CHATGPT_PROVIDER_KEY, CHATGPT_PROVIDER_NAME); toast('已清除 ChatGPT 模板'); })); const template = getBridgeTemplate(CHATGPT_PROVIDER_KEY); if (template) { updateProviderStatus(CHATGPT_PROVIDER_KEY, CHATGPT_PROVIDER_NAME); return; } const debug = getChatGptDebug(); if (debug) { setStatus(`ChatGPT 捕获调试:${debug.stage || 'candidate'} ${debug.tag} ${debug.method} ${debug.bodyLength}B / messages=${debug.hasMessages} / action=next=${debug.hasActionNext}`); } else { setStatus('ChatGPT 捕获调试:waiting-capture idle - 0B / messages=false / action=next=false'); } } function initChatGptTokenPage() { const panel = makePanel(); panel.querySelector('#__ykt_ai_subset_actions__').innerHTML = ''; panel.querySelector('#__ykt_ai_subset_actions__').appendChild(makeBtn('同步ChatGPT令牌', async () => { try { const accessToken = await syncChatGptSessionToken(true); setStatus(`当前已同步 ChatGPT 令牌:${maskKey(accessToken)}`); } catch {} })); panel.querySelector('#__ykt_ai_subset_actions__').appendChild(makeBtn('导入ChatGPT cURL', () => { try { const profile = importChatGptCurlProfile(true); if (!profile) return; const accessToken = normalizeChatGptAccessToken(profile.headers?.authorization || GM_getValue('chatgptAccessToken', '')); setStatus(`当前已同步 ChatGPT 令牌:${accessToken ? maskKey(accessToken) : '未同步'} / 已导入请求画像`); } catch (err) { toast(`导入 ChatGPT cURL 失败:${err?.message || err}`, 6000); } })); panel.querySelector('#__ykt_ai_subset_actions__').appendChild(makeBtn('清除ChatGPT令牌', () => { GM_deleteValue('chatgptAccessToken'); deleteChatGptCurlProfile(); setStatus('当前还没有同步 ChatGPT 令牌,也没有导入请求画像。'); toast('已清除 ChatGPT 配置'); })); const current = normalizeChatGptAccessToken(GM_getValue('chatgptAccessToken', '')); const profile = getChatGptCurlProfile(); if (current) { setStatus(`当前已同步 ChatGPT 令牌:${maskKey(current)} / ${profile ? '已导入请求画像' : '未导入请求画像'}`); } else { setStatus(profile ? '当前已导入 ChatGPT 请求画像,但还没有同步令牌。' : '当前还没有同步 ChatGPT 令牌,也没有导入请求画像。'); } syncChatGptSessionToken(false) .then(accessToken => setStatus(`当前已同步 ChatGPT 令牌:${maskKey(accessToken)} / ${getChatGptCurlProfile() ? '已导入请求画像' : '未导入请求画像'}`)) .catch(() => {}); } function renderExamPagePanel() { if (!isExamPage()) return; const panel = makePanel(); bindSettingsEvents(panel); const actions = panel.querySelector('#__ykt_ai_subset_actions__'); actions.innerHTML = ''; actions.appendChild(makeBtn('配置参数', configureAiSettings)); actions.appendChild(makeBtn('提交设置', configureSubmitSettings)); actions.appendChild(makeBtn('指定题号', solveSelectedQuestions)); actions.appendChild(makeBtn('自动作答', solveAllQuestions)); if (getAiConfig().mode === AI_MODE_CHATGPT_TOKEN) { actions.appendChild(makeBtn('打开ChatGPT', openChatGptWithSelectedPrompt)); actions.appendChild(makeBtn('导入答案', importManualAnswersAndSubmit)); } actions.appendChild(makeBtn('导出题目', exportSelectedQuestionsJson)); const config = getAiConfig(); if (config.mode === AI_MODE_DOUBAO_WEB) { const providerKey = BRIDGE_PROVIDER_KEY; const providerName = BRIDGE_PROVIDER_NAME; const template = getBridgeTemplate(providerKey); const online = getBridgeState(providerKey)?.online ? '在线' : '离线'; setStatus(`等待操作。当前模式:${providerName}网页登录态 / ${providerName}页${online} / ${template ? '已有模板' : '无模板'}`); } else { setStatus(`等待操作。当前模式:${getAiModeLabel(config.mode)}`); } } function boot() { if (isDoubaoPage()) { initDoubaoPage(); return; } renderExamPagePanel(); } if (isDoubaoPage()) { injectDoubaoCaptureScript(); } setTimeout(boot, 1200); })();