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/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
+ )