diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml new file mode 100644 index 0000000..e9e2802 --- /dev/null +++ b/.github/workflows/pr-review.yml @@ -0,0 +1,114 @@ +name: PR Review + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: write + +jobs: + review: + name: Code Review + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v3 + with: + version: 8.10.0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - id: lint + continue-on-error: true + run: pnpm lint 2>&1 | tee lint_output.txt + + - id: build + continue-on-error: true + run: pnpm build 2>&1 | tee build_output.txt + + - id: test + continue-on-error: true + run: pnpm test:unit 2>&1 | tee test_output.txt + working-directory: apps/vscode-extension + + - id: linelength + continue-on-error: true + shell: bash + run: | + overages="" + while IFS= read -r file; do + if [ -f "$file" ]; then + results=$(node -e " + var fs=require('fs'); + var lines=fs.readFileSync('$file','utf-8').split('\n'); + var start=-1,name=''; + for(var i=0;i=0){ + var cnt=i-start; + if(cnt>130)console.log(name+' in $file ('+cnt+' lines, line '+(start+1)+')'); + } + start=i;name=m[3]; + } + } + ") + if [ -n "$results" ]; then overages="$overages"$'\n'"$results"; fi + fi + done < <(find apps packages -name "*.ts" -o -name "*.tsx") + echo "$overages" | tee linelength_output.txt + if [ -z "$overages" ]; then echo "passed=true" >> $GITHUB_OUTPUT; else echo "passed=false" >> $GITHUB_OUTPUT; fi + + - env: + GH_TOKEN: ${{ github.token }} + BUILD: ${{ steps.build.outcome }} + LINT: ${{ steps.lint.outcome }} + TEST: ${{ steps.test.outcome }} + LL: ${{ steps.linelength.outputs.passed }} + shell: bash + run: | + echo "## PR 审查报告" > comment.md + echo "" >> comment.md + echo "### 检查结果" >> comment.md + echo "" >> comment.md + echo "| 检查项 | 状态 |" >> comment.md + echo "|--------|------|" >> comment.md + + if [ "$BUILD" = "success" ]; then echo "| \`pnpm build\` | :white_check_mark: |" >> comment.md; else echo "| \`pnpm build\` | :x: |" >> comment.md; fi + if [ "$LINT" = "success" ]; then echo "| \`pnpm lint\` | :white_check_mark: |" >> comment.md; else echo "| \`pnpm lint\` | :x: |" >> comment.md; fi + if [ "$TEST" = "success" ]; then echo "| \`pnpm test\` | :white_check_mark: |" >> comment.md; else echo "| \`pnpm test\` | :x: |" >> comment.md; fi + if [ "$LL" = "true" ]; then echo "| 函数 <= 130 行 | :white_check_mark: |" >> comment.md; else echo "| 函数 <= 130 行 | :x: |" >> comment.md; fi + + echo "" >> comment.md + echo "### 未通过的检查" >> comment.md + echo "" >> comment.md + + any_fail=false + if [ "$BUILD" != "success" ]; then echo "- [ ] \`pnpm build\`" >> comment.md; any_fail=true; fi + if [ "$LINT" != "success" ]; then echo "- [ ] \`pnpm lint\`" >> comment.md; any_fail=true; fi + if [ "$TEST" != "success" ]; then echo "- [ ] \`pnpm test\`" >> comment.md; any_fail=true; fi + if [ "$LL" != "true" ]; then echo "- [ ] 函数长度不超过 130 行" >> comment.md; any_fail=true; fi + + if [ "$any_fail" = false ]; then echo "全部通过,无需修改。" >> comment.md; fi + + echo "" >> comment.md + echo "---" >> comment.md + echo "" >> comment.md + echo "*自动生成 by PR Review workflow*" >> comment.md + + gh pr comment ${{ github.event.pull_request.number }} --body "$(cat comment.md)" + + if [ "$any_fail" = true ]; then exit 1; fi diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 85ce708..bef2e72 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -18,7 +18,7 @@ "presentation": { "reveal": "silent", "panel": "dedicated" }, "group": { "kind": "build", "isDefault": true }, "options": { - "cwd": "${workspaceFolder}/packages/vscode-extension" + "cwd": "${workspaceFolder}/apps/vscode-extension" } }, { @@ -38,7 +38,7 @@ "presentation": { "reveal": "silent", "panel": "dedicated" }, "group": "build", "options": { - "cwd": "${workspaceFolder}/packages/vscode-extension" + "cwd": "${workspaceFolder}/apps/vscode-extension" } } ] diff --git a/apps/vscode-extension/src/SignUpContest.ts b/apps/vscode-extension/src/SignUpContest.ts new file mode 100644 index 0000000..51d9223 --- /dev/null +++ b/apps/vscode-extension/src/SignUpContest.ts @@ -0,0 +1,56 @@ +import { fetchText, fetchTextPost, CfError, LoginRequiredError, ProxyError } from "./tools/fetch"; + +export interface ContestPage { + contest: string; + title: string; + url: string; + signed: boolean; + csrfToken: string; + Rated: boolean; +} + +export async function fetchContest(contest: string): Promise { + const url = `https://atcoder.jp/contests/${contest}`; + const html = await fetchText(url); + const titleMatch = html.match(/([^<]+)<\/title>/i); + const title = titleMatch ? titleMatch[1].trim() : "Untitled"; + const formRegex = new RegExp( + `<form[^>]*action="[^"]*${contest}\/register"[^>]*>([\\s\\S]*?)<\\/form>`, + "i" + ); + const from = html.match(formRegex)?.[1]; + let csrfToken = "", signed = false, Rated = false; + if (from) { + const csrfMatch = from.match(/name="csrf_token"[^>]*value="([^"]*)"/i); + csrfToken = csrfMatch ? (csrfMatch[1] ?? "") : ""; + signed = /<button[^>]*>Unregister<\/button>/i.test(from); + Rated = /<input[^>]*name\s*=\s*"rated"/i.test(from) + } + return { contest, title, url, signed, csrfToken, Rated }; +} + +export async function signedUpContest(contest: string, csrfToken: string, rated?: boolean): Promise<{ success: boolean; message: string }> { + const url = `https://atcoder.jp/contests/${contest}/register`; + let body = `csrf_token=${encodeURIComponent(csrfToken)}&terms=on`; + + if (rated) body += `&rated=on`; + + try { + const html = await fetchTextPost(url, body); + + const isSigned = /Unregister|registered/i.test(html); + + if (isSigned) { + return { success: true, message: "报名成功!" }; + } + + const errMatch = html.match(/<div[^>]*class="[^"]*(?:alert-danger|alert-warning|alert-error)[^"]*"[^>]*>([\s\S]*?)<\/div>/i); + const errMsg = errMatch ? errMatch[1].replace(/<[^>]+>/g, "").trim() : "报名失败,请检查 Cookie 是否有效"; + return { success: false, message: errMsg }; + } catch (error) { + if (error instanceof CfError || error instanceof LoginRequiredError || error instanceof ProxyError) { + throw error; + } + return { success: false, message: error instanceof Error ? error.message : "报名请求失败" }; + } +} \ No newline at end of file diff --git a/apps/vscode-extension/src/atcoder.test.ts b/apps/vscode-extension/src/atcoder.test.ts index 51058c6..5ed9ca2 100644 --- a/apps/vscode-extension/src/atcoder.test.ts +++ b/apps/vscode-extension/src/atcoder.test.ts @@ -1,5 +1,6 @@ import * as assert from "assert"; import { parseProblemPage } from "./atcoder"; +import { setSessionCookie, getSessionCookie } from "./tools/fetch"; const sampleHtml = ` <html> @@ -44,7 +45,6 @@ assert.ok(mathResult.constraints.includes('class="katex"'), "should have math re console.log("HTML/LaTeX rendering test passed"); // Cookie tests -import { setSessionCookie, getSessionCookie } from "./atcoder"; assert.strictEqual(getSessionCookie(), "", "default cookie should be empty"); setSessionCookie("REVEL_SESSION=test123"); assert.strictEqual(getSessionCookie(), "REVEL_SESSION=test123", "cookie should be set"); diff --git a/apps/vscode-extension/src/atcoder.ts b/apps/vscode-extension/src/atcoder.ts index 233b8db..62bd094 100644 --- a/apps/vscode-extension/src/atcoder.ts +++ b/apps/vscode-extension/src/atcoder.ts @@ -1,49 +1,5 @@ -import * as https from "https"; -import * as http from "http"; -import * as zlib from "zlib"; -import * as net from "net"; -import * as tls from "tls"; import katex from "katex"; - -export class CfError extends Error { - url: string; - constructor(url: string) { - super(`AtCoder 需要 Cloudflare 验证,请在浏览器中打开 ${url} 完成验证后重试`); - this.name = "CfError"; - this.url = url; - } -} - -export class ProxyError extends Error { - constructor() { - super( - `网络代理连接失败,无法访问 AtCoder。\n` + - `可能原因:在 WSL 2 中,代理地址 127.0.0.1 指向 WSL 而非 Windows 宿主机。\n` + - `解决方案:\n` + - ` 1. 在 WSL 中执行: export NO_PROXY=.atcoder.jp\n` + - ` 2. 或设置正确的宿主机 IP: export HTTPS_PROXY=http://$(hostname).local:7897\n` + - ` 3. 或连接 Windows 宿主机的 WSL 网关 IP(查看 /etc/resolv.conf)` - ); - this.name = "ProxyError"; - } -} - -export class LoginRequiredError extends Error { - url: string; - constructor(url: string) { - super( - `访问需要登录,请设置 AtCoder Cookie。\n` + - `获取方法:\n` + - ` 1. 在浏览器中登录 https://atcoder.jp\n` + - ` 2. 按 F12 打开开发者工具 → Application → Cookies\n` + - ` 3. 找到 atcoder.jp 下的 REVEL_SESSION,复制其 Value\n` + - ` 4. 在插件设置中输入: REVEL_SESSION=复制的值` - ); - this.name = "LoginRequiredError"; - this.url = url; - } -} - +import { fetchText, CfError, ProxyError, LoginRequiredError } from "./tools/fetch"; export interface SampleCase { index: number; @@ -62,245 +18,6 @@ export interface AtCoderProblem { samples: SampleCase[]; } - - -const cfPatterns = [ - "Just a moment", - "Checking your browser", - "cf-challenge", - "challenge-form", - "Cloudflare", - "cf-browser-verification", - "attention required", - "checking-browser", - "challenge-platform", - "cf-im-under-attack", -]; - -function isCfChallenge(body: string): boolean { - const lower = body.toLowerCase(); - const matched = cfPatterns.find((p) => lower.includes(p)); - if (matched) { - console.log(`[isCfChallenge] 匹配到 CF 特征: "${matched}"`); - } - return !!matched; -} - -function isLoginPage(body: string): boolean { - if (body.length < 500) { - console.log(`[isLoginPage] body 过短 (${body.length}), 跳过`); - return false; - } - const lower = body.toLowerCase(); - if (lower.includes("sign in") && lower.includes("password")) { - console.log(`[isLoginPage] 匹配: sign in + password`); - return true; - } - const loginForm = /<form[^>]*action\s*=\s*"\/login[^"]*"/i.test(body); - const loginHref = /<a[^>]*href\s*=\s*"\/login[^"]*"/i.test(body); - const hasLoginFields = /<input[^>]*(name\s*=\s*"(username|password|csrf_token)")/i.test(body); - if (loginForm && (loginHref || hasLoginFields)) { - console.log(`[isLoginPage] 匹配: login 表单 + 链接/输入框`); - return true; - } - console.log(`[isLoginPage] 未匹配, body.preview=${body.substring(0, 200).replace(/\n/g, " ")}`); - return false; -} - -const BROWSER_HEADERS = { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - Accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", - "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", - "Accept-Encoding": "gzip, deflate, br", - "Cache-Control": "no-cache", - Pragma: "no-cache", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", -}; - -let sessionCookie = ""; - -export function setSessionCookie(cookie: string): void { - console.log(`[setSessionCookie] 设置 Cookie: ${cookie ? cookie.substring(0, 40) + "..." : "清空"}`); - sessionCookie = cookie; -} - -export function getSessionCookie(): string { - return sessionCookie; -} - -function getHeaders(): Record<string, string> { - const headers: Record<string, string> = { ...BROWSER_HEADERS }; - if (sessionCookie) { - headers["Cookie"] = sessionCookie; - console.log(`[getHeaders] 已注入 Cookie: ${sessionCookie.substring(0, 40)}...`); - } else { - console.log(`[getHeaders] 未设置 Cookie`); - } - return headers; -} - -function isProxyError(err: Error): boolean { - const msg = err.message.toLowerCase(); - return msg.includes("proxy") || msg.includes("connect") || msg.includes("econnrefused") || msg.includes("econnreset"); -} - -function getAgent(url: string): http.Agent | https.Agent { - const isHttps = url.startsWith("https"); - const baseAgent = isHttps ? https : http; - return new (baseAgent as any).Agent({ - keepAlive: true, - keepAliveMsecs: 1000, - createConnection: (options: any, callback: any) => { - const hostname = options.hostname || options.host || "localhost"; - const port = options.port || (isHttps ? 443 : 80); - const socket = net.connect(port, hostname, () => { - if (isHttps) { - callback(null, tls.connect({ - socket, - host: hostname, - servername: hostname, - })); - } else { - callback(null, socket); - } - }); - socket.on("error", callback); - }, - }); -} - -const directAgents = new Map<string, http.Agent | https.Agent>(); - -function getDirectAgent(url: string): http.Agent | https.Agent { - const key = url.startsWith("https") ? "https" : "http"; - if (!directAgents.has(key)) { - directAgents.set(key, getAgent(url)); - } - return directAgents.get(key)!; -} - -function fetchText(url: string): Promise<string> { - const logPrefix = `[fetchText]`; - - const savedProxy = { - HTTP_PROXY: process.env.HTTP_PROXY, - HTTPS_PROXY: process.env.HTTPS_PROXY, - NO_PROXY: process.env.NO_PROXY, - http_proxy: process.env.http_proxy, - https_proxy: process.env.https_proxy, - no_proxy: process.env.no_proxy, - }; - delete process.env.HTTP_PROXY; - delete process.env.HTTPS_PROXY; - delete process.env.http_proxy; - delete process.env.https_proxy; - process.env.NO_PROXY = "*"; - process.env.no_proxy = "*"; - - function restoreProxy() { - for (const [k, v] of Object.entries(savedProxy)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - } - - console.log(`${logPrefix} 开始请求`, url, `Cookie: ${sessionCookie ? sessionCookie.substring(0, 25) + "..." : "无"}`); - return new Promise((resolve, reject) => { - const client = url.startsWith("https") ? https : http; - const req = client.get( - url, - { - headers: getHeaders(), - agent: getDirectAgent(url), - }, - (res) => { - console.log(`${logPrefix} 收到响应:`, url, `status=${res.statusCode}`, `headers=${JSON.stringify(res.headers)}`); - - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - console.log(`${logPrefix} 重定向:`, res.statusCode, `->`, res.headers.location); - restoreProxy(); - if (res.headers.location.includes("/login")) { - console.log(`${logPrefix} 检测到登录重定向`); - reject(new LoginRequiredError(url)); - return; - } - resolve(fetchText(res.headers.location)); - return; - } - - const encoding = res.headers["content-encoding"] || ""; - let stream: any = res; - if (encoding.includes("br")) { - stream = res.pipe(zlib.createBrotliDecompress()); - } else if (encoding.includes("gzip")) { - stream = res.pipe(zlib.createGunzip()); - } else if (encoding.includes("deflate")) { - stream = res.pipe(zlib.createInflate()); - } - - const chunks: Buffer[] = []; - stream.on("data", (chunk: Buffer) => { - chunks.push(chunk); - }); - stream.on("end", () => { - const body = Buffer.concat(chunks).toString("utf8"); - console.log(`${logPrefix} 响应体:`, url, `status=${res.statusCode}`, `body.length=${body.length}`, `body.preview=${body.substring(0, 300)}`); - - restoreProxy(); - - if (res.statusCode === 403 || isCfChallenge(body)) { - console.log(`${logPrefix} 检测到 Cloudflare 挑战`); - reject(new CfError(url)); - return; - } - if (isLoginPage(body)) { - console.log(`${logPrefix} 检测到登录页面`); - reject(new LoginRequiredError(url)); - return; - } - if (res.statusCode !== 200) { - if (res.statusCode === 404) { - if (sessionCookie) { - console.log(`${logPrefix} 404 但有 Cookie,可能 Cookie 无效`); - reject(new Error(`访问失败 (404)。Cookie 可能无效或已过期,请重新登录 AtCoder 获取新的 REVEL_SESSION`)); - return; - } - console.log(`${logPrefix} 404 且无 Cookie,需要登录`); - reject(new Error(`访问失败 (404)。题目不存在或需要登录,请先设置 AtCoder Cookie。`)); - return; - } - console.log(`${logPrefix} 非 200 状态码:`, res.statusCode); - reject(new Error(`Request failed with status ${res.statusCode}`)); - return; - } - - console.log(`${logPrefix} 请求成功:`, url, `body.length=${body.length}`); - resolve(body); - }); - stream.on("error", (err: Error) => { - restoreProxy(); - reject(err); - }); - } - ); - req.on("error", (err: Error) => { - restoreProxy(); - console.log(`${logPrefix} 请求错误:`, url, err.message); - if (isProxyError(err)) { - reject(new ProxyError()); - return; - } - reject(new Error(`网络错误: ${err.message}`)); - }); - }); -} - function decodeEntities(text: string): string { return text .replace(/&/g, "&") diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 3cce237..ceea4ca 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,7 +1,9 @@ import * as vscode from "vscode"; import * as path from "path"; -import * as https from "https"; -import { fetchAtCoderProblem, fetchAtCoderTasks, CfError, ProxyError, LoginRequiredError, setSessionCookie } from "./atcoder"; +import { fetchAtCoderProblem, fetchAtCoderTasks } from "./atcoder"; +import { CfError, ProxyError, LoginRequiredError, setSessionCookie } from "./tools/fetch"; +import { fetchContest, signedUpContest } from "./SignUpContest"; +import { translateTextRaw } from "./tools/deepl"; const log = { info: (...args: any[]) => { @@ -29,339 +31,281 @@ function getWebviewContent(webviewJsSrc: vscode.Uri): string { </html>`; } -export function activate(context: vscode.ExtensionContext) { - log.info("Extension is now active!"); +function handleErrorWithCfAndLogin(error: unknown, send: (payload: any) => void): boolean { + if (error instanceof CfError) { + const open = "在浏览器中打开并验证"; + const setCookie = "验证后设置 Cookie"; + vscode.window.showErrorMessage(error.message, open, setCookie).then((choice) => { + if (choice === open) vscode.env.openExternal(vscode.Uri.parse(error.url)); + if (choice === setCookie) vscode.commands.executeCommand("extension.setAtCoderCookie"); + }); + send({ type: "cf_challenge", url: error.url }); + return true; + } + if (error instanceof ProxyError) { + const fixNoProxy = "设置 NO_PROXY"; + const fixWsl = "查看 WSL 代理说明"; + vscode.window.showErrorMessage("代理连接失败,无法访问 AtCoder", fixNoProxy, fixWsl).then((choice) => { + if (choice === fixNoProxy) vscode.env.openExternal(vscode.Uri.parse("https://github.com/anomalyco/opencode/issues")); + if (choice === fixWsl) vscode.env.openExternal(vscode.Uri.parse("https://learn.microsoft.com/zh-cn/windows/wsl/networking")); + }); + send({ type: "error", text: error.message }); + return true; + } + if (error instanceof LoginRequiredError) { + const openLogin = "打开 AtCoder 登录"; + const setCookie = "设置 Cookie"; + vscode.window.showErrorMessage(error.message, openLogin, setCookie).then((choice) => { + if (choice === openLogin) vscode.env.openExternal(vscode.Uri.parse("https://atcoder.jp/login")); + if (choice === setCookie) vscode.commands.executeCommand("extension.setAtCoderCookie"); + }); + send({ type: "loginRequired" }); + return true; + } + return false; +} +async function handleContestLoad(contest: string, send: (payload: any) => void) { + send({ type: "loading", text: `正在抓取 ${contest} 的题目列表...` }); try { - context.secrets.get("atcoderCookie").then((cookie) => { - if (cookie) { - setSessionCookie(cookie); - log.info("已加载 AtCoder 登录 Cookie"); - } - }); + const tasks = await fetchAtCoderTasks(contest); + send({ type: "tasks", tasks }); + } catch (error) { + if (!handleErrorWithCfAndLogin(error, send)) { + send({ type: "error", text: error instanceof Error ? error.message : "抓取题目失败" }); + } + } +} - context.subscriptions.push( - vscode.commands.registerCommand("extension.setDeeplApiKey", async () => { - const key = await vscode.window.showInputBox({ - prompt: "请输入 DeepL API Key", - password: true, - placeHolder: "例如 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:fx", - ignoreFocusOut: true, - }); - if (key?.trim()) { - await context.secrets.store("deeplApiKey", key.trim()); - vscode.window.showInformationMessage("DeepL API Key 已保存"); - } - }) - ); +async function handleProblemLoad(contest: string, task: string, send: (payload: any) => void) { + send({ type: "loading", text: `正在抓取 ${contest}/${task} 的题面...` }); + try { + const problem = await fetchAtCoderProblem(contest, task); + send({ type: "problem", problem }); + } catch (error) { + if (!handleErrorWithCfAndLogin(error, send)) { + send({ type: "error", text: error instanceof Error ? error.message : "抓取题面失败" }); + } + } +} - context.subscriptions.push( - vscode.commands.registerCommand("extension.setAtCoderCookie", async () => { - const cookie = await vscode.window.showInputBox({ - prompt: "粘贴 AtCoder 的 Cookie(仅需 REVEL_SESSION)", - password: true, - placeHolder: "REVEL_SESSION=abcdef1234567890abcdef1234567890", - ignoreFocusOut: true, - }); - if (cookie?.trim()) { - const trimmed = cookie.trim(); - if (!trimmed.startsWith("REVEL_SESSION=")) { - const fix = `REVEL_SESSION=${trimmed}`; - const choice = await vscode.window.showWarningMessage( - `Cookie 格式似乎不正确,是否添加 REVEL_SESSION= 前缀?`, - { modal: false }, - "自动修复", - "取消" - ); - if (choice === "自动修复") { - await context.secrets.store("atcoderCookie", fix); - setSessionCookie(fix); - vscode.window.showInformationMessage("AtCoder Cookie 已保存并自动修复格式"); - } - return; - } - await context.secrets.store("atcoderCookie", trimmed); - setSessionCookie(trimmed); - vscode.window.showInformationMessage("AtCoder Cookie 已保存"); - } - }) - ); +async function handleTranslate( + payload: Record<string, string> | undefined, + targetLang: string | undefined, + context: vscode.ExtensionContext, + send: (payload: any) => void, +) { + const lang = targetLang ?? "ZH"; + const texts = payload ?? {}; + const translated: Record<string, string> = {}; + try { + const apiKey = await context.secrets.get("deeplApiKey"); + if (!apiKey) { + const set = "设置 API Key"; + const choice = await vscode.window.showErrorMessage("请先设置 DeepL API Key", set); + if (choice === set) vscode.commands.executeCommand("extension.setDeeplApiKey"); + send({ type: "error", text: "未设置 DeepL API Key" }); + return; + } + for (const [key, value] of Object.entries(texts)) { + if (typeof value === "string" && value.trim()) { + send({ type: "loading", text: `正在翻译 ${key}...` }); + translated[key] = await translateTextRaw(value, lang, apiKey); + } + } + send({ type: "translation", translated }); + } catch (error) { + send({ type: "error", text: error instanceof Error ? error.message : "翻译失败" }); + } +} - let disposable = vscode.commands.registerCommand( - "extension.showWebview", - () => { - try { - const panel = vscode.window.createWebviewPanel( - "templateWebview", - "AtCoder 题目浏览器", - vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.file(path.join(context.extensionPath, "dist")), - ], - } - ); +async function handleGetCookie(context: vscode.ExtensionContext, send: (payload: any) => void) { + const storedCookie = await context.secrets.get("atcoderCookie"); + const masked = storedCookie ? storedCookie.substring(0, 20) + "..." : ""; + send({ + type: "cookieStatus", + hasCookie: !!storedCookie, + masked, + statusMessage: storedCookie ? "✅ Cookie 已加载,可访问需要登录的题目" : "未设置 Cookie", + }); +} - const webviewJsPath = vscode.Uri.file( - path.join(context.extensionPath, "dist", "webview.js") - ); - const webviewJsSrc = panel.webview.asWebviewUri(webviewJsPath); +async function handleSetCookie( + cookie: string | undefined, + context: vscode.ExtensionContext, + send: (payload: any) => void, +) { + if (cookie) { + if (!cookie.startsWith("REVEL_SESSION=")) { + send({ type: "cookieStatus", hasCookie: false, statusMessage: "❌ Cookie 格式错误,请以 REVEL_SESSION= 开头" }); + return; + } + if (cookie.length < 20) { + send({ type: "cookieStatus", hasCookie: false, statusMessage: "❌ Cookie 值过短,请确认已完整复制 REVEL_SESSION 的值" }); + return; + } + await context.secrets.store("atcoderCookie", cookie); + setSessionCookie(cookie); + vscode.window.showInformationMessage("AtCoder Cookie 已保存"); + send({ type: "cookieStatus", hasCookie: true, statusMessage: "✅ Cookie 保存成功" }); + } else { + await context.secrets.delete("atcoderCookie"); + setSessionCookie(""); + send({ type: "cookieStatus", hasCookie: false, statusMessage: "Cookie 已清除" }); + } +} - panel.webview.html = getWebviewContent(webviewJsSrc); +async function handleRegistration(contest: string, rated: boolean | undefined, send: (payload: any) => void) { + send({ type: "loading", text: `正在报名 ${contest} ...` }); + try { + const page = await fetchContest(contest); + if (page.signed) { + send({ type: "registrationStatus", signed: true, registrationMessage: "已报名,无需重复操作" }); + return; + } + if (!page.csrfToken) { + send({ type: "registrationStatus", signed: false, registrationMessage: "报名已截止或无法获取报名信息" }); + return; + } + const result = await signedUpContest(contest, page.csrfToken, rated); + send({ type: "registrationStatus", signed: result.success, registrationMessage: result.message }); + } catch (error) { + if (!handleErrorWithCfAndLogin(error, send)) { + send({ type: "registrationStatus", signed: false, registrationMessage: error instanceof Error ? error.message : "报名失败" }); + } + } +} - const sendToWebview = (payload: unknown) => { - panel.webview.postMessage(payload); - }; +function registerSetDeeplApiKey(context: vscode.ExtensionContext) { + return vscode.commands.registerCommand("extension.setDeeplApiKey", async () => { + const key = await vscode.window.showInputBox({ + prompt: "请输入 DeepL API Key", + password: true, + placeHolder: "例如 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:fx", + ignoreFocusOut: true, + }); + if (key?.trim()) { + await context.secrets.store("deeplApiKey", key.trim()); + vscode.window.showInformationMessage("DeepL API Key 已保存"); + } + }); +} - const handleCfError = (err: CfError) => { - const open = "在浏览器中打开并验证"; - const setCookie = "验证后设置 Cookie"; - vscode.window.showErrorMessage(err.message, open, setCookie).then((choice) => { - if (choice === open) { - vscode.env.openExternal(vscode.Uri.parse(err.url)); - } - if (choice === setCookie) { - vscode.commands.executeCommand("extension.setAtCoderCookie"); - } - }); - }; +function registerSetAtCoderCookie(context: vscode.ExtensionContext) { + return vscode.commands.registerCommand("extension.setAtCoderCookie", async () => { + const cookie = await vscode.window.showInputBox({ + prompt: "粘贴 AtCoder 的 Cookie(仅需 REVEL_SESSION)", + password: true, + placeHolder: "REVEL_SESSION=abcdef1234567890abcdef1234567890", + ignoreFocusOut: true, + }); + if (!cookie?.trim()) return; + const trimmed = cookie.trim(); + if (!trimmed.startsWith("REVEL_SESSION=")) { + const fix = `REVEL_SESSION=${trimmed}`; + const choice = await vscode.window.showWarningMessage( + `Cookie 格式似乎不正确,是否添加 REVEL_SESSION= 前缀?`, + { modal: false }, + "自动修复", + "取消" + ); + if (choice === "自动修复") { + await context.secrets.store("atcoderCookie", fix); + setSessionCookie(fix); + vscode.window.showInformationMessage("AtCoder Cookie 已保存并自动修复格式"); + } + return; + } + await context.secrets.store("atcoderCookie", trimmed); + setSessionCookie(trimmed); + vscode.window.showInformationMessage("AtCoder Cookie 已保存"); + }); +} - const handleLoginRequired = (err: LoginRequiredError) => { - const openLogin = "打开 AtCoder 登录"; - const setCookie = "设置 Cookie"; - vscode.window.showErrorMessage(err.message, openLogin, setCookie).then((choice) => { - if (choice === openLogin) { - vscode.env.openExternal(vscode.Uri.parse("https://atcoder.jp/login")); - } - if (choice === setCookie) { - vscode.commands.executeCommand("extension.setAtCoderCookie"); - } - }); - }; +function createShowWebview(context: vscode.ExtensionContext) { + return () => { + const panel = vscode.window.createWebviewPanel( + "templateWebview", + "AtCoder 题目浏览器", + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.file(path.join(context.extensionPath, "dist")), + ], + } + ); - const handleProxyError = (err: ProxyError) => { - const fixNoProxy = "设置 NO_PROXY"; - const fixWsl = "查看 WSL 代理说明"; - vscode.window.showErrorMessage("代理连接失败,无法访问 AtCoder", fixNoProxy, fixWsl).then((choice) => { - if (choice === fixNoProxy) { - vscode.env.openExternal(vscode.Uri.parse("https://github.com/anomalyco/opencode/issues")); - } - if (choice === fixWsl) { - vscode.env.openExternal(vscode.Uri.parse("https://learn.microsoft.com/zh-cn/windows/wsl/networking")); - } - }); - }; + const webviewJsPath = vscode.Uri.file( + path.join(context.extensionPath, "dist", "webview.js") + ); + const webviewJsSrc = panel.webview.asWebviewUri(webviewJsPath); - const loadContest = async (contest: string) => { - sendToWebview({ type: "loading", text: `正在抓取 ${contest} 的题目列表...` }); - try { - const tasks = await fetchAtCoderTasks(contest); - sendToWebview({ type: "tasks", tasks }); - } catch (error) { - if (error instanceof CfError) { - handleCfError(error); - sendToWebview({ type: "cf_challenge", url: error.url }); - return; - } - if (error instanceof ProxyError) { - handleProxyError(error); - sendToWebview({ type: "error", text: error.message }); - return; - } - if (error instanceof LoginRequiredError) { - handleLoginRequired(error); - sendToWebview({ type: "loginRequired" }); - return; - } - sendToWebview({ type: "error", text: error instanceof Error ? error.message : "抓取题目失败" }); - } - }; + panel.webview.html = getWebviewContent(webviewJsSrc); - const loadProblem = async (contest: string, task: string) => { - sendToWebview({ type: "loading", text: `正在抓取 ${contest}/${task} 的题面...` }); - try { - const problem = await fetchAtCoderProblem(contest, task); - sendToWebview({ type: "problem", problem }); - } catch (error) { - if (error instanceof CfError) { - handleCfError(error); - sendToWebview({ type: "cf_challenge", url: error.url }); - return; - } - if (error instanceof ProxyError) { - handleProxyError(error); - sendToWebview({ type: "error", text: error.message }); - return; - } - if (error instanceof LoginRequiredError) { - handleLoginRequired(error); - sendToWebview({ type: "loginRequired" }); - return; - } - sendToWebview({ type: "error", text: error instanceof Error ? error.message : "抓取题面失败" }); - } - }; + const sendToWebview = (payload: unknown) => { + panel.webview.postMessage(payload); + }; - const translateText = async (text: string, targetLang: string): Promise<string> => { - const apiKey = await context.secrets.get("deeplApiKey"); - if (!apiKey) { - const set = "设置 API Key"; - const choice = await vscode.window.showErrorMessage("请先设置 DeepL API Key", set); - if (choice === set) { - vscode.commands.executeCommand("extension.setDeeplApiKey"); - } - throw new Error("未设置 DeepL API Key"); + panel.webview.onDidReceiveMessage( + async (message) => { + switch (message.command) { + case "loadContest": + await handleContestLoad(message.contest, sendToWebview); + return; + case "loadProblem": + await handleProblemLoad(message.contest, message.task, sendToWebview); + return; + case "openBrowser": + if (message.url) vscode.env.openExternal(vscode.Uri.parse(message.url)); + return; + case "translate": + await handleTranslate(message.payload, message.targetLang, context, sendToWebview); + return; + case "setApiKey": + if (message.text?.trim()) { + await context.secrets.store("deeplApiKey", message.text.trim()); + vscode.window.showInformationMessage("DeepL API Key 已保存"); } + return; + case "getCookie": + await handleGetCookie(context, sendToWebview); + return; + case "setCookie": + await handleSetCookie(message.text, context, sendToWebview); + return; + case "registerContest": + await handleRegistration(message.contest, message.rated, sendToWebview); + return; + case "alert": + vscode.window.showInformationMessage(message.text); + sendToWebview({ type: "update", text: `Extension received: ${message.text}` }); + return; + } + }, + undefined, + context.subscriptions + ); + }; +} - return new Promise((resolve, reject) => { - const params = new URLSearchParams({ text, target_lang: targetLang }); - const host = apiKey.endsWith(":fx") ? "api-free.deepl.com" : "api.deepl.com"; - const req = https.request( - { - hostname: host, - path: "/v2/translate", - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `DeepL-Auth-Key ${apiKey}`, - }, - }, - (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - try { - const json = JSON.parse(data); - if (res.statusCode && res.statusCode >= 400) { - reject(new Error(json.message || `翻译接口错误 (${res.statusCode})`)); - return; - } - resolve(json.translations?.[0]?.text ?? text); - } catch { - reject(new Error("翻译接口返回异常")); - } - }); - } - ); - req.on("error", () => reject(new Error("翻译请求失败"))); - req.write(params.toString()); - req.end(); - }); - }; - - panel.webview.onDidReceiveMessage( - async (message) => { - switch (message.command) { - case "loadContest": - await loadContest(message.contest); - return; - case "loadProblem": - await loadProblem(message.contest, message.task); - return; - case "openBrowser": - if (message.url) { - vscode.env.openExternal(vscode.Uri.parse(message.url)); - } - return; - case "translate": { - try { - const targetLang = message.targetLang ?? "ZH"; - const texts: Record<string, string> = message.payload ?? {}; - const translated: Record<string, string> = {}; - for (const [key, value] of Object.entries(texts)) { - if (typeof value === "string" && value.trim()) { - sendToWebview({ type: "loading", text: `正在翻译 ${key}...` }); - translated[key] = await translateText(value, targetLang); - } - } - sendToWebview({ type: "translation", translated }); - } catch (error) { - sendToWebview({ - type: "error", - text: error instanceof Error ? error.message : "翻译失败", - }); - } - return; - } - case "setApiKey": { - const key = message.text?.trim(); - if (key) { - await context.secrets.store("deeplApiKey", key); - vscode.window.showInformationMessage("DeepL API Key 已保存"); - } - return; - } - case "getCookie": { - const storedCookie = await context.secrets.get("atcoderCookie"); - const masked = storedCookie ? storedCookie.substring(0, 20) + "..." : ""; - sendToWebview({ - type: "cookieStatus", - hasCookie: !!storedCookie, - masked, - statusMessage: storedCookie ? "✅ Cookie 已加载,可访问需要登录的题目" : "未设置 Cookie", - }); - return; - } - case "setCookie": { - const cookie = message.text?.trim(); - if (cookie) { - if (!cookie.startsWith("REVEL_SESSION=")) { - sendToWebview({ - type: "cookieStatus", - hasCookie: false, - statusMessage: "❌ Cookie 格式错误,请以 REVEL_SESSION= 开头", - }); - return; - } - if (cookie.length < 20) { - sendToWebview({ - type: "cookieStatus", - hasCookie: false, - statusMessage: "❌ Cookie 值过短,请确认已完整复制 REVEL_SESSION 的值", - }); - return; - } - await context.secrets.store("atcoderCookie", cookie); - setSessionCookie(cookie); - vscode.window.showInformationMessage("AtCoder Cookie 已保存"); - sendToWebview({ - type: "cookieStatus", - hasCookie: true, - statusMessage: "✅ Cookie 保存成功", - }); - } else { - await context.secrets.delete("atcoderCookie"); - setSessionCookie(""); - sendToWebview({ - type: "cookieStatus", - hasCookie: false, - statusMessage: "Cookie 已清除", - }); - } - return; - } - case "alert": - vscode.window.showInformationMessage(message.text); - sendToWebview({ - type: "update", - text: `Extension received: ${message.text}`, - }); - return; - } - }, - undefined, - context.subscriptions - ); +export function activate(context: vscode.ExtensionContext) { + log.info("Extension is now active!"); - } catch (error) { - log.error("Failed to show webview:", error); - } + try { + context.secrets.get("atcoderCookie").then((cookie) => { + if (cookie) { + setSessionCookie(cookie); + log.info("已加载 AtCoder 登录 Cookie"); } - ); + }); - context.subscriptions.push(disposable); + context.subscriptions.push(registerSetDeeplApiKey(context)); + context.subscriptions.push(registerSetAtCoderCookie(context)); + context.subscriptions.push( + vscode.commands.registerCommand("extension.showWebview", createShowWebview(context)) + ); } catch (error) { log.error("Failed to activate extension:", error); } diff --git a/apps/vscode-extension/src/tools/deepl.ts b/apps/vscode-extension/src/tools/deepl.ts new file mode 100644 index 0000000..29dcc43 --- /dev/null +++ b/apps/vscode-extension/src/tools/deepl.ts @@ -0,0 +1,38 @@ +import * as https from "https"; + +export function translateTextRaw(text: string, targetLang: string, apiKey: string): Promise<string> { + return new Promise((resolve, reject) => { + const params = new URLSearchParams({ text, target_lang: targetLang }); + const host = apiKey.endsWith(":fx") ? "api-free.deepl.com" : "api.deepl.com"; + const req = https.request( + { + hostname: host, + path: "/v2/translate", + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `DeepL-Auth-Key ${apiKey}`, + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + try { + const json = JSON.parse(data); + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(json.message || `翻译接口错误 (${res.statusCode})`)); + return; + } + resolve(json.translations?.[0]?.text ?? text); + } catch { + reject(new Error("翻译接口返回异常")); + } + }); + } + ); + req.on("error", () => reject(new Error("翻译请求失败"))); + req.write(params.toString()); + req.end(); + }); +} diff --git a/apps/vscode-extension/src/tools/fetch.ts b/apps/vscode-extension/src/tools/fetch.ts new file mode 100644 index 0000000..871552b --- /dev/null +++ b/apps/vscode-extension/src/tools/fetch.ts @@ -0,0 +1,343 @@ +import * as https from "https"; +import * as http from "http"; +import * as zlib from "zlib"; +import * as net from "net"; +import * as tls from "tls"; + +export class CfError extends Error { + url: string; + constructor(url: string) { + super(`AtCoder 需要 Cloudflare 验证,请在浏览器中打开 ${url} 完成验证后重试`); + this.name = "CfError"; + this.url = url; + } +} + +export class ProxyError extends Error { + constructor() { + super( + `网络代理连接失败,无法访问 AtCoder。\n` + + `可能原因:在 WSL 2 中,代理地址 127.0.0.1 指向 WSL 而非 Windows 宿主机。\n` + + `解决方案:\n` + + ` 1. 在 WSL 中执行: export NO_PROXY=.atcoder.jp\n` + + ` 2. 或设置正确的宿主机 IP: export HTTPS_PROXY=http://$(hostname).local:7897\n` + + ` 3. 或连接 Windows 宿主机的 WSL 网关 IP(查看 /etc/resolv.conf)` + ); + this.name = "ProxyError"; + } +} + +export class LoginRequiredError extends Error { + url: string; + constructor(url: string) { + super( + `访问需要登录,请设置 AtCoder Cookie。\n` + + `获取方法:\n` + + ` 1. 在浏览器中登录 https://atcoder.jp\n` + + ` 2. 按 F12 打开开发者工具 → Application → Cookies\n` + + ` 3. 找到 atcoder.jp 下的 REVEL_SESSION,复制其 Value\n` + + ` 4. 在插件设置中输入: REVEL_SESSION=复制的值` + ); + this.name = "LoginRequiredError"; + this.url = url; + } +} + +const cfPatterns = [ + "Just a moment", + "Checking your browser", + "cf-challenge", + "challenge-form", + "Cloudflare", + "cf-browser-verification", + "attention required", + "checking-browser", + "challenge-platform", + "cf-im-under-attack", +]; + +function isCfChallenge(body: string): boolean { + const lower = body.toLowerCase(); + const matched = cfPatterns.find((p) => lower.includes(p)); + if (matched) { + console.log(`[isCfChallenge] 匹配到 CF 特征: "${matched}"`); + } + return !!matched; +} + +function isLoginPage(body: string): boolean { + if (body.length < 500) { + console.log(`[isLoginPage] body 过短 (${body.length}), 跳过`); + return false; + } + const lower = body.toLowerCase(); + if (lower.includes("sign in") && lower.includes("password")) { + console.log(`[isLoginPage] 匹配: sign in + password`); + return true; + } + const loginForm = /<form[^>]*action\s*=\s*"\/login[^"]*"/i.test(body); + const loginHref = /<a[^>]*href\s*=\s*"\/login[^"]*"/i.test(body); + const hasLoginFields = /<input[^>]*(name\s*=\s*"(username|password|csrf_token)")/i.test(body); + if (loginForm && (loginHref || hasLoginFields)) { + console.log(`[isLoginPage] 匹配: login 表单 + 链接/输入框`); + return true; + } + console.log(`[isLoginPage] 未匹配, body.preview=${body.substring(0, 200).replace(/\n/g, " ")}`); + return false; +} + +const BROWSER_HEADERS = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + Pragma: "no-cache", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", +}; + +let sessionCookie = ""; + +export function setSessionCookie(cookie: string): void { + console.log(`[setSessionCookie] 设置 Cookie: ${cookie ? cookie.substring(0, 40) + "..." : "清空"}`); + sessionCookie = cookie; +} + +export function getSessionCookie(): string { + return sessionCookie; +} + +export function getHeaders(): Record<string, string> { + const headers: Record<string, string> = { ...BROWSER_HEADERS }; + if (sessionCookie) { + headers["Cookie"] = sessionCookie; + console.log(`[getHeaders] 已注入 Cookie: ${sessionCookie.substring(0, 40)}...`); + } else { + console.log(`[getHeaders] 未设置 Cookie`); + } + return headers; +} + +function isProxyError(err: Error): boolean { + const msg = err.message.toLowerCase(); + return msg.includes("proxy") || msg.includes("connect") || msg.includes("econnrefused") || msg.includes("econnreset"); +} + +function getAgent(url: string): http.Agent | https.Agent { + const isHttps = url.startsWith("https"); + const baseAgent = isHttps ? https : http; + return new (baseAgent as any).Agent({ + keepAlive: true, + keepAliveMsecs: 1000, + createConnection: (options: any, callback: any) => { + const hostname = options.hostname || options.host || "localhost"; + const port = options.port || (isHttps ? 443 : 80); + const socket = net.connect(port, hostname, () => { + if (isHttps) { + callback(null, tls.connect({ + socket, + host: hostname, + servername: hostname, + })); + } else { + callback(null, socket); + } + }); + socket.on("error", callback); + }, + }); +} + +const directAgents = new Map<string, http.Agent | https.Agent>(); + +function getDirectAgent(url: string): http.Agent | https.Agent { + const key = url.startsWith("https") ? "https" : "http"; + if (!directAgents.has(key)) { + directAgents.set(key, getAgent(url)); + } + return directAgents.get(key)!; +} + +function saveProxyEnv() { + const saved = { + HTTP_PROXY: process.env.HTTP_PROXY, + HTTPS_PROXY: process.env.HTTPS_PROXY, + NO_PROXY: process.env.NO_PROXY, + http_proxy: process.env.http_proxy, + https_proxy: process.env.https_proxy, + no_proxy: process.env.no_proxy, + }; + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + process.env.NO_PROXY = "*"; + process.env.no_proxy = "*"; + return saved; +} + +function restoreProxyEnv(saved: Record<string, string | undefined>) { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +} + +function handleResponse( + url: string, + res: http.IncomingMessage, + restoreSaved: Record<string, string | undefined>, + resolve: (value: string | PromiseLike<string>) => void, + reject: (reason: any) => void, + redirectFetcher: (url: string) => Promise<string>, + logPrefix: string = "[fetchText]", +) { + console.log(`${logPrefix} 收到响应:`, url, `status=${res.statusCode}`); + + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + const redirectUrl = new URL(res.headers.location, url).href; + console.log(`${logPrefix} 重定向:`, res.statusCode, `->`, redirectUrl); + restoreProxyEnv(restoreSaved); + if (redirectUrl.includes("/login")) { + console.log(`${logPrefix} 检测到登录重定向`); + reject(new LoginRequiredError(redirectUrl)); + return; + } + resolve(redirectFetcher(redirectUrl)); + return; + } + + const encoding = res.headers["content-encoding"] || ""; + let stream: any = res; + if (encoding.includes("br")) { + stream = res.pipe(zlib.createBrotliDecompress()); + } else if (encoding.includes("gzip")) { + stream = res.pipe(zlib.createGunzip()); + } else if (encoding.includes("deflate")) { + stream = res.pipe(zlib.createInflate()); + } + + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + stream.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + console.log(`${logPrefix} 响应体:`, url, `status=${res.statusCode}`, `body.length=${body.length}`); + + restoreProxyEnv(restoreSaved); + + if (res.statusCode === 403 || isCfChallenge(body)) { + console.log(`${logPrefix} 检测到 Cloudflare 挑战`); + reject(new CfError(url)); + return; + } + if (isLoginPage(body)) { + console.log(`${logPrefix} 检测到登录页面`); + reject(new LoginRequiredError(url)); + return; + } + if (res.statusCode !== 200) { + if (res.statusCode === 404) { + if (sessionCookie) { + console.log(`${logPrefix} 404 但有 Cookie,可能 Cookie 无效`); + reject(new Error(`访问失败 (404)。Cookie 可能无效或已过期,请重新登录 AtCoder 获取新的 REVEL_SESSION`)); + return; + } + console.log(`${logPrefix} 404 且无 Cookie,需要登录`); + reject(new Error(`访问失败 (404)。题目不存在或需要登录,请先设置 AtCoder Cookie。`)); + return; + } + console.log(`${logPrefix} 非 200 状态码:`, res.statusCode); + reject(new Error(`Request failed with status ${res.statusCode}`)); + return; + } + + console.log(`${logPrefix} 请求成功:`, url, `body.length=${body.length}`); + resolve(body); + }); + stream.on("error", (err: Error) => { + restoreProxyEnv(restoreSaved); + reject(err); + }); +} + +export function fetchText(url: string): Promise<string> { + const logPrefix = `[fetchText]`; + + const savedProxy = saveProxyEnv(); + + function restoreProxy() { restoreProxyEnv(savedProxy); } + + console.log(`${logPrefix} 开始请求`, url, `Cookie: ${sessionCookie ? sessionCookie.substring(0, 25) + "..." : "无"}`); + return new Promise((resolve, reject) => { + const client = url.startsWith("https") ? https : http; + const req = client.get( + url, + { + headers: getHeaders(), + agent: getDirectAgent(url), + }, + (res) => { + handleResponse(url, res, savedProxy, resolve, reject, fetchText); + } + ); + req.on("error", (err: Error) => { + restoreProxy(); + console.log(`${logPrefix} 请求错误:`, url, err.message); + if (isProxyError(err)) { + reject(new ProxyError()); + return; + } + reject(new Error(`网络错误: ${err.message}`)); + }); + }); +} + +export function fetchTextPost(url: string, body: string): Promise<string> { + const logPrefix = `[fetchTextPost]`; + + const savedProxy = saveProxyEnv(); + + function restoreProxy() { restoreProxyEnv(savedProxy); } + + console.log(`${logPrefix} 开始请求`, url, `body=${body.substring(0, 100)}`); + return new Promise((resolve, reject) => { + const client = url.startsWith("https") ? https : http; + const headers = { + ...getHeaders(), + "Content-Type": "application/x-www-form-urlencoded", + }; + const req = client.request( + url, + { + method: "POST", + headers, + agent: getDirectAgent(url), + }, + (res) => { + handleResponse(url, res, savedProxy, resolve, (err) => { + restoreProxy(); + reject(err); + }, fetchText, logPrefix); + } + ); + req.write(body); + req.end(); + req.on("error", (err: Error) => { + restoreProxy(); + console.log(`${logPrefix} 请求错误:`, url, err.message); + if (isProxyError(err)) { + reject(new ProxyError()); + return; + } + reject(new Error(`网络错误: ${err.message}`)); + }); + }); +} diff --git a/docs/Standard.md b/docs/Standard.md new file mode 100644 index 0000000..637401a --- /dev/null +++ b/docs/Standard.md @@ -0,0 +1,97 @@ +# 项目规范 + +## 项目结构 + +``` +vscode-boilerplate/ +├── apps/vscode-extension/ # VS Code 插件主程序 +├── packages/core/ # 核心业务逻辑 (@template/core) +├── packages/ui/ # UI 组件库 (@template/ui) +├── packages/webview/ # WebView 前端应用 (@template/webview) +├── docs/ # 文档 +├── assets/ # 静态资源 +├── release/ # 打包输出 +└── tools/ # 工具函数 (apps/vscode-extension/src/tools/) +``` + +## 文件名规范 + +- 文件名应**清晰反映功能** +- React 组件文件使用 **PascalCase**(如 `WebviewApp.tsx`、`HtmlContent.tsx`) +- 工具/逻辑文件使用 **camelCase**(如 `fetch.ts`、`deepl.ts`) +- 类型定义文件使用 **camelCase**(如 `types.ts`) +- 测试文件与源文件同名 + `.test.ts`(如 `atcoder.test.ts`) + +## 代码规范 + +### TypeScript + +- 使用 **4 空格** 缩进 +- 使用 **camelCase** 命名变量和函数 +- 使用 **PascalCase** 命名类、接口、类型、React 组件 +- 类型定义优先使用 `interface`,尽量不使用 `type` +- 逻辑判断优先使用 `if`,尽量不使用 `switch` +- 使用 `export function` / `export async function` 导出,避免 `export default` +- 禁止使用 `any`,使用 `unknown` 替代(无法避免时需加注释说明) + +### 函数 + +- **一个函数最多 120 行**,超过需拆分(可适当超出 10-20 行) +- 每个函数只做一件事 +- 超过 40 行时应考虑是否需要拆分 + +### 导入规范 + +导入顺序: +1. 外部依赖(`vscode`、`react`、`https` 等) +2. 项目内部包(`@template/core`、`@template/ui` 等) +3. 相对路径导入(`./tools/fetch`、`./atcoder` 等) + +禁止循环依赖。 + +### 错误处理 + +- 网络请求错误使用 `CfError`、`ProxyError`、`LoginRequiredError`(定义在 `tools/fetch.ts`) +- 使用 `handleErrorWithCfAndLogin()` 统一处理三类错误并通知 WebView +- 禁止裸 `throw new Error()` 替代专用错误类型 + +### WebView 通信 + +- 消息类型定义在 `packages/webview/src/types.ts` 的 `WebviewMessage` 接口 +- WebView → Extension: `postMessage({ command: "...", ... })` +- Extension → WebView: `postMessage({ type: "...", ... })` +- 新增消息类型需同步更新 `types.ts` 和 `extension.ts` 中的 `switch` + +### CSS / 样式 + +- 使用 **Tailwind CSS** 工具类 +- WebView 内使用 `var(--vscode-*)` CSS 变量适配 VS Code 主题 +- HTML 渲染内容使用 `.html-content` 容器类命名空间 +- KaTeX 公式使用 `.katex` / `.katex-display` 类 + +### Git + +- 分支命名:`feat/xxx`、`fix/xxx`、`refactor/xxx`、`docs/xxx` +- 主分支:`main`,开发分支:`dev` +- 提交信息使用英文,简洁描述改动 + +## 工具目录规范 (`apps/vscode-extension/src/tools/`) + +| 文件 | 职责 | +|------|------| +| `fetch.ts` | HTTP 请求工具(`fetchText`、`fetchTextPost`)、网络错误类、Cookie 管理 | +| `deepl.ts` | DeepL 翻译 API 调用 | + +每个工具文件应职责单一,避免在一个文件中混合不同领域的工具函数。 + +## 审查清单 + +PR 合并到 `main` 前需满足: + +- [ ] `pnpm build` 通过 +- [ ] `pnpm lint` 通过 +- [ ] `pnpm test` 通过 +- [ ] 无新引入的 `any` 类型 +- [ ] 所有函数 ≤ 120 行 +- [ ] 文件名符合命名规范 +- [ ] 新增消息类型已同步更新 `types.ts` 和 `extension.ts` diff --git a/packages/webview/src/WebviewApp.tsx b/packages/webview/src/WebviewApp.tsx index ce44d57..6c9806e 100644 --- a/packages/webview/src/WebviewApp.tsx +++ b/packages/webview/src/WebviewApp.tsx @@ -26,6 +26,10 @@ const WebviewApp: React.FC<WebviewAppProps> = ({ const [cookieInput, setCookieInput] = React.useState(""); const [hasCookie, setHasCookie] = React.useState(false); const [showSettings, setShowSettings] = React.useState(false); + const [signed, setSigned] = React.useState(false); + const [registrationMessage, setRegistrationMessage] = React.useState<string | null>(null); + const [Rated, setRated] = React.useState(false); + const [isRated, setIsRated] = React.useState(true); const inputRef = React.useRef<HTMLInputElement>(null); const loadContest = async (nextContest: string) => { @@ -41,6 +45,12 @@ const WebviewApp: React.FC<WebviewAppProps> = ({ vscode.postMessage({ command: "loadProblem", contest: nextContest, task }); }; + const handleRegister = () => { + setRegistrationMessage(null); + setStatus(`正在报名 ${contest} ...`); + vscode.postMessage({ command: "registerContest", contest, rated: isRated }); + }; + const doTranslate = () => { if (!problem) return; setTranslating(true); @@ -69,9 +79,14 @@ const WebviewApp: React.FC<WebviewAppProps> = ({ setTasks(nextTasks); setSelectedTask(""); setProblem(null); + setRated(false); + setIsRated(true); setStatus(`已加载 ${nextTasks.length} 道题目`); setIsLoading(false); } + if (message.type === "contestInfo") { + setRated(message.Rated ?? false); + } if (message.type === "problem") { setProblem(message.problem ?? null); setStatus(`已加载题面:${message.problem?.title ?? ""}`); @@ -108,6 +123,12 @@ const WebviewApp: React.FC<WebviewAppProps> = ({ setStatus(message.statusMessage); } } + if (message.type === "registrationStatus") { + setSigned(message.signed ?? false); + setRegistrationMessage(message.registrationMessage ?? null); + setStatus(message.registrationMessage ?? (message.signed ? "报名成功" : "报名失败")); + setIsLoading(false); + } }; window.addEventListener("message", handleMessage); @@ -218,7 +239,33 @@ const WebviewApp: React.FC<WebviewAppProps> = ({ <Button onClick={() => void loadContest(contest)} disabled={isLoading} className="h-[28px] text-[12px]"> 加载题目 </Button> + {Rated && ( + <label className="flex items-center gap-1 text-[12px] select-none cursor-pointer"> + <input + type="checkbox" + checked={isRated} + onChange={(e) => setIsRated(e.target.checked)} + className="w-3 h-3" + /> + 评级报名 + </label> + )} + <Button + onClick={handleRegister} + disabled={isLoading || !hasCookie} + variant={signed ? "secondary" : "primary"} + size="sm" + className="h-[28px] text-[12px]" + title={!hasCookie ? "请先设置 AtCoder Cookie" : signed ? "已报名" : "报名比赛"} + > + {signed ? "已报名" : "报名比赛"} + </Button> </div> + {registrationMessage && ( + <div className={`text-[12px] ${signed ? "text-green-500" : "text-red-500"}`}> + {registrationMessage} + </div> + )} <div className="text-[12px] opacity-70">{status}</div> </div> diff --git a/packages/webview/src/types.ts b/packages/webview/src/types.ts index 7af0cf6..692b772 100644 --- a/packages/webview/src/types.ts +++ b/packages/webview/src/types.ts @@ -1,6 +1,6 @@ export interface WebviewMessage { type?: string; - command?: 'alert' | 'error' | 'loadContest' | 'loadProblem' | 'openBrowser' | 'translate' | 'setApiKey' | 'setCookie' | 'getCookie' | 'loginRequired'; + command?: 'alert' | 'error' | 'loadContest' | 'loadProblem' | 'openBrowser' | 'translate' | 'setApiKey' | 'setCookie' | 'getCookie' | 'loginRequired' | 'registerContest'; statusMessage?: string; text?: string; contest?: string; @@ -13,6 +13,10 @@ export interface WebviewMessage { problem?: any; hasCookie?: boolean; masked?: string; + signed?: boolean; + registrationMessage?: string; + rated?: boolean; + Rated?: boolean; } export interface VSCodeAPI {