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.
Files changed (52) hide show
  1. {pagefetch-0.3.0 → pagefetch-0.5.0}/PKG-INFO +20 -6
  2. {pagefetch-0.3.0 → pagefetch-0.5.0}/README.md +19 -5
  3. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/__init__.py +0 -2
  4. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/bootstrap.py +14 -2
  5. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/cli.py +13 -0
  6. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/client.py +141 -38
  7. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/config.py +12 -1
  8. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/fetching/__init__.py +3 -1
  9. pagefetch-0.5.0/pagefetch/fetching/browser.py +409 -0
  10. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/fetching/http.py +22 -2
  11. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/fetching/readiness.py +55 -8
  12. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/interactive.py +70 -54
  13. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/detector.py +200 -164
  14. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/html.py +8 -2
  15. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/proxy/providers.py +1 -1
  16. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/utils/durations.py +1 -1
  17. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/utils/urls.py +3 -3
  18. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/PKG-INFO +20 -6
  19. {pagefetch-0.3.0 → pagefetch-0.5.0}/pyproject.toml +1 -1
  20. {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_browser.py +70 -14
  21. {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_client.py +102 -1
  22. {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_processing.py +23 -0
  23. pagefetch-0.3.0/pagefetch/fetching/browser.py +0 -265
  24. {pagefetch-0.3.0 → pagefetch-0.5.0}/LICENSE +0 -0
  25. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/__main__.py +0 -0
  26. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/cache/__init__.py +0 -0
  27. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/cache/keys.py +0 -0
  28. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/cache/sqlite.py +0 -0
  29. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/constants.py +0 -0
  30. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/exceptions.py +0 -0
  31. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/models.py +0 -0
  32. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/__init__.py +0 -0
  33. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/cleaner.py +0 -0
  34. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/images.py +0 -0
  35. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/links.py +0 -0
  36. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/markdown.py +0 -0
  37. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/metadata.py +0 -0
  38. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/processing/non_html.py +0 -0
  39. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/proxy/__init__.py +0 -0
  40. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/utils/__init__.py +0 -0
  41. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch/utils/rendering.py +0 -0
  42. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/SOURCES.txt +0 -0
  43. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/dependency_links.txt +0 -0
  44. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/entry_points.txt +0 -0
  45. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/requires.txt +0 -0
  46. {pagefetch-0.3.0 → pagefetch-0.5.0}/pagefetch.egg-info/top_level.txt +0 -0
  47. {pagefetch-0.3.0 → pagefetch-0.5.0}/setup.cfg +0 -0
  48. {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_bootstrap.py +0 -0
  49. {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_config_cli.py +0 -0
  50. {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_non_html.py +0 -0
  51. {pagefetch-0.3.0 → pagefetch-0.5.0}/tests/test_proxy_cache.py +0 -0
  52. {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.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) — if confidence is below the threshold (default 0.80),
146
- Camoufox takes over: renders JavaScript, scrolls for lazy-loaded content, waits for
147
- network stability, and re-extracts.
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 src, alt, title
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`, and `--browser-concurrency` are all supported.
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) — if confidence is below the threshold (default 0.80),
108
- Camoufox takes over: renders JavaScript, scrolls for lazy-loaded content, waits for
109
- network stability, and re-extracts.
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 src, alt, title
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`, and `--browser-concurrency` are all supported.
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
  ---
@@ -46,5 +46,3 @@ __all__ = [
46
46
  "VALID_PROXIES",
47
47
  "ensure_runtime_requirements",
48
48
  ]
49
-
50
- __version__ = "0.1.0"
@@ -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
- install_camoufox_browser()
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
- ensure_runtime_requirements()
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
- ttl = self.config.cache_ttl if cache_ttl is None else parse_duration(cache_ttl)
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
- if selected_mode == "browser":
231
- result = await self._fetch_browser(normalized_url, selected_proxy, status_code=None)
232
- else:
233
- result = await self._fetch_http_or_auto(normalized_url, selected_mode, selected_proxy)
234
- except (TransportFailure, ProxyConfigurationError) as exc:
235
- error = exc.error if isinstance(exc, TransportFailure) else FetchErrorInfo(
236
- "connection_error", str(exc), False, type(exc).__name__
237
- )
238
- result = self._finish_error(
239
- url=normalized_url,
240
- proxy=selected_proxy,
241
- error=error,
242
- status_code=getattr(exc, "status_code", None),
243
- started_at=started_at,
244
- should_raise=should_raise,
245
- )
246
- except Exception as exc:
247
- result = self._finish_error(
248
- url=normalized_url,
249
- proxy=selected_proxy,
250
- error=FetchErrorInfo("unknown_error", "An unexpected error occurred while fetching the page.", False, type(exc).__name__),
251
- started_at=started_at,
252
- should_raise=should_raise,
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=normalize_url(item),
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 [by_url[item] for item in ordered]
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 as exc:
339
- if mode == "auto" and exc.error.retryable:
340
- logger.info("HTTP transport failed; using browser for %s", url)
341
- return await self._fetch_browser(url, proxy, status_code=None)
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
- if mode == "auto" and (response.status_code in BLOCKED_STATUS_CODES or retryable):
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 = analyze_html(response.html)
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
- return any(value in content_type for value in XML_TYPES)
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
- flat.update(resolved.pop(section))
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
  ]