cf-tool-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cf_tool/__init__.py +3 -0
- cf_tool/__main__.py +6 -0
- cf_tool/build.py +211 -0
- cf_tool/cli/__init__.py +117 -0
- cf_tool/cli/account.py +32 -0
- cf_tool/cli/common.py +262 -0
- cf_tool/cli/completion.py +226 -0
- cf_tool/cli/configure.py +386 -0
- cf_tool/cli/problems.py +399 -0
- cf_tool/cli/setup.py +195 -0
- cf_tool/cli/submitting.py +309 -0
- cf_tool/cli/testing.py +354 -0
- cf_tool/codeforces/__init__.py +3 -0
- cf_tool/codeforces/browser.py +346 -0
- cf_tool/codeforces/client.py +521 -0
- cf_tool/codeforces/crypto.py +41 -0
- cf_tool/codeforces/gateway.py +288 -0
- cf_tool/codeforces/http.py +213 -0
- cf_tool/codeforces/pages.py +767 -0
- cf_tool/codeforces/session.py +103 -0
- cf_tool/doctor.py +124 -0
- cf_tool/errors.py +37 -0
- cf_tool/judge.py +481 -0
- cf_tool/languages.py +1195 -0
- cf_tool/listen.py +178 -0
- cf_tool/paths.py +57 -0
- cf_tool/process.py +431 -0
- cf_tool/settings.py +386 -0
- cf_tool/stress.py +167 -0
- cf_tool/ui.py +455 -0
- cf_tool/views.py +306 -0
- cf_tool/workspace.py +777 -0
- cf_tool_cli-1.0.0.dist-info/METADATA +106 -0
- cf_tool_cli-1.0.0.dist-info/RECORD +38 -0
- cf_tool_cli-1.0.0.dist-info/WHEEL +5 -0
- cf_tool_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cf_tool_cli-1.0.0.dist-info/licenses/LICENSE +48 -0
- cf_tool_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Real-browser fallback (DrissionPage) for what plain HTTP cannot do: get
|
|
2
|
+
past a Cloudflare / Turnstile check, and log in when the form demands one.
|
|
3
|
+
|
|
4
|
+
gateway.py runs these headless first and in a visible window only as a last
|
|
5
|
+
resort. Cookies and the User-Agent exported here go back to the HTTP
|
|
6
|
+
transport together: Cloudflare binds cf_clearance to the UA (and to the IP,
|
|
7
|
+
which is why the browser uses the configured proxy too).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Callable, TypeVar
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
from cf_tool.codeforces import pages
|
|
22
|
+
from cf_tool.codeforces.http import CookieDict
|
|
23
|
+
from cf_tool.errors import BrowserError, BrowserUnavailable, CFError, LoginRejected
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
# seconds for a whole fetch/login; a window may be waiting on a human click
|
|
28
|
+
TIMEOUT = {True: 45.0, False: 180.0}
|
|
29
|
+
_POLL = 0.5
|
|
30
|
+
|
|
31
|
+
_JS_USER_AGENT = "return navigator.userAgent;"
|
|
32
|
+
_JS_FORM_ERRORS = (
|
|
33
|
+
"return Array.from(document.querySelectorAll('.error'))"
|
|
34
|
+
".map(e => e.textContent.trim()).filter(Boolean);"
|
|
35
|
+
)
|
|
36
|
+
_JS_TURNSTILE_TOKEN = (
|
|
37
|
+
"const e = document.querySelector('[name=\"cf-turnstile-response\"]');"
|
|
38
|
+
" return e ? e.value : '';"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class BrowserSession:
|
|
44
|
+
cookies: list[CookieDict]
|
|
45
|
+
user_agent: str
|
|
46
|
+
html: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch(url: str, *, headless: bool, proxy: str = "") -> BrowserSession:
|
|
50
|
+
"""Visit url, wait out any Cloudflare/Turnstile check, export the session."""
|
|
51
|
+
|
|
52
|
+
def action(page) -> BrowserSession:
|
|
53
|
+
_goto(page, url, time.monotonic() + TIMEOUT[headless])
|
|
54
|
+
return _export(page)
|
|
55
|
+
|
|
56
|
+
return _run(headless, proxy, action)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def login(
|
|
60
|
+
host: str, handle_or_email: str, password: str, *, headless: bool, proxy: str = ""
|
|
61
|
+
) -> BrowserSession:
|
|
62
|
+
"""Log in through the real /enter form; returns the logged-in session.
|
|
63
|
+
|
|
64
|
+
Raises LoginRejected when Codeforces itself refuses the credentials.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def action(page) -> BrowserSession:
|
|
68
|
+
deadline = time.monotonic() + TIMEOUT[headless]
|
|
69
|
+
handle_input = _login_form(page, f"{host}/enter", deadline)
|
|
70
|
+
password_input = _fill(page, handle_input, handle_or_email, password)
|
|
71
|
+
if pages.has_turnstile(_html(page)) and not _wait(
|
|
72
|
+
lambda: bool(_js(page, _JS_TURNSTILE_TOKEN)), deadline
|
|
73
|
+
):
|
|
74
|
+
raise CFError("the Turnstile check on the login form was not passed in time")
|
|
75
|
+
submit = page.ele("@value=Login", timeout=2) or page.ele(
|
|
76
|
+
'xpath://input[@type="submit"]', timeout=2
|
|
77
|
+
)
|
|
78
|
+
if submit:
|
|
79
|
+
submit.click()
|
|
80
|
+
else:
|
|
81
|
+
password_input.input("\n")
|
|
82
|
+
return _login_result(page, deadline)
|
|
83
|
+
|
|
84
|
+
return _run(headless, proxy, action)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_CHROME_FILES = {
|
|
88
|
+
"darwin": [
|
|
89
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
90
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
91
|
+
"~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
92
|
+
],
|
|
93
|
+
"win32": [
|
|
94
|
+
"%PROGRAMFILES%/Google/Chrome/Application/chrome.exe",
|
|
95
|
+
"%PROGRAMFILES(X86)%/Google/Chrome/Application/chrome.exe",
|
|
96
|
+
"%LOCALAPPDATA%/Google/Chrome/Application/chrome.exe",
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
_CHROME_COMMANDS = (
|
|
100
|
+
"google-chrome",
|
|
101
|
+
"google-chrome-stable",
|
|
102
|
+
"chromium",
|
|
103
|
+
"chromium-browser",
|
|
104
|
+
"chrome",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_chrome(
|
|
109
|
+
platform: str = sys.platform,
|
|
110
|
+
which: Callable[[str], "str | None"] = shutil.which,
|
|
111
|
+
is_file: Callable[[str], bool] = os.path.isfile,
|
|
112
|
+
) -> str | None:
|
|
113
|
+
"""Where Chrome or Chromium is installed (without starting it), or None."""
|
|
114
|
+
for candidate in _CHROME_FILES.get(platform, []):
|
|
115
|
+
where = os.path.expanduser(os.path.expandvars(candidate))
|
|
116
|
+
if is_file(where):
|
|
117
|
+
return where
|
|
118
|
+
return next((found for name in _CHROME_COMMANDS if (found := which(name))), None)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------ helpers
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _run(headless: bool, proxy: str, action: Callable[[Any], T]) -> T:
|
|
125
|
+
"""Launch Chrome, run action(page), always quit; anything DrissionPage
|
|
126
|
+
throws becomes a BrowserError (our own CFErrors pass through)."""
|
|
127
|
+
page = _launch(headless, proxy)
|
|
128
|
+
try:
|
|
129
|
+
return action(page)
|
|
130
|
+
except CFError:
|
|
131
|
+
raise
|
|
132
|
+
except Exception as e:
|
|
133
|
+
raise BrowserError(f"{type(e).__name__}: {_first_line(e)}") from e
|
|
134
|
+
finally:
|
|
135
|
+
_close(page)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _close(page) -> None:
|
|
139
|
+
"""Quit Chrome, then end whatever is left of it: a stuck browser can
|
|
140
|
+
ignore quit and stay open after cf."""
|
|
141
|
+
started = _process(page)
|
|
142
|
+
with contextlib.suppress(Exception):
|
|
143
|
+
page.quit()
|
|
144
|
+
if started is None:
|
|
145
|
+
return
|
|
146
|
+
import psutil
|
|
147
|
+
|
|
148
|
+
pid, created = started
|
|
149
|
+
try:
|
|
150
|
+
chrome = psutil.Process(pid)
|
|
151
|
+
if chrome.create_time() != created:
|
|
152
|
+
return # NOTE: gone, and its number taken by another program
|
|
153
|
+
family = [chrome, *chrome.children(recursive=True)]
|
|
154
|
+
except psutil.Error:
|
|
155
|
+
return
|
|
156
|
+
for process in family:
|
|
157
|
+
with contextlib.suppress(psutil.Error):
|
|
158
|
+
process.kill()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _process(page) -> tuple[int, float] | None:
|
|
162
|
+
"""The browser's process id and start time (which tells it from a later
|
|
163
|
+
program given the same id)."""
|
|
164
|
+
try:
|
|
165
|
+
import psutil
|
|
166
|
+
|
|
167
|
+
pid = page.process_id
|
|
168
|
+
return (pid, psutil.Process(pid).create_time()) if pid else None
|
|
169
|
+
except Exception:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _launch(headless: bool, proxy: str):
|
|
174
|
+
try:
|
|
175
|
+
from DrissionPage import ChromiumOptions, ChromiumPage
|
|
176
|
+
except ImportError as e:
|
|
177
|
+
raise BrowserUnavailable("DrissionPage is missing: reinstall cf-tool") from e
|
|
178
|
+
|
|
179
|
+
def options(user_agent: str = ""):
|
|
180
|
+
opts = ChromiumOptions()
|
|
181
|
+
opts.auto_port() # private temporary profile, free debugging port
|
|
182
|
+
for arg in (
|
|
183
|
+
"--no-first-run",
|
|
184
|
+
"--no-default-browser-check",
|
|
185
|
+
"--disable-infobars",
|
|
186
|
+
"--disable-blink-features=AutomationControlled",
|
|
187
|
+
):
|
|
188
|
+
opts.set_argument(arg)
|
|
189
|
+
if proxy:
|
|
190
|
+
# the raw flag: ChromiumOptions.set_proxy() prints its own warnings
|
|
191
|
+
opts.set_argument("--proxy-server", proxy)
|
|
192
|
+
if user_agent:
|
|
193
|
+
opts.set_user_agent(user_agent)
|
|
194
|
+
if headless:
|
|
195
|
+
opts.headless()
|
|
196
|
+
return opts
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
page = ChromiumPage(options())
|
|
200
|
+
except Exception as e:
|
|
201
|
+
raise BrowserUnavailable(
|
|
202
|
+
f"cannot start Chrome ({_first_line(e)}). Is Google Chrome or Chromium installed?"
|
|
203
|
+
) from e
|
|
204
|
+
if not headless:
|
|
205
|
+
return page
|
|
206
|
+
try:
|
|
207
|
+
user_agent = page.run_js(_JS_USER_AGENT) or ""
|
|
208
|
+
if "HeadlessChrome" not in user_agent:
|
|
209
|
+
return page
|
|
210
|
+
# NOTE: cf_clearance is bound to the UA: show a normal Chrome's from the start
|
|
211
|
+
_close(page)
|
|
212
|
+
page = None
|
|
213
|
+
return ChromiumPage(options(user_agent.replace("HeadlessChrome", "Chrome")))
|
|
214
|
+
except Exception as e:
|
|
215
|
+
if page is not None:
|
|
216
|
+
_close(page)
|
|
217
|
+
raise BrowserError(f"the headless browser failed to start ({_first_line(e)})") from e
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _first_line(e: Exception) -> str:
|
|
221
|
+
text = str(e).strip()
|
|
222
|
+
return text.splitlines()[0] if text else type(e).__name__
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _html(page) -> str:
|
|
226
|
+
try:
|
|
227
|
+
return page.html or ""
|
|
228
|
+
except Exception: # mid-navigation: the old document is gone
|
|
229
|
+
return ""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _js(page, script: str):
|
|
233
|
+
try:
|
|
234
|
+
return page.run_js(script)
|
|
235
|
+
except Exception:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _wait(ready: Callable[[], bool], deadline: float) -> bool:
|
|
240
|
+
while time.monotonic() < deadline:
|
|
241
|
+
if ready():
|
|
242
|
+
return True
|
|
243
|
+
time.sleep(_POLL)
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _bounced_home(requested: str, current: str) -> bool:
|
|
248
|
+
"""After solving a challenge Cloudflare sometimes redirects to the site
|
|
249
|
+
root instead of the page we asked for."""
|
|
250
|
+
req, cur = urlparse(requested), urlparse(current)
|
|
251
|
+
return cur.path in ("", "/") and req.path not in ("", "/")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _goto(page, url: str, deadline: float) -> None:
|
|
255
|
+
"""Navigate and wait until no check page is showing; retries once when
|
|
256
|
+
the solved challenge bounced us to the site root."""
|
|
257
|
+
for attempt in range(2):
|
|
258
|
+
page.get(url)
|
|
259
|
+
if not _wait(lambda: (html := _html(page)) != "" and not pages.is_gate(html), deadline):
|
|
260
|
+
raise CFError(f"the check was not passed in time: {_stuck(page)}")
|
|
261
|
+
if attempt or not _bounced_home(url, page.url):
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _login_form(page, enter_url: str, deadline: float):
|
|
266
|
+
"""Open /enter and wait for the form; returns the handle input.
|
|
267
|
+
|
|
268
|
+
Waits for the form itself rather than for "no gate": that works whatever
|
|
269
|
+
check Cloudflare or Codeforces puts in front of it this week.
|
|
270
|
+
"""
|
|
271
|
+
page.get(enter_url)
|
|
272
|
+
retried = False
|
|
273
|
+
while time.monotonic() < deadline:
|
|
274
|
+
try:
|
|
275
|
+
field = page.ele("#handleOrEmail", timeout=0)
|
|
276
|
+
if field:
|
|
277
|
+
return field
|
|
278
|
+
if not retried and _bounced_home(enter_url, page.url):
|
|
279
|
+
retried = True
|
|
280
|
+
page.get(enter_url)
|
|
281
|
+
continue
|
|
282
|
+
except Exception: # mid-navigation
|
|
283
|
+
pass
|
|
284
|
+
time.sleep(_POLL)
|
|
285
|
+
raise CFError(f"could not find the login form: {_stuck(page)}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _stuck(page) -> str:
|
|
289
|
+
"""Where the browser stopped, for an error message: "still on
|
|
290
|
+
Codeforces' browser check at /enter"."""
|
|
291
|
+
try:
|
|
292
|
+
path = urlparse(page.url).path or "/"
|
|
293
|
+
except Exception:
|
|
294
|
+
path = "?"
|
|
295
|
+
return f"still on {pages.describe_page(_html(page))} at {path}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _fill(page, handle_input, handle_or_email: str, password: str):
|
|
299
|
+
"""Type the credentials, tick "remember me"; returns the password input."""
|
|
300
|
+
handle_input.clear()
|
|
301
|
+
handle_input.input(handle_or_email)
|
|
302
|
+
password_input = page.ele("#password", timeout=5)
|
|
303
|
+
if not password_input:
|
|
304
|
+
raise CFError("could not find the password field")
|
|
305
|
+
password_input.clear()
|
|
306
|
+
password_input.input(password)
|
|
307
|
+
remember = page.ele("#remember", timeout=1)
|
|
308
|
+
if remember and not remember.attr("checked"):
|
|
309
|
+
remember.click()
|
|
310
|
+
return password_input
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _login_result(page, deadline: float) -> BrowserSession:
|
|
314
|
+
"""Wait for the outcome of submitting the login form."""
|
|
315
|
+
while time.monotonic() < deadline:
|
|
316
|
+
if pages.find_handle(_html(page)):
|
|
317
|
+
return _export(page)
|
|
318
|
+
errors = _js(page, _JS_FORM_ERRORS) or []
|
|
319
|
+
if errors:
|
|
320
|
+
message = str(errors[0])
|
|
321
|
+
if pages.is_captcha_error(message):
|
|
322
|
+
raise CFError(message)
|
|
323
|
+
raise LoginRejected(message)
|
|
324
|
+
time.sleep(_POLL)
|
|
325
|
+
raise CFError("the login did not complete in time")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _export(page) -> BrowserSession:
|
|
329
|
+
cookies = []
|
|
330
|
+
for c in page.cookies(all_info=True):
|
|
331
|
+
expires = c.get("expires")
|
|
332
|
+
cookies.append(
|
|
333
|
+
{
|
|
334
|
+
"name": c["name"],
|
|
335
|
+
"value": c["value"],
|
|
336
|
+
"domain": c.get("domain") or urlparse(page.url).hostname or "",
|
|
337
|
+
"path": c.get("path") or "/",
|
|
338
|
+
"secure": bool(c.get("secure")),
|
|
339
|
+
# CDP: seconds since epoch, -1 for a session cookie
|
|
340
|
+
"expires": (
|
|
341
|
+
int(expires) if isinstance(expires, (int, float)) and expires > 0 else None
|
|
342
|
+
),
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
user_agent = _js(page, _JS_USER_AGENT) or ""
|
|
346
|
+
return BrowserSession(cookies=cookies, user_agent=user_agent, html=_html(page))
|