feat(cses): implement submit via REST API
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. Also update `scraper.lua` to pass the full ndjson event to `on_status` (instead of just the status string) and handle `credentials` events for platforms that return updated credentials.
This commit is contained in:
parent
c194f12eee
commit
972044fd0f
3 changed files with 165 additions and 9 deletions
|
|
@ -298,9 +298,12 @@ function M.submit(
|
|||
stdin = source_code,
|
||||
env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) },
|
||||
on_event = function(ev)
|
||||
if ev.credentials ~= nil then
|
||||
require('cp.cache').set_credentials(platform, ev.credentials)
|
||||
end
|
||||
if ev.status ~= nil then
|
||||
if type(on_status) == 'function' then
|
||||
on_status(ev.status)
|
||||
on_status(ev)
|
||||
end
|
||||
elseif ev.success ~= nil then
|
||||
done = true
|
||||
|
|
|
|||
|
|
@ -64,9 +64,9 @@ function M.submit(opts)
|
|||
language,
|
||||
source_code,
|
||||
creds,
|
||||
function(status)
|
||||
function(ev)
|
||||
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,
|
||||
function(result)
|
||||
|
|
|
|||
165
scrapers/cses.py
165
scrapers/cses.py
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
|
@ -18,6 +19,8 @@ from .models import (
|
|||
)
|
||||
|
||||
BASE_URL = "https://cses.fi"
|
||||
API_URL = "https://cses.fi/api"
|
||||
SUBMIT_SCOPE = "courses/problemset"
|
||||
INDEX_PATH = "/problemset"
|
||||
TASK_PATH = "/problemset/task/{id}"
|
||||
HEADERS = {
|
||||
|
|
@ -26,6 +29,16 @@ HEADERS = {
|
|||
TIMEOUT_S = 15.0
|
||||
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:
|
||||
return category_name.lower().replace(" ", "_").replace("&", "and")
|
||||
|
|
@ -270,6 +283,73 @@ class CSESScraper(BaseScraper):
|
|||
payload = await coro
|
||||
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(
|
||||
self,
|
||||
contest_id: str,
|
||||
|
|
@ -278,12 +358,85 @@ class CSESScraper(BaseScraper):
|
|||
language_id: str,
|
||||
credentials: dict[str, str],
|
||||
) -> SubmitResult:
|
||||
return SubmitResult(
|
||||
success=False,
|
||||
error="CSES submit not yet implemented",
|
||||
submission_id="",
|
||||
verdict="",
|
||||
)
|
||||
username = credentials.get("username", "")
|
||||
password = credentials.get("password", "")
|
||||
if not username or not password:
|
||||
return self._submit_error(
|
||||
"Missing credentials. Use :CP login cses"
|
||||
)
|
||||
|
||||
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__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue