Compare commits

..

No commits in common. "4622c8c373611d5dc2b5b5417b010e6f18f823db" and "c7f2af16d4b9eb6ec29ccf0302e3a52729b866ef" have entirely different histories.

26 changed files with 159 additions and 337 deletions

View file

@ -266,7 +266,6 @@ end
---@param memory_mb number ---@param memory_mb number
---@param interactive boolean ---@param interactive boolean
---@param multi_test boolean ---@param multi_test boolean
---@param precision number?
function M.set_test_cases( function M.set_test_cases(
platform, platform,
contest_id, contest_id,
@ -468,7 +467,6 @@ function M.clear_credentials(platform)
M.save() M.save()
end end
---@return nil
function M.clear_all() function M.clear_all()
cache_data = {} cache_data = {}
M.save() M.save()
@ -490,7 +488,6 @@ function M.get_data_pretty()
return vim.inspect(cache_data) return vim.inspect(cache_data)
end end
---@return table
function M.get_raw_cache() function M.get_raw_cache()
return cache_data return cache_data
end end

View file

@ -257,7 +257,10 @@ local function parse_command(args)
if vim.tbl_contains(platforms, first) then if vim.tbl_contains(platforms, first) then
if #args == 1 then if #args == 1 then
return { type = 'action', action = 'pick', requires_context = false, platform = first } return {
type = 'error',
message = 'Too few arguments - specify a contest.',
}
elseif #args == 2 then elseif #args == 2 then
if args[2] == 'login' or args[2] == 'logout' or args[2] == 'signup' then if args[2] == 'login' or args[2] == 'logout' or args[2] == 'signup' then
return { type = 'action', action = args[2], requires_context = false, platform = first } return { type = 'action', action = args[2], requires_context = false, platform = first }
@ -359,7 +362,6 @@ local function check_platform_enabled(platform)
end end
--- Core logic for handling `:CP ...` commands --- Core logic for handling `:CP ...` commands
---@param opts { fargs: string[] }
---@return nil ---@return nil
function M.handle_command(opts) function M.handle_command(opts)
local cmd = parse_command(opts.fargs) local cmd = parse_command(opts.fargs)
@ -398,7 +400,7 @@ function M.handle_command(opts)
setup.navigate_problem(-1, cmd.language) setup.navigate_problem(-1, cmd.language)
elseif cmd.action == 'pick' then elseif cmd.action == 'pick' then
local picker = require('cp.commands.picker') local picker = require('cp.commands.picker')
picker.handle_pick_action(cmd.language, cmd.platform) picker.handle_pick_action(cmd.language)
elseif cmd.action == 'edit' then elseif cmd.action == 'edit' then
local edit = require('cp.ui.edit') local edit = require('cp.ui.edit')
edit.toggle_edit(cmd.test_index) edit.toggle_edit(cmd.test_index)

View file

@ -5,9 +5,8 @@ local logger = require('cp.log')
--- Dispatch `:CP pick` to appropriate picker --- Dispatch `:CP pick` to appropriate picker
---@param language? string ---@param language? string
---@param platform? string
---@return nil ---@return nil
function M.handle_pick_action(language, platform) function M.handle_pick_action(language)
local config = config_module.get_config() local config = config_module.get_config()
if not (config.ui and config.ui.picker) then if not (config.ui and config.ui.picker) then
@ -55,7 +54,7 @@ function M.handle_pick_action(language, platform)
picker = fzf_picker picker = fzf_picker
end end
picker.pick(language, platform) picker.pick(language)
end end
return M return M

View file

@ -531,12 +531,10 @@ end
local current_config = nil local current_config = nil
---@param config cp.Config
function M.set_current_config(config) function M.set_current_config(config)
current_config = config current_config = config
end end
---@return cp.Config
function M.get_config() function M.get_config()
return current_config or M.defaults return current_config or M.defaults
end end

View file

@ -208,12 +208,6 @@ M.LANGUAGE_VERSIONS = {
python = { python3 = 'PYTH 3', pypy3 = 'PYPY3' }, python = { python3 = 'PYTH 3', pypy3 = 'PYPY3' },
java = { java = 'JAVA' }, java = { java = 'JAVA' },
rust = { rust = 'rust' }, rust = { rust = 'rust' },
c = { c = 'C' },
go = { go = 'GO' },
kotlin = { kotlin = 'KTLN' },
javascript = { nodejs = 'NODEJS' },
typescript = { typescript = 'TS' },
csharp = { csharp = 'C#' },
}, },
} }

View file

