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,67 @@
|
|
|
1
|
+
"""Lightweight label dispatch for ordinary developer process functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .context import CrawlContext
|
|
9
|
+
from .errors import ConfigurationError
|
|
10
|
+
from .models import CaptureResult
|
|
11
|
+
|
|
12
|
+
Handler = Callable[[CaptureResult, CrawlContext], Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RouteAlreadyRegistered(ConfigurationError):
|
|
16
|
+
"""Raised when a label is registered more than once."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RouteNotFound(ConfigurationError):
|
|
20
|
+
"""Raised when a capture has neither a matching route nor a default."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Router:
|
|
24
|
+
"""Dispatch a capture to a sync or async handler using its request label.
|
|
25
|
+
|
|
26
|
+
The router is itself an ordinary ``process(capture, context)`` callable. It
|
|
27
|
+
deliberately returns the selected handler's value unchanged; the crawler's
|
|
28
|
+
existing process semantics await async results and pass through sync results.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
routes: Mapping[str, Handler] | None = None,
|
|
34
|
+
*,
|
|
35
|
+
default: Handler | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
if default is not None and not callable(default):
|
|
38
|
+
raise ConfigurationError("Default route must be callable")
|
|
39
|
+
self._routes: dict[str, Handler] = {}
|
|
40
|
+
self._default = default
|
|
41
|
+
for label, handler in (routes or {}).items():
|
|
42
|
+
self.register(label, handler)
|
|
43
|
+
|
|
44
|
+
def register(self, label: str, handler: Handler) -> Handler:
|
|
45
|
+
"""Register one exact label and return the handler for decorator use."""
|
|
46
|
+
if not isinstance(label, str):
|
|
47
|
+
raise ConfigurationError("Route label must be a string")
|
|
48
|
+
if not callable(handler):
|
|
49
|
+
raise ConfigurationError("Route handler must be callable")
|
|
50
|
+
if label in self._routes:
|
|
51
|
+
raise RouteAlreadyRegistered(f"Route label {label!r} is already registered")
|
|
52
|
+
self._routes[label] = handler
|
|
53
|
+
return handler
|
|
54
|
+
|
|
55
|
+
def route(self, label: str) -> Callable[[Handler], Handler]:
|
|
56
|
+
"""Return a small decorator that registers an exact label."""
|
|
57
|
+
|
|
58
|
+
def decorate(handler: Handler) -> Handler:
|
|
59
|
+
return self.register(label, handler)
|
|
60
|
+
|
|
61
|
+
return decorate
|
|
62
|
+
|
|
63
|
+
def __call__(self, capture: CaptureResult, context: CrawlContext) -> Any:
|
|
64
|
+
handler = self._routes.get(capture.request.label, self._default)
|
|
65
|
+
if handler is None:
|
|
66
|
+
raise RouteNotFound(f"No route registered for request label {capture.request.label!r}")
|
|
67
|
+
return handler(capture, context)
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Optional resource-aware concurrency and conservative acquisition selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from html.parser import HTMLParser
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal, Protocol
|
|
16
|
+
|
|
17
|
+
try: # The stdlib module is unavailable on Windows.
|
|
18
|
+
import resource as _resource_module
|
|
19
|
+
except ImportError: # pragma: no cover - exercised through the platform-independent helper test
|
|
20
|
+
_resource: Any = None
|
|
21
|
+
else:
|
|
22
|
+
_resource = _resource_module
|
|
23
|
+
|
|
24
|
+
from .errors import ConfigurationError
|
|
25
|
+
from .models import CaptureResult, Request
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ResourceSample:
|
|
30
|
+
"""Normalized pressure plus scope; pressure may exceed one under contention."""
|
|
31
|
+
|
|
32
|
+
cpu: float
|
|
33
|
+
memory: float
|
|
34
|
+
cpu_scope: Literal["cgroup", "local"] = "local"
|
|
35
|
+
memory_scope: Literal["cgroup", "local", "process", "unavailable"] = "local"
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
for name in ("cpu", "memory"):
|
|
39
|
+
value = getattr(self, name)
|
|
40
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
41
|
+
raise ConfigurationError(f"ResourceSample.{name} must be numeric")
|
|
42
|
+
if not math.isfinite(value) or value < 0:
|
|
43
|
+
raise ConfigurationError(f"ResourceSample.{name} must be finite and nonnegative")
|
|
44
|
+
if self.cpu_scope not in {"cgroup", "local"}:
|
|
45
|
+
raise ConfigurationError("ResourceSample.cpu_scope is invalid")
|
|
46
|
+
if self.memory_scope not in {"cgroup", "local", "process", "unavailable"}:
|
|
47
|
+
raise ConfigurationError("ResourceSample.memory_scope is invalid")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ResourceSampler(Protocol):
|
|
51
|
+
def __call__(self) -> ResourceSample | Awaitable[ResourceSample]: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_number(path: str, *, allow_zero: bool = False) -> float | None:
|
|
55
|
+
try:
|
|
56
|
+
value = Path(path).read_text(encoding="ascii").strip()
|
|
57
|
+
if value == "max":
|
|
58
|
+
return None
|
|
59
|
+
number = float(value)
|
|
60
|
+
minimum_ok = number >= 0 if allow_zero else number > 0
|
|
61
|
+
return number if math.isfinite(number) and minimum_ok else None
|
|
62
|
+
except (OSError, ValueError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cgroup_cpu_count(root: Path) -> float | None:
|
|
67
|
+
try:
|
|
68
|
+
quota_text, period_text = (root / "cpu.max").read_text(encoding="ascii").split()[:2]
|
|
69
|
+
if quota_text != "max" and float(quota_text) > 0 and float(period_text) > 0:
|
|
70
|
+
return float(quota_text) / float(period_text)
|
|
71
|
+
except (OSError, ValueError, IndexError):
|
|
72
|
+
pass
|
|
73
|
+
quota_value = _read_number(str(root / "cpu/cpu.cfs_quota_us"))
|
|
74
|
+
period_value = _read_number(str(root / "cpu/cpu.cfs_period_us"))
|
|
75
|
+
return (
|
|
76
|
+
quota_value / period_value
|
|
77
|
+
if quota_value is not None and period_value is not None
|
|
78
|
+
else None
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _effective_cpu_count(root: Path) -> float:
|
|
83
|
+
local = float(os.cpu_count() or 1)
|
|
84
|
+
cgroup = _cgroup_cpu_count(root)
|
|
85
|
+
return max(0.01, min(local, cgroup)) if cgroup is not None else local
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cgroup_cpu_usage(root: Path) -> float | None:
|
|
89
|
+
"""Return aggregate cgroup CPU seconds, including browser child processes."""
|
|
90
|
+
try:
|
|
91
|
+
values = dict(
|
|
92
|
+
line.split(maxsplit=1)
|
|
93
|
+
for line in (root / "cpu.stat").read_text(encoding="ascii").splitlines()
|
|
94
|
+
if len(line.split(maxsplit=1)) == 2
|
|
95
|
+
)
|
|
96
|
+
usage = float(values["usage_usec"]) / 1_000_000
|
|
97
|
+
return usage if math.isfinite(usage) and usage >= 0 else None
|
|
98
|
+
except (KeyError, OSError, ValueError):
|
|
99
|
+
pass
|
|
100
|
+
usage_ns = _read_number(str(root / "cpuacct/cpuacct.usage"), allow_zero=True)
|
|
101
|
+
return usage_ns / 1_000_000_000 if usage_ns is not None else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _current_rss(proc_root: Path) -> float:
|
|
105
|
+
if sys.platform.startswith("linux"):
|
|
106
|
+
try:
|
|
107
|
+
fields = (proc_root / "self/statm").read_text(encoding="ascii").split()
|
|
108
|
+
return float(fields[1]) * os.sysconf("SC_PAGE_SIZE")
|
|
109
|
+
except (AttributeError, OSError, ValueError, IndexError):
|
|
110
|
+
pass
|
|
111
|
+
if _resource is None:
|
|
112
|
+
return 0.0
|
|
113
|
+
peak = float(_resource.getrusage(_resource.RUSAGE_SELF).ru_maxrss)
|
|
114
|
+
return peak if sys.platform == "darwin" else peak * 1024
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _local_memory_pressure(proc_root: Path) -> float | None:
|
|
118
|
+
try:
|
|
119
|
+
values = {
|
|
120
|
+
fields[0].rstrip(":"): float(fields[1]) * 1024
|
|
121
|
+
for line in (proc_root / "meminfo").read_text(encoding="ascii").splitlines()
|
|
122
|
+
if len(fields := line.split()) >= 2
|
|
123
|
+
}
|
|
124
|
+
total, available = values["MemTotal"], values["MemAvailable"]
|
|
125
|
+
if total > 0:
|
|
126
|
+
return max(0.0, min(1.0, (total - available) / total))
|
|
127
|
+
except (KeyError, OSError, ValueError):
|
|
128
|
+
pass
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _memory_values(
|
|
133
|
+
root: Path, proc_root: Path
|
|
134
|
+
) -> tuple[float, Literal["cgroup", "local", "process", "unavailable"]]:
|
|
135
|
+
current = _read_number(str(root / "memory.current"), allow_zero=True)
|
|
136
|
+
limit = _read_number(str(root / "memory.max"))
|
|
137
|
+
if current is None:
|
|
138
|
+
current = _read_number(
|
|
139
|
+
str(root / "memory/memory.usage_in_bytes"), allow_zero=True
|
|
140
|
+
)
|
|
141
|
+
if limit is None:
|
|
142
|
+
limit = _read_number(str(root / "memory/memory.limit_in_bytes"))
|
|
143
|
+
if current is not None and limit is not None:
|
|
144
|
+
return current / max(limit, 1.0), "cgroup"
|
|
145
|
+
local = _local_memory_pressure(proc_root)
|
|
146
|
+
if local is not None:
|
|
147
|
+
return local, "local"
|
|
148
|
+
rss = _current_rss(proc_root)
|
|
149
|
+
try:
|
|
150
|
+
physical = float(os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES"))
|
|
151
|
+
except (AttributeError, OSError, ValueError):
|
|
152
|
+
physical = 0.0
|
|
153
|
+
if rss > 0 and physical > 0:
|
|
154
|
+
# Darwin's ru_maxrss fallback is peak process RSS; it excludes browser children.
|
|
155
|
+
return rss / physical, "process"
|
|
156
|
+
return 0.0, "unavailable"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class LocalResourceSampler:
|
|
160
|
+
"""Sample cgroup-wide pressure, falling back to explicitly scoped local metrics.
|
|
161
|
+
|
|
162
|
+
Cgroup CPU and memory include browser subprocesses. Outside a cgroup, CPU uses
|
|
163
|
+
the larger of process CPU and host load average; Linux memory uses MemAvailable.
|
|
164
|
+
The final portable memory fallback is process RSS (peak RSS on Darwin).
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
*,
|
|
170
|
+
cgroup_root: str | Path = "/sys/fs/cgroup",
|
|
171
|
+
proc_root: str | Path = "/proc",
|
|
172
|
+
) -> None:
|
|
173
|
+
self._cgroup_root = Path(cgroup_root)
|
|
174
|
+
self._proc_root = Path(proc_root)
|
|
175
|
+
self._wall = time.monotonic()
|
|
176
|
+
self._cpu = time.process_time()
|
|
177
|
+
self._cgroup_cpu = _cgroup_cpu_usage(self._cgroup_root)
|
|
178
|
+
|
|
179
|
+
def __call__(self) -> ResourceSample:
|
|
180
|
+
wall, cpu = time.monotonic(), time.process_time()
|
|
181
|
+
elapsed = max(wall - self._wall, 1e-9)
|
|
182
|
+
cgroup_cpu = _cgroup_cpu_usage(self._cgroup_root)
|
|
183
|
+
effective_cpus = _effective_cpu_count(self._cgroup_root)
|
|
184
|
+
if cgroup_cpu is not None and self._cgroup_cpu is not None:
|
|
185
|
+
cpu_pressure = max(cgroup_cpu - self._cgroup_cpu, 0.0) / (
|
|
186
|
+
elapsed * effective_cpus
|
|
187
|
+
)
|
|
188
|
+
cpu_scope: Literal["cgroup", "local"] = "cgroup"
|
|
189
|
+
else:
|
|
190
|
+
process_pressure = max(cpu - self._cpu, 0.0) / (elapsed * effective_cpus)
|
|
191
|
+
try:
|
|
192
|
+
load_pressure = max(float(os.getloadavg()[0]), 0.0) / effective_cpus
|
|
193
|
+
except (AttributeError, OSError):
|
|
194
|
+
load_pressure = 0.0
|
|
195
|
+
cpu_pressure = max(process_pressure, load_pressure)
|
|
196
|
+
cpu_scope = "local"
|
|
197
|
+
self._wall, self._cpu = wall, cpu
|
|
198
|
+
self._cgroup_cpu = cgroup_cpu
|
|
199
|
+
memory_pressure, memory_scope = _memory_values(self._cgroup_root, self._proc_root)
|
|
200
|
+
return ResourceSample(cpu_pressure, memory_pressure, cpu_scope, memory_scope)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class AutoConcurrency:
|
|
205
|
+
"""Configuration for a bounded, acquisition-driven concurrency controller."""
|
|
206
|
+
|
|
207
|
+
min_concurrency: int = 1
|
|
208
|
+
max_concurrency: int | None = None
|
|
209
|
+
sample_interval: float = 0.5
|
|
210
|
+
target_cpu: float = 0.75
|
|
211
|
+
target_memory: float = 0.80
|
|
212
|
+
decrease_ratio: float = 0.5
|
|
213
|
+
sampler: ResourceSampler | None = None
|
|
214
|
+
|
|
215
|
+
def __post_init__(self) -> None:
|
|
216
|
+
for name in ("min_concurrency", "max_concurrency"):
|
|
217
|
+
value = getattr(self, name)
|
|
218
|
+
if value is not None and (
|
|
219
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 1
|
|
220
|
+
):
|
|
221
|
+
raise ConfigurationError(f"{name} must be a positive integer or None")
|
|
222
|
+
if self.max_concurrency is not None and self.min_concurrency > self.max_concurrency:
|
|
223
|
+
raise ConfigurationError("min_concurrency cannot exceed max_concurrency")
|
|
224
|
+
for name in ("sample_interval", "target_cpu", "target_memory", "decrease_ratio"):
|
|
225
|
+
value = getattr(self, name)
|
|
226
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
227
|
+
raise ConfigurationError(f"{name} must be numeric")
|
|
228
|
+
if not math.isfinite(value) or value <= 0:
|
|
229
|
+
raise ConfigurationError(f"{name} must be finite and positive")
|
|
230
|
+
if self.target_cpu > 1 or self.target_memory > 1 or self.decrease_ratio >= 1:
|
|
231
|
+
raise ConfigurationError("resource targets must be <= 1 and decrease_ratio must be < 1")
|
|
232
|
+
|
|
233
|
+
def controller(self, ceiling: int) -> ConcurrencyController:
|
|
234
|
+
return ConcurrencyController(self, ceiling=ceiling)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ConcurrencyController:
|
|
238
|
+
"""Resizable async permit gate that never revokes active acquisitions."""
|
|
239
|
+
|
|
240
|
+
def __init__(self, config: AutoConcurrency, *, ceiling: int) -> None:
|
|
241
|
+
if isinstance(ceiling, bool) or not isinstance(ceiling, int) or ceiling < 1:
|
|
242
|
+
raise ConfigurationError("concurrency ceiling must be a positive integer")
|
|
243
|
+
configured = config.max_concurrency if config.max_concurrency is not None else ceiling
|
|
244
|
+
self._ceiling = min(ceiling, configured)
|
|
245
|
+
if config.min_concurrency > self._ceiling:
|
|
246
|
+
raise ConfigurationError("min_concurrency cannot exceed the concurrency ceiling")
|
|
247
|
+
self._config = config
|
|
248
|
+
self._sampler = config.sampler or LocalResourceSampler()
|
|
249
|
+
self._condition = asyncio.Condition()
|
|
250
|
+
self._sample_lock = asyncio.Lock()
|
|
251
|
+
self._limit = config.min_concurrency
|
|
252
|
+
self._active = 0
|
|
253
|
+
self._last_sample_at = -math.inf
|
|
254
|
+
self._last_sample: ResourceSample | None = None
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def current_limit(self) -> int:
|
|
258
|
+
return self._limit
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def active(self) -> int:
|
|
262
|
+
return self._active
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def last_sample(self) -> ResourceSample | None:
|
|
266
|
+
return self._last_sample
|
|
267
|
+
|
|
268
|
+
async def _sample_if_due(self) -> None:
|
|
269
|
+
now = time.monotonic()
|
|
270
|
+
if now - self._last_sample_at < self._config.sample_interval:
|
|
271
|
+
return
|
|
272
|
+
async with self._sample_lock:
|
|
273
|
+
now = time.monotonic()
|
|
274
|
+
if now - self._last_sample_at < self._config.sample_interval:
|
|
275
|
+
return
|
|
276
|
+
value = self._sampler()
|
|
277
|
+
sample = await value if inspect.isawaitable(value) else value
|
|
278
|
+
if not isinstance(sample, ResourceSample):
|
|
279
|
+
raise ConfigurationError("resource sampler must return ResourceSample")
|
|
280
|
+
pressured = (
|
|
281
|
+
sample.cpu >= self._config.target_cpu
|
|
282
|
+
or sample.memory >= self._config.target_memory
|
|
283
|
+
)
|
|
284
|
+
async with self._condition:
|
|
285
|
+
if pressured:
|
|
286
|
+
reduced = max(
|
|
287
|
+
self._config.min_concurrency,
|
|
288
|
+
math.floor(self._limit * self._config.decrease_ratio),
|
|
289
|
+
)
|
|
290
|
+
self._limit = min(self._limit, reduced)
|
|
291
|
+
elif self._limit < self._ceiling:
|
|
292
|
+
self._limit += 1
|
|
293
|
+
self._last_sample = sample
|
|
294
|
+
self._last_sample_at = now
|
|
295
|
+
self._condition.notify_all()
|
|
296
|
+
|
|
297
|
+
async def acquire(self) -> None:
|
|
298
|
+
await self._sample_if_due()
|
|
299
|
+
async with self._condition:
|
|
300
|
+
await self._condition.wait_for(lambda: self._active < self._limit)
|
|
301
|
+
self._active += 1
|
|
302
|
+
|
|
303
|
+
async def release(self) -> None:
|
|
304
|
+
async with self._condition:
|
|
305
|
+
if self._active < 1:
|
|
306
|
+
raise RuntimeError("Concurrency permit released without an active acquisition")
|
|
307
|
+
self._active -= 1
|
|
308
|
+
self._condition.notify_all()
|
|
309
|
+
|
|
310
|
+
async def __aenter__(self) -> ConcurrencyController:
|
|
311
|
+
await self.acquire()
|
|
312
|
+
return self
|
|
313
|
+
|
|
314
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
315
|
+
await self.release()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(frozen=True)
|
|
319
|
+
class AdaptiveDecision:
|
|
320
|
+
use_browser: bool
|
|
321
|
+
reason: str
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
Validation = Callable[[CaptureResult], bool | Awaitable[bool]]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class _VisibleText(HTMLParser):
|
|
328
|
+
def __init__(self) -> None:
|
|
329
|
+
super().__init__()
|
|
330
|
+
self._hidden = 0
|
|
331
|
+
self.parts: list[str] = []
|
|
332
|
+
|
|
333
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
334
|
+
if tag.lower() in {"script", "style", "noscript", "template"}:
|
|
335
|
+
self._hidden += 1
|
|
336
|
+
|
|
337
|
+
def handle_endtag(self, tag: str) -> None:
|
|
338
|
+
if tag.lower() in {"script", "style", "noscript", "template"} and self._hidden:
|
|
339
|
+
self._hidden -= 1
|
|
340
|
+
|
|
341
|
+
def handle_data(self, data: str) -> None:
|
|
342
|
+
if not self._hidden:
|
|
343
|
+
self.parts.append(data)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _looks_like_javascript_shell(capture: CaptureResult) -> bool:
|
|
347
|
+
content_type = capture.headers.get("content-type", "").lower()
|
|
348
|
+
if "html" not in content_type and not capture.body.lstrip().lower().startswith(b"<!doctype"):
|
|
349
|
+
return False
|
|
350
|
+
text = capture.text
|
|
351
|
+
parser = _VisibleText()
|
|
352
|
+
try:
|
|
353
|
+
parser.feed(text)
|
|
354
|
+
except Exception:
|
|
355
|
+
return False
|
|
356
|
+
visible = " ".join(" ".join(parser.parts).split())
|
|
357
|
+
lowered = text.lower()
|
|
358
|
+
root_marker = any(marker in lowered for marker in ('id="root"', "id='root'", "__next"))
|
|
359
|
+
return len(visible) < 24 and root_marker and "<script" in lowered
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class AdaptivePolicy:
|
|
363
|
+
"""Choose a browser retry only when a safe HTTP GET is demonstrably incomplete."""
|
|
364
|
+
|
|
365
|
+
def __init__(self, validate: Validation | None = None) -> None:
|
|
366
|
+
if validate is not None and not callable(validate):
|
|
367
|
+
raise ConfigurationError("adaptive validate must be callable or None")
|
|
368
|
+
self._validate = validate
|
|
369
|
+
|
|
370
|
+
def initial_request(self, request: Request) -> Request:
|
|
371
|
+
self._check_request(request)
|
|
372
|
+
return replace(request, mode="http")
|
|
373
|
+
|
|
374
|
+
def browser_request(self, request: Request) -> Request:
|
|
375
|
+
self._check_request(request)
|
|
376
|
+
if request.method != "GET":
|
|
377
|
+
raise ConfigurationError("Only adaptive GET requests can use browser fallback")
|
|
378
|
+
return replace(request, mode="browser")
|
|
379
|
+
|
|
380
|
+
async def decide(self, capture: CaptureResult) -> AdaptiveDecision:
|
|
381
|
+
method = capture.request.method
|
|
382
|
+
if method == "HEAD":
|
|
383
|
+
return AdaptiveDecision(False, "head_http_only")
|
|
384
|
+
if method != "GET":
|
|
385
|
+
return AdaptiveDecision(False, "method_not_replayable")
|
|
386
|
+
status = capture.status
|
|
387
|
+
if status is None or not 200 <= status < 300:
|
|
388
|
+
return AdaptiveDecision(False, "http_status_not_eligible")
|
|
389
|
+
lowered = capture.body.lower()
|
|
390
|
+
if any(
|
|
391
|
+
marker in lowered
|
|
392
|
+
for marker in (
|
|
393
|
+
b"captcha",
|
|
394
|
+
b"verify you are human",
|
|
395
|
+
b"/cdn-cgi/challenge",
|
|
396
|
+
b"cf-chl-",
|
|
397
|
+
)
|
|
398
|
+
):
|
|
399
|
+
return AdaptiveDecision(False, "access_challenge_detected")
|
|
400
|
+
if self._validate is not None:
|
|
401
|
+
result = self._validate(capture)
|
|
402
|
+
valid = await result if inspect.isawaitable(result) else result
|
|
403
|
+
if not isinstance(valid, bool):
|
|
404
|
+
raise ConfigurationError("adaptive validate must return a boolean")
|
|
405
|
+
return AdaptiveDecision(not valid, "validation_failed" if not valid else "validated")
|
|
406
|
+
if status == 200 and not capture.body.strip():
|
|
407
|
+
return AdaptiveDecision(True, "empty_http_body")
|
|
408
|
+
if _looks_like_javascript_shell(capture):
|
|
409
|
+
return AdaptiveDecision(True, "javascript_shell")
|
|
410
|
+
return AdaptiveDecision(False, "http_content_present")
|
|
411
|
+
|
|
412
|
+
@staticmethod
|
|
413
|
+
def _check_request(request: Request) -> None:
|
|
414
|
+
if request.mode != "auto":
|
|
415
|
+
raise ConfigurationError("AdaptivePolicy requires Request.mode='auto'")
|
|
416
|
+
if request.method not in {"GET", "HEAD"}:
|
|
417
|
+
raise ConfigurationError("Adaptive acquisition supports only GET or HEAD")
|
|
418
|
+
if request.headers:
|
|
419
|
+
raise ConfigurationError(
|
|
420
|
+
"Adaptive browser fallback requires origin-scoped Identity.headers"
|
|
421
|
+
)
|
|
422
|
+
if request.content is not None or request.json is not None:
|
|
423
|
+
raise ConfigurationError("Adaptive acquisition does not replay request bodies")
|
|
424
|
+
if request.method == "GET" and not request.can_retry:
|
|
425
|
+
raise ConfigurationError("Adaptive GET requests must be replayable")
|