Compare commits

...

3 commits

Author SHA1 Message Date
e9dd236172
docs: document :CP open command in vimdoc 2026-03-05 19:39:31 -05:00
cc279166cb
feat(commands): implement :CP open [problem|contest|standings]
Problem: There was no way to open a problem or contest page in the
browser from within the plugin; users had to manually navigate to
the platform URL.

Solution: Add `contest_url` and `standings_url` to `MetadataResult`
and persist them via `cache.set_contest_data`. Add `cache.get_open_urls`
to assemble all three URLs from cache. Wire up `:CP open` in
`commands/init.lua` to call `vim.ui.open` on the resolved URL,
warning when none is available (e.g. CSES standings).
2026-03-05 19:13:06 -05:00
cc48c901c0
fix(scrapers): harden CSES and CF submit edge cases (#295)
Problem: CSES `_web_login` did bare dict indexing on the API response,
causing an opaque `KeyError` if fields were absent. `_check_token`
swallowed all exceptions as `False`, treating transient network errors
as invalid tokens. CF wrote cookies unconditionally (login and submit),
and swallowed `_solve_turnstile` failures in `submit_action`.

Solution: Replace direct indexing with `.get()` + `RuntimeError` for
missing CSES API fields. Re-raise `httpx` network/timeout exceptions
from `_check_token`. Guard CF cookie writes behind an `X-User-Handle`
check (the CF auth cookie). Propagate `_solve_turnstile` errors so
failures surface rather than silently proceeding.
2026-03-05 18:58:27 -05:00
9 changed files with 117 additions and 21 deletions

View file

@ -24,15 +24,16 @@ CONTENTS *cp-contents*
16. Race .......................................................... |cp-race|
17. Credentials ............................................ |cp-credentials|
18. Submit ...................................................... |cp-submit|
19. ANSI Colors ................................................... |cp-ansi|
20. Highlight Groups ........................................ |cp-highlights|
21. Terminal Colors .................................... |cp-terminal-colors|
22. Highlight Customization .......................... |cp-highlight-custom|
23. Helpers .................................................... |cp-helpers|
24. Statusline Integration .................................. |cp-statusline|
25. Panel Keymaps .......................................... |cp-panel-keys|
26. File Structure ................................................ |cp-files|
27. Health Check ................................................ |cp-health|
19. Open ......................................................... |cp-open|
20. ANSI Colors ................................................... |cp-ansi|
21. Highlight Groups ........................................ |cp-highlights|
22. Terminal Colors .................................... |cp-terminal-colors|
23. Highlight Customization .......................... |cp-highlight-custom|
24. Helpers .................................................... |cp-helpers|
25. Statusline Integration .................................. |cp-statusline|
26. Panel Keymaps .......................................... |cp-panel-keys|
27. File Structure ................................................ |cp-files|
28. Health Check ................................................ |cp-health|
==============================================================================
INTRODUCTION *cp.nvim*
@ -487,6 +488,17 @@ COMMANDS *cp-commands*
credentials are saved.
--lang: Submit solution for a specific language.
:CP open [problem|contest|standings]
Open the URL for the current problem, contest,
or standings page in the browser via
|vim.ui.open|. Defaults to "problem" if no
argument is given. Warns if the URL is not
available (e.g. CSES has no standings).
Examples: >
:CP open
:CP open contest
:CP open standings
<
State Restoration ~
:CP Restore state from current file.
Automatically detects platform, contest, problem,
@ -580,6 +592,9 @@ through the same code path as |:CP|.
*<Plug>(cp-submit)*
<Plug>(cp-submit) Submit current solution. Equivalent to :CP submit.
*<Plug>(cp-open)*
<Plug>(cp-open) Open current problem URL in browser. Equivalent to :CP open.
*<Plug>(cp-race-stop)*
<Plug>(cp-race-stop) Cancel active race countdown. Equivalent to :CP race stop.
@ -1021,6 +1036,22 @@ Submit the current solution to the online judge.
AtCoder Fully implemented.
Others Not yet implemented.
==============================================================================
OPEN *cp-open*
Open a platform URL for the current contest in the browser.
:CP open [problem|contest|standings]
Open the URL for the active problem, contest page, or standings.
Defaults to "problem" if no argument is given. Uses |vim.ui.open|.
Warns if the URL is unavailable (e.g. CSES has no standings page).
Platform support:
AtCoder problem, contest, standings
Codeforces problem, contest, standings
CSES problem, contest (no standings)
Others Not yet implemented.
==============================================================================
ANSI COLORS AND HIGHLIGHTING *cp-ansi*

View file

@ -10,6 +10,8 @@
---@field name string
---@field display_name string
---@field url string
---@field contest_url string
---@field standings_url string
---@class ContestSummary
---@field display_name string
@ -148,12 +150,16 @@ end
---@param contest_id string
---@param problems Problem[]
---@param url string
function M.set_contest_data(platform, contest_id, problems, url)
---@param contest_url string
---@param standings_url string
function M.set_contest_data(platform, contest_id, problems, url, contest_url, standings_url)
vim.validate({
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
problems = { problems, 'table' },
url = { url, 'string' },
contest_url = { contest_url, 'string' },
standings_url = { standings_url, 'string' },
})
cache_data[platform] = cache_data[platform] or {}
@ -165,6 +171,8 @@ function M.set_contest_data(platform, contest_id, problems, url)
problems = problems,
index_map = {},
url = url,
contest_url = contest_url,
standings_url = standings_url,
}
for i, p in ipairs(out.problems) do
out.index_map[p.id] = i
@ -174,6 +182,25 @@ function M.set_contest_data(platform, contest_id, problems, url)
M.save()
end
---@param platform string?
---@param contest_id string?
---@param problem_id string?
---@return { problem: string|nil, contest: string|nil, standings: string|nil }|nil
function M.get_open_urls(platform, contest_id, problem_id)
if not platform or not contest_id then
return nil
end
if not cache_data[platform] or not cache_data[platform][contest_id] then
return nil
end
local cd = cache_data[platform][contest_id]
return {
problem = cd.url ~= '' and problem_id and string.format(cd.url, problem_id) or nil,
contest = cd.contest_url ~= '' and cd.contest_url or nil,
standings = cd.standings_url ~= '' and cd.standings_url or nil,
}
end
---@param platform string
---@param contest_id string
function M.clear_contest_data(platform, contest_id)

