Compare commits

..

No commits in common. "e13eed0c4df193b26b9603982d70ed0c59ca4690" and "8c8e49d75c99bb950141c217a7a7103f6e45f51f" have entirely different histories.

9 changed files with 987 additions and 490 deletions

View file

@ -26,20 +26,6 @@
ps.requests ps.requests
]); ]);
mkDevPythonEnv =
pkgs:
pkgs.python312.withPackages (ps: [
ps.backoff
ps.beautifulsoup4
ps.curl-cffi
ps.httpx
ps.ndjson
ps.pydantic
ps.requests
ps.pytest
ps.pytest-mock
]);
mkSubmitEnv = mkSubmitEnv =
pkgs: pkgs:
pkgs.buildFHSEnv { pkgs.buildFHSEnv {
@ -58,24 +44,19 @@
glib glib
gtk3 gtk3
libdrm libdrm
libGL
libxkbcommon libxkbcommon
mesa mesa
libGL
nspr nspr
nss nss
pango pango
libx11 xorg.libX11
libxcomposite xorg.libXcomposite
libxdamage xorg.libXdamage
libxext xorg.libXext
libxfixes xorg.libXfixes
libxrandr xorg.libXrandr
libxcb xorg.libxcb
at-spi2-core
expat
libgbm
systemdLibs
zlib
]; ];
runScript = "${pkgs.uv}/bin/uv"; runScript = "${pkgs.uv}/bin/uv";
}; };
@ -119,19 +100,15 @@
submitEnv = mkSubmitEnv (pkgsFor system); submitEnv = mkSubmitEnv (pkgsFor system);
}); });
formatter = eachSystem (system: (pkgsFor system).nixfmt-tree);
devShells = eachSystem (system: { devShells = eachSystem (system: {
default = (pkgsFor system).mkShell { default = (pkgsFor system).mkShell {
packages = with (pkgsFor system); [ packages = with (pkgsFor system); [
uv uv
(mkDevPythonEnv (pkgsFor system)) python312
prettier prettier
ruff
stylua stylua
selene selene
lua-language-server lua-language-server
ty
]; ];
}; };
}); });

View file

