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