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