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,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__
|