fix(scrapers): fix Kattis and USACO login and submit (#330)

## Problem

Kattis and USACO login and submit were broken in multiple ways
discovered
during manual end-to-end testing. Neither platform could successfully
authenticate or submit through the plugin.

## Solution

**Kattis:** switch login from `POST /login/email` (requires CSRF fetch)
to
`POST /login` with `script=true` (200 = success, 403 = bad credentials);
remove `_check_kattis_login` entirely since Kattis blocks all GET
requests
from httpx; add submit retry on `"Request validation failed"` to handle
expired sessions; fix language ID `"C++17"` → `"C++"`.

**USACO:** fix login field `user` → `uname`; fix success check to
`code==1`;
fix submit endpoint to `submit-solution.php`, file field to
`sourcefile`,
hidden field extraction off-by-one (`group(2)` → `group(1)`); fix
`_pick_lang_option` loop order (keywords outer, options inner) so
specific
keywords like `"c++17"` match before broad ones like `"c++"`.

**`submit.lua`:** absolutize source file path via `fnamemodify(...,
':p')`
before passing to the scraper — Python is spawned with `cwd=plugin_path`
so relative paths silently fail with `FileNotFoundError`.

**Both platforms:** remove cookie fast path from `login` subcommand so
credentials are always validated, preventing stale cookies from masking
wrong credentials.
This commit is contained in:
Barrett Ruth 2026-03-06 12:38:32 -05:00 committed by GitHub
parent 543480a4fe
commit b6d3df03e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 42 additions and 53 deletions

View file

@ -54,6 +54,7 @@ function M.submit(opts)
logger.log('Source file not found', { level = vim.log.levels.ERROR })
return
end
source_file = vim.fn.fnamemodify(source_file, ':p')
prompt_credentials(platform, function(creds)
vim.cmd.update()

View file

@ -221,25 +221,17 @@ async def _save_kattis_cookies(client: httpx.AsyncClient) -> None:
_COOKIE_PATH.write_text(json.dumps(cookies))
async def _check_kattis_login(client: httpx.AsyncClient) -> bool:
try:
r = await client.get(BASE_URL + "/", headers=HEADERS, timeout=HTTP_TIMEOUT)
text = r.text.lower()
return "sign out" in text or "logout" in text or "my profile" in text
except Exception:
return False
async def _do_kattis_login(
client: httpx.AsyncClient, username: str, password: str
) -> bool:
client.cookies.clear()
r = await client.post(
f"{BASE_URL}/login/email",
f"{BASE_URL}/login",
data={"user": username, "password": password, "script": "true"},
headers=HEADERS,
timeout=HTTP_TIMEOUT,
)
return r.status_code == 200 and "login failed" not in r.text.lower()
return r.status_code == 200
class KattisScraper(BaseScraper):
@ -330,9 +322,7 @@ class KattisScraper(BaseScraper):
async with httpx.AsyncClient(follow_redirects=True) as client:
await _load_kattis_cookies(client)
print(json.dumps({"status": "checking_login"}), flush=True)
logged_in = bool(client.cookies) and await _check_kattis_login(client)
if not logged_in:
if not client.cookies:
print(json.dumps({"status": "logging_in"}), flush=True)
ok = await _do_kattis_login(client, username, password)
if not ok:
@ -351,18 +341,35 @@ class KattisScraper(BaseScraper):
}
if contest_id != problem_id:
data["contest"] = contest_id
try:
r = await client.post(
async def _do_submit() -> httpx.Response:
return await client.post(
f"{BASE_URL}/submit",
data=data,
files={"sub_file[]": (f"solution.{ext}", source, "text/plain")},
headers=HEADERS,
timeout=HTTP_TIMEOUT,
)
try:
r = await _do_submit()
r.raise_for_status()
except Exception as e:
return self._submit_error(f"Submit request failed: {e}")
if r.text == "Request validation failed":
_COOKIE_PATH.unlink(missing_ok=True)
print(json.dumps({"status": "logging_in"}), flush=True)
ok = await _do_kattis_login(client, username, password)
if not ok:
return self._submit_error("Login failed (bad credentials?)")
await _save_kattis_cookies(client)
try:
r = await _do_submit()
r.raise_for_status()
except Exception as e:
return self._submit_error(f"Submit request failed: {e}")
sid_m = re.search(r"Submission ID:\s*(\d+)", r.text, re.IGNORECASE)
sid = sid_m.group(1) if sid_m else ""
return SubmitResult(
@ -376,21 +383,10 @@ class KattisScraper(BaseScraper):
return self._login_error("Missing username or password")
async with httpx.AsyncClient(follow_redirects=True) as client:
await _load_kattis_cookies(client)
if client.cookies:
print(json.dumps({"status": "checking_login"}), flush=True)
if await _check_kattis_login(client):
return LoginResult(
success=True,
error="",
credentials={"username": username, "password": password},
)
print(json.dumps({"status": "logging_in"}), flush=True)
ok = await _do_kattis_login(client, username, password)
if not ok:
return self._login_error("Login failed (bad credentials?)")
await _save_kattis_cookies(client)
return LoginResult(
success=True,

View file

@ -16,7 +16,7 @@ LANGUAGE_IDS = {
"python": "python",
},
"kattis": {
"cpp": "C++17",
"cpp": "C++",
"python": "Python 3",
},
"codechef": {

View file

@ -29,7 +29,7 @@ CONNECTIONS = 4
_COOKIE_PATH = Path.home() / ".cache" / "cp-nvim" / "usaco-cookies.json"
_LOGIN_PATH = "/current/tpcm/login-session.php"
_SUBMIT_PATH = "/current/tpcm/submitproblem.php"
_SUBMIT_PATH = "/current/tpcm/submit-solution.php"
_LANG_KEYWORDS: dict[str, list[str]] = {
"cpp": ["c++17", "c++ 17", "g++17", "c++", "cpp"],
@ -141,13 +141,16 @@ def _parse_problem_page(html: str) -> dict[str, Any]:
def _pick_lang_option(select_body: str, language_id: str) -> str | None:
keywords = _LANG_KEYWORDS.get(language_id.lower(), [language_id.lower()])
for m in re.finditer(
r'<option\b[^>]*\bvalue=["\']([^"\']*)["\'][^>]*>([^<]+)',
select_body,
re.IGNORECASE,
):
val, text = m.group(1), m.group(2).strip().lower()
for kw in keywords:
options = [
(m.group(1), m.group(2).strip().lower())
for m in re.finditer(
r'<option\b[^>]*\bvalue=["\']([^"\']*)["\'][^>]*>([^<]+)',
select_body,
re.IGNORECASE,
)
]
for kw in keywords:
for val, text in options:
if kw in text:
return val
return None
@ -165,7 +168,7 @@ def _parse_submit_form(
re.DOTALL | re.IGNORECASE,
):
action, body = form_m.group(1), form_m.group(2)
if "sub_file" not in body.lower():
if "sourcefile" not in body.lower():
continue
if action.startswith("http"):
form_action = action
@ -182,7 +185,7 @@ def _parse_submit_form(
name_m = re.search(r'\bname=["\']([^"\']+)["\']', tag, re.IGNORECASE)
val_m = re.search(r'\bvalue=["\']([^"\']*)["\']', tag, re.IGNORECASE)
if name_m and val_m:
hidden[name_m.group(1)] = val_m.group(2)
hidden[name_m.group(1)] = val_m.group(1)
for sel_m in re.finditer(
r'<select\b[^>]*\bname=["\']([^"\']+)["\'][^>]*>(.*?)</select>',
body,
@ -231,16 +234,15 @@ async def _do_usaco_login(
) -> bool:
r = await client.post(
f"{_AUTH_BASE}{_LOGIN_PATH}",
data={"user": username, "password": password},
data={"uname": username, "password": password},
headers=HEADERS,
timeout=HTTP_TIMEOUT,
)
r.raise_for_status()
try:
data = r.json()
return bool(data.get("success") or data.get("status") == "success")
return r.json().get("code") == 1
except Exception:
return r.status_code == 200 and "error" not in r.text.lower()
return False
class USACOScraper(BaseScraper):
@ -453,7 +455,7 @@ class USACOScraper(BaseScraper):
r = await client.post(
form_url,
data=data,
files={"sub_file[]": (f"solution.{ext}", source, "text/plain")},
files={"sourcefile": (f"solution.{ext}", source, "text/plain")},
headers=HEADERS,
timeout=HTTP_TIMEOUT,
)
@ -477,16 +479,6 @@ class USACOScraper(BaseScraper):
return self._login_error("Missing username or password")
async with httpx.AsyncClient(follow_redirects=True) as client:
await _load_usaco_cookies(client)
if client.cookies:
print(json.dumps({"status": "checking_login"}), flush=True)
if await _check_usaco_login(client, username):
return LoginResult(
success=True,
error="",
credentials={"username": username, "password": password},
)
print(json.dumps({"status": "logging_in"}), flush=True)
try:
ok = await _do_usaco_login(client, username, password)