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