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,125 @@
1
+ """Bounded list discovery with stable keys and locator re-resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Awaitable, Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from ..context import CrawlContext
10
+ from ..errors import ConfigurationError
11
+ from ._common import maybe_await, positive_integer, positive_seconds
12
+
13
+ LocatorFactory = Callable[[], Any]
14
+ KeyFunction = Callable[[Any], Awaitable[str] | str]
15
+ AdvanceFunction = Callable[[int], Awaitable[bool | None] | bool | None]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ListItem:
20
+ """A stable business key whose current Locator is resolved on demand."""
21
+
22
+ key: str
23
+ round_number: int
24
+ discovered_index: int
25
+ _resolve: Callable[[], Awaitable[Any]] = field(repr=False, compare=False)
26
+
27
+ async def locator(self) -> Any:
28
+ """Find this key in the current DOM and return its current Locator."""
29
+ return await self._resolve()
30
+
31
+
32
+ async def iterate_list(
33
+ items: LocatorFactory,
34
+ key: KeyFunction,
35
+ context: CrawlContext,
36
+ *,
37
+ advance: AdvanceFunction | None = None,
38
+ max_items: int = 100,
39
+ max_rounds: int = 20,
40
+ no_progress_rounds: int = 1,
41
+ operation_timeout: float = 10,
42
+ ) -> AsyncIterator[ListItem]:
43
+ """Yield newly discovered list keys across bounded scroll/page rounds.
44
+
45
+ ``items`` must return a fresh locator for the current list. ``key`` must
46
+ derive a stable, non-empty business key from one item locator. The helper
47
+ never retains a Locator between yields; ``ListItem.locator`` scans the
48
+ current DOM by key. ``advance(round_number)`` may scroll or paginate and
49
+ returns ``False`` when no later round exists.
50
+
51
+ The helper does not restore virtual-list position or page state. Keep those
52
+ site-specific responsibilities in the calling collection flow.
53
+ """
54
+ if not callable(items) or not callable(key):
55
+ raise ConfigurationError("items and key must be callable")
56
+ if advance is not None and not callable(advance):
57
+ raise ConfigurationError("advance must be callable or None")
58
+ max_items = positive_integer(max_items, "max_items")
59
+ max_rounds = positive_integer(max_rounds, "max_rounds")
60
+ no_progress_rounds = positive_integer(no_progress_rounds, "no_progress_rounds")
61
+ operation_timeout = positive_seconds(operation_timeout, "operation_timeout")
62
+ seen: set[str] = set()
63
+ stalled = 0
64
+
65
+ async def read_key(locator: Any) -> str:
66
+ context.check()
67
+ value = await context.run(maybe_await(key(locator)), timeout=operation_timeout)
68
+ if not isinstance(value, str) or not value.strip():
69
+ raise ValueError("List item key must be a non-empty string")
70
+ return value.strip()
71
+
72
+ async def resolve(item_key: str) -> Any:
73
+ context.check()
74
+ current = items()
75
+ count = await context.run(current.count(), timeout=operation_timeout)
76
+ for index in range(count):
77
+ candidate = current.nth(index)
78
+ if await read_key(candidate) == item_key:
79
+ return candidate
80
+ raise LookupError("List item is no longer present")
81
+
82
+ def resolver_for(item_key: str) -> Callable[[], Awaitable[Any]]:
83
+ async def current_locator() -> Any:
84
+ return await resolve(item_key)
85
+
86
+ return current_locator
87
+
88
+ for round_number in range(1, max_rounds + 1):
89
+ context.check()
90
+ locator = items()
91
+ count = await context.run(locator.count(), timeout=operation_timeout)
92
+ round_keys: list[str] = []
93
+ round_seen: set[str] = set()
94
+ for index in range(count):
95
+ context.check()
96
+ item_key = await read_key(locator.nth(index))
97
+ if item_key not in round_seen:
98
+ round_seen.add(item_key)
99
+ round_keys.append(item_key)
100
+ del locator
101
+ added = 0
102
+ for index, item_key in enumerate(round_keys):
103
+ context.check()
104
+ if item_key in seen:
105
+ continue
106
+ seen.add(item_key)
107
+ added += 1
108
+ yield ListItem(
109
+ key=item_key,
110
+ round_number=round_number,
111
+ discovered_index=index,
112
+ _resolve=resolver_for(item_key),
113
+ )
114
+ if len(seen) >= max_items:
115
+ return
116
+ stalled = stalled + 1 if added == 0 else 0
117
+ if stalled >= no_progress_rounds or advance is None or round_number >= max_rounds:
118
+ return
119
+ context.check()
120
+ should_continue = await context.run(
121
+ maybe_await(advance(round_number)),
122
+ timeout=operation_timeout,
123
+ )
124
+ if should_continue is False:
125
+ return
@@ -0,0 +1,243 @@
1
+ """Bounded navigation scopes for a native Playwright Page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ import time
8
+ from collections.abc import AsyncIterator, Awaitable, Callable
9
+ from contextlib import asynccontextmanager
10
+ from typing import Any, Literal
11
+
12
+ from .._logging import get_logger, target_host
13
+ from .._network_diagnostics import observe_network
14
+ from ..context import CrawlContext
15
+ from ..errors import (
16
+ ConfigurationError,
17
+ CrawlCancelled,
18
+ FetchError,
19
+ LimitReached,
20
+ NetworkDiagnosticsSummary,
21
+ browser_error_code,
22
+ )
23
+ from ._common import cleanup, finish_cleanup, maybe_await, positive_seconds
24
+
25
+ WaitUntil = Literal["commit", "domcontentloaded", "load", "networkidle"]
26
+
27
+
28
+ def _navigation_evidence(summary: NetworkDiagnosticsSummary) -> tuple[bool, str | None]:
29
+ main_request_seen = False
30
+ failed_code = None
31
+ for event in summary["events"]:
32
+ if not event.get("main_document"):
33
+ continue
34
+ if event.get("event") == "request":
35
+ main_request_seen = True
36
+ if event.get("event") == "requestfailed":
37
+ failed_code = event.get("browser_error_code")
38
+ return main_request_seen, failed_code
39
+
40
+
41
+ def _reserve_top_level(context: CrawlContext) -> None:
42
+ context.check()
43
+ if context.stats.pages >= context.limits.max_pages:
44
+ context.stats.stop_reason = "max_pages"
45
+ raise LimitReached("Page limit reached")
46
+ context.reserve_request()
47
+ context.stats.pages += 1
48
+
49
+
50
+ async def open_url(
51
+ page: Any,
52
+ url: str,
53
+ context: CrawlContext,
54
+ *,
55
+ wait_until: WaitUntil = "domcontentloaded",
56
+ timeout: float = 30,
57
+ ) -> Any:
58
+ """Navigate and count one explicit top-level browser acquisition.
59
+
60
+ Page actions performed directly by developer code cannot be counted by the
61
+ Crawler. Use this helper for top-level navigations that should consume the
62
+ current ``max_requests`` and ``max_pages`` budgets.
63
+ """
64
+ timeout = positive_seconds(timeout, "timeout")
65
+ if not isinstance(url, str) or not url.strip():
66
+ raise ConfigurationError("url must be a non-empty string")
67
+ if wait_until not in {"commit", "domcontentloaded", "load", "networkidle"}:
68
+ raise ConfigurationError("Unknown browser navigation wait strategy")
69
+ _reserve_top_level(context)
70
+ started = time.monotonic()
71
+ network = observe_network(page, started, capture_events=True)
72
+ try:
73
+ return await context.run(
74
+ page.goto(url, wait_until=wait_until, timeout=timeout * 1000),
75
+ timeout=timeout,
76
+ )
77
+ except (CrawlCancelled, LimitReached):
78
+ raise
79
+ except Exception as exc:
80
+ code = browser_error_code(exc)
81
+ summary: NetworkDiagnosticsSummary = (
82
+ network.summary()
83
+ if network is not None
84
+ else {"events": [], "events_dropped": 0, "main_document_status": None}
85
+ )
86
+ main_request_seen, failed_code = _navigation_evidence(summary)
87
+ code = code or failed_code
88
+ main_status = summary["main_document_status"]
89
+ failure = FetchError(
90
+ "Browser navigation failed",
91
+ retryable=(
92
+ isinstance(exc, TimeoutError)
93
+ or type(exc).__name__ == "TimeoutError"
94
+ or code
95
+ in {"ERR_CONNECTION_RESET", "ERR_CONNECTION_CLOSED", "ERR_TIMED_OUT"}
96
+ ),
97
+ phase="navigation",
98
+ browser_error_code=code,
99
+ network_diagnostics=summary,
100
+ main_document_status=main_status,
101
+ )
102
+ failure.browser_diagnostics = {
103
+ "exception_type": type(exc).__name__,
104
+ "wait_until": wait_until,
105
+ "timeout_seconds": timeout,
106
+ "elapsed_seconds": round(time.monotonic() - started, 3),
107
+ "main_document_request_seen": main_request_seen,
108
+ "main_document_response_seen": main_status is not None,
109
+ }
110
+ get_logger().log(
111
+ logging.ERROR,
112
+ "[crawler] Navigation failed host=%s phase=navigation code=%s "
113
+ "main_status=%s request_seen=%s response_seen=%s elapsed=%.3fs",
114
+ target_host(url),
115
+ code or "NAVIGATION_FAILED",
116
+ main_status if main_status is not None else "none",
117
+ main_request_seen,
118
+ main_status is not None,
119
+ failure.browser_diagnostics["elapsed_seconds"],
120
+ )
121
+ raise failure from None
122
+ finally:
123
+ if network is not None:
124
+ network.detach()
125
+
126
+
127
+ @asynccontextmanager
128
+ async def new_page(
129
+ source_page: Any,
130
+ url: str,
131
+ context: CrawlContext,
132
+ *,
133
+ wait_until: WaitUntil = "domcontentloaded",
134
+ timeout: float = 30,
135
+ cleanup_timeout: float = 5,
136
+ ) -> AsyncIterator[Any]:
137
+ """Open an independently owned tab and always close it on exit."""
138
+ cleanup_timeout = positive_seconds(cleanup_timeout, "cleanup_timeout")
139
+ page = await context.run(source_page.context.new_page(), timeout=timeout)
140
+ try:
141
+ await open_url(page, url, context, wait_until=wait_until, timeout=timeout)
142
+ yield page
143
+ finally:
144
+ await finish_cleanup(
145
+ page.close(),
146
+ timeout=cleanup_timeout,
147
+ primary_error=sys.exc_info()[1],
148
+ failure_message="Browser helper page cleanup failed",
149
+ failure_note="Browser helper page cleanup also failed",
150
+ )
151
+
152
+
153
+ @asynccontextmanager
154
+ async def popup_page(
155
+ source_page: Any,
156
+ action: Callable[[], Awaitable[Any] | Any],
157
+ context: CrawlContext,
158
+ *,
159
+ timeout: float = 30,
160
+ cleanup_timeout: float = 5,
161
+ ) -> AsyncIterator[Any]:
162
+ """Run an action that opens a popup, then close the popup on exit."""
163
+ timeout = positive_seconds(timeout, "timeout")
164
+ cleanup_timeout = positive_seconds(cleanup_timeout, "cleanup_timeout")
165
+ if not callable(action):
166
+ raise ConfigurationError("action must be callable")
167
+ _reserve_top_level(context)
168
+ popup_info: Any | None = None
169
+ page: Any | None = None
170
+ try:
171
+ async with source_page.expect_popup(timeout=timeout * 1000) as popup_info:
172
+ context.check()
173
+ try:
174
+ await context.run(maybe_await(action()), timeout=timeout)
175
+ except BaseException:
176
+ try:
177
+ page = await cleanup(popup_info.value, timeout=cleanup_timeout)
178
+ except BaseException:
179
+ page = None
180
+ raise
181
+ page = await context.run(popup_info.value, timeout=timeout)
182
+ yield page
183
+ finally:
184
+ primary_error = sys.exc_info()[1]
185
+ if page is None and popup_info is not None:
186
+ try:
187
+ page = await cleanup(popup_info.value, timeout=cleanup_timeout)
188
+ except BaseException:
189
+ # 动作可能尚未创建弹窗;只在有限时间内观察未决事件。
190
+ page = None
191
+ if page is not None and page is not source_page:
192
+ await finish_cleanup(
193
+ page.close(),
194
+ timeout=cleanup_timeout,
195
+ primary_error=primary_error,
196
+ failure_message="Browser helper popup cleanup failed",
197
+ failure_note="Browser helper popup cleanup also failed",
198
+ )
199
+
200
+
201
+ @asynccontextmanager
202
+ async def same_tab(
203
+ page: Any,
204
+ url: str,
205
+ context: CrawlContext,
206
+ *,
207
+ wait_until: WaitUntil = "domcontentloaded",
208
+ timeout: float = 30,
209
+ restore_timeout: float = 10,
210
+ ) -> AsyncIterator[Any]:
211
+ """Navigate in place and restore the original URL on exit.
212
+
213
+ Browser history is tried first. If it does not restore the original URL,
214
+ the helper navigates directly to that URL. This restores location, not
215
+ arbitrary in-memory DOM, scroll, form, or virtual-list state.
216
+ """
217
+ restore_timeout = positive_seconds(restore_timeout, "restore_timeout")
218
+ original_url = page.url
219
+ try:
220
+ await open_url(page, url, context, wait_until=wait_until, timeout=timeout)
221
+ yield page
222
+ finally:
223
+ primary_error = sys.exc_info()[1]
224
+ if page.url != original_url:
225
+ try:
226
+ await cleanup(
227
+ page.go_back(wait_until=wait_until, timeout=restore_timeout * 1000),
228
+ timeout=restore_timeout,
229
+ )
230
+ except Exception:
231
+ pass
232
+ if page.url != original_url:
233
+ await finish_cleanup(
234
+ page.goto(
235
+ original_url,
236
+ wait_until=wait_until,
237
+ timeout=restore_timeout * 1000,
238
+ ),
239
+ timeout=restore_timeout,
240
+ primary_error=primary_error,
241
+ failure_message="Browser helper tab restoration failed",
242
+ failure_note="Browser helper tab restoration also failed",
243
+ )
@@ -0,0 +1,42 @@
1
+ """Cancellation-aware polling for site-specific readiness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import TypeVar
8
+
9
+ from ..context import CrawlContext
10
+ from ._common import maybe_await, positive_seconds
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ async def wait_until(
16
+ observe: Callable[[], Awaitable[T] | T],
17
+ predicate: Callable[[T], bool],
18
+ context: CrawlContext,
19
+ *,
20
+ timeout: float = 30,
21
+ poll_interval: float = 0.1,
22
+ ) -> T:
23
+ """Return the first observed value accepted by ``predicate``.
24
+
25
+ ``timeout`` bounds this readiness condition while ``CrawlContext`` still
26
+ enforces cancellation and the whole-crawl duration.
27
+ """
28
+ timeout = positive_seconds(timeout, "timeout")
29
+ poll_interval = positive_seconds(poll_interval, "poll_interval")
30
+ deadline = time.monotonic() + timeout
31
+ while True:
32
+ context.check()
33
+ remaining = deadline - time.monotonic()
34
+ if remaining <= 0:
35
+ raise TimeoutError("Readiness timeout reached")
36
+ value = await context.run(maybe_await(observe()), timeout=remaining)
37
+ if predicate(value):
38
+ return value
39
+ remaining = deadline - time.monotonic()
40
+ if remaining <= 0:
41
+ raise TimeoutError("Readiness timeout reached")
42
+ await context.sleep(min(poll_interval, remaining))