@ -44,10 +44,6 @@ local function run_scraper(platform, subcommand, args, opts)
return { success = false, error = msg } return { success = false, error = msg }
end end
if subcommand == 'submit' then
utils.setup_nix_submit_env()
end
local plugin_path = utils.get_plugin_path() local plugin_path = utils.get_plugin_path()
local cmd local cmd
if subcommand == 'submit' then if subcommand == 'submit' then
@ -77,10 +73,6 @@ local function run_scraper(platform, subcommand, args, opts)
if opts and opts.ndjson then if opts and opts.ndjson then
local uv = vim.uv local uv = vim.uv
local stdin_pipe = nil
if opts.stdin then
stdin_pipe = uv.new_pipe(false)
end
local stdout = uv.new_pipe(false) local stdout = uv.new_pipe(false)
local stderr = uv.new_pipe(false) local stderr = uv.new_pipe(false)
local buf = '' local buf = ''
@ -88,7 +80,7 @@ local function run_scraper(platform, subcommand, args, opts)
local handle local handle
handle = uv.spawn(cmd[1], { handle = uv.spawn(cmd[1], {
args = vim.list_slice(cmd, 2), args = vim.list_slice(cmd, 2),
stdio = { stdin_pipe, stdout, stderr }, stdio = { nil, stdout, stderr },
env = spawn_env_list(env), env = spawn_env_list(env),
cwd = plugin_path, cwd = plugin_path,
}, function(code, signal) }, function(code, signal)
@ -102,9 +94,6 @@ local function run_scraper(platform, subcommand, args, opts)
if opts.on_exit then if opts.on_exit then
opts.on_exit({ success = (code == 0), code = code, signal = signal }) opts.on_exit({ success = (code == 0), code = code, signal = signal })
end end
if stdin_pipe and not stdin_pipe:is_closing() then
stdin_pipe:close()
end
if not stdout:is_closing() then if not stdout:is_closing() then
stdout:close() stdout:close()
end end
@ -117,21 +106,10 @@ local function run_scraper(platform, subcommand, args, opts)
end) end)
if not handle then if not handle then
if stdin_pipe and not stdin_pipe:is_closing() then
stdin_pipe:close()
end
logger.log('Failed to start scraper process', vim.log.levels.ERROR) logger.log('Failed to start scraper process', vim.log.levels.ERROR)
return { success = false, error = 'spawn failed' } return { success = false, error = 'spawn failed' }
end end
if stdin_pipe then
uv.write(stdin_pipe, opts.stdin, function()
uv.shutdown(stdin_pipe, function()
stdin_pipe:close()
end)
end)
end
uv.read_start(stdout, function(_, data) uv.read_start(stdout, function(_, data)
if data == nil then if data == nil then
if buf ~= '' and opts.on_event then if buf ~= '' and opts.on_event then
@ -282,39 +260,18 @@ function M.scrape_all_tests(platform, contest_id, callback)
}) })
end end
function M.submit( function M.submit(platform, contest_id, problem_id, language, source_code, credentials, callback)
platform, local creds_json = vim.json.encode(credentials)
contest_id,
problem_id,
language,
source_code,
credentials,
on_status,
callback
)
local done = false
run_scraper(platform, 'submit', { contest_id, problem_id, language }, { run_scraper(platform, 'submit', { contest_id, problem_id, language }, {
ndjson = true,
stdin = source_code, stdin = source_code,
env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) }, env_extra = { CP_CREDENTIALS = creds_json },
on_event = function(ev) on_exit = function(result)
if ev.status ~= nil then if type(callback) == 'function' then
if type(on_status) == 'function' then if result and result.success then
on_status(ev.status) callback(result.data or { success = true })
else
callback({ success = false, error = result and result.error or 'unknown' })
end end
elseif ev.success ~= nil then
done = true
if type(callback) == 'function' then
callback(ev)
end
end
end,
on_exit = function(proc)
if not done and type(callback) == 'function' then
callback({
success = false,
error = 'submit process exited (code=' .. tostring(proc.code) .. ')',
})
end end
end, end,
}) })

View file

