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