@ -11,7 +11,6 @@ local STATUS_MESSAGES = {
installing_browser = 'Installing browser...', installing_browser = 'Installing browser...',
} }
---@param platform string?
function M.login(platform) function M.login(platform)
platform = platform or state.get_platform() platform = platform or state.get_platform()
if not platform then if not platform then
@ -69,7 +68,6 @@ function M.login(platform)
end) end)
end end
---@param platform string?
function M.logout(platform) function M.logout(platform)
platform = platform or state.get_platform() platform = platform or state.get_platform()
if not platform then if not platform then

View file

@ -66,7 +66,6 @@ local function check()
end end
end end
---@return nil
function M.check() function M.check()
local version = require('cp.version') local version = require('cp.version')
vim.health.start('cp.nvim health check ~') vim.health.start('cp.nvim health check ~')

View file

@ -38,13 +38,11 @@ function M.handle_command(opts)
commands.handle_command(opts) commands.handle_command(opts)
end end
---@return boolean
function M.is_initialized() function M.is_initialized()
return initialized return initialized
end end
---@deprecated Use `vim.g.cp` instead ---@deprecated Use `vim.g.cp` instead
---@param user_config table?
function M.setup(user_config) function M.setup(user_config)
vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.7.7', 'cp.nvim', false) vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.7.7', 'cp.nvim', false)

View file

@ -58,18 +58,11 @@ local function contest_picker(platform, refresh, language)
}) })
end end
---@param language? string function M.pick(language)
---@param platform? string
function M.pick(language, platform)
if platform then
contest_picker(platform, false, language)
return
end
local fzf = require('fzf-lua') local fzf = require('fzf-lua')
local platforms = picker_utils.get_platforms() local platforms = picker_utils.get_platforms()
local entries = vim.tbl_map(function(p) local entries = vim.tbl_map(function(platform)
return p.display_name return platform.display_name
end, platforms) end, platforms)
return fzf.fzf_exec(entries, { return fzf.fzf_exec(entries, {
@ -81,16 +74,16 @@ function M.pick(language, platform)
end end
local selected_name = selected[1] local selected_name = selected[1]
local found = nil local platform = nil
for _, p in ipairs(platforms) do for _, p in ipairs(platforms) do
if p.display_name == selected_name then if p.display_name == selected_name then
found = p platform = p
break break
end end
end end
if found then if platform then
contest_picker(found.id, false, language) contest_picker(platform.id, false, language)
end end
end, end,
}, },

View file

@ -64,14 +64,7 @@ local function contest_picker(opts, platform, refresh, language)
:find() :find()
end end
---@param language? string function M.pick(language)
---@param platform? string
function M.pick(language, platform)
if platform then
contest_picker({}, platform, false, language)
return
end
local opts = {} local opts = {}
local platforms = picker_utils.get_platforms() local platforms = picker_utils.get_platforms()

View file

@ -134,9 +134,6 @@ local function race_try_setup(platform, contest_id, language, attempt, token)
) )
end end
---@param platform string
---@param contest_id string
---@param language? string
function M.start(platform, contest_id, language) function M.start(platform, contest_id, language)
if not platform or not vim.tbl_contains(constants.PLATFORMS, platform) then if not platform or not vim.tbl_contains(constants.PLATFORMS, platform) then
logger.log('Invalid platform', { level = vim.log.levels.ERROR }) logger.log('Invalid platform', { level = vim.log.levels.ERROR })
@ -255,7 +252,6 @@ function M.start(platform, contest_id, language)
) )
end end
---@return nil
function M.stop() function M.stop()
local timer = race_state.timer local timer = race_state.timer
if not timer then if not timer then
@ -280,7 +276,6 @@ function M.stop()
) )
end end
---@return { active: boolean, platform?: string, contest_id?: string, remaining_seconds?: integer }
function M.status() function M.status()
if not race_state.timer or not race_state.start_time then if not race_state.timer or not race_state.start_time then
return { active = false } return { active = false }

View file

@ -33,9 +33,6 @@ local function substitute_template(cmd_template, substitutions)
return out return out
end end
---@param cmd_template string[]
---@param substitutions SubstitutableCommand
---@return string[]
function M.build_command(cmd_template, substitutions) function M.build_command(cmd_template, substitutions)
return substitute_template(cmd_template, substitutions) return substitute_template(cmd_template, substitutions)
end end

View file

@ -376,7 +376,6 @@ function M.get_highlight_groups()
} }
end end
---@return nil
function M.setup_highlights() function M.setup_highlights()
local groups = M.get_highlight_groups() local groups = M.get_highlight_groups()
for name, opts in pairs(groups) do for name, opts in pairs(groups) do