@ -4,13 +4,6 @@ local cache = require('cp.cache')
local logger = require('cp.log') local logger = require('cp.log')
local state = require('cp.state') local state = require('cp.state')
local STATUS_MSGS = {
installing_browser = 'Installing browser (first time setup)...',
checking_login = 'Checking login...',
logging_in = 'Logging in...',
submitting = 'Submitting...',
}
local function prompt_credentials(platform, callback) local function prompt_credentials(platform, callback)
local saved = cache.get_credentials(platform) local saved = cache.get_credentials(platform)
if saved and saved.username and saved.password then if saved and saved.username and saved.password then
@ -55,8 +48,6 @@ function M.submit(opts)
local source_lines = vim.fn.readfile(source_file) local source_lines = vim.fn.readfile(source_file)
local source_code = table.concat(source_lines, '\n') local source_code = table.concat(source_lines, '\n')
vim.notify('[cp.nvim] Submitting...', vim.log.levels.INFO)
require('cp.scraper').submit( require('cp.scraper').submit(
platform, platform,
contest_id, contest_id,
@ -64,11 +55,6 @@ function M.submit(opts)
language, language,
source_code, source_code,
creds, creds,
function(status)
vim.schedule(function()
vim.notify('[cp.nvim] ' .. (STATUS_MSGS[status] or status), vim.log.levels.INFO)
end)
end,
function(result) function(result)
vim.schedule(function() vim.schedule(function()
if result and result.success then if result and result.success then

View file

@ -119,76 +119,10 @@ function M.get_python_submit_cmd(module, plugin_path)
if _nix_submit_cmd then if _nix_submit_cmd then
return { _nix_submit_cmd, 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module } return { _nix_submit_cmd, 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
end end
return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module } return M.get_python_cmd(module, plugin_path)
end end
local python_env_setup = false local python_env_setup = false
local _nix_submit_attempted = false
---@return boolean
local function discover_nix_submit_cmd()
local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim'
local cache_file = cache_dir .. '/nix-submit'
local f = io.open(cache_file, 'r')
if f then
local cached = f:read('*l')
f:close()
if cached and vim.fn.executable(cached) == 1 then
_nix_submit_cmd = cached
return true
end
end
local plugin_path = M.get_plugin_path()
vim.cmd.redraw()
vim.notify('Building submit environment...', vim.log.levels.INFO)
vim.cmd.redraw()
local result = vim
.system(
{ 'nix', 'build', plugin_path .. '#submitEnv', '--no-link', '--print-out-paths' },
{ text = true }
)
:wait()
if result.code ~= 0 then
logger.log('nix build #submitEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN)
return false
end
local store_path = result.stdout:gsub('%s+$', '')
local submit_cmd = store_path .. '/bin/cp-nvim-submit'
if vim.fn.executable(submit_cmd) ~= 1 then
logger.log('nix submit cmd not executable at ' .. submit_cmd, vim.log.levels.WARN)
return false
end
vim.fn.mkdir(cache_dir, 'p')
f = io.open(cache_file, 'w')
if f then
f:write(submit_cmd)
f:close()
end
_nix_submit_cmd = submit_cmd
return true
end
---@return boolean
function M.setup_nix_submit_env()
if _nix_submit_cmd then
return true
end
if _nix_submit_attempted then
return false
end
_nix_submit_attempted = true
if vim.fn.executable('nix') == 1 then
return discover_nix_submit_cmd()
end
return false
end
---@return boolean ---@return boolean
local function discover_nix_python() local function discover_nix_python()

View file

@ -7,7 +7,7 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"backoff>=2.2.1", "backoff>=2.2.1",
"beautifulsoup4>=4.13.5", "beautifulsoup4>=4.13.5",
"scrapling[fetchers]>=0.4", "camoufox[geoip]",
"curl-cffi>=0.13.0", "curl-cffi>=0.13.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"ndjson>=0.3.1", "ndjson>=0.3.1",

View file

@ -4,9 +4,7 @@ import asyncio
import json import json
import os import os
import re import re
import subprocess
import sys import sys
import tempfile
import time import time
from typing import Any from typing import Any
@ -235,183 +233,6 @@ def _extract_samples(html: str) -> list[TestCase]:
return cases return cases
_TURNSTILE_JS = "() => { const el = document.querySelector('[name=\"cf-turnstile-response\"]'); return el && el.value.length > 0; }"
def _solve_turnstile(page) -> None:
for _ in range(6):
has_token = page.evaluate(_TURNSTILE_JS)
if has_token:
return
try:
box = page.locator(
'iframe[src*="challenges.cloudflare.com"]'
).first.bounding_box()
if box:
page.mouse.click(
box["x"] + box["width"] * 0.15,
box["y"] + box["height"] * 0.5,
)
except Exception:
pass
try:
page.wait_for_function(_TURNSTILE_JS, timeout=5000)
return
except Exception:
pass
raise RuntimeError("Turnstile not solved after multiple attempts")
def _ensure_browser() -> None:
try:
from patchright._impl._driver import compute_driver_executable # type: ignore[import-untyped,unresolved-import]
node, cli = compute_driver_executable()
browser_info = subprocess.run(
[node, cli, "install", "--dry-run", "chromium"],
capture_output=True,
text=True,
)
for line in browser_info.stdout.splitlines():
if "Install location:" in line:
install_dir = line.split(":", 1)[1].strip()
if not os.path.isdir(install_dir):
print(json.dumps({"status": "installing_browser"}), flush=True)
subprocess.run(
[node, cli, "install", "chromium"],
check=True,
)
break
except Exception:
pass
def _submit_headless(
contest_id: str,
problem_id: str,
source_code: str,
language_id: str,
credentials: dict[str, str],
) -> "SubmitResult":
from pathlib import Path
try:
from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import]
except ImportError:
return SubmitResult(
success=False,
error="scrapling is required for AtCoder submit. Install it: uv add 'scrapling[fetchers]>=0.4'",
)
_ensure_browser()
cookie_cache = Path.home() / ".cache" / "cp-nvim" / "atcoder-cookies.json"
cookie_cache.parent.mkdir(parents=True, exist_ok=True)
saved_cookies: list[dict[str, Any]] = []
if cookie_cache.exists():
try:
saved_cookies = json.loads(cookie_cache.read_text())
except Exception:
pass
logged_in = False
login_error: str | None = None
submit_error: str | None = None
def check_login(page):
nonlocal logged_in
logged_in = page.evaluate(
"() => Array.from(document.querySelectorAll('a')).some(a => a.textContent.trim() === 'Sign Out')"
)
def login_action(page):
nonlocal login_error
try:
_solve_turnstile(page)
page.fill('input[name="username"]', credentials.get("username", ""))
page.fill('input[name="password"]', credentials.get("password", ""))
page.click("#submit")
page.wait_for_url(lambda url: "/login" not in url, timeout=60000)
except Exception as e:
login_error = str(e)
def submit_action(page):
nonlocal submit_error
try:
_solve_turnstile(page)
page.select_option(
'select[name="data.TaskScreenName"]',
f"{contest_id}_{problem_id}",
)
page.locator(
f'select[name="data.LanguageId"] option[value="{language_id}"]'
).wait_for(state="attached", timeout=15000)
page.select_option('select[name="data.LanguageId"]', language_id)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".cpp", delete=False, prefix="atcoder_"
) as tf:
tf.write(source_code)
tmp_path = tf.name
try:
page.set_input_files("#input-open-file", tmp_path)
page.wait_for_timeout(500)
finally:
os.unlink(tmp_path)
page.locator('button[type="submit"]').click()
page.wait_for_url(lambda url: "/submissions/me" in url, timeout=60000)
except Exception as e:
submit_error = str(e)
try:
with StealthySession(
headless=True,
timeout=60000,
google_search=False,
cookies=saved_cookies,
) as session:
print(json.dumps({"status": "checking_login"}), flush=True)
session.fetch(
f"{BASE_URL}/home",
page_action=check_login,
network_idle=True,
)
if not logged_in:
print(json.dumps({"status": "logging_in"}), flush=True)
session.fetch(
f"{BASE_URL}/login",
page_action=login_action,
solve_cloudflare=True,
)
if login_error:
return SubmitResult(
success=False, error=f"Login failed: {login_error}"
)
print(json.dumps({"status": "submitting"}), flush=True)
session.fetch(
f"{BASE_URL}/contests/{contest_id}/submit",
page_action=submit_action,
solve_cloudflare=True,
)
try:
browser_cookies = session.context.cookies()
if any(c["name"] == "REVEL_SESSION" for c in browser_cookies):
cookie_cache.write_text(json.dumps(browser_cookies))
except Exception:
pass
if submit_error:
return SubmitResult(success=False, error=submit_error)
return SubmitResult(
success=True, error="", submission_id="", verdict="submitted"
)
except Exception as e:
return SubmitResult(success=False, error=str(e))
def _scrape_tasks_sync(contest_id: str) -> list[dict[str, str]]: def _scrape_tasks_sync(contest_id: str) -> list[dict[str, str]]:
html = _fetch(f"{BASE_URL}/contests/{contest_id}/tasks") html = _fetch(f"{BASE_URL}/contests/{contest_id}/tasks")
return _parse_tasks_list(html) return _parse_tasks_list(html)
@ -558,14 +379,83 @@ class AtcoderScraper(BaseScraper):
language_id: str, language_id: str,
credentials: dict[str, str], credentials: dict[str, str],
) -> SubmitResult: ) -> SubmitResult:
return await asyncio.to_thread( def _submit_sync() -> SubmitResult:
_submit_headless, try:
contest_id, from camoufox.sync_api import Camoufox
problem_id, except ImportError:
source_code, return SubmitResult(
language_id, success=False,
credentials, error="camoufox is required for AtCoder login. Install it: uv add 'camoufox[geoip]'",
) )
from curl_cffi import requests as curl_requests
try:
with Camoufox(headless=True) as browser:
page = browser.new_page()
page.goto(f"{BASE_URL}/login", wait_until="domcontentloaded")
page.wait_for_load_state("networkidle")
page.fill('input[name="username"]', credentials.get("username", ""))
page.fill('input[name="password"]', credentials.get("password", ""))
page.click('#submit')
page.wait_for_url(lambda url: "/login" not in url, timeout=30000)
cookies = page.context.cookies()
session = curl_requests.Session(impersonate="chrome")
for cookie in cookies:
session.cookies.set(
cookie["name"],
cookie["value"],
domain=cookie.get("domain", ""),
)
submit_page = session.get(
f"{BASE_URL}/contests/{contest_id}/submit",
timeout=TIMEOUT_SECONDS,
)
submit_page.raise_for_status()
soup = BeautifulSoup(submit_page.text, "html.parser")
csrf_input = soup.find("input", {"name": "csrf_token"})
if not csrf_input or not hasattr(csrf_input, "get"):
return SubmitResult(
success=False, error="Could not find CSRF token on submit page"
)
csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr]
task_screen_name = f"{contest_id}_{problem_id}"
submit_resp = session.post(
f"{BASE_URL}/contests/{contest_id}/submit",
data={
"data.TaskScreenName": task_screen_name,
"data.LanguageId": language_id,
"sourceCode": source_code,
"csrf_token": csrf_token,
},
timeout=TIMEOUT_SECONDS,
allow_redirects=False,
)
if submit_resp.status_code in (301, 302):
location = submit_resp.headers.get("Location", "")
if "/submissions/me" in location:
return SubmitResult(
success=True,
error="",
submission_id="",
verdict="submitted",
)
return SubmitResult(
success=False,
error=f"Submit may have failed: redirected to {location}",
)
submit_resp.raise_for_status()
return SubmitResult(
success=False,
error="Unexpected response from submit (expected redirect)",
)
except Exception as e:
return SubmitResult(success=False, error=str(e))
return await asyncio.to_thread(_submit_sync)
async def main_async() -> int: async def main_async() -> int:

View file

@ -1,7 +1,7 @@
LANGUAGE_IDS = { LANGUAGE_IDS = {
"atcoder": { "atcoder": {
"cpp": "6017", "cpp": "5028",
"python": "6082", "python": "5078",
}, },
"codeforces": { "codeforces": {
"cpp": "89", "cpp": "89",

View file

@ -4,10 +4,8 @@ set -eu
nix develop --command stylua --check . nix develop --command stylua --check .
git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
nix develop --command prettier --check . nix develop --command prettier --check .
nix fmt
git diff --exit-code -- '*.nix'
nix develop --command lua-language-server --check . --checklevel=Warning nix develop --command lua-language-server --check . --checklevel=Warning
nix develop --command ruff format --check . nix develop --command uvx ruff format --check .
nix develop --command ruff check . nix develop --command uvx ruff check .
nix develop --command ty check . nix develop --command uvx ty check .
nix develop --command python -m pytest tests/ -v nix develop --command uv run pytest tests/ -v

1011
uv.lock generated

File diff suppressed because it is too large Load diff