Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions cfStatementParser.js
Original file line number Diff line number Diff line change
@@ -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 <br> tags with newlines, then return the
* trimmed text content of an element. This preserves paragraph breaks without
* leaking any HTML tags to the caller.
*
Comment on lines +11 to +15
* @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:
// <div class="time-limit">
// <div class="property-title">time limit per test</div>2 seconds
// </div>
// 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,
};
}
214 changes: 214 additions & 0 deletions cfStatementParser.test.js
Original file line number Diff line number Diff line change
@@ -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 = `
<!DOCTYPE html>
<html>
<body>
<div class="problem-statement">
<div class="header">
<div class="title">A. Two Sum</div>
<div class="time-limit">
<div class="property-title">time limit per test</div>2 seconds
</div>
<div class="memory-limit">
<div class="property-title">memory limit per test</div>256 megabytes
</div>
<div class="input-file">
<div class="property-title">input</div>standard input
</div>
<div class="output-file">
<div class="property-title">output</div>standard output
</div>
</div>

<p>You are given two integers $a$ and $b$.</p>
<p>Print their sum.</p>

<div class="input-specification">
<div class="section-title">Input</div>
<p>The first line contains two integers $a$ and $b$ ($1 <= a, b <= 10^9$).</p>
</div>

<div class="output-specification">
<div class="section-title">Output</div>
<p>Print a single integer — the sum of $a$ and $b$.</p>
</div>

<div class="sample-tests">
<div class="section-title">Examples</div>
<div class="sample-test">
<div class="input">
<div class="title">Input</div>
<pre>3 4</pre>
</div>
<div class="output">
<div class="title">Output</div>
<pre>7</pre>
</div>
</div>
<div class="sample-test">
<div class="input">
<div class="title">Input</div>
<pre>1000000000 1000000000</pre>
</div>
<div class="output">
<div class="title">Output</div>
<pre>2000000000</pre>
</div>
</div>
</div>

<div class="note">
<div class="section-title">Note</div>
<p>In the first example the answer is 7.</p>
</div>
</div>
</body>
</html>
`;

// A page that contains no .problem-statement (e.g. 404 page, wrong URL).
const FIXTURE_NO_STATEMENT = `
<!DOCTYPE html>
<html>
<body>
<div class="content">Page not found</div>
</body>
</html>
`;

// A problem with multiple paragraphs in the body and a multi-line sample.
const FIXTURE_MULTI_PARA = `
<!DOCTYPE html>
<html>
<body>
<div class="problem-statement">
<div class="header">
<div class="title">B. Multi Para</div>
<div class="time-limit">
<div class="property-title">time limit per test</div>1 second
</div>
<div class="memory-limit">
<div class="property-title">memory limit per test</div>512 megabytes
</div>
</div>

<p>First paragraph.</p>
<p>Second paragraph.</p>
<p>Third paragraph.</p>

<div class="input-specification">
<div class="section-title">Input</div>
<p>First line is $n$.</p>
<p>Second line contains $n$ integers.</p>
</div>

<div class="output-specification">
<div class="section-title">Output</div>
<p>Print the answer.</p>
</div>

<div class="sample-tests">
<div class="section-title">Examples</div>
<div class="sample-test">
<div class="input">
<div class="title">Input</div>
<pre>3
1 2 3</pre>
</div>
<div class="output">
<div class="title">Output</div>
<pre>6</pre>
</div>
</div>
</div>
</div>
</body>
</html>
`;

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');
});
});
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default defineConfig([
},
},
{
files: ['server.js'],
files: ['server.js', 'cfStatementParser.js', 'cfStatementParser.test.js'],
languageOptions: {
globals: globals.node,
},
Expand Down
Loading
Loading