feat(remote_judger/luogu): luogu open api (#48)
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Baoshuo Ren 2023-03-21 13:44:34 +08:00 committed by GitHub
commit 95733e3905
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 226 additions and 70 deletions

View File

@ -12,7 +12,6 @@
"crlf-normalize": "^1.0.18",
"fs-extra": "^11.1.0",
"jsdom": "^21.0.0",
"lodash.flattendeep": "^4.4.0",
"math-sum": "^2.0.0",
"reggol": "^1.3.4",
"string-strip-html": "^13.1.0",
@ -826,11 +825,6 @@
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
},
"node_modules/lodash.flattendeep": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
"integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ=="
},
"node_modules/lodash.trim": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz",
@ -2288,11 +2282,6 @@
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
},
"lodash.flattendeep": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
"integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ=="
},
"lodash.trim": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz",

View File

@ -15,7 +15,6 @@
"crlf-normalize": "^1.0.18",
"fs-extra": "^11.1.0",
"jsdom": "^21.0.0",
"lodash.flattendeep": "^4.4.0",
"math-sum": "^2.0.0",
"reggol": "^1.3.4",
"string-strip-html": "^13.1.0",

View File

@ -4,7 +4,6 @@ import proxy from 'superagent-proxy';
import Logger from '../utils/logger';
import { IBasicProvider, RemoteAccount, USER_AGENT } from '../interface';
import sleep from '../utils/sleep';
import flattenDeep from 'lodash.flattendeep';
import htmlspecialchars from '../utils/htmlspecialchars';
proxy(superagent);
@ -76,10 +75,58 @@ const LANGS_MAP = {
},
};
const API_LANGS_MAP = {
C: {
id: 'c/99/gcc',
name: 'C',
comment: '//',
},
'C++98': {
id: 'cxx/98/gcc',
name: 'C++98',
comment: '//',
},
'C++11': {
id: 'cxx/11/gcc',
name: 'C++11',
comment: '//',
},
'C++': {
id: 'cxx/14/gcc',
name: 'C++14',
comment: '//',
},
'C++17': {
id: 'cxx/17/gcc',
name: 'C++17',
comment: '//',
},
'C++20': {
id: 'cxx/20/gcc',
name: 'C++20',
comment: '//',
},
Python3: {
id: 'python3/c',
name: 'Python 3',
comment: '#',
},
Java8: {
id: 'java/8',
name: 'Java 8',
comment: '//',
},
Pascal: {
id: 'pascal/fpc',
name: 'Pascal',
comment: '//',
},
};
function buildLuoguTestCaseInfoBlock(test) {
let res = '';
res += `<test num="${test.id + 1}" info="${STATUS_MAP[test.status]}" time="${
res += `<test num="${test.id}" info="${STATUS_MAP[test.status]}" time="${
test.time || -1
}" memory="${test.memory || -1}" score="${test.score || ''}">`;
res += `<res>${htmlspecialchars(test.description || '')}</res>`;
@ -88,6 +135,28 @@ function buildLuoguTestCaseInfoBlock(test) {
return res;
}
export function getAccountInfoFromEnv(): RemoteAccount | null {
const {
LUOGU_HANDLE,
LUOGU_PASSWORD,
LUOGU_ENDPOINT = 'https://open-v1.lgapi.cn',
LUOGU_PROXY,
} = process.env;
if (!LUOGU_HANDLE || !LUOGU_PASSWORD) return null;
const account: RemoteAccount = {
type: 'luogu-api',
handle: LUOGU_HANDLE,
password: LUOGU_PASSWORD,
endpoint: LUOGU_ENDPOINT,
};
if (LUOGU_PROXY) account.proxy = LUOGU_PROXY;
return account;
}
export default class LuoguProvider implements IBasicProvider {
constructor(public account: RemoteAccount) {
if (account.cookie) this.cookie = account.cookie;
@ -101,19 +170,23 @@ export default class LuoguProvider implements IBasicProvider {
}
cookie: string[] = [];
csrf: string;
csrf: string = null;
get(url: string) {
logger.debug('get', url, this.cookie);
if (!url.includes('//'))
url = `${this.account.endpoint || 'https://www.luogu.com.cn'}${url}`;
logger.debug('get', url, this.cookie);
const req = superagent
.get(url)
.set('Cookie', this.cookie)
.set('User-Agent', USER_AGENT);
if (this.account.type == 'luogu-api') {
req.auth(this.account.handle, this.account.password);
}
if (this.account.proxy) return req.proxy(this.account.proxy);
return req;
@ -137,11 +210,11 @@ export default class LuoguProvider implements IBasicProvider {
}
post(url: string) {
logger.debug('post', url, this.cookie);
if (!url.includes('//'))
url = `${this.account.endpoint || 'https://www.luogu.com.cn'}${url}`;
logger.debug('post', url, this.cookie);
const req = superagent
.post(url)
.set('Cookie', this.cookie)
@ -150,6 +223,10 @@ export default class LuoguProvider implements IBasicProvider {
.set('x-requested-with', 'XMLHttpRequest')
.set('origin', 'https://www.luogu.com.cn');
if (this.account.type == 'luogu-api') {
req.auth(this.account.handle, this.account.password);
}
if (this.account.proxy) return req.proxy(this.account.proxy);
return req;
@ -186,6 +263,8 @@ export default class LuoguProvider implements IBasicProvider {
}
async ensureLogin() {
if (this.account.type == 'luogu-api') return true;
if (await this.loggedIn) {
await this.getCsrfToken('/user/setting');
@ -227,7 +306,10 @@ export default class LuoguProvider implements IBasicProvider {
return null;
}
const programType = LANGS_MAP[lang] || LANGS_MAP['C++'];
const programType =
this.account.type == 'luogu-api'
? API_LANGS_MAP[lang] || API_LANGS_MAP['C++']
: LANGS_MAP[lang] || LANGS_MAP['C++'];
const comment = programType.comment;
if (comment) {
@ -237,6 +319,28 @@ export default class LuoguProvider implements IBasicProvider {
code = `${comment[0]} ${msg} ${comment[1]}\n${code}`;
}
if (this.account.type == 'luogu-api') {
const result = await this.post('/judge/problem').send({
pid: id,
code,
lang: programType.id,
o2: 1,
trackId: submissionId,
});
if (result.status == 402) {
await end({
error: true,
id,
status: 'Judgment Failed',
message: 'Payment required.',
});
return null;
}
return result.body.requestId;
} else {
const result = await this.post(`/fe/api/problem/submit/${id}`)
.set('referer', `https://www.luogu.com.cn/problem/${id}`)
.send({
@ -249,8 +353,11 @@ export default class LuoguProvider implements IBasicProvider {
return result.body.rid;
}
}
async ensureIsOwnSubmission(id: string) {
if (this.account.type == 'luogu-api') return true;
const { body } = await this.safeGet(`/record/${id}?_contentOnly=1`);
const current_uid = body.currentUser?.uid;
@ -273,24 +380,42 @@ export default class LuoguProvider implements IBasicProvider {
let fail = 0;
let count = 0;
while (count < 180 && fail < 10) {
await sleep(1000);
while (count < 360 && fail < 10) {
await sleep(500);
count++;
try {
const { body } = await this.safeGet(`/record/${id}?_contentOnly=1`);
const data = body.currentData.record;
let result, data, body;
if (!data) {
if (this.account.type == 'luogu-api') {
result = await this.get(`/judge/result?id=${id}`);
body = result.body;
data = body.data;
} else {
result = await this.safeGet(`/record/${id}?_contentOnly=1`);
body = result.body;
data = body.currentData.record;
}
if (result.status == 204) {
await next({ status: '[Luogu] Judging' });
continue;
}
if (result.status == 200 && !data) {
return await end({
error: true,
id: `R${id}`,
id,
status: 'Judgment Failed',
message: 'Failed to fetch submission details.',
});
}
if (data.problem.pid != problem_id) {
if (
this.account.type != 'luogu-api' &&
data.problem.pid != problem_id
) {
return await end({
id,
error: true,
@ -299,29 +424,55 @@ export default class LuoguProvider implements IBasicProvider {
});
}
if (this.account.type == 'luogu-api') {
data = {
...data,
status: data.judge.status,
score: data.judge.score,
time: data.judge.time,
memory: data.judge.memory,
detail: {
compileResult: data.compile,
judgeResult: {
...data.judge,
subtasks: data.judge.subtasks.map(sub => ({
...sub,
testCases: sub.cases,
})),
},
},
};
}
if (
data.detail.compileResult &&
data.detail.compileResult.success === false
) {
return await end({
error: true,
id: `R${id}`,
id,
status: 'Compile Error',
message: data.detail.compileResult.message,
});
}
logger.info('Fetched with length', JSON.stringify(body).length);
const total = flattenDeep(
Object.entries(body.currentData.testCaseGroup || {}).map(o => o[1])
).length;
if (!data.detail.judgeResult?.subtasks) continue;
const finishedTestCases = Object.entries(
data.detail.judgeResult.subtasks
)
.map(o => o[1])
.reduce(
(acc: number, sub: any) =>
acc +
Object.entries(sub.testCases as any[])
.map(o => o[1])
.filter(test => test.status >= 2).length,
0
);
await next({
status: `Judging (${
data.detail.judgeResult?.finishedCaseCount || '?'
}/${total})`,
status: `[Luogu] Judging (${finishedTestCases} judged)`,
});
if (data.status < 2) continue;
@ -331,24 +482,31 @@ export default class LuoguProvider implements IBasicProvider {
const status = STATUS_MAP[data.status];
let details = '';
if (this.account.type != 'luogu-api') {
details +=
'<remote-result-container>' +
'<remote-result-table>' +
Object.entries({
: `<a href="https://www.luogu.com.cn/problem/${
data.problem.pid
}">${data.problem.pid} ${htmlspecialchars(data.problem.title)}</a>`,
}">${data.problem.pid} ${htmlspecialchars(
data.problem.title
)}</a>`,
: `<a href="https://www.luogu.com.cn/record/${id}">R${id}</a>`,
提交时间: new Date(data.submitTime * 1000).toLocaleString('zh-CN'),
提交时间: new Date(data.submitTime * 1000).toLocaleString(
'zh-CN'
),
: `<a href="https://www.luogu.com.cn/user/${data.user.uid}">${data.user.name}</a>`,
状态: status,
})
.map(
o => `<remote-result-tr name="${o[0]}">${o[1]}</remote-result-tr>`
o =>
`<remote-result-tr name="${o[0]}">${o[1]}</remote-result-tr>`
)
.join('') +
'</remote-result-table>' +
'</remote-result-container>';
}
details += '<tests>';
@ -357,7 +515,12 @@ export default class LuoguProvider implements IBasicProvider {
data.detail.judgeResult.subtasks[0].testCases
)
.map(o => o[1])
.map(buildLuoguTestCaseInfoBlock)
.map((testcase: any) =>
buildLuoguTestCaseInfoBlock({
...testcase,
id: testcase.id + (this.account.type != 'luogu-api'),
})
)
.join('\n');
} else {
details += Object.entries(data.detail.judgeResult.subtasks)
@ -372,7 +535,12 @@ export default class LuoguProvider implements IBasicProvider {
subtask.testCases
)
.map(o => o[1])
.map(buildLuoguTestCaseInfoBlock)
.map((testcase: any) =>
buildLuoguTestCaseInfoBlock({
...testcase,
id: testcase.id + (this.account.type != 'luogu-api'),
})
)
.join('\n')}</subtask>`
)
.join('\n');
@ -381,15 +549,15 @@ export default class LuoguProvider implements IBasicProvider {
details += '</tests>';
return await end({
id: `R${id}`,
id,
status,
score:
status === 'Accepted'
? 100
: (data.score / data.problem.fullScore) * 100,
score: status === 'Accepted' ? 100 : Math.min(97, data.score),
time: data.time,
memory: data.memory,
details: `<div>${details}</div>`,
details:
this.account.type != 'luogu-api'
? `<div>${details}</div>`
: details,
});
} catch (e) {
logger.error(e);

View File

@ -241,7 +241,7 @@ export async function apply(request: any) {
await vjudge.addProvider('atcoder');
await vjudge.addProvider('uoj');
await vjudge.addProvider('loj');
await vjudge.importProvider('luogu');
await vjudge.addProvider('luogu');
await vjudge.addProvider('qoj');
return vjudge;

View File

@ -49,7 +49,7 @@ class UOJRemoteProblem {
'short_name' => '洛谷',
'url' => 'https://www.luogu.com.cn',
'languages' => ['C', 'C++98', 'C++11', 'C++', 'C++17', 'C++20', 'Python3', 'Java8', 'Pascal'],
'submit_type' => ['archive'],
'submit_type' => ['bot', 'my', 'archive'],
],
'qoj' => [
'name' => 'Qingyu Online Judge',