dataapp_crawler 0.1.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.
- dataapp_crawler/__init__.py +87 -0
- dataapp_crawler/__main__.py +35 -0
- dataapp_crawler/_logging.py +148 -0
- dataapp_crawler/_network_diagnostics.py +193 -0
- dataapp_crawler/browser_tools/__init__.py +23 -0
- dataapp_crawler/browser_tools/_common.py +66 -0
- dataapp_crawler/browser_tools/dom.py +49 -0
- dataapp_crawler/browser_tools/lists.py +125 -0
- dataapp_crawler/browser_tools/navigation.py +243 -0
- dataapp_crawler/browser_tools/readiness.py +42 -0
- dataapp_crawler/captcha.py +912 -0
- dataapp_crawler/checkpoint.py +487 -0
- dataapp_crawler/cloudflare.py +523 -0
- dataapp_crawler/context.py +231 -0
- dataapp_crawler/cookies.py +207 -0
- dataapp_crawler/crawler.py +1392 -0
- dataapp_crawler/errors.py +184 -0
- dataapp_crawler/http.py +223 -0
- dataapp_crawler/images.py +267 -0
- dataapp_crawler/local_auth.py +77 -0
- dataapp_crawler/models.py +285 -0
- dataapp_crawler/pagination.py +172 -0
- dataapp_crawler/parsing.py +13 -0
- dataapp_crawler/providers/__init__.py +20 -0
- dataapp_crawler/providers/browser.py +292 -0
- dataapp_crawler/providers/camoufox.py +184 -0
- dataapp_crawler/providers/dataos.py +174 -0
- dataapp_crawler/providers/pool.py +451 -0
- dataapp_crawler/providers/proxy.py +209 -0
- dataapp_crawler/py.typed +0 -0
- dataapp_crawler/routing.py +67 -0
- dataapp_crawler/scheduling.py +425 -0
- dataapp_crawler/sessions.py +735 -0
- dataapp_crawler-0.1.0.dist-info/METADATA +211 -0
- dataapp_crawler-0.1.0.dist-info/RECORD +36 -0
- dataapp_crawler-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""Explicit Cloudflare Challenge parameter capture for an existing browser Page.
|
|
2
|
+
|
|
3
|
+
This module never navigates, refreshes, contacts a solver, or claims that a
|
|
4
|
+
target website accepted a solution. Install the capture before navigation,
|
|
5
|
+
send the returned task request through the managed resource client, then apply
|
|
6
|
+
the returned token to the exact same page and challenge generation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import math
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
17
|
+
|
|
18
|
+
from .errors import ConfigurationError, FetchError
|
|
19
|
+
|
|
20
|
+
_STATE = "__dataappCloudflareChallengeCapture"
|
|
21
|
+
_MAX_CAPTURE_AGE_SECONDS = 180.0
|
|
22
|
+
|
|
23
|
+
_INSTALL_SCRIPT = f"""
|
|
24
|
+
(() => {{
|
|
25
|
+
if (window.top !== window || window[{_STATE!r}]?.version === 1) return;
|
|
26
|
+
const state = {{
|
|
27
|
+
version: 1,
|
|
28
|
+
documentId: `${{Date.now()}}-${{Math.random().toString(36).slice(2)}}`,
|
|
29
|
+
generation: 0,
|
|
30
|
+
challenges: [],
|
|
31
|
+
invalidCallbackObserved: false,
|
|
32
|
+
wrappedObjects: new WeakMap()
|
|
33
|
+
}};
|
|
34
|
+
Object.defineProperty(window, {_STATE!r}, {{
|
|
35
|
+
value: state, configurable: false, enumerable: false, writable: false
|
|
36
|
+
}});
|
|
37
|
+
|
|
38
|
+
const trustedApiUrl = () => {{
|
|
39
|
+
const values = [];
|
|
40
|
+
for (const script of Array.from(document.scripts || [])) values.push(script.src || "");
|
|
41
|
+
try {{
|
|
42
|
+
for (const entry of performance.getEntriesByType("resource") || []) {{
|
|
43
|
+
values.push(entry.name || "");
|
|
44
|
+
}}
|
|
45
|
+
}} catch (_) {{}}
|
|
46
|
+
return values.find(value => {{
|
|
47
|
+
try {{
|
|
48
|
+
const url = new URL(value, location.href);
|
|
49
|
+
return url.protocol === "https:" && url.hostname === "challenges.cloudflare.com"
|
|
50
|
+
&& url.pathname.startsWith("/turnstile/")
|
|
51
|
+
&& url.username === "" && url.password === "" && url.hash === "";
|
|
52
|
+
}} catch (_) {{ return false; }}
|
|
53
|
+
}}) || null;
|
|
54
|
+
}};
|
|
55
|
+
|
|
56
|
+
const invalidate = (items, widgetId) => {{
|
|
57
|
+
for (const item of items) {{
|
|
58
|
+
if (widgetId === undefined || widgetId === null || item.widgetId === widgetId) {{
|
|
59
|
+
item.active = false;
|
|
60
|
+
}}
|
|
61
|
+
}}
|
|
62
|
+
}};
|
|
63
|
+
|
|
64
|
+
const wrap = value => {{
|
|
65
|
+
if (!value || (typeof value !== "object" && typeof value !== "function")) return value;
|
|
66
|
+
if (state.wrappedObjects.has(value)) return state.wrappedObjects.get(value);
|
|
67
|
+
const proxy = new Proxy(value, {{
|
|
68
|
+
get(target, property, receiver) {{
|
|
69
|
+
const original = Reflect.get(target, property, receiver);
|
|
70
|
+
if (property === "render" && typeof original === "function") {{
|
|
71
|
+
return function(element, params, ...rest) {{
|
|
72
|
+
const challengeLike = params && typeof params === "object"
|
|
73
|
+
&& typeof params.sitekey === "string" && params.sitekey.length > 0
|
|
74
|
+
&& typeof params.action === "string" && params.action.length > 0
|
|
75
|
+
&& typeof params.cData === "string" && params.cData.length > 0
|
|
76
|
+
&& typeof params.chlPageData === "string" && params.chlPageData.length > 0;
|
|
77
|
+
if (!challengeLike) return Reflect.apply(original, target, [element, params, ...rest]);
|
|
78
|
+
if (typeof params.callback !== "function") {{
|
|
79
|
+
state.invalidCallbackObserved = true;
|
|
80
|
+
return Reflect.apply(original, target, [element, params, ...rest]);
|
|
81
|
+
}}
|
|
82
|
+
const generation = ++state.generation;
|
|
83
|
+
const captureId = `${{state.documentId}}-${{generation}}`;
|
|
84
|
+
const item = {{
|
|
85
|
+
captureId, generation, capturedAtMs: Date.now(), websiteUrl: location.href,
|
|
86
|
+
websiteKey: params.sitekey, action: params.action, data: params.cData,
|
|
87
|
+
pageData: params.chlPageData, userAgent: navigator.userAgent,
|
|
88
|
+
apiJsUrl: trustedApiUrl(), callback: params.callback,
|
|
89
|
+
callbackInvoked: false, active: true, widgetId: null
|
|
90
|
+
}};
|
|
91
|
+
const forwarded = Object.assign({{}}, params, {{
|
|
92
|
+
callback: function(token, ...callbackRest) {{
|
|
93
|
+
item.callbackInvoked = true;
|
|
94
|
+
return Reflect.apply(item.callback, this, [token, ...callbackRest]);
|
|
95
|
+
}}
|
|
96
|
+
}});
|
|
97
|
+
const widgetId = Reflect.apply(original, target, [element, forwarded, ...rest]);
|
|
98
|
+
item.widgetId = widgetId;
|
|
99
|
+
state.challenges.push(item);
|
|
100
|
+
return widgetId;
|
|
101
|
+
}};
|
|
102
|
+
}}
|
|
103
|
+
if ((property === "reset" || property === "remove") && typeof original === "function") {{
|
|
104
|
+
return function(widgetId, ...rest) {{
|
|
105
|
+
const existing = Array.from(state.challenges);
|
|
106
|
+
const result = Reflect.apply(original, target, [widgetId, ...rest]);
|
|
107
|
+
invalidate(existing, widgetId);
|
|
108
|
+
return result;
|
|
109
|
+
}};
|
|
110
|
+
}}
|
|
111
|
+
return original;
|
|
112
|
+
}}
|
|
113
|
+
}});
|
|
114
|
+
state.wrappedObjects.set(value, proxy);
|
|
115
|
+
state.wrappedObjects.set(proxy, proxy);
|
|
116
|
+
return proxy;
|
|
117
|
+
}};
|
|
118
|
+
|
|
119
|
+
const attach = () => {{
|
|
120
|
+
let current = window.turnstile;
|
|
121
|
+
if (!current || typeof current.render !== "function") return;
|
|
122
|
+
current = wrap(current);
|
|
123
|
+
try {{
|
|
124
|
+
Object.defineProperty(window, "turnstile", {{
|
|
125
|
+
configurable: true,
|
|
126
|
+
enumerable: true,
|
|
127
|
+
get: () => current,
|
|
128
|
+
set: value => {{ current = wrap(value); }}
|
|
129
|
+
}});
|
|
130
|
+
}} catch (_) {{
|
|
131
|
+
try {{ window.turnstile = current; }} catch (_) {{}}
|
|
132
|
+
}}
|
|
133
|
+
}};
|
|
134
|
+
// 先创建 undefined 属性会触发供应商的 "turnstile" in window 重复加载判定。
|
|
135
|
+
// 外链脚本 load 在其排队的 onload 回调前触发,此时真实 API 已完成导出。
|
|
136
|
+
document.addEventListener("load", event => {{
|
|
137
|
+
if (event.target?.tagName === "SCRIPT") attach();
|
|
138
|
+
}}, true);
|
|
139
|
+
attach();
|
|
140
|
+
}})();
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
_READ_SCRIPT = f"""
|
|
144
|
+
() => {{
|
|
145
|
+
const state = window[{_STATE!r}];
|
|
146
|
+
if (!state || state.version !== 1) return {{status: "not_installed"}};
|
|
147
|
+
if (state.invalidCallbackObserved) return {{status: "invalid_callback"}};
|
|
148
|
+
const active = state.challenges.filter(item => item.active && !item.callbackInvoked);
|
|
149
|
+
if (active.length > 1) return {{status: "ambiguous"}};
|
|
150
|
+
if (active.length === 0) return {{status: "waiting"}};
|
|
151
|
+
const item = active[0];
|
|
152
|
+
if (Date.now() - item.capturedAtMs > {int(_MAX_CAPTURE_AGE_SECONDS * 1000)}) {{
|
|
153
|
+
item.active = false;
|
|
154
|
+
return {{status: "expired"}};
|
|
155
|
+
}}
|
|
156
|
+
return {{
|
|
157
|
+
status: "ready", document_id: state.documentId, capture_id: item.captureId,
|
|
158
|
+
generation: item.generation, captured_at_ms: item.capturedAtMs,
|
|
159
|
+
website_url: item.websiteUrl, website_key: item.websiteKey, action: item.action,
|
|
160
|
+
data: item.data, page_data: item.pageData, user_agent: item.userAgent,
|
|
161
|
+
api_js_url: item.apiJsUrl
|
|
162
|
+
}};
|
|
163
|
+
}}
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
_APPLY_SCRIPT = f"""
|
|
167
|
+
input => {{
|
|
168
|
+
const state = window[{_STATE!r}];
|
|
169
|
+
if (!state || state.version !== 1 || state.documentId !== input.document_id)
|
|
170
|
+
return {{applied: false, reason: "document_changed"}};
|
|
171
|
+
const active = state.challenges.filter(item => item.active && !item.callbackInvoked);
|
|
172
|
+
if (active.length !== 1) return {{applied: false, reason: active.length ? "ambiguous" : "stale"}};
|
|
173
|
+
const item = active[0];
|
|
174
|
+
if (item.captureId !== input.capture_id || item.generation !== input.generation)
|
|
175
|
+
return {{applied: false, reason: "challenge_changed"}};
|
|
176
|
+
if (Date.now() - item.capturedAtMs > input.max_age_ms)
|
|
177
|
+
return {{applied: false, reason: "expired"}};
|
|
178
|
+
if (navigator.userAgent !== input.user_agent || item.userAgent !== input.user_agent)
|
|
179
|
+
return {{applied: false, reason: "user_agent_changed"}};
|
|
180
|
+
if (typeof item.callback !== "function")
|
|
181
|
+
return {{applied: false, reason: "callback_unavailable"}};
|
|
182
|
+
item.callbackInvoked = true;
|
|
183
|
+
Reflect.apply(item.callback, window, [input.token]);
|
|
184
|
+
return {{applied: true}};
|
|
185
|
+
}}
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _Page(Protocol):
|
|
190
|
+
async def add_init_script(self, script: str) -> Any: ...
|
|
191
|
+
|
|
192
|
+
async def evaluate(self, expression: str, arg: Any = None) -> Any: ...
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True, slots=True)
|
|
196
|
+
class CloudflareChallengeParameters:
|
|
197
|
+
"""Current full-page Challenge inputs; representations hide all page values."""
|
|
198
|
+
|
|
199
|
+
website_url: str = field(repr=False)
|
|
200
|
+
website_key: str = field(repr=False)
|
|
201
|
+
action: str = field(repr=False)
|
|
202
|
+
data: str = field(repr=False)
|
|
203
|
+
page_data: str = field(repr=False)
|
|
204
|
+
user_agent: str = field(repr=False)
|
|
205
|
+
api_js_url: str | None = field(default=None, repr=False)
|
|
206
|
+
_document_id: str = field(default="", repr=False)
|
|
207
|
+
_capture_id: str = field(default="", repr=False)
|
|
208
|
+
_generation: int = field(default=0, repr=False)
|
|
209
|
+
_captured_at_ms: int = field(default=0, repr=False)
|
|
210
|
+
|
|
211
|
+
def as_task_request(self) -> dict[str, str]:
|
|
212
|
+
"""Return the managed token-mode request without internal page binding fields."""
|
|
213
|
+
|
|
214
|
+
request = {
|
|
215
|
+
"type": "cloudflare_challenge",
|
|
216
|
+
"website_url": self.website_url,
|
|
217
|
+
"website_key": self.website_key,
|
|
218
|
+
"action": self.action,
|
|
219
|
+
"data": self.data,
|
|
220
|
+
"page_data": self.page_data,
|
|
221
|
+
"user_agent": self.user_agent,
|
|
222
|
+
}
|
|
223
|
+
if self.api_js_url is not None:
|
|
224
|
+
request["api_js_url"] = self.api_js_url
|
|
225
|
+
return request
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class CloudflareChallengeCapture:
|
|
229
|
+
"""Capture and apply one dynamic full-page Cloudflare Challenge generation."""
|
|
230
|
+
|
|
231
|
+
def __init__(self, page: _Page, timeout: float) -> None:
|
|
232
|
+
self._page = page
|
|
233
|
+
self._timeout = timeout
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
async def install(cls, page: _Page, timeout: float = 5.0) -> CloudflareChallengeCapture:
|
|
237
|
+
"""Install before navigation; no navigation or solver call is performed."""
|
|
238
|
+
|
|
239
|
+
checked = _timeout(timeout, maximum=30.0)
|
|
240
|
+
try:
|
|
241
|
+
await asyncio.wait_for(page.add_init_script(_INSTALL_SCRIPT), timeout=checked)
|
|
242
|
+
await asyncio.wait_for(page.evaluate(_INSTALL_SCRIPT), timeout=checked)
|
|
243
|
+
except asyncio.CancelledError:
|
|
244
|
+
raise
|
|
245
|
+
except TimeoutError:
|
|
246
|
+
raise FetchError(
|
|
247
|
+
"Cloudflare Challenge capture installation timed out",
|
|
248
|
+
phase="cloudflare_challenge_capture",
|
|
249
|
+
) from None
|
|
250
|
+
except Exception:
|
|
251
|
+
raise FetchError(
|
|
252
|
+
"Cloudflare Challenge capture installation failed",
|
|
253
|
+
phase="cloudflare_challenge_capture",
|
|
254
|
+
) from None
|
|
255
|
+
return cls(page, checked)
|
|
256
|
+
|
|
257
|
+
async def wait(self, timeout: float = 30.0) -> CloudflareChallengeParameters:
|
|
258
|
+
"""Wait for one unambiguous render without refreshing or mutating the page."""
|
|
259
|
+
|
|
260
|
+
wait_timeout = _timeout(timeout, maximum=180.0)
|
|
261
|
+
deadline = time.monotonic() + wait_timeout
|
|
262
|
+
last_read_unavailable = False
|
|
263
|
+
while True:
|
|
264
|
+
remaining = deadline - time.monotonic()
|
|
265
|
+
if remaining <= 0:
|
|
266
|
+
raise FetchError(
|
|
267
|
+
(
|
|
268
|
+
"Cloudflare Challenge page remained unavailable until capture timeout"
|
|
269
|
+
if last_read_unavailable
|
|
270
|
+
else "Cloudflare Challenge parameters were not captured"
|
|
271
|
+
),
|
|
272
|
+
phase="cloudflare_challenge_capture",
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
raw = await asyncio.wait_for(
|
|
276
|
+
self._page.evaluate(_READ_SCRIPT),
|
|
277
|
+
timeout=min(self._timeout, remaining),
|
|
278
|
+
)
|
|
279
|
+
except asyncio.CancelledError:
|
|
280
|
+
raise
|
|
281
|
+
except TimeoutError:
|
|
282
|
+
last_read_unavailable = True
|
|
283
|
+
await asyncio.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
|
|
284
|
+
continue
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
if _is_transient_context_error(exc):
|
|
287
|
+
last_read_unavailable = True
|
|
288
|
+
await asyncio.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
|
|
289
|
+
continue
|
|
290
|
+
raise FetchError(
|
|
291
|
+
"Cloudflare Challenge page read failed",
|
|
292
|
+
phase="cloudflare_challenge_capture",
|
|
293
|
+
) from None
|
|
294
|
+
last_read_unavailable = False
|
|
295
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("status"), str):
|
|
296
|
+
raise FetchError(
|
|
297
|
+
"Cloudflare Challenge capture returned invalid data",
|
|
298
|
+
phase="cloudflare_challenge_capture",
|
|
299
|
+
)
|
|
300
|
+
status = raw["status"]
|
|
301
|
+
if status == "ready":
|
|
302
|
+
return _parameters(raw)
|
|
303
|
+
if status == "invalid_callback":
|
|
304
|
+
raise FetchError(
|
|
305
|
+
"Cloudflare Challenge callback is not callable",
|
|
306
|
+
phase="cloudflare_challenge_capture",
|
|
307
|
+
)
|
|
308
|
+
if status == "ambiguous":
|
|
309
|
+
raise FetchError(
|
|
310
|
+
"Multiple Cloudflare Challenges are active",
|
|
311
|
+
phase="cloudflare_challenge_capture",
|
|
312
|
+
)
|
|
313
|
+
if status == "expired":
|
|
314
|
+
raise FetchError(
|
|
315
|
+
"Cloudflare Challenge parameters expired before solver submission",
|
|
316
|
+
phase="cloudflare_challenge_capture",
|
|
317
|
+
)
|
|
318
|
+
if status == "not_installed":
|
|
319
|
+
raise FetchError(
|
|
320
|
+
"Cloudflare Challenge capture is not installed in the current document",
|
|
321
|
+
phase="cloudflare_challenge_capture",
|
|
322
|
+
)
|
|
323
|
+
if status != "waiting":
|
|
324
|
+
raise FetchError(
|
|
325
|
+
"Cloudflare Challenge capture returned an invalid status",
|
|
326
|
+
phase="cloudflare_challenge_capture",
|
|
327
|
+
)
|
|
328
|
+
remaining = deadline - time.monotonic()
|
|
329
|
+
await asyncio.sleep(min(0.1, remaining))
|
|
330
|
+
|
|
331
|
+
async def apply_token(
|
|
332
|
+
self,
|
|
333
|
+
token: str,
|
|
334
|
+
*,
|
|
335
|
+
challenge: CloudflareChallengeParameters,
|
|
336
|
+
user_agent: str | None = None,
|
|
337
|
+
timeout: float = 5.0,
|
|
338
|
+
) -> bool:
|
|
339
|
+
"""Invoke the original page callback; true does not prove site acceptance."""
|
|
340
|
+
|
|
341
|
+
checked = _timeout(timeout, maximum=30.0)
|
|
342
|
+
if not isinstance(challenge, CloudflareChallengeParameters):
|
|
343
|
+
raise ConfigurationError("challenge must be CloudflareChallengeParameters")
|
|
344
|
+
_bounded_text("token", token, maximum=8192)
|
|
345
|
+
if user_agent is None:
|
|
346
|
+
user_agent = challenge.user_agent
|
|
347
|
+
_bounded_text("user_agent", user_agent, maximum=512)
|
|
348
|
+
if user_agent != challenge.user_agent:
|
|
349
|
+
raise FetchError(
|
|
350
|
+
"Cloudflare Challenge solution user agent does not match the browser",
|
|
351
|
+
phase="cloudflare_challenge_apply",
|
|
352
|
+
)
|
|
353
|
+
payload = {
|
|
354
|
+
"token": token,
|
|
355
|
+
"user_agent": user_agent,
|
|
356
|
+
"document_id": challenge._document_id,
|
|
357
|
+
"capture_id": challenge._capture_id,
|
|
358
|
+
"generation": challenge._generation,
|
|
359
|
+
"max_age_ms": int(_MAX_CAPTURE_AGE_SECONDS * 1000),
|
|
360
|
+
}
|
|
361
|
+
try:
|
|
362
|
+
raw = await asyncio.wait_for(
|
|
363
|
+
self._page.evaluate(_APPLY_SCRIPT, payload), timeout=checked
|
|
364
|
+
)
|
|
365
|
+
except asyncio.CancelledError:
|
|
366
|
+
raise
|
|
367
|
+
except TimeoutError:
|
|
368
|
+
raise FetchError(
|
|
369
|
+
"Cloudflare Challenge token application timed out",
|
|
370
|
+
phase="cloudflare_challenge_apply",
|
|
371
|
+
) from None
|
|
372
|
+
except Exception:
|
|
373
|
+
raise FetchError(
|
|
374
|
+
"Cloudflare Challenge token application failed",
|
|
375
|
+
phase="cloudflare_challenge_apply",
|
|
376
|
+
) from None
|
|
377
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("applied"), bool):
|
|
378
|
+
raise FetchError(
|
|
379
|
+
"Cloudflare Challenge token application returned invalid data",
|
|
380
|
+
phase="cloudflare_challenge_apply",
|
|
381
|
+
)
|
|
382
|
+
if raw["applied"]:
|
|
383
|
+
return True
|
|
384
|
+
reasons = {
|
|
385
|
+
"document_changed": "Cloudflare Challenge page changed before token application",
|
|
386
|
+
"ambiguous": "Multiple Cloudflare Challenges are active",
|
|
387
|
+
"stale": "Cloudflare Challenge is no longer active",
|
|
388
|
+
"challenge_changed": "Cloudflare Challenge changed before token application",
|
|
389
|
+
"expired": "Cloudflare Challenge parameters expired before token application",
|
|
390
|
+
"user_agent_changed": "Cloudflare Challenge browser user agent changed",
|
|
391
|
+
"callback_unavailable": "Cloudflare Challenge callback is unavailable",
|
|
392
|
+
}
|
|
393
|
+
reason = raw.get("reason")
|
|
394
|
+
message = (
|
|
395
|
+
reasons.get(reason, "Cloudflare Challenge token application was rejected")
|
|
396
|
+
if isinstance(reason, str)
|
|
397
|
+
else "Cloudflare Challenge token application was rejected"
|
|
398
|
+
)
|
|
399
|
+
raise FetchError(
|
|
400
|
+
message,
|
|
401
|
+
phase="cloudflare_challenge_apply",
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _timeout(value: float, *, maximum: float) -> float:
|
|
406
|
+
if (
|
|
407
|
+
isinstance(value, bool)
|
|
408
|
+
or not isinstance(value, int | float)
|
|
409
|
+
or not math.isfinite(value)
|
|
410
|
+
or value <= 0
|
|
411
|
+
or value > maximum
|
|
412
|
+
):
|
|
413
|
+
raise ConfigurationError(f"timeout must be between 0 and {int(maximum)} seconds")
|
|
414
|
+
return float(value)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _is_transient_context_error(error: Exception) -> bool:
|
|
418
|
+
"""Classify only well-known navigation context replacement errors."""
|
|
419
|
+
|
|
420
|
+
if error.__class__.__name__ == "TargetClosedError":
|
|
421
|
+
return False
|
|
422
|
+
message = str(error).lower()
|
|
423
|
+
return any(
|
|
424
|
+
marker in message
|
|
425
|
+
for marker in (
|
|
426
|
+
"execution context was destroyed",
|
|
427
|
+
"cannot find context with specified id",
|
|
428
|
+
"cannot find execution context",
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _bounded_text(name: str, value: Any, *, maximum: int) -> str:
|
|
434
|
+
if (
|
|
435
|
+
not isinstance(value, str)
|
|
436
|
+
or not 0 < len(value) <= maximum
|
|
437
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in value)
|
|
438
|
+
):
|
|
439
|
+
raise ConfigurationError(f"Cloudflare Challenge {name} is invalid")
|
|
440
|
+
return value
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _parameters(raw: dict[str, Any]) -> CloudflareChallengeParameters:
|
|
444
|
+
try:
|
|
445
|
+
values = {
|
|
446
|
+
"website_url": _page_url(raw.get("website_url")),
|
|
447
|
+
"website_key": _bounded_text("website_key", raw.get("website_key"), maximum=512),
|
|
448
|
+
"action": _bounded_text("action", raw.get("action"), maximum=64),
|
|
449
|
+
"data": _bounded_text("data", raw.get("data"), maximum=4096),
|
|
450
|
+
"page_data": _bounded_text("page_data", raw.get("page_data"), maximum=16384),
|
|
451
|
+
"user_agent": _bounded_text("user_agent", raw.get("user_agent"), maximum=512),
|
|
452
|
+
}
|
|
453
|
+
api_js_url = raw.get("api_js_url")
|
|
454
|
+
if api_js_url is not None:
|
|
455
|
+
api_js_url = _api_url(api_js_url)
|
|
456
|
+
document_id = _bounded_text("document binding", raw.get("document_id"), maximum=256)
|
|
457
|
+
capture_id = _bounded_text("capture binding", raw.get("capture_id"), maximum=512)
|
|
458
|
+
generation = raw.get("generation")
|
|
459
|
+
captured_at_ms = raw.get("captured_at_ms")
|
|
460
|
+
if (
|
|
461
|
+
isinstance(generation, bool)
|
|
462
|
+
or not isinstance(generation, int)
|
|
463
|
+
or generation <= 0
|
|
464
|
+
or isinstance(captured_at_ms, bool)
|
|
465
|
+
or not isinstance(captured_at_ms, int)
|
|
466
|
+
or captured_at_ms <= 0
|
|
467
|
+
):
|
|
468
|
+
raise ValueError("invalid binding")
|
|
469
|
+
except (ConfigurationError, FetchError, TypeError, ValueError, OverflowError) as exc:
|
|
470
|
+
raise FetchError(
|
|
471
|
+
"Cloudflare Challenge capture returned invalid data",
|
|
472
|
+
phase="cloudflare_challenge_capture",
|
|
473
|
+
) from exc
|
|
474
|
+
return CloudflareChallengeParameters(
|
|
475
|
+
**values,
|
|
476
|
+
api_js_url=api_js_url,
|
|
477
|
+
_document_id=document_id,
|
|
478
|
+
_capture_id=capture_id,
|
|
479
|
+
_generation=generation,
|
|
480
|
+
_captured_at_ms=captured_at_ms,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _page_url(value: Any) -> str:
|
|
485
|
+
value = _bounded_text("website_url", value, maximum=2048)
|
|
486
|
+
try:
|
|
487
|
+
parts = urlsplit(value)
|
|
488
|
+
except ValueError:
|
|
489
|
+
parts = None
|
|
490
|
+
if (
|
|
491
|
+
parts is None
|
|
492
|
+
or parts.scheme not in {"http", "https"}
|
|
493
|
+
or not parts.hostname
|
|
494
|
+
or parts.username is not None
|
|
495
|
+
or parts.password is not None
|
|
496
|
+
):
|
|
497
|
+
raise FetchError(
|
|
498
|
+
"Cloudflare Challenge page URL is invalid",
|
|
499
|
+
phase="cloudflare_challenge_capture",
|
|
500
|
+
)
|
|
501
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, parts.query, ""))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _api_url(value: Any) -> str:
|
|
505
|
+
value = _bounded_text("api_js_url", value, maximum=2048)
|
|
506
|
+
try:
|
|
507
|
+
parts = urlsplit(value)
|
|
508
|
+
except ValueError:
|
|
509
|
+
parts = None
|
|
510
|
+
if (
|
|
511
|
+
parts is None
|
|
512
|
+
or parts.scheme != "https"
|
|
513
|
+
or (parts.hostname or "").lower() != "challenges.cloudflare.com"
|
|
514
|
+
or not parts.path.startswith("/turnstile/")
|
|
515
|
+
or parts.username is not None
|
|
516
|
+
or parts.password is not None
|
|
517
|
+
or parts.fragment
|
|
518
|
+
):
|
|
519
|
+
raise FetchError(
|
|
520
|
+
"Cloudflare Challenge script URL is invalid",
|
|
521
|
+
phase="cloudflare_challenge_capture",
|
|
522
|
+
)
|
|
523
|
+
return value
|