View file

@ -216,10 +216,6 @@ local function run_scraper(platform, subcommand, args, opts)
end end
end end
---@param platform string
---@param contest_id string
---@param callback fun(data: table)?
---@param on_error fun()?
function M.scrape_contest_metadata(platform, contest_id, callback, on_error) function M.scrape_contest_metadata(platform, contest_id, callback, on_error)
run_scraper(platform, 'metadata', { contest_id }, { run_scraper(platform, 'metadata', { contest_id }, {
on_exit = function(result) on_exit = function(result)
@ -257,8 +253,6 @@ function M.scrape_contest_metadata(platform, contest_id, callback, on_error)
}) })
end end
---@param platform string
---@return { contests: ContestSummary[], supports_countdown: boolean }?
function M.scrape_contest_list(platform) function M.scrape_contest_list(platform)
local result = run_scraper(platform, 'contests', {}, { sync = true }) local result = run_scraper(platform, 'contests', {}, { sync = true })
if not result or not result.success or not (result.data and result.data.contests) then if not result or not result.success or not (result.data and result.data.contests) then
@ -336,10 +330,6 @@ function M.scrape_all_tests(platform, contest_id, callback, on_done)
}) })
end end
---@param platform string
---@param credentials table
---@param on_status fun(ev: table)?
---@param callback fun(result: table)?
function M.login(platform, credentials, on_status, callback) function M.login(platform, credentials, on_status, callback)
local done = false local done = false
run_scraper(platform, 'login', {}, { run_scraper(platform, 'login', {}, {
@ -371,14 +361,6 @@ function M.login(platform, credentials, on_status, callback)
}) })
end end
---@param platform string
---@param contest_id string
---@param problem_id string
---@param language string
---@param source_file string
---@param credentials table
---@param on_status fun(ev: table)?
---@param callback fun(result: table)?
function M.submit( function M.submit(
platform, platform,
contest_id, contest_id,

View file

@ -58,8 +58,6 @@ local function build_run_cmd(file)
return './' .. file return './' .. file
end end
---@param generator_cmd? string
---@param brute_cmd? string
function M.toggle(generator_cmd, brute_cmd) function M.toggle(generator_cmd, brute_cmd)
if state.get_active_panel() == 'stress' then if state.get_active_panel() == 'stress' then
if state.stress_buf and vim.api.nvim_buf_is_valid(state.stress_buf) then if state.stress_buf and vim.api.nvim_buf_is_valid(state.stress_buf) then
@ -241,7 +239,6 @@ function M.toggle(generator_cmd, brute_cmd)
end) end)
end end
---@return nil
function M.cancel() function M.cancel()
if state.stress_buf and vim.api.nvim_buf_is_valid(state.stress_buf) then if state.stress_buf and vim.api.nvim_buf_is_valid(state.stress_buf) then
local job = vim.b[state.stress_buf].terminal_job_id local job = vim.b[state.stress_buf].terminal_job_id

View file

@ -38,7 +38,6 @@ local function prompt_credentials(platform, callback)
end) end)
end end
---@param opts { language?: string }?
function M.submit(opts) function M.submit(opts)
local platform = state.get_platform() local platform = state.get_platform()
local contest_id = state.get_contest_id() local contest_id = state.get_contest_id()
@ -65,13 +64,13 @@ function M.submit(opts)
local eff = plat_effective and plat_effective[language] local eff = plat_effective and plat_effective[language]
if eff then if eff then
if eff.submit_id then if eff.submit_id then
submit_language = eff.submit_id or submit_language submit_language = eff.submit_id
else else
local ver = eff.version or constants.DEFAULT_VERSIONS[language] local ver = eff.version or constants.DEFAULT_VERSIONS[language]
if ver then if ver then
local versions = (constants.LANGUAGE_VERSIONS[platform] or {})[language] local versions = (constants.LANGUAGE_VERSIONS[platform] or {})[language]
if versions and versions[ver] then if versions and versions[ver] then
submit_language = versions[ver] or submit_language submit_language = versions[ver]
end end
end end
end end

View file

@ -331,7 +331,6 @@ setup_keybindings = function(buf)
}) })
end end
---@param test_index? integer
function M.toggle_edit(test_index) function M.toggle_edit(test_index)
if edit_state then if edit_state then
save_all_tests() save_all_tests()

View file

