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.
@@ -0,0 +1,288 @@
1
+ """The way in to Codeforces: plain HTTP first, a browser only when Cloudflare
2
+ insists, and the tiered login.
3
+
4
+ Login tiers (first that works wins):
5
+ 1. HTTP — GET and POST /enter over curl_cffi. Needs no browser, but a check
6
+ stands in front of /enter (Cloudflare's, then Codeforces' own), so this
7
+ rarely gets through any more.
8
+ 2. headless browser — passes the checks without showing anything; a second,
9
+ fresh one when the first gets stuck.
10
+ 3. browser window — last resort, for when a human has to tick a box.
11
+ A credentials error (LoginRejected) stops at once, and so does a missing
12
+ Chrome (BrowserUnavailable): another tier would only fail the same way.
13
+
14
+ Pages are fetched the same way round: HTTP, and only on a challenge a
15
+ headless browser, then a window. Browser work, logins and session saves share
16
+ one lock; a login or a check passed once is reused, and a failure is not
17
+ retried within the run.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import random
23
+ import threading
24
+ from typing import Callable, TypeVar
25
+ from urllib.parse import urlparse
26
+
27
+ from cf_tool import ui
28
+ from cf_tool.codeforces import browser, crypto, pages
29
+ from cf_tool.codeforces.http import Transport, is_cloudflare_cookie
30
+ from cf_tool.codeforces.session import Session
31
+ from cf_tool.errors import (
32
+ BrowserUnavailable,
33
+ CFError,
34
+ CloudflareChallengeError,
35
+ LoginRejected,
36
+ )
37
+
38
+ T = TypeVar("T")
39
+
40
+ BFAA = "f1b3f18c715565b589b7823cda7448ce"
41
+ LOGIN_COOKIES = ("X-User-Sha1", "X-User") # NOTE: set only for a logged-in session
42
+
43
+
44
+ def _token(n: int) -> str:
45
+ return "".join(random.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(n))
46
+
47
+
48
+ class Gateway:
49
+ def __init__(self, host: str, proxy: str, session: Session) -> None:
50
+ self.host = host
51
+ self.proxy = proxy
52
+ self.session = session
53
+ self.http = Transport(proxy=proxy, user_agent=session.user_agent, cookies=session.cookies)
54
+ self._lock = threading.RLock()
55
+ # bumped by each successful login / challenge solve, so a thread that
56
+ # waited on the lock can tell the work was done meanwhile
57
+ self._login_count = 0
58
+ self._solve_count = 0
59
+ # a failure is remembered: other threads must not retry (and pop
60
+ # more windows); a fresh `cf` run tries again
61
+ self._login_error: CFError | None = None
62
+ self._solve_error: CFError | None = None
63
+
64
+ def save(self) -> None:
65
+ with self._lock:
66
+ self.session.cookies = self.http.export_cookies()
67
+ self.session.user_agent = self.http.user_agent
68
+ self.session.save()
69
+
70
+ # ------------------------------------------------------------- fetching
71
+
72
+ def get(self, url: str, *, browser_fallback: bool = True) -> str:
73
+ """Body of url. A Cloudflare check is passed in a browser (headless,
74
+ then a window), unless browser_fallback is False."""
75
+ seen = self._solve_count
76
+ try:
77
+ return self.http.get(url).text
78
+ except CloudflareChallengeError:
79
+ if not browser_fallback:
80
+ raise
81
+ solved = self._solve(url, seen)
82
+ try:
83
+ return self.http.get(url).text
84
+ except CloudflareChallengeError:
85
+ # the transport is still refused, but the browser saw the content
86
+ if solved is not None and not pages.is_gate(solved.html):
87
+ return solved.html
88
+ raise
89
+
90
+ def get_authed(self, url: str) -> str:
91
+ """Body of a page that needs login; logs in again once if the session lapsed."""
92
+ seen = self._login_count
93
+ body = self.get(url)
94
+ if pages.find_handle(body) is not None:
95
+ return body
96
+ self._relogin(seen)
97
+ body = self.get(url)
98
+ pages.require_handle(body)
99
+ return body
100
+
101
+ def post(self, url: str, data: dict[str, str], headers: dict[str, str] | None = None) -> str:
102
+ """POST a form. A challenged POST never reached Codeforces, so after
103
+ passing the check in a browser it is safe to send it once more."""
104
+ seen = self._solve_count
105
+ extra = {"headers": headers} if headers else {}
106
+ try:
107
+ return self.http.post(url, data=data, **extra).text
108
+ except CloudflareChallengeError:
109
+ self._solve(url, seen)
110
+ return self.http.post(url, data=data, **extra).text
111
+
112
+ def get_json(self, url: str) -> dict:
113
+ return self.http.get_json(url)
114
+
115
+ def _solve(self, url: str, seen: int) -> browser.BrowserSession | None:
116
+ """Pass the check on url in a browser and adopt its cookies; None if
117
+ another thread already did so while this one waited."""
118
+ with self._lock:
119
+ if self._solve_count != seen:
120
+ return None
121
+ if self._solve_error is not None:
122
+ raise self._solve_error
123
+ try:
124
+ solved = self._in_browser(
125
+ "Cloudflare check",
126
+ lambda headless: browser.fetch(url, headless=headless, proxy=self.proxy),
127
+ )
128
+ except CFError as e:
129
+ self._solve_error = e
130
+ raise
131
+ # NOTE: merged, not replaced; and once logged in, only Cloudflare's
132
+ # \ cookies: the fresh browser profile's own session is anonymous
133
+ logged_in = any(self.http.has_cookie(name) for name in LOGIN_COOKIES)
134
+ self._adopt(solved, replace=False, only_cloudflare=logged_in)
135
+ self._solve_count += 1
136
+ return solved
137
+
138
+ def _in_browser(self, what: str, run: Callable[[bool], T]) -> T:
139
+ """run(headless=True), then once more in a fresh background browser,
140
+ and only then run(headless=False) in a window."""
141
+ for attempt in range(2):
142
+ try:
143
+ with ui.status(f"{what}: working in a background browser..."):
144
+ return run(True)
145
+ except (LoginRejected, BrowserUnavailable):
146
+ raise
147
+ except CFError as e:
148
+ if attempt:
149
+ ui.yellow(f"The background browser did not make it ({e}).")
150
+ else:
151
+ # NOTE: a stuck one usually goes through on a second try
152
+ ui.cyan(f"The background browser got stuck ({e}); starting a fresh one")
153
+ ui.yellow(
154
+ f"{what}: opening a browser window. If Cloudflare shows a check there, "
155
+ "complete it; everything else is filled in for you."
156
+ )
157
+ with ui.status(f"{what}: waiting for the browser window..."):
158
+ return run(False)
159
+
160
+ def _adopt(
161
+ self, solved: browser.BrowserSession, replace: bool, only_cloudflare: bool = False
162
+ ) -> None:
163
+ with self._lock:
164
+ if replace:
165
+ self.http.clear_cookies()
166
+ cookies = solved.cookies
167
+ if only_cloudflare:
168
+ cookies = [c for c in cookies if is_cloudflare_cookie(c["name"])]
169
+ self.http.adopt_cookies(cookies)
170
+ self.http.set_user_agent(solved.user_agent)
171
+ self.save()
172
+
173
+ # ---------------------------------------------------------------- login
174
+
175
+ def form_tokens(self) -> tuple[str, str]:
176
+ """(ftaa, bfaa) that Codeforces forms expect, created on first use."""
177
+ with self._lock:
178
+ self.session.ftaa = self.session.ftaa or _token(18)
179
+ self.session.bfaa = self.session.bfaa or BFAA
180
+ return self.session.ftaa, self.session.bfaa
181
+
182
+ def set_credentials(self, handle_or_email: str, password: str) -> None:
183
+ """Store the credentials (password encrypted) for later logins."""
184
+ with self._lock:
185
+ self.session.handle_or_email = handle_or_email
186
+ self.session.password = crypto.encrypt_password(handle_or_email, password)
187
+ self._login_error = None
188
+ self.save()
189
+
190
+ def login(self) -> str:
191
+ """Log in with the stored credentials, tier by tier; returns the handle."""
192
+ with self._lock:
193
+ session = self.session
194
+ if not session.handle_or_email or not session.password:
195
+ raise CFError("Not logged in: run cf login")
196
+ password = crypto.decrypt_password(session.handle_or_email, session.password)
197
+ ui.cyan(f"Logging in as {session.handle_or_email}...")
198
+ try:
199
+ with ui.status("Logging in over HTTP..."):
200
+ handle = self._login_http(password)
201
+ except LoginRejected:
202
+ raise
203
+ except CFError as e:
204
+ # NOTE: a check in front of the login page is the usual reason, and
205
+ # \ the browser gets past it: no warning for that
206
+ if not isinstance(e, CloudflareChallengeError):
207
+ ui.yellow(f"HTTP login is not possible right now ({e}); using a browser.")
208
+ handle = self._in_browser(
209
+ "Login", lambda headless: self._login_browser(password, headless)
210
+ )
211
+ session.handle = handle
212
+ self.form_tokens()
213
+ self._login_count += 1
214
+ self.save()
215
+ ui.green(f"Logged in as {handle}")
216
+ return handle
217
+
218
+ def _relogin(self, seen: int) -> None:
219
+ with self._lock:
220
+ if self._login_count != seen:
221
+ return # another thread logged in while this one waited
222
+ if self._login_error is not None:
223
+ raise self._login_error
224
+ ui.cyan("The saved login has expired")
225
+ try:
226
+ self.login()
227
+ except CFError as e:
228
+ self._login_error = e
229
+ raise
230
+
231
+ def _login_http(self, password: str) -> str:
232
+ session = self.session
233
+ # NOTE: a fresh login, but Cloudflare's cookies stay: without a valid
234
+ # \ cf_clearance /enter is challenged
235
+ self.http.clear_cookies(keep=is_cloudflare_cookie)
236
+ body = self.http.get(f"{self.host}/enter").text
237
+ if pages.has_turnstile(body) or pages.is_gate(body):
238
+ raise CloudflareChallengeError("the login page wants a browser check")
239
+ csrf = pages.find_csrf(body)
240
+ ftaa = _token(18)
241
+ body = self.http.post(
242
+ f"{self.host}/enter",
243
+ data={
244
+ "csrf_token": csrf,
245
+ "action": "enter",
246
+ "ftaa": ftaa,
247
+ "bfaa": BFAA,
248
+ "handleOrEmail": session.handle_or_email,
249
+ "password": password,
250
+ "_tta": "176",
251
+ "remember": "on",
252
+ },
253
+ ).text
254
+ handle = pages.find_handle(body)
255
+ if handle:
256
+ session.ftaa, session.bfaa = ftaa, BFAA
257
+ return handle
258
+ error = pages.find_error_message(body)
259
+ if error and not pages.is_captcha_error(error):
260
+ raise LoginRejected(error)
261
+ raise CFError(error or "Codeforces did not accept the login")
262
+
263
+ def _login_browser(self, password: str, headless: bool) -> str:
264
+ solved = browser.login(
265
+ self.host,
266
+ self.session.handle_or_email,
267
+ password,
268
+ headless=headless,
269
+ proxy=self.proxy,
270
+ )
271
+ self._adopt(solved, replace=True)
272
+ try:
273
+ handle = pages.find_handle(self.http.get(self.host).text)
274
+ except CloudflareChallengeError:
275
+ handle = None
276
+ if handle:
277
+ return handle
278
+ if pages.find_handle(solved.html):
279
+ raise CFError(
280
+ "logged in inside the browser, but Codeforces refused the "
281
+ "exported cookies over HTTP"
282
+ )
283
+ raise CFError("the browser session is not logged in")
284
+
285
+
286
+ def host_domain(host: str) -> str:
287
+ """Hostname of the configured host URL, e.g. "codeforces.com"."""
288
+ return urlparse(host).hostname or ""
@@ -0,0 +1,213 @@
1
+ """HTTP transport: curl_cffi with Chrome TLS impersonation.
2
+
3
+ A Cloudflare interstitial (or Codeforces' own Turnstile gate) is raised as
4
+ CloudflareChallengeError; gateway.py then decides whether a browser may help.
5
+ A server error (5xx) or "too many requests" (429) is never content: a GET is
6
+ tried again a little later, a POST never is (it may have arrived).
7
+
8
+ Requests run one at a time: curl_cffi copies the whole cookie jar into each
9
+ request and writes it all back afterwards, so overlapping requests would put
10
+ stale cookies back (a fresh login undone).
11
+
12
+ Cookies travel as plain dicts so they can be persisted and handed over from
13
+ the browser without losing their domain/path/expiry:
14
+ {"name", "value", "domain", "path", "secure", "expires"} # expires: epoch or None
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ import time
21
+ from http.cookiejar import Cookie
22
+ from typing import Any, Callable
23
+ from urllib.parse import urlparse
24
+
25
+ from curl_cffi import requests as curl_requests
26
+
27
+ from cf_tool.codeforces import pages
28
+ from cf_tool.errors import CFError, CloudflareChallengeError
29
+
30
+ IMPERSONATE = "chrome"
31
+ TIMEOUT = 30
32
+ RETRIES = 3
33
+ BACKOFF = 2.0 # seconds before the first retry, doubled for each next one
34
+
35
+ CookieDict = dict[str, Any]
36
+
37
+
38
+ class ServerError(CFError):
39
+ """Codeforces answered with an error page (5xx or 429), not content."""
40
+
41
+ def __init__(self, status: int) -> None:
42
+ reason = "too many requests" if status == 429 else "a server error"
43
+ super().__init__(f"Codeforces is answering with {reason} ({status}); try again soon")
44
+ self.status = status
45
+
46
+
47
+ class Uncertain(CFError):
48
+ """A POST got no answer: it may or may not have reached Codeforces."""
49
+
50
+
51
+ def is_challenge(response) -> bool:
52
+ """True when the response is a check page instead of the content."""
53
+ if response.headers.get("cf-mitigated") == "challenge":
54
+ return True
55
+ text = response.text
56
+ if response.status_code in (403, 503) and pages.is_cloudflare_challenge(text):
57
+ return True
58
+ # Codeforces' own Turnstile gate and browser check are served with 200
59
+ return pages.is_verification_gate(text) or pages.is_browser_check(text)
60
+
61
+
62
+ def is_cloudflare_cookie(name: str) -> bool:
63
+ """cf_clearance, __cf_bm, _cfuvid, ...: Cloudflare's, not Codeforces' login."""
64
+ return name.startswith(("cf_", "__cf", "_cf"))
65
+
66
+
67
+ def _to_jar_cookie(c: CookieDict) -> Cookie:
68
+ domain = c["domain"]
69
+ return Cookie(
70
+ version=0,
71
+ name=c["name"],
72
+ value=c["value"],
73
+ port=None,
74
+ port_specified=False,
75
+ domain=domain,
76
+ domain_specified=domain.startswith("."),
77
+ domain_initial_dot=domain.startswith("."),
78
+ path=c.get("path") or "/",
79
+ path_specified=True,
80
+ secure=bool(c.get("secure")),
81
+ expires=c.get("expires"),
82
+ discard=c.get("expires") is None,
83
+ comment=None,
84
+ comment_url=None,
85
+ rest={},
86
+ )
87
+
88
+
89
+ class Transport:
90
+ def __init__(
91
+ self,
92
+ proxy: str = "",
93
+ user_agent: str = "",
94
+ cookies: list[CookieDict] | None = None,
95
+ ) -> None:
96
+ self._session = curl_requests.Session(impersonate=IMPERSONATE, timeout=TIMEOUT)
97
+ if proxy:
98
+ self._session.proxies = {"http": proxy, "https": proxy}
99
+ self._lock = threading.RLock() # NOTE: one request (or cookie change) at a time
100
+ self.user_agent = ""
101
+ self.set_user_agent(user_agent)
102
+ self.load_cookies(cookies or [])
103
+
104
+ # -------------------------------------------------------------- cookies
105
+
106
+ def export_cookies(self) -> list[CookieDict]:
107
+ """Every cookie in the jar, in the persistable dict form."""
108
+ with self._lock:
109
+ return [
110
+ {
111
+ "name": c.name,
112
+ "value": c.value or "",
113
+ "domain": c.domain,
114
+ "path": c.path,
115
+ "secure": c.secure,
116
+ "expires": c.expires,
117
+ }
118
+ for c in list(self._session.cookies.jar)
119
+ ]
120
+
121
+ def load_cookies(self, cookies: list[CookieDict]) -> None:
122
+ """Add cookies to the jar; already-expired ones are dropped."""
123
+ now = time.time()
124
+ with self._lock:
125
+ for c in cookies:
126
+ if c.get("expires") is not None and c["expires"] <= now:
127
+ continue
128
+ self._session.cookies.jar.set_cookie(_to_jar_cookie(c))
129
+
130
+ def adopt_cookies(self, cookies: list[CookieDict]) -> None:
131
+ """Merge cookies from elsewhere (the browser): each name replaces every
132
+ cookie of that name, whatever domain it was filed under."""
133
+ with self._lock:
134
+ for name in {c["name"] for c in cookies}:
135
+ self._session.cookies.delete(name)
136
+ self.load_cookies(cookies)
137
+
138
+ def clear_cookies(self, keep: Callable[[str], bool] | None = None) -> None:
139
+ """Drop cookies, except those whose name satisfies keep."""
140
+ with self._lock:
141
+ jar = self._session.cookies.jar
142
+ for c in list(jar):
143
+ if keep is None or not keep(c.name):
144
+ jar.clear(c.domain, c.path, c.name)
145
+
146
+ def has_cookie(self, name: str) -> bool:
147
+ with self._lock:
148
+ return any(c.name == name for c in list(self._session.cookies.jar))
149
+
150
+ def set_user_agent(self, user_agent: str) -> None:
151
+ if user_agent:
152
+ with self._lock:
153
+ self.user_agent = user_agent
154
+ self._session.headers["User-Agent"] = user_agent
155
+
156
+ # ------------------------------------------------------------- requests
157
+
158
+ def _send(self, method: str, url: str, **kwargs):
159
+ with self._lock:
160
+ response = self._session.request(
161
+ method, url, allow_redirects=True, **kwargs # type: ignore[arg-type]
162
+ )
163
+ if is_challenge(response):
164
+ challenge = CloudflareChallengeError()
165
+ # NOTE: checked on a page redirected to: the request itself got through
166
+ final = getattr(response, "url", "") or url
167
+ moved = urlparse(final).path != urlparse(url).path
168
+ challenge.redirected = moved # type: ignore[attr-defined]
169
+ raise challenge
170
+ if response.status_code >= 500 or response.status_code == 429:
171
+ raise ServerError(response.status_code)
172
+ return response
173
+
174
+ def get(self, url: str, **kwargs):
175
+ """GET url, tried again (after a growing pause) when the connection
176
+ fails or Codeforces answers with a server error."""
177
+ last_error: Exception | None = None
178
+ for attempt in range(RETRIES):
179
+ if attempt:
180
+ time.sleep(BACKOFF * 2 ** (attempt - 1))
181
+ try:
182
+ return self._send("GET", url, **kwargs)
183
+ except (curl_requests.exceptions.RequestException, ServerError) as e:
184
+ last_error = e
185
+ if isinstance(last_error, ServerError):
186
+ raise last_error
187
+ raise CFError(f"Cannot reach Codeforces ({_first_line(last_error)})")
188
+
189
+ def post(self, url: str, data: dict[str, str] | None = None, **kwargs):
190
+ """POST a form, once: raises Uncertain when the answer was lost, since
191
+ the form may have arrived (a submission must not be sent twice)."""
192
+ try:
193
+ return self._send("POST", url, data=data, **kwargs)
194
+ except curl_requests.exceptions.RequestException as e:
195
+ raise Uncertain(f"No answer from Codeforces ({_first_line(e)})") from e
196
+ except ServerError as e:
197
+ raise Uncertain(str(e)) from e
198
+ except CloudflareChallengeError as e:
199
+ if getattr(e, "redirected", False):
200
+ raise Uncertain("Cloudflare checked the page shown after the form") from e
201
+ raise # NOTE: stopped before Codeforces: safe to send again once passed
202
+
203
+ def get_json(self, url: str) -> dict[str, Any]:
204
+ response = self.get(url)
205
+ try:
206
+ return response.json()
207
+ except Exception as e:
208
+ raise CFError(f"Invalid JSON from {url}: {e}") from e
209
+
210
+
211
+ def _first_line(error: Exception | None) -> str:
212
+ text = str(error or "").strip()
213
+ return text.splitlines()[0] if text else type(error).__name__