diff --git a/remote_judger/src/interface.ts b/remote_judger/src/interface.ts index edf1ddf..208573d 100644 --- a/remote_judger/src/interface.ts +++ b/remote_judger/src/interface.ts @@ -20,6 +20,7 @@ export interface IBasicProvider { end: NextFunction ): Promise; waitForSubmission( + problem_id: string, id: string, next: NextFunction, end: NextFunction diff --git a/remote_judger/src/providers/atcoder.ts b/remote_judger/src/providers/atcoder.ts new file mode 100644 index 0000000..fd38931 --- /dev/null +++ b/remote_judger/src/providers/atcoder.ts @@ -0,0 +1,332 @@ +import { JSDOM } from 'jsdom'; +import superagent from 'superagent'; +import proxy from 'superagent-proxy'; +import sleep from '../utils/sleep'; +import { IBasicProvider, RemoteAccount } from '../interface'; +import Logger from '../utils/logger'; + +proxy(superagent); +const logger = new Logger('remote/atcoder'); + +const langs_map = { + C: { + name: 'C (GCC 9.2.1)', + id: 4001, + comment: '//', + }, + 'C++': { + name: 'C++ (GCC 9.2.1)', + id: 4003, + comment: '//', + }, + Pascal: { + name: 'Pascal (FPC 3.0.4)', + id: 4041, + comment: '//', + }, + Python3: { + name: 'Python (3.8.2)', + id: 4006, + comment: '#', + }, +}; + +export function getAccountInfoFromEnv(): RemoteAccount | null { + const { + ATCODER_HANDLE, + ATCODER_PASSWORD, + ATCODER_ENDPOINT = 'https://atcoder.jp', + ATCODER_PROXY, + } = process.env; + + if (!ATCODER_HANDLE || !ATCODER_PASSWORD) return null; + + const account: RemoteAccount = { + type: 'atcoder', + handle: ATCODER_HANDLE, + password: ATCODER_PASSWORD, + endpoint: ATCODER_ENDPOINT, + }; + + if (ATCODER_PROXY) account.proxy = ATCODER_PROXY; + + return account; +} + +function parseProblemId(id: string) { + let [, contestId, problemId] = /^(\w+)([a-z][1-9]?)$/.exec(id); + + if (contestId.endsWith('_')) { + problemId = `${contestId}${problemId}`; + } else { + problemId = `${contestId}_${problemId}`; + } + + contestId = contestId.replace(/_/g, ''); + + return [contestId, problemId]; +} + +export default class AtcoderProvider implements IBasicProvider { + constructor(public account: RemoteAccount) { + if (account.cookie) this.cookie = account.cookie; + this.account.endpoint ||= 'https://atcoder.jp'; + } + + cookie: string[] = ['language=en']; + csrf: string; + + get(url: string) { + logger.debug('get', url); + if (!url.includes('//')) url = `${this.account.endpoint}${url}`; + const req = superagent + .get(url) + .redirects(0) + .ok(res => res.status < 400) + .set('Cookie', this.cookie) + .set( + 'User-Agent', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36 S2OJ/3.1.0' + ); + if (this.account.proxy) return req.proxy(this.account.proxy); + return req; + } + + post(url: string) { + logger.debug('post', url, this.cookie); + if (!url.includes('//')) url = `${this.account.endpoint}${url}`; + const req = superagent + .post(url) + .type('form') + .redirects(0) + .ok(res => res.status < 400) + .set('Cookie', this.cookie) + .set( + 'User-Agent', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36 S2OJ/3.1.0' + ); + if (this.account.proxy) return req.proxy(this.account.proxy); + return req; + } + + getCookie(target: string) { + return this.cookie + .find(i => i.startsWith(`${target}=`)) + ?.split('=')[1] + ?.split(';')[0]; + } + + setCookie(target: string, value: string) { + this.cookie = this.cookie.filter(i => !i.startsWith(`${target}=`)); + this.cookie.push(`${target}=${value}`); + } + + async getCsrfToken(url: string) { + const { text: html, header } = await this.get(url); + const { + window: { document }, + } = new JSDOM(html); + + if (header['set-cookie']) { + this.cookie = header['set-cookie']; + } + + if (document.body.children.length < 2 && html.length < 512) { + throw new Error(document.body.textContent!); + } + + return document + .querySelector('input[name="csrf_token"]') + ?.getAttribute('value'); + } + + get loggedIn() { + return this.get('/login').then(res => { + const html = res.text; + + if (res.header['set-cookie']) { + this.cookie = res.header['set-cookie']; + } + + if (html.includes('Sign In')) return false; + return true; + }); + } + + async ensureLogin() { + if (await this.loggedIn) return true; + logger.info('retry normal login'); + const csrf = await this.getCsrfToken('/login'); + const res = await this.post('/login').send({ + csrf_token: csrf, + username: this.account.handle, + password: this.account.password, + }); + const cookie = res.header['set-cookie']; + if (cookie) { + this.cookie = cookie; + } + if (await this.loggedIn) { + logger.success('Logged in'); + return true; + } + return false; + } + + async submitProblem( + id: string, + lang: string, + code: string, + submissionId: number, + next, + end + ) { + const programType = langs_map[lang] || langs_map['C++']; + const comment = programType.comment; + + if (comment) { + const msg = `S2OJ Submission #${submissionId} @ ${new Date().getTime()}`; + if (typeof comment === 'string') code = `${comment} ${msg}\n${code}`; + else if (comment instanceof Array) + code = `${comment[0]} ${msg} ${comment[1]}\n${code}`; + } + + const [contestId, problemId] = parseProblemId(id); + const csrf = await this.getCsrfToken( + `/contests/${contestId}/tasks/${problemId}` + ); + + logger.debug( + 'Submitting', + id, + programType, + lang, + `(S2OJ Submission #${submissionId})` + ); + + // TODO: check submit time to ensure submission + const res = await this.post(`/contests/${contestId}/submit`).send({ + csrf_token: csrf, + 'data.TaskScreenName': problemId, + 'data.LanguageId': programType.id, + sourceCode: code, + }); + + if (res.error) { + await end({ + error: true, + status: 'Judgment Failed', + message: 'Failed to submit code.', + }); + + return null; + } + + if (res.header['set-cookie']) { + this.cookie = res.header['set-cookie']; + } + + const { text: status, header: status_header } = await this.get( + `/contests/${contestId}/submissions/me` + ).retry(3); + + if (status_header['set-cookie']) { + this.cookie = status_header['set-cookie']; + } + + const { + window: { document }, + } = new JSDOM(status); + + return document + .querySelector('.submission-score[data-id]') + .getAttribute('data-id'); + } + + async waitForSubmission(problem_id: string, id: string, next, end) { + let i = 0; + + const [contestId] = parseProblemId(problem_id); + const status_url = `/contests/${contestId}/submissions/me/status/json?reload=true&sids[]=${id}`; + + while (true) { + if (++i > 60) { + return await end({ + id, + error: true, + status: 'Judgment Failed', + message: 'Failed to fetch submission details.', + }); + } + + await sleep(2000); + const { body, error, header } = await this.get(status_url).retry(3); + + if (header['set-cookie']) { + this.cookie = header['set-cookie']; + } + + if (error) continue; + + const result = body.Result[id]; + const { + window: { document }, + } = new JSDOM(`${result.Html}
`); + + const elements = document.querySelectorAll('td'); + const statusTd = elements[0]; + const statusElem = statusTd.querySelector('span'); + + if ( + statusElem.title === 'Waiting for Judging' || + statusElem.title === 'Waiting for Re-judging' || + ['WJ', 'WR'].includes(statusElem.innerHTML.trim()) + ) { + await next({ test_id: 0 }); + + continue; + } + + if ( + statusElem.title === 'Judging' || + (statusTd.colSpan == 3 && statusTd.className.includes('waiting-judge')) + ) { + await next({ test_id: /(\d+)/.exec(statusElem.innerHTML)[1] || 0 }); + + continue; + } + + if (statusElem.title === 'Compilation Error') { + return await end({ + id, + error: true, + status: 'Compile Error', + message: '', + }); + } + + if (statusElem.title === 'Internal Error') { + return await end({ + error: true, + status: 'Judgment Failed', + message: 'AtCoder Internal Error.', + }); + } + + const time = parseInt(elements[1].innerHTML.trim()); + const memory = parseInt(elements[2].innerHTML.trim()); + + return await end({ + id, + status: statusElem.title || 'None', + score: + statusElem.title === 'Accepted' || + statusElem.innerHTML.trim() === 'AC' + ? 100 + : 0, + time, + memory, + }); + } + } +} diff --git a/remote_judger/src/providers/codeforces.ts b/remote_judger/src/providers/codeforces.ts index fb6e678..3cec070 100644 --- a/remote_judger/src/providers/codeforces.ts +++ b/remote_judger/src/providers/codeforces.ts @@ -233,7 +233,7 @@ export default class CodeforcesProvider implements IBasicProvider { `(S2OJ Submission #${submissionId})` ); // TODO: check submit time to ensure submission - const { text: submit } = await this.post( + const { text: submit, error } = await this.post( `/${ type !== 'GYM' ? 'problemset' : `gym/${contestId}` }/submit?csrf_token=${csrf}` @@ -256,6 +256,17 @@ export default class CodeforcesProvider implements IBasicProvider { submittedProblemIndex: problemId, }), }); + + if (error) { + end({ + error: true, + status: 'Judgment Failed', + message: 'Failed to submit code.', + }); + + return null; + } + const { window: { document: statusDocument }, } = new JSDOM(submit); @@ -264,10 +275,12 @@ export default class CodeforcesProvider implements IBasicProvider { .join('') .replace(/ /g, ' ') .trim(); + if (message) { end({ error: true, status: 'Compile Error', message }); return null; } + const { text: status } = await this.get( type !== 'GYM' ? '/problemset/status?my=on' : `/gym/${contestId}/my` ).retry(3); @@ -282,17 +295,27 @@ export default class CodeforcesProvider implements IBasicProvider { .getAttribute('data-submission-id'); } - async waitForSubmission(id: string, next, end) { - let i = 1; + async waitForSubmission(problem_id: string, id: string, next, end) { + let i = 0; while (true) { + if (++i > 60) { + return await end({ + id, + error: true, + status: 'Judgment Failed', + message: 'Failed to fetch submission details.', + }); + } + await sleep(3000); - const { body } = await this.post('/data/submitSource') + const { body, error } = await this.post('/data/submitSource') .send({ csrf_token: this.csrf, submissionId: id, }) .retry(3); + if (error) continue; if (body.compilationError === 'true') { return await end({ id, diff --git a/remote_judger/src/vjudge.ts b/remote_judger/src/vjudge.ts index fb9e900..b2f3e3a 100644 --- a/remote_judger/src/vjudge.ts +++ b/remote_judger/src/vjudge.ts @@ -86,7 +86,7 @@ class AccountService { if (!rid) return; - await this.api.waitForSubmission(rid, next, end); + await this.api.waitForSubmission(problem_id, rid, next, end); } catch (e) { logger.error(e); await end({ error: true, message: e.message }); @@ -150,6 +150,7 @@ export async function apply(request: any) { const vjudge = new VJudge(request); await vjudge.addProvider('codeforces'); + await vjudge.addProvider('atcoder'); return vjudge; }