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,912 @@
1
+ """Read-only CAPTCHA detection and solver-component matching.
2
+
3
+ Detection never navigates, clicks, refreshes, captures images, or invokes a
4
+ solver. It returns fixed evidence codes instead of page values so URLs, site
5
+ keys, tokens, and image content do not leak through result representations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import math
12
+ import re
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass, field
15
+ from html.parser import HTMLParser
16
+ from typing import Any, Literal
17
+ from urllib.parse import SplitResult, parse_qs, urlsplit
18
+
19
+ from .errors import ConfigurationError, FetchError, ResponseTooLarge
20
+
21
+ CaptchaFamily = Literal[
22
+ "recaptcha_v2",
23
+ "recaptcha_v2_enterprise",
24
+ "turnstile",
25
+ "cloudflare_challenge",
26
+ "image_to_text",
27
+ "unknown",
28
+ ]
29
+ CaptchaPresence = Literal["access_challenge", "widget_observed", "unknown"]
30
+ CaptchaConfidence = Literal["high", "medium", "low"]
31
+ SolverComponent = Literal["resource_proxy_capmonster", "vision_model"]
32
+
33
+ _FAMILIES = {
34
+ "recaptcha_v2",
35
+ "recaptcha_v2_enterprise",
36
+ "turnstile",
37
+ "cloudflare_challenge",
38
+ "image_to_text",
39
+ "unknown",
40
+ }
41
+ _PRESENCES = {"access_challenge", "widget_observed", "unknown"}
42
+ _COMPONENTS = {"resource_proxy_capmonster", "vision_model"}
43
+ _SAFE_NAME = re.compile(r"[a-z][a-z0-9_-]{0,63}")
44
+ _BLOCKING_STATUS = {401, 403, 429}
45
+ _TEXT_CHALLENGE_MARKERS = (
46
+ "verify you are human",
47
+ "verify that you are human",
48
+ "checking your browser",
49
+ "security check",
50
+ "human verification",
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class CaptchaEvidence:
56
+ """One sanitized reason for a detection, without a raw page value."""
57
+
58
+ code: str
59
+ source: Literal["dom", "resource", "text", "status", "override"]
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class SolverMatch:
64
+ """Structural match between an observation and an integrated component.
65
+
66
+ ``can_attempt`` is true only when the caller explicitly declared the
67
+ component available and all structurally required inputs were observed or
68
+ overridden. It does not prove a Resource Proxy grant, supplier health,
69
+ answer correctness, or acceptance by the target website.
70
+ """
71
+
72
+ component: SolverComponent
73
+ task_type: str
74
+ integrated: bool
75
+ configured: bool
76
+ can_attempt: bool
77
+ missing_inputs: tuple[str, ...]
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class CaptchaObservation:
82
+ """Immutable CAPTCHA classification with safe evidence and component matches."""
83
+
84
+ family: CaptchaFamily
85
+ vendor: str | None
86
+ module_hint: str | None
87
+ confidence: CaptchaConfidence
88
+ presence: CaptchaPresence
89
+ evidence: tuple[CaptchaEvidence, ...]
90
+ solvers: tuple[SolverMatch, ...]
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class CaptchaOverride:
95
+ """Developer-provided classification when site knowledge is stronger.
96
+
97
+ Boolean fields only declare that the application already has an input.
98
+ Actual URLs, keys, image bytes, and tokens stay outside the detector.
99
+ """
100
+
101
+ family: CaptchaFamily
102
+ presence: CaptchaPresence = "widget_observed"
103
+ vendor: str | None = None
104
+ module_hint: str | None = None
105
+ has_website_url: bool = False
106
+ has_website_key: bool = False
107
+ has_image: bool = False
108
+ has_action: bool = False
109
+ has_data: bool = False
110
+ has_page_data: bool = False
111
+ has_user_agent: bool = False
112
+
113
+ def __post_init__(self) -> None:
114
+ if self.family not in _FAMILIES:
115
+ raise ConfigurationError("CAPTCHA override family is unsupported")
116
+ if self.presence not in _PRESENCES:
117
+ raise ConfigurationError("CAPTCHA override presence is unsupported")
118
+ for label, value in (("vendor", self.vendor), ("module_hint", self.module_hint)):
119
+ if value is not None and not _SAFE_NAME.fullmatch(value):
120
+ raise ConfigurationError(f"CAPTCHA override {label} is invalid")
121
+ for label, bool_value in (
122
+ ("has_website_url", self.has_website_url),
123
+ ("has_website_key", self.has_website_key),
124
+ ("has_image", self.has_image),
125
+ ("has_action", self.has_action),
126
+ ("has_data", self.has_data),
127
+ ("has_page_data", self.has_page_data),
128
+ ("has_user_agent", self.has_user_agent),
129
+ ):
130
+ if not isinstance(bool_value, bool):
131
+ raise ConfigurationError(f"CAPTCHA override {label} must be a boolean")
132
+
133
+
134
+ @dataclass(slots=True)
135
+ class _Candidate:
136
+ family: CaptchaFamily
137
+ vendor: str | None = None
138
+ module_hint: str | None = None
139
+ confidence: CaptchaConfidence = "low"
140
+ presence: CaptchaPresence = "unknown"
141
+ evidence: list[CaptchaEvidence] = field(default_factory=list)
142
+ has_website_url: bool = False
143
+ has_website_key: bool = False
144
+ has_image: bool = False
145
+ has_action: bool = False
146
+ has_data: bool = False
147
+ has_page_data: bool = False
148
+ has_user_agent: bool = False
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class _Node:
153
+ tag: str
154
+ attrs: tuple[tuple[str, str], ...]
155
+
156
+ def get(self, name: str) -> str:
157
+ for key, value in self.attrs:
158
+ if key == name:
159
+ return value
160
+ return ""
161
+
162
+
163
+ class _DocumentParser(HTMLParser):
164
+ def __init__(self) -> None:
165
+ super().__init__(convert_charrefs=True)
166
+ self.nodes: list[_Node] = []
167
+ self.text_parts: list[str] = []
168
+ self.script_parts: list[str] = []
169
+ self._ignored_text_depth = 0
170
+ self._script_depth = 0
171
+
172
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
173
+ lowered_tag = tag.lower()
174
+ if lowered_tag in {"script", "style", "noscript"}:
175
+ self._ignored_text_depth += 1
176
+ if lowered_tag == "script":
177
+ self._script_depth += 1
178
+ self.nodes.append(
179
+ _Node(
180
+ lowered_tag,
181
+ tuple((key.lower(), value or "") for key, value in attrs),
182
+ )
183
+ )
184
+
185
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
186
+ self.handle_starttag(tag, attrs)
187
+ lowered_tag = tag.lower()
188
+ if lowered_tag == "script":
189
+ self._script_depth -= 1
190
+ if lowered_tag in {"script", "style", "noscript"}:
191
+ self._ignored_text_depth -= 1
192
+
193
+ def handle_endtag(self, tag: str) -> None:
194
+ lowered_tag = tag.lower()
195
+ if lowered_tag == "script" and self._script_depth:
196
+ self._script_depth -= 1
197
+ if lowered_tag in {"script", "style", "noscript"} and self._ignored_text_depth:
198
+ self._ignored_text_depth -= 1
199
+
200
+ def handle_data(self, data: str) -> None:
201
+ if self._script_depth and data.strip():
202
+ self.script_parts.append(data)
203
+ if not self._ignored_text_depth and data.strip():
204
+ self.text_parts.append(data)
205
+
206
+
207
+ def detect_captchas(
208
+ html: str,
209
+ *,
210
+ page_url: str | None = None,
211
+ status: int | None = None,
212
+ resource_urls: Sequence[str] = (),
213
+ overrides: Sequence[CaptchaOverride] = (),
214
+ available_components: Sequence[SolverComponent] = (),
215
+ max_html_chars: int = 1_000_000,
216
+ ) -> tuple[CaptchaObservation, ...]:
217
+ """Classify CAPTCHA integrations without performing browser or paid actions.
218
+
219
+ Raw URLs and public site keys are inspected only for marker presence and are
220
+ never returned. ``resource_urls`` is useful for URLs observed by an existing
221
+ capture; this function never fetches them. A script include alone produces
222
+ an ``unknown`` clue, not a positive widget or access-challenge verdict.
223
+ """
224
+ _validate_inputs(html, page_url, status, resource_urls, overrides, max_html_chars)
225
+ configured = _configured_components(available_components)
226
+ parser = _DocumentParser()
227
+ try:
228
+ parser.feed(html)
229
+ parser.close()
230
+ except (TypeError, ValueError):
231
+ raise ConfigurationError("CAPTCHA detection received invalid HTML") from None
232
+
233
+ text = " ".join(parser.text_parts).lower()
234
+ blocking = status in _BLOCKING_STATUS or any(
235
+ marker in text for marker in _TEXT_CHALLENGE_MARKERS
236
+ )
237
+ page_url_available = _usable_page_url(page_url)
238
+ urls = [node.get("src") for node in parser.nodes if node.get("src")]
239
+ urls.extend(resource_urls)
240
+ url_text = "\n".join(url.lower() for url in urls)
241
+ script_text = " ".join(parser.script_parts).lower()
242
+
243
+ observations: list[_Candidate] = []
244
+ recaptcha_nodes = [node for node in parser.nodes if _is_recaptcha_widget(node)]
245
+ recaptcha_response = any(_is_recaptcha_response(node) for node in parser.nodes)
246
+ recaptcha_script = any(_is_recaptcha_resource(url) for url in urls)
247
+ enterprise_script = any(_is_enterprise_recaptcha_resource(url) for url in urls)
248
+ regular_script = any(_is_regular_recaptcha_resource(url) for url in urls)
249
+ enterprise_frame = any(_is_enterprise_recaptcha_frame(url) for url in urls)
250
+ regular_frame = any(_is_regular_recaptcha_frame(url) for url in urls)
251
+ enterprise_inline = bool(
252
+ re.search(r"\bgrecaptcha\.enterprise\.(?:execute|ready|render)\s*\(", script_text)
253
+ )
254
+ enterprise = enterprise_inline or enterprise_script or enterprise_frame
255
+ regular = regular_script or regular_frame
256
+ recaptcha_v3 = bool(
257
+ re.search(r"\bgrecaptcha(?:\.enterprise)?\.execute\s*\(", script_text)
258
+ ) or any(_is_recaptcha_v3_resource(url) for url in urls)
259
+ site_key = bool(recaptcha_nodes) and all(
260
+ _node_has_website_key(node) for node in recaptcha_nodes
261
+ )
262
+ mixed_integration = enterprise and regular
263
+
264
+ if recaptcha_nodes and recaptcha_v3:
265
+ observations.append(
266
+ _unresolved_recaptcha_candidate(
267
+ blocking=blocking,
268
+ page_url_available=page_url_available,
269
+ evidence_code="recaptcha-v3-widget-ambiguous",
270
+ )
271
+ )
272
+ elif recaptcha_nodes and mixed_integration:
273
+ observations.append(
274
+ _clue_candidate("google", "recaptcha-mixed-integration", page_url_available)
275
+ )
276
+ elif recaptcha_nodes and (enterprise or regular):
277
+ family: CaptchaFamily = "recaptcha_v2_enterprise" if enterprise else "recaptcha_v2"
278
+ candidate = _positive_candidate(
279
+ family,
280
+ vendor="google",
281
+ blocking=blocking,
282
+ confidence="high",
283
+ evidence_code=("recaptcha-enterprise-widget" if enterprise else "recaptcha-v2-widget"),
284
+ )
285
+ candidate.has_website_url = page_url_available
286
+ candidate.has_website_key = site_key
287
+ observations.append(candidate)
288
+ elif recaptcha_nodes:
289
+ observations.append(
290
+ _unresolved_recaptcha_candidate(
291
+ blocking=blocking,
292
+ page_url_available=page_url_available,
293
+ evidence_code="recaptcha-family-unresolved",
294
+ )
295
+ )
296
+ elif (
297
+ recaptcha_response
298
+ and not recaptcha_v3
299
+ and not mixed_integration
300
+ and (enterprise or regular)
301
+ ):
302
+ candidate = _positive_candidate(
303
+ "recaptcha_v2_enterprise" if enterprise else "recaptcha_v2",
304
+ vendor="google",
305
+ blocking=blocking,
306
+ confidence="medium",
307
+ evidence_code="recaptcha-response-field",
308
+ )
309
+ candidate.has_website_url = page_url_available
310
+ candidate.has_website_key = any(
311
+ _resource_has_website_key(url) for url in urls if _is_recaptcha_frame(url)
312
+ )
313
+ observations.append(candidate)
314
+ elif recaptcha_response and not recaptcha_v3 and not mixed_integration:
315
+ observations.append(
316
+ _unresolved_recaptcha_candidate(
317
+ blocking=blocking,
318
+ page_url_available=page_url_available,
319
+ evidence_code="recaptcha-response-family-unresolved",
320
+ )
321
+ )
322
+
323
+ if recaptcha_v3:
324
+ observations.append(_clue_candidate("google", "recaptcha-v3-marker", page_url_available))
325
+ elif recaptcha_script and not recaptcha_nodes and not recaptcha_response:
326
+ observations.append(_clue_candidate("google", "recaptcha-script-only", page_url_available))
327
+
328
+ turnstile_nodes = [node for node in parser.nodes if _is_turnstile_widget(node)]
329
+ turnstile_response = any(_is_turnstile_response(node) for node in parser.nodes)
330
+ turnstile_script = any(_is_turnstile_resource(url) for url in urls)
331
+ challenge_options = bool(re.search(r"\b(?:window\.)?_cf_chl_opt\s*=", script_text))
332
+ challenge_form = any(_is_cloudflare_challenge_form(node) for node in parser.nodes)
333
+ challenge_resource = any(_is_cloudflare_challenge_resource(url, page_url) for url in urls)
334
+ challenge_text = any(
335
+ marker in text
336
+ for marker in (
337
+ "just a moment",
338
+ "performing security verification",
339
+ "enable javascript and cookies to continue",
340
+ "checking your browser",
341
+ "verify you are human",
342
+ "verify that you are human",
343
+ )
344
+ )
345
+ full_cloudflare = (
346
+ challenge_options and (challenge_form or challenge_text or challenge_resource)
347
+ ) or (challenge_form and challenge_resource)
348
+ if full_cloudflare:
349
+ candidate = _positive_candidate(
350
+ "cloudflare_challenge",
351
+ vendor="cloudflare",
352
+ blocking=True,
353
+ confidence="high",
354
+ evidence_code=(
355
+ "cloudflare-challenge-options" if challenge_options
356
+ else "cloudflare-challenge-form"
357
+ ),
358
+ )
359
+ candidate.has_website_url = page_url_available
360
+ candidate.has_website_key = bool(turnstile_nodes) and all(
361
+ _node_has_website_key(node) for node in turnstile_nodes
362
+ )
363
+ if challenge_form:
364
+ candidate.evidence.append(CaptchaEvidence("cloudflare-challenge-form", "dom"))
365
+ if challenge_resource:
366
+ candidate.evidence.append(CaptchaEvidence("cloudflare-challenge-resource", "resource"))
367
+ if challenge_text:
368
+ candidate.evidence.append(CaptchaEvidence("cloudflare-challenge-interstitial", "text"))
369
+ observations.append(candidate)
370
+ elif turnstile_nodes or turnstile_response:
371
+ candidate = _positive_candidate(
372
+ "turnstile",
373
+ vendor="cloudflare",
374
+ blocking=blocking,
375
+ confidence="high" if turnstile_nodes else "medium",
376
+ evidence_code="turnstile-widget" if turnstile_nodes else "turnstile-response-field",
377
+ )
378
+ candidate.has_website_url = page_url_available
379
+ candidate.has_website_key = bool(turnstile_nodes) and all(
380
+ _node_has_website_key(node) for node in turnstile_nodes
381
+ )
382
+ observations.append(candidate)
383
+ elif turnstile_script:
384
+ observations.append(
385
+ _clue_candidate("cloudflare", "turnstile-script-only", page_url_available)
386
+ )
387
+
388
+ botdetect_nodes = [node for node in parser.nodes if _is_botdetect_widget(node)]
389
+ botdetect_resource = (
390
+ "botdetect" in url_text
391
+ or "botdetect.init" in script_text
392
+ or "botdetect.getinstancebyid" in script_text
393
+ )
394
+ if botdetect_nodes:
395
+ candidate = _positive_candidate(
396
+ "image_to_text",
397
+ vendor="botdetect",
398
+ module_hint="botdetect",
399
+ blocking=blocking,
400
+ confidence="high",
401
+ evidence_code="botdetect-widget",
402
+ )
403
+ candidate.has_website_url = page_url_available
404
+ observations.append(candidate)
405
+ elif botdetect_resource:
406
+ observations.append(
407
+ _clue_candidate("botdetect", "botdetect-resource-only", page_url_available)
408
+ )
409
+
410
+ if not botdetect_nodes and _has_generic_image_captcha(parser.nodes):
411
+ candidate = _positive_candidate(
412
+ "image_to_text",
413
+ vendor=None,
414
+ blocking=blocking,
415
+ confidence="medium",
416
+ evidence_code="image-captcha-widget",
417
+ )
418
+ candidate.has_website_url = page_url_available
419
+ observations.append(candidate)
420
+
421
+ if status in _BLOCKING_STATUS:
422
+ _add_evidence(observations, CaptchaEvidence("blocking-http-status", "status"))
423
+ if any(marker in text for marker in _TEXT_CHALLENGE_MARKERS):
424
+ _add_evidence(observations, CaptchaEvidence("human-verification-text", "text"))
425
+
426
+ for override in overrides:
427
+ candidate = _candidate_from_override(override, page_url_available)
428
+ same_family = [item for item in observations if item.family == override.family]
429
+ if override.vendor is None and len(same_family) == 1:
430
+ candidate.vendor = same_family[0].vendor
431
+ unresolved = [
432
+ item
433
+ for item in observations
434
+ if item.family == "unknown"
435
+ and item.vendor == "google"
436
+ and any(
437
+ evidence.code
438
+ in {"recaptcha-family-unresolved", "recaptcha-response-family-unresolved"}
439
+ for evidence in item.evidence
440
+ )
441
+ ]
442
+ if (
443
+ override.family in {"recaptcha_v2", "recaptcha_v2_enterprise"}
444
+ and override.vendor in {None, "google"}
445
+ and not same_family
446
+ and len(unresolved) == 1
447
+ ):
448
+ source = unresolved[0]
449
+ observations.remove(source)
450
+ candidate.vendor = "google"
451
+ candidate.presence = _max_presence(candidate.presence, source.presence)
452
+ candidate.has_website_url |= source.has_website_url
453
+ candidate.evidence.extend(source.evidence)
454
+ observations.append(candidate)
455
+
456
+ merged = _merge_candidates(observations)
457
+ return tuple(_finalize(candidate, configured) for candidate in merged)
458
+
459
+
460
+ async def detect_captchas_on_page(
461
+ page: Any,
462
+ *,
463
+ timeout: float = 5.0,
464
+ overrides: Sequence[CaptchaOverride] = (),
465
+ available_components: Sequence[SolverComponent] = (),
466
+ max_html_chars: int = 1_000_000,
467
+ ) -> tuple[CaptchaObservation, ...]:
468
+ """Read one existing Page's DOM without navigation or other page mutation.
469
+
470
+ ``timeout`` is in seconds. The adapter calls only ``page.content()`` and
471
+ reads ``page.url``; it does not evaluate scripts, inspect network secrets,
472
+ capture images, submit forms, or contact a solver.
473
+ """
474
+ if (
475
+ isinstance(timeout, bool)
476
+ or not isinstance(timeout, (int, float))
477
+ or not math.isfinite(timeout)
478
+ or timeout <= 0
479
+ or timeout > 30
480
+ ):
481
+ raise ConfigurationError("CAPTCHA detection timeout must be between 0 and 30 seconds")
482
+ try:
483
+ html = await asyncio.wait_for(page.content(), timeout=float(timeout))
484
+ except TimeoutError:
485
+ raise TimeoutError("CAPTCHA detection timed out") from None
486
+ except Exception:
487
+ raise FetchError("CAPTCHA page inspection failed", phase="captcha_detection") from None
488
+ if not isinstance(html, str):
489
+ raise FetchError("Browser returned invalid page content", phase="captcha_detection")
490
+ page_url = getattr(page, "url", None)
491
+ if not isinstance(page_url, str):
492
+ page_url = None
493
+ return detect_captchas(
494
+ html,
495
+ page_url=page_url,
496
+ overrides=overrides,
497
+ available_components=available_components,
498
+ max_html_chars=max_html_chars,
499
+ )
500
+
501
+
502
+ def _validate_inputs(
503
+ html: str,
504
+ page_url: str | None,
505
+ status: int | None,
506
+ resource_urls: Sequence[str],
507
+ overrides: Sequence[CaptchaOverride],
508
+ max_html_chars: int,
509
+ ) -> None:
510
+ if not isinstance(html, str):
511
+ raise ConfigurationError("CAPTCHA detection HTML must be a string")
512
+ if (
513
+ isinstance(max_html_chars, bool)
514
+ or not isinstance(max_html_chars, int)
515
+ or not 1 <= max_html_chars <= 5_000_000
516
+ ):
517
+ raise ConfigurationError("max_html_chars must be between 1 and 5000000")
518
+ if len(html) > max_html_chars:
519
+ raise ResponseTooLarge("CAPTCHA detection HTML exceeds max_html_chars")
520
+ if page_url is not None and not isinstance(page_url, str):
521
+ raise ConfigurationError("CAPTCHA detection page_url must be a string")
522
+ if status is not None and (isinstance(status, bool) or not isinstance(status, int)):
523
+ raise ConfigurationError("CAPTCHA detection status must be an integer")
524
+ if isinstance(resource_urls, (str, bytes)) or len(resource_urls) > 128:
525
+ raise ConfigurationError("resource_urls must contain at most 128 URLs")
526
+ if any(not isinstance(url, str) or len(url) > 4096 for url in resource_urls):
527
+ raise ConfigurationError("resource_urls contains an invalid URL")
528
+ if isinstance(overrides, (str, bytes)) or len(overrides) > 16:
529
+ raise ConfigurationError("overrides must contain at most 16 items")
530
+ if any(not isinstance(item, CaptchaOverride) for item in overrides):
531
+ raise ConfigurationError("overrides must contain CaptchaOverride values")
532
+
533
+
534
+ def _configured_components(values: Sequence[SolverComponent]) -> frozenset[str]:
535
+ if isinstance(values, (str, bytes)):
536
+ raise ConfigurationError("available_components must be a sequence")
537
+ configured = set(values)
538
+ if len(configured) != len(values) or not configured.issubset(_COMPONENTS):
539
+ raise ConfigurationError("available_components contains invalid or duplicate values")
540
+ return frozenset(configured)
541
+
542
+
543
+ def _usable_page_url(value: str | None) -> bool:
544
+ if not value or len(value) > 4096:
545
+ return False
546
+ try:
547
+ parts = urlsplit(value)
548
+ except ValueError:
549
+ return False
550
+ return (
551
+ parts.scheme in {"http", "https"}
552
+ and bool(parts.hostname)
553
+ and parts.username is None
554
+ and parts.password is None
555
+ )
556
+
557
+
558
+ def _is_recaptcha_widget(node: _Node) -> bool:
559
+ return "g-recaptcha" in node.get("class").lower().split() or (
560
+ node.tag == "iframe" and _is_recaptcha_frame(node.get("src"))
561
+ )
562
+
563
+
564
+ def _is_recaptcha_response(node: _Node) -> bool:
565
+ return node.tag in {"input", "textarea"} and _has_exact_form_name(node, "g-recaptcha-response")
566
+
567
+
568
+ def _is_enterprise_recaptcha_resource(value: str) -> bool:
569
+ parts = _trusted_recaptcha_parts(value)
570
+ return parts is not None and parts.path.lower() == "/recaptcha/enterprise.js"
571
+
572
+
573
+ def _is_regular_recaptcha_resource(value: str) -> bool:
574
+ parts = _trusted_recaptcha_parts(value)
575
+ return parts is not None and parts.path.lower() == "/recaptcha/api.js"
576
+
577
+
578
+ def _is_recaptcha_resource(value: str) -> bool:
579
+ return _is_regular_recaptcha_resource(value) or _is_enterprise_recaptcha_resource(value)
580
+
581
+
582
+ def _is_recaptcha_frame(value: str) -> bool:
583
+ return _is_regular_recaptcha_frame(value) or _is_enterprise_recaptcha_frame(value)
584
+
585
+
586
+ def _is_regular_recaptcha_frame(value: str) -> bool:
587
+ parts = _trusted_recaptcha_parts(value)
588
+ if parts is None:
589
+ return False
590
+ return parts.path.lower().startswith("/recaptcha/api2/")
591
+
592
+
593
+ def _is_enterprise_recaptcha_frame(value: str) -> bool:
594
+ parts = _trusted_recaptcha_parts(value)
595
+ if parts is None:
596
+ return False
597
+ return parts.path.lower().startswith("/recaptcha/enterprise/")
598
+
599
+
600
+ def _trusted_recaptcha_parts(value: str) -> SplitResult | None:
601
+ try:
602
+ parts = urlsplit(value)
603
+ except ValueError:
604
+ return None
605
+ if not _is_http_resource(parts) or parts.username or parts.password:
606
+ return None
607
+ if (parts.hostname or "").lower() not in {
608
+ "google.com",
609
+ "www.google.com",
610
+ "recaptcha.net",
611
+ "www.recaptcha.net",
612
+ }:
613
+ return None
614
+ return parts
615
+
616
+
617
+ def _is_recaptcha_v3_resource(value: str) -> bool:
618
+ if not _is_recaptcha_resource(value):
619
+ return False
620
+ try:
621
+ render = parse_qs(urlsplit(value).query).get("render", ())
622
+ except ValueError:
623
+ return False
624
+ return any(item and item.lower() != "explicit" for item in render)
625
+
626
+
627
+ def _resource_has_website_key(value: str) -> bool:
628
+ try:
629
+ query = parse_qs(urlsplit(value).query)
630
+ except ValueError:
631
+ return False
632
+ return any(bool(query.get(name)) for name in ("k", "sitekey"))
633
+
634
+
635
+ def _node_has_website_key(node: _Node) -> bool:
636
+ if node.get("data-sitekey"):
637
+ return True
638
+ if node.tag != "iframe":
639
+ return False
640
+ source = node.get("src")
641
+ return (
642
+ _is_recaptcha_frame(source) or _is_turnstile_resource(source)
643
+ ) and _resource_has_website_key(source)
644
+
645
+
646
+ def _is_turnstile_widget(node: _Node) -> bool:
647
+ return "cf-turnstile" in node.get("class").lower().split() or (
648
+ node.tag == "iframe" and _is_turnstile_resource(node.get("src"))
649
+ )
650
+
651
+
652
+ def _is_cloudflare_challenge_resource(value: str, page_url: str | None) -> bool:
653
+ try:
654
+ parts = urlsplit(value)
655
+ page_host = urlsplit(page_url or "").hostname
656
+ except ValueError:
657
+ return False
658
+ if parts.username or parts.password or parts.scheme not in {"", "http", "https"}:
659
+ return False
660
+ if not parts.path.startswith("/cdn-cgi/challenge-platform/"):
661
+ return False
662
+ return parts.hostname is None or parts.hostname in {page_host, "challenges.cloudflare.com"}
663
+
664
+
665
+ def _is_cloudflare_challenge_form(node: _Node) -> bool:
666
+ if node.tag != "form" or node.get("id") != "challenge-form":
667
+ return False
668
+ try:
669
+ action = urlsplit(node.get("action"))
670
+ return action.scheme in {"", "http", "https"} and bool(
671
+ parse_qs(action.query).get("__cf_chl_f_tk")
672
+ )
673
+ except ValueError:
674
+ return False
675
+
676
+
677
+ def _is_turnstile_resource(value: str) -> bool:
678
+ try:
679
+ parts = urlsplit(value)
680
+ except ValueError:
681
+ return False
682
+ return (
683
+ _is_http_resource(parts)
684
+ and parts.username is None
685
+ and parts.password is None
686
+ and (parts.hostname or "").lower() == "challenges.cloudflare.com"
687
+ and parts.path.lower().startswith("/turnstile/")
688
+ )
689
+
690
+
691
+ def _is_http_resource(parts: SplitResult) -> bool:
692
+ return parts.scheme in {"http", "https"} or (not parts.scheme and bool(parts.netloc))
693
+
694
+
695
+ def _is_turnstile_response(node: _Node) -> bool:
696
+ return node.tag in {"input", "textarea"} and _has_exact_form_name(node, "cf-turnstile-response")
697
+
698
+
699
+ def _is_botdetect_widget(node: _Node) -> bool:
700
+ tokens = " ".join(node.get(name).lower() for name in ("id", "class", "name"))
701
+ integration_marker = (
702
+ "bdc_captcha" in tokens or "bdc-captcha" in tokens or "botdetectcaptcha" in tokens
703
+ )
704
+ if node.tag in {"div", "span", "section", "input", "textarea", "canvas"}:
705
+ return integration_marker
706
+ if node.tag == "img":
707
+ return integration_marker or _is_botdetect_image_source(node.get("src"))
708
+ return False
709
+
710
+
711
+ def _is_botdetect_image_source(value: str) -> bool:
712
+ try:
713
+ path = urlsplit(value).path.lower()
714
+ except ValueError:
715
+ return False
716
+ return path.rsplit("/", 1)[-1].startswith("botdetectcaptcha.")
717
+
718
+
719
+ def _has_exact_form_name(node: _Node, expected: str) -> bool:
720
+ return node.get("name").lower() == expected or node.get("id").lower() == expected
721
+
722
+
723
+ def _has_generic_image_captcha(nodes: Sequence[_Node]) -> bool:
724
+ image = any(
725
+ node.tag in {"img", "canvas", "svg"}
726
+ and _has_generic_captcha_marker(node, include_source=True)
727
+ for node in nodes
728
+ )
729
+ answer = any(
730
+ node.tag == "input" and _has_generic_captcha_marker(node, include_source=False)
731
+ for node in nodes
732
+ )
733
+ return image and answer
734
+
735
+
736
+ def _has_generic_captcha_marker(node: _Node, *, include_source: bool) -> bool:
737
+ value = " ".join(node.get(name).lower() for name in ("id", "class", "name"))
738
+ if include_source:
739
+ try:
740
+ value = f"{value} {urlsplit(node.get('src')).path.rsplit('/', 1)[-1].lower()}"
741
+ except ValueError:
742
+ pass
743
+ return (
744
+ "captcha" in value
745
+ and "recaptcha" not in value
746
+ and "turnstile" not in value
747
+ and "botdetect" not in value
748
+ and "bdc_captcha" not in value
749
+ )
750
+
751
+
752
+ def _positive_candidate(
753
+ family: CaptchaFamily,
754
+ *,
755
+ vendor: str | None,
756
+ blocking: bool,
757
+ confidence: CaptchaConfidence,
758
+ evidence_code: str,
759
+ module_hint: str | None = None,
760
+ ) -> _Candidate:
761
+ return _Candidate(
762
+ family=family,
763
+ vendor=vendor,
764
+ module_hint=module_hint,
765
+ confidence=confidence,
766
+ presence="access_challenge" if blocking else "widget_observed",
767
+ evidence=[CaptchaEvidence(evidence_code, "dom")],
768
+ )
769
+
770
+
771
+ def _clue_candidate(vendor: str, code: str, has_website_url: bool) -> _Candidate:
772
+ return _Candidate(
773
+ family="unknown",
774
+ vendor=vendor,
775
+ confidence="low",
776
+ presence="unknown",
777
+ evidence=[CaptchaEvidence(code, "resource")],
778
+ has_website_url=has_website_url,
779
+ )
780
+
781
+
782
+ def _unresolved_recaptcha_candidate(
783
+ *, blocking: bool, page_url_available: bool, evidence_code: str
784
+ ) -> _Candidate:
785
+ return _Candidate(
786
+ family="unknown",
787
+ vendor="google",
788
+ confidence="medium",
789
+ presence="access_challenge" if blocking else "widget_observed",
790
+ evidence=[CaptchaEvidence(evidence_code, "dom")],
791
+ has_website_url=page_url_available,
792
+ )
793
+
794
+
795
+ def _candidate_from_override(override: CaptchaOverride, page_url_available: bool) -> _Candidate:
796
+ return _Candidate(
797
+ family=override.family,
798
+ vendor=override.vendor,
799
+ module_hint=override.module_hint,
800
+ confidence="high",
801
+ presence=override.presence,
802
+ evidence=[CaptchaEvidence("developer-override", "override")],
803
+ has_website_url=override.has_website_url or page_url_available,
804
+ has_website_key=override.has_website_key,
805
+ has_image=override.has_image,
806
+ has_action=override.has_action,
807
+ has_data=override.has_data,
808
+ has_page_data=override.has_page_data,
809
+ has_user_agent=override.has_user_agent,
810
+ )
811
+
812
+
813
+ def _add_evidence(candidates: Sequence[_Candidate], evidence: CaptchaEvidence) -> None:
814
+ for candidate in candidates:
815
+ if candidate.presence != "unknown" and evidence not in candidate.evidence:
816
+ candidate.evidence.append(evidence)
817
+
818
+
819
+ def _merge_candidates(candidates: Sequence[_Candidate]) -> list[_Candidate]:
820
+ merged: dict[tuple[str, str | None], _Candidate] = {}
821
+ for candidate in candidates:
822
+ key = (candidate.family, candidate.vendor)
823
+ existing = merged.get(key)
824
+ if existing is None:
825
+ merged[key] = candidate
826
+ continue
827
+ existing.module_hint = candidate.module_hint or existing.module_hint
828
+ existing.confidence = _max_confidence(existing.confidence, candidate.confidence)
829
+ existing.presence = _max_presence(existing.presence, candidate.presence)
830
+ existing.has_website_url |= candidate.has_website_url
831
+ existing.has_website_key |= candidate.has_website_key
832
+ existing.has_image |= candidate.has_image
833
+ existing.has_action |= candidate.has_action
834
+ existing.has_data |= candidate.has_data
835
+ existing.has_page_data |= candidate.has_page_data
836
+ existing.has_user_agent |= candidate.has_user_agent
837
+ for evidence in candidate.evidence:
838
+ if evidence not in existing.evidence:
839
+ existing.evidence.append(evidence)
840
+ return sorted(
841
+ merged.values(),
842
+ key=lambda item: (item.family == "unknown", item.family, item.vendor or ""),
843
+ )
844
+
845
+
846
+ def _max_confidence(left: CaptchaConfidence, right: CaptchaConfidence) -> CaptchaConfidence:
847
+ order = {"low": 0, "medium": 1, "high": 2}
848
+ return left if order[left] >= order[right] else right
849
+
850
+
851
+ def _max_presence(left: CaptchaPresence, right: CaptchaPresence) -> CaptchaPresence:
852
+ order = {"unknown": 0, "widget_observed": 1, "access_challenge": 2}
853
+ return left if order[left] >= order[right] else right
854
+
855
+
856
+ def _finalize(candidate: _Candidate, configured: frozenset[str]) -> CaptchaObservation:
857
+ solvers = _solver_matches(candidate, configured)
858
+ evidence = tuple(sorted(candidate.evidence, key=lambda item: (item.source, item.code)))
859
+ return CaptchaObservation(
860
+ family=candidate.family,
861
+ vendor=candidate.vendor,
862
+ module_hint=candidate.module_hint,
863
+ confidence=candidate.confidence,
864
+ presence=candidate.presence,
865
+ evidence=evidence,
866
+ solvers=solvers,
867
+ )
868
+
869
+
870
+ def _solver_matches(candidate: _Candidate, configured: frozenset[str]) -> tuple[SolverMatch, ...]:
871
+ if candidate.family == "unknown":
872
+ return ()
873
+ required: tuple[tuple[str, bool], ...] = (
874
+ (("image_body", candidate.has_image),)
875
+ if candidate.family == "image_to_text"
876
+ else (
877
+ ("website_url", candidate.has_website_url),
878
+ ("website_key", candidate.has_website_key),
879
+ )
880
+ )
881
+ if candidate.family == "cloudflare_challenge":
882
+ required += (
883
+ ("action", candidate.has_action),
884
+ ("data", candidate.has_data),
885
+ ("page_data", candidate.has_page_data),
886
+ ("user_agent", candidate.has_user_agent),
887
+ )
888
+ missing = tuple(name for name, present in required if not present)
889
+ resource_configured = "resource_proxy_capmonster" in configured
890
+ matches = [
891
+ SolverMatch(
892
+ component="resource_proxy_capmonster",
893
+ task_type=candidate.family,
894
+ integrated=True,
895
+ configured=resource_configured,
896
+ can_attempt=resource_configured and not missing,
897
+ missing_inputs=missing,
898
+ )
899
+ ]
900
+ if candidate.family == "image_to_text":
901
+ vision_configured = "vision_model" in configured
902
+ matches.append(
903
+ SolverMatch(
904
+ component="vision_model",
905
+ task_type="image_to_text",
906
+ integrated=True,
907
+ configured=vision_configured,
908
+ can_attempt=vision_configured and not missing,
909
+ missing_inputs=missing,
910
+ )
911
+ )
912
+ return tuple(matches)