pagefetch 0.3.0__tar.gz → 0.5.0__tar.gz
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-0.3.0 → pagefetch-0.5.0}/PKG-INFO +20 -6
- {pagefetch-0.3.0 → pagefetch-0.5.0}/README.md +19 -5
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/__init__.py +0 -2
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/bootstrap.py +14 -2
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/cli.py +13 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/client.py +141 -38
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/config.py +12 -1
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/fetching/__init__.py +3 -1
- pagefetch-0.5.0/pagefetch/fetching/browser.py +409 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/fetching/http.py +22 -2
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/fetching/readiness.py +55 -8
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/interactive.py +70 -54
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/detector.py +200 -164
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/html.py +8 -2
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/proxy/providers.py +1 -1
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/utils/durations.py +1 -1
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/utils/urls.py +3 -3
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/PKG-INFO +20 -6
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pyproject.toml +1 -1
- {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_browser.py +70 -14
- {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_client.py +102 -1
- {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_processing.py +23 -0
- pagefetch-0.3.0/pagefetch/fetching/browser.py +0 -265
- {pagefetch-0.3.0 → pagefetch-0.5.0}/LICENSE +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/__main__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/cache/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/cache/keys.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/cache/sqlite.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/constants.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/exceptions.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/models.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/cleaner.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/images.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/links.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/markdown.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/metadata.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/non_html.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/proxy/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/utils/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/utils/rendering.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/SOURCES.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/dependency_links.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/entry_points.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/requires.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/top_level.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/setup.cfg +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_bootstrap.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_config_cli.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_non_html.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_proxy_cache.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_utils_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pagefetch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Asynchronous HTTP-first web fetching with conservative content extraction and browser fallback
|
|
5
5
|
Author: PageFetch contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -142,9 +142,10 @@ async with PageFetch(mode="auto") as client:
|
|
|
142
142
|
4. **Content analysis** — the `confidence` score evaluates HTML completeness using
|
|
143
143
|
text density, structural markup, heading presence, link counts, and common blocking signals
|
|
144
144
|
(captcha walls, empty bodies, access-denied patterns).
|
|
145
|
-
5. **Browser fallback** (auto mode only) —
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
5. **Browser fallback** (auto mode only) — Camoufox takes over only when HTTP content
|
|
146
|
+
confidence is below the threshold (default 0.80), or when the server returns a blocked
|
|
147
|
+
status (403/429). Timeouts, connection failures, 404s, and 5xx responses fail fast at
|
|
148
|
+
the HTTP layer instead of waiting on a browser navigation.
|
|
148
149
|
6. **Processing pipeline** — cleaned HTML → extracted links, images, metadata →
|
|
149
150
|
converted to Markdown via a custom converter that preserves tables, code blocks,
|
|
150
151
|
and nested lists.
|
|
@@ -216,6 +217,7 @@ client = PageFetch(
|
|
|
216
217
|
max_redirects=10, # Maximum redirect chain
|
|
217
218
|
max_content_size=25 * 1024 * 1024, # Max response body bytes (25 MiB)
|
|
218
219
|
confidence_threshold=0.80, # Min confidence before browser fallback
|
|
220
|
+
block_images=True, # Block image loading in browser mode to save bandwidth
|
|
219
221
|
raise_on_error=False, # Raise PageFetchError instead of returning error result
|
|
220
222
|
)
|
|
221
223
|
```
|
|
@@ -264,7 +266,7 @@ class FetchResult:
|
|
|
264
266
|
text: str | None # Plain-text body fallback
|
|
265
267
|
metadata: dict # OpenGraph, Twitter Cards, meta tags
|
|
266
268
|
links: list[LinkInfo] # All <a> tags with text, URL, rel
|
|
267
|
-
images: list[ImageInfo] # All <img> tags with
|
|
269
|
+
images: list[ImageInfo] # All <img> tags with url, alt, title
|
|
268
270
|
fetch_method: str | None # "http" or "browser"
|
|
269
271
|
proxy_provider: str # "none", "decodo", or "dataimpulse"
|
|
270
272
|
content_confidence: float | None # 0–1 completeness score (None for browser mode)
|
|
@@ -300,6 +302,9 @@ PageFetch ships with a command-line interface accessible via `pagefetch`:
|
|
|
300
302
|
# Fetch and print Markdown
|
|
301
303
|
pagefetch https://example.com --format markdown
|
|
302
304
|
|
|
305
|
+
# Fetch and print raw HTML
|
|
306
|
+
pagefetch https://example.com --format html
|
|
307
|
+
|
|
303
308
|
# Fetch from a list and output JSON
|
|
304
309
|
pagefetch urls.txt --format json --mode auto
|
|
305
310
|
|
|
@@ -311,10 +316,18 @@ pagefetch https://example.com --format json --include-html
|
|
|
311
316
|
|
|
312
317
|
# Multiple URLs from a file (one URL per line)
|
|
313
318
|
pagefetch urls.txt --format json --mode auto
|
|
319
|
+
|
|
320
|
+
# Load configuration from a YAML file with CLI overrides
|
|
321
|
+
pagefetch --config config.yaml --mode browser https://example.com
|
|
322
|
+
|
|
323
|
+
# Override cache TTL and disable image loading
|
|
324
|
+
pagefetch https://example.com --cache-ttl 1h --block-images
|
|
314
325
|
```
|
|
315
326
|
|
|
316
327
|
CLI arguments map directly to the Python API — `--mode`, `--proxy`, `--timeout`,
|
|
317
|
-
`--http-concurrency`,
|
|
328
|
+
`--browser-timeout`, `--http-concurrency`, `--browser-concurrency`, `--cache-ttl`,
|
|
329
|
+
`--no-cache`, `--block-images` / `--no-block-images`, `--include-html`,
|
|
330
|
+
`--debug`, and `--config` are all supported.
|
|
318
331
|
|
|
319
332
|
---
|
|
320
333
|
|
|
@@ -396,6 +409,7 @@ at the HTTP response level before any processing pipeline runs.
|
|
|
396
409
|
| `max_redirects` | `int` | `10` | Maximum redirect chain to follow |
|
|
397
410
|
| `max_content_size` | `int` | `25 MiB` | Maximum response body in bytes |
|
|
398
411
|
| `confidence_threshold` | `float` | `0.80` | Threshold for browser fallback in auto mode |
|
|
412
|
+
| `block_images` | `bool` | `True` | Block image loading in browser mode to save bandwidth |
|
|
399
413
|
| `raise_on_error` | `bool` | `False` | Raise `PageFetchError` on failure instead of returning error result |
|
|
400
414
|
|
|
401
415
|
---
|
|
@@ -104,9 +104,10 @@ async with PageFetch(mode="auto") as client:
|
|
|
104
104
|
4. **Content analysis** — the `confidence` score evaluates HTML completeness using
|
|
105
105
|
text density, structural markup, heading presence, link counts, and common blocking signals
|
|
106
106
|
(captcha walls, empty bodies, access-denied patterns).
|
|
107
|
-
5. **Browser fallback** (auto mode only) —
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
5. **Browser fallback** (auto mode only) — Camoufox takes over only when HTTP content
|
|
108
|
+
confidence is below the threshold (default 0.80), or when the server returns a blocked
|
|
109
|
+
status (403/429). Timeouts, connection failures, 404s, and 5xx responses fail fast at
|
|
110
|
+
the HTTP layer instead of waiting on a browser navigation.
|
|
110
111
|
6. **Processing pipeline** — cleaned HTML → extracted links, images, metadata →
|
|
111
112
|
converted to Markdown via a custom converter that preserves tables, code blocks,
|
|
112
113
|
and nested lists.
|
|
@@ -178,6 +179,7 @@ client = PageFetch(
|
|
|
178
179
|
max_redirects=10, # Maximum redirect chain
|
|
179
180
|
max_content_size=25 * 1024 * 1024, # Max response body bytes (25 MiB)
|
|
180
181
|
confidence_threshold=0.80, # Min confidence before browser fallback
|
|
182
|
+
block_images=True, # Block image loading in browser mode to save bandwidth
|
|
181
183
|
raise_on_error=False, # Raise PageFetchError instead of returning error result
|
|
182
184
|
)
|
|
183
185
|
```
|
|
@@ -226,7 +228,7 @@ class FetchResult:
|
|
|
226
228
|
text: str | None # Plain-text body fallback
|
|
227
229
|
metadata: dict # OpenGraph, Twitter Cards, meta tags
|
|
228
230
|
links: list[LinkInfo] # All <a> tags with text, URL, rel
|
|
229
|
-
images: list[ImageInfo] # All <img> tags with
|
|
231
|
+
images: list[ImageInfo] # All <img> tags with url, alt, title
|
|
230
232
|
fetch_method: str | None # "http" or "browser"
|
|
231
233
|
proxy_provider: str # "none", "decodo", or "dataimpulse"
|
|
232
234
|
content_confidence: float | None # 0–1 completeness score (None for browser mode)
|
|
@@ -262,6 +264,9 @@ PageFetch ships with a command-line interface accessible via `pagefetch`:
|
|
|
262
264
|
# Fetch and print Markdown
|
|
263
265
|
pagefetch https://example.com --format markdown
|
|
264
266
|
|
|
267
|
+
# Fetch and print raw HTML
|
|
268
|
+
pagefetch https://example.com --format html
|
|
269
|
+
|
|
265
270
|
# Fetch from a list and output JSON
|
|
266
271
|
pagefetch urls.txt --format json --mode auto
|
|
267
272
|
|
|
@@ -273,10 +278,18 @@ pagefetch https://example.com --format json --include-html
|
|
|
273
278
|
|
|
274
279
|
# Multiple URLs from a file (one URL per line)
|
|
275
280
|
pagefetch urls.txt --format json --mode auto
|
|
281
|
+
|
|
282
|
+
# Load configuration from a YAML file with CLI overrides
|
|
283
|
+
pagefetch --config config.yaml --mode browser https://example.com
|
|
284
|
+
|
|
285
|
+
# Override cache TTL and disable image loading
|
|
286
|
+
pagefetch https://example.com --cache-ttl 1h --block-images
|
|
276
287
|
```
|
|
277
288
|
|
|
278
289
|
CLI arguments map directly to the Python API — `--mode`, `--proxy`, `--timeout`,
|
|
279
|
-
`--http-concurrency`,
|
|
290
|
+
`--browser-timeout`, `--http-concurrency`, `--browser-concurrency`, `--cache-ttl`,
|
|
291
|
+
`--no-cache`, `--block-images` / `--no-block-images`, `--include-html`,
|
|
292
|
+
`--debug`, and `--config` are all supported.
|
|
280
293
|
|
|
281
294
|
---
|
|
282
295
|
|
|
@@ -358,6 +371,7 @@ at the HTTP response level before any processing pipeline runs.
|
|
|
358
371
|
| `max_redirects` | `int` | `10` | Maximum redirect chain to follow |
|
|
359
372
|
| `max_content_size` | `int` | `25 MiB` | Maximum response body in bytes |
|
|
360
373
|
| `confidence_threshold` | `float` | `0.80` | Threshold for browser fallback in auto mode |
|
|
374
|
+
| `block_images` | `bool` | `True` | Block image loading in browser mode to save bandwidth |
|
|
361
375
|
| `raise_on_error` | `bool` | `False` | Raise `PageFetchError` on failure instead of returning error result |
|
|
362
376
|
|
|
363
377
|
---
|
|
@@ -24,6 +24,7 @@ _RUNTIME_REQUIREMENTS = (
|
|
|
24
24
|
)
|
|
25
25
|
_FALSE_VALUES = frozenset({"0", "false", "no", "off"})
|
|
26
26
|
_bootstrap_complete = False
|
|
27
|
+
_camoufox_installed = False
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
class RuntimeBootstrapError(RuntimeError):
|
|
@@ -81,15 +82,20 @@ def install_python_requirements() -> None:
|
|
|
81
82
|
|
|
82
83
|
def install_camoufox_browser() -> None:
|
|
83
84
|
"""Download or update the Camoufox browser binary when it is unavailable."""
|
|
85
|
+
global _camoufox_installed
|
|
86
|
+
if _camoufox_installed:
|
|
87
|
+
return
|
|
84
88
|
try:
|
|
85
89
|
from camoufox.pkgman import camoufox_path
|
|
86
90
|
|
|
87
91
|
try:
|
|
88
92
|
camoufox_path(download_if_missing=False)
|
|
93
|
+
_camoufox_installed = True
|
|
89
94
|
return
|
|
90
95
|
except Exception:
|
|
91
96
|
print("[pagefetch] Installing the Camoufox browser runtime...", file=sys.stderr)
|
|
92
97
|
camoufox_path(download_if_missing=True)
|
|
98
|
+
_camoufox_installed = True
|
|
93
99
|
except Exception as exc:
|
|
94
100
|
raise RuntimeBootstrapError(
|
|
95
101
|
"PageFetch could not install the Camoufox browser runtime. "
|
|
@@ -98,17 +104,23 @@ def install_camoufox_browser() -> None:
|
|
|
98
104
|
) from exc
|
|
99
105
|
|
|
100
106
|
|
|
101
|
-
def ensure_runtime_requirements() -> None:
|
|
107
|
+
def ensure_runtime_requirements(*, needs_browser: bool = True) -> None:
|
|
102
108
|
"""Install all runtime requirements once at process startup.
|
|
103
109
|
|
|
104
110
|
The caller (PageFetch.start) is responsible for ensuring this is
|
|
105
111
|
called at most once per client lifecycle via its own asyncio.Lock.
|
|
112
|
+
|
|
113
|
+
*needs_browser* gates Camoufox installation so that HTTP-only
|
|
114
|
+
clients do not force an unnecessary browser download.
|
|
106
115
|
"""
|
|
107
116
|
global _bootstrap_complete
|
|
108
117
|
if not auto_install_enabled():
|
|
109
118
|
return
|
|
110
119
|
if _bootstrap_complete:
|
|
120
|
+
if needs_browser:
|
|
121
|
+
install_camoufox_browser()
|
|
111
122
|
return
|
|
112
123
|
install_python_requirements()
|
|
113
|
-
|
|
124
|
+
if needs_browser:
|
|
125
|
+
install_camoufox_browser()
|
|
114
126
|
_bootstrap_complete = True
|
|
@@ -36,6 +36,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
36
36
|
parser.add_argument("--browser-concurrency", type=int, default=argparse.SUPPRESS)
|
|
37
37
|
parser.add_argument("--timeout", type=float, default=argparse.SUPPRESS)
|
|
38
38
|
parser.add_argument("--browser-timeout", type=float, default=argparse.SUPPRESS)
|
|
39
|
+
parser.add_argument("--block-images", action="store_true", default=argparse.SUPPRESS, dest="block_images")
|
|
40
|
+
parser.add_argument("--no-block-images", action="store_false", default=argparse.SUPPRESS, dest="block_images")
|
|
39
41
|
parser.add_argument("--debug", action="store_true")
|
|
40
42
|
return parser
|
|
41
43
|
|
|
@@ -82,6 +84,8 @@ def _build_config(args: argparse.Namespace) -> PageFetchConfig:
|
|
|
82
84
|
overrides["http_timeout"] = args.timeout
|
|
83
85
|
if hasattr(args, "browser_timeout"):
|
|
84
86
|
overrides["browser_timeout"] = args.browser_timeout
|
|
87
|
+
if hasattr(args, "block_images"):
|
|
88
|
+
overrides["block_images"] = args.block_images
|
|
85
89
|
|
|
86
90
|
if not overrides and not args.no_cache:
|
|
87
91
|
return config
|
|
@@ -102,6 +106,7 @@ def _build_config(args: argparse.Namespace) -> PageFetchConfig:
|
|
|
102
106
|
max_redirects=config.max_redirects,
|
|
103
107
|
max_content_size=config.max_content_size,
|
|
104
108
|
confidence_threshold=config.confidence_threshold,
|
|
109
|
+
block_images=bool(overrides.get("block_images", config.block_images)),
|
|
105
110
|
raise_on_error=config.raise_on_error,
|
|
106
111
|
)
|
|
107
112
|
|
|
@@ -116,10 +121,18 @@ async def _run(args: argparse.Namespace) -> int:
|
|
|
116
121
|
proxy=config.proxy,
|
|
117
122
|
cache_enabled=config.cache_enabled,
|
|
118
123
|
cache_ttl=config.cache_ttl,
|
|
124
|
+
cache_path=config.cache_path,
|
|
119
125
|
http_concurrency=config.http_concurrency,
|
|
120
126
|
browser_concurrency=config.browser_concurrency,
|
|
121
127
|
http_timeout=config.http_timeout,
|
|
122
128
|
browser_timeout=config.browser_timeout,
|
|
129
|
+
retries_http=config.retries_http,
|
|
130
|
+
retries_browser=config.retries_browser,
|
|
131
|
+
max_redirects=config.max_redirects,
|
|
132
|
+
max_content_size=config.max_content_size,
|
|
133
|
+
confidence_threshold=config.confidence_threshold,
|
|
134
|
+
block_images=config.block_images,
|
|
135
|
+
raise_on_error=config.raise_on_error,
|
|
123
136
|
) as client:
|
|
124
137
|
results = await client.fetch_many(urls)
|
|
125
138
|
rendered = _render(results, args.format, args.include_html)
|
|
@@ -27,7 +27,7 @@ from .constants import (
|
|
|
27
27
|
from .exceptions import PageFetchError
|
|
28
28
|
from .fetching import BrowserFetcher, HTTPFetcher, HTTPResponse, TransportFailure
|
|
29
29
|
from .models import FetchErrorInfo, FetchResult
|
|
30
|
-
from .processing.detector import analyze_html
|
|
30
|
+
from .processing.detector import ConfidenceReport, analyze_html
|
|
31
31
|
from .processing.html import process_html
|
|
32
32
|
from .processing.non_html import process_pdf, process_text, process_xml
|
|
33
33
|
from .proxy import ProxyConfigurationError, resolve_proxy
|
|
@@ -62,6 +62,7 @@ class PageFetch:
|
|
|
62
62
|
max_redirects: int = 10,
|
|
63
63
|
max_content_size: int = 25 * 1024 * 1024,
|
|
64
64
|
confidence_threshold: float = 0.80,
|
|
65
|
+
block_images: bool = True,
|
|
65
66
|
raise_on_error: bool = False,
|
|
66
67
|
) -> None:
|
|
67
68
|
self.config = PageFetchConfig.build(
|
|
@@ -79,6 +80,7 @@ class PageFetch:
|
|
|
79
80
|
max_redirects=max_redirects,
|
|
80
81
|
max_content_size=max_content_size,
|
|
81
82
|
confidence_threshold=confidence_threshold,
|
|
83
|
+
block_images=block_images,
|
|
82
84
|
raise_on_error=raise_on_error,
|
|
83
85
|
)
|
|
84
86
|
self._http_semaphore = asyncio.Semaphore(self.config.http_concurrency)
|
|
@@ -93,6 +95,8 @@ class PageFetch:
|
|
|
93
95
|
self._closed = False
|
|
94
96
|
self._lifecycle_lock = asyncio.Lock()
|
|
95
97
|
self._startup_warnings: list[str] = []
|
|
98
|
+
self._active_fetches = 0
|
|
99
|
+
self._active_fetches_lock = asyncio.Lock()
|
|
96
100
|
|
|
97
101
|
async def __aenter__(self) -> PageFetch:
|
|
98
102
|
await self.start()
|
|
@@ -108,7 +112,10 @@ class PageFetch:
|
|
|
108
112
|
return self
|
|
109
113
|
if self._closed:
|
|
110
114
|
raise RuntimeError("PageFetch has already been closed")
|
|
111
|
-
|
|
115
|
+
await asyncio.to_thread(
|
|
116
|
+
ensure_runtime_requirements,
|
|
117
|
+
needs_browser=self.config.mode in ("auto", "browser"),
|
|
118
|
+
)
|
|
112
119
|
if self._cache:
|
|
113
120
|
try:
|
|
114
121
|
await self._cache.start()
|
|
@@ -127,6 +134,19 @@ class PageFetch:
|
|
|
127
134
|
async with self._lifecycle_lock:
|
|
128
135
|
if self._closed:
|
|
129
136
|
return
|
|
137
|
+
# Wait for in-flight fetches to drain before tearing down resources
|
|
138
|
+
for _ in range(50): # up to ~5 seconds
|
|
139
|
+
async with self._active_fetches_lock:
|
|
140
|
+
if self._active_fetches == 0:
|
|
141
|
+
break
|
|
142
|
+
await asyncio.sleep(0.1)
|
|
143
|
+
async with self._active_fetches_lock:
|
|
144
|
+
remaining = self._active_fetches
|
|
145
|
+
if remaining > 0:
|
|
146
|
+
logger.warning(
|
|
147
|
+
"close timed out waiting for %d in-flight fetch(es); tearing down resources anyway",
|
|
148
|
+
remaining,
|
|
149
|
+
)
|
|
130
150
|
browser_results = await asyncio.gather(
|
|
131
151
|
*(browser.close() for browser in self._browser_fetchers.values()),
|
|
132
152
|
return_exceptions=True,
|
|
@@ -201,17 +221,40 @@ class PageFetch:
|
|
|
201
221
|
return result
|
|
202
222
|
|
|
203
223
|
await self.start()
|
|
204
|
-
|
|
224
|
+
if self._closed:
|
|
225
|
+
return self._finish_error(
|
|
226
|
+
url=normalized_url,
|
|
227
|
+
proxy=selected_proxy,
|
|
228
|
+
error=FetchErrorInfo("client_closed", "PageFetch client has been closed", False),
|
|
229
|
+
started_at=started_at,
|
|
230
|
+
should_raise=should_raise,
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
ttl = self.config.cache_ttl if cache_ttl is None else parse_duration(cache_ttl)
|
|
234
|
+
except (TypeError, ValueError) as exc:
|
|
235
|
+
result = self._finish_error(
|
|
236
|
+
url=normalized_url,
|
|
237
|
+
proxy=selected_proxy,
|
|
238
|
+
error=FetchErrorInfo("invalid_cache_ttl", f"Invalid cache_ttl: {exc}", False, type(exc).__name__),
|
|
239
|
+
started_at=started_at,
|
|
240
|
+
should_raise=should_raise,
|
|
241
|
+
)
|
|
242
|
+
result.duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
243
|
+
return result
|
|
205
244
|
cache_key = build_cache_key(
|
|
206
245
|
normalized_url,
|
|
207
246
|
mode=selected_mode,
|
|
208
247
|
proxy=selected_proxy,
|
|
209
248
|
settings={
|
|
249
|
+
"block_images": self.config.block_images,
|
|
210
250
|
"browser_timeout": self.config.browser_timeout,
|
|
211
251
|
"confidence_threshold": self.config.confidence_threshold,
|
|
212
252
|
"headers": BROWSER_HEADERS,
|
|
253
|
+
"http_timeout": self.config.http_timeout,
|
|
213
254
|
"max_content_size": self.config.max_content_size,
|
|
255
|
+
"max_redirects": self.config.max_redirects,
|
|
214
256
|
"retries_browser": self.config.retries_browser,
|
|
257
|
+
"retries_http": self.config.retries_http,
|
|
215
258
|
},
|
|
216
259
|
)
|
|
217
260
|
fetch_warnings = list(self._startup_warnings)
|
|
@@ -226,31 +269,37 @@ class PageFetch:
|
|
|
226
269
|
fetch_warnings.append("Cache read failed; content was fetched normally.")
|
|
227
270
|
logger.warning("cache read failed: %s", type(exc).__name__)
|
|
228
271
|
|
|
272
|
+
async with self._active_fetches_lock:
|
|
273
|
+
self._active_fetches += 1
|
|
229
274
|
try:
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
275
|
+
try:
|
|
276
|
+
if selected_mode == "browser":
|
|
277
|
+
result = await self._fetch_browser(normalized_url, selected_proxy, status_code=None)
|
|
278
|
+
else:
|
|
279
|
+
result = await self._fetch_http_or_auto(normalized_url, selected_mode, selected_proxy)
|
|
280
|
+
except (TransportFailure, ProxyConfigurationError) as exc:
|
|
281
|
+
error = exc.error if isinstance(exc, TransportFailure) else FetchErrorInfo(
|
|
282
|
+
"connection_error", str(exc), False, type(exc).__name__
|
|
283
|
+
)
|
|
284
|
+
result = self._finish_error(
|
|
285
|
+
url=normalized_url,
|
|
286
|
+
proxy=selected_proxy,
|
|
287
|
+
error=error,
|
|
288
|
+
status_code=getattr(exc, "status_code", None),
|
|
289
|
+
started_at=started_at,
|
|
290
|
+
should_raise=should_raise,
|
|
291
|
+
)
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
result = self._finish_error(
|
|
294
|
+
url=normalized_url,
|
|
295
|
+
proxy=selected_proxy,
|
|
296
|
+
error=FetchErrorInfo("unknown_error", "An unexpected error occurred while fetching the page.", False, type(exc).__name__),
|
|
297
|
+
started_at=started_at,
|
|
298
|
+
should_raise=should_raise,
|
|
299
|
+
)
|
|
300
|
+
finally:
|
|
301
|
+
async with self._active_fetches_lock:
|
|
302
|
+
self._active_fetches -= 1
|
|
254
303
|
|
|
255
304
|
result.duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
256
305
|
result.warnings[:0] = fetch_warnings
|
|
@@ -319,8 +368,14 @@ class PageFetch:
|
|
|
319
368
|
raise_on_error=_raise_on_error,
|
|
320
369
|
)
|
|
321
370
|
except PageFetchError as exc:
|
|
371
|
+
# Use the raw item string — normalize_url would re-raise
|
|
372
|
+
# ValueError for the same invalid URL that caused the error.
|
|
373
|
+
try:
|
|
374
|
+
result_url = normalize_url(item)
|
|
375
|
+
except ValueError:
|
|
376
|
+
result_url = str(item)
|
|
322
377
|
return FetchResult(
|
|
323
|
-
url=
|
|
378
|
+
url=result_url,
|
|
324
379
|
success=False,
|
|
325
380
|
proxy_provider=proxy or self.config.proxy,
|
|
326
381
|
error=exc.error,
|
|
@@ -330,19 +385,32 @@ class PageFetch:
|
|
|
330
385
|
|
|
331
386
|
fetched = await asyncio.gather(*(one(item) for item in unique))
|
|
332
387
|
by_url = dict(zip(unique, fetched, strict=True))
|
|
333
|
-
return
|
|
388
|
+
# For duplicate URLs, return independent copies so callers
|
|
389
|
+
# do not accidentally alias mutable fields across entries.
|
|
390
|
+
from copy import deepcopy
|
|
391
|
+
results: list[FetchResult] = []
|
|
392
|
+
emitted: set[int] = set()
|
|
393
|
+
for item in ordered:
|
|
394
|
+
result = by_url[item]
|
|
395
|
+
if id(result) in emitted:
|
|
396
|
+
result = deepcopy(result)
|
|
397
|
+
emitted.add(id(result))
|
|
398
|
+
results.append(result)
|
|
399
|
+
return results
|
|
334
400
|
|
|
335
401
|
async def _fetch_http_or_auto(self, url: str, mode: Literal["auto", "http", "browser"], proxy: str) -> FetchResult:
|
|
336
402
|
try:
|
|
337
403
|
response = await (await self._http_fetcher(proxy)).fetch(url)
|
|
338
|
-
except TransportFailure
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
404
|
+
except TransportFailure:
|
|
405
|
+
# Timeouts, DNS failures, disconnects, and other transport errors are not
|
|
406
|
+
# fixed by rendering in a browser — surface them immediately instead of
|
|
407
|
+
# waiting on an expensive Camoufox navigation that will fail the same way.
|
|
342
408
|
raise
|
|
343
409
|
if response.status_code >= 400:
|
|
344
410
|
retryable = response.status_code in RETRYABLE_STATUS_CODES
|
|
345
|
-
|
|
411
|
+
# Only anti-bot / rate-limit responses benefit from a stealth browser.
|
|
412
|
+
# 4xx like 404 and 5xx server errors should fail fast at the HTTP layer.
|
|
413
|
+
if mode == "auto" and response.status_code in BLOCKED_STATUS_CODES:
|
|
346
414
|
logger.info("HTTP %s; using browser for %s", response.status_code, url)
|
|
347
415
|
return await self._fetch_browser(url, proxy, status_code=response.status_code)
|
|
348
416
|
code = "blocked" if response.status_code in BLOCKED_STATUS_CODES else "http_error"
|
|
@@ -358,9 +426,18 @@ class PageFetch:
|
|
|
358
426
|
return self._result_from_xml(url, response, proxy)
|
|
359
427
|
if content_type.startswith("text/plain"):
|
|
360
428
|
return self._result_from_text(url, response, proxy)
|
|
429
|
+
if not self._is_html_like(content_type) and not self._looks_like_html(response.content):
|
|
430
|
+
raise TransportFailure(
|
|
431
|
+
FetchErrorInfo(
|
|
432
|
+
"unsupported_content_type",
|
|
433
|
+
f"Content type {content_type!r} is not HTML; cannot process with HTTP/auto mode",
|
|
434
|
+
False,
|
|
435
|
+
),
|
|
436
|
+
status_code=response.status_code,
|
|
437
|
+
)
|
|
361
438
|
html = self._decode(response)
|
|
362
439
|
raw_soup = BeautifulSoup(html, "lxml")
|
|
363
|
-
report = analyze_html(html)
|
|
440
|
+
report = analyze_html(html, soup=raw_soup)
|
|
364
441
|
if mode == "auto" and report.score < self.config.confidence_threshold:
|
|
365
442
|
logger.info("HTTP confidence %.3f; using browser for %s", report.score, url)
|
|
366
443
|
try:
|
|
@@ -377,6 +454,7 @@ class PageFetch:
|
|
|
377
454
|
method="http",
|
|
378
455
|
response_headers=response.headers,
|
|
379
456
|
soup=raw_soup,
|
|
457
|
+
confidence=report,
|
|
380
458
|
)
|
|
381
459
|
available.warnings.extend(
|
|
382
460
|
[
|
|
@@ -397,6 +475,7 @@ class PageFetch:
|
|
|
397
475
|
method="http",
|
|
398
476
|
response_headers=response.headers,
|
|
399
477
|
soup=raw_soup,
|
|
478
|
+
confidence=report,
|
|
400
479
|
)
|
|
401
480
|
available.warnings.extend(
|
|
402
481
|
[
|
|
@@ -418,6 +497,7 @@ class PageFetch:
|
|
|
418
497
|
method="http",
|
|
419
498
|
response_headers=response.headers,
|
|
420
499
|
soup=raw_soup,
|
|
500
|
+
confidence=report,
|
|
421
501
|
)
|
|
422
502
|
if mode == "http" and report.score < self.config.confidence_threshold:
|
|
423
503
|
result.warnings.append("HTTP content may be incomplete; browser fallback is disabled.")
|
|
@@ -436,9 +516,10 @@ class PageFetch:
|
|
|
436
516
|
proxy=proxy,
|
|
437
517
|
method="browser",
|
|
438
518
|
soup=raw_soup,
|
|
519
|
+
confidence=response.confidence,
|
|
439
520
|
)
|
|
440
521
|
result.warnings.extend(response.warnings)
|
|
441
|
-
report =
|
|
522
|
+
report = response.confidence
|
|
442
523
|
if response.status_code is not None and response.status_code >= 400:
|
|
443
524
|
result.success = False
|
|
444
525
|
code = "blocked" if response.status_code in BLOCKED_STATUS_CODES else "http_error"
|
|
@@ -493,6 +574,7 @@ class PageFetch:
|
|
|
493
574
|
proxy=resolve_proxy(provider),
|
|
494
575
|
max_content_size=self.config.max_content_size,
|
|
495
576
|
confidence_threshold=self.config.confidence_threshold,
|
|
577
|
+
block_images=self.config.block_images,
|
|
496
578
|
)
|
|
497
579
|
return self._browser_fetchers[provider]
|
|
498
580
|
|
|
@@ -509,9 +591,10 @@ class PageFetch:
|
|
|
509
591
|
method: str,
|
|
510
592
|
response_headers: httpx.Headers | None = None,
|
|
511
593
|
soup: BeautifulSoup | None = None,
|
|
594
|
+
confidence: ConfidenceReport | None = None,
|
|
512
595
|
) -> FetchResult:
|
|
513
596
|
try:
|
|
514
|
-
processed = process_html(html, final_url, response_headers, soup=soup)
|
|
597
|
+
processed = process_html(html, final_url, response_headers, soup=soup, confidence=confidence)
|
|
515
598
|
except Exception as exc:
|
|
516
599
|
raise TransportFailure(
|
|
517
600
|
FetchErrorInfo("parse_error", "HTML content could not be processed", False, type(exc).__name__)
|
|
@@ -616,7 +699,27 @@ class PageFetch:
|
|
|
616
699
|
|
|
617
700
|
@staticmethod
|
|
618
701
|
def _is_xml(content_type: str) -> bool:
|
|
619
|
-
|
|
702
|
+
if content_type in ("application/xml", "text/xml"):
|
|
703
|
+
return True
|
|
704
|
+
# Match XML-based subtypes like application/atom+xml, image/svg+xml
|
|
705
|
+
# but NOT application/xhtml+xml (which is HTML5, not generic XML).
|
|
706
|
+
return "+xml" in content_type and content_type != "application/xhtml+xml"
|
|
707
|
+
|
|
708
|
+
@staticmethod
|
|
709
|
+
def _is_html_like(content_type: str) -> bool:
|
|
710
|
+
"""Return True when *content_type* should be processed as HTML."""
|
|
711
|
+
return content_type in ("text/html", "application/xhtml+xml")
|
|
712
|
+
|
|
713
|
+
@staticmethod
|
|
714
|
+
def _looks_like_html(content: bytes) -> bool:
|
|
715
|
+
"""Heuristic HTML sniff for responses with ambiguous content types."""
|
|
716
|
+
stripped = content.lstrip()
|
|
717
|
+
if not stripped:
|
|
718
|
+
return False
|
|
719
|
+
return stripped.startswith(b"<") and any(
|
|
720
|
+
marker in stripped[:512].lower()
|
|
721
|
+
for marker in (b"<!doctype html", b"<html", b"<head", b"<body", b"<title", b"<meta", b"<div", b"<p", b"<a ")
|
|
722
|
+
)
|
|
620
723
|
|
|
621
724
|
@staticmethod
|
|
622
725
|
def _validate_fetch_options(mode: str, proxy: str) -> None:
|
|
@@ -49,6 +49,7 @@ class PageFetchConfig:
|
|
|
49
49
|
max_redirects: int = 10
|
|
50
50
|
max_content_size: int = 25 * 1024 * 1024
|
|
51
51
|
confidence_threshold: float = 0.80
|
|
52
|
+
block_images: bool = True
|
|
52
53
|
raise_on_error: bool = False
|
|
53
54
|
|
|
54
55
|
@classmethod
|
|
@@ -73,7 +74,12 @@ class PageFetchConfig:
|
|
|
73
74
|
flat: dict[str, Any] = {}
|
|
74
75
|
for section in ("proxy", "cache"):
|
|
75
76
|
if section in resolved and isinstance(resolved[section], dict):
|
|
76
|
-
|
|
77
|
+
section_data = resolved.pop(section)
|
|
78
|
+
# A nested proxy section may use "provider" to select the provider;
|
|
79
|
+
# map it to the top-level "proxy" key so it is not silently dropped.
|
|
80
|
+
if section == "proxy" and "provider" in section_data:
|
|
81
|
+
flat["proxy"] = section_data.pop("provider")
|
|
82
|
+
flat.update(section_data)
|
|
77
83
|
flat.update(resolved)
|
|
78
84
|
|
|
79
85
|
return cls.build(
|
|
@@ -91,6 +97,7 @@ class PageFetchConfig:
|
|
|
91
97
|
max_redirects=flat.get("max_redirects", 10),
|
|
92
98
|
max_content_size=flat.get("max_content_size", 25 * 1024 * 1024),
|
|
93
99
|
confidence_threshold=flat.get("confidence_threshold", 0.80),
|
|
100
|
+
block_images=flat.get("block_images", True),
|
|
94
101
|
raise_on_error=flat.get("raise_on_error", False),
|
|
95
102
|
)
|
|
96
103
|
|
|
@@ -112,6 +119,7 @@ class PageFetchConfig:
|
|
|
112
119
|
max_redirects: int = 10,
|
|
113
120
|
max_content_size: int = 25 * 1024 * 1024,
|
|
114
121
|
confidence_threshold: float = 0.80,
|
|
122
|
+
block_images: bool = True,
|
|
115
123
|
raise_on_error: bool = False,
|
|
116
124
|
) -> PageFetchConfig:
|
|
117
125
|
if not isinstance(mode, str) or mode not in VALID_MODES:
|
|
@@ -146,6 +154,8 @@ class PageFetchConfig:
|
|
|
146
154
|
raise ValueError("confidence_threshold must be between 0 and 1")
|
|
147
155
|
if not isinstance(cache_enabled, bool) or not isinstance(raise_on_error, bool):
|
|
148
156
|
raise ValueError("cache_enabled and raise_on_error must be booleans")
|
|
157
|
+
if not isinstance(block_images, bool):
|
|
158
|
+
raise ValueError("block_images must be a boolean")
|
|
149
159
|
ttl = parse_duration(cache_ttl)
|
|
150
160
|
path = Path(cache_path).expanduser() if cache_path is not None else user_cache_path("pagefetch") / "cache.sqlite3"
|
|
151
161
|
return cls(
|
|
@@ -163,5 +173,6 @@ class PageFetchConfig:
|
|
|
163
173
|
max_redirects=max_redirects,
|
|
164
174
|
max_content_size=max_content_size,
|
|
165
175
|
confidence_threshold=float(confidence_threshold),
|
|
176
|
+
block_images=block_images,
|
|
166
177
|
raise_on_error=raise_on_error,
|
|
167
178
|
)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from .browser import BrowserFetcher, BrowserResponse
|
|
4
4
|
from .http import HTTPFetcher, HTTPResponse, TransportFailure
|
|
5
|
-
from .readiness import controlled_scroll, wait_for_stability
|
|
5
|
+
from .readiness import PageMetrics, controlled_scroll, in_page_metrics, wait_for_stability
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"BrowserFetcher",
|
|
@@ -10,6 +10,8 @@ __all__ = [
|
|
|
10
10
|
"HTTPFetcher",
|
|
11
11
|
"HTTPResponse",
|
|
12
12
|
"TransportFailure",
|
|
13
|
+
"PageMetrics",
|
|
13
14
|
"controlled_scroll",
|
|
15
|
+
"in_page_metrics",
|
|
14
16
|
"wait_for_stability",
|
|
15
17
|
]
|