diff --git a/cfStatementParser.js b/cfStatementParser.js new file mode 100644 index 0000000..dd7b838 --- /dev/null +++ b/cfStatementParser.js @@ -0,0 +1,115 @@ +/** + * Server-side helper: parse a Codeforces problem page HTML string into + * structured, plain-text fields safe to return to the React frontend. + * + * Returns null when the page does not contain a `.problem-statement` element + * (e.g. the problem/contest does not exist or the URL was wrong). + */ + +import * as cheerio from 'cheerio'; + +/** + * Replace block-level children and
tags with newlines, then return the + * trimmed text content of an element. This preserves paragraph breaks without + * leaking any HTML tags to the caller. + * + * @param {import('cheerio').CheerioAPI} $ - cheerio root + * @param {import('cheerio').Cheerio} el - element to extract text from + * @returns {string} + */ +function blockText($, el) { + const clone = $(el).clone(); + clone.find('br').replaceWith('\n'); + clone.find('p').each((_, p) => { + $(p).prepend('\n').append('\n'); + }); + return clone + .text() + .replace(/\r\n|\r/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * Parse a raw Codeforces problem page HTML string. + * + * @param {string} html - full HTML of the Codeforces problemset/problem page + * @returns {{ title: string, timeLimit: string, memoryLimit: string, + * statement: string, inputSpecification: string, + * outputSpecification: string, + * samples: Array<{ input: string, output: string }> } | null} + */ +export function parseCFProblemStatement(html) { + const $ = cheerio.load(html); + + const stmtEl = $('.problem-statement').first(); + if (!stmtEl.length) return null; + + const header = stmtEl.find('.header').first(); + + // ---- title ---------------------------------------------------------------- + const title = header.find('.title').first().text().trim(); + + // ---- limits --------------------------------------------------------------- + // The limit divs look like: + //
+ //
time limit per test
2 seconds + //
+ // We clone, remove the inner label, then read the remaining text. + + const timeLimitClone = header.find('.time-limit').clone(); + timeLimitClone.find('.property-title').remove(); + const timeLimit = timeLimitClone.text().trim(); + + const memLimitClone = header.find('.memory-limit').clone(); + memLimitClone.find('.property-title').remove(); + const memoryLimit = memLimitClone.text().trim(); + + // ---- problem statement body ----------------------------------------------- + // Collect direct children of .problem-statement that are NOT one of the + // well-known named sections. + const SKIP_CLASSES = new Set([ + 'header', + 'input-specification', + 'output-specification', + 'sample-tests', + 'note', + ]); + + const statementParts = []; + stmtEl.children().each((_, child) => { + const classes = ($(child).attr('class') || '').split(/\s+/); + if (classes.some(c => SKIP_CLASSES.has(c))) return; + const text = blockText($, child); + if (text) statementParts.push(text); + }); + const statement = statementParts.join('\n\n'); + + // ---- input specification -------------------------------------------------- + const inputSpecClone = stmtEl.find('.input-specification').clone(); + inputSpecClone.find('.section-title').remove(); + const inputSpecification = blockText($, inputSpecClone); + + // ---- output specification ------------------------------------------------- + const outputSpecClone = stmtEl.find('.output-specification').clone(); + outputSpecClone.find('.section-title').remove(); + const outputSpecification = blockText($, outputSpecClone); + + // ---- sample tests --------------------------------------------------------- + const samples = []; + stmtEl.find('.sample-test').each((_, sampleEl) => { + const input = $(sampleEl).find('.input pre').first().text().trim(); + const output = $(sampleEl).find('.output pre').first().text().trim(); + samples.push({ input, output }); + }); + + return { + title, + timeLimit, + memoryLimit, + statement, + inputSpecification, + outputSpecification, + samples, + }; +} diff --git a/cfStatementParser.test.js b/cfStatementParser.test.js new file mode 100644 index 0000000..1866b37 --- /dev/null +++ b/cfStatementParser.test.js @@ -0,0 +1,214 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { parseCFProblemStatement } from './cfStatementParser.js'; + +// --------------------------------------------------------------------------- +// Minimal but realistic Codeforces problem page HTML fixture. +// The structure mirrors the actual CF page HTML that the parser targets. +// --------------------------------------------------------------------------- +const FIXTURE_HTML = ` + + + +
+
+
A. Two Sum
+
+
time limit per test
2 seconds +
+
+
memory limit per test
256 megabytes +
+
+
input
standard input +
+
+
output
standard output +
+
+ +

You are given two integers $a$ and $b$.

+

Print their sum.

+ +
+
Input
+

The first line contains two integers $a$ and $b$ ($1 <= a, b <= 10^9$).

+
+ +
+
Output
+

Print a single integer — the sum of $a$ and $b$.

+
+ +
+
Examples
+
+
+
Input
+
3 4
+
+
+
Output
+
7
+
+
+
+
+
Input
+
1000000000 1000000000
+
+
+
Output
+
2000000000
+
+
+
+ +
+
Note
+

In the first example the answer is 7.

+
+
+ + +`; + +// A page that contains no .problem-statement (e.g. 404 page, wrong URL). +const FIXTURE_NO_STATEMENT = ` + + + +
Page not found
+ + +`; + +// A problem with multiple paragraphs in the body and a multi-line sample. +const FIXTURE_MULTI_PARA = ` + + + +
+
+
B. Multi Para
+
+
time limit per test
1 second +
+
+
memory limit per test
512 megabytes +
+
+ +

First paragraph.

+

Second paragraph.

+

Third paragraph.

+ +
+
Input
+

First line is $n$.

+

Second line contains $n$ integers.

+
+ +
+
Output
+

Print the answer.

+
+ +
+
Examples
+
+
+
Input
+
3
+1 2 3
+
+
+
Output
+
6
+
+
+
+
+ + +`; + +describe('parseCFProblemStatement', () => { + it('returns null when there is no .problem-statement element', () => { + expect(parseCFProblemStatement(FIXTURE_NO_STATEMENT)).toBeNull(); + }); + + it('parses the problem title', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result.title).toBe('A. Two Sum'); + }); + + it('parses the time limit without the property-title label', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result.timeLimit).toBe('2 seconds'); + }); + + it('parses the memory limit without the property-title label', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result.memoryLimit).toBe('256 megabytes'); + }); + + it('parses statement body paragraphs', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result.statement).toContain('You are given two integers'); + expect(result.statement).toContain('Print their sum'); + }); + + it('does not include special section text in the statement body', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result.statement).not.toContain('Input'); + expect(result.statement).not.toContain('Output'); + expect(result.statement).not.toContain('Note'); + expect(result.statement).not.toContain('Examples'); + }); + + it('parses input specification without section-title', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result.inputSpecification).toContain('two integers'); + expect(result.inputSpecification).not.toContain('Input'); + }); + + it('parses output specification without section-title', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result.outputSpecification).toContain('sum of'); + expect(result.outputSpecification).not.toContain('Output'); + }); + + it('parses sample tests into input/output pairs', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result.samples).toHaveLength(2); + expect(result.samples[0]).toEqual({ input: '3 4', output: '7' }); + expect(result.samples[1]).toEqual({ + input: '1000000000 1000000000', + output: '2000000000', + }); + }); + + it('returns all expected fields', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result).toMatchObject({ + title: expect.any(String), + timeLimit: expect.any(String), + memoryLimit: expect.any(String), + statement: expect.any(String), + inputSpecification: expect.any(String), + outputSpecification: expect.any(String), + samples: expect.any(Array), + }); + }); + + it('handles multi-paragraph statement and multi-line sample input', () => { + const result = parseCFProblemStatement(FIXTURE_MULTI_PARA); + expect(result.title).toBe('B. Multi Para'); + expect(result.statement).toContain('First paragraph'); + expect(result.statement).toContain('Second paragraph'); + expect(result.statement).toContain('Third paragraph'); + expect(result.samples[0].input).toBe('3\n1 2 3'); + expect(result.samples[0].output).toBe('6'); + }); +}); diff --git a/eslint.config.js b/eslint.config.js index 26235ca..8ce2303 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,7 +27,7 @@ export default defineConfig([ }, }, { - files: ['server.js'], + files: ['server.js', 'cfStatementParser.js', 'cfStatementParser.test.js'], languageOptions: { globals: globals.node, }, diff --git a/package-lock.json b/package-lock.json index f93a172..c75cf9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "hdd-app", "version": "0.0.0", "dependencies": { + "cheerio": "^1.2.0", "express": "^5.2.1", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -1849,6 +1850,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/boxen": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", @@ -2049,6 +2056,48 @@ "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -2208,6 +2257,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2257,6 +2334,61 @@ "node": ">= 0.8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2305,6 +2437,43 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3038,6 +3207,37 @@ "hermes-estree": "0.25.1" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3526,6 +3726,18 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3657,6 +3869,55 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4549,6 +4810,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4782,6 +5052,40 @@ } } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 79e5739..21e4917 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test": "vitest run" }, "dependencies": { + "cheerio": "^1.2.0", "express": "^5.2.1", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/server.js b/server.js index 7161230..fb1eee6 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ import fs from 'fs'; import https from 'https'; import path from 'path'; import { fileURLToPath } from 'url'; +import { parseCFProblemStatement } from './cfStatementParser.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -53,6 +54,66 @@ function rateLimit(req, res, next) { // --------------------------------------------------------------------------- app.use(express.static(DIST_DIR)); +// --------------------------------------------------------------------------- +// Problem statement: GET /api/cf/problem/:contestId/:index/statement +// Fetches the Codeforces problem page server-side, parses it with cheerio, +// and returns structured plain-text fields – no raw HTML is forwarded. +// --------------------------------------------------------------------------- +app.get('/api/cf/problem/:contestId/:index/statement', rateLimit, (req, res) => { + const { contestId, index } = req.params; + + if (!/^\d+$/.test(contestId)) { + return res.status(400).json({ error: 'Invalid contestId: must be numeric.' }); + } + if (!/^[a-zA-Z0-9]+$/.test(index)) { + return res.status(400).json({ error: 'Invalid index: must be alphanumeric (e.g. A, B, C1).' }); + } + + const cfPath = `/problemset/problem/${contestId}/${index}`; + const options = { + hostname: CF_BASE, + path: cfPath, + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; HDD-App/1.0)', + Accept: 'text/html', + }, + }; + + let rawHtml = ''; + + const request = https.get(options, (cfRes) => { + if (cfRes.statusCode !== 200) { + res.status(502).json({ error: `Codeforces returned HTTP ${cfRes.statusCode}` }); + cfRes.resume(); + return; + } + + cfRes.setEncoding('utf-8'); + cfRes.on('data', chunk => { rawHtml += chunk; }); + cfRes.on('end', () => { + const parsed = parseCFProblemStatement(rawHtml); + if (!parsed) { + return res.status(404).json({ error: 'Problem statement not found on Codeforces page.' }); + } + res.json(parsed); + }); + }); + + request.on('error', (err) => { + console.error('[statement] Codeforces request failed:', err.message); + res.status(502).json({ error: 'Failed to reach Codeforces.' }); + }); + + request.setTimeout(15000, () => { + request.destroy(); + if (!res.headersSent) { + res.status(504).json({ error: 'Codeforces request timed out.' }); + } else { + res.end(); + } + }); +}); + // --------------------------------------------------------------------------- // Proxy: GET /api/cf/:method // Proxies requests to the Codeforces API (https://codeforces.com/api/:method) diff --git a/src/components/ProblemWorkspace.css b/src/components/ProblemWorkspace.css index adf4bec..cbeb10a 100644 --- a/src/components/ProblemWorkspace.css +++ b/src/components/ProblemWorkspace.css @@ -26,9 +26,12 @@ padding: 1.1rem 1.15rem; } -.problem-workspace-panel--info { +.problem-workspace-panel--info, +.problem-workspace-panel--statement { position: sticky; top: 72px; + max-height: calc(100vh - 90px); + overflow-y: auto; } .problem-workspace-section-label { @@ -149,8 +152,10 @@ grid-template-columns: 1fr; } - .problem-workspace-panel--info { + .problem-workspace-panel--info, + .problem-workspace-panel--statement { position: static; + max-height: none; } } @@ -172,3 +177,107 @@ width: 100%; } } + +/* ---- Statement panel ---------------------------------------------------- */ + +.problem-workspace-limits { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.85rem; +} + +.problem-workspace-limit-item { + font-size: 0.78rem; + color: var(--ik-muted); + background: var(--ik-surface); + border: 1px solid var(--ik-border); + border-radius: 4px; + padding: 0.15rem 0.5rem; +} + +.problem-workspace-stmt-loading { + color: var(--ik-muted); + font-size: 0.9rem; + margin-top: 0.5rem; +} + +.problem-workspace-stmt-error { + margin-top: 0.5rem; +} + +.problem-workspace-stmt-error p { + color: var(--ik-muted); + font-size: 0.9rem; + margin-bottom: 0.3rem; +} + +.problem-workspace-stmt-error-detail { + font-size: 0.8rem; + color: #e05050; + margin-bottom: 0.75rem; +} + +.problem-workspace-stmt-body { + margin-top: 1rem; + border-top: 1px solid var(--ik-border); + padding-top: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.problem-workspace-stmt-section { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.problem-workspace-stmt-heading { + font-size: 0.85rem; + font-weight: 700; + color: var(--ik-yellow); + margin: 0 0 0.25rem; +} + +.problem-workspace-stmt-text { + font-size: 0.87rem; + line-height: 1.65; + color: var(--ik-text); + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +.problem-workspace-sample { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-top: 0.35rem; +} + +.problem-workspace-sample-col { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.problem-workspace-sample-label { + font-size: 0.72rem; + font-weight: 700; + color: var(--ik-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.problem-workspace-sample-pre { + background: #111111; + border: 1px solid var(--ik-border); + border-radius: var(--ik-radius-sm); + padding: 0.55rem 0.7rem; + font: 0.82rem/1.5 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + color: var(--ik-text); + margin: 0; + white-space: pre; + overflow-x: auto; +} diff --git a/src/components/ProblemWorkspace.jsx b/src/components/ProblemWorkspace.jsx index e1bbd49..c875554 100644 --- a/src/components/ProblemWorkspace.jsx +++ b/src/components/ProblemWorkspace.jsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; +import { fetchProblemStatement } from '../services/cfStatement'; import { getDraftStorageKey, parseProblemWorkspaceQuery, @@ -38,6 +39,33 @@ function ProblemWorkspace() { const [code, setCode] = useState(() => loadDraft(storageKey)); + // Statement fetch state + const [stmtStatus, setStmtStatus] = useState('idle'); // 'idle' | 'loading' | 'ok' | 'error' + const [statement, setStatement] = useState(null); + const [stmtError, setStmtError] = useState(''); + + useEffect(() => { + if (!contestId || !index) return; + let cancelled = false; + setStmtStatus('loading'); + setStatement(null); + setStmtError(''); + fetchProblemStatement(contestId, index) + .then(data => { + if (!cancelled) { + setStatement(data); + setStmtStatus('ok'); + } + }) + .catch(err => { + if (!cancelled) { + setStmtError(err.message || 'Failed to load statement.'); + setStmtStatus('error'); + } + }); + return () => { cancelled = true; }; + }, [contestId, index]); + useEffect(() => { setCode(loadDraft(storageKey)); }, [storageKey]); @@ -52,46 +80,145 @@ function ProblemWorkspace() { const problemLink = `https://codeforces.com/problemset/problem/${contestId}/${index}`; + const displayTitle = + statement?.title || problemDetails.name || `${contestId} ${index}`; + return (
-