diff --git a/remote_judger/src/providers/qoj.ts b/remote_judger/src/providers/qoj.ts new file mode 100644 index 0000000..314e832 --- /dev/null +++ b/remote_judger/src/providers/qoj.ts @@ -0,0 +1,296 @@ +import { JSDOM } from 'jsdom'; +import superagent from 'superagent'; +import proxy from 'superagent-proxy'; +import Logger from '../utils/logger'; +import { IBasicProvider, RemoteAccount, USER_AGENT } from '../interface'; +import { parseTimeMS, parseMemoryMB } from '../utils/parse'; +import sleep from '../utils/sleep'; +import UOJProvider from './uoj'; + +proxy(superagent); +const logger = new Logger('remote/qoj'); + +const LANGS_MAP = { + C: { + name: 'C', + id: 'C', + comment: '//', + }, + 'C++98': { + name: 'C++ 98', + id: 'C++', + comment: '//', + }, + 'C++11': { + name: 'C++ 11', + id: 'C++11', + comment: '//', + }, + 'C++': { + name: 'C++ 14', + id: 'C++14', + comment: '//', + }, + 'C++17': { + name: 'C++ 17', + id: 'C++17', + comment: '//', + }, + 'C++20': { + name: 'C++ 20', + id: 'C++20', + comment: '//', + }, + 'Python2.7': { + name: 'Python 2', + id: 'Python2', + comment: '#', + }, + Python3: { + name: 'Python 3', + id: 'Python3', + comment: '#', + }, + Java8: { + name: 'Java 8', + id: 'Java8', + comment: '//', + }, + Java11: { + name: 'Java 11', + id: 'Java11', + comment: '//', + }, + Pascal: { + name: 'Pascal', + id: 'Pascal', + comment: '//', + }, +}; + +export function getAccountInfoFromEnv(): RemoteAccount | null { + const { + QOJ_HANDLE, + QOJ_PASSWORD, + QOJ_ENDPOINT = 'https://qoj.ac', + QOJ_PROXY, + } = process.env; + + if (!QOJ_HANDLE || !QOJ_PASSWORD) return null; + + const account: RemoteAccount = { + type: 'qoj', + handle: QOJ_HANDLE, + password: QOJ_PASSWORD, + endpoint: QOJ_ENDPOINT, + }; + + if (QOJ_PROXY) account.proxy = QOJ_PROXY; + + return account; +} + +export default class QOJProvider extends UOJProvider implements IBasicProvider { + constructor(public account: RemoteAccount) { + super(account); + } + + static constructFromAccountData(data) { + return new this({ + type: 'qoj', + cookie: Object.entries(data).map(([key, value]) => `${key}=${value}`), + }); + } + + cookie: string[] = []; + csrf: string = null; + + get(url: string) { + logger.debug('get', url, this.cookie); + + if (!url.includes('//')) + url = `${this.account.endpoint || 'https://qoj.ac'}${url}`; + + const req = superagent + .get(url) + .set('Cookie', this.cookie) + .set('User-Agent', USER_AGENT); + + 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 || 'https://qoj.ac'}${url}`; + + const req = superagent + .post(url) + .set('Cookie', this.cookie) + .set('User-Agent', USER_AGENT) + .type('form'); + + if (this.account.proxy) return req.proxy(this.account.proxy); + + return req; + } + + get loggedIn() { + return this.get('/login').then( + ({ text: html }) => + !html.includes('Login') && + !html.includes('<input type="password"') + ); + } + + async ensureLogin() { + if (await this.loggedIn) return true; + + if (!this.account.handle) return false; + + logger.info('retry login'); + + const _token = await this.getCsrfToken('/login'); + const { header, text } = await this.post('/login').send({ + _token, + login: '', + username: this.account.handle, + // NOTE: you should pass a pre-hashed key! + password: this.account.password, + }); + + if (header['set-cookie'] && this.cookie.length === 1) { + header['set-cookie'].push(...this.cookie); + this.cookie = header['set-cookie']; + } + + if (text === 'ok') return true; + + return text; + } + + async submitProblem( + id: string, + lang: string, + code: string, + submissionId: number, + next, + end + ) { + if (!(await this.ensureLogin())) { + await end({ + error: true, + status: 'Judgment Failed', + message: 'Login failed', + }); + + return null; + } + + 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 _token = await this.getCsrfToken(`/problem/${id}`); + const { text } = await this.post(`/problem/${id}`).send({ + _token, + answer_answer_language: programType.id, + answer_answer_upload_type: 'editor', + answer_answer_editor: code, + 'submit-answer': 'answer', + }); + + if (!text.includes('href="/submissions?submitter=' + this.account.handle)) { + throw new Error('Submit failed'); + } + + const { text: status } = await this.get( + `/submissions?problem_id=${id}&submitter=${this.account.handle}` + ); + + const $dom = new JSDOM(status); + + return $dom.window.document + .querySelector('tbody>tr>td>a') + .innerHTML.split('#')[1]; + } + + async waitForSubmission(id: string, next, end) { + let count = 0; + let fail = 0; + + while (count < 180 && fail < 10) { + count++; + await sleep(1000); + + try { + const { text } = await this.get(`/submission/${id}`); + const { + window: { document }, + } = new JSDOM(text); + const find = (content: string) => + Array.from( + document.querySelectorAll('.panel-heading>.panel-title') + ).find(n => n.innerHTML === content).parentElement.parentElement + .children[1]; + if (text.includes('Compile Error')) { + return await end({ + error: true, + id, + status: 'Compile Error', + message: find('详细').children[0].innerHTML, + }); + } + + await next({}); + + const summary = document.querySelector('tbody>tr'); + if (!summary) continue; + const time = parseTimeMS(summary.children[4].innerHTML); + const memory = parseMemoryMB(summary.children[5].innerHTML) * 1024; + let panel = document.getElementById( + 'details_details_accordion_collapse_subtask_1' + ); + if (!panel) { + panel = document.getElementById('details_details_accordion'); + if (!panel) continue; + } + + if (document.querySelector('tbody').innerHTML.includes('Judging')) + continue; + + const score = + parseInt(summary.children[3]?.children[0]?.innerHTML || '') || 0; + const status = score === 100 ? 'Accepted' : 'Unaccepted'; + + return await end({ + id, + status, + score, + time, + memory, + }); + } catch (e) { + logger.error(e); + + fail++; + } + } + + return await end({ + id, + error: true, + status: 'Judgment Failed', + message: 'Failed to fetch submission details.', + }); + } +} diff --git a/remote_judger/src/vjudge.ts b/remote_judger/src/vjudge.ts index ab4deff..a3d0394 100644 --- a/remote_judger/src/vjudge.ts +++ b/remote_judger/src/vjudge.ts @@ -242,6 +242,7 @@ export async function apply(request: any) { await vjudge.addProvider('uoj'); await vjudge.addProvider('loj'); await vjudge.importProvider('luogu'); + await vjudge.addProvider('qoj'); return vjudge; } diff --git a/web/app/controllers/new_remote_problem.php b/web/app/controllers/new_remote_problem.php index 6af5b90..d1bfb58 100644 --- a/web/app/controllers/new_remote_problem.php +++ b/web/app/controllers/new_remote_problem.php @@ -60,6 +60,14 @@ $new_remote_problem_form->addInput('remote_problem_id', [ $vdata['remote_problem_id'] = $id; + return ''; + } else if ($remote_oj === 'qoj') { + if (!validateUInt($id)) { + return '不合法的题目 ID'; + } + + $vdata['remote_problem_id'] = $id; + return ''; } @@ -153,6 +161,7 @@ $new_remote_problem_form->runAtServer(); <li><a href="https://uoj.ac/problems">UniversalOJ</a></li> <li><a href="https://loj.ac/p">LibreOJ</a></li> <li><a href="https://www.luogu.com.cn/problem/list">洛谷</a>(不能使用公用账号提交)</li> + <li><a href="https://qoj.ac/problems">Qingyu Online Judge</a></li> </ul> </li> <li>在导入题目前请先搜索题库中是否已经存在相应题目,避免重复添加。</li> diff --git a/web/app/models/UOJRemoteProblem.php b/web/app/models/UOJRemoteProblem.php index c2eb23b..ce9fa63 100644 --- a/web/app/models/UOJRemoteProblem.php +++ b/web/app/models/UOJRemoteProblem.php @@ -51,6 +51,16 @@ class UOJRemoteProblem { 'languages' => ['C', 'C++98', 'C++11', 'C++', 'C++17', 'C++20', 'Python3', 'Java8', 'Pascal'], 'submit_type' => ['archive'], ], + 'qoj' => [ + 'name' => 'Qingyu Online Judge', + 'short_name' => 'QOJ', + 'url' => 'https://qoj.ac', + 'not_exist_texts' => [ + '未找到该页面', + ], + 'languages' => ['C', 'C++98', 'C++11', 'C++', 'C++17', 'C++20', 'Python3', 'Python2.7', 'Java8', 'Java11', 'Pascal'], + 'submit_type' => ['bot'], + ], ]; private static function curl_get($url) { @@ -105,6 +115,10 @@ class UOJRemoteProblem { return static::$providers['luogu']['url'] . '/problem/' . $id; } + private static function getQojProblemUrl($id) { + return static::$providers['qoj']['url'] . '/problem/' . $id; + } + private static function getCodeforcesProblemBasicInfoFromHtml($id, $html) { $remote_provider = static::$providers['codeforces']; @@ -205,6 +219,55 @@ class UOJRemoteProblem { ]; } + private static function getUojLikeProblemBasicInfoFromHtml($id, $html, $oj = 'uoj') { + $remote_provider = static::$providers[$oj]; + + $dom = new \IvoPetkov\HTML5DOMDocument(); + $dom->loadHTML($html); + + $title_dom = $dom->querySelector('.page-header'); + $title_matches = []; + preg_match('/^#[1-9][0-9]*\. (.*)$/', trim($title_dom->textContent), $title_matches); + $title = "【{$remote_provider['short_name']}{$id}】{$title_matches[1]}"; + + $statement_dom = $dom->querySelector('.uoj-article'); + $statement = HTML::tag('h3', [], '题目描述'); + + foreach ($statement_dom->querySelectorAll('a') as &$elem) { + $href = $elem->getAttribute('href'); + $href = getAbsoluteUrl($href, $remote_provider['url']); + $elem->setAttribute('href', $href); + } + + $statement .= $statement_dom->innerHTML; + + $res = [ + 'type' => 'html', + 'title' => $title, + 'time_limit' => null, + 'memory_limit' => null, + 'difficulty' => -1, + 'statement' => $statement, + ]; + + if ($oj == 'qoj') { // QOJ PDF + $pdf_statement_dom = $dom->getElementById('statements-pdf'); + + if ($pdf_statement_dom) { + $pdf_url = $pdf_statement_dom->getAttribute('src'); + $pdf_res = static::curl_get(getAbsoluteUrl($pdf_url, $remote_provider['url'])); + + if (str_starts_with($pdf_res['content-type'], 'application/pdf')) { + $res['type'] = 'pdf'; + $res['pdf_data'] = $pdf_res['response']; + $res['statement'] = ''; + } + } + } + + return $res; + } + private static function getCodeforcesProblemBasicInfo($id) { $res = static::curl_get(static::getCodeforcesProblemUrl($id)); @@ -224,14 +287,7 @@ class UOJRemoteProblem { 'memory_limit' => null, 'difficulty' => -1, 'pdf_data' => $res['response'], - 'statement' => HTML::tag('h3', [], '提示') . - HTML::tag( - 'p', - [], - '若无法正常加载 PDF,请' . - HTML::tag('a', ['href' => static::getCodeforcesProblemUrl($id), 'target' => '_blank'], '点此') . - '查看原题面。' - ), + 'statement' => '', ]; } else { return null; @@ -313,38 +369,19 @@ class UOJRemoteProblem { } private static function getUojProblemBasicInfo($id) { - $remote_provider = static::$providers['uoj']; $res = static::curl_get(static::getUojProblemUrl($id)); if (!$res) return null; - $dom = new \IvoPetkov\HTML5DOMDocument(); - $dom->loadHTML($res['response']); + return static::getUojLikeProblemBasicInfoFromHtml($id, $res['response'], 'uoj'); + } - $title_dom = $dom->querySelector('.page-header'); - $title_matches = []; - preg_match('/^#[1-9][0-9]*\. (.*)$/', trim($title_dom->textContent), $title_matches); - $title = "【{$remote_provider['short_name']}{$id}】{$title_matches[1]}"; + private static function getQojProblemBasicInfo($id) { + $res = static::curl_get(static::getQojProblemUrl($id)); - $statement_dom = $dom->querySelector('.uoj-article'); - $statement = HTML::tag('h3', [], '题目描述'); + if (!$res) return null; - foreach ($statement_dom->querySelectorAll('a') as &$elem) { - $href = $elem->getAttribute('href'); - $href = getAbsoluteUrl($href, $remote_provider['url']); - $elem->setAttribute('href', $href); - } - - $statement .= $statement_dom->innerHTML; - - return [ - 'type' => 'html', - 'title' => $title, - 'time_limit' => null, - 'memory_limit' => null, - 'difficulty' => -1, - 'statement' => $statement, - ]; + return static::getUojLikeProblemBasicInfoFromHtml($id, $res['response'], 'qoj'); } private static function getLojProblemBasicInfo($id) { @@ -490,6 +527,8 @@ class UOJRemoteProblem { return static::getLojProblemUrl($id); } else if ($oj === 'luogu') { return static::getLuoguProblemUrl($id); + } else if ($oj === 'qoj') { + return static::getQojProblemUrl($id); } return null; @@ -507,6 +546,8 @@ class UOJRemoteProblem { return static::getLojProblemBasicInfo($id); } else if ($oj === 'luogu') { return static::getLuoguProblemBasicInfo($id); + } else if ($oj === 'qoj') { + return static::getQojProblemBasicInfo($id); } return null;