From 87c9bcbb76fff7450819074e68f43c38414744c3 Mon Sep 17 00:00:00 2001 From: Chtholly Date: Sat, 8 Aug 2026 07:48:33 +0800 Subject: [PATCH] Revert "Feat(core, ui): add sidebar" --- .gitignore | 4 +- apps/vscode-extension/media/atcoder.svg | 3 - apps/vscode-extension/package.json | 25 +- apps/vscode-extension/src/extension.ts | 165 +--- .../src/tools/SignUpContest.ts | 24 - apps/vscode-extension/src/tools/command.ts | 33 - apps/vscode-extension/src/tools/fetch.ts | 93 +-- apps/vscode-extension/src/tools/homepage.ts | 42 - apps/vscode-extension/src/tools/standings.ts | 33 - apps/vscode-extension/src/tools/submission.ts | 291 ------- apps/vscode-extension/src/tools/submit.ts | 13 +- apps/vscode-extension/src/tools/types.ts | 19 - apps/vscode-extension/src/tools/webview.ts | 36 - apps/vscode-extension/src/viewProvider.ts | 72 -- packages/webview/src/ContestApp.tsx | 734 ------------------ packages/webview/src/SidebarApp.tsx | 297 ------- packages/webview/src/SubmissionDetailApp.tsx | 225 ------ packages/webview/src/WebviewApp.tsx | 4 +- packages/webview/src/index.tsx | 19 +- packages/webview/src/types.ts | 54 +- release/extension.vsix | Bin 1128855 -> 1128831 bytes 21 files changed, 51 insertions(+), 2135 deletions(-) delete mode 100644 apps/vscode-extension/media/atcoder.svg delete mode 100644 apps/vscode-extension/src/tools/homepage.ts delete mode 100644 apps/vscode-extension/src/tools/standings.ts delete mode 100644 apps/vscode-extension/src/tools/submission.ts delete mode 100644 apps/vscode-extension/src/tools/webview.ts delete mode 100644 apps/vscode-extension/src/viewProvider.ts delete mode 100644 packages/webview/src/ContestApp.tsx delete mode 100644 packages/webview/src/SidebarApp.tsx delete mode 100644 packages/webview/src/SubmissionDetailApp.tsx diff --git a/.gitignore b/.gitignore index 0107342..2063f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,4 @@ node_modules/ .DS_Store .swc .vsix -ToDo.md -memory.md -issue.md \ No newline at end of file +DEEP.md \ No newline at end of file diff --git a/apps/vscode-extension/media/atcoder.svg b/apps/vscode-extension/media/atcoder.svg deleted file mode 100644 index 836637e..0000000 --- a/apps/vscode-extension/media/atcoder.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 486fd36..fb55096 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -16,15 +16,14 @@ "Other" ], "activationEvents": [ - "onCommand:extension.showWebview", - "onView:atcoderHelper.webviewView" + "onCommand:extension.showWebview" ], "main": "./dist/extension.js", "contributes": { "commands": [ { "command": "extension.showWebview", - "title": "AtCoder Helper(编辑器)" + "title": "AtCoder" }, { "command": "extension.setDeeplApiKey", @@ -34,25 +33,7 @@ "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/extension.ts b/apps/vscode-extension/src/extension.ts index 60d158d..06d2bb8 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,18 +1,14 @@ import * as vscode from "vscode"; import * as path from "path"; 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 { CfError, ProxyError, LoginRequiredError, setSessionCookie, fetchSubStatus, fetchSubmitHistory } from "./tools/fetch"; +import { fetchContest, signedUpContest } 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 { buildCphProblem, sendToCph } from "./tools/cph" import { IncomingMessage } from "./tools/types"; -import { getWebviewContent } from "./tools/webview"; -import { AtCoderViewProvider } from "./viewProvider"; +import { send } from "process"; const log = { info: (...args: unknown[]) => { @@ -26,6 +22,22 @@ const log = { const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); +function getWebviewContent(webviewJsSrc: vscode.Uri): string { + return ` + + + + + + VSCode Boilerplate + + +
+ + + `; +} + function handleErrorWithCfAndLogin(error: unknown, send: (payload: Record) => void): boolean { if (error instanceof CfError) { vscode.window.showErrorMessage(error.message, "在浏览器中打开").then((choice) => { @@ -77,8 +89,7 @@ export async function handleContestLoad(contest: string, send: (payload: Record< try { const contestInfo = await fetchContest(contest); - const announcement = await fetchContestAnnouncement(contest); - send({ type: "contestInfo", Rated: contestInfo.Rated, announcement, title: contestInfo.title }); + send({ type: "contestInfo", Rated: contestInfo.Rated }); } catch (e) { //不处理 } @@ -200,14 +211,6 @@ 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 : "获取提交页面失败" }); } @@ -243,14 +246,6 @@ 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 : "提交失败" } }); } @@ -269,42 +264,6 @@ 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); @@ -312,7 +271,7 @@ export async function handleExportToCph(problem: AtCoderProblem, send: (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 }); + send({ type: "error", success: false, message: message }); } } @@ -416,83 +375,9 @@ 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) { @@ -505,12 +390,6 @@ 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 e13327c..f1cf29c 100644 --- a/apps/vscode-extension/src/tools/SignUpContest.ts +++ b/apps/vscode-extension/src/tools/SignUpContest.ts @@ -29,30 +29,6 @@ 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 61221f2..3d635be 100644 --- a/apps/vscode-extension/src/tools/command.ts +++ b/apps/vscode-extension/src/tools/command.ts @@ -12,11 +12,6 @@ import { handleSubmitCode, handleFetchSubHistory, handleExportToCph, - handleFetchStandings, - handleGetContests, - handleFetchSubmissionDetail, - openContestPanel, - openSubmissionPanel, } from "../extension"; const loadCommands = new Set(["loadContest", "loadProblem", "openBrowser"]); @@ -24,7 +19,6 @@ const deeplCommands = new Set(["translate", "setApiKey"]); const cookieCommands = new Set(["getCookie", "setCookie"]); 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); @@ -32,32 +26,9 @@ 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": @@ -72,10 +43,6 @@ 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; } diff --git a/apps/vscode-extension/src/tools/fetch.ts b/apps/vscode-extension/src/tools/fetch.ts index c1a6b0d..a47ab71 100644 --- a/apps/vscode-extension/src/tools/fetch.ts +++ b/apps/vscode-extension/src/tools/fetch.ts @@ -103,37 +103,22 @@ 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(withCookie = true): Record { +export function getHeaders(): Record { const headers: Record = { ...BROWSER_HEADERS }; - if (withCookie && sessionCookie) { + if (sessionCookie) { headers["Cookie"] = sessionCookie; console.log(`[getHeaders] 已注入 Cookie: ${sessionCookie.substring(0, 40)}...`); } else { - console.log(`[getHeaders] 未注入 Cookie`); + console.log(`[getHeaders] 未设置 Cookie`); } return headers; } @@ -294,41 +279,24 @@ function handleResponse( }); } -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 { +export function fetchText(url: string): Promise { const logPrefix = `[fetchText]`; const savedProxy = saveProxyEnv(); function restoreProxy() { restoreProxyEnv(savedProxy); } - console.log(`${logPrefix} 开始请求`, url, withCookie ? `Cookie: ${sessionCookie ? sessionCookie.substring(0, 25) + "..." : "无"}` : "无 Cookie(降级)"); + 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(withCookie), + headers: getHeaders(), agent: getDirectAgent(url), }, (res) => { - handleResponse(url, res, savedProxy, resolve, reject, (u) => fetchTextOnce(u, withCookie), logPrefix); + handleResponse(url, res, savedProxy, resolve, reject, fetchText); } ); req.on("error", (err: Error) => { @@ -403,30 +371,6 @@ 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[] = []; @@ -434,28 +378,26 @@ export async function fetchSubmitHistory(contest: string): Promise let rowMatch: RegExpExecArray | null; for (; (rowMatch = reg.exec(html)) !== null;) { const rowHtml = rowMatch[1]; - const cells = splitRowCells(rowHtml); - if (cells.length < 7) continue; + const timeMatch = rowHtml.match(/]*class="text-center"[^>]*>([\s\S]*?)<\/td>/i); + if (!timeMatch) continue; + const time = timeMatch[1].trim().replace(/<[^>]+>/g, ""); - const timeMatch = cells[0].match(/]*>([^<]+)<\/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); + const taskLinkMatch = rowHtml.match(/href="\/contests\/[^/]+\/tasks\/([^"#?]+)"[^>]*>([^<]+)]*class="text-center"[^>]*>[\s\S]*?<\/td>\s*]*>([^<]*)<\/td>/i); + const language = langMatch ? langMatch[1].trim() : ""; - const scoreMatch = cells[4].match(/(\d+)/); + const scoreMatch = rowHtml.match(/]*class="text-right"[^>]*>\s*(\d+)\s*<\/td>/i); const score = scoreMatch ? scoreMatch[1] : "0"; - const statusMatch = cells[6].match(/]*class=(["'])[^"']*\blabel\b[^'"]*\1[^>]*>\s*([^<]+)\s*<\/span>/i); + const statusMatch = rowHtml.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]; @@ -463,3 +405,4 @@ 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 deleted file mode 100644 index e60f978..0000000 --- a/apps/vscode-extension/src/tools/homepage.ts +++ /dev/null @@ -1,42 +0,0 @@ -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(/