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:
Barrett Ruth 2026-03-05 01:03:53 -05:00
parent c194f12eee
commit 972044fd0f
3 changed files with 165 additions and 9 deletions

View file

@ -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

View file

@ -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)

View file

@ -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__":