// ==UserScript== // @name PTA 题解库秒做助手(函数题 + 编程题) // @namespace https://pintia.cn/ // @version 2.0.1 // @description PTA(pintia.cn) 自动化:把自己已通过题目的代码回收进本地题解库 → 换账号后按题目自动秒做。纯本地数据、无 LLM、无 API Key。同时支持函数题(CODE_COMPLETION)与编程题(PROGRAMMING)。 // @author auto // @match https://pintia.cn/* // @run-at document-idle // @grant GM_setValue // @grant GM_getValue // ==/UserScript== (function () { 'use strict'; /* ============================================================ * v2.0.0 去除全部 LLM/API 功能,改为「题解库提交器」 * · 新增「回收我的通过代码」:拉取本人全部提交,取 ACCEPTED 的, * 逐条读取回传的完整代码,按「题目标题」写入本地题解库(GM 存储) * · 新增「导出题库 / 导入题库」:JSON 文件,便于换浏览器/换设备 * · 新增「换账号秒做」:换账号登录后照常载入题目,命中题库即直接提交 * · 修复「读取不了函数题」:不再用「编程题」type 前缀过滤,改为一律 * 以当前页题目为准;函数题按 CODE_COMPLETION 格式提交 * ============================================================ */ /* ============================================================ * v2.0.1 修复「回收老是失败」:接口 429 限流 * · 新增全局节流器:任意两次同源请求强制间隔 >= minGap(默认 400ms), * 避免回收 / 状态同步等流程短时间密集打接口 * · 新增 429 / 网络异常自动退避重试:优先读 Retry-After,否则指数退避; * 每命中一次 429 就把全局间隔翻倍(上限 3s),使后续请求自动降速 * · 提交(POST)仅在 429 时重试,网络错误不重试,避免重复提交 * ============================================================ */ /* ============================================================ * 已逆向确认的接口(均同源,自动携带 Cookie,无需 CSRF 头) * 1) examId : GET /api/problem-sets/{psid}/exams → resp.exam.id * 2) 题目详情 : GET /api/problem-sets/{psid}/exam-problems/{pspId} * → resp.problemSetProblem.{title, content, score, type} * type = PROGRAMMING(编程题) | CODE_COMPLETION(函数题) | ... * 3) 提交 : POST /api/exams/{examId}/exam-submissions body = {problemType, details} * 编程题 details[0] = {problemId:'0', problemSetProblemId, * programmingSubmissionDetail:{program, compiler:'PYTHON3'}} * 函数题 details[0] = {problemId:'0', problemSetProblemId, * codeCompletionSubmissionDetail:{program}} ← 无 compiler * (格式取自 PTA 前端 bundle 的实际构造逻辑) * 4) 判题 : GET /api/exams/{examId}/submissions/{submissionId}? * → resp.submission.{status, score, submissionDetails, ...} * resp.queued 为数值,-1 表示已出队 * 5) 提交详情 : GET /api/exams/{examId}/submissions/{submissionId}? * → resp.submission.submissionDetails[0].programmingSubmissionDetail.program * 函数题为 submissionDetails[0].codeCompletionSubmissionDetail.program * (本人历史提交会回传完整代码 —— 回收题库的唯一数据源) * 6) 本人提交 : GET /api/problem-sets/{psid}/submissions → resp.submissions(倒序,100 条/页) * resp.hasBefore 为真时继续带 ?before={本页最后一条 id} * 状态枚举:ACCEPTED / PARTIAL_ACCEPTED / WRONG_ANSWER / NON_ZERO_EXIT_CODE ... * ============================================================ */ const COMPILER = 'PYTHON3'; const FETCH_TIMEOUT_MS = 30000; // 同源接口超时 const DEFAULT_CFG = { delayMs: 1500, // 每次提交之间的间隔 pollMs: 1200, // 判题轮询间隔 pollTimeoutMs: 60000, // 判题最长等待 onlyUnaccepted: true, // 跳过平台已通过的题 autoSubmit: true, // 命中题库后自动提交 }; const CFG_KEY = 'pta_auto_cfg_v1'; const BANK_KEY = 'pta_auto_bank_v1'; // 题解库 { 题目标题: 代码 } const STATUS_KEY = 'pta_auto_status_v1'; // 平台状态兜底 { pspId: {rank,score,status} } const cfg = Object.assign({}, DEFAULT_CFG, safeGet(CFG_KEY, {})); let bank = safeGet(BANK_KEY, {}); const state = { running: false, paused: false, examId: '', problemSetId: '', problems: [], // {pspId, no, title, type, score, status, fromServer} logLines: [], }; function safeGet(k, d) { try { const v = GM_getValue(k, null); return v == null ? d : JSON.parse(v); } catch (e) { return d; } } function safeSet(k, v) { try { GM_setValue(k, JSON.stringify(v)); } catch (e) {} } /* ============================================================ * 工具函数 * ============================================================ */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 所有同源 fetch 都带超时,避免请求悬停导致整轮卡死 function fetchWithTimeout(url, opts, ms) { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), ms || FETCH_TIMEOUT_MS); return fetch(url, Object.assign({}, opts, { signal: ctl.signal })) .finally(() => clearTimeout(timer)); } /* ---- 限流友好请求层:全局节流 + 429/网络异常自动退避重试 ---- * PTA 对 exam-problems 等接口有频率限制,短时间密集请求会返回 429, * 造成「回收老是失败」。这里统一排队并对 429 自动降速重试。 */ const RATE = { minGap: 400, nextAt: 0 }; // 相邻请求最小间隔(ms),触发 429 时自动放大 const RATE_MAX_GAP = 3000; const jitter = (ms) => ms + Math.floor(Math.random() * 150); async function pace() { // 全局排队:保证任意两次请求间隔 >= minGap const w = RATE.nextAt - Date.now(); if (w > 0) await sleep(w); RATE.nextAt = Date.now() + RATE.minGap; } function retryAfterMs(r) { const h = r && r.headers && r.headers.get('retry-after'); const s = Number(h); return isNaN(s) ? 0 : Math.max(0, s) * 1000; } /** * 统一同源请求:自动节流 + 对 429/502/503/504 与网络错误做指数退避重试。 * options.tries 最大尝试次数(默认 6) * options.retryNetwork 网络错误是否重试(POST 提交传 false,避免重复提交) */ async function requestJSON(url, opts, options) { opts = opts || {}; options = options || {}; const maxTries = options.tries || 6; const retryNetwork = options.retryNetwork !== false; for (let attempt = 1; ; attempt++) { await pace(); let r = null, err = null; try { r = await fetchWithTimeout(url, opts, FETCH_TIMEOUT_MS); } catch (e) { err = e; } if (r && r.ok) return r.json(); const rateLimited = !!(r && r.status === 429); const retriable = (err && retryNetwork) || !!(r && (r.status === 429 || r.status === 502 || r.status === 503 || r.status === 504)); if (!retriable || attempt >= maxTries) { if (r && !r.ok) throw new Error('HTTP ' + r.status + ' ' + url); throw new Error((err && err.message) || ('请求失败 ' + url)); } let wait; if (rateLimited) { RATE.minGap = Math.min(Math.max(RATE.minGap * 2, 800), RATE_MAX_GAP); // 被限流就整体降速 wait = (retryAfterMs(r) || Math.min(1000 * Math.pow(2, attempt - 1), 8000)) + jitter(200); } else { wait = Math.min(500 * Math.pow(2, attempt - 1), 5000) + jitter(100); } log(` 接口${rateLimited ? '限流(429)' : '异常'},${Math.round(wait / 1000)}s 后重试(第 ${attempt} 次)`); await sleep(wait); } } // 长耗时等待时每 20 秒打一行心跳 function withHeartbeat(label, promise) { const t0 = Date.now(); const timer = setInterval(() => { log(`${label} 仍在进行…已等待 ${Math.round((Date.now() - t0) / 1000)} 秒`); }, 20000); return promise.finally(() => clearInterval(timer)); } function log(msg, level) { const t = new Date().toLocaleTimeString(); const line = `[${t}] ${msg}`; state.logLines.push(line); if (state.logLines.length > 500) state.logLines.shift(); // 注意:日志元素在 Shadow DOM 内,必须从 shadow root 查找 const el = ui && ui.sh ? ui.sh.getElementById('pta-log') : null; if (el) { el.textContent = state.logLines.join('\n'); el.scrollTop = el.scrollHeight; } if (level === 'error') console.error('[PTA-BANK]', msg); else console.log('[PTA-BANK]', msg); } function getProblemSetId() { const m = location.pathname.match(/\/problem-sets\/(\d+)/); return m ? m[1] : ''; } function selKey(pspId) { const p = state.problems.find((x) => x.pspId === pspId); return p ? (p.no || pspId) : pspId; } function typeLabel(t) { if (t === 'PROGRAMMING') return '编程题'; if (t === 'CODE_COMPLETION') return '函数题'; return t || ''; } // 从左侧导航建立「题号前缀 → 题型」映射(6→函数题, 7→编程题) function getNavTypeMap() { const map = {}; Array.from(document.querySelectorAll('a[href*="/exam/problems/type/"]')).forEach((a) => { const h = (a.getAttribute('href') || '').match(/\/type\/(\d+)/); if (!h) return; const label = (a.textContent || '').trim(); if (/函数题/.test(label)) map[h[1]] = 'CODE_COMPLETION'; else if (/编程题/.test(label)) map[h[1]] = 'PROGRAMMING'; }); return map; } async function apiGetJSON(url) { return requestJSON(url, { headers: { accept: 'application/json;charset=UTF-8' }, credentials: 'include' }); } async function discoverExamId(psid) { const j = await apiGetJSON(`/api/problem-sets/${psid}/exams`); if (j && j.exam && j.exam.id) return j.exam.id; throw new Error('无法获取 examId(可能未登录)'); } async function fetchProblem(pspId) { const j = await apiGetJSON(`/api/problem-sets/${state.problemSetId}/exam-problems/${pspId}`); const p = j.problemSetProblem || j.problem || {}; return { title: p.title || '', type: p.type || '', score: p.score || 0 }; } /* ============================================================ * 提交 / 判题 * ============================================================ */ async function submitCode(pspId, program, type) { const detail = (type === 'CODE_COMPLETION') ? { problemId: '0', problemSetProblemId: String(pspId), codeCompletionSubmissionDetail: { program: program } } : { problemId: '0', problemSetProblemId: String(pspId), programmingSubmissionDetail: { program: program, compiler: COMPILER } }; const body = { problemType: type || 'PROGRAMMING', details: [detail] }; const j = await requestJSON(`/api/exams/${state.examId}/exam-submissions`, { method: 'POST', headers: { 'content-type': 'application/json;charset=UTF-8', accept: 'application/json;charset=UTF-8' }, credentials: 'include', body: JSON.stringify(body), }, { tries: 5, retryNetwork: false }); return j.submissionId; } // 接口的 queued 是数值,-1 表示“已不在评测队列”,>0 或 true 表示仍在排队 function isQueued(q) { return q === true || (typeof q === 'number' && q > 0); } async function fetchVerdict(submissionId) { const j = await apiGetJSON(`/api/exams/${state.examId}/submissions/${submissionId}?`); const s = j.submission || {}; return { queued: isQueued(j.queued), status: s.status || '', score: s.score, raw: s }; } async function pollVerdict(submissionId) { const t0 = Date.now(); const limit = Math.min(Number(cfg.pollTimeoutMs) || 60000, 180000); while (Date.now() - t0 < limit) { let v; try { v = await fetchVerdict(submissionId); } catch (e) { await sleep(cfg.pollMs); // 单次查询失败不致命,继续轮询 continue; } const s = (v.status || '').toUpperCase(); if (!v.queued && s && s !== 'PENDING' && s !== 'JUDGING') return v; await sleep(cfg.pollMs); } return { queued: false, status: 'TIMEOUT', score: 0 }; } /* ============================================================ * 平台状态同步:一次拉全本人提交(分页),取每题最好成绩 * ============================================================ */ function statusRank(st, score) { if ((st || '').toUpperCase() === 'ACCEPTED') return 3; return (Number(score) || 0) > 0 ? 2 : 1; } async function fetchAllSubmissions() { const examId = state.examId, psid = state.problemSetId; const base = `/api/exams/${examId}/problem-sets/${psid}/submissions`; let all = []; let url = base; for (let i = 0; i < 30; i++) { const j = await apiGetJSON(url); const subs = j.submissions || []; all = all.concat(subs); if (!j.hasBefore || !subs.length) break; url = base + '?before=' + subs[subs.length - 1].id; await sleep(200); } return all; } function buildBestMap(subs) { const best = {}; for (const s of subs) { const id = String(s.problemSetProblemId || ''); if (!id) continue; const st = (s.status || '').toUpperCase(); const sc = Number(s.score) || 0; const rank = statusRank(st, sc); const cur = best[id]; if (!cur || rank > cur.rank || (rank === cur.rank && sc > cur.score)) best[id] = { rank: rank, score: sc, status: st }; } return best; } function applyBestMap(best) { let acPlat = 0; for (const p of state.problems) { const b = best[String(p.pspId)]; if (!b) { p.fromServer = false; continue; } p.fromServer = true; if (b.status === 'ACCEPTED') { p.status = 'AC ✓(平台)'; p.score = b.score || p.score; acPlat++; } else if (b.rank === 2) { p.status = `部分通过 ${b.score}分`; } else { p.status = '平台未通过'; } } renderTable(); return acPlat; } async function syncServerStatus() { try { log('正在同步平台作答状态…'); const subs = await fetchAllSubmissions(); const best = buildBestMap(subs); safeSet(STATUS_KEY, best); // 存本地,接口异常时兜底 const acPlat = applyBestMap(best); log(`平台状态同步完成:${Object.keys(best).length} 题提交过,其中 ${acPlat} 题已通过(这些会被跳过)`); return best; } catch (e) { log('平台状态同步失败,使用本地缓存兜底:' + e.message, 'error'); const cached = safeGet(STATUS_KEY, null); if (cached) applyBestMap(cached); return null; } } /* ============================================================ * 回收题库:把自己已通过(ACCEPTED)的题代码取回本地 * ============================================================ */ async function harvestBank() { if (state.running) return; state.problemSetId = getProblemSetId() || state.problemSetId; if (!state.problemSetId) { log('回收失败:当前不在题目集中', 'error'); return; } if (!state.examId) { try { state.examId = await discoverExamId(state.problemSetId); } catch (e) { log('回收失败:' + e.message, 'error'); return; } } try { log('正在回收历史提交…'); const subs = await fetchAllSubmissions(); const pick = {}; // pspId -> {id, score} for (const s of subs) { if ((s.status || '').toUpperCase() !== 'ACCEPTED') continue; const id = String(s.problemSetProblemId || ''); if (!id) continue; const sc = Number(s.score) || 0; if (!pick[id] || sc > pick[id].score) pick[id] = { id: s.id, score: sc }; } const ids = Object.keys(pick); log(`共扫描 ${subs.length} 条提交,其中 ${ids.length} 道题已通过,开始抓取代码…`); let got = 0, fail = 0; for (const pspId of ids) { try { const d = await apiGetJSON(`/api/exams/${state.examId}/submissions/${pick[pspId].id}?`); const s = d.submission || {}; const det = (s.submissionDetails || [])[0]; const pd = det && (det.programmingSubmissionDetail || det.codeCompletionSubmissionDetail); const prog = pd && pd.program; if (!prog) { fail++; log(` 未取到代码 ${pspId}`, 'error'); await sleep(120); continue; } const cached = state.problems.find((x) => x.pspId === pspId) || {}; let title = cached.title, type = cached.type; if (!title) { const info = await fetchProblem(pspId); title = info.title; type = info.type; } const key = title || pspId; bank[key] = prog; got++; log(` 已回收 [${typeLabel(type)}] ${key}(${String(prog).length} 字符)`); } catch (e) { fail++; log(` 回收失败 ${pspId}:${e.message}`, 'error'); } await sleep(150); } safeSet(BANK_KEY, bank); renderTable(); log(`回收完成:写入/更新 ${got} 道题${fail ? `,失败 ${fail} 道` : ''},题库现有 ${Object.keys(bank).length} 条`); } catch (e) { log('回收终止:' + e.message, 'error'); } } function exportBank() { const keys = Object.keys(bank); if (!keys.length) { log('题库为空,无需导出'); return; } const blob = new Blob([JSON.stringify(bank, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'pta-bank-' + keys.length + 'items.json'; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); log(`已导出题库:${keys.length} 条`); } function importBank(file) { const fr = new FileReader(); fr.onload = () => { try { const j = JSON.parse(String(fr.result)); if (!j || typeof j !== 'object') throw new Error('格式不是 JSON 对象'); let n = 0; for (const k of Object.keys(j)) { if (typeof j[k] === 'string' && j[k]) { bank[k] = j[k]; n++; } } safeSet(BANK_KEY, bank); log(`已导入题库:合并 ${n} 条,题库现有 ${Object.keys(bank).length} 条`); } catch (e) { log('导入失败:' + e.message, 'error'); } }; fr.onerror = () => log('导入失败:文件读取错误', 'error'); fr.readAsText(file); } /* ============================================================ * 题目列表:一律以当前页题目为准(不再按 type 前缀过滤) * ============================================================ */ function loadProblemsFromPage() { const anchors = Array.from(document.querySelectorAll('a[href*="problemSetProblemId="]')); const seen = new Set(); const list = []; for (const a of anchors) { const id = (a.getAttribute('href').match(/problemSetProblemId=(\d+)/) || [])[1]; if (!id || seen.has(id)) continue; seen.add(id); const no = (a.textContent || '').trim(); list.push({ pspId: id, no: /^\d+$/.test(no) ? no : '', title: '', type: '', score: 0, status: '', fromServer: false }); } // 从列表表格补全 题号 / 标题 / 分数 const rows = Array.from(document.querySelectorAll('table tbody tr')); for (const tr of rows) { const tds = tr.querySelectorAll('td'); if (tds.length < 3) continue; const no = (tds[1].textContent || '').trim(); const title = (tds[2].textContent || '').trim(); const score = parseInt((tds[3].textContent || '0').trim(), 10) || 0; const idDiv = tr.querySelector('td div[id]'); const pspId = idDiv ? idDiv.id : ''; const item = list.find((x) => x.pspId === pspId) || list.find((x) => x.no === no && !x.title); if (item) { item.title = title; item.score = score; item.no = no; } } // 从左侧导航推断题型(6→函数题 CODE_COMPLETION,7→编程题 PROGRAMMING) const tmap = getNavTypeMap(); for (const x of list) { const pfx = (x.no || '').split('-')[0]; if (pfx && tmap[pfx]) x.type = tmap[pfx]; } list.sort((a, b) => (parseInt((a.no.split('-')[1] || '0'), 10) - parseInt((b.no.split('-')[1] || '0'), 10)) || a.pspId.localeCompare(b.pspId)); return list; } async function loadAndSync() { state.problemSetId = getProblemSetId() || state.problemSetId; if (!state.problemSetId) throw new Error('当前不在题目集中,请先打开题目集页面'); if (!state.examId) state.examId = await discoverExamId(state.problemSetId); if (ui) ui.sh.getElementById('cfg-exam').value = state.examId; const list = loadProblemsFromPage(); if (!list.length) throw new Error('未发现题目。请先打开「函数题」或「编程题」列表页再试'); state.problems = list; renderTable(); log(`已载入 ${list.length} 道题(题库现有 ${Object.keys(bank).length} 条题解)`); await syncServerStatus(); return list; } /* ============================================================ * 核心流程:命中题库就提交,没有题解就跳过 * ============================================================ */ async function solveOne(item) { item.status = '读题…'; renderTable(); let info; try { info = await fetchProblem(item.pspId); } catch (e) { item.status = '题面失败'; renderTable(); log(`#${selKey(item.pspId)} 题面获取失败:${e.message}`, 'error'); return; } item.title = info.title || item.title; item.type = info.type || item.type; item.score = info.score || item.score; const code = bank[item.title]; if (!code) { item.status = '无题解'; renderTable(); log(`#${selKey(item.pspId)} [${typeLabel(item.type)}] ${item.title} → 题库里没有,已跳过(先用已通过的账号回收一次)`); return; } if (!cfg.autoSubmit) { item.status = '有题解(未提交)'; renderTable(); return; } item.status = '提交中…'; renderTable(); let submissionId; try { submissionId = await submitCode(item.pspId, code, item.type); } catch (e) { item.status = '提交失败'; renderTable(); log(`#${selKey(item.pspId)} 提交失败:${e.message}`, 'error'); return; } const v = await withHeartbeat(`#${selKey(item.pspId)} 判题中`, pollVerdict(submissionId)); const st = (v.status || '').toUpperCase(); if (st === 'ACCEPTED') { item.status = 'AC ✓'; item.fromServer = false; item.score = v.score != null ? v.score : item.score; renderTable(); log(`#${selKey(item.pspId)} ${item.title} → 答案正确 (${v.score}分)`); return; } item.status = (st === 'TIMEOUT' ? '判题超时' : (st + (v.score ? ` ${v.score}分` : ''))); item.fromServer = false; renderTable(); log(`#${selKey(item.pspId)} ${item.title} → ${st === 'TIMEOUT' ? '判题超时(未取到结果)' : v.status}(题库代码在此题不通,建议人工核对)`, 'error'); } async function runAll() { if (state.running) return; syncCfgFromUI(); if (!Object.keys(bank).length) { log('题库为空,先点「回收我的通过代码」或「导入题库」', 'error'); return; } state.running = true; state.paused = false; setBtns(); try { if (!state.problems.length) { await loadAndSync(); } else { await syncServerStatus(); // 开跑前以平台真实状态刷新一次 } const total = state.problems.length; let done = 0, skipped = 0, noPlan = 0; for (const item of state.problems) { if (!state.running) break; while (state.paused) await sleep(300); if (cfg.onlyUnaccepted && /^AC/.test(item.status)) { done++; skipped++; continue; } log(`===== 开始 #${selKey(item.pspId)} ${item.title || ''} (${++done}/${total}) =====`); try { await solveOne(item); if (item.status === '无题解') noPlan++; } catch (e) { item.status = '错误'; log('异常:' + e.message, 'error'); renderTable(); } await sleep(Number(cfg.delayMs) || 1500); } log(`===== 本轮结束(跳过已通过 ${skipped} 题,题库缺失 ${noPlan} 题)=====`); } catch (e) { log('运行终止:' + e.message, 'error'); } finally { state.running = false; setBtns(); } } /* ============================================================ * 界面 UI(Shadow DOM,避免污染页面样式) * ============================================================ */ let ui = null; function buildUI() { const host = document.createElement('div'); host.id = 'pta-bank-host'; host.style.cssText = 'position:fixed;top:70px;right:16px;z-index:2147483647;'; const sh = host.attachShadow({ mode: 'open' }); sh.innerHTML = `
| # | 题号 | 题型 | 标题 | 分数 | 状态 |
|---|