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,87 @@
1
+ """Developer-friendly acquisition, without a dependency on the DataOS Runtime."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .captcha import (
6
+ CaptchaConfidence,
7
+ CaptchaEvidence,
8
+ CaptchaFamily,
9
+ CaptchaObservation,
10
+ CaptchaOverride,
11
+ CaptchaPresence,
12
+ SolverComponent,
13
+ SolverMatch,
14
+ detect_captchas,
15
+ detect_captchas_on_page,
16
+ )
17
+ from .checkpoint import CrawlCheckpoint
18
+ from .cloudflare import CloudflareChallengeCapture, CloudflareChallengeParameters
19
+ from .context import CrawlContext
20
+ from .crawler import Crawler
21
+ from .errors import (
22
+ ConfigurationError,
23
+ CrawlCancelled,
24
+ CrawlerError,
25
+ FetchError,
26
+ LimitReached,
27
+ ProcessingError,
28
+ QueueFull,
29
+ ResponseTooLarge,
30
+ )
31
+ from .local_auth import local_resource_environment, read_api_key_file, run_local_app
32
+ from .models import CaptureResult, CrawlItem, Failure, Identity, Limits, Request, Stats
33
+ from .pagination import Pagination
34
+ from .routing import RouteAlreadyRegistered, RouteNotFound, Router
35
+ from .scheduling import AdaptivePolicy, AutoConcurrency, ResourceSample
36
+ from .sessions import Session, SessionInfo, SessionPool
37
+
38
+ __all__ = [
39
+ "AdaptivePolicy",
40
+ "AutoConcurrency",
41
+ "CaptchaConfidence",
42
+ "CaptchaEvidence",
43
+ "CaptchaFamily",
44
+ "CaptchaObservation",
45
+ "CaptchaOverride",
46
+ "CaptchaPresence",
47
+ "CaptureResult",
48
+ "CrawlCheckpoint",
49
+ "CloudflareChallengeCapture",
50
+ "CloudflareChallengeParameters",
51
+ "ResourceSample",
52
+ "ConfigurationError",
53
+ "CrawlCancelled",
54
+ "CrawlContext",
55
+ "Crawler",
56
+ "CrawlerError",
57
+ "CrawlItem",
58
+ "Failure",
59
+ "FetchError",
60
+ "Identity",
61
+ "LimitReached",
62
+ "local_resource_environment",
63
+ "Limits",
64
+ "ProcessingError",
65
+ "QueueFull",
66
+ "Request",
67
+ "ResponseTooLarge",
68
+ "read_api_key_file",
69
+ "RouteAlreadyRegistered",
70
+ "RouteNotFound",
71
+ "run_local_app",
72
+ "Router",
73
+ "Pagination",
74
+ "Stats",
75
+ "Session",
76
+ "SessionInfo",
77
+ "SessionPool",
78
+ "SolverComponent",
79
+ "SolverMatch",
80
+ "detect_captchas",
81
+ "detect_captchas_on_page",
82
+ ]
83
+
84
+ try:
85
+ __version__ = version("dataapp_crawler")
86
+ except PackageNotFoundError:
87
+ __version__ = "0.0.0"
@@ -0,0 +1,35 @@
1
+ """Local Crawler SDK command helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Sequence
7
+
8
+ from .errors import ConfigurationError
9
+ from .local_auth import run_local_app
10
+
11
+
12
+ def main(argv: Sequence[str] | None = None) -> int:
13
+ parser = argparse.ArgumentParser(prog="python -m dataapp_crawler")
14
+ commands = parser.add_subparsers(dest="command", required=True)
15
+ run = commands.add_parser(
16
+ "run",
17
+ help="run a local DataApp with a file-backed platform API Key",
18
+ )
19
+ run.add_argument("--api-key-file", required=True, help="regular file containing one API Key")
20
+ run.add_argument("app_command", nargs=argparse.REMAINDER, help="application command after --")
21
+ options = parser.parse_args(argv)
22
+ if options.command != "run":
23
+ return 2
24
+ command = list(options.app_command)
25
+ if command[:1] == ["--"]:
26
+ command = command[1:]
27
+ try:
28
+ return run_local_app(options.api_key_file, command)
29
+ except ConfigurationError as error:
30
+ print(f"配置错误:{error}")
31
+ return 2
32
+
33
+
34
+ if __name__ == "__main__":
35
+ raise SystemExit(main())
@@ -0,0 +1,148 @@
1
+ """Default stderr logging and bounded, credential-free diagnostic labels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import math
8
+ import re
9
+ import sys
10
+ from urllib.parse import urlsplit
11
+
12
+ from .errors import (
13
+ CrawlCancelled,
14
+ FetchError,
15
+ LimitReached,
16
+ ProcessingError,
17
+ browser_error_code,
18
+ http_error_code,
19
+ process_error_code,
20
+ )
21
+
22
+ _PROVIDERS = {
23
+ "AWSBrowser": "AgentCore",
24
+ "AgentCoreBrowser": "AgentCore",
25
+ "BrowserEnvRelay": "Relay/BrowserEnv",
26
+ "RelayBrowser": "Relay",
27
+ "CDPBrowser": "CDP",
28
+ "PlaywrightBrowser": "Playwright",
29
+ "CamoufoxBrowser": "Camoufox",
30
+ "DataOSProxy": "ResourceProxy",
31
+ "StaticProxy": "custom-proxy",
32
+ }
33
+ _PHASES = frozenset({
34
+ "navigation", "before_navigation", "after_navigation", "headers", "snapshot",
35
+ "page_creation", "page_cleanup", "http_transport", "response", "process",
36
+ "browser_open", "proxy_acquire", "startup", "cleanup", "request",
37
+ })
38
+ _EXCEPTION_TYPES = frozenset({
39
+ "TimeoutError", "Error", "TargetClosedError", "ConnectionError", "ConnectionResetError",
40
+ "ConnectionAbortedError", "BrokenPipeError", "OSError", "RuntimeError", "ValueError",
41
+ "KeyError", "IndexError", "AttributeError", "TypeError", "AssertionError",
42
+ "FetchError", "ProcessingError", "ConfigurationError", "CrawlCancelled",
43
+ "CancelledError", "LimitReached", "Exception",
44
+ })
45
+
46
+
47
+ class _StderrHandler(logging.StreamHandler):
48
+ @property
49
+ def stream(self): # type: ignore[no-untyped-def]
50
+ return sys.stderr
51
+
52
+ @stream.setter
53
+ def stream(self, value): # type: ignore[no-untyped-def]
54
+ pass
55
+
56
+
57
+ def get_logger() -> logging.Logger:
58
+ """Return the standalone INFO logger without configuring the root logger.
59
+
60
+ INFO is intentionally concise. Developers can set this logger, or a logger
61
+ supplied to :class:`Crawler`, to DEBUG for per-stage acquisition details.
62
+ """
63
+ logger = logging.getLogger("dataapp_crawler")
64
+ if not logger.handlers:
65
+ handler = _StderrHandler()
66
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
67
+ logger.addHandler(handler)
68
+ logger.setLevel(logging.INFO)
69
+ logger.propagate = False
70
+ return logger
71
+
72
+
73
+ def provider_name(provider: object | None) -> str:
74
+ return "none" if provider is None else _PROVIDERS.get(type(provider).__name__, "custom")
75
+
76
+
77
+ def target_host(url: str) -> str:
78
+ """Keep only an ASCII hostname; omit paths, userinfo, query and fragments."""
79
+ host = urlsplit(url).hostname or ""
80
+ return host[:253] if re.fullmatch(r"[A-Za-z0-9.:\[\]-]+", host) else "redacted"
81
+
82
+
83
+ def root_page_host(page: object) -> str:
84
+ """Read only the root Page's hostname; a closed page must not mask an error."""
85
+ try:
86
+ url = getattr(page, "url", None)
87
+ if not isinstance(url, str):
88
+ return "none"
89
+ return target_host(url) or "none"
90
+ except BaseException:
91
+ return "unavailable"
92
+
93
+
94
+ def failure_details(error: BaseException, default_phase: str) -> tuple[str, str]:
95
+ phase = getattr(error, "phase", default_phase)
96
+ phase = phase if isinstance(phase, str) and phase in _PHASES else default_phase
97
+ if isinstance(error, ProcessingError):
98
+ return "process", process_error_code(error)
99
+ if isinstance(error, CrawlCancelled | asyncio.CancelledError):
100
+ return phase, "CRAWL_CANCELLED"
101
+ if isinstance(error, LimitReached):
102
+ return phase, "LIMIT_REACHED"
103
+ if isinstance(error, FetchError):
104
+ return phase, browser_error_code(error) or http_error_code(error) or "FETCH_ERROR"
105
+ return phase, "OPERATION_FAILED"
106
+
107
+
108
+ def failure_context(error: BaseException) -> str:
109
+ """Append fixed exception labels and browser evidence, without raw error text."""
110
+ exception_type = type(error).__name__
111
+ exception_type = exception_type if exception_type in _EXCEPTION_TYPES else "Exception"
112
+ fields = [f"exception_type={exception_type}"]
113
+ if not isinstance(error, FetchError) or not isinstance(error.browser_diagnostics, dict):
114
+ return " " + " ".join(fields)
115
+ diagnostics = error.browser_diagnostics
116
+ if not diagnostics:
117
+ return " " + " ".join(fields)
118
+ diagnostic_type = diagnostics.get("exception_type")
119
+ exception_type = (
120
+ diagnostic_type if isinstance(diagnostic_type, str)
121
+ and diagnostic_type in _EXCEPTION_TYPES else "Exception"
122
+ )
123
+ fields = [f"exception_type={exception_type}"]
124
+ timeout = diagnostics.get("timeout_seconds")
125
+ if (
126
+ isinstance(timeout, int | float) and not isinstance(timeout, bool)
127
+ and 0 < timeout <= 86400 and math.isfinite(timeout)
128
+ ):
129
+ fields.append(f"timeout={timeout:.3f}s")
130
+ wait_until = diagnostics.get("wait_until")
131
+ if (
132
+ isinstance(wait_until, str)
133
+ and wait_until in {"commit", "domcontentloaded", "load", "networkidle"}
134
+ ):
135
+ fields.append(f"wait_until={wait_until}")
136
+ if isinstance(error.retryable, bool):
137
+ fields.append(f"retryable={error.retryable}")
138
+ status = error.main_document_status
139
+ valid_status = isinstance(status, int) and not isinstance(status, bool) and 100 <= status <= 599
140
+ fields.append(f"main_status={status if valid_status else 'none'}")
141
+ for key, label in (
142
+ ("main_document_request_seen", "request_seen"),
143
+ ("main_document_response_seen", "response_seen"),
144
+ ):
145
+ value = diagnostics.get(key)
146
+ if isinstance(value, bool):
147
+ fields.append(f"{label}={value}")
148
+ return " " + " ".join(fields)
@@ -0,0 +1,193 @@
1
+ """Bounded browser navigation telemetry containing no network identifiers or objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from .errors import NetworkDiagnosticsSummary, browser_error_code
10
+
11
+ _EVENTS = ("request", "response", "requestfailed")
12
+ _RESOURCE_TYPES = frozenset(
13
+ {
14
+ "document",
15
+ "stylesheet",
16
+ "image",
17
+ "media",
18
+ "font",
19
+ "script",
20
+ "texttrack",
21
+ "xhr",
22
+ "fetch",
23
+ "eventsource",
24
+ "websocket",
25
+ "manifest",
26
+ "other",
27
+ }
28
+ )
29
+ _MAX_EVENTS = 20
30
+
31
+
32
+ class NetworkDiagnostics:
33
+ """Own exact Page listeners for one navigation/read attempt."""
34
+
35
+ def __init__(
36
+ self,
37
+ remove_listener: Callable[[str, Callable[[Any], None]], Any],
38
+ started: float,
39
+ main_frame: Any,
40
+ capture_events: bool,
41
+ ) -> None:
42
+ self._remove_listener: Callable[[str, Callable[[Any], None]], Any] | None = (
43
+ remove_listener
44
+ )
45
+ self._started = started
46
+ self._main_frame = main_frame
47
+ self._capture_events = capture_events
48
+ self._listeners: list[tuple[str, Callable[[Any], None]]] = []
49
+ self._records: list[dict[str, Any]] = []
50
+ self._events_dropped = 0
51
+ self._main_document_status: int | None = None
52
+
53
+ def add_listener(self, event: str, callback: Callable[[Any], None]) -> None:
54
+ self._listeners.append((event, callback))
55
+
56
+ def detach(self) -> None:
57
+ remove_listener = self._remove_listener
58
+ if remove_listener is None:
59
+ return
60
+ for event, callback in self._listeners:
61
+ try:
62
+ remove_listener(event, callback)
63
+ except Exception:
64
+ # Diagnostics must never change acquisition or cleanup semantics.
65
+ pass
66
+ self._listeners.clear()
67
+ # A bound remove_listener retains its Page; discard it after the window closes.
68
+ self._remove_listener = None
69
+ self._main_frame = None
70
+
71
+ def summary(self) -> NetworkDiagnosticsSummary:
72
+ return {
73
+ "events": [dict(record) for record in self._records],
74
+ "events_dropped": self._events_dropped,
75
+ "main_document_status": self._main_document_status,
76
+ }
77
+
78
+ @property
79
+ def main_document_status(self) -> int | None:
80
+ return self._main_document_status
81
+
82
+ def on_request(self, request: Any) -> None:
83
+ try:
84
+ self._append("request", request)
85
+ except Exception:
86
+ pass
87
+
88
+ def on_response(self, response: Any) -> None:
89
+ try:
90
+ request = response.request
91
+ status = response.status
92
+ self._append(
93
+ "response",
94
+ request,
95
+ status=status if isinstance(status, int) and not isinstance(status, bool) else None,
96
+ )
97
+ except Exception:
98
+ pass
99
+
100
+ def on_requestfailed(self, request: Any) -> None:
101
+ try:
102
+ failure = request.failure
103
+ code = browser_error_code(failure) if isinstance(failure, str) else None
104
+ self._append(
105
+ "requestfailed",
106
+ request,
107
+ browser_error_code=code,
108
+ unknown_failure=code is None,
109
+ )
110
+ except Exception:
111
+ pass
112
+
113
+ def _append(
114
+ self,
115
+ event: str,
116
+ request: Any,
117
+ *,
118
+ status: int | None = None,
119
+ browser_error_code: str | None = None,
120
+ unknown_failure: bool | None = None,
121
+ ) -> None:
122
+ try:
123
+ navigation = request.is_navigation_request()
124
+ frame = request.frame
125
+ except Exception:
126
+ return
127
+ main_document = (
128
+ navigation is True
129
+ and self._main_frame is not None
130
+ and frame is self._main_frame
131
+ )
132
+ valid_status = (
133
+ status
134
+ if isinstance(status, int) and not isinstance(status, bool) and 100 <= status <= 599
135
+ else None
136
+ )
137
+ if main_document and valid_status is not None:
138
+ # Keep the latest main-frame response even when the bounded event list is full.
139
+ self._main_document_status = valid_status
140
+ if not self._capture_events:
141
+ return
142
+ try:
143
+ resource_type = request.resource_type
144
+ except Exception:
145
+ return
146
+ if len(self._records) >= _MAX_EVENTS:
147
+ self._events_dropped += 1
148
+ return
149
+ record: dict[str, Any] = {
150
+ "event": event,
151
+ "resource_type": resource_type if resource_type in _RESOURCE_TYPES else "other",
152
+ "main_document": main_document,
153
+ }
154
+ if valid_status is not None:
155
+ record["status"] = valid_status
156
+ if browser_error_code is not None:
157
+ record["browser_error_code"] = browser_error_code
158
+ if unknown_failure is not None:
159
+ record["unknown_failure"] = unknown_failure
160
+ record["elapsed"] = max(0.0, round(time.monotonic() - self._started, 6))
161
+ self._records.append(record)
162
+
163
+
164
+ def observe_network(
165
+ page: Any, started: float, *, capture_events: bool
166
+ ) -> NetworkDiagnostics | None:
167
+ """Attach supported listeners atomically, otherwise leave the Page untouched."""
168
+ try:
169
+ on = getattr(page, "on", None)
170
+ remove_listener = getattr(page, "remove_listener", None)
171
+ main_frame = getattr(page, "main_frame", None)
172
+ except Exception:
173
+ return None
174
+ if not callable(on) or not callable(remove_listener):
175
+ return None
176
+ if callable(main_frame):
177
+ main_frame = None
178
+ diagnostics = NetworkDiagnostics(remove_listener, started, main_frame, capture_events)
179
+ callbacks = {
180
+ "request": diagnostics.on_request,
181
+ "response": diagnostics.on_response,
182
+ "requestfailed": diagnostics.on_requestfailed,
183
+ }
184
+ events = _EVENTS if capture_events else ("response",)
185
+ try:
186
+ for event in events:
187
+ callback = callbacks[event]
188
+ diagnostics.add_listener(event, callback)
189
+ on(event, callback)
190
+ except Exception:
191
+ diagnostics.detach()
192
+ return None
193
+ return diagnostics
@@ -0,0 +1,23 @@
1
+ """Small Playwright helpers for page-driven browser collection.
2
+
3
+ The module imports no Playwright classes at runtime. Developers keep native
4
+ ``Page`` and ``Locator`` objects while these helpers add Crawler budgets,
5
+ bounded readiness, deterministic navigation cleanup, and stable list keys.
6
+ """
7
+
8
+ from .dom import attribute_of, text_of
9
+ from .lists import ListItem, iterate_list
10
+ from .navigation import new_page, open_url, popup_page, same_tab
11
+ from .readiness import wait_until
12
+
13
+ __all__ = [
14
+ "ListItem",
15
+ "attribute_of",
16
+ "iterate_list",
17
+ "new_page",
18
+ "open_url",
19
+ "popup_page",
20
+ "same_tab",
21
+ "text_of",
22
+ "wait_until",
23
+ ]
@@ -0,0 +1,66 @@
1
+ """Internal validation and cleanup helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import math
8
+ from collections.abc import Awaitable
9
+ from typing import Any, TypeVar
10
+
11
+ from ..errors import ConfigurationError
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ def positive_seconds(value: float, name: str) -> float:
17
+ if (
18
+ isinstance(value, bool)
19
+ or not isinstance(value, (int, float))
20
+ or not math.isfinite(value)
21
+ or value <= 0
22
+ ):
23
+ raise ConfigurationError(f"{name} must be a finite positive number")
24
+ return float(value)
25
+
26
+
27
+ def positive_integer(value: int, name: str) -> int:
28
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
29
+ raise ConfigurationError(f"{name} must be a positive integer")
30
+ return value
31
+
32
+
33
+ async def maybe_await(value: T | Awaitable[T]) -> T:
34
+ return await value if inspect.isawaitable(value) else value
35
+
36
+
37
+ async def cleanup(awaitable: Awaitable[T], *, timeout: float) -> T:
38
+ """Finish one bounded cleanup even after the CrawlContext was cancelled."""
39
+ task = asyncio.ensure_future(awaitable)
40
+ try:
41
+ return await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
42
+ except BaseException:
43
+ if not task.done():
44
+ task.cancel()
45
+ await asyncio.gather(task, return_exceptions=True)
46
+ raise
47
+
48
+
49
+ async def finish_cleanup(
50
+ awaitable: Awaitable[Any],
51
+ *,
52
+ timeout: float,
53
+ primary_error: BaseException | None,
54
+ failure_message: str,
55
+ failure_note: str,
56
+ ) -> None:
57
+ """Run sanitized cleanup without replacing an active body exception."""
58
+ try:
59
+ await cleanup(awaitable, timeout=timeout)
60
+ except BaseException as error:
61
+ if primary_error is not None:
62
+ primary_error.add_note(failure_note)
63
+ return
64
+ if not isinstance(error, Exception):
65
+ raise
66
+ raise ConfigurationError(failure_message) from None
@@ -0,0 +1,49 @@
1
+ """Lightweight extraction from native Playwright locators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..context import CrawlContext
8
+ from ..errors import ConfigurationError
9
+ from ._common import positive_seconds
10
+
11
+
12
+ async def text_of(
13
+ locator: Any,
14
+ context: CrawlContext,
15
+ *,
16
+ timeout: float = 10,
17
+ required: bool = False,
18
+ ) -> str | None:
19
+ """Return stripped inner text, or ``None`` when optional text is empty."""
20
+ timeout = positive_seconds(timeout, "timeout")
21
+ value = (await context.run(locator.inner_text(timeout=timeout * 1000), timeout=timeout)).strip()
22
+ if value:
23
+ return value
24
+ if required:
25
+ raise ValueError("Required element text is empty")
26
+ return None
27
+
28
+
29
+ async def attribute_of(
30
+ locator: Any,
31
+ name: str,
32
+ context: CrawlContext,
33
+ *,
34
+ timeout: float = 10,
35
+ required: bool = False,
36
+ ) -> str | None:
37
+ """Return one locator attribute with optional required validation."""
38
+ timeout = positive_seconds(timeout, "timeout")
39
+ if not isinstance(name, str) or not name:
40
+ raise ConfigurationError("Attribute name must be a non-empty string")
41
+ value = await context.run(
42
+ locator.get_attribute(name, timeout=timeout * 1000),
43
+ timeout=timeout,
44
+ )
45
+ if value is not None and value != "":
46
+ return value
47
+ if required:
48
+ raise ValueError(f"Required element attribute {name!r} is empty")
49
+ return None