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