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,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
+ ]