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,77 @@
|
|
|
1
|
+
"""Local-only helpers for starting a Crawler app with managed resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .errors import ConfigurationError
|
|
11
|
+
|
|
12
|
+
_MAX_API_KEY_FILE_BYTES = 8 * 1024
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_api_key_file(path: str | Path) -> str:
|
|
16
|
+
"""Read one developer API Key from a regular local file.
|
|
17
|
+
|
|
18
|
+
The file is consumed locally and the value is returned to the caller. It is
|
|
19
|
+
never sent as a file to Resource Proxy. Symlinks, directories, special files,
|
|
20
|
+
multiple lines, and whitespace inside the key are rejected.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
source = Path(path).expanduser()
|
|
24
|
+
try:
|
|
25
|
+
info = source.lstat()
|
|
26
|
+
except OSError as error:
|
|
27
|
+
raise ConfigurationError(
|
|
28
|
+
f"cannot inspect API Key file ({type(error).__name__})"
|
|
29
|
+
) from None
|
|
30
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
31
|
+
raise ConfigurationError("API Key file must be a regular file, not a symlink or directory")
|
|
32
|
+
if info.st_size > _MAX_API_KEY_FILE_BYTES:
|
|
33
|
+
raise ConfigurationError("API Key file is too large")
|
|
34
|
+
try:
|
|
35
|
+
value = source.read_text(encoding="utf-8")
|
|
36
|
+
except (OSError, UnicodeError) as error:
|
|
37
|
+
raise ConfigurationError(
|
|
38
|
+
f"cannot read API Key file ({type(error).__name__})"
|
|
39
|
+
) from None
|
|
40
|
+
lines = value.splitlines()
|
|
41
|
+
if len(lines) != 1 or not lines[0].strip() or lines[0].strip() != lines[0]:
|
|
42
|
+
raise ConfigurationError("API Key file must contain exactly one non-empty line")
|
|
43
|
+
key = lines[0]
|
|
44
|
+
if any(character.isspace() for character in key):
|
|
45
|
+
raise ConfigurationError("API Key file must contain one Key without whitespace")
|
|
46
|
+
return key
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def local_resource_environment(
|
|
50
|
+
api_key_file: str | Path,
|
|
51
|
+
environ: Mapping[str, str] | None = None,
|
|
52
|
+
) -> dict[str, str]:
|
|
53
|
+
"""Return a child-process environment with the local API Key injected.
|
|
54
|
+
|
|
55
|
+
This helper does not mutate the parent process and does not persist the Key.
|
|
56
|
+
It is intended for a local launcher that starts ``app.py``. Platform Runs
|
|
57
|
+
use their injected Run identity and should not call this helper.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
result = dict(os.environ if environ is None else environ)
|
|
61
|
+
result["DATAAPP_API_KEY"] = read_api_key_file(api_key_file)
|
|
62
|
+
result.pop("DATAOS_API_KEY", None)
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_local_app(
|
|
67
|
+
api_key_file: str | Path,
|
|
68
|
+
command: Sequence[str],
|
|
69
|
+
) -> int:
|
|
70
|
+
"""Run a local app command with a file-backed API Key in its environment."""
|
|
71
|
+
|
|
72
|
+
if not command:
|
|
73
|
+
raise ConfigurationError("a local app command is required")
|
|
74
|
+
import subprocess
|
|
75
|
+
|
|
76
|
+
completed = subprocess.run(command, env=local_resource_environment(api_key_file))
|
|
77
|
+
return completed.returncode
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Public acquisition values. No platform Run, Dataset or workflow models live here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
from .errors import ConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def origin(url: str) -> str:
|
|
17
|
+
parts = urlsplit(url)
|
|
18
|
+
if parts.scheme not in {"http", "https"} or not parts.hostname:
|
|
19
|
+
raise ConfigurationError("Only absolute HTTP(S) URLs are supported")
|
|
20
|
+
if parts.username is not None or parts.password is not None:
|
|
21
|
+
raise ConfigurationError("Target URLs must not contain credentials")
|
|
22
|
+
port = parts.port
|
|
23
|
+
suffix = f":{port}" if port and port != (443 if parts.scheme == "https" else 80) else ""
|
|
24
|
+
host = f"[{parts.hostname}]" if ":" in parts.hostname else parts.hostname
|
|
25
|
+
return f"{parts.scheme}://{host}{suffix}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def safe_url(url: str) -> str:
|
|
29
|
+
"""Diagnostics omit query values, fragments and URL userinfo."""
|
|
30
|
+
parts = urlsplit(url)
|
|
31
|
+
host = parts.hostname or ""
|
|
32
|
+
if ":" in host:
|
|
33
|
+
host = f"[{host}]"
|
|
34
|
+
try:
|
|
35
|
+
port = parts.port
|
|
36
|
+
except ValueError:
|
|
37
|
+
port = None
|
|
38
|
+
return urlunsplit((parts.scheme, host + (f":{port}" if port else ""), parts.path, "", ""))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Request:
|
|
43
|
+
"""One bounded acquisition request.
|
|
44
|
+
|
|
45
|
+
``mode`` selects HTTP, browser, or adaptive acquisition. Browser requests
|
|
46
|
+
use ``wait_until`` only for navigation; site-specific readiness belongs in
|
|
47
|
+
``process`` and may call native Page methods through ``CrawlContext.run``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
url: str = field(repr=False)
|
|
51
|
+
method: str = "GET"
|
|
52
|
+
headers: dict[str, str] = field(default_factory=dict, repr=False)
|
|
53
|
+
params: dict[str, Any] | None = field(default=None, repr=False)
|
|
54
|
+
content: bytes | str | None = field(default=None, repr=False)
|
|
55
|
+
json: Any = field(default=None, repr=False)
|
|
56
|
+
label: str = ""
|
|
57
|
+
user_data: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
58
|
+
key: str | None = None
|
|
59
|
+
replayable: bool | None = None
|
|
60
|
+
mode: Literal["http", "browser", "auto"] = "http"
|
|
61
|
+
site_key: str | None = None
|
|
62
|
+
session_key: str | None = field(default=None, repr=False)
|
|
63
|
+
deduplicate: bool = True
|
|
64
|
+
wait_until: Literal["commit", "domcontentloaded", "load", "networkidle"] = "domcontentloaded"
|
|
65
|
+
snapshot: bool = True
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
origin(self.url)
|
|
69
|
+
object.__setattr__(self, "method", self.method.upper())
|
|
70
|
+
if self.mode not in {"http", "browser", "auto"}:
|
|
71
|
+
raise ConfigurationError("Unknown acquisition mode")
|
|
72
|
+
if self.wait_until not in {"commit", "domcontentloaded", "load", "networkidle"}:
|
|
73
|
+
raise ConfigurationError("Unknown browser navigation wait strategy")
|
|
74
|
+
if not isinstance(self.snapshot, bool):
|
|
75
|
+
raise ConfigurationError("snapshot must be a boolean")
|
|
76
|
+
if self.mode == "auto" and self.method not in {"GET", "HEAD"}:
|
|
77
|
+
raise ConfigurationError("Adaptive acquisition supports GET or HEAD only")
|
|
78
|
+
if self.mode == "auto" and (
|
|
79
|
+
self.headers or self.content is not None or self.json is not None
|
|
80
|
+
):
|
|
81
|
+
raise ConfigurationError("Adaptive identity headers belong in origin-scoped Identity")
|
|
82
|
+
if self.mode == "auto" and self.method == "GET" and not self.can_retry:
|
|
83
|
+
raise ConfigurationError("Adaptive GET requests must be replayable")
|
|
84
|
+
if self.mode == "browser" and self.method != "GET":
|
|
85
|
+
raise ConfigurationError("Browser navigation supports GET; use native Page for actions")
|
|
86
|
+
if self.mode == "browser" and self.headers:
|
|
87
|
+
raise ConfigurationError("Browser headers must use origin-scoped Identity.headers")
|
|
88
|
+
if self.content is not None and self.json is not None:
|
|
89
|
+
raise ConfigurationError("Specify either content or json")
|
|
90
|
+
if any(k.lower() == "proxy-authorization" for k in self.headers):
|
|
91
|
+
raise ConfigurationError("Proxy authentication belongs in proxy configuration")
|
|
92
|
+
if self.session_key is not None and (
|
|
93
|
+
not isinstance(self.session_key, str) or not self.session_key
|
|
94
|
+
):
|
|
95
|
+
raise ConfigurationError("session_key must be a non-empty string or None")
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def can_retry(self) -> bool:
|
|
99
|
+
return (
|
|
100
|
+
self.replayable
|
|
101
|
+
if self.replayable is not None
|
|
102
|
+
else self.method in {"GET", "HEAD", "OPTIONS"}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def unique_key(self) -> str:
|
|
107
|
+
if self.key is not None:
|
|
108
|
+
return self.key
|
|
109
|
+
payload = [
|
|
110
|
+
self.method,
|
|
111
|
+
self.url,
|
|
112
|
+
self.params,
|
|
113
|
+
self.headers,
|
|
114
|
+
self.content,
|
|
115
|
+
self.json,
|
|
116
|
+
self.label,
|
|
117
|
+
self.user_data,
|
|
118
|
+
self.mode,
|
|
119
|
+
]
|
|
120
|
+
if self.mode in {"browser", "auto"}:
|
|
121
|
+
payload.extend([self.wait_until, self.snapshot])
|
|
122
|
+
return hashlib.sha256(json.dumps(payload, default=str, sort_keys=True).encode()).hexdigest()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Identity:
|
|
127
|
+
"""Single-run website state. Header keys are exact HTTP(S) origins, not wildcards."""
|
|
128
|
+
|
|
129
|
+
cookies: list[dict[str, Any]] = field(default_factory=list, repr=False)
|
|
130
|
+
headers: dict[str, dict[str, str]] = field(default_factory=dict, repr=False)
|
|
131
|
+
storage_state: dict[str, Any] | None = field(default=None, repr=False)
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
for scope in self.headers:
|
|
135
|
+
parts = urlsplit(scope)
|
|
136
|
+
if parts.path not in {"", "/"} or parts.query or parts.fragment:
|
|
137
|
+
raise ConfigurationError("Identity header scope must be an exact origin")
|
|
138
|
+
self.headers = {origin(k): dict(v) for k, v in self.headers.items()}
|
|
139
|
+
cookies = [*(self.storage_state or {}).get("cookies", []), *self.cookies]
|
|
140
|
+
for cookie in cookies:
|
|
141
|
+
domain = cookie.get("domain")
|
|
142
|
+
if not isinstance(domain, str) or not domain or not cookie.get("name"):
|
|
143
|
+
raise ConfigurationError("Identity cookies require an explicit domain and name")
|
|
144
|
+
if domain.startswith("."):
|
|
145
|
+
raise ConfigurationError("Identity cookies must be host-only")
|
|
146
|
+
for headers in self.headers.values():
|
|
147
|
+
if any(k.lower() in {"cookie", "proxy-authorization", "host"} for k in headers):
|
|
148
|
+
raise ConfigurationError(
|
|
149
|
+
"Use domain cookies and proxy configuration for authentication"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def headers_for(self, url: str) -> dict[str, str]:
|
|
153
|
+
return dict(self.headers.get(origin(url), {}))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class Limits:
|
|
158
|
+
"""Finite crawl, concurrency, response-size, retry, and timeout budgets.
|
|
159
|
+
|
|
160
|
+
``navigation_timeout`` and ``snapshot_timeout`` default to
|
|
161
|
+
``request_timeout``. These acquisition budgets are separate from any short
|
|
162
|
+
Page-operation timeout supplied to ``CrawlContext.run`` by a process.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
concurrency: int = 4
|
|
166
|
+
per_site_concurrency: int = 2
|
|
167
|
+
max_requests: int = 1000
|
|
168
|
+
max_pages: int = 1000
|
|
169
|
+
max_results: int = 1000
|
|
170
|
+
max_pending: int = 1000
|
|
171
|
+
max_duration: float = 300
|
|
172
|
+
request_timeout: float = 30
|
|
173
|
+
min_interval: float = 0.25
|
|
174
|
+
retries: int = 2
|
|
175
|
+
backoff: float = 0.5
|
|
176
|
+
max_response_bytes: int = 10 * 1024 * 1024
|
|
177
|
+
max_redirects: int = 10
|
|
178
|
+
navigation_timeout: float | None = None
|
|
179
|
+
snapshot_timeout: float | None = None
|
|
180
|
+
|
|
181
|
+
def __post_init__(self) -> None:
|
|
182
|
+
for name in (
|
|
183
|
+
"concurrency",
|
|
184
|
+
"per_site_concurrency",
|
|
185
|
+
"max_requests",
|
|
186
|
+
"max_pages",
|
|
187
|
+
"max_results",
|
|
188
|
+
"max_pending",
|
|
189
|
+
"max_response_bytes",
|
|
190
|
+
):
|
|
191
|
+
value = getattr(self, name)
|
|
192
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
193
|
+
raise ConfigurationError(f"{name} must be a positive integer")
|
|
194
|
+
for name in ("max_duration", "request_timeout", "min_interval", "backoff"):
|
|
195
|
+
value = getattr(self, name)
|
|
196
|
+
if not math.isfinite(value) or value < 0 or (name.endswith("timeout") and value == 0):
|
|
197
|
+
raise ConfigurationError(f"{name} must be finite and within its allowed range")
|
|
198
|
+
if self.max_duration == 0:
|
|
199
|
+
raise ConfigurationError("max_duration must be positive")
|
|
200
|
+
for name in ("navigation_timeout", "snapshot_timeout"):
|
|
201
|
+
value = getattr(self, name)
|
|
202
|
+
if value is not None and (
|
|
203
|
+
isinstance(value, bool)
|
|
204
|
+
or not isinstance(value, (int, float))
|
|
205
|
+
or not math.isfinite(value)
|
|
206
|
+
or value <= 0
|
|
207
|
+
):
|
|
208
|
+
raise ConfigurationError(f"{name} must be a finite positive number or None")
|
|
209
|
+
for value in (self.retries, self.max_redirects):
|
|
210
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
211
|
+
raise ConfigurationError("Retry and redirect limits must be nonnegative integers")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class CaptureResult:
|
|
216
|
+
"""Raw acquired content and safe diagnostics passed to ``process``.
|
|
217
|
+
|
|
218
|
+
A browser ``page`` remains live only while the process callback runs. It is
|
|
219
|
+
cleared when the attempt closes; callers that need browser actions after a
|
|
220
|
+
fetch should instead use ``Crawler.browser()`` explicitly.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
request: Request = field(repr=False)
|
|
224
|
+
final_url: str = field(repr=False)
|
|
225
|
+
status: int | None
|
|
226
|
+
headers: dict[str, str] = field(default_factory=dict, repr=False)
|
|
227
|
+
body: bytes = field(default=b"", repr=False)
|
|
228
|
+
page: Any = field(default=None, repr=False)
|
|
229
|
+
elapsed: float = 0
|
|
230
|
+
attempts: int = 1
|
|
231
|
+
captured_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
232
|
+
diagnostics: dict[str, Any] = field(default_factory=dict)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def text(self) -> str:
|
|
236
|
+
content_type = self.headers.get("content-type", "")
|
|
237
|
+
encoding = (
|
|
238
|
+
content_type.split("charset=")[-1].split(";")[0].strip()
|
|
239
|
+
if "charset=" in content_type
|
|
240
|
+
else "utf-8"
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
return self.body.decode(encoding, errors="replace")
|
|
244
|
+
except LookupError:
|
|
245
|
+
return self.body.decode("utf-8", errors="replace")
|
|
246
|
+
|
|
247
|
+
def json(self) -> Any:
|
|
248
|
+
return json.loads(self.body)
|
|
249
|
+
|
|
250
|
+
def selector(self) -> Any:
|
|
251
|
+
from .parsing import selector
|
|
252
|
+
|
|
253
|
+
return selector(self.text)
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def access_issue(self) -> str | None:
|
|
257
|
+
if self.status is None:
|
|
258
|
+
return None
|
|
259
|
+
return {401: "authentication", 403: "access_denied", 429: "rate_limited"}.get(self.status)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class Stats:
|
|
264
|
+
requests: int = 0
|
|
265
|
+
pages: int = 0
|
|
266
|
+
captured: int = 0
|
|
267
|
+
results: int = 0
|
|
268
|
+
retries: int = 0
|
|
269
|
+
duplicates: int = 0
|
|
270
|
+
failed: int = 0
|
|
271
|
+
stop_reason: str | None = None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class Failure:
|
|
276
|
+
request: Request = field(repr=False)
|
|
277
|
+
kind: str
|
|
278
|
+
message: str
|
|
279
|
+
diagnostics: dict[str, Any] = field(default_factory=dict)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass
|
|
283
|
+
class CrawlItem:
|
|
284
|
+
data: Any = field(repr=False)
|
|
285
|
+
capture: CaptureResult = field(repr=False)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Bounded manual pagination for developer-owned site rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urldefrag, urljoin
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .context import CrawlContext
|
|
14
|
+
from .errors import ConfigurationError
|
|
15
|
+
from .models import CaptureResult, Request, origin
|
|
16
|
+
|
|
17
|
+
_STATE_KEY = "__dataos_pagination__"
|
|
18
|
+
_STATE_VERSION = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _digest(value: object) -> str:
|
|
22
|
+
encoded = json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode()
|
|
23
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _effective_url(request: Request) -> str:
|
|
27
|
+
"""Match HTTPX query handling while excluding fragments from loop identity."""
|
|
28
|
+
url = request.url
|
|
29
|
+
if request.params is not None:
|
|
30
|
+
url = str(httpx.URL(url, params=request.params))
|
|
31
|
+
return urldefrag(str(httpx.URL(url)))[0]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Pagination:
|
|
35
|
+
"""Enqueue developer-discovered pages with a finite, carried chain state.
|
|
36
|
+
|
|
37
|
+
``max_pages`` includes the current/seed page. URL pagination rejects a URL
|
|
38
|
+
already present in the chain, ignoring changing ``user_data``. Cursor-based
|
|
39
|
+
pagination permits the same URL but rejects a repeated explicit cursor. The
|
|
40
|
+
helper discovers no links or cursors itself and delegates queue deduplication
|
|
41
|
+
and pending/request budgets to ``CrawlContext.enqueue``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, *, max_pages: int = 100, same_origin: bool = True) -> None:
|
|
45
|
+
if isinstance(max_pages, bool) or not isinstance(max_pages, int) or max_pages < 1:
|
|
46
|
+
raise ConfigurationError("Pagination max_pages must be a positive integer")
|
|
47
|
+
if not isinstance(same_origin, bool):
|
|
48
|
+
raise ConfigurationError("Pagination same_origin must be a boolean")
|
|
49
|
+
self.max_pages = max_pages
|
|
50
|
+
self.same_origin = same_origin
|
|
51
|
+
|
|
52
|
+
def enqueue(
|
|
53
|
+
self,
|
|
54
|
+
capture: CaptureResult,
|
|
55
|
+
context: CrawlContext,
|
|
56
|
+
next_page: Request | str | None,
|
|
57
|
+
*,
|
|
58
|
+
cursor: str | int | None = None,
|
|
59
|
+
) -> bool:
|
|
60
|
+
"""Enqueue one explicit next page; false means filtered, repeated, or full chain.
|
|
61
|
+
|
|
62
|
+
Queue exceptions are intentionally propagated. Chain state advances only
|
|
63
|
+
on a request accepted by the crawl queue.
|
|
64
|
+
"""
|
|
65
|
+
state = self._read_state(capture, context)
|
|
66
|
+
if state["pages"] >= state["limit"] or next_page is None:
|
|
67
|
+
return False
|
|
68
|
+
if cursor is not None and (
|
|
69
|
+
isinstance(cursor, bool)
|
|
70
|
+
or not isinstance(cursor, (str, int))
|
|
71
|
+
or (isinstance(cursor, str) and not cursor)
|
|
72
|
+
):
|
|
73
|
+
raise ConfigurationError("Pagination cursor must be a non-empty string or integer")
|
|
74
|
+
|
|
75
|
+
request = self._request(capture, next_page)
|
|
76
|
+
if request is None:
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
if self.same_origin and origin(request.url) != origin(capture.final_url):
|
|
80
|
+
return False
|
|
81
|
+
except (ConfigurationError, ValueError):
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
url_token = _digest(["url", _effective_url(request)])
|
|
85
|
+
cursor_token = (
|
|
86
|
+
_digest(["cursor", type(cursor).__name__, cursor]) if cursor is not None else None
|
|
87
|
+
)
|
|
88
|
+
if cursor_token is not None:
|
|
89
|
+
if cursor_token in state["cursors"]:
|
|
90
|
+
return False
|
|
91
|
+
elif url_token in state["urls"]:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
next_state = {
|
|
95
|
+
"version": _STATE_VERSION,
|
|
96
|
+
"limit": state["limit"],
|
|
97
|
+
"pages": state["pages"] + 1,
|
|
98
|
+
"urls": [*state["urls"], url_token],
|
|
99
|
+
"cursors": [*state["cursors"], *([cursor_token] if cursor_token else [])],
|
|
100
|
+
}
|
|
101
|
+
# Always replace carried state, including when a developer derived the
|
|
102
|
+
# next Request from the current request's user_data.
|
|
103
|
+
request = replace(request, user_data={**request.user_data, _STATE_KEY: next_state})
|
|
104
|
+
return context.enqueue(request)
|
|
105
|
+
|
|
106
|
+
def _request(self, capture: CaptureResult, next_page: Request | str) -> Request | None:
|
|
107
|
+
if isinstance(next_page, Request):
|
|
108
|
+
return replace(next_page, url=urldefrag(next_page.url)[0])
|
|
109
|
+
if not isinstance(next_page, str):
|
|
110
|
+
raise ConfigurationError("Next page must be a Request, URL string, or None")
|
|
111
|
+
if not next_page.strip():
|
|
112
|
+
return None
|
|
113
|
+
candidate = next_page.strip()
|
|
114
|
+
if candidate.startswith("#"):
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
url = urldefrag(urljoin(capture.final_url, candidate))[0]
|
|
118
|
+
origin(url)
|
|
119
|
+
return Request(
|
|
120
|
+
url,
|
|
121
|
+
label=capture.request.label,
|
|
122
|
+
mode=capture.request.mode,
|
|
123
|
+
site_key=capture.request.site_key,
|
|
124
|
+
session_key=capture.request.session_key,
|
|
125
|
+
wait_until=capture.request.wait_until,
|
|
126
|
+
snapshot=capture.request.snapshot,
|
|
127
|
+
)
|
|
128
|
+
except (ConfigurationError, ValueError):
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def _read_state(self, capture: CaptureResult, context: CrawlContext) -> dict[str, Any]:
|
|
132
|
+
raw = capture.request.user_data.get(_STATE_KEY)
|
|
133
|
+
if raw is None:
|
|
134
|
+
initial_limit = min(self.max_pages, context.limits.max_pages)
|
|
135
|
+
return {
|
|
136
|
+
"version": _STATE_VERSION,
|
|
137
|
+
"limit": initial_limit,
|
|
138
|
+
"pages": 1,
|
|
139
|
+
"urls": [_digest(["url", _effective_url(capture.request)])],
|
|
140
|
+
"cursors": [],
|
|
141
|
+
}
|
|
142
|
+
if not isinstance(raw, dict):
|
|
143
|
+
raise ConfigurationError("Invalid carried pagination state")
|
|
144
|
+
version = raw.get("version")
|
|
145
|
+
limit = raw.get("limit")
|
|
146
|
+
pages = raw.get("pages")
|
|
147
|
+
urls = raw.get("urls")
|
|
148
|
+
cursors = raw.get("cursors")
|
|
149
|
+
if (
|
|
150
|
+
version != _STATE_VERSION
|
|
151
|
+
or isinstance(limit, bool)
|
|
152
|
+
or not isinstance(limit, int)
|
|
153
|
+
or limit < 1
|
|
154
|
+
or isinstance(pages, bool)
|
|
155
|
+
or not isinstance(pages, int)
|
|
156
|
+
or pages < 1
|
|
157
|
+
or pages > limit
|
|
158
|
+
or not isinstance(urls, list)
|
|
159
|
+
or not isinstance(cursors, list)
|
|
160
|
+
or len(urls) > pages
|
|
161
|
+
or len(cursors) > pages
|
|
162
|
+
or not all(isinstance(value, str) and len(value) == 64 for value in [*urls, *cursors])
|
|
163
|
+
):
|
|
164
|
+
raise ConfigurationError("Invalid carried pagination state")
|
|
165
|
+
effective_limit = min(limit, self.max_pages, context.limits.max_pages)
|
|
166
|
+
return {
|
|
167
|
+
"version": _STATE_VERSION,
|
|
168
|
+
"limit": effective_limit,
|
|
169
|
+
"pages": pages,
|
|
170
|
+
"urls": list(urls),
|
|
171
|
+
"cursors": list(cursors),
|
|
172
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Optional, ordinary parsing helpers for developer process functions."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .errors import ConfigurationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def selector(text: str) -> Any:
|
|
9
|
+
try:
|
|
10
|
+
from parsel import Selector
|
|
11
|
+
except ImportError:
|
|
12
|
+
raise ConfigurationError("DOM helpers require dataapp_crawler[parse]") from None
|
|
13
|
+
return Selector(text=text)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Generic browser and proxy provider contracts and implementations."""
|
|
2
|
+
|
|
3
|
+
from .browser import BrowserHandle, BrowserProvider, CDPBrowser, PlaywrightBrowser
|
|
4
|
+
from .camoufox import CamoufoxBrowser
|
|
5
|
+
from .dataos import DataOSProxy
|
|
6
|
+
from .pool import BrowserPool
|
|
7
|
+
from .proxy import ProxyHandle, ProxyProvider, StaticProxy
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BrowserHandle",
|
|
11
|
+
"BrowserPool",
|
|
12
|
+
"BrowserProvider",
|
|
13
|
+
"CDPBrowser",
|
|
14
|
+
"CamoufoxBrowser",
|
|
15
|
+
"DataOSProxy",
|
|
16
|
+
"PlaywrightBrowser",
|
|
17
|
+
"ProxyHandle",
|
|
18
|
+
"ProxyProvider",
|
|
19
|
+
"StaticProxy",
|
|
20
|
+
]
|