pagefetch 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.
- pagefetch/__init__.py +29 -0
- pagefetch/__main__.py +4 -0
- pagefetch/bootstrap.py +113 -0
- pagefetch/cache/__init__.py +7 -0
- pagefetch/cache/keys.py +28 -0
- pagefetch/cache/sqlite.py +103 -0
- pagefetch/cli.py +165 -0
- pagefetch/client.py +643 -0
- pagefetch/config.py +167 -0
- pagefetch/constants.py +32 -0
- pagefetch/exceptions.py +15 -0
- pagefetch/fetching/__init__.py +7 -0
- pagefetch/fetching/browser.py +261 -0
- pagefetch/fetching/http.py +130 -0
- pagefetch/fetching/readiness.py +81 -0
- pagefetch/models.py +100 -0
- pagefetch/processing/__init__.py +6 -0
- pagefetch/processing/cleaner.py +39 -0
- pagefetch/processing/detector.py +142 -0
- pagefetch/processing/html.py +47 -0
- pagefetch/processing/images.py +31 -0
- pagefetch/processing/links.py +40 -0
- pagefetch/processing/markdown.py +217 -0
- pagefetch/processing/metadata.py +84 -0
- pagefetch/processing/non_html.py +74 -0
- pagefetch/proxy/__init__.py +6 -0
- pagefetch/proxy/providers.py +84 -0
- pagefetch/utils/__init__.py +2 -0
- pagefetch/utils/durations.py +25 -0
- pagefetch/utils/urls.py +68 -0
- pagefetch-0.1.0.dist-info/METADATA +423 -0
- pagefetch-0.1.0.dist-info/RECORD +36 -0
- pagefetch-0.1.0.dist-info/WHEEL +5 -0
- pagefetch-0.1.0.dist-info/entry_points.txt +2 -0
- pagefetch-0.1.0.dist-info/licenses/LICENSE +22 -0
- pagefetch-0.1.0.dist-info/top_level.txt +1 -0
pagefetch/client.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""Main PageFetch client and fetch pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from bs4 import BeautifulSoup
|
|
16
|
+
|
|
17
|
+
from .bootstrap import ensure_runtime_requirements
|
|
18
|
+
from .cache import SQLiteCache, build_cache_key
|
|
19
|
+
from .config import VALID_MODES, VALID_PROXIES, PageFetchConfig
|
|
20
|
+
from .constants import (
|
|
21
|
+
BLOCKED_STATUS_CODES,
|
|
22
|
+
BROWSER_HEADERS,
|
|
23
|
+
RETRYABLE_STATUS_CODES,
|
|
24
|
+
SAFE_RESPONSE_HEADERS,
|
|
25
|
+
XML_TYPES,
|
|
26
|
+
)
|
|
27
|
+
from .exceptions import PageFetchError
|
|
28
|
+
from .fetching import BrowserFetcher, HTTPFetcher, HTTPResponse, TransportFailure
|
|
29
|
+
from .models import FetchErrorInfo, FetchResult
|
|
30
|
+
from .processing.detector import analyze_html
|
|
31
|
+
from .processing.html import process_html
|
|
32
|
+
from .processing.non_html import process_pdf, process_text, process_xml
|
|
33
|
+
from .proxy import ProxyConfigurationError, resolve_proxy
|
|
34
|
+
from .utils.durations import parse_duration
|
|
35
|
+
from .utils.urls import normalize_url, validate_url
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("pagefetch")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PageFetch:
|
|
41
|
+
"""Asynchronous HTTP-first page fetcher with automatic Camoufox fallback.
|
|
42
|
+
|
|
43
|
+
The client may be used as an async context manager. Calling :meth:`fetch`
|
|
44
|
+
without an explicit context also starts resources lazily; call :meth:`close`
|
|
45
|
+
when finished in that case.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
mode: str = "auto",
|
|
52
|
+
proxy: str = "none",
|
|
53
|
+
http_concurrency: int = 10,
|
|
54
|
+
browser_concurrency: int = 4,
|
|
55
|
+
cache_enabled: bool = True,
|
|
56
|
+
cache_ttl: str | int = "24h",
|
|
57
|
+
cache_path: str | Path | None = None,
|
|
58
|
+
http_timeout: float = 20.0,
|
|
59
|
+
browser_timeout: float = 45.0,
|
|
60
|
+
retries_http: int = 3,
|
|
61
|
+
retries_browser: int = 2,
|
|
62
|
+
max_redirects: int = 10,
|
|
63
|
+
max_content_size: int = 25 * 1024 * 1024,
|
|
64
|
+
confidence_threshold: float = 0.80,
|
|
65
|
+
raise_on_error: bool = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.config = PageFetchConfig.build(
|
|
68
|
+
mode=mode,
|
|
69
|
+
proxy=proxy,
|
|
70
|
+
http_concurrency=http_concurrency,
|
|
71
|
+
browser_concurrency=browser_concurrency,
|
|
72
|
+
cache_enabled=cache_enabled,
|
|
73
|
+
cache_ttl=cache_ttl,
|
|
74
|
+
cache_path=cache_path,
|
|
75
|
+
http_timeout=http_timeout,
|
|
76
|
+
browser_timeout=browser_timeout,
|
|
77
|
+
retries_http=retries_http,
|
|
78
|
+
retries_browser=retries_browser,
|
|
79
|
+
max_redirects=max_redirects,
|
|
80
|
+
max_content_size=max_content_size,
|
|
81
|
+
confidence_threshold=confidence_threshold,
|
|
82
|
+
raise_on_error=raise_on_error,
|
|
83
|
+
)
|
|
84
|
+
self._http_semaphore = asyncio.Semaphore(self.config.http_concurrency)
|
|
85
|
+
self._browser_semaphore = asyncio.Semaphore(self.config.browser_concurrency)
|
|
86
|
+
self._http_clients: dict[str, httpx.AsyncClient] = {}
|
|
87
|
+
self._http_fetchers: dict[str, HTTPFetcher] = {}
|
|
88
|
+
self._browser_fetchers: dict[str, BrowserFetcher] = {}
|
|
89
|
+
import threading as _threading
|
|
90
|
+
self._http_init_lock = _threading.Lock()
|
|
91
|
+
self._browser_init_lock = _threading.Lock()
|
|
92
|
+
self._cache = SQLiteCache(self.config.cache_path) if self.config.cache_enabled else None
|
|
93
|
+
self._started = False
|
|
94
|
+
self._closed = False
|
|
95
|
+
self._lifecycle_lock = asyncio.Lock()
|
|
96
|
+
self._startup_warnings: list[str] = []
|
|
97
|
+
|
|
98
|
+
async def __aenter__(self) -> PageFetch:
|
|
99
|
+
await self.start()
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
|
|
103
|
+
await self.close()
|
|
104
|
+
|
|
105
|
+
async def start(self) -> PageFetch:
|
|
106
|
+
"""Initialize the cache; network and browser transports remain lazy."""
|
|
107
|
+
async with self._lifecycle_lock:
|
|
108
|
+
if self._started and not self._closed:
|
|
109
|
+
return self
|
|
110
|
+
if self._closed:
|
|
111
|
+
raise RuntimeError("PageFetch has already been closed")
|
|
112
|
+
ensure_runtime_requirements()
|
|
113
|
+
if self._cache:
|
|
114
|
+
try:
|
|
115
|
+
await self._cache.start()
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
self._cache = None
|
|
118
|
+
self._startup_warnings.append("Cache initialization failed; caching is disabled.")
|
|
119
|
+
logger.warning("cache initialization failed: %s", type(exc).__name__)
|
|
120
|
+
self._started = True
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
async def close(self) -> None:
|
|
124
|
+
"""Release all HTTP clients, browser processes, and the cache database.
|
|
125
|
+
|
|
126
|
+
Safe to call repeatedly — subsequent calls are no-ops.
|
|
127
|
+
"""
|
|
128
|
+
async with self._lifecycle_lock:
|
|
129
|
+
if self._closed:
|
|
130
|
+
return
|
|
131
|
+
browser_results = await asyncio.gather(
|
|
132
|
+
*(browser.close() for browser in self._browser_fetchers.values()),
|
|
133
|
+
return_exceptions=True,
|
|
134
|
+
)
|
|
135
|
+
client_results = await asyncio.gather(
|
|
136
|
+
*(client.aclose() for client in self._http_clients.values()),
|
|
137
|
+
return_exceptions=True,
|
|
138
|
+
)
|
|
139
|
+
for failure in (*browser_results, *client_results):
|
|
140
|
+
if isinstance(failure, Exception):
|
|
141
|
+
logger.warning("resource cleanup failed: %s", type(failure).__name__)
|
|
142
|
+
if self._cache:
|
|
143
|
+
try:
|
|
144
|
+
await self._cache.close()
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.warning("cache cleanup failed: %s", type(exc).__name__)
|
|
147
|
+
self._browser_fetchers.clear()
|
|
148
|
+
self._http_fetchers.clear()
|
|
149
|
+
self._http_clients.clear()
|
|
150
|
+
self._closed = True
|
|
151
|
+
|
|
152
|
+
async def fetch(
|
|
153
|
+
self,
|
|
154
|
+
url: str,
|
|
155
|
+
*,
|
|
156
|
+
mode: str | None = None,
|
|
157
|
+
proxy: str | None = None,
|
|
158
|
+
use_cache: bool = True,
|
|
159
|
+
cache_ttl: str | int | None = None,
|
|
160
|
+
raise_on_error: bool | None = None,
|
|
161
|
+
) -> FetchResult:
|
|
162
|
+
"""Fetch one URL and return a structured result.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
url : str
|
|
167
|
+
The target URL to fetch.
|
|
168
|
+
mode : str | None
|
|
169
|
+
Override the default fetch mode (``'auto'``, ``'http'``, or ``'browser'``).
|
|
170
|
+
proxy : str | None
|
|
171
|
+
Override the default proxy provider.
|
|
172
|
+
use_cache : bool
|
|
173
|
+
Whether to attempt reading from and writing to the cache (default ``True``).
|
|
174
|
+
cache_ttl : str | int | None
|
|
175
|
+
Override the default cache TTL.
|
|
176
|
+
raise_on_error : bool | None
|
|
177
|
+
Override the default ``raise_on_error`` flag.
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
FetchResult
|
|
182
|
+
Structured result with success status, content, and metadata.
|
|
183
|
+
"""
|
|
184
|
+
started_at = time.perf_counter()
|
|
185
|
+
selected_mode = mode or self.config.mode
|
|
186
|
+
selected_proxy = proxy or self.config.proxy
|
|
187
|
+
should_raise = self.config.raise_on_error if raise_on_error is None else raise_on_error
|
|
188
|
+
try:
|
|
189
|
+
self._validate_fetch_options(selected_mode, selected_proxy)
|
|
190
|
+
validate_url(url)
|
|
191
|
+
normalized_url = normalize_url(url)
|
|
192
|
+
except (TypeError, ValueError) as exc:
|
|
193
|
+
code = "unsupported_scheme" if "scheme" in str(exc) else "invalid_url"
|
|
194
|
+
result = self._finish_error(
|
|
195
|
+
url=str(url),
|
|
196
|
+
proxy=selected_proxy,
|
|
197
|
+
error=FetchErrorInfo(code, str(exc), False, type(exc).__name__),
|
|
198
|
+
started_at=started_at,
|
|
199
|
+
should_raise=should_raise,
|
|
200
|
+
)
|
|
201
|
+
result.duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
await self.start()
|
|
205
|
+
ttl = self.config.cache_ttl if cache_ttl is None else parse_duration(cache_ttl)
|
|
206
|
+
cache_key = build_cache_key(
|
|
207
|
+
normalized_url,
|
|
208
|
+
mode=selected_mode,
|
|
209
|
+
proxy=selected_proxy,
|
|
210
|
+
settings={
|
|
211
|
+
"browser_timeout": self.config.browser_timeout,
|
|
212
|
+
"confidence_threshold": self.config.confidence_threshold,
|
|
213
|
+
"headers": BROWSER_HEADERS,
|
|
214
|
+
"max_content_size": self.config.max_content_size,
|
|
215
|
+
"retries_browser": self.config.retries_browser,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
fetch_warnings = list(self._startup_warnings)
|
|
219
|
+
if self._cache and use_cache:
|
|
220
|
+
try:
|
|
221
|
+
cached = await self._cache.get(cache_key)
|
|
222
|
+
if cached:
|
|
223
|
+
cached.duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
224
|
+
logger.debug("cache hit for %s", normalized_url)
|
|
225
|
+
return cached
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
fetch_warnings.append("Cache read failed; content was fetched normally.")
|
|
228
|
+
logger.warning("cache read failed: %s", type(exc).__name__)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
if selected_mode == "browser":
|
|
232
|
+
result = await self._fetch_browser(normalized_url, selected_proxy, status_code=None)
|
|
233
|
+
else:
|
|
234
|
+
result = await self._fetch_http_or_auto(normalized_url, selected_mode, selected_proxy)
|
|
235
|
+
except (TransportFailure, ProxyConfigurationError) as exc:
|
|
236
|
+
error = exc.error if isinstance(exc, TransportFailure) else FetchErrorInfo(
|
|
237
|
+
"connection_error", str(exc), False, type(exc).__name__
|
|
238
|
+
)
|
|
239
|
+
result = self._finish_error(
|
|
240
|
+
url=normalized_url,
|
|
241
|
+
proxy=selected_proxy,
|
|
242
|
+
error=error,
|
|
243
|
+
status_code=getattr(exc, "status_code", None),
|
|
244
|
+
started_at=started_at,
|
|
245
|
+
should_raise=should_raise,
|
|
246
|
+
)
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
result = self._finish_error(
|
|
249
|
+
url=normalized_url,
|
|
250
|
+
proxy=selected_proxy,
|
|
251
|
+
error=FetchErrorInfo("unknown_error", "An unexpected error occurred while fetching the page.", False, type(exc).__name__),
|
|
252
|
+
started_at=started_at,
|
|
253
|
+
should_raise=should_raise,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
result.duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
257
|
+
result.warnings[:0] = fetch_warnings
|
|
258
|
+
if not result.success and should_raise and result.error:
|
|
259
|
+
raise PageFetchError(result.error, url=result.url)
|
|
260
|
+
if self._cache and use_cache and result.success and not self._uncacheable(result):
|
|
261
|
+
try:
|
|
262
|
+
await self._cache.set(cache_key, result, ttl)
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
result.warnings.append("Result could not be written to cache.")
|
|
265
|
+
logger.warning("cache write failed: %s", type(exc).__name__)
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
async def fetch_many(
|
|
269
|
+
self,
|
|
270
|
+
urls: Iterable[str],
|
|
271
|
+
*,
|
|
272
|
+
mode: str | None = None,
|
|
273
|
+
proxy: str | None = None,
|
|
274
|
+
use_cache: bool = True,
|
|
275
|
+
cache_ttl: str | int | None = None,
|
|
276
|
+
raise_on_error: bool | None = None,
|
|
277
|
+
) -> list[FetchResult]:
|
|
278
|
+
"""Fetch unique URLs concurrently while preserving input order.
|
|
279
|
+
|
|
280
|
+
Parameters
|
|
281
|
+
----------
|
|
282
|
+
urls : Iterable[str]
|
|
283
|
+
URLs to fetch (duplicates are deduplicated before fetching).
|
|
284
|
+
mode : str | None
|
|
285
|
+
Override the default fetch mode.
|
|
286
|
+
proxy : str | None
|
|
287
|
+
Override the default proxy provider.
|
|
288
|
+
use_cache : bool
|
|
289
|
+
Whether to use the cache (default ``True``).
|
|
290
|
+
cache_ttl : str | int | None
|
|
291
|
+
Override the default cache TTL.
|
|
292
|
+
raise_on_error : bool | None
|
|
293
|
+
Override the default ``raise_on_error`` flag.
|
|
294
|
+
|
|
295
|
+
Returns
|
|
296
|
+
-------
|
|
297
|
+
list[FetchResult]
|
|
298
|
+
One result per input URL in the original order, preserving
|
|
299
|
+
duplicates and failures.
|
|
300
|
+
"""
|
|
301
|
+
ordered = list(urls)
|
|
302
|
+
unique = list(dict.fromkeys(ordered))
|
|
303
|
+
|
|
304
|
+
async def one(item: str) -> FetchResult:
|
|
305
|
+
item_start = time.perf_counter()
|
|
306
|
+
try:
|
|
307
|
+
return await self.fetch(
|
|
308
|
+
item,
|
|
309
|
+
mode=mode,
|
|
310
|
+
proxy=proxy,
|
|
311
|
+
use_cache=use_cache,
|
|
312
|
+
cache_ttl=cache_ttl,
|
|
313
|
+
raise_on_error=raise_on_error,
|
|
314
|
+
)
|
|
315
|
+
except PageFetchError as exc:
|
|
316
|
+
return FetchResult(
|
|
317
|
+
url=normalize_url(item),
|
|
318
|
+
success=False,
|
|
319
|
+
proxy_provider=proxy or self.config.proxy,
|
|
320
|
+
error=exc.error,
|
|
321
|
+
duration_ms=round((time.perf_counter() - item_start) * 1000, 2),
|
|
322
|
+
fetched_at=datetime.now(UTC),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
fetched = await asyncio.gather(*(one(item) for item in unique))
|
|
326
|
+
by_url = dict(zip(unique, fetched, strict=True))
|
|
327
|
+
return [by_url[item] for item in ordered]
|
|
328
|
+
|
|
329
|
+
async def _fetch_http_or_auto(self, url: str, mode: str, proxy: str) -> FetchResult:
|
|
330
|
+
try:
|
|
331
|
+
response = await self._http_fetcher(proxy).fetch(url)
|
|
332
|
+
except TransportFailure as exc:
|
|
333
|
+
if mode == "auto" and exc.error.retryable:
|
|
334
|
+
logger.info("HTTP transport failed; using browser for %s", url)
|
|
335
|
+
return await self._fetch_browser(url, proxy, status_code=None)
|
|
336
|
+
raise
|
|
337
|
+
if response.status_code >= 400:
|
|
338
|
+
retryable = response.status_code in RETRYABLE_STATUS_CODES
|
|
339
|
+
if mode == "auto" and (response.status_code in BLOCKED_STATUS_CODES or retryable):
|
|
340
|
+
logger.info("HTTP %s; using browser for %s", response.status_code, url)
|
|
341
|
+
return await self._fetch_browser(url, proxy, status_code=response.status_code)
|
|
342
|
+
code = "blocked" if response.status_code in BLOCKED_STATUS_CODES else "http_error"
|
|
343
|
+
raise TransportFailure(
|
|
344
|
+
FetchErrorInfo(code, f"HTTP request returned status {response.status_code}", retryable),
|
|
345
|
+
status_code=response.status_code,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
content_type = self._content_type(response.headers.get("Content-Type"))
|
|
349
|
+
if self._is_pdf(content_type, response.content):
|
|
350
|
+
return self._result_from_pdf(url, response, proxy)
|
|
351
|
+
if self._is_xml(content_type):
|
|
352
|
+
return self._result_from_xml(url, response, proxy)
|
|
353
|
+
if content_type.startswith("text/plain"):
|
|
354
|
+
return self._result_from_text(url, response, proxy)
|
|
355
|
+
html = self._decode(response)
|
|
356
|
+
report = analyze_html(html)
|
|
357
|
+
if mode == "auto" and report.score < self.config.confidence_threshold:
|
|
358
|
+
logger.info("HTTP confidence %.3f; using browser for %s", report.score, url)
|
|
359
|
+
try:
|
|
360
|
+
rendered = await self._fetch_browser(url, proxy, status_code=response.status_code)
|
|
361
|
+
except TransportFailure:
|
|
362
|
+
available = self._result_from_html(
|
|
363
|
+
original_url=url,
|
|
364
|
+
final_url=response.url,
|
|
365
|
+
status_code=response.status_code,
|
|
366
|
+
html=html,
|
|
367
|
+
content_type=content_type,
|
|
368
|
+
encoding=response.encoding,
|
|
369
|
+
proxy=proxy,
|
|
370
|
+
method="http",
|
|
371
|
+
response_headers=response.headers,
|
|
372
|
+
)
|
|
373
|
+
available.warnings.extend(
|
|
374
|
+
[
|
|
375
|
+
"Browser rendering failed; showing basic HTTP version instead.",
|
|
376
|
+
"Content may be incomplete.",
|
|
377
|
+
]
|
|
378
|
+
)
|
|
379
|
+
return available
|
|
380
|
+
if not rendered.success:
|
|
381
|
+
available = self._result_from_html(
|
|
382
|
+
original_url=url,
|
|
383
|
+
final_url=response.url,
|
|
384
|
+
status_code=response.status_code,
|
|
385
|
+
html=html,
|
|
386
|
+
content_type=content_type,
|
|
387
|
+
encoding=response.encoding,
|
|
388
|
+
proxy=proxy,
|
|
389
|
+
method="http",
|
|
390
|
+
response_headers=response.headers,
|
|
391
|
+
)
|
|
392
|
+
available.warnings.extend(
|
|
393
|
+
[
|
|
394
|
+
"Browser rendered content was not usable; showing HTTP version instead.",
|
|
395
|
+
"Content may be incomplete.",
|
|
396
|
+
]
|
|
397
|
+
)
|
|
398
|
+
return available
|
|
399
|
+
rendered.warnings.insert(0, "HTTP content confidence was low; browser fallback was used.")
|
|
400
|
+
return rendered
|
|
401
|
+
result = self._result_from_html(
|
|
402
|
+
original_url=url,
|
|
403
|
+
final_url=response.url,
|
|
404
|
+
status_code=response.status_code,
|
|
405
|
+
html=html,
|
|
406
|
+
content_type=content_type,
|
|
407
|
+
encoding=response.encoding,
|
|
408
|
+
proxy=proxy,
|
|
409
|
+
method="http",
|
|
410
|
+
response_headers=response.headers,
|
|
411
|
+
)
|
|
412
|
+
if mode == "http" and report.score < self.config.confidence_threshold:
|
|
413
|
+
result.warnings.append("HTTP content may be incomplete; browser fallback is disabled.")
|
|
414
|
+
return result
|
|
415
|
+
|
|
416
|
+
async def _fetch_browser(self, url: str, proxy: str, status_code: int | None) -> FetchResult:
|
|
417
|
+
response = await self._browser_fetcher(proxy).fetch(url)
|
|
418
|
+
raw_soup = BeautifulSoup(response.html, "lxml")
|
|
419
|
+
result = self._result_from_html(
|
|
420
|
+
original_url=url,
|
|
421
|
+
final_url=response.url,
|
|
422
|
+
status_code=response.status_code if response.status_code is not None else status_code,
|
|
423
|
+
html=response.html,
|
|
424
|
+
content_type="text/html",
|
|
425
|
+
encoding="utf-8",
|
|
426
|
+
proxy=proxy,
|
|
427
|
+
method="browser",
|
|
428
|
+
soup=raw_soup,
|
|
429
|
+
)
|
|
430
|
+
result.warnings.extend(response.warnings)
|
|
431
|
+
report = analyze_html(response.html, soup=raw_soup)
|
|
432
|
+
if response.status_code is not None and response.status_code >= 400:
|
|
433
|
+
result.success = False
|
|
434
|
+
code = "blocked" if response.status_code in BLOCKED_STATUS_CODES else "http_error"
|
|
435
|
+
result.error = FetchErrorInfo(
|
|
436
|
+
code,
|
|
437
|
+
f"browser navigation returned status {response.status_code}",
|
|
438
|
+
response.status_code in RETRYABLE_STATUS_CODES,
|
|
439
|
+
)
|
|
440
|
+
elif report.challenge:
|
|
441
|
+
result.success = False
|
|
442
|
+
result.error = FetchErrorInfo("captcha_detected", "challenge page remained after browser retries", False)
|
|
443
|
+
elif report.score < self.config.confidence_threshold:
|
|
444
|
+
result.warnings.append("Rendered content may still be incomplete.")
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
def _http_fetcher(self, provider: str) -> HTTPFetcher:
|
|
448
|
+
if provider not in self._http_fetchers:
|
|
449
|
+
with self._http_init_lock:
|
|
450
|
+
if provider in self._http_fetchers:
|
|
451
|
+
return self._http_fetchers[provider]
|
|
452
|
+
settings = resolve_proxy(provider)
|
|
453
|
+
client = httpx.AsyncClient(
|
|
454
|
+
headers=BROWSER_HEADERS,
|
|
455
|
+
timeout=httpx.Timeout(self.config.http_timeout),
|
|
456
|
+
follow_redirects=True,
|
|
457
|
+
max_redirects=self.config.max_redirects,
|
|
458
|
+
http2=True,
|
|
459
|
+
proxy=settings.url,
|
|
460
|
+
limits=httpx.Limits(
|
|
461
|
+
max_connections=self.config.http_concurrency * 2,
|
|
462
|
+
max_keepalive_connections=self.config.http_concurrency,
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
self._http_clients[provider] = client
|
|
466
|
+
self._http_fetchers[provider] = HTTPFetcher(
|
|
467
|
+
client,
|
|
468
|
+
self._http_semaphore,
|
|
469
|
+
retries=self.config.retries_http,
|
|
470
|
+
max_content_size=self.config.max_content_size,
|
|
471
|
+
)
|
|
472
|
+
return self._http_fetchers[provider]
|
|
473
|
+
|
|
474
|
+
def _browser_fetcher(self, provider: str) -> BrowserFetcher:
|
|
475
|
+
if provider not in self._browser_fetchers:
|
|
476
|
+
with self._browser_init_lock:
|
|
477
|
+
if provider in self._browser_fetchers:
|
|
478
|
+
return self._browser_fetchers[provider]
|
|
479
|
+
self._browser_fetchers[provider] = BrowserFetcher(
|
|
480
|
+
self._browser_semaphore,
|
|
481
|
+
timeout=self.config.browser_timeout,
|
|
482
|
+
retries=self.config.retries_browser,
|
|
483
|
+
proxy=resolve_proxy(provider),
|
|
484
|
+
max_content_size=self.config.max_content_size,
|
|
485
|
+
confidence_threshold=self.config.confidence_threshold,
|
|
486
|
+
)
|
|
487
|
+
return self._browser_fetchers[provider]
|
|
488
|
+
|
|
489
|
+
def _result_from_html(
|
|
490
|
+
self,
|
|
491
|
+
*,
|
|
492
|
+
original_url: str,
|
|
493
|
+
final_url: str,
|
|
494
|
+
status_code: int | None,
|
|
495
|
+
html: str,
|
|
496
|
+
content_type: str,
|
|
497
|
+
encoding: str | None,
|
|
498
|
+
proxy: str,
|
|
499
|
+
method: str,
|
|
500
|
+
response_headers: httpx.Headers | None = None,
|
|
501
|
+
soup: BeautifulSoup | None = None,
|
|
502
|
+
) -> FetchResult:
|
|
503
|
+
try:
|
|
504
|
+
processed = process_html(html, final_url, response_headers, soup=soup)
|
|
505
|
+
except Exception as exc:
|
|
506
|
+
raise TransportFailure(
|
|
507
|
+
FetchErrorInfo("parse_error", "HTML content could not be processed", False, type(exc).__name__)
|
|
508
|
+
) from exc
|
|
509
|
+
return FetchResult(
|
|
510
|
+
url=original_url,
|
|
511
|
+
final_url=final_url,
|
|
512
|
+
status_code=status_code,
|
|
513
|
+
success=True,
|
|
514
|
+
content_type=content_type,
|
|
515
|
+
encoding=encoding,
|
|
516
|
+
title=processed.title,
|
|
517
|
+
markdown=processed.markdown,
|
|
518
|
+
html=html,
|
|
519
|
+
text=processed.text,
|
|
520
|
+
metadata=processed.metadata,
|
|
521
|
+
links=processed.links,
|
|
522
|
+
images=processed.images,
|
|
523
|
+
fetch_method=method,
|
|
524
|
+
proxy_provider=proxy,
|
|
525
|
+
content_confidence=processed.confidence.score,
|
|
526
|
+
fetched_at=datetime.now(UTC),
|
|
527
|
+
warnings=processed.warnings,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def _result_from_pdf(self, url: str, response: HTTPResponse, proxy: str) -> FetchResult:
|
|
531
|
+
try:
|
|
532
|
+
doc = process_pdf(response.content)
|
|
533
|
+
except Exception as exc:
|
|
534
|
+
raise TransportFailure(FetchErrorInfo("pdf_parse_error", "PDF could not be parsed", False, type(exc).__name__)) from exc
|
|
535
|
+
return self._document_result(url, response, proxy, doc, "pdf", "application/pdf")
|
|
536
|
+
|
|
537
|
+
def _result_from_xml(self, url: str, response: HTTPResponse, proxy: str) -> FetchResult:
|
|
538
|
+
try:
|
|
539
|
+
doc = process_xml(response.content, response.encoding)
|
|
540
|
+
except Exception as exc:
|
|
541
|
+
raise TransportFailure(FetchErrorInfo("xml_parse_error", "XML could not be parsed", False, type(exc).__name__)) from exc
|
|
542
|
+
return self._document_result(
|
|
543
|
+
url,
|
|
544
|
+
response,
|
|
545
|
+
proxy,
|
|
546
|
+
doc,
|
|
547
|
+
"xml",
|
|
548
|
+
self._content_type(response.headers.get("Content-Type")),
|
|
549
|
+
raw_source=self._decode(response),
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def _result_from_text(self, url: str, response: HTTPResponse, proxy: str) -> FetchResult:
|
|
553
|
+
doc = process_text(response.content, response.encoding)
|
|
554
|
+
return self._document_result(url, response, proxy, doc, "text", self._content_type(response.headers.get("Content-Type")))
|
|
555
|
+
|
|
556
|
+
@staticmethod
|
|
557
|
+
def _document_result(
|
|
558
|
+
url: str,
|
|
559
|
+
response: HTTPResponse,
|
|
560
|
+
proxy: str,
|
|
561
|
+
doc: Any,
|
|
562
|
+
method: str,
|
|
563
|
+
content_type: str,
|
|
564
|
+
*,
|
|
565
|
+
raw_source: str | None = None,
|
|
566
|
+
) -> FetchResult:
|
|
567
|
+
doc.metadata["headers"] = {
|
|
568
|
+
key.lower(): value
|
|
569
|
+
for key, value in response.headers.items()
|
|
570
|
+
if key.lower() in SAFE_RESPONSE_HEADERS
|
|
571
|
+
}
|
|
572
|
+
return FetchResult(
|
|
573
|
+
url=url,
|
|
574
|
+
final_url=response.url,
|
|
575
|
+
status_code=response.status_code,
|
|
576
|
+
success=True,
|
|
577
|
+
content_type=content_type,
|
|
578
|
+
encoding=response.encoding,
|
|
579
|
+
title=doc.title,
|
|
580
|
+
markdown=doc.markdown,
|
|
581
|
+
html=raw_source,
|
|
582
|
+
text=doc.text,
|
|
583
|
+
metadata=doc.metadata,
|
|
584
|
+
fetch_method=method,
|
|
585
|
+
proxy_provider=proxy,
|
|
586
|
+
content_confidence=1.0,
|
|
587
|
+
fetched_at=datetime.now(UTC),
|
|
588
|
+
warnings=doc.warnings,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
@staticmethod
|
|
592
|
+
def _decode(response: HTTPResponse) -> str:
|
|
593
|
+
encoding = response.encoding or "utf-8"
|
|
594
|
+
try:
|
|
595
|
+
return response.content.decode(encoding)
|
|
596
|
+
except (LookupError, UnicodeDecodeError):
|
|
597
|
+
return response.content.decode("utf-8", errors="replace")
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def _content_type(header: str | None) -> str:
|
|
601
|
+
return (header or "application/octet-stream").split(";", 1)[0].strip().lower()
|
|
602
|
+
|
|
603
|
+
@staticmethod
|
|
604
|
+
def _is_pdf(content_type: str, content: bytes) -> bool:
|
|
605
|
+
return content_type == "application/pdf" or content.startswith(b"%PDF-")
|
|
606
|
+
|
|
607
|
+
@staticmethod
|
|
608
|
+
def _is_xml(content_type: str) -> bool:
|
|
609
|
+
return any(value in content_type for value in XML_TYPES)
|
|
610
|
+
|
|
611
|
+
@staticmethod
|
|
612
|
+
def _validate_fetch_options(mode: str, proxy: str) -> None:
|
|
613
|
+
if mode not in VALID_MODES:
|
|
614
|
+
raise ValueError(f"mode must be one of {sorted(VALID_MODES)}")
|
|
615
|
+
if proxy not in VALID_PROXIES:
|
|
616
|
+
raise ValueError(f"proxy must be one of {sorted(VALID_PROXIES)}")
|
|
617
|
+
|
|
618
|
+
@staticmethod
|
|
619
|
+
def _uncacheable(result: FetchResult) -> bool:
|
|
620
|
+
return bool(result.error) or result.status_code in BLOCKED_STATUS_CODES or (
|
|
621
|
+
result.status_code is not None and result.status_code >= 500
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
@staticmethod
|
|
625
|
+
def _finish_error(
|
|
626
|
+
*,
|
|
627
|
+
url: str,
|
|
628
|
+
proxy: str,
|
|
629
|
+
error: FetchErrorInfo,
|
|
630
|
+
started_at: float,
|
|
631
|
+
should_raise: bool,
|
|
632
|
+
status_code: int | None = None,
|
|
633
|
+
) -> FetchResult:
|
|
634
|
+
if should_raise:
|
|
635
|
+
raise PageFetchError(error, url=url)
|
|
636
|
+
return FetchResult(
|
|
637
|
+
url=url,
|
|
638
|
+
status_code=status_code,
|
|
639
|
+
success=False,
|
|
640
|
+
proxy_provider=proxy,
|
|
641
|
+
fetched_at=datetime.now(UTC),
|
|
642
|
+
error=error,
|
|
643
|
+
)
|