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.
@@ -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