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,292 @@
1
+ """Optional Playwright browser providers; importing core never imports Playwright."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Protocol
10
+
11
+ from ..errors import ConfigurationError
12
+ from ..models import Identity
13
+ from .proxy import ProxyHandle
14
+
15
+ Closer = Callable[[], Awaitable[None]]
16
+
17
+
18
+ class BrowserProvider(Protocol):
19
+ async def open(
20
+ self, *, identity: Identity | None = None, proxy: ProxyHandle | None = None
21
+ ) -> BrowserHandle: ...
22
+
23
+
24
+ async def _maybe_await(value: Any) -> Any:
25
+ return await value if inspect.isawaitable(value) else value
26
+
27
+
28
+ async def _best_effort_close(*closers: Callable[[], Any]) -> None:
29
+ for closer in closers:
30
+ try:
31
+ await asyncio.wait_for(_maybe_await(closer()), timeout=5)
32
+ except BaseException:
33
+ pass
34
+
35
+
36
+ async def _start_playwright(factory: Callable[[], Any] | None) -> Any:
37
+ if factory is None:
38
+ try:
39
+ from playwright.async_api import async_playwright
40
+ except ImportError as error:
41
+ raise ConfigurationError(
42
+ "Install dataapp_crawler[browser] to use browser providers"
43
+ ) from error
44
+ factory = async_playwright
45
+ manager = factory()
46
+ start = getattr(manager, "start", None)
47
+ if not callable(start):
48
+ return manager
49
+ try:
50
+ return await _maybe_await(start())
51
+ except BaseException:
52
+ stop = getattr(manager, "stop", None)
53
+ if callable(stop):
54
+ await _best_effort_close(stop)
55
+ raise
56
+
57
+
58
+ async def _origin_headers(context: Any, identity: Identity | None) -> None:
59
+ if identity is None or not identity.headers:
60
+ return
61
+
62
+ async def add_headers(route: Any, request: Any) -> None:
63
+ headers = identity.headers_for(request.url)
64
+ if not headers:
65
+ await route.continue_()
66
+ return
67
+ # Follow redirects in Chromium so each hop is routed and independently scoped.
68
+ response = await route.fetch(headers={**request.headers, **headers}, max_redirects=0)
69
+ await route.fulfill(response=response)
70
+
71
+ await context.route("**/*", add_headers)
72
+
73
+
74
+ async def _apply_identity(context: Any, identity: Identity | None) -> None:
75
+ if identity is None:
76
+ return
77
+ if identity.cookies:
78
+ await context.add_cookies(_browser_cookies(identity.cookies))
79
+ await _origin_headers(context, identity)
80
+
81
+
82
+ def _browser_cookies(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
83
+ return [{**cookie, "path": cookie.get("path") or "/"} for cookie in cookies]
84
+
85
+
86
+ def _has_identity_state(identity: Identity | None) -> bool:
87
+ return identity is not None and bool(
88
+ identity.cookies or identity.headers or identity.storage_state is not None
89
+ )
90
+
91
+
92
+ @dataclass
93
+ class BrowserHandle:
94
+ provider: str
95
+ capabilities: frozenset[str]
96
+ owned: bool
97
+ context: Any = field(repr=False)
98
+ _closers: list[Closer] = field(default_factory=list, repr=False)
99
+ _pages: list[Any] = field(default_factory=list, repr=False)
100
+ _closed: bool = field(default=False, init=False, repr=False)
101
+ _cleanup_timeout: float = field(default=5, repr=False)
102
+
103
+ async def new_page(self) -> Any:
104
+ if self._closed:
105
+ raise RuntimeError("Browser handle is closed")
106
+ page = await self.context.new_page()
107
+ self._pages.append(page)
108
+ return page
109
+
110
+ async def close_page(self, page: Any) -> None:
111
+ """Close one acquired Page and stop retaining it after successful release."""
112
+ try:
113
+ await asyncio.wait_for(page.close(), timeout=5)
114
+ except Exception:
115
+ raise ConfigurationError("Browser page cleanup failed") from None
116
+ if page in self._pages:
117
+ self._pages.remove(page)
118
+
119
+ async def close(self) -> None:
120
+ if self._closed:
121
+ return
122
+ self._closed = True
123
+ first_error: BaseException | None = None
124
+ for page in list(reversed(self._pages)):
125
+ try:
126
+ await self.close_page(page)
127
+ except BaseException as error:
128
+ first_error = first_error or error
129
+ for closer in self._closers:
130
+ try:
131
+ await asyncio.wait_for(closer(), timeout=self._cleanup_timeout)
132
+ except BaseException as error:
133
+ first_error = first_error or error
134
+ if first_error is not None:
135
+ if not isinstance(first_error, Exception):
136
+ raise first_error
137
+ raise ConfigurationError("Browser resource cleanup failed") from None
138
+
139
+
140
+ class CDPBrowser:
141
+ """Borrow a CDP browser while owning only this Playwright client and its pages."""
142
+
143
+ def __init__(
144
+ self,
145
+ endpoint_url: str,
146
+ *,
147
+ headers: dict[str, str] | None = None,
148
+ timeout: float = 30,
149
+ playwright_factory: Callable[[], Any] | None = None,
150
+ ) -> None:
151
+ self._endpoint_url = endpoint_url
152
+ self._headers = dict(headers or {})
153
+ self._timeout = timeout
154
+ self._playwright_factory = playwright_factory
155
+
156
+ def __repr__(self) -> str:
157
+ return "CDPBrowser(endpoint_url=<redacted>)"
158
+
159
+ async def open(
160
+ self, *, identity: Identity | None = None, proxy: ProxyHandle | None = None
161
+ ) -> BrowserHandle:
162
+ if _has_identity_state(identity) or proxy is not None:
163
+ raise ConfigurationError(
164
+ "Borrowed CDP browsers cannot safely apply identity or proxy configuration"
165
+ )
166
+ playwright = await _start_playwright(self._playwright_factory)
167
+ try:
168
+ browser = await playwright.chromium.connect_over_cdp(
169
+ self._endpoint_url,
170
+ headers=self._headers or None,
171
+ timeout=self._timeout * 1000,
172
+ )
173
+ if not browser.contexts:
174
+ raise ConfigurationError("CDP browser exposed no browser context")
175
+ except BaseException as error:
176
+ await _best_effort_close(playwright.stop)
177
+ if not isinstance(error, Exception):
178
+ raise
179
+ if isinstance(error, ConfigurationError):
180
+ raise
181
+ raise ConfigurationError(f"CDP connection failed ({type(error).__name__})") from None
182
+
183
+ async def stop_client() -> None:
184
+ # Stopping this Playwright client disconnects without stopping the remote browser.
185
+ await playwright.stop()
186
+
187
+ return BrowserHandle(
188
+ provider="cdp",
189
+ capabilities=frozenset({"page", "cdp", "borrowed"}),
190
+ owned=False,
191
+ context=browser.contexts[0],
192
+ _closers=[stop_client],
193
+ )
194
+
195
+
196
+ class PlaywrightBrowser:
197
+ """Own a local Playwright browser and isolated context."""
198
+
199
+ def __init__(
200
+ self,
201
+ *,
202
+ browser: str = "chromium",
203
+ headless: bool = True,
204
+ executable_path: str | None = None,
205
+ launch_options: dict[str, Any] | None = None,
206
+ context_options: dict[str, Any] | None = None,
207
+ playwright_factory: Callable[[], Any] | None = None,
208
+ ) -> None:
209
+ self._browser = browser
210
+ self._headless = headless
211
+ self._executable_path = executable_path
212
+ self._launch_options = dict(launch_options or {})
213
+ self._context_options = dict(context_options or {})
214
+ self._playwright_factory = playwright_factory
215
+
216
+ async def _launch(self) -> tuple[Any, Any]:
217
+ """Launch one native browser process for this provider's configuration."""
218
+ playwright = await _start_playwright(self._playwright_factory)
219
+ try:
220
+ browser_type = getattr(playwright, self._browser)
221
+ except AttributeError as error:
222
+ await _best_effort_close(playwright.stop)
223
+ raise ConfigurationError(f"Unknown Playwright browser: {self._browser}") from error
224
+ launch = {"headless": self._headless, **self._launch_options}
225
+ if self._executable_path:
226
+ launch["executable_path"] = self._executable_path
227
+ try:
228
+ browser = await browser_type.launch(**launch)
229
+ return playwright, browser
230
+ except BaseException as error:
231
+ await _best_effort_close(playwright.stop)
232
+ if not isinstance(error, Exception):
233
+ raise
234
+ raise ConfigurationError(
235
+ f"Playwright browser startup failed ({type(error).__name__})"
236
+ ) from None
237
+
238
+ async def _open_context(
239
+ self,
240
+ browser: Any,
241
+ *,
242
+ identity: Identity | None = None,
243
+ proxy: ProxyHandle | None = None,
244
+ ) -> Any:
245
+ """Create one isolated context in an already launched native browser."""
246
+ try:
247
+ context_options = dict(self._context_options)
248
+ if proxy is not None:
249
+ context_options["proxy"] = proxy.as_playwright()
250
+ if identity is not None and identity.storage_state is not None:
251
+ context_options["storage_state"] = identity.storage_state
252
+ if identity is not None and identity.headers:
253
+ # Context routing does not see requests claimed by a Service Worker.
254
+ context_options["service_workers"] = "block"
255
+ context = await browser.new_context(**context_options)
256
+ await _apply_identity(context, identity)
257
+ except BaseException as error:
258
+ if "context" in locals():
259
+ await _best_effort_close(context.close)
260
+ if not isinstance(error, Exception):
261
+ raise
262
+ raise ConfigurationError(
263
+ f"Playwright browser context startup failed ({type(error).__name__})"
264
+ ) from None
265
+ return context
266
+
267
+ async def open(
268
+ self, *, identity: Identity | None = None, proxy: ProxyHandle | None = None
269
+ ) -> BrowserHandle:
270
+ playwright, browser = await self._launch()
271
+ try:
272
+ context = await self._open_context(browser, identity=identity, proxy=proxy)
273
+ except BaseException:
274
+ await _best_effort_close(browser.close, playwright.stop)
275
+ raise
276
+
277
+ async def close_context() -> None:
278
+ await context.close()
279
+
280
+ async def close_browser() -> None:
281
+ await browser.close()
282
+
283
+ async def stop_playwright() -> None:
284
+ await playwright.stop()
285
+
286
+ return BrowserHandle(
287
+ provider="playwright",
288
+ capabilities=frozenset({"page", "cookies", "origin_headers", "proxy", "storage_state"}),
289
+ owned=True,
290
+ context=context,
291
+ _closers=[close_context, close_browser, stop_playwright],
292
+ )
@@ -0,0 +1,184 @@
1
+ """Optional Camoufox provider with one coherent identity per owned context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable, Mapping
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from ..errors import ConfigurationError
11
+ from ..models import Identity
12
+ from .browser import BrowserHandle, _apply_identity, _best_effort_close
13
+ from .proxy import ProxyHandle
14
+
15
+ CamoufoxFactory = Callable[..., Any]
16
+ ContextFactory = Callable[..., Any]
17
+ CamoufoxHeadless = bool | Literal["virtual"]
18
+
19
+ _IDENTITY_CONTEXT_OPTIONS = frozenset(
20
+ {
21
+ "device_scale_factor",
22
+ "extra_http_headers",
23
+ "ff_version",
24
+ "geolocation",
25
+ "has_touch",
26
+ "http_credentials",
27
+ "is_mobile",
28
+ "locale",
29
+ "permissions",
30
+ "preset",
31
+ "proxy",
32
+ "os",
33
+ "screen",
34
+ "storage_state",
35
+ "timezone_id",
36
+ "user_agent",
37
+ "viewport",
38
+ "webrtc_ip",
39
+ }
40
+ )
41
+
42
+
43
+ class CamoufoxBrowser:
44
+ """Own one Camoufox browser and one independently fingerprinted context.
45
+
46
+ A provider instance is safe to use as one Crawler or SessionPool slot
47
+ generation. Camoufox draws the context identity during :meth:`open` and the
48
+ returned handle keeps that identity, proxy and Cookie state together until
49
+ it is closed.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ headless: CamoufoxHeadless = True,
56
+ os: Literal["windows", "macos", "linux"] | None = None,
57
+ humanize: bool | float = False,
58
+ executable_path: str | Path | None = None,
59
+ context_options: Mapping[str, Any] | None = None,
60
+ camoufox_factory: CamoufoxFactory | None = None,
61
+ context_factory: ContextFactory | None = None,
62
+ ) -> None:
63
+ if not (
64
+ type(headless) is bool or isinstance(headless, str) and headless == "virtual"
65
+ ):
66
+ raise ConfigurationError("Camoufox headless must be true, false or 'virtual'")
67
+ if os not in {None, "windows", "macos", "linux"}:
68
+ raise ConfigurationError("Unknown Camoufox operating-system identity")
69
+ if not isinstance(humanize, bool) and (
70
+ not isinstance(humanize, (int, float))
71
+ or not math.isfinite(humanize)
72
+ or not 0 < humanize <= 5
73
+ ):
74
+ raise ConfigurationError("Camoufox humanize must be boolean or 0 < seconds <= 5")
75
+ if (camoufox_factory is None) != (context_factory is None):
76
+ raise ConfigurationError("Camoufox test factories must be configured together")
77
+ options = dict(context_options or {})
78
+ reserved = sorted(_IDENTITY_CONTEXT_OPTIONS.intersection(options))
79
+ if reserved:
80
+ raise ConfigurationError(
81
+ "Camoufox context identity options are managed by the Provider: "
82
+ + ", ".join(reserved)
83
+ )
84
+ self._headless = headless
85
+ self._os = os
86
+ self._humanize = humanize
87
+ self._executable_path = (
88
+ str(Path(executable_path).expanduser()) if executable_path is not None else None
89
+ )
90
+ self._context_options = options
91
+ self._camoufox_factory = camoufox_factory
92
+ self._context_factory = context_factory
93
+
94
+ def __repr__(self) -> str:
95
+ return f"CamoufoxBrowser(headless={self._headless!r}, os={self._os!r})"
96
+
97
+ @staticmethod
98
+ def _optional_api() -> tuple[CamoufoxFactory, ContextFactory]:
99
+ try:
100
+ from camoufox.async_api import AsyncCamoufox, AsyncNewContext
101
+ except ImportError as error:
102
+ raise ConfigurationError(
103
+ "Install dataapp_crawler[fingerprint] in its isolated environment "
104
+ "and fetch the paired Camoufox browser build"
105
+ ) from error
106
+ return AsyncCamoufox, AsyncNewContext
107
+
108
+ async def open(
109
+ self, *, identity: Identity | None = None, proxy: ProxyHandle | None = None
110
+ ) -> BrowserHandle:
111
+ camoufox_factory = self._camoufox_factory
112
+ context_factory = self._context_factory
113
+ if camoufox_factory is None or context_factory is None:
114
+ camoufox_factory, context_factory = self._optional_api()
115
+
116
+ launch_options: dict[str, Any] = {
117
+ "headless": self._headless,
118
+ "humanize": self._humanize,
119
+ }
120
+ if self._os is not None:
121
+ # Browser-level OS behavior and the context fingerprint must agree.
122
+ launch_options["os"] = self._os
123
+ if self._executable_path is not None:
124
+ launch_options["executable_path"] = self._executable_path
125
+
126
+ manager = camoufox_factory(**launch_options)
127
+ browser: Any = None
128
+ try:
129
+ browser = await manager.__aenter__()
130
+ context_options = dict(self._context_options)
131
+ if self._os is not None:
132
+ context_options["os"] = self._os
133
+ if proxy is not None:
134
+ # AsyncNewContext derives WebRTC IP and timezone from this exit.
135
+ context_options["proxy"] = proxy.as_playwright()
136
+ if identity is not None and identity.storage_state is not None:
137
+ context_options["storage_state"] = identity.storage_state
138
+ if identity is not None and identity.headers:
139
+ # Origin routing cannot scope requests claimed by a Service Worker.
140
+ context_options["service_workers"] = "block"
141
+ context = await context_factory(browser, **context_options)
142
+ await _apply_identity(context, identity)
143
+ except BaseException as error:
144
+ if "context" in locals():
145
+ await _best_effort_close(context.close)
146
+ if browser is not None:
147
+ failure = error
148
+ error_type = type(error)
149
+ traceback = error.__traceback__
150
+
151
+ async def close_failed_manager() -> None:
152
+ await manager.__aexit__(error_type, failure, traceback)
153
+
154
+ await _best_effort_close(close_failed_manager)
155
+ if not isinstance(error, Exception):
156
+ raise
157
+ if isinstance(error, ConfigurationError):
158
+ raise
159
+ raise ConfigurationError(
160
+ f"Camoufox browser startup failed ({type(error).__name__})"
161
+ ) from None
162
+
163
+ async def close_context() -> None:
164
+ await context.close()
165
+
166
+ async def close_manager() -> None:
167
+ await manager.__aexit__(None, None, None)
168
+
169
+ return BrowserHandle(
170
+ provider="camoufox",
171
+ capabilities=frozenset(
172
+ {
173
+ "page",
174
+ "cookies",
175
+ "origin_headers",
176
+ "proxy",
177
+ "storage_state",
178
+ "fingerprint",
179
+ }
180
+ ),
181
+ owned=True,
182
+ context=context,
183
+ _closers=[close_context, close_manager],
184
+ )
@@ -0,0 +1,174 @@
1
+ """DataOS-managed browser and proxy providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Protocol
7
+
8
+ from ..errors import ConfigurationError
9
+ from .proxy import ProxyHandle
10
+
11
+ _SAFE_RESOURCE_ERROR_CODES = frozenset({
12
+ "configuration-error",
13
+ "insufficient-developer-resource-balance",
14
+ "managed-billing-conflict",
15
+ "managed-billing-not-found",
16
+ "managed-billing-request-rejected",
17
+ "managed-billing-unavailable",
18
+ "provider-connection-unavailable",
19
+ "provider-invalid-response",
20
+ "provider-outcome-unknown",
21
+ "provider-rejected",
22
+ "proxy-group-unavailable",
23
+ "proxy-group-network-conflict",
24
+ "proxy-location-unavailable",
25
+ "proxy-location-unsupported",
26
+ "resource-outcome-unavailable",
27
+ "resource-proxy-node-unavailable",
28
+ "resource-proxy-unavailable",
29
+ "resource-request-in-progress",
30
+ "resource-request-reserved",
31
+ "resource-revocation-pending",
32
+ "transport-error",
33
+ })
34
+
35
+
36
+ class _ManagedProxyConfigurationError(ConfigurationError):
37
+ def __init__(self, code: str, status_code: int | None) -> None:
38
+ self.code = code
39
+ self.status_code = status_code
40
+ detail = f"code={code}" + (
41
+ f", status={status_code}" if status_code is not None else ""
42
+ )
43
+ super().__init__(f"DataOS proxy acquisition failed ({detail})")
44
+
45
+
46
+ def _configuration_error(error: Exception) -> ConfigurationError:
47
+ raw_code = getattr(error, "code", None)
48
+ code = (
49
+ raw_code
50
+ if isinstance(raw_code, str) and raw_code in _SAFE_RESOURCE_ERROR_CODES
51
+ else "resource-service-error"
52
+ )
53
+ raw_status = getattr(error, "status_code", None)
54
+ status = (
55
+ raw_status
56
+ if (
57
+ isinstance(raw_status, int)
58
+ and not isinstance(raw_status, bool)
59
+ and 400 <= raw_status <= 599
60
+ )
61
+ else None
62
+ )
63
+ # Keep only the protocol's bounded diagnostic labels. Never retain the
64
+ # upstream message, URL, response body, or supplier credentials.
65
+ return _ManagedProxyConfigurationError(code, status)
66
+
67
+
68
+ class _ProxyResource(Protocol):
69
+ lease_id: str
70
+ proxy_url: str
71
+
72
+ async def renew(self) -> None: ...
73
+
74
+ async def close(self) -> None: ...
75
+
76
+
77
+ class ResourceClient(Protocol):
78
+ async def proxy(self, **kwargs: Any) -> _ProxyResource: ...
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class DataOSProxy:
83
+ """Acquire a DataOS-managed proxy lease for one crawler generation.
84
+
85
+ The first service version cannot authoritatively replace the supplier
86
+ session or exit IP, so Crawler exit rotation is deliberately unsupported.
87
+ """
88
+
89
+ resources: ResourceClient
90
+ country: str | None = None
91
+ expire_time: str | None = None
92
+ exit_policy: str = "renewable"
93
+ max_rotations: int = 0
94
+ network_preference: str | None = None
95
+ target_hint: str | None = None
96
+ state: str | None = None
97
+ city: str | None = None
98
+ proxy_group: str | None = None
99
+
100
+ def __post_init__(self) -> None:
101
+ if self.network_preference not in {None, "auto", "domestic", "overseas"}:
102
+ raise ConfigurationError(
103
+ "network_preference must be auto, domestic or overseas"
104
+ )
105
+ if self.proxy_group not in {
106
+ None,
107
+ "auto",
108
+ "domestic_basic",
109
+ "overseas_basic",
110
+ "overseas_enhanced",
111
+ }:
112
+ raise ConfigurationError(
113
+ "proxy_group must be auto, domestic_basic, overseas_basic, "
114
+ "or overseas_enhanced"
115
+ )
116
+ group_network = self._group_network()
117
+ if (
118
+ group_network is not None
119
+ and self.network_preference not in {None, "auto", group_network}
120
+ ):
121
+ raise ConfigurationError("proxy_group conflicts with network_preference")
122
+ if self.max_rotations != 0:
123
+ raise ConfigurationError(
124
+ "DataOSProxy does not support exit rotation; max_rotations must be 0"
125
+ )
126
+
127
+ def __repr__(self) -> str:
128
+ return (
129
+ "DataOSProxy("
130
+ f"country={self.country!r}, network_preference={self.network_preference!r}, "
131
+ f"proxy_group={self.proxy_group!r}, exit_policy={self.exit_policy!r})"
132
+ )
133
+
134
+ def _group_network(self) -> str | None:
135
+ if self.proxy_group == "domestic_basic":
136
+ return "domestic"
137
+ if self.proxy_group in {"overseas_basic", "overseas_enhanced"}:
138
+ return "overseas"
139
+ return None
140
+
141
+ async def acquire(self) -> ProxyHandle:
142
+ try:
143
+ options: dict[str, Any] = {
144
+ "country": self.country,
145
+ "expire_time": self.expire_time,
146
+ "exit_policy": self.exit_policy,
147
+ }
148
+ group_network = self._group_network()
149
+ selected_network = group_network or self.network_preference
150
+ if selected_network not in {None, "auto"}:
151
+ options["network_preference"] = selected_network
152
+ if self.proxy_group is not None:
153
+ options["proxy_group"] = self.proxy_group
154
+ if self.target_hint is not None:
155
+ options["target_hint"] = self.target_hint
156
+ if self.state is not None:
157
+ options["state"] = self.state
158
+ if self.city is not None:
159
+ options["city"] = self.city
160
+ resource = await self.resources.proxy(**options)
161
+ except Exception as error:
162
+ raise _configuration_error(error) from None
163
+
164
+ async def release() -> str:
165
+ await resource.close()
166
+ return "released"
167
+
168
+ return ProxyHandle(
169
+ provider="dataos",
170
+ _url=resource.proxy_url,
171
+ tunnel_id=resource.lease_id,
172
+ max_rotations=0,
173
+ _release=release,
174
+ )