View file

@ -245,6 +245,12 @@ local function parse_command(args)
debug = debug,
mode = mode,
}
elseif first == 'open' then
local target = args[2] or 'problem'
if not vim.tbl_contains({ 'problem', 'contest', 'standings' }, target) then
return { type = 'error', message = 'Usage: :CP open [problem|contest|standings]' }
end
return { type = 'action', action = 'open', requires_context = true, subcommand = target }
elseif first == 'pick' then
local language = nil
if #args >= 3 and args[2] == '--lang' then
@ -375,6 +381,20 @@ function M.handle_command(opts)
require('cp.race').start(cmd.platform, cmd.contest, cmd.language)
elseif cmd.action == 'race_stop' then
require('cp.race').stop()
elseif cmd.action == 'open' then
local cache = require('cp.cache')
cache.load()
local urls =
cache.get_open_urls(state.get_platform(), state.get_contest_id(), state.get_problem_id())
local url = urls and urls[cmd.subcommand]
if not url or url == '' then
logger.log(
("No URL available for '%s'"):format(cmd.subcommand),
{ level = vim.log.levels.WARN }
)
return
end
vim.ui.open(url)
elseif cmd.action == 'login' then
require('cp.credentials').login(cmd.platform)
elseif cmd.action == 'logout' then

View file

