feat(cses): implement submit via REST API (#299)
## Problem CSES submit was a stub returning "not yet implemented". ## Solution Authenticate via web login + API token bridge (POST `/login` form, then POST `/api/login` and confirm the auth page), submit source to `/api/courses/problemset/submissions` with base64-encoded content, and poll for verdict. Uses the same username/password credential model as AtCoder — no browser dependencies needed. Tested end-to-end with a real CSES account (verdict: `ACCEPTED`). Also updates `scraper.lua` to pass the full ndjson event object to `on_status` and handle `credentials` events for future platform use.
This commit is contained in:
parent
e674265527
commit
e9f72dfbbc
4 changed files with 210 additions and 9 deletions
|
|
@ -298,9 +298,12 @@ function M.submit(
|
||||||
stdin = source_code,
|
stdin = source_code,
|
||||||
env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) },
|
env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) },
|
||||||
on_event = function(ev)
|
on_event = function(ev)
|
||||||
|
if ev.credentials ~= nil then
|
||||||
|
require('cp.cache').set_credentials(platform, ev.credentials)
|
||||||
|
end
|
||||||
if ev.status ~= nil then
|
if ev.status ~= nil then
|
||||||
if type(on_status) == 'function' then
|
if type(on_status) == 'function' then
|
||||||
on_status(ev.status)
|
on_status(ev)
|
||||||
end
|
end
|
||||||
elseif ev.success ~= nil then
|
elseif ev.success ~= nil then
|
||||||
done = true
|
done = true
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ local function prompt_credentials(platform, callback)
|
||||||
vim.fn.inputsave()
|
vim.fn.inputsave()
|
||||||
local password = vim.fn.inputsecret(platform .. ' password: ')
|
local password = vim.fn.inputsecret(platform .. ' password: ')
|
||||||
vim.fn.inputrestore()
|
vim.fn.inputrestore()
|
||||||
|
vim.cmd.redraw()
|
||||||
if not password or password == '' then
|
if not password or password == '' then
|
||||||
logger.log('Submit cancelled', vim.log.levels.WARN)
|
logger.log('Submit cancelled', vim.log.levels.WARN)
|
||||||
return
|
return
|
||||||
|
|
@ -64,9 +65,9 @@ function M.submit(opts)
|
||||||
language,
|
language,
|
||||||
source_code,
|
source_code,
|
||||||
creds,
|
creds,
|
||||||
function(status)
|
function(ev)
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
vim.notify('[cp.nvim] ' .. (STATUS_MSGS[status] or status), vim.log.levels.INFO)
|
vim.notify('[cp.nvim] ' .. (STATUS_MSGS[ev.status] or ev.status), vim.log.levels.INFO)
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
function(result)
|
function(result)
|
||||||
|
|
|
||||||
155
scrapers/cses.py
155
scrapers/cses.py
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -18,6 +19,8 @@ from .models import (
|
||||||
)
|
)
|
||||||
|
|
||||||
BASE_URL = "https://cses.fi"
|
BASE_URL = "https://cses.fi"
|
||||||
|
API_URL = "https://cses.fi/api"
|
||||||
|
SUBMIT_SCOPE = "courses/problemset"
|
||||||
INDEX_PATH = "/problemset"
|
INDEX_PATH = "/problemset"
|
||||||
TASK_PATH = "/problemset/task/{id}"
|
TASK_PATH = "/problemset/task/{id}"
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
|
|
@ -26,6 +29,16 @@ HEADERS = {
|
||||||
TIMEOUT_S = 15.0
|
TIMEOUT_S = 15.0
|
||||||
CONNECTIONS = 8
|
CONNECTIONS = 8
|
||||||
|
|
||||||
|
CSES_LANGUAGES: dict[str, dict[str, str]] = {
|
||||||
|
"C++17": {"name": "C++", "option": "C++17"},
|
||||||
|
"Python3": {"name": "Python", "option": "CPython3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
EXTENSIONS: dict[str, str] = {
|
||||||
|
"C++17": "cpp",
|
||||||
|
"Python3": "py",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def normalize_category_name(category_name: str) -> str:
|
def normalize_category_name(category_name: str) -> str:
|
||||||
return category_name.lower().replace(" ", "_").replace("&", "and")
|
return category_name.lower().replace(" ", "_").replace("&", "and")
|
||||||
|
|
@ -270,6 +283,65 @@ class CSESScraper(BaseScraper):
|
||||||
payload = await coro
|
payload = await coro
|
||||||
print(json.dumps(payload), flush=True)
|
print(json.dumps(payload), flush=True)
|
||||||
|
|
||||||
|
async def _web_login(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
) -> str | None:
|
||||||
|
login_page = await client.get(
|
||||||
|
f"{BASE_URL}/login", headers=HEADERS, timeout=TIMEOUT_S
|
||||||
|
)
|
||||||
|
csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', login_page.text)
|
||||||
|
if not csrf_match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
login_resp = await client.post(
|
||||||
|
f"{BASE_URL}/login",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf_match.group(1),
|
||||||
|
"nick": username,
|
||||||
|
"pass": password,
|
||||||
|
},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=TIMEOUT_S,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "Invalid username or password" in login_resp.text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
api_resp = await client.post(
|
||||||
|
f"{API_URL}/login", headers=HEADERS, timeout=TIMEOUT_S
|
||||||
|
)
|
||||||
|
api_data = api_resp.json()
|
||||||
|
token: str = api_data["X-Auth-Token"]
|
||||||
|
auth_url: str = api_data["authentication_url"]
|
||||||
|
|
||||||
|
auth_page = await client.get(auth_url, headers=HEADERS, timeout=TIMEOUT_S)
|
||||||
|
auth_csrf = re.search(r'name="csrf_token" value="([^"]+)"', auth_page.text)
|
||||||
|
form_token = re.search(r'name="token" value="([^"]+)"', auth_page.text)
|
||||||
|
if not auth_csrf or not form_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
auth_url,
|
||||||
|
data={
|
||||||
|
"csrf_token": auth_csrf.group(1),
|
||||||
|
"token": form_token.group(1),
|
||||||
|
},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=TIMEOUT_S,
|
||||||
|
)
|
||||||
|
|
||||||
|
check = await client.get(
|
||||||
|
f"{API_URL}/login",
|
||||||
|
headers={"X-Auth-Token": token, **HEADERS},
|
||||||
|
timeout=TIMEOUT_S,
|
||||||
|
)
|
||||||
|
if check.status_code != 200:
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
async def submit(
|
async def submit(
|
||||||
self,
|
self,
|
||||||
contest_id: str,
|
contest_id: str,
|
||||||
|
|
@ -278,12 +350,83 @@ class CSESScraper(BaseScraper):
|
||||||
language_id: str,
|
language_id: str,
|
||||||
credentials: dict[str, str],
|
credentials: dict[str, str],
|
||||||
) -> SubmitResult:
|
) -> SubmitResult:
|
||||||
return SubmitResult(
|
username = credentials.get("username", "")
|
||||||
success=False,
|
password = credentials.get("password", "")
|
||||||
error="CSES submit not yet implemented",
|
if not username or not password:
|
||||||
submission_id="",
|
return self._submit_error("Missing credentials. Use :CP login cses")
|
||||||
verdict="",
|
|
||||||
)
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
|
||||||
|
token = await self._web_login(client, username, password)
|
||||||
|
if not token:
|
||||||
|
return self._submit_error("Login failed (bad credentials?)")
|
||||||
|
|
||||||
|
print(json.dumps({"status": "submitting"}), flush=True)
|
||||||
|
|
||||||
|
ext = EXTENSIONS.get(language_id, "cpp")
|
||||||
|
lang = CSES_LANGUAGES.get(language_id, {})
|
||||||
|
content_b64 = base64.b64encode(source_code.encode()).decode()
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"language": lang,
|
||||||
|
"filename": f"{problem_id}.{ext}",
|
||||||
|
"content": content_b64,
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"{API_URL}/{SUBMIT_SCOPE}/submissions",
|
||||||
|
json=payload,
|
||||||
|
params={"task": problem_id},
|
||||||
|
headers={
|
||||||
|
"X-Auth-Token": token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
**HEADERS,
|
||||||
|
},
|
||||||
|
timeout=TIMEOUT_S,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code not in range(200, 300):
|
||||||
|
try:
|
||||||
|
err = r.json().get("message", r.text)
|
||||||
|
except Exception:
|
||||||
|
err = r.text
|
||||||
|
return self._submit_error(f"Submit request failed: {err}")
|
||||||
|
|
||||||
|
info = r.json()
|
||||||
|
submission_id = str(info.get("id", ""))
|
||||||
|
|
||||||
|
for _ in range(60):
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
f"{API_URL}/{SUBMIT_SCOPE}/submissions/{submission_id}",
|
||||||
|
params={"poll": "true"},
|
||||||
|
headers={
|
||||||
|
"X-Auth-Token": token,
|
||||||
|
**HEADERS,
|
||||||
|
},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
info = r.json()
|
||||||
|
if not info.get("pending", True):
|
||||||
|
verdict = info.get("result", "unknown")
|
||||||
|
return SubmitResult(
|
||||||
|
success=True,
|
||||||
|
error="",
|
||||||
|
submission_id=submission_id,
|
||||||
|
verdict=verdict,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return SubmitResult(
|
||||||
|
success=True,
|
||||||
|
error="",
|
||||||
|
submission_id=submission_id,
|
||||||
|
verdict="submitted (poll timed out)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
54
t/1068.cc
Normal file
54
t/1068.cc
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
#include <bits/stdc++.h> // {{{
|
||||||
|
|
||||||
|
#include <version>
|
||||||
|
#ifdef __cpp_lib_ranges_enumerate
|
||||||
|
#include <ranges>
|
||||||
|
namespace rv = std::views;
|
||||||
|
namespace rs = std::ranges;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#pragma GCC optimize("O2,unroll-loops")
|
||||||
|
#pragma GCC target("avx2,bmi,bmi2,lzcnt,popcnt")
|
||||||
|
|
||||||
|
using namespace std;
|
||||||
|
|
||||||
|
using i32 = int32_t;
|
||||||
|
using u32 = uint32_t;
|
||||||
|
using i64 = int64_t;
|
||||||
|
using u64 = uint64_t;
|
||||||
|
using f64 = double;
|
||||||
|
using f128 = long double;
|
||||||
|
|
||||||
|
#if __cplusplus >= 202002L
|
||||||
|
template <typename T>
|
||||||
|
constexpr T MIN = std::numeric_limits<T>::min();
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
constexpr T MAX = std::numeric_limits<T>::max();
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LOCAL
|
||||||
|
#define db(...) std::print(__VA_ARGS__)
|
||||||
|
#define dbln(...) std::println(__VA_ARGS__)
|
||||||
|
#else
|
||||||
|
#define db(...)
|
||||||
|
#define dbln(...)
|
||||||
|
#endif
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
void solve() {
|
||||||
|
cout << "hi\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() { // {{{
|
||||||
|
std::cin.exceptions(std::cin.failbit);
|
||||||
|
#ifdef LOCAL
|
||||||
|
std::cerr.rdbuf(std::cout.rdbuf());
|
||||||
|
std::cout.setf(std::ios::unitbuf);
|
||||||
|
std::cerr.setf(std::ios::unitbuf);
|
||||||
|
#else
|
||||||
|
std::cin.tie(nullptr)->sync_with_stdio(false);
|
||||||
|
#endif
|
||||||
|
solve();
|
||||||
|
return 0;
|
||||||
|
} // }}}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue