diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 691dc82..9448910 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -266,6 +266,7 @@ end ---@param memory_mb number ---@param interactive boolean ---@param multi_test boolean +---@param precision number? function M.set_test_cases( platform, contest_id, @@ -467,6 +468,7 @@ function M.clear_credentials(platform) M.save() end +---@return nil function M.clear_all() cache_data = {} M.save() @@ -488,6 +490,7 @@ function M.get_data_pretty() return vim.inspect(cache_data) end +---@return table function M.get_raw_cache() return cache_data end diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index d28de00..9ea34a4 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -257,10 +257,7 @@ local function parse_command(args) if vim.tbl_contains(platforms, first) then if #args == 1 then - return { - type = 'error', - message = 'Too few arguments - specify a contest.', - } + return { type = 'action', action = 'pick', requires_context = false, platform = first } elseif #args == 2 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 } @@ -362,6 +359,7 @@ local function check_platform_enabled(platform) end --- Core logic for handling `:CP ...` commands +---@param opts { fargs: string[] } ---@return nil function M.handle_command(opts) local cmd = parse_command(opts.fargs) @@ -400,7 +398,7 @@ function M.handle_command(opts) setup.navigate_problem(-1, cmd.language) elseif cmd.action == 'pick' then local picker = require('cp.commands.picker') - picker.handle_pick_action(cmd.language) + picker.handle_pick_action(cmd.language, cmd.platform) elseif cmd.action == 'edit' then local edit = require('cp.ui.edit') edit.toggle_edit(cmd.test_index) diff --git a/lua/cp/commands/picker.lua b/lua/cp/commands/picker.lua index e01b680..04541d2 100644 --- a/lua/cp/commands/picker.lua +++ b/lua/cp/commands/picker.lua @@ -5,8 +5,9 @@ local logger = require('cp.log') --- Dispatch `:CP pick` to appropriate picker ---@param language? string +---@param platform? string ---@return nil -function M.handle_pick_action(language) +function M.handle_pick_action(language, platform) local config = config_module.get_config() if not (config.ui and config.ui.picker) then @@ -54,7 +55,7 @@ function M.handle_pick_action(language) picker = fzf_picker end - picker.pick(language) + picker.pick(language, platform) end return M diff --git a/lua/cp/config.lua b/lua/cp/config.lua index bf2daac..74a40b7 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -531,10 +531,12 @@ end local current_config = nil +---@param config cp.Config function M.set_current_config(config) current_config = config end +---@return cp.Config function M.get_config() return current_config or M.defaults end diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index cf62b37..ec3c384 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -208,6 +208,12 @@ M.LANGUAGE_VERSIONS = { python = { python3 = 'PYTH 3', pypy3 = 'PYPY3' }, java = { java = 'JAVA' }, rust = { rust = 'rust' }, + c = { c = 'C' }, + go = { go = 'GO' }, + kotlin = { kotlin = 'KTLN' }, + javascript = { nodejs = 'NODEJS' }, + typescript = { typescript = 'TS' }, + csharp = { csharp = 'C#' }, }, } diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index 031b464..5328b8a 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -11,6 +11,7 @@ local STATUS_MESSAGES = { installing_browser = 'Installing browser...', } +---@param platform string? function M.login(platform) platform = platform or state.get_platform() if not platform then @@ -68,6 +69,7 @@ function M.login(platform) end) end +---@param platform string? function M.logout(platform) platform = platform or state.get_platform() if not platform then diff --git a/lua/cp/health.lua b/lua/cp/health.lua index 97595d6..f37a6c5 100644 --- a/lua/cp/health.lua +++ b/lua/cp/health.lua @@ -66,6 +66,7 @@ local function check() end end +---@return nil function M.check() local version = require('cp.version') vim.health.start('cp.nvim health check ~') diff --git a/lua/cp/init.lua b/lua/cp/init.lua index cab16ad..aed24b6 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -38,11 +38,13 @@ function M.handle_command(opts) commands.handle_command(opts) end +---@return boolean function M.is_initialized() return initialized end ---@deprecated Use `vim.g.cp` instead +---@param user_config table? function M.setup(user_config) vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.7.7', 'cp.nvim', false) diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index 65a600a..378e9f5 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -58,11 +58,18 @@ local function contest_picker(platform, refresh, language) }) end -function M.pick(language) +---@param language? string +---@param platform? string +function M.pick(language, platform) + if platform then + contest_picker(platform, false, language) + return + end + local fzf = require('fzf-lua') local platforms = picker_utils.get_platforms() - local entries = vim.tbl_map(function(platform) - return platform.display_name + local entries = vim.tbl_map(function(p) + return p.display_name end, platforms) return fzf.fzf_exec(entries, { @@ -74,16 +81,16 @@ function M.pick(language) end local selected_name = selected[1] - local platform = nil + local found = nil for _, p in ipairs(platforms) do if p.display_name == selected_name then - platform = p + found = p break end end - if platform then - contest_picker(platform.id, false, language) + if found then + contest_picker(found.id, false, language) end end, }, diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index 890ae6a..9397f8b 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -64,7 +64,14 @@ local function contest_picker(opts, platform, refresh, language) :find() end -function M.pick(language) +---@param language? string +---@param platform? string +function M.pick(language, platform) + if platform then + contest_picker({}, platform, false, language) + return + end + local opts = {} local platforms = picker_utils.get_platforms() diff --git a/lua/cp/race.lua b/lua/cp/race.lua index 84333ed..1728506 100644 --- a/lua/cp/race.lua +++ b/lua/cp/race.lua @@ -134,6 +134,9 @@ local function race_try_setup(platform, contest_id, language, attempt, token) ) end +---@param platform string +---@param contest_id string +---@param language? string function M.start(platform, contest_id, language) if not platform or not vim.tbl_contains(constants.PLATFORMS, platform) then logger.log('Invalid platform', { level = vim.log.levels.ERROR }) @@ -252,6 +255,7 @@ function M.start(platform, contest_id, language) ) end +---@return nil function M.stop() local timer = race_state.timer if not timer then @@ -276,6 +280,7 @@ function M.stop() ) end +---@return { active: boolean, platform?: string, contest_id?: string, remaining_seconds?: integer } function M.status() if not race_state.timer or not race_state.start_time then return { active = false } diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index 41a7b51..b24174a 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -33,6 +33,9 @@ local function substitute_template(cmd_template, substitutions) return out end +---@param cmd_template string[] +---@param substitutions SubstitutableCommand +---@return string[] function M.build_command(cmd_template, substitutions) return substitute_template(cmd_template, substitutions) end diff --git a/lua/cp/runner/run_render.lua b/lua/cp/runner/run_render.lua index 2dfb45b..4922ddb 100644 --- a/lua/cp/runner/run_render.lua +++ b/lua/cp/runner/run_render.lua @@ -376,6 +376,7 @@ function M.get_highlight_groups() } end +---@return nil function M.setup_highlights() local groups = M.get_highlight_groups() for name, opts in pairs(groups) do diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 4004ba6..02f20b3 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -216,6 +216,10 @@ local function run_scraper(platform, subcommand, args, opts) 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) run_scraper(platform, 'metadata', { contest_id }, { on_exit = function(result) @@ -253,6 +257,8 @@ function M.scrape_contest_metadata(platform, contest_id, callback, on_error) }) end +---@param platform string +---@return { contests: ContestSummary[], supports_countdown: boolean }? function M.scrape_contest_list(platform) local result = run_scraper(platform, 'contests', {}, { sync = true }) if not result or not result.success or not (result.data and result.data.contests) then @@ -330,6 +336,10 @@ function M.scrape_all_tests(platform, contest_id, callback, on_done) }) 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) local done = false run_scraper(platform, 'login', {}, { @@ -361,6 +371,14 @@ function M.login(platform, credentials, on_status, callback) }) 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( platform, contest_id, diff --git a/lua/cp/stress.lua b/lua/cp/stress.lua index caf9699..be3dcf0 100644 --- a/lua/cp/stress.lua +++ b/lua/cp/stress.lua @@ -58,6 +58,8 @@ local function build_run_cmd(file) return './' .. file end +---@param generator_cmd? string +---@param brute_cmd? string function M.toggle(generator_cmd, brute_cmd) if state.get_active_panel() == 'stress' then if state.stress_buf and vim.api.nvim_buf_is_valid(state.stress_buf) then @@ -239,6 +241,7 @@ function M.toggle(generator_cmd, brute_cmd) end) end +---@return nil function M.cancel() 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 diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index 7302d1d..e7146fe 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -38,6 +38,7 @@ local function prompt_credentials(platform, callback) end) end +---@param opts { language?: string }? function M.submit(opts) local platform = state.get_platform() local contest_id = state.get_contest_id() @@ -64,13 +65,13 @@ function M.submit(opts) local eff = plat_effective and plat_effective[language] if eff then if eff.submit_id then - submit_language = eff.submit_id + submit_language = eff.submit_id or submit_language else local ver = eff.version or constants.DEFAULT_VERSIONS[language] if ver then local versions = (constants.LANGUAGE_VERSIONS[platform] or {})[language] if versions and versions[ver] then - submit_language = versions[ver] + submit_language = versions[ver] or submit_language end end end diff --git a/lua/cp/ui/edit.lua b/lua/cp/ui/edit.lua index 93083aa..d380944 100644 --- a/lua/cp/ui/edit.lua +++ b/lua/cp/ui/edit.lua @@ -331,6 +331,7 @@ setup_keybindings = function(buf) }) end +---@param test_index? integer function M.toggle_edit(test_index) if edit_state then save_all_tests() diff --git a/lua/cp/ui/layouts.lua b/lua/cp/ui/layouts.lua index 9b40f49..97bc1f8 100644 --- a/lua/cp/ui/layouts.lua +++ b/lua/cp/ui/layouts.lua @@ -1,3 +1,9 @@ +---@class DiffLayout +---@field buffers integer[] +---@field windows integer[] +---@field mode string +---@field cleanup fun() + local M = {} local helpers = require('cp.helpers') @@ -171,6 +177,11 @@ local function create_single_layout(parent_win, content) } 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) if mode == 'single' then return create_single_layout(parent_win, actual_content) @@ -185,6 +196,13 @@ function M.create_diff_layout(mode, parent_win, expected_content, actual_content 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( current_diff_layout, current_mode, diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 599bded..22ff769 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -16,6 +16,7 @@ local current_diff_layout = nil local current_mode = nil local _run_gen = 0 +---@return nil function M.disable() local active_panel = state.get_active_panel() if not active_panel then @@ -351,6 +352,7 @@ local function create_window_layout(output_buf, input_buf) vim.api.nvim_set_current_win(solution_win) end +---@return nil function M.ensure_io_view() local platform, contest_id, problem_id = state.get_platform(), state.get_contest_id(), state.get_problem_id() @@ -598,6 +600,9 @@ 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) end +---@param test_indices_arg integer[]? +---@param debug boolean? +---@param mode? string function M.run_io_view(test_indices_arg, debug, mode) _run_gen = _run_gen + 1 local gen = _run_gen @@ -754,10 +759,12 @@ function M.run_io_view(test_indices_arg, debug, mode) end) end +---@return nil function M.cancel_io_view() _run_gen = _run_gen + 1 end +---@return nil function M.cancel_interactive() 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 diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index c27763f..8325369 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -314,6 +314,7 @@ end --- Configure the buffer with good defaults ---@param filetype? string +---@return integer function M.create_buffer_with_options(filetype) local buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_set_option_value('bufhidden', 'hide', { buf = buf }) @@ -345,6 +346,7 @@ function M.update_buffer_content(bufnr, lines, highlights, namespace) end end +---@return boolean, string? function M.check_required_runtime() if is_windows() then return false, 'Windows is not supported' @@ -419,16 +421,19 @@ local function find_gnu_timeout() return _timeout_path, _timeout_reason end +---@return string? function M.timeout_path() local path = find_gnu_timeout() return path end +---@return { ok: boolean, path: string|nil, reason: string|nil } function M.timeout_capability() local path, reason = find_gnu_timeout() return { ok = path ~= nil, path = path, reason = reason } end +---@return string[] function M.cwd_executables() local uv = vim.uv local req = uv.fs_scandir('.') @@ -452,6 +457,7 @@ function M.cwd_executables() return out end +---@return nil function M.ensure_dirs() vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() end diff --git a/scrapers/codechef.py b/scrapers/codechef.py index 614e8c2..7767b72 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -10,7 +10,7 @@ from typing import Any import httpx from .base import BaseScraper -from .timeouts import BROWSER_NAV_TIMEOUT, BROWSER_SESSION_TIMEOUT, HTTP_TIMEOUT +from .timeouts import BROWSER_SESSION_TIMEOUT, HTTP_TIMEOUT from .models import ( ContestListResult, ContestSummary, @@ -23,6 +23,7 @@ from .models import ( BASE_URL = "https://www.codechef.com" API_CONTESTS_ALL = "/api/list/contests/all" +API_CONTESTS_PAST = "/api/list/contests/past" API_CONTEST = "/api/contests/{contest_id}" API_PROBLEM = "/api/contests/{contest_id}/problems/{problem_id}" HEADERS = { @@ -32,17 +33,19 @@ CONNECTIONS = 8 _COOKIE_PATH = Path.home() / ".cache" / "cp-nvim" / "codechef-cookies.json" -_CC_CHECK_LOGIN_JS = """() => { - const d = document.getElementById('__NEXT_DATA__'); - if (d) { - try { - const p = JSON.parse(d.textContent); - if (p?.props?.pageProps?.currentUser?.username) return true; - } catch(e) {} - } - return !!document.querySelector('a[href="/logout"]') || - !!document.querySelector('[class*="user-name"]'); -}""" +_CC_CHECK_LOGIN_JS = "() => !!document.querySelector('a[href*=\"/users/\"]')" + +_CC_LANG_IDS: dict[str, str] = { + "C++": "42", + "PYTH 3": "116", + "JAVA": "10", + "PYPY3": "109", + "GO": "114", + "rust": "93", + "KTLN": "47", + "NODEJS": "56", + "TS": "35", +} async def fetch_json(client: httpx.AsyncClient, path: str) -> dict[str, Any]: @@ -71,21 +74,19 @@ def _login_headless_codechef(credentials: dict[str, str]) -> LoginResult: def check_login(page): nonlocal logged_in - logged_in = page.evaluate(_CC_CHECK_LOGIN_JS) + logged_in = "dashboard" in page.url or page.evaluate(_CC_CHECK_LOGIN_JS) def login_action(page): nonlocal login_error try: - page.locator('input[type="email"], input[name="email"]').first.fill( - credentials.get("username", "") - ) - page.locator('input[type="password"], input[name="password"]').first.fill( - credentials.get("password", "") - ) - page.locator('button[type="submit"]').first.click() - page.wait_for_url( - lambda url: "/login" not in url, timeout=BROWSER_NAV_TIMEOUT - ) + page.locator('input[name="name"]').fill(credentials.get("username", "")) + page.locator('input[name="pass"]').fill(credentials.get("password", "")) + page.locator("input.cc-login-btn").click() + try: + page.wait_for_url(lambda url: "/login" not in url, timeout=3000) + except Exception: + login_error = "Login failed (bad credentials?)" + return except Exception as e: login_error = str(e) @@ -155,21 +156,19 @@ def _submit_headless_codechef( def check_login(page): nonlocal logged_in - logged_in = page.evaluate(_CC_CHECK_LOGIN_JS) + logged_in = "dashboard" in page.url or page.evaluate(_CC_CHECK_LOGIN_JS) def login_action(page): nonlocal login_error try: - page.locator('input[type="email"], input[name="email"]').first.fill( - credentials.get("username", "") - ) - page.locator('input[type="password"], input[name="password"]').first.fill( - credentials.get("password", "") - ) - page.locator('button[type="submit"]').first.click() - page.wait_for_url( - lambda url: "/login" not in url, timeout=BROWSER_NAV_TIMEOUT - ) + page.locator('input[name="name"]').fill(credentials.get("username", "")) + page.locator('input[name="pass"]').fill(credentials.get("password", "")) + page.locator("input.cc-login-btn").click() + try: + page.wait_for_url(lambda url: "/login" not in url, timeout=3000) + except Exception: + login_error = "Login failed (bad credentials?)" + return except Exception as e: login_error = str(e) @@ -179,54 +178,44 @@ def _submit_headless_codechef( needs_relogin = True return try: - 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.wait_for_timeout(2000) - if not selected: - lang_trigger = page.locator( - '[class*="language"] button, [data-testid*="language"] button' - ).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('[aria-haspopup="listbox"]').click() + page.wait_for_selector('[role="option"]', timeout=5000) + page.locator(f'[role="option"][data-value="{language_id}"]').click() + page.wait_for_timeout(2000) + page.locator(".ace_editor").click() + page.keyboard.press("Control+a") + page.wait_for_timeout(200) page.evaluate( """(code) => { - if (typeof monaco !== 'undefined') { - const models = monaco.editor.getModels(); - if (models.length > 0) { models[0].setValue(code); return; } - } - 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})); } + const textarea = document.querySelector('.ace_text-input'); + const dt = new DataTransfer(); + dt.setData('text/plain', code); + textarea.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dt, bubbles: true, cancelable: true + })); }""", source_code, ) + page.wait_for_timeout(1000) - page.locator( - '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.evaluate( + "() => document.getElementById('submit_btn').scrollIntoView({block:'center'})" ) + 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: submit_error = str(e) @@ -252,10 +241,12 @@ def _submit_headless_codechef( ) print(json.dumps({"status": "submitting"}), flush=True) - session.fetch( - f"{BASE_URL}/{contest_id}/submit/{problem_id}", - page_action=submit_action, + submit_url = ( + f"{BASE_URL}/submit/{problem_id}" + if contest_id == "PRACTICE" + else f"{BASE_URL}/{contest_id}/submit/{problem_id}" ) + session.fetch(submit_url, page_action=submit_action) try: browser_cookies = session.context.cookies() @@ -275,12 +266,20 @@ def _submit_headless_codechef( _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: return SubmitResult(success=False, error=submit_error) - return SubmitResult( - success=True, error="", submission_id="", verdict="submitted" - ) + return SubmitResult(success=True, error="", submission_id="") except Exception as e: return SubmitResult(success=False, error=str(e)) @@ -296,12 +295,19 @@ class CodeChefScraper(BaseScraper): data = await fetch_json( client, API_CONTEST.format(contest_id=contest_id) ) - if not data.get("problems"): + problems_raw = 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( f"No problems found for contest {contest_id}" ) problems = [] - for problem_code, problem_data in data["problems"].items(): + for problem_code, problem_data in problems_raw.items(): if problem_data.get("category_name") == "main": problems.append( ProblemSummary( @@ -314,42 +320,120 @@ class CodeChefScraper(BaseScraper): error="", contest_id=contest_id, problems=problems, - url=f"{BASE_URL}/{contest_id}", + url=f"{BASE_URL}/problems/%s", + contest_url=f"{BASE_URL}/{contest_id}", + standings_url=f"{BASE_URL}/{contest_id}/rankings", ) except Exception as e: return self._metadata_error(f"Failed to fetch contest {contest_id}: {e}") async def scrape_contest_list(self) -> ContestListResult: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient( + limits=httpx.Limits(max_connections=CONNECTIONS) + ) as client: try: data = await fetch_json(client, API_CONTESTS_ALL) except httpx.HTTPStatusError as e: return self._contests_error(f"Failed to fetch contests: {e}") - contests: list[ContestSummary] = [] - seen: set[str] = set() - for c in data.get("future_contests", []) + data.get("past_contests", []): - code = c.get("contest_code", "") - name = c.get("contest_name", code) - if not re.match(r"^START\d+$", code): - continue - if code in seen: - continue - seen.add(code) - start_time: int | None = None - iso = c.get("contest_start_date_iso") - if iso: + + 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: - dt = datetime.fromisoformat(iso) - start_time = int(dt.timestamp()) + 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 - contests.append( - ContestSummary( - id=code, name=name, display_name=name, start_time=start_time - ) - ) + 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] = [] + seen: set[str] = set() + for group in results: + for entry in group: + if entry.id not in seen: + seen.add(entry.id) + contests.append(entry) + if not contests: - return self._contests_error("No Starters contests found") + return self._contests_error("No contests found") return ContestListResult(success=True, error="", contests=contests) async def stream_tests_for_category_async(self, category_id: str) -> None: @@ -369,6 +453,15 @@ class CodeChefScraper(BaseScraper): ) return 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: print( json.dumps( diff --git a/scrapers/cses.py b/scrapers/cses.py index 1ddd292..2d29689 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -9,7 +9,7 @@ from typing import Any import httpx from .base import BaseScraper, extract_precision -from .timeouts import HTTP_TIMEOUT, SUBMIT_POLL_TIMEOUT +from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, ContestSummary, @@ -465,40 +465,8 @@ class CSESScraper(BaseScraper): 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=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)", - ) + submission_id = str(r.json().get("id", "")) + return SubmitResult(success=True, error="", submission_id=submission_id) if __name__ == "__main__": diff --git a/scrapers/language_ids.py b/scrapers/language_ids.py index f69c4d9..f7e4e18 100644 --- a/scrapers/language_ids.py +++ b/scrapers/language_ids.py @@ -130,6 +130,14 @@ LANGUAGE_IDS = { "python": "PYTH 3", "java": "JAVA", "rust": "rust", + "c": "C", + "go": "GO", + "kotlin": "KTLN", + "javascript": "NODEJS", + "typescript": "TS", + "csharp": "C#", + "php": "PHP", + "r": "R", }, } diff --git a/scrapers/timeouts.py b/scrapers/timeouts.py index 715b07f..e4ff583 100644 --- a/scrapers/timeouts.py +++ b/scrapers/timeouts.py @@ -12,5 +12,3 @@ BROWSER_SUBMIT_NAV_TIMEOUT["codeforces"] = BROWSER_NAV_TIMEOUT * 2 BROWSER_TURNSTILE_POLL = 5000 BROWSER_ELEMENT_WAIT = 10000 BROWSER_SETTLE_DELAY = 500 - -SUBMIT_POLL_TIMEOUT = 30.0 diff --git a/tests/conftest.py b/tests/conftest.py index f1248a5..5d281d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -221,6 +221,9 @@ def run_scraper_offline(fixture_text): if "/api/list/contests/all" in url: data = json.loads(fixture_text("codechef/contests.json")) 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: contest_id = url.rstrip("/").split("/")[-1] try: diff --git a/tests/fixtures/codechef/contests_past.json b/tests/fixtures/codechef/contests_past.json new file mode 100644 index 0000000..449b185 --- /dev/null +++ b/tests/fixtures/codechef/contests_past.json @@ -0,0 +1,16 @@ +{ + "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" + } + ] +}