@ -13,6 +13,7 @@ M.ACTIONS = {
'race',
'stress',
'submit',
'open',
}
M.PLATFORM_DISPLAY_NAMES = {

View file

@ -243,7 +243,14 @@ function M.setup_contest(platform, contest_id, problem_id, language)
contest_id,
vim.schedule_wrap(function(result)
local problems = result.problems or {}
cache.set_contest_data(platform, contest_id, problems, result.url)
cache.set_contest_data(
platform,
contest_id,
problems,
result.url,
result.contest_url or '',
result.standings_url or ''
)
local prov = state.get_provisional()
if not prov or prov.platform ~= platform or prov.contest_id ~= contest_id then
return

View file

@ -606,6 +606,8 @@ class AtcoderScraper(BaseScraper):
contest_id=contest_id,
problems=problems,
url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s",
contest_url=f"https://atcoder.jp/contests/{contest_id}",
standings_url=f"https://atcoder.jp/contests/{contest_id}/standings",
)
except Exception as e:
return self._metadata_error(str(e))

View file

@ -223,6 +223,8 @@ class CodeforcesScraper(BaseScraper):
contest_id=contest_id,
problems=problems,
url=f"https://codeforces.com/contest/{contest_id}/problem/%s",
contest_url=f"https://codeforces.com/contest/{contest_id}",
standings_url=f"https://codeforces.com/contest/{contest_id}/standings",
)
except Exception as e:
return self._metadata_error(str(e))
@ -401,7 +403,8 @@ def _login_headless_cf(credentials: dict[str, str]) -> LoginResult:
try:
browser_cookies = session.context.cookies()
cookie_cache.write_text(json.dumps(browser_cookies))
if any(c.get("name") == "X-User-Handle" for c in browser_cookies):
cookie_cache.write_text(json.dumps(browser_cookies))
except Exception:
pass
@ -478,10 +481,7 @@ def _submit_headless(
if "/enter" in page.url or "/login" in page.url:
needs_relogin = True
return
try:
_solve_turnstile(page)
except Exception:
pass
_solve_turnstile(page)
try:
page.select_option(
'select[name="submittedProblemIndex"]',
@ -550,7 +550,7 @@ def _submit_headless(
try:
browser_cookies = session.context.cookies()
if browser_cookies:
if any(c.get("name") == "X-User-Handle" for c in browser_cookies):
cookie_cache.write_text(json.dumps(browser_cookies))
except Exception:
pass

View file

@ -218,6 +218,8 @@ class CSESScraper(BaseScraper):
contest_id=contest_id,
problems=problems,
url="https://cses.fi/problemset/task/%s",
contest_url="https://cses.fi/problemset",
standings_url="",
)
async def scrape_contest_list(self) -> ContestListResult:
@ -352,8 +354,12 @@ class CSESScraper(BaseScraper):
f"{API_URL}/login", headers=HEADERS, timeout=HTTP_TIMEOUT
)
api_data = api_resp.json()
token: str = api_data["X-Auth-Token"]
auth_url: str = api_data["authentication_url"]
token: str | None = api_data.get("X-Auth-Token")
auth_url: str | None = api_data.get("authentication_url")
if not token:
raise RuntimeError("CSES API login response missing 'X-Auth-Token'")
if not auth_url:
raise RuntimeError("CSES API login response missing 'authentication_url'")
auth_page = await client.get(auth_url, headers=HEADERS, timeout=HTTP_TIMEOUT)
auth_csrf = re.search(r'name="csrf_token" value="([^"]+)"', auth_page.text)
@ -388,8 +394,8 @@ class CSESScraper(BaseScraper):
timeout=HTTP_TIMEOUT,
)
return r.status_code == 200
except Exception:
return False
except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError):
raise
async def submit(
self,

View file

@ -42,6 +42,8 @@ class MetadataResult(ScrapingResult):
contest_id: str = ""
problems: list[ProblemSummary] = Field(default_factory=list)
url: str
contest_url: str = ""
standings_url: str = ""
model_config = ConfigDict(extra="forbid")