atcoder-workspace 1.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/THIRD_PARTY_LICENSES +21 -0
  4. package/dist/atcoder/client.d.ts +2 -0
  5. package/dist/atcoder/client.js +23 -0
  6. package/dist/atcoder/new.d.ts +15 -0
  7. package/dist/atcoder/new.js +123 -0
  8. package/dist/atcoder/parser/contest-tasks.d.ts +6 -0
  9. package/dist/atcoder/parser/contest-tasks.js +86 -0
  10. package/dist/atcoder/parser/limits.d.ts +10 -0
  11. package/dist/atcoder/parser/limits.js +71 -0
  12. package/dist/atcoder/parser/problem-page.d.ts +12 -0
  13. package/dist/atcoder/parser/problem-page.js +136 -0
  14. package/dist/atcoder/parser/submission-status.d.ts +8 -0
  15. package/dist/atcoder/parser/submission-status.js +78 -0
  16. package/dist/atcoder/submit.d.ts +9 -0
  17. package/dist/atcoder/submit.js +182 -0
  18. package/dist/cli.d.ts +2 -0
  19. package/dist/cli.js +508 -0
  20. package/dist/config/config-store.d.ts +17 -0
  21. package/dist/config/config-store.js +92 -0
  22. package/dist/session/auth.d.ts +8 -0
  23. package/dist/session/auth.js +117 -0
  24. package/dist/session/store.d.ts +15 -0
  25. package/dist/session/store.js +75 -0
  26. package/dist/test-runner/diff.d.ts +7 -0
  27. package/dist/test-runner/diff.js +32 -0
  28. package/dist/test-runner/runner.d.ts +46 -0
  29. package/dist/test-runner/runner.js +274 -0
  30. package/dist/utils/errors.d.ts +15 -0
  31. package/dist/utils/errors.js +35 -0
  32. package/dist/utils/format.d.ts +9 -0
  33. package/dist/utils/format.js +89 -0
  34. package/dist/utils/i18n.d.ts +345 -0
  35. package/dist/utils/i18n.js +413 -0
  36. package/dist/utils/open.d.ts +8 -0
  37. package/dist/utils/open.js +50 -0
  38. package/dist/workspace/finder.d.ts +9 -0
  39. package/dist/workspace/finder.js +62 -0
  40. package/dist/workspace/initializer.d.ts +4 -0
  41. package/dist/workspace/initializer.js +109 -0
  42. package/package.json +38 -0
  43. package/src/atcoder/client.ts +21 -0
  44. package/src/atcoder/new.ts +107 -0
  45. package/src/atcoder/parser/contest-tasks.test.ts +37 -0
  46. package/src/atcoder/parser/contest-tasks.ts +61 -0
  47. package/src/atcoder/parser/limits.test.ts +52 -0
  48. package/src/atcoder/parser/limits.ts +75 -0
  49. package/src/atcoder/parser/problem-page.test.ts +68 -0
  50. package/src/atcoder/parser/problem-page.ts +126 -0
  51. package/src/atcoder/parser/submission-status.test.ts +36 -0
  52. package/src/atcoder/parser/submission-status.ts +54 -0
  53. package/src/atcoder/submit.ts +170 -0
  54. package/src/cli.ts +554 -0
  55. package/src/config/config-store.ts +72 -0
  56. package/src/session/auth.ts +87 -0
  57. package/src/session/store.ts +50 -0
  58. package/src/test-runner/diff.test.ts +26 -0
  59. package/src/test-runner/diff.ts +42 -0
  60. package/src/test-runner/runner.test.ts +70 -0
  61. package/src/test-runner/runner.ts +315 -0
  62. package/src/utils/errors.ts +31 -0
  63. package/src/utils/format.test.ts +69 -0
  64. package/src/utils/format.ts +95 -0
  65. package/src/utils/i18n.test.ts +74 -0
  66. package/src/utils/i18n.ts +418 -0
  67. package/src/utils/open.ts +47 -0
  68. package/src/workspace/finder.ts +29 -0
  69. package/src/workspace/initializer.ts +85 -0
  70. package/tsconfig.json +16 -0
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "atcoder-workspace",
3
+ "version": "1.1.0-beta.1",
4
+ "description": "AtCoder All-in-One CLI (Local-first)",
5
+ "main": "dist/cli.js",
6
+ "bin": {
7
+ "atc": "./dist/cli.js"
8
+ },
9
+ "type": "commonjs",
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "dev": "tsx src/cli.ts",
15
+ "test-env:setup": "npm run build && npm pack && npm --prefix test install atcoder-workspace-1.1.0-beta.1.tgz"
16
+ },
17
+ "keywords": [
18
+ "atcoder",
19
+ "cli",
20
+ "competitive-programming"
21
+ ],
22
+ "author": "",
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "@clack/prompts": "^0.7.0",
26
+ "axios": "^1.6.8",
27
+ "cheerio": "^1.0.0-rc.12",
28
+ "commander": "^12.0.0",
29
+ "ora": "^5.4.1",
30
+ "picocolors": "^1.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^20.11.24",
34
+ "tsx": "^4.7.1",
35
+ "typescript": "^5.3.3",
36
+ "vitest": "^1.3.1"
37
+ }
38
+ }
@@ -0,0 +1,21 @@
1
+ import axios, { AxiosInstance } from 'axios';
2
+ import { loadSession, getCookieHeaderString } from '../session/store';
3
+
4
+ export function createAtCoderClient(workspaceRoot: string): AxiosInstance {
5
+ const cookies = loadSession(workspaceRoot);
6
+ const headers: Record<string, string> = {
7
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
8
+ };
9
+
10
+ if (cookies) {
11
+ headers['Cookie'] = getCookieHeaderString(cookies);
12
+ }
13
+
14
+ const client = axios.create({
15
+ baseURL: 'https://atcoder.jp',
16
+ headers,
17
+ timeout: 15000,
18
+ });
19
+
20
+ return client;
21
+ }
@@ -0,0 +1,107 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { createAtCoderClient } from './client';
4
+ import { parseContestTasks, TaskInfo } from './parser/contest-tasks';
5
+ import { parseProblemPage, SampleCase } from './parser/problem-page';
6
+ import { loadConfig } from '../config/config-store';
7
+ import { AtcError } from '../utils/errors';
8
+
9
+ export interface SetupResult {
10
+ contestId: string;
11
+ taskLabel: string;
12
+ taskDir: string;
13
+ sampleCount: number;
14
+ }
15
+
16
+ /**
17
+ * Fetches the list of tasks for a given contest.
18
+ */
19
+ export async function fetchContestTasks(workspaceRoot: string, contestId: string): Promise<TaskInfo[]> {
20
+ const client = createAtCoderClient(workspaceRoot);
21
+ try {
22
+ const res = await client.get(`/contests/${contestId}/tasks`);
23
+ return parseContestTasks(res.data);
24
+ } catch (err: any) {
25
+ throw new AtcError(`Failed to fetch tasks for contest "${contestId}": ${err.message}`);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Sets up a single task by downloading sample cases and copying language templates.
31
+ */
32
+ export async function setupTask(
33
+ workspaceRoot: string,
34
+ contestId: string,
35
+ task: TaskInfo,
36
+ languageKey?: string
37
+ ): Promise<SetupResult> {
38
+ const config = loadConfig(workspaceRoot);
39
+ const langKey = languageKey || config.defaultLanguage;
40
+ const langConfig = config.languages[langKey];
41
+
42
+ if (!langConfig) {
43
+ throw new AtcError(`Language configuration for "${langKey}" not found in config.json`);
44
+ }
45
+
46
+ // Create contest and task directories
47
+ const contestParentDir = config.contestDir ? path.join(workspaceRoot, config.contestDir) : workspaceRoot;
48
+ const contestDir = path.join(contestParentDir, contestId);
49
+ const taskDir = path.join(contestDir, task.label);
50
+
51
+ if (!fs.existsSync(taskDir)) {
52
+ fs.mkdirSync(taskDir, { recursive: true });
53
+ }
54
+
55
+ // Copy template files
56
+ const templateSrcDir = path.join(workspaceRoot, '.atcoder-cli', langConfig.templateDir);
57
+ if (fs.existsSync(templateSrcDir) && fs.statSync(templateSrcDir).isDirectory()) {
58
+ const files = fs.readdirSync(templateSrcDir);
59
+ for (const file of files) {
60
+ const srcPath = path.join(templateSrcDir, file);
61
+ const destPath = path.join(taskDir, file);
62
+ if (fs.statSync(srcPath).isFile() && !fs.existsSync(destPath)) {
63
+ fs.copyFileSync(srcPath, destPath);
64
+ }
65
+ }
66
+ }
67
+
68
+ // Fetch problem page
69
+ const client = createAtCoderClient(workspaceRoot);
70
+ let problemHtml = '';
71
+ try {
72
+ const res = await client.get(`/contests/${contestId}/tasks/${task.id}`);
73
+ problemHtml = res.data;
74
+ } catch (err: any) {
75
+ throw new AtcError(`Failed to fetch problem page for "${task.id}": ${err.message}`);
76
+ }
77
+
78
+ const problemDetails = parseProblemPage(problemHtml);
79
+
80
+ // Write sample cases to tests/ directory
81
+ const testDirName = config.testDirName || 'tests';
82
+ const testDir = path.join(taskDir, testDirName);
83
+
84
+ if (fs.existsSync(testDir)) {
85
+ // Clear existing sample files to avoid mixing old/new samples
86
+ const files = fs.readdirSync(testDir);
87
+ for (const file of files) {
88
+ if (file.startsWith('sample-')) {
89
+ fs.unlinkSync(path.join(testDir, file));
90
+ }
91
+ }
92
+ } else {
93
+ fs.mkdirSync(testDir, { recursive: true });
94
+ }
95
+
96
+ for (const sample of problemDetails.samples) {
97
+ fs.writeFileSync(path.join(testDir, `sample-${sample.index}.in`), sample.input, 'utf8');
98
+ fs.writeFileSync(path.join(testDir, `sample-${sample.index}.out`), sample.output, 'utf8');
99
+ }
100
+
101
+ return {
102
+ contestId,
103
+ taskLabel: task.label,
104
+ taskDir,
105
+ sampleCount: problemDetails.samples.length
106
+ };
107
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseContestTasks } from './contest-tasks';
3
+
4
+ const MOCK_CONTEST_TASKS_HTML = `
5
+ <table>
6
+ <thead>
7
+ <tr><th>Task</th><th>Task Name</th></tr>
8
+ </thead>
9
+ <tbody>
10
+ <tr>
11
+ <td><a href="/contests/abc300/tasks/abc300_a">A</a></td>
12
+ <td><a href="/contests/abc300/tasks/abc300_a">Bitwise XOR</a></td>
13
+ </tr>
14
+ <tr>
15
+ <td><a href="/contests/abc300/tasks/abc300_b">B</a></td>
16
+ <td><a href="/contests/abc300/tasks/abc300_b">Grid Rotations</a></td>
17
+ </tr>
18
+ </tbody>
19
+ </table>
20
+ `;
21
+
22
+ describe('contest-tasks parser', () => {
23
+ it('should parse task list correctly', () => {
24
+ const tasks = parseContestTasks(MOCK_CONTEST_TASKS_HTML);
25
+ expect(tasks).toHaveLength(2);
26
+ expect(tasks[0]).toEqual({
27
+ id: 'abc300_a',
28
+ label: 'a',
29
+ url: '/contests/abc300/tasks/abc300_a'
30
+ });
31
+ expect(tasks[1]).toEqual({
32
+ id: 'abc300_b',
33
+ label: 'b',
34
+ url: '/contests/abc300/tasks/abc300_b'
35
+ });
36
+ });
37
+ });
@@ -0,0 +1,61 @@
1
+ import * as cheerio from 'cheerio';
2
+
3
+ export interface TaskInfo {
4
+ id: string;
5
+ label: string;
6
+ url: string;
7
+ }
8
+
9
+ export function parseContestTasks(html: string): TaskInfo[] {
10
+ const $ = cheerio.load(html);
11
+ const tasks: TaskInfo[] = [];
12
+ const seenIds = new Set<string>();
13
+
14
+ $('a').each((_, elem) => {
15
+ const href = $(elem).attr('href');
16
+ if (href) {
17
+ const match = href.match(/\/contests\/([^/]+)\/tasks\/([^/?#]+)$/);
18
+ if (match) {
19
+ const id = match[2];
20
+ const label = $(elem).text().trim().toLowerCase();
21
+
22
+ if (label.length > 0 && label.length <= 4 && !seenIds.has(id)) {
23
+ seenIds.add(id);
24
+ tasks.push({
25
+ id,
26
+ label,
27
+ url: href
28
+ });
29
+ }
30
+ }
31
+ }
32
+ });
33
+
34
+ if (tasks.length === 0) {
35
+ $('a').each((_, elem) => {
36
+ const href = $(elem).attr('href');
37
+ if (href) {
38
+ const match = href.match(/\/contests\/([^/]+)\/tasks\/([^/?#]+)$/);
39
+ if (match) {
40
+ const id = match[2];
41
+ const label = $(elem).text().trim().toLowerCase();
42
+ if (label && !seenIds.has(id)) {
43
+ seenIds.add(id);
44
+ let cleanLabel = label;
45
+ if (cleanLabel.length > 4) {
46
+ const parts = id.split('_');
47
+ cleanLabel = parts[parts.length - 1] || 'task';
48
+ }
49
+ tasks.push({
50
+ id,
51
+ label: cleanLabel.toLowerCase(),
52
+ url: href
53
+ });
54
+ }
55
+ }
56
+ }
57
+ });
58
+ }
59
+
60
+ return tasks.sort((a, b) => a.label.localeCompare(b.label));
61
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseTimeLimit, parseMemoryLimit } from './limits';
3
+
4
+ describe('limits parser', () => {
5
+ describe('parseTimeLimit', () => {
6
+ it('should parse standard english formats', () => {
7
+ expect(parseTimeLimit('Time Limit: 2 sec')).toBe(2000);
8
+ expect(parseTimeLimit('Time Limit: 2.5 sec')).toBe(2500);
9
+ expect(parseTimeLimit('Time Limit: 1000 ms')).toBe(1000);
10
+ });
11
+
12
+ it('should parse standard japanese formats', () => {
13
+ expect(parseTimeLimit('実行時間制限: 2 秒')).toBe(2000);
14
+ expect(parseTimeLimit('実行時間制限: 2.5 秒')).toBe(2500);
15
+ expect(parseTimeLimit('実行時間制限: 1000 ミリ秒')).toBe(1000);
16
+ });
17
+
18
+ it('should parse simple formats without labels', () => {
19
+ expect(parseTimeLimit('2 sec')).toBe(2000);
20
+ expect(parseTimeLimit('1500 ms')).toBe(1500);
21
+ });
22
+
23
+ it('should throw ParseError on invalid formats', () => {
24
+ expect(() => parseTimeLimit('no limit here')).toThrow();
25
+ });
26
+ });
27
+
28
+ describe('parseMemoryLimit', () => {
29
+ it('should parse standard english formats', () => {
30
+ expect(parseMemoryLimit('Memory Limit: 1024 MB')).toBe(1024 * 1024 * 1024);
31
+ expect(parseMemoryLimit('Memory Limit: 1024 MiB')).toBe(1024 * 1024 * 1024);
32
+ expect(parseMemoryLimit('Memory Limit: 256 KB')).toBe(256 * 1024);
33
+ expect(parseMemoryLimit('Memory Limit: 512 KiB')).toBe(512 * 1024);
34
+ expect(parseMemoryLimit('Memory Limit: 1 GB')).toBe(1024 * 1024 * 1024);
35
+ expect(parseMemoryLimit('Memory Limit: 1 GiB')).toBe(1024 * 1024 * 1024);
36
+ });
37
+
38
+ it('should parse standard japanese formats', () => {
39
+ expect(parseMemoryLimit('メモリ制限: 1024 MB')).toBe(1024 * 1024 * 1024);
40
+ expect(parseMemoryLimit('メモリ制限: 1024 MiB')).toBe(1024 * 1024 * 1024);
41
+ });
42
+
43
+ it('should parse simple formats without labels', () => {
44
+ expect(parseMemoryLimit('1024 MB')).toBe(1024 * 1024 * 1024);
45
+ expect(parseMemoryLimit('512 MiB')).toBe(512 * 1024 * 1024);
46
+ });
47
+
48
+ it('should throw ParseError on invalid formats', () => {
49
+ expect(() => parseMemoryLimit('no memory here')).toThrow();
50
+ });
51
+ });
52
+ });
@@ -0,0 +1,75 @@
1
+ import { ParseError } from '../../utils/errors';
2
+
3
+ /**
4
+ * Parses a time limit string and returns the limit in milliseconds.
5
+ * E.g., "2 sec", "2.5 sec", "2000 ms", "2 秒", "2.5 秒"
6
+ */
7
+ export function parseTimeLimit(text: string): number {
8
+ // Support both English and Japanese formats
9
+ const timeRegex = /(?:Time Limit|実行時間制限)\s*:\s*([\d.]+)\s*(sec|ms|秒|ミリ秒)/i;
10
+ const match = text.match(timeRegex);
11
+
12
+ if (!match) {
13
+ // If not found with label, try parsing raw units in the context of limits
14
+ const simpleRegex = /(\d+(?:\.\d+)?)\s*(sec|ms|秒|ミリ秒)/i;
15
+ const simpleMatch = text.match(simpleRegex);
16
+ if (!simpleMatch) {
17
+ throw new ParseError(`Could not parse time limit from text: "${text}"`);
18
+ }
19
+ return convertTimeToMs(parseFloat(simpleMatch[1]), simpleMatch[2]);
20
+ }
21
+
22
+ const value = parseFloat(match[1]);
23
+ const unit = match[2];
24
+ return convertTimeToMs(value, unit);
25
+ }
26
+
27
+ function convertTimeToMs(value: number, unit: string): number {
28
+ const normUnit = unit.toLowerCase();
29
+ if (normUnit === 'sec' || normUnit === '秒') {
30
+ return Math.round(value * 1000);
31
+ }
32
+ if (normUnit === 'ms' || normUnit === 'ミリ秒') {
33
+ return Math.round(value);
34
+ }
35
+ throw new ParseError(`Unknown time unit: "${unit}"`);
36
+ }
37
+
38
+ /**
39
+ * Parses a memory limit string and returns the limit in bytes.
40
+ * E.g., "1024 MB", "1024 MiB", "256 KB", "256 KiB", "1 GB", "1 GiB"
41
+ */
42
+ export function parseMemoryLimit(text: string): number {
43
+ const memoryRegex = /(?:Memory Limit|メモリ制限)\s*:\s*(\d+)\s*(KB|MB|GB|KiB|MiB|GiB)/i;
44
+ const match = text.match(memoryRegex);
45
+
46
+ if (!match) {
47
+ const simpleRegex = /(\d+)\s*(KB|MB|GB|KiB|MiB|GiB)/i;
48
+ const simpleMatch = text.match(simpleRegex);
49
+ if (!simpleMatch) {
50
+ throw new ParseError(`Could not parse memory limit from text: "${text}"`);
51
+ }
52
+ return convertMemoryToBytes(parseInt(simpleMatch[1], 10), simpleMatch[2]);
53
+ }
54
+
55
+ const value = parseInt(match[1], 10);
56
+ const unit = match[2];
57
+ return convertMemoryToBytes(value, unit);
58
+ }
59
+
60
+ function convertMemoryToBytes(value: number, unit: string): number {
61
+ const normUnit = unit.toLowerCase();
62
+ switch (normUnit) {
63
+ case 'kb':
64
+ case 'kib':
65
+ return value * 1024;
66
+ case 'mb':
67
+ case 'mib':
68
+ return value * 1024 * 1024;
69
+ case 'gb':
70
+ case 'gib':
71
+ return value * 1024 * 1024 * 1024;
72
+ default:
73
+ throw new ParseError(`Unknown memory unit: "${unit}"`);
74
+ }
75
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseProblemPage } from './problem-page';
3
+
4
+ const MOCK_PROBLEM_HTML = `
5
+ <!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <title>A - ABC Problem</title>
9
+ </head>
10
+ <body>
11
+ <span class="h2">A - ABC Problem</span>
12
+ <div id="task-statement">
13
+ <p>Time Limit: 2 sec / Memory Limit: 1024 MB</p>
14
+ <span class="lang-en">
15
+ <div class="part">
16
+ <h3>Sample Input 1</h3>
17
+ <pre>1 2
18
+ 3</pre>
19
+ </div>
20
+ <div class="part">
21
+ <h3>Sample Output 1</h3>
22
+ <pre>6</pre>
23
+ </div>
24
+ <div class="part">
25
+ <h3>Sample Input 2</h3>
26
+ <pre>10 20
27
+ 30</pre>
28
+ </div>
29
+ <div class="part">
30
+ <h3>Sample Output 2</h3>
31
+ <pre>60</pre>
32
+ </div>
33
+ </span>
34
+ <span class="lang-ja">
35
+ <div class="part">
36
+ <h3>入力例 1</h3>
37
+ <pre>1 2
38
+ 3</pre>
39
+ </div>
40
+ <div class="part">
41
+ <h3>出力例 1</h3>
42
+ <pre>6</pre>
43
+ </div>
44
+ </span>
45
+ </div>
46
+ </body>
47
+ </html>
48
+ `;
49
+
50
+ describe('problem-page parser', () => {
51
+ it('should parse details correctly', () => {
52
+ const details = parseProblemPage(MOCK_PROBLEM_HTML);
53
+ expect(details.title).toBe('A - ABC Problem');
54
+ expect(details.timeLimitMs).toBe(2000);
55
+ expect(details.memoryLimitBytes).toBe(1024 * 1024 * 1024);
56
+ expect(details.samples).toHaveLength(2);
57
+ expect(details.samples[0]).toEqual({
58
+ index: 1,
59
+ input: '1 2\n3\n',
60
+ output: '6\n'
61
+ });
62
+ expect(details.samples[1]).toEqual({
63
+ index: 2,
64
+ input: '10 20\n30\n',
65
+ output: '60\n'
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,126 @@
1
+ import * as cheerio from 'cheerio';
2
+ import { parseTimeLimit, parseMemoryLimit } from './limits';
3
+ import { ParseError } from '../../utils/errors';
4
+
5
+ export interface SampleCase {
6
+ index: number;
7
+ input: string;
8
+ output: string;
9
+ }
10
+
11
+ export interface TaskDetails {
12
+ title: string;
13
+ timeLimitMs: number;
14
+ memoryLimitBytes: number;
15
+ samples: SampleCase[];
16
+ }
17
+
18
+ export function parseProblemPage(html: string): TaskDetails {
19
+ const $ = cheerio.load(html);
20
+
21
+ let title = $('span.h2').first().text().trim();
22
+ if (!title) {
23
+ title = $('h2').first().text().trim();
24
+ }
25
+ if (!title) {
26
+ title = $('title').text().trim() || 'Task';
27
+ }
28
+
29
+ title = title.replace(/\s+/g, ' ');
30
+
31
+ const taskStatementText = $('#task-statement').text() || $('body').text();
32
+ let timeLimitMs = 2000;
33
+ let memoryLimitBytes = 1024 * 1024 * 1024;
34
+
35
+ try {
36
+ timeLimitMs = parseTimeLimit(taskStatementText);
37
+ } catch (e) {
38
+ try {
39
+ timeLimitMs = parseTimeLimit($('body').text());
40
+ } catch (err) {
41
+ console.warn('Could not parse time limit. Using default 2000ms.');
42
+ }
43
+ }
44
+
45
+ try {
46
+ memoryLimitBytes = parseMemoryLimit(taskStatementText);
47
+ } catch (e) {
48
+ try {
49
+ memoryLimitBytes = parseMemoryLimit($('body').text());
50
+ } catch (err) {
51
+ console.warn('Could not parse memory limit. Using default 1024MB.');
52
+ }
53
+ }
54
+
55
+ let $container = $('#task-statement');
56
+ if ($container.find('.lang-en').length > 0) {
57
+ $container = $container.find('.lang-en');
58
+ } else if ($container.find('.lang-ja').length > 0) {
59
+ $container = $container.find('.lang-ja');
60
+ }
61
+
62
+ const sampleMap: Record<number, { input?: string; output?: string }> = {};
63
+
64
+ const inputRegex = /(?:Sample\s+Input|入力例)\s*(?:#|No\.?)?\s*(\d+)/i;
65
+ const outputRegex = /(?:Sample\s+Output|出力例)\s*(?:#|No\.?)?\s*(\d+)/i;
66
+
67
+ $container.find('h3, h4').each((_, elem) => {
68
+ const headerText = $(elem).text().trim();
69
+
70
+ const inputMatch = headerText.match(inputRegex);
71
+ const outputMatch = headerText.match(outputRegex);
72
+
73
+ if (inputMatch || outputMatch) {
74
+ const isInput = !!inputMatch;
75
+ const index = parseInt(isInput ? inputMatch![1] : outputMatch![1], 10);
76
+
77
+ let $pre = $(elem).next('pre');
78
+ if ($pre.length === 0) {
79
+ $pre = $(elem).parent().find('pre');
80
+ }
81
+ if ($pre.length === 0 || $pre.parent().is(elem)) {
82
+ $pre = $(elem).nextAll('pre').first();
83
+ }
84
+ if ($pre.length === 0) {
85
+ $pre = $(elem).nextAll().find('pre').first();
86
+ }
87
+
88
+ if ($pre.length > 0) {
89
+ let content = $pre.text();
90
+ content = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
91
+ if (content && !content.endsWith('\n')) {
92
+ content += '\n';
93
+ }
94
+
95
+ if (!sampleMap[index]) {
96
+ sampleMap[index] = {};
97
+ }
98
+
99
+ if (isInput) {
100
+ sampleMap[index].input = content;
101
+ } else {
102
+ sampleMap[index].output = content;
103
+ }
104
+ }
105
+ }
106
+ });
107
+
108
+ const samples: SampleCase[] = Object.keys(sampleMap)
109
+ .map(key => {
110
+ const idx = parseInt(key, 10);
111
+ return {
112
+ index: idx,
113
+ input: sampleMap[idx].input ?? '',
114
+ output: sampleMap[idx].output ?? ''
115
+ };
116
+ })
117
+ .filter(s => s.input !== '' || s.output !== '')
118
+ .sort((a, b) => a.index - b.index);
119
+
120
+ return {
121
+ title,
122
+ timeLimitMs,
123
+ memoryLimitBytes,
124
+ samples
125
+ };
126
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseSubmissionStatus } from './submission-status';
3
+
4
+ const MOCK_SUBMISSION_WJ = `
5
+ <table>
6
+ <tr><th>Status</th><td id="judge-status">12/15 WJ</td></tr>
7
+ <tr><th>Score</th><td>0</td></tr>
8
+ </table>
9
+ `;
10
+
11
+ const MOCK_SUBMISSION_AC = `
12
+ <table>
13
+ <tr><th>Status</th><td id="judge-status"><span class="label label-success">AC</span></td></tr>
14
+ <tr><th>Execution Time</th><td>15 ms</td></tr>
15
+ <tr><th>Memory</th><td>2048 KB</td></tr>
16
+ <tr><th>Score</th><td>100</td></tr>
17
+ </table>
18
+ `;
19
+
20
+ describe('submission-status parser', () => {
21
+ it('should parse WJ state correctly', () => {
22
+ const res = parseSubmissionStatus(MOCK_SUBMISSION_WJ);
23
+ expect(res.status).toBe('12/15 WJ');
24
+ expect(res.isCompleted).toBe(false);
25
+ expect(res.score).toBe('0');
26
+ });
27
+
28
+ it('should parse AC state correctly', () => {
29
+ const res = parseSubmissionStatus(MOCK_SUBMISSION_AC);
30
+ expect(res.status).toBe('AC');
31
+ expect(res.isCompleted).toBe(true);
32
+ expect(res.time).toBe('15 ms');
33
+ expect(res.memory).toBe('2048 KB');
34
+ expect(res.score).toBe('100');
35
+ });
36
+ });
@@ -0,0 +1,54 @@
1
+ import * as cheerio from 'cheerio';
2
+
3
+ export interface SubmissionStatus {
4
+ status: string;
5
+ time?: string;
6
+ memory?: string;
7
+ score?: string;
8
+ isCompleted: boolean;
9
+ }
10
+
11
+ export function parseSubmissionStatus(html: string): SubmissionStatus {
12
+ const $ = cheerio.load(html);
13
+
14
+ let status = '';
15
+ let time: string | undefined;
16
+ let memory: string | undefined;
17
+ let score: string | undefined;
18
+
19
+ const statusElem = $('#judge-status');
20
+ if (statusElem.length > 0) {
21
+ status = statusElem.text().trim();
22
+ } else {
23
+ $('table tr').each((_, tr) => {
24
+ const thText = $(tr).find('th').text().trim();
25
+ if (thText === 'Status' || thText === '状態' || thText === '結果') {
26
+ status = $(tr).find('td').text().trim();
27
+ }
28
+ });
29
+ }
30
+
31
+ $('table tr').each((_, tr) => {
32
+ const thText = $(tr).find('th').text().trim();
33
+ const tdText = $(tr).find('td').text().trim();
34
+
35
+ if (thText === 'Execution Time' || thText === '実行時間') {
36
+ time = tdText;
37
+ } else if (thText === 'Memory' || thText === 'メモリ') {
38
+ memory = tdText;
39
+ } else if (thText === 'Score' || thText === '得点') {
40
+ score = tdText;
41
+ }
42
+ });
43
+
44
+ status = status.replace(/\s+/g, ' ');
45
+ const isCompleted = status !== '' && !status.includes('WJ') && !status.includes('Judging');
46
+
47
+ return {
48
+ status: status || 'WJ',
49
+ time,
50
+ memory,
51
+ score,
52
+ isCompleted
53
+ };
54
+ }