diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..4079ae4 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A VS Code extension called "AtCoder Helper neo" for browsing AtCoder competitive programming contest problems, with LaTeX rendering, DeepL translation, code submission, and contest registration. Chinese-language UI and documentation. + +## Commands + +```bash +pnpm install # Install dependencies +pnpm build # Build all packages (turbo) +pnpm dev # Dev watch mode (extension + webview) +pnpm lint # ESLint all packages +pnpm format # Prettier format +pnpm test # Run all tests + +# Run unit tests directly +cd apps/vscode-extension && pnpm test + +# Package extension +cd apps/vscode-extension && pnpm package +# Output: release/extension.vsix +``` + +Debug: press F5 in VS Code to launch Extension Host (preLaunchTask runs `pnpm dev`). + +## Architecture + +Monorepo with pnpm workspaces + Turborepo. Webpack dual-config builds both the Node extension and the React webview into `apps/vscode-extension/dist/`. + +**Dependency flow:** `apps/vscode-extension` → `packages/webview` → `packages/ui` + `packages/core` + +Webpack resolves `@template/ui` and `@template/core` via path aliases directly to source — no need to pre-build packages during development. + +### Extension host (Node, CommonJS) + +Entry: `apps/vscode-extension/src/extension.ts` — registers commands, creates WebView panel, routes messages. + +Communication protocol: WebView sends `{ command: "..." }`, Extension responds with `{ type: "..." }`. Message types defined in `packages/webview/src/types.ts` (`WebviewMessage` union). Adding a new message requires updating both `types.ts` and the `switch` in `extension.ts`. + +Network layer (`tools/fetch.ts`): custom HTTP client using Node https/http/zlib, no external deps. Three error classes: `CfError` (Cloudflare), `ProxyError`, `LoginRequiredError` — handled uniformly by `handleErrorWithCfAndLogin()`. + +### WebView frontend (React, browser target) + +Entry: `packages/webview/src/index.tsx` → `WebviewApp.tsx`. Uses Tailwind CSS with `var(--vscode-*)` variables for theme adaptation. KaTeX math is pre-rendered server-side in the extension host. + +### Shared packages + +- `packages/core` — `MessageBus` (pub/sub) and `StateManager` +- `packages/ui` — Button, Card, Input, Spinner components styled with VS Code CSS variables + +## Code Conventions + +- 4-space indent, no `export default`, prefer `interface` over `type` +- No `any` — use `unknown` instead +- Functions max 120 lines (CI enforces 130 hard limit) +- Import order: external deps → `@template/*` packages → relative paths +- Component files: PascalCase. Tool/utility files: camelCase +- Error handling: use `CfError`/`ProxyError`/`LoginRequiredError`, never bare `throw new Error()` +- Git branches: `feat/xxx`, `fix/xxx`, `refactor/xxx`, `docs/xxx` +- Main branch: `main`, development branch: `dev` + +## CI + +PR to `main` triggers: lint → build → test → function length check (>130 lines fails). Results posted as PR comment. + +## Git Branch Rules (Claude MUST follow) + +- **NEVER** commit, push, or merge to `main` branch directly +- **NEVER** run `git checkout main` or `git push origin main` +- **ALWAYS** work on `dev` branch or feature branches (`feat/*`, `fix/*`, `refactor/*`, `docs/*`) +- Before any Git operation, confirm current branch with `git branch --show-current` +- If user asks to modify `main`, remind them to use `dev` instead \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2063f8a..0107342 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ node_modules/ .DS_Store .swc .vsix -DEEP.md \ No newline at end of file +ToDo.md +memory.md +issue.md \ No newline at end of file diff --git a/apps/vscode-extension/media/atcoder.svg b/apps/vscode-extension/media/atcoder.svg new file mode 100644 index 0000000..836637e --- /dev/null +++ b/apps/vscode-extension/media/atcoder.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index fb55096..486fd36 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -16,14 +16,15 @@ "Other" ], "activationEvents": [ - "onCommand:extension.showWebview" + "onCommand:extension.showWebview", + "onView:atcoderHelper.webviewView" ], "main": "./dist/extension.js", "contributes": { "commands": [ { "command": "extension.showWebview", - "title": "AtCoder" + "title": "AtCoder Helper(编辑器)" }, { "command": "extension.setDeeplApiKey", @@ -33,7 +34,25 @@ "command": "extension.setAtCoderCookie", "title": "Set AtCoder Login Cookie" } - ] + ], + "viewsContainers": { + "activitybar": [ + { + "id": "atcoder-helper-container", + "title": "AtCoder Helper", + "icon": "media/atcoder.svg" + } + ] + }, + "views": { + "atcoder-helper-container": [ + { + "type": "webview", + "id": "atcoderHelper.webviewView", + "name": "AtCoder Helper" + } + ] + } }, "scripts": { "vscode:prepublish": "pnpm run build", diff --git a/apps/vscode-extension/src/atcoder.ts b/apps/vscode-extension/src/atcoder.ts index 81318a5..e3d5817 100644 --- a/apps/vscode-extension/src/atcoder.ts +++ b/apps/vscode-extension/src/atcoder.ts @@ -18,6 +18,8 @@ export interface AtCoderProblem { outputFormat: string; samples: SampleCase[]; sampleUrl?: string; + timeLimit?: number; + memoryLimit?: number; } function decodeEntities(text: string): string { @@ -148,6 +150,37 @@ function getLangContent(html: string, lang: "en" | "ja"): string { return html.slice(start); } +function extractHeaderValue(html: string, labels: string[]): string | undefined { + const rowRegex = /]*>([\s\S]*?)<\/tr>/gi; + let rowMatch: RegExpExecArray | null; + for (; (rowMatch = rowRegex.exec(html)) !== null;) { + const row = rowMatch[1]; + if (!labels.some((label) => row.includes(label))) continue; + const tds = Array.from(row.matchAll(/]*>([\s\S]*?)<\/td>/gi)); + if (tds.length === 0) continue; + const value = cleanText(tds[tds.length - 1][1]); + return value || undefined; + } + return undefined; +} + +function extractTimeLimit(html: string): number | undefined { + const value = extractHeaderValue(html, ["Time Limit", "時間制限"]); + const match = value && value.match(/(\d+(?:\.\d+)?)/); + if (!match) return undefined; + const ms = value!.toLowerCase().includes("sec") + ? parseFloat(match[1]) * 1000 + : parseFloat(match[1]); + return Math.round(ms); +} + +function extractMemoryLimit(html: string): number | undefined { + const value = extractHeaderValue(html, ["Memory Limit", "メモリ制限"]); + const match = value && value.match(/(\d+(?:\.\d+)?)/); + if (!match) return undefined; + return Math.round(parseFloat(match[1])); +} + export function parseProblemPage(html: string, url: string): AtCoderProblem { const titleMatch = html.match(/([^<]+)<\/title>/i); const title = titleMatch ? cleanText(titleMatch[1]) : "Untitled"; @@ -208,6 +241,8 @@ export function parseProblemPage(html: string, url: string): AtCoderProblem { outputFormat, samples, sampleUrl, + timeLimit: extractTimeLimit(html), + memoryLimit: extractMemoryLimit(html), }; } diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 2600c91..60d158d 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,12 +1,18 @@ import * as vscode from "vscode"; import * as path from "path"; -import { fetchAtCoderProblem, fetchAtCoderTasks } from "./atcoder"; -import { CfError, ProxyError, LoginRequiredError, setSessionCookie, fetchSubStatus, fetchSubmitHistory } from "./tools/fetch"; -import { fetchContest, signedUpContest } from "./tools/SignUpContest"; +import { AtCoderProblem, fetchAtCoderProblem, fetchAtCoderTasks } from "./atcoder"; +import { CfError, ProxyError, LoginRequiredError, setSessionCookie, setStaleCookieHandler, fetchSubStatus, fetchSubmitHistory } from "./tools/fetch"; +import { fetchContest, signedUpContest, fetchContestAnnouncement } from "./tools/SignUpContest"; import { translateTextRaw, translateTextFree } from "./tools/deepl"; import { runCommand } from "./tools/command"; import { fetchSubmitPage, submitCodeWithRedirect } from "./tools/submit"; +import { buildCphProblem, sendToCph } from "./tools/cph"; +import { fetchStandings } from "./tools/standings"; +import { fetchHomepageContests } from "./tools/homepage"; +import { fetchSubmissionDetail } from "./tools/submission"; import { IncomingMessage } from "./tools/types"; +import { getWebviewContent } from "./tools/webview"; +import { AtCoderViewProvider } from "./viewProvider"; const log = { info: (...args: unknown[]) => { @@ -20,22 +26,6 @@ const log = { const sleep = (ms: number): Promise<void> => new Promise<void>(resolve => setTimeout(resolve, ms)); -function getWebviewContent(webviewJsSrc: vscode.Uri): string { - return `<!DOCTYPE html> - <html lang="en"> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src vscode-resource: https:; script-src 'unsafe-eval' 'unsafe-inline' vscode-resource:; style-src vscode-resource: 'unsafe-inline';"> - <title>VSCode Boilerplate - - -
- - - `; -} - function handleErrorWithCfAndLogin(error: unknown, send: (payload: Record) => void): boolean { if (error instanceof CfError) { vscode.window.showErrorMessage(error.message, "在浏览器中打开").then((choice) => { @@ -87,7 +77,8 @@ export async function handleContestLoad(contest: string, send: (payload: Record< try { const contestInfo = await fetchContest(contest); - send({ type: "contestInfo", Rated: contestInfo.Rated }); + const announcement = await fetchContestAnnouncement(contest); + send({ type: "contestInfo", Rated: contestInfo.Rated, announcement, title: contestInfo.title }); } catch (e) { //不处理 } @@ -209,6 +200,14 @@ export async function handleFetchSubmitPage(contest: string, send: (payload: Rec send({ type: "submitPage", submitTasks: pageData.tasks, languages: pageData.languages, csrfToken: pageData.csrfToken }); send({ type: "update", text: "已获取提交页面信息" }); } catch (error) { + if (error instanceof CfError) { + send({ type: "submitPageError", message: "该比赛提交需要 Cloudflare 验证,插件无法自动完成。请在浏览器中打开提交页完成验证后提交。", url: error.url }); + return; + } + if (error instanceof LoginRequiredError) { + send({ type: "submitPageError", message: "提交需要登录,请先设置 AtCoder Cookie 后再试。", url: `https://atcoder.jp/contests/${contest}/submit` }); + return; + } if (!handleErrorWithCfAndLogin(error, send)) { send({ type: "error", text: error instanceof Error ? error.message : "获取提交页面失败" }); } @@ -244,6 +243,14 @@ export async function handleSubmitCode( } } else send({ type: "error", text: result.message }); } catch (error) { + if (error instanceof CfError) { + send({ type: "submitResult", submitResult: { success: false, message: "该比赛提交需要 Cloudflare 验证,插件无法自动完成。请在浏览器中打开提交页完成验证后提交。" } }); + return; + } + if (error instanceof LoginRequiredError) { + send({ type: "submitResult", submitResult: { success: false, message: "提交需要登录,请先设置 AtCoder Cookie 后再试。" } }); + return; + } if (!handleErrorWithCfAndLogin(error, send)) { send({ type: "submitResult", submitResult: { success: false, message: error instanceof Error ? error.message : "提交失败" } }); } @@ -262,6 +269,53 @@ export async function handleFetchSubHistory(contest: string, send: (payload: Rec } } +export async function handleFetchSubmissionDetail(contest: string, id: string, send: (payload: Record) => void) { + send({ type: "loading", text: `正在获取提交 ${id} 的详细信息...` }); + try { + const detail = await fetchSubmissionDetail(contest, id); + send({ type: "submissionDetail", submissionDetail: detail }); + } catch (error) { + if (!handleErrorWithCfAndLogin(error, send)) { + send({ type: "error", text: error instanceof Error ? error.message : "获取提交详情失败" }); + } + } +} + +export async function handleFetchStandings(contest: string, send: (payload: Record) => void) { + send({ type: "loading", text: `正在获取 ${contest} 排行榜...` }); + try { + const standings = await fetchStandings(contest); + send({ type: "standings", contest, standings }); + } catch (error) { + if (!handleErrorWithCfAndLogin(error, send)) { + send({ type: "error", text: error instanceof Error ? error.message : "获取排行榜失败" }); + } + } +} + +export async function handleGetContests(send: (payload: Record) => void) { + send({ type: "loading", text: "正在抓取 AtCoder 首页比赛列表..." }); + try { + const contests = await fetchHomepageContests(); + send({ type: "contestList", contests }); + } catch (error) { + if (!handleErrorWithCfAndLogin(error, send)) { + send({ type: "error", text: error instanceof Error ? error.message : "获取比赛列表失败" }); + } + } +} + +export async function handleExportToCph(problem: AtCoderProblem, send: (payload: Record) => void) { + try { + const payload = buildCphProblem(problem); + await sendToCph(payload); + send({ type: "cphExportResult", success: true, message: "success send to cph" }); + } catch (error) { + const message = error instanceof Error ? error.message : "fail to send cph"; + send({ type: "cphExportResult", success: false, message: message }); + } +} + async function pullSubmitStatu(contest: string, taskName: string, send: (payload: Record) => void,): Promise { const maxSetp = 15; const judgeStatus = new Set(["AC", "WA", "TLE", "MLE", "RE", "CE", "OLE"]); @@ -362,9 +416,83 @@ function createShowWebview(context: vscode.ExtensionContext) { }; } +export function openContestPanel(context: vscode.ExtensionContext, contest: string) { + const panel = vscode.window.createWebviewPanel( + "atcoderContest", + `AtCoder - ${contest}`, + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.file(path.join(context.extensionPath, "dist")), + ], + } + ); + + const webviewJsPath = vscode.Uri.file( + path.join(context.extensionPath, "dist", "webview.js") + ); + const webviewJsSrc = panel.webview.asWebviewUri(webviewJsPath); + + panel.webview.html = getWebviewContent(webviewJsSrc, "contest", contest); + + const sendToWebview = (payload: Record) => { + panel.webview.postMessage(payload); + }; + + panel.webview.onDidReceiveMessage( + (message: IncomingMessage) => { runCommand(message, context, sendToWebview); }, + undefined, + context.subscriptions + ); + + context.subscriptions.push(panel); +} + +export function openSubmissionPanel(context: vscode.ExtensionContext, contest: string, id: string) { + const panel = vscode.window.createWebviewPanel( + "atcoderSubmission", + `提交 ${id} - ${contest}`, + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.file(path.join(context.extensionPath, "dist")), + ], + } + ); + + const webviewJsPath = vscode.Uri.file( + path.join(context.extensionPath, "dist", "webview.js") + ); + const webviewJsSrc = panel.webview.asWebviewUri(webviewJsPath); + + panel.webview.html = getWebviewContent(webviewJsSrc, "submission", contest, id); + + const sendToWebview = (payload: Record) => { + panel.webview.postMessage(payload); + }; + + panel.webview.onDidReceiveMessage( + (message: IncomingMessage) => { runCommand(message, context, sendToWebview); }, + undefined, + context.subscriptions + ); + + context.subscriptions.push(panel); +} + export async function activate(context: vscode.ExtensionContext) { log.info("Extension is now active!"); + setStaleCookieHandler(() => { + // vscode.window.showWarningMessage( + // "检测到 AtCoder Cookie 可能已过期:已临时使用无 Cookie 访问公开页面。如需提交/报名等登录功能,请重新登录并更新 Cookie。" + // ); + }); + try { const cookie = await context.secrets.get("atcoderCookie"); if (cookie) { @@ -377,6 +505,12 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.commands.registerCommand("extension.showWebview", createShowWebview(context)) ); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + AtCoderViewProvider.viewType, + new AtCoderViewProvider(context) + ) + ); } catch (error) { log.error("Failed to activate extension:", error); } diff --git a/apps/vscode-extension/src/tools/SignUpContest.ts b/apps/vscode-extension/src/tools/SignUpContest.ts index f1cf29c..e13327c 100644 --- a/apps/vscode-extension/src/tools/SignUpContest.ts +++ b/apps/vscode-extension/src/tools/SignUpContest.ts @@ -29,6 +29,30 @@ export async function fetchContest(contest: string): Promise { return { contest, title, url, signed, csrfToken, Rated }; } +function decodeAnnouncementEntities(text: string): string { + return text + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) + .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(parseInt(code, 16))) + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +export async function fetchContestAnnouncement(contest: string): Promise { + const url = `https://atcoder.jp/posts/${contest}_en`; + try { + let html = await fetchText(url); + html = html.replace(/]*>/g, ''); + const match = html.match(/class="panel-body blog-post"[^>]*>([\s\S]*?)<\/div>/i); + if (!match) return ""; + return decodeAnnouncementEntities(match[1]).trim(); + } catch { + return ""; + } +} + export async function signedUpContest(contest: string, csrfToken: string, rated?: boolean): Promise<{ success: boolean; message: string }> { const step1Url = `https://atcoder.jp/contests/${contest}/register`; const step2Url = `https://atcoder.jp/contests/${contest}/rated_register`; diff --git a/apps/vscode-extension/src/tools/command.ts b/apps/vscode-extension/src/tools/command.ts index 6b342eb..61221f2 100644 --- a/apps/vscode-extension/src/tools/command.ts +++ b/apps/vscode-extension/src/tools/command.ts @@ -11,13 +11,20 @@ import { handleFetchSubmitPage, handleSubmitCode, handleFetchSubHistory, + handleExportToCph, + handleFetchStandings, + handleGetContests, + handleFetchSubmissionDetail, + openContestPanel, + openSubmissionPanel, } from "../extension"; const loadCommands = new Set(["loadContest", "loadProblem", "openBrowser"]); const deeplCommands = new Set(["translate", "setApiKey"]); const cookieCommands = new Set(["getCookie", "setCookie"]); -const problemCommands = new Set(["registerContest", "copyMarkdown", "alert"]); +const problemCommands = new Set(["registerContest", "copyMarkdown", "alert", "sendCph"]); const submitCommands = new Set(["fetchSubmitPage", "submitCode", "fetchSubmissionHistory", "fetchSubmissionDetail"]); +const contestCommands = new Set(["fetchStandings", "getContests", "openContest", "openSubmission"]); export async function runCommand(message: IncomingMessage, context: vscode.ExtensionContext, sendToWebview: (payload: Record) => void,) { if (loadCommands.has(message.command!)) await runLoadCommand(message, sendToWebview); @@ -25,9 +32,32 @@ export async function runCommand(message: IncomingMessage, context: vscode.Exten else if (cookieCommands.has(message.command!)) await runCookie(message, context, sendToWebview); else if (problemCommands.has(message.command!)) await runProblem(message, context, sendToWebview); else if (submitCommands.has(message.command!)) await runSubmit(message, context, sendToWebview); + else if (contestCommands.has(message.command!)) await runContest(message, context, sendToWebview); else throw new Error("unknown command"); } +async function runContest(command: IncomingMessage, context: vscode.ExtensionContext, sendToWebview: (payload: Record) => void,): Promise { + switch (command.command) { + case "fetchStandings": + if (!command.contest) return false; + await handleFetchStandings(command.contest, sendToWebview); + return true; + case "getContests": + await handleGetContests(sendToWebview); + return true; + case "openContest": + if (!command.contest) return false; + openContestPanel(context, command.contest); + return true; + case "openSubmission": + if (!command.contest || !command.id) return false; + openSubmissionPanel(context, command.contest, command.id); + return true; + default: + return false; + } +} + async function runSubmit(command: IncomingMessage, context: vscode.ExtensionContext, sendToWebview: (payload: Record) => void,): Promise { switch (command.command) { case "fetchSubmitPage": @@ -42,6 +72,10 @@ async function runSubmit(command: IncomingMessage, context: vscode.ExtensionCont if (!command.contest) return false; await handleFetchSubHistory(command.contest, sendToWebview); return true; + case "fetchSubmissionDetail": + if (!command.contest || !command.id) return false; + await handleFetchSubmissionDetail(command.contest, command.id, sendToWebview); + return true; default: return false; } @@ -63,6 +97,11 @@ async function runProblem(command: IncomingMessage, context: vscode.ExtensionCon vscode.window.showInformationMessage(command.text ?? ""); sendToWebview({ type: "update", text: `Extension received: ${command.text ?? ""}` }); return true; + case "sendCph": + if (command.problem) { + await handleExportToCph(command.problem, sendToWebview); + } + return true; default: return false; } diff --git a/apps/vscode-extension/src/tools/cph.ts b/apps/vscode-extension/src/tools/cph.ts new file mode 100644 index 0000000..930fc32 --- /dev/null +++ b/apps/vscode-extension/src/tools/cph.ts @@ -0,0 +1,84 @@ +import * as http from "http"; +import { AtCoderProblem } from "../atcoder"; + +export interface CphTestCase { + input: string; + output: string; + id: number; +} + +export interface CphProblem { + name: string; + url: string; + interactive: boolean; + memoryLimit: number; + timeLimit: number; + group: string; + tests: CphTestCase[]; + srcPath: string; + local: boolean; +} + +export class CphNotRunningError extends Error { + constructor() { + super( + "未检测到 CPH 插件(localhost:27121 无响应)。\n" + + "请安装并启用 Competitive Programming Helper 扩展后重试。" + ); + this.name = "CphNotRunningError"; + } +} + +export function buildCphProblem(problem: AtCoderProblem): CphProblem { + const tests = problem.samples.map((sample, index) => ({ + input: sample.input, + output: sample.output, + id: index + 1, + })); + return { + name: problem.title, + url: problem.url, + interactive: false, + memoryLimit: (problem.memoryLimit ?? 1024) * 1024 * 1024, + timeLimit: problem.timeLimit ?? 2000, + group: `AtCoder - ${problem.contest}`, + tests, + srcPath: "", + local: false, + }; +} + +export function sendToCph(problem: CphProblem): Promise { + const body = JSON.stringify(problem); + return new Promise((resolve, reject) => { + const req = http.request( + "http://localhost:27121", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + }, + (res) => { + res.resume(); + res.on("end", () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(); + } else { + reject(new Error(`CPH 返回状态码 ${res.statusCode}`)); + } + }); + } + ); + req.on("error", (err) => { + if ((err as NodeJS.ErrnoException).code === "ECONNREFUSED") { + reject(new CphNotRunningError()); + } else { + reject(new Error(`连接 CPH 失败: ${err.message}`)); + } + }); + req.write(body); + req.end(); + }); +} diff --git a/apps/vscode-extension/src/tools/fetch.ts b/apps/vscode-extension/src/tools/fetch.ts index a47ab71..c1a6b0d 100644 --- a/apps/vscode-extension/src/tools/fetch.ts +++ b/apps/vscode-extension/src/tools/fetch.ts @@ -103,22 +103,37 @@ const BROWSER_HEADERS = { let sessionCookie = ""; +let staleCookieNotified = false; +let staleCookieHandler: (() => void) | null = null; + +export function setStaleCookieHandler(handler: (() => void) | null): void { + staleCookieHandler = handler; +} + +function notifyStaleCookie(): void { + if (staleCookieHandler && !staleCookieNotified) { + staleCookieNotified = true; + staleCookieHandler(); + } +} + export function setSessionCookie(cookie: string): void { console.log(`[setSessionCookie] 设置 Cookie: ${cookie ? cookie.substring(0, 40) + "..." : "清空"}`); sessionCookie = cookie; + staleCookieNotified = false; } export function getSessionCookie(): string { return sessionCookie; } -export function getHeaders(): Record { +export function getHeaders(withCookie = true): Record { const headers: Record = { ...BROWSER_HEADERS }; - if (sessionCookie) { + if (withCookie && sessionCookie) { headers["Cookie"] = sessionCookie; console.log(`[getHeaders] 已注入 Cookie: ${sessionCookie.substring(0, 40)}...`); } else { - console.log(`[getHeaders] 未设置 Cookie`); + console.log(`[getHeaders] 未注入 Cookie`); } return headers; } @@ -279,24 +294,41 @@ function handleResponse( }); } -export function fetchText(url: string): Promise { +export function fetchText(url: string, opts?: { withCookie?: boolean }): Promise { + const withCookie = opts?.withCookie ?? true; + if (withCookie && sessionCookie) { + return fetchTextOnce(url, true).catch((error) => { + if (error instanceof CfError) { + console.log(`[fetchText] 带 Cookie 请求触发 Cloudflare,改用无 Cookie 重试: ${url}`); + return fetchTextOnce(url, false).then((body) => { + notifyStaleCookie(); + return body; + }); + } + throw error; + }); + } + return fetchTextOnce(url, withCookie); +} + +function fetchTextOnce(url: string, withCookie: boolean): Promise { const logPrefix = `[fetchText]`; const savedProxy = saveProxyEnv(); function restoreProxy() { restoreProxyEnv(savedProxy); } - console.log(`${logPrefix} 开始请求`, url, `Cookie: ${sessionCookie ? sessionCookie.substring(0, 25) + "..." : "无"}`); + console.log(`${logPrefix} 开始请求`, url, withCookie ? `Cookie: ${sessionCookie ? sessionCookie.substring(0, 25) + "..." : "无"}` : "无 Cookie(降级)"); return new Promise((resolve, reject) => { const client = url.startsWith("https") ? https : http; const req = client.get( url, { - headers: getHeaders(), + headers: getHeaders(withCookie), agent: getDirectAgent(url), }, (res) => { - handleResponse(url, res, savedProxy, resolve, reject, fetchText); + handleResponse(url, res, savedProxy, resolve, reject, (u) => fetchTextOnce(u, withCookie), logPrefix); } ); req.on("error", (err: Error) => { @@ -371,6 +403,30 @@ export async function fetchSubStatus(contest: string): Promise]+>/g, "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) + .replace(/ /gi, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function splitRowCells(rowHtml: string): string[] { + const cells: string[] = []; + const cellRegex = /]*>([\s\S]*?)<\/td>/gi; + let match: RegExpExecArray | null; + while ((match = cellRegex.exec(rowHtml)) !== null) { + cells.push(match[1]); + } + return cells; +} + export async function fetchSubmitHistory(contest: string): Promise { const html = await fetchText(`https://atcoder.jp/contests/${contest}/submissions/me`); const records: SubRecord[] = []; @@ -378,26 +434,28 @@ export async function fetchSubmitHistory(contest: string): Promise let rowMatch: RegExpExecArray | null; for (; (rowMatch = reg.exec(html)) !== null;) { const rowHtml = rowMatch[1]; - const timeMatch = rowHtml.match(/]*class="text-center"[^>]*>([\s\S]*?)<\/td>/i); - if (!timeMatch) continue; - const time = timeMatch[1].trim().replace(/<[^>]+>/g, ""); + const cells = splitRowCells(rowHtml); + if (cells.length < 7) continue; - const taskLinkMatch = rowHtml.match(/href="\/contests\/[^/]+\/tasks\/([^"#?]+)"[^>]*>([^<]+)]*>([^<]+)<\/time>/i); + const time = timeMatch ? timeMatch[1].trim() : stripHtmlTags(cells[0]); + if (!time) continue; + + const taskLinkMatch = cells[1].match(/href="[^"]*\/tasks\/([^"#?]+)"[^>]*>([\s\S]*?)<\/a>/i); if (!taskLinkMatch) continue; const taskScreenName = taskLinkMatch[1]; - const task = taskLinkMatch[2].trim(); + const task = stripHtmlTags(taskLinkMatch[2]); - const langMatch = rowHtml.match(/]*class="text-center"[^>]*>[\s\S]*?<\/td>\s*]*>([^<]*)<\/td>/i); - const language = langMatch ? langMatch[1].trim() : ""; + const language = stripHtmlTags(cells[3]); - const scoreMatch = rowHtml.match(/]*class="text-right"[^>]*>\s*(\d+)\s*<\/td>/i); + const scoreMatch = cells[4].match(/(\d+)/); const score = scoreMatch ? scoreMatch[1] : "0"; - const statusMatch = rowHtml.match(/]*class=(["'])[^"']*\blabel\b[^"']*\1[^>]*>\s*([^<]+)\s*<\/span>/i); + const statusMatch = cells[6].match(/]*class=(["'])[^"']*\blabel\b[^'"]*\1[^>]*>\s*([^<]+)\s*<\/span>/i); if (!statusMatch) continue; const status = statusMatch[2].trim(); - const detailMatch = rowHtml.match(/]*href="\/contests\/[^/]+\/submissions\/(\d+)"[^>]*>/i); + const detailMatch = rowHtml.match(/href="\/contests\/[^/]+\/submissions\/(\d+)"/i); if (!detailMatch) continue; const id = detailMatch[1]; @@ -405,4 +463,3 @@ export async function fetchSubmitHistory(contest: string): Promise } return records; } - diff --git a/apps/vscode-extension/src/tools/homepage.ts b/apps/vscode-extension/src/tools/homepage.ts new file mode 100644 index 0000000..e60f978 --- /dev/null +++ b/apps/vscode-extension/src/tools/homepage.ts @@ -0,0 +1,42 @@ +import { fetchText } from "./fetch"; + +export type HomepageContestCategory = "active" | "upcoming" | "recent" | "daily"; + +export interface HomepageContest { + id: string; + title: string; + start: string; + category: HomepageContestCategory; +} + +const CATEGORY_ORDER: HomepageContestCategory[] = ["active", "upcoming", "recent", "daily"]; + +export function parseHomepageContests(html: string): HomepageContest[] { + const contests: HomepageContest[] = []; + for (const category of CATEGORY_ORDER) { + const marker = `id="contest-table-${category}"`; + const sectionStart = html.indexOf(marker); + if (sectionStart === -1) continue; + const tableEnd = html.indexOf("", sectionStart); + const sectionEnd = tableEnd === -1 ? html.length : tableEnd; + const section = html.slice(sectionStart, sectionEnd); + for (const row of section.split("")) { + const idMatch = row.match(/href="\/contests\/([a-zA-Z0-9_-]+)"/); + if (!idMatch) continue; + const titleMatch = row.match(/([^<]+)<\/a>/); + const timeMatch = row.match(/