dataapp-crawler-octoparse-browser 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,13 @@
1
+ """Octoparse browser provider extension for DataApp Crawler."""
2
+
3
+ from .browserenv import BrowserEnvRelay
4
+ from .captcha import CaptchaType, ManagedCaptchaPlugin, PreparedCaptchaExtension
5
+ from .relay import RelayBrowser
6
+
7
+ __all__ = [
8
+ "BrowserEnvRelay",
9
+ "CaptchaType",
10
+ "ManagedCaptchaPlugin",
11
+ "PreparedCaptchaExtension",
12
+ "RelayBrowser",
13
+ ]
@@ -0,0 +1,439 @@
1
+ """Connect to BrowserEnv's browser or start its managed-plugin mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import ipaddress
8
+ import json
9
+ import math
10
+ import os
11
+ import tempfile
12
+ import threading
13
+ from collections.abc import Callable, Sequence
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Any, Protocol
17
+ from urllib.parse import unquote, urlsplit
18
+
19
+ import httpx
20
+ from dataapp_crawler.errors import ConfigurationError
21
+ from dataapp_crawler.models import Identity
22
+ from dataapp_crawler.providers.browser import (
23
+ BrowserHandle,
24
+ _best_effort_close,
25
+ _maybe_await,
26
+ _start_playwright,
27
+ )
28
+ from dataapp_crawler.providers.proxy import ProxyHandle
29
+
30
+ from .captcha import ManagedCaptchaPlugin, PreparedCaptchaExtension
31
+
32
+
33
+ class _WafTask(Protocol):
34
+ @property
35
+ def indices(self) -> tuple[int, ...] | None: ...
36
+
37
+
38
+ class _WafResources(Protocol):
39
+ async def solve_captcha(
40
+ self, *, type: str, target: str, images: Sequence[str],
41
+ idempotency_key: str, timeout: float,
42
+ ) -> _WafTask: ...
43
+
44
+
45
+ class BrowserEnvRelay:
46
+ """Borrow BrowserEnv's exclusive Relay browser for one Crawler.
47
+
48
+ In the default mode, BrowserEnv's ``octoparse-relay-task`` owns the browser
49
+ process and Relay extension. With ``captcha=...``, this provider obtains a
50
+ run-scoped plugin grant and asks BrowserEnv's launcher to start the browser
51
+ only after the current Data App Run exists. A proxy is installed
52
+ process-wide in BrowserEnv's local GOST chain; BrowserEnv may still route
53
+ image, audio, and video requests directly.
54
+ Pass ``aws_waf_resources=app.resources()`` to use the current Run's
55
+ managed image solver with the Relay extension.
56
+ """
57
+
58
+ _state_lock = threading.Lock()
59
+ _active = False
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ connect_timeout: float = 45,
65
+ playwright_factory: Callable[[], Any] | None = None,
66
+ connection_factory: Callable[..., Any] | None = None,
67
+ http_transport: httpx.AsyncBaseTransport | None = None,
68
+ captcha: ManagedCaptchaPlugin | None = None,
69
+ aws_waf_resources: _WafResources | None = None,
70
+ launcher_path: str | Path = "/usr/local/bin/octoparse-relay-task",
71
+ launcher_factory: Callable[..., Any] | None = None,
72
+ ) -> None:
73
+ if not math.isfinite(connect_timeout) or connect_timeout <= 0:
74
+ raise ConfigurationError("connect_timeout must be finite and positive")
75
+ self._connect_timeout = connect_timeout
76
+ self._playwright_factory = playwright_factory
77
+ self._connection_factory = connection_factory
78
+ self._http_transport = http_transport
79
+ self._captcha = captcha
80
+ self._aws_waf_resources = aws_waf_resources
81
+ self._launcher_path = Path(launcher_path)
82
+ self._launcher_factory = launcher_factory
83
+
84
+ def __repr__(self) -> str:
85
+ return "BrowserEnvRelay()"
86
+
87
+ async def _solve_aws_waf(
88
+ self, target: str, images: list[str], request_id: str
89
+ ) -> Sequence[int]:
90
+ resources = self._aws_waf_resources
91
+ assert resources is not None
92
+ task = await resources.solve_captcha(
93
+ type="aws_waf_image",
94
+ target=target,
95
+ images=images,
96
+ idempotency_key=request_id,
97
+ timeout=45,
98
+ )
99
+ if task.indices is None:
100
+ raise ConfigurationError("Managed AWS WAF solver returned no image selection")
101
+ return task.indices
102
+
103
+ @classmethod
104
+ def _claim(cls) -> None:
105
+ with cls._state_lock:
106
+ if cls._active:
107
+ raise ConfigurationError(
108
+ "BrowserEnv exposes one shared browser; close its current handle "
109
+ "before opening another"
110
+ )
111
+ cls._active = True
112
+
113
+ @classmethod
114
+ async def _release_claim(cls) -> None:
115
+ with cls._state_lock:
116
+ cls._active = False
117
+
118
+ async def open(
119
+ self, *, identity: Identity | None = None, proxy: ProxyHandle | None = None
120
+ ) -> BrowserHandle:
121
+ if identity is not None and (
122
+ identity.cookies or identity.headers or identity.storage_state is not None
123
+ ):
124
+ raise ConfigurationError(
125
+ "BrowserEnv's shared browser cannot safely apply per-Crawler identity state"
126
+ )
127
+ if proxy is not None:
128
+ # Validate before claiming the process-wide browser. A failed GOST
129
+ # request may still have applied, so later transport failures must
130
+ # always attempt to restore direct routing.
131
+ _chain_payload(proxy.http_url())
132
+ self._claim()
133
+ playwright = None
134
+ connection = None
135
+ proxy_configured = proxy is not None
136
+ prepared_captcha: PreparedCaptchaExtension | None = None
137
+ launcher = None
138
+ launch_runtime: tempfile.TemporaryDirectory[str] | None = None
139
+ relay_environment: dict[str, str] = {}
140
+ try:
141
+ await self._configure_proxy(proxy)
142
+ if self._captcha is not None:
143
+ prepared_captcha = await self._captcha.prepare()
144
+ prepared_captcha.require_active()
145
+ launcher, launch_runtime, relay_environment = (
146
+ await self._start_managed_browser(prepared_captcha)
147
+ )
148
+ async with asyncio.timeout(self._connect_timeout):
149
+ playwright = await _start_playwright(self._playwright_factory)
150
+ connection_factory = self._connection_factory
151
+ if connection_factory is None:
152
+ try:
153
+ from octoparse.playwright_relay import connect_async # type: ignore[import-untyped]
154
+ except ImportError as error:
155
+ raise ConfigurationError(
156
+ "Install dataapp-crawler-octoparse-browser to use BrowserEnvRelay"
157
+ ) from error
158
+ connection_factory = connect_async
159
+ connection_options: dict[str, Any] = {"timeout": self._connect_timeout}
160
+ if self._aws_waf_resources is not None:
161
+ connection_options["aws_waf_solver"] = self._solve_aws_waf
162
+ with _temporary_environment(relay_environment):
163
+ connection = await _maybe_await(
164
+ connection_factory(playwright.chromium, **connection_options)
165
+ )
166
+ browser = connection.browser
167
+ if not browser.contexts:
168
+ raise ConfigurationError("BrowserEnv Relay exposed no browser context")
169
+ context = browser.contexts[0]
170
+ if prepared_captcha is not None:
171
+ prepared_captcha.require_active()
172
+ except BaseException as error:
173
+ closers = []
174
+ if playwright is not None:
175
+ closers.append(playwright.stop)
176
+ if connection is not None and getattr(connection, "server", None) is not None:
177
+ closers.append(connection.close)
178
+ if launcher is not None:
179
+ closers.append(lambda: _stop_launcher(launcher))
180
+ if prepared_captcha is not None:
181
+ closers.append(prepared_captcha.close)
182
+ await _best_effort_close(*closers)
183
+ if launch_runtime is not None:
184
+ try:
185
+ launch_runtime.cleanup()
186
+ except Exception:
187
+ pass
188
+ if proxy_configured:
189
+ try:
190
+ await self._configure_proxy(None)
191
+ except BaseException:
192
+ pass
193
+ await self._release_claim()
194
+ if not isinstance(error, Exception):
195
+ raise
196
+ if isinstance(error, ConfigurationError):
197
+ raise
198
+ raise ConfigurationError(
199
+ f"BrowserEnv Relay connection failed ({type(error).__name__})"
200
+ ) from None
201
+
202
+ async def stop_playwright() -> None:
203
+ await _maybe_await(playwright.stop())
204
+
205
+ async def close_owned_relay() -> None:
206
+ await _maybe_await(connection.close())
207
+
208
+ async def restore_direct() -> None:
209
+ await self._configure_proxy(None)
210
+
211
+ async def stop_launcher() -> None:
212
+ if launcher is not None:
213
+ await _stop_launcher(launcher)
214
+
215
+ async def cleanup_captcha() -> None:
216
+ if prepared_captcha is not None:
217
+ await prepared_captcha.close()
218
+
219
+ async def cleanup_launch_runtime() -> None:
220
+ if launch_runtime is not None:
221
+ launch_runtime.cleanup()
222
+
223
+ closers = [stop_playwright]
224
+ if getattr(connection, "server", None) is not None:
225
+ closers.append(close_owned_relay)
226
+ if launcher is not None:
227
+ closers.extend([stop_launcher, cleanup_captcha, cleanup_launch_runtime])
228
+ closers.extend([restore_direct, self._release_claim])
229
+ capabilities = {"page", "relay", "process_proxy"}
230
+ if launcher is None:
231
+ capabilities.add("borrowed")
232
+ else:
233
+ capabilities.add("captcha_plugin")
234
+ return BrowserHandle(
235
+ provider="browserenv-relay",
236
+ capabilities=frozenset(capabilities),
237
+ owned=launcher is not None,
238
+ context=context,
239
+ _closers=closers,
240
+ )
241
+
242
+ async def _start_managed_browser(
243
+ self, prepared: PreparedCaptchaExtension
244
+ ) -> tuple[Any, tempfile.TemporaryDirectory[str], dict[str, str]]:
245
+ launcher_path = self._launcher_path
246
+ if not launcher_path.is_absolute():
247
+ raise ConfigurationError("BrowserEnv launcher path must be absolute")
248
+ runtime = tempfile.TemporaryDirectory(prefix="dataos-browserenv-")
249
+ ready_file = Path(runtime.name) / "ready"
250
+ factory = self._launcher_factory or asyncio.create_subprocess_exec
251
+ environment = dict(os.environ)
252
+ environment.pop("CAPMONSTER_API_KEY", None)
253
+ environment.pop("CAPMONSTER_CAPTCHA_TYPES", None)
254
+ try:
255
+ process = await _maybe_await(
256
+ factory(
257
+ str(launcher_path),
258
+ "--browser-only",
259
+ "--captcha-extension",
260
+ str(prepared.extension_path),
261
+ "--ready-file",
262
+ str(ready_file),
263
+ stdout=asyncio.subprocess.DEVNULL,
264
+ env=environment,
265
+ )
266
+ )
267
+ async with asyncio.timeout(self._connect_timeout):
268
+ while not ready_file.is_file():
269
+ if process.returncode is not None:
270
+ raise ConfigurationError(
271
+ "BrowserEnv launcher exited before browser readiness"
272
+ )
273
+ await asyncio.sleep(0.05)
274
+ relay_environment = _read_launcher_ready(ready_file)
275
+ if not relay_environment:
276
+ raise ConfigurationError("BrowserEnv launcher returned invalid readiness")
277
+ prepared.require_active()
278
+ return process, runtime, relay_environment
279
+ except BaseException:
280
+ if "process" in locals():
281
+ await _best_effort_close(lambda: _stop_launcher(process))
282
+ runtime.cleanup()
283
+ raise
284
+
285
+ async def _configure_proxy(self, proxy: ProxyHandle | None) -> None:
286
+ api_url = _proxy_api_setting(
287
+ "URL",
288
+ "http://127.0.0.1:18080/api/config/chains/chain-0",
289
+ ).strip()
290
+ endpoint = urlsplit(api_url)
291
+ if (
292
+ endpoint.scheme != "http"
293
+ or endpoint.hostname is None
294
+ or endpoint.username is not None
295
+ or endpoint.password is not None
296
+ or endpoint.query
297
+ or endpoint.fragment
298
+ or not _loopback(endpoint.hostname)
299
+ ):
300
+ raise ConfigurationError(
301
+ "BrowserEnv proxy software API must be an HTTP loopback endpoint"
302
+ )
303
+ payload = _chain_payload(proxy.http_url() if proxy is not None else None)
304
+ username = _proxy_api_setting("USERNAME", "admin")
305
+ password = _proxy_api_setting("PASSWORD", "")
306
+ authorization = base64.b64encode(f"{username}:{password}".encode()).decode()
307
+ try:
308
+ async with httpx.AsyncClient(
309
+ transport=self._http_transport,
310
+ timeout=self._connect_timeout,
311
+ trust_env=False,
312
+ ) as client:
313
+ response = await client.put(
314
+ api_url,
315
+ json=payload,
316
+ headers={"Authorization": f"Basic {authorization}"},
317
+ )
318
+ response.raise_for_status()
319
+ except (httpx.HTTPError, ValueError):
320
+ raise ConfigurationError("BrowserEnv proxy configuration failed") from None
321
+
322
+
323
+ def _loopback(hostname: str) -> bool:
324
+ if hostname.lower().rstrip(".") == "localhost":
325
+ return True
326
+ try:
327
+ return ipaddress.ip_address(hostname).is_loopback
328
+ except ValueError:
329
+ return False
330
+
331
+
332
+ async def _stop_launcher(process: Any) -> None:
333
+ if process.returncode is not None:
334
+ await _maybe_await(process.wait())
335
+ return
336
+ process.terminate()
337
+ try:
338
+ async with asyncio.timeout(4):
339
+ await _maybe_await(process.wait())
340
+ except TimeoutError:
341
+ process.kill()
342
+ await _maybe_await(process.wait())
343
+
344
+
345
+ def _read_launcher_ready(path: Path) -> dict[str, str]:
346
+ try:
347
+ payload = json.loads(path.read_text(encoding="utf-8"))
348
+ except (OSError, UnicodeError, json.JSONDecodeError):
349
+ return {}
350
+ if payload.get("ready") is not True:
351
+ return {}
352
+ browser = payload.get("browser")
353
+ chrome = payload.get("chrome")
354
+ profile = payload.get("profile")
355
+ if browser not in {"chromium", "google-stable", "cloakbrowser"}:
356
+ return {}
357
+ if not all(
358
+ isinstance(value, str)
359
+ and value.startswith("/")
360
+ and "\x00" not in value
361
+ and "\n" not in value
362
+ for value in (chrome, profile)
363
+ ):
364
+ return {}
365
+ return {
366
+ "BROWSER": "/usr/local/bin/octoparse-relay-open-url %s",
367
+ "OCTOPARSE_RELAY_BROWSER": browser,
368
+ "OCTOPARSE_RELAY_CHROME_BIN": chrome,
369
+ "OCTOPARSE_RELAY_PROFILE_DIR": profile,
370
+ }
371
+
372
+
373
+ @contextmanager
374
+ def _temporary_environment(values: dict[str, str]):
375
+ previous = {name: os.environ.get(name) for name in values}
376
+ os.environ.update(values)
377
+ try:
378
+ yield
379
+ finally:
380
+ for name, value in previous.items():
381
+ if value is None:
382
+ os.environ.pop(name, None)
383
+ else:
384
+ os.environ[name] = value
385
+
386
+
387
+ def _proxy_api_setting(name: str, default: str) -> str:
388
+ return os.environ.get(
389
+ f"DATAAPP_BROWSER_PROXY_API_{name}",
390
+ os.environ.get(f"OCTOPUS_PROXY_SOFTWARE_API_{name}", default),
391
+ )
392
+
393
+
394
+ def _chain_payload(proxy_url: str | None) -> dict[str, Any]:
395
+ keepalive = {
396
+ "keepalive": True,
397
+ "keepalive.idle": "60s",
398
+ "keepalive.interval": "15s",
399
+ "keepalive.count": 4,
400
+ }
401
+ if proxy_url is None:
402
+ node: dict[str, Any] = {
403
+ "name": "node-0",
404
+ "addr": ":0",
405
+ "connector": {"type": "direct"},
406
+ "dialer": {"type": "direct", "metadata": keepalive},
407
+ }
408
+ else:
409
+ parts = urlsplit(proxy_url)
410
+ if (
411
+ parts.scheme not in {"http", "https"}
412
+ or parts.hostname is None
413
+ or parts.path not in {"", "/"}
414
+ or parts.query
415
+ or parts.fragment
416
+ ):
417
+ raise ConfigurationError("BrowserEnv supports only HTTP and HTTPS upstream proxies")
418
+ if parts.password is not None and parts.username is None:
419
+ raise ConfigurationError("BrowserEnv proxy password requires a username")
420
+ host = f"[{parts.hostname}]" if ":" in parts.hostname else parts.hostname
421
+ tls = parts.scheme == "https"
422
+ dialer: dict[str, Any] = {"type": "tcp", "metadata": keepalive}
423
+ if tls:
424
+ dialer["type"] = "tls"
425
+ dialer["tls"] = {"secure": True, "serverName": parts.hostname}
426
+ port = parts.port if parts.port is not None else (443 if tls else 80)
427
+ node = {
428
+ "name": "node-0",
429
+ "addr": f"{host}:{port}",
430
+ "connector": {
431
+ "type": "http",
432
+ "auth": {
433
+ "username": unquote(parts.username or ""),
434
+ "password": unquote(parts.password or ""),
435
+ },
436
+ },
437
+ "dialer": dialer,
438
+ }
439
+ return {"name": "chain-0", "hops": [{"name": "hop-0", "nodes": [node]}]}