@ -1,9 +1,3 @@
---@class DiffLayout
---@field buffers integer[]
---@field windows integer[]
---@field mode string
---@field cleanup fun()
local M = {} local M = {}
local helpers = require('cp.helpers') local helpers = require('cp.helpers')
@ -177,11 +171,6 @@ local function create_single_layout(parent_win, content)
} }
end end
---@param mode string
---@param parent_win integer
---@param expected_content string
---@param actual_content string
---@return DiffLayout
function M.create_diff_layout(mode, parent_win, expected_content, actual_content) function M.create_diff_layout(mode, parent_win, expected_content, actual_content)
if mode == 'single' then if mode == 'single' then
return create_single_layout(parent_win, actual_content) return create_single_layout(parent_win, actual_content)
@ -196,13 +185,6 @@ function M.create_diff_layout(mode, parent_win, expected_content, actual_content
end end
end end
---@param current_diff_layout DiffLayout?
---@param current_mode string?
---@param main_win integer
---@param run table
---@param config cp.Config
---@param setup_keybindings_for_buffer fun(buf: integer)
---@return DiffLayout?, string?
function M.update_diff_panes( function M.update_diff_panes(
current_diff_layout, current_diff_layout,
current_mode, current_mode,

View file

@ -16,7 +16,6 @@ local current_diff_layout = nil
local current_mode = nil local current_mode = nil
local _run_gen = 0 local _run_gen = 0
---@return nil
function M.disable() function M.disable()
local active_panel = state.get_active_panel() local active_panel = state.get_active_panel()
if not active_panel then if not active_panel then
@ -352,7 +351,6 @@ local function create_window_layout(output_buf, input_buf)
vim.api.nvim_set_current_win(solution_win) vim.api.nvim_set_current_win(solution_win)
end end
---@return nil
function M.ensure_io_view() function M.ensure_io_view()
local platform, contest_id, problem_id = local platform, contest_id, problem_id =
state.get_platform(), state.get_contest_id(), state.get_problem_id() state.get_platform(), state.get_contest_id(), state.get_problem_id()
@ -600,9 +598,6 @@ local function render_io_view_results(io_state, test_indices, mode, combined_res
utils.update_buffer_content(io_state.output_buf, output_lines, final_highlights, output_ns) utils.update_buffer_content(io_state.output_buf, output_lines, final_highlights, output_ns)
end end
---@param test_indices_arg integer[]?
---@param debug boolean?
---@param mode? string
function M.run_io_view(test_indices_arg, debug, mode) function M.run_io_view(test_indices_arg, debug, mode)
_run_gen = _run_gen + 1 _run_gen = _run_gen + 1
local gen = _run_gen local gen = _run_gen
@ -759,12 +754,10 @@ function M.run_io_view(test_indices_arg, debug, mode)
end) end)
end end
---@return nil
function M.cancel_io_view() function M.cancel_io_view()
_run_gen = _run_gen + 1 _run_gen = _run_gen + 1
end end
---@return nil
function M.cancel_interactive() function M.cancel_interactive()
if state.interactive_buf and vim.api.nvim_buf_is_valid(state.interactive_buf) then if state.interactive_buf and vim.api.nvim_buf_is_valid(state.interactive_buf) then
local job = vim.b[state.interactive_buf].terminal_job_id local job = vim.b[state.interactive_buf].terminal_job_id

View file

@ -314,7 +314,6 @@ end
--- Configure the buffer with good defaults --- Configure the buffer with good defaults
---@param filetype? string ---@param filetype? string
---@return integer
function M.create_buffer_with_options(filetype) function M.create_buffer_with_options(filetype)
local buf = vim.api.nvim_create_buf(false, true) local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'hide', { buf = buf }) vim.api.nvim_set_option_value('bufhidden', 'hide', { buf = buf })
@ -346,7 +345,6 @@ function M.update_buffer_content(bufnr, lines, highlights, namespace)
end end
end end
---@return boolean, string?
function M.check_required_runtime() function M.check_required_runtime()
if is_windows() then if is_windows() then
return false, 'Windows is not supported' return false, 'Windows is not supported'
@ -421,19 +419,16 @@ local function find_gnu_timeout()
return _timeout_path, _timeout_reason return _timeout_path, _timeout_reason
end end
---@return string?
function M.timeout_path() function M.timeout_path()
local path = find_gnu_timeout() local path = find_gnu_timeout()
return path return path
end end
---@return { ok: boolean, path: string|nil, reason: string|nil }
function M.timeout_capability() function M.timeout_capability()
local path, reason = find_gnu_timeout() local path, reason = find_gnu_timeout()
return { ok = path ~= nil, path = path, reason = reason } return { ok = path ~= nil, path = path, reason = reason }
end end
---@return string[]
function M.cwd_executables() function M.cwd_executables()
local uv = vim.uv local uv = vim.uv
local req = uv.fs_scandir('.') local req = uv.fs_scandir('.')
@ -457,7 +452,6 @@ function M.cwd_executables()
return out return out
end end
---@return nil
function M.ensure_dirs() function M.ensure_dirs()
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
end end

View file

@ -10,7 +10,7 @@ from typing import Any
import httpx import httpx
from .base import BaseScraper from .base import BaseScraper
from .timeouts import BROWSER_SESSION_TIMEOUT, HTTP_TIMEOUT from .timeouts import BROWSER_NAV_TIMEOUT, BROWSER_SESSION_TIMEOUT, HTTP_TIMEOUT
from .models import ( from .models import (
ContestListResult, ContestListResult,
ContestSummary, ContestSummary,
@ -23,7 +23,6 @@ from .models import (
BASE_URL = "https://www.codechef.com" BASE_URL = "https://www.codechef.com"
API_CONTESTS_ALL = "/api/list/contests/all" API_CONTESTS_ALL = "/api/list/contests/all"
API_CONTESTS_PAST = "/api/list/contests/past"
API_CONTEST = "/api/contests/{contest_id}" API_CONTEST = "/api/contests/{contest_id}"
API_PROBLEM = "/api/contests/{contest_id}/problems/{problem_id}" API_PROBLEM = "/api/contests/{contest_id}/problems/{problem_id}"
HEADERS = { HEADERS = {
@ -33,19 +32,17 @@ CONNECTIONS = 8
_COOKIE_PATH = Path.home() / ".cache" / "cp-nvim" / "codechef-cookies.json" _COOKIE_PATH = Path.home() / ".cache" / "cp-nvim" / "codechef-cookies.json"
_CC_CHECK_LOGIN_JS = "() => !!document.querySelector('a[href*=\"/users/\"]')" _CC_CHECK_LOGIN_JS = """() => {
const d = document.getElementById('__NEXT_DATA__');
_CC_LANG_IDS: dict[str, str] = { if (d) {
"C++": "42", try {
"PYTH 3": "116", const p = JSON.parse(d.textContent);
"JAVA": "10", if (p?.props?.pageProps?.currentUser?.username) return true;
"PYPY3": "109", } catch(e) {}
"GO": "114", }
"rust": "93", return !!document.querySelector('a[href="/logout"]') ||
"KTLN": "47", !!document.querySelector('[class*="user-name"]');
"NODEJS": "56", }"""
"TS": "35",
}
async def fetch_json(client: httpx.AsyncClient, path: str) -> dict[str, Any]: async def fetch_json(client: httpx.AsyncClient, path: str) -> dict[str, Any]:
@ -74,19 +71,21 @@ def _login_headless_codechef(credentials: dict[str, str]) -> LoginResult:
def check_login(page): def check_login(page):
nonlocal logged_in nonlocal logged_in
logged_in = "dashboard" in page.url or page.evaluate(_CC_CHECK_LOGIN_JS) logged_in = page.evaluate(_CC_CHECK_LOGIN_JS)
def login_action(page): def login_action(page):
nonlocal login_error nonlocal login_error
try: try:
page.locator('input[name="name"]').fill(credentials.get("username", "")) page.locator('input[type="email"], input[name="email"]').first.fill(
page.locator('input[name="pass"]').fill(credentials.get("password", "")) credentials.get("username", "")
page.locator("input.cc-login-btn").click() )
try: page.locator('input[type="password"], input[name="password"]').first.fill(
page.wait_for_url(lambda url: "/login" not in url, timeout=3000) credentials.get("password", "")
except Exception: )
login_error = "Login failed (bad credentials?)" page.locator('button[type="submit"]').first.click()
return page.wait_for_url(
lambda url: "/login" not in url, timeout=BROWSER_NAV_TIMEOUT
)
except Exception as e: except Exception as e:
login_error = str(e) login_error = str(e)
@ -156,19 +155,21 @@ def _submit_headless_codechef(
def check_login(page): def check_login(page):
nonlocal logged_in nonlocal logged_in
logged_in = "dashboard" in page.url or page.evaluate(_CC_CHECK_LOGIN_JS) logged_in = page.evaluate(_CC_CHECK_LOGIN_JS)
def login_action(page): def login_action(page):
nonlocal login_error nonlocal login_error
try: try:
page.locator('input[name="name"]').fill(credentials.get("username", "")) page.locator('input[type="email"], input[name="email"]').first.fill(
page.locator('input[name="pass"]').fill(credentials.get("password", "")) credentials.get("username", "")
page.locator("input.cc-login-btn").click() )
try: page.locator('input[type="password"], input[name="password"]').first.fill(
page.wait_for_url(lambda url: "/login" not in url, timeout=3000) credentials.get("password", "")
except Exception: )
login_error = "Login failed (bad credentials?)" page.locator('button[type="submit"]').first.click()
return page.wait_for_url(
lambda url: "/login" not in url, timeout=BROWSER_NAV_TIMEOUT
)
except Exception as e: except Exception as e:
login_error = str(e) login_error = str(e)
@ -178,44 +179,54 @@ def _submit_headless_codechef(
needs_relogin = True needs_relogin = True
return return
try: try:
page.wait_for_timeout(2000) selected = False
selects = page.locator("select")
for i in range(selects.count()):
try:
sel = selects.nth(i)
opts = sel.locator("option").all_inner_texts()
match = next(
(o for o in opts if language_id.lower() in o.lower()), None
)
if match:
sel.select_option(label=match)
selected = True
break
except Exception:
pass
page.locator('[aria-haspopup="listbox"]').click() if not selected:
page.wait_for_selector('[role="option"]', timeout=5000) lang_trigger = page.locator(
page.locator(f'[role="option"][data-value="{language_id}"]').click() '[class*="language"] button, [data-testid*="language"] button'
page.wait_for_timeout(2000) ).first
lang_trigger.click()
page.wait_for_timeout(500)
page.locator(
f'[role="option"]:has-text("{language_id}"), '
f'li:has-text("{language_id}")'
).first.click()
page.locator(".ace_editor").click()
page.keyboard.press("Control+a")
page.wait_for_timeout(200)
page.evaluate( page.evaluate(
"""(code) => { """(code) => {
const textarea = document.querySelector('.ace_text-input'); if (typeof monaco !== 'undefined') {
const dt = new DataTransfer(); const models = monaco.editor.getModels();
dt.setData('text/plain', code); if (models.length > 0) { models[0].setValue(code); return; }
textarea.dispatchEvent(new ClipboardEvent('paste', { }
clipboardData: dt, bubbles: true, cancelable: true const cm = document.querySelector('.CodeMirror');
})); if (cm && cm.CodeMirror) { cm.CodeMirror.setValue(code); return; }
const ta = document.querySelector('textarea');
if (ta) { ta.value = code; ta.dispatchEvent(new Event('input', {bubbles: true})); }
}""", }""",
source_code, source_code,
) )
page.wait_for_timeout(1000)
page.evaluate( page.locator(
"() => document.getElementById('submit_btn').scrollIntoView({block:'center'})" 'button[type="submit"]:has-text("Submit"), button:has-text("Submit Code")'
).first.click()
page.wait_for_url(
lambda url: "/submit/" not in url or "submission" in url,
timeout=BROWSER_NAV_TIMEOUT * 2,
) )
page.wait_for_timeout(200)
page.locator("#submit_btn").dispatch_event("click")
page.wait_for_timeout(3000)
dialog_text = page.evaluate("""() => {
const d = document.querySelector('[role="dialog"], .swal2-popup');
return d ? d.textContent.trim() : null;
}""")
if dialog_text and "not available for accepting solutions" in dialog_text:
submit_error = "PRACTICE_FALLBACK"
elif dialog_text:
submit_error = dialog_text
except Exception as e: except Exception as e:
submit_error = str(e) submit_error = str(e)
@ -241,12 +252,10 @@ def _submit_headless_codechef(
) )
print(json.dumps({"status": "submitting"}), flush=True) print(json.dumps({"status": "submitting"}), flush=True)
submit_url = ( session.fetch(
f"{BASE_URL}/submit/{problem_id}" f"{BASE_URL}/{contest_id}/submit/{problem_id}",
if contest_id == "PRACTICE" page_action=submit_action,
else f"{BASE_URL}/{contest_id}/submit/{problem_id}"
) )
session.fetch(submit_url, page_action=submit_action)
try: try:
browser_cookies = session.context.cookies() browser_cookies = session.context.cookies()
@ -266,20 +275,12 @@ def _submit_headless_codechef(
_retried=True, _retried=True,
) )
if submit_error == "PRACTICE_FALLBACK" and not _retried:
return _submit_headless_codechef(
"PRACTICE",
problem_id,
file_path,
language_id,
credentials,
_retried=True,
)
if submit_error: if submit_error:
return SubmitResult(success=False, error=submit_error) return SubmitResult(success=False, error=submit_error)
return SubmitResult(success=True, error="", submission_id="") return SubmitResult(
success=True, error="", submission_id="", verdict="submitted"
)
except Exception as e: except Exception as e:
return SubmitResult(success=False, error=str(e)) return SubmitResult(success=False, error=str(e))
@ -295,19 +296,12 @@ class CodeChefScraper(BaseScraper):
data = await fetch_json( data = await fetch_json(
client, API_CONTEST.format(contest_id=contest_id) client, API_CONTEST.format(contest_id=contest_id)
) )
problems_raw = data.get("problems") if not data.get("problems"):
if not problems_raw and isinstance(data.get("child_contests"), dict):
for div in ("div_4", "div_3", "div_2", "div_1"):
child = data["child_contests"].get(div, {})
child_code = child.get("contest_code")
if child_code:
return await self.scrape_contest_metadata(child_code)
if not problems_raw:
return self._metadata_error( return self._metadata_error(
f"No problems found for contest {contest_id}" f"No problems found for contest {contest_id}"
) )
problems = [] problems = []
for problem_code, problem_data in problems_raw.items(): for problem_code, problem_data in data["problems"].items():
if problem_data.get("category_name") == "main": if problem_data.get("category_name") == "main":
problems.append( problems.append(
ProblemSummary( ProblemSummary(
@ -320,120 +314,42 @@ class CodeChefScraper(BaseScraper):
error="", error="",
contest_id=contest_id, contest_id=contest_id,
problems=problems, problems=problems,
url=f"{BASE_URL}/problems/%s", url=f"{BASE_URL}/{contest_id}",
contest_url=f"{BASE_URL}/{contest_id}",
standings_url=f"{BASE_URL}/{contest_id}/rankings",
) )
except Exception as e: except Exception as e:
return self._metadata_error(f"Failed to fetch contest {contest_id}: {e}") return self._metadata_error(f"Failed to fetch contest {contest_id}: {e}")
async def scrape_contest_list(self) -> ContestListResult: async def scrape_contest_list(self) -> ContestListResult:
async with httpx.AsyncClient( async with httpx.AsyncClient() as client:
limits=httpx.Limits(max_connections=CONNECTIONS)
) as client:
try: try:
data = await fetch_json(client, API_CONTESTS_ALL) data = await fetch_json(client, API_CONTESTS_ALL)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
return self._contests_error(f"Failed to fetch contests: {e}") return self._contests_error(f"Failed to fetch contests: {e}")
present = data.get("present_contests", [])
future = data.get("future_contests", [])
async def fetch_past_page(offset: int) -> list[dict[str, Any]]:
r = await client.get(
BASE_URL + API_CONTESTS_PAST,
params={
"sort_by": "START",
"sorting_order": "desc",
"offset": offset,
},
headers=HEADERS,
timeout=HTTP_TIMEOUT,
)
r.raise_for_status()
return r.json().get("contests", [])
past: list[dict[str, Any]] = []
offset = 0
while True:
page = await fetch_past_page(offset)
past.extend(
c for c in page if re.match(r"^START\d+", c.get("contest_code", ""))
)
if len(page) < 20:
break
offset += 20
raw: list[dict[str, Any]] = []
seen_raw: set[str] = set()
for c in present + future + past:
code = c.get("contest_code", "")
if not code or code in seen_raw:
continue
seen_raw.add(code)
raw.append(c)
sem = asyncio.Semaphore(CONNECTIONS)
async def expand(c: dict[str, Any]) -> list[ContestSummary]:
code = c["contest_code"]
name = c.get("contest_name", code)
start_time: int | None = None
iso = c.get("contest_start_date_iso")
if iso:
try:
start_time = int(datetime.fromisoformat(iso).timestamp())
except Exception:
pass
base_name = re.sub(r"\s*\(.*?\)\s*$", "", name).strip()
try:
async with sem:
detail = await fetch_json(
client, API_CONTEST.format(contest_id=code)
)
children = detail.get("child_contests")
if children and isinstance(children, dict):
divs: list[ContestSummary] = []
for div_key in ("div_1", "div_2", "div_3", "div_4"):
child = children.get(div_key)
if not child:
continue
child_code = child.get("contest_code")
div_num = child.get("div", {}).get(
"div_number", div_key[-1]
)
if child_code:
display = f"{base_name} (Div. {div_num})"
divs.append(
ContestSummary(
id=child_code,
name=display,
display_name=display,
start_time=start_time,
)
)
if divs:
return divs
except Exception:
pass
return [
ContestSummary(
id=code, name=name, display_name=name, start_time=start_time
)
]
results = await asyncio.gather(*[expand(c) for c in raw])
contests: list[ContestSummary] = [] contests: list[ContestSummary] = []
seen: set[str] = set() seen: set[str] = set()
for group in results: for c in data.get("future_contests", []) + data.get("past_contests", []):
for entry in group: code = c.get("contest_code", "")
if entry.id not in seen: name = c.get("contest_name", code)
seen.add(entry.id) if not re.match(r"^START\d+$", code):
contests.append(entry) continue
if code in seen:
continue
seen.add(code)
start_time: int | None = None
iso = c.get("contest_start_date_iso")
if iso:
try:
dt = datetime.fromisoformat(iso)
start_time = int(dt.timestamp())
except Exception:
pass
contests.append(
ContestSummary(
id=code, name=name, display_name=name, start_time=start_time
)
)
if not contests: if not contests:
return self._contests_error("No contests found") return self._contests_error("No Starters contests found")
return ContestListResult(success=True, error="", contests=contests) return ContestListResult(success=True, error="", contests=contests)
async def stream_tests_for_category_async(self, category_id: str) -> None: async def stream_tests_for_category_async(self, category_id: str) -> None:
@ -453,15 +369,6 @@ class CodeChefScraper(BaseScraper):
) )
return return
all_problems = contest_data.get("problems", {}) all_problems = contest_data.get("problems", {})
if not all_problems and isinstance(
contest_data.get("child_contests"), dict
):
for div in ("div_4", "div_3", "div_2", "div_1"):
child = contest_data["child_contests"].get(div, {})
child_code = child.get("contest_code")
if child_code:
await self.stream_tests_for_category_async(child_code)
return
if not all_problems: if not all_problems:
print( print(
json.dumps( json.dumps(

View file

@ -9,7 +9,7 @@ from typing import Any
import httpx import httpx
from .base import BaseScraper, extract_precision from .base import BaseScraper, extract_precision
from .timeouts import HTTP_TIMEOUT from .timeouts import HTTP_TIMEOUT, SUBMIT_POLL_TIMEOUT
from .models import ( from .models import (
ContestListResult, ContestListResult,
ContestSummary, ContestSummary,
@ -465,8 +465,40 @@ class CSESScraper(BaseScraper):
err = r.text err = r.text
return self._submit_error(f"Submit request failed: {err}") return self._submit_error(f"Submit request failed: {err}")
submission_id = str(r.json().get("id", "")) info = r.json()
return SubmitResult(success=True, error="", submission_id=submission_id) 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=SUBMIT_POLL_TIMEOUT,
)
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__":

View file

@ -130,14 +130,6 @@ LANGUAGE_IDS = {
"python": "PYTH 3", "python": "PYTH 3",
"java": "JAVA", "java": "JAVA",
"rust": "rust", "rust": "rust",
"c": "C",
"go": "GO",
"kotlin": "KTLN",
"javascript": "NODEJS",
"typescript": "TS",
"csharp": "C#",
"php": "PHP",
"r": "R",
}, },
} }

View file

@ -12,3 +12,5 @@ BROWSER_SUBMIT_NAV_TIMEOUT["codeforces"] = BROWSER_NAV_TIMEOUT * 2
BROWSER_TURNSTILE_POLL = 5000 BROWSER_TURNSTILE_POLL = 5000
BROWSER_ELEMENT_WAIT = 10000 BROWSER_ELEMENT_WAIT = 10000
BROWSER_SETTLE_DELAY = 500 BROWSER_SETTLE_DELAY = 500
SUBMIT_POLL_TIMEOUT = 30.0

View file

@ -221,9 +221,6 @@ def run_scraper_offline(fixture_text):
if "/api/list/contests/all" in url: if "/api/list/contests/all" in url:
data = json.loads(fixture_text("codechef/contests.json")) data = json.loads(fixture_text("codechef/contests.json"))
return MockResponse(data) return MockResponse(data)
if "/api/list/contests/past" in url:
data = json.loads(fixture_text("codechef/contests_past.json"))
return MockResponse(data)
if "/api/contests/START" in url and "/problems/" not in url: if "/api/contests/START" in url and "/problems/" not in url:
contest_id = url.rstrip("/").split("/")[-1] contest_id = url.rstrip("/").split("/")[-1]
try: try:

View file

@ -1,16 +0,0 @@
{
"status": "success",
"message": "past contests list",
"contests": [
{
"contest_code": "START209D",
"contest_name": "Starters 209 Div 4",
"contest_start_date_iso": "2025-01-01T10:30:00+05:30"
},
{
"contest_code": "START208",
"contest_name": "Starters 208",
"contest_start_date_iso": "2024-12-25T10:30:00+05:30"
}
]
}