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