// ==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 定向作答
',
'支持指定题号定向作答,也支持整卷自动作答,不会自动交卷。
',
'',
'',
'
提交设置
',
'
',
'
',
'
',
'
',
'
实际等待时间 = 固定间隔 + 0 到随机抖动之间的随机值。
',
'
',
' ',
' ',
'
',
'
',
''
].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);
})();