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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,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)
|
||||||
|
|
|
||||||
165
scrapers/cses.py
165
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,73 @@ 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 +358,85 @@ 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(
|
||||||
verdict="",
|
"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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue