pagefetch 0.3.0__tar.gz → 0.4.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 (51) hide show
  1. {pagefetch-0.3.0 → pagefetch-0.4.0}/PKG-INFO +1 -1
  2. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/cli.py +7 -0
  3. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/client.py +40 -4
  4. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/fetching/http.py +22 -2
  5. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/interactive.py +66 -50
  6. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/detector.py +191 -164
  7. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/html.py +1 -1
  8. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/utils/durations.py +1 -1
  9. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/PKG-INFO +1 -1
  10. {pagefetch-0.3.0 → pagefetch-0.4.0}/pyproject.toml +1 -1
  11. {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_processing.py +23 -0
  12. {pagefetch-0.3.0 → pagefetch-0.4.0}/LICENSE +0 -0
  13. {pagefetch-0.3.0 → pagefetch-0.4.0}/README.md +0 -0
  14. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/__init__.py +0 -0
  15. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/__main__.py +0 -0
  16. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/bootstrap.py +0 -0
  17. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/cache/__init__.py +0 -0
  18. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/cache/keys.py +0 -0
  19. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/cache/sqlite.py +0 -0
  20. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/config.py +0 -0
  21. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/constants.py +0 -0
  22. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/exceptions.py +0 -0
  23. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/fetching/__init__.py +0 -0
  24. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/fetching/browser.py +0 -0
  25. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/fetching/readiness.py +0 -0
  26. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/models.py +0 -0
  27. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/__init__.py +0 -0
  28. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/cleaner.py +0 -0
  29. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/images.py +0 -0
  30. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/links.py +0 -0
  31. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/markdown.py +0 -0
  32. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/metadata.py +0 -0
  33. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/non_html.py +0 -0
  34. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/proxy/__init__.py +0 -0
  35. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/proxy/providers.py +0 -0
  36. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/utils/__init__.py +0 -0
  37. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/utils/rendering.py +0 -0
  38. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/utils/urls.py +0 -0
  39. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/SOURCES.txt +0 -0
  40. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/dependency_links.txt +0 -0
  41. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/entry_points.txt +0 -0
  42. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/requires.txt +0 -0
  43. {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/top_level.txt +0 -0
  44. {pagefetch-0.3.0 → pagefetch-0.4.0}/setup.cfg +0 -0
  45. {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_bootstrap.py +0 -0
  46. {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_browser.py +0 -0
  47. {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_client.py +0 -0
  48. {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_config_cli.py +0 -0
  49. {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_non_html.py +0 -0
  50. {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_proxy_cache.py +0 -0
  51. {pagefetch-0.3.0 → pagefetch-0.4.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.4.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
@@ -116,10 +116,17 @@ async def _run(args: argparse.Namespace) -> int:
116
116
  proxy=config.proxy,
117
117
  cache_enabled=config.cache_enabled,
118
118
  cache_ttl=config.cache_ttl,
119
+ cache_path=config.cache_path,
119
120
  http_concurrency=config.http_concurrency,
120
121
  browser_concurrency=config.browser_concurrency,
121
122
  http_timeout=config.http_timeout,
122
123
  browser_timeout=config.browser_timeout,
124
+ retries_http=config.retries_http,
125
+ retries_browser=config.retries_browser,
126
+ max_redirects=config.max_redirects,
127
+ max_content_size=config.max_content_size,
128
+ confidence_threshold=config.confidence_threshold,
129
+ raise_on_error=config.raise_on_error,
123
130
  ) as client:
124
131
  results = await client.fetch_many(urls)
125
132
  rendered = _render(results, args.format, args.include_html)
@@ -108,7 +108,7 @@ class PageFetch:
108
108
  return self
109
109
  if self._closed:
110
110
  raise RuntimeError("PageFetch has already been closed")
111
- ensure_runtime_requirements()
111
+ await asyncio.to_thread(ensure_runtime_requirements)
112
112
  if self._cache:
113
113
  try:
114
114
  await self._cache.start()
@@ -210,8 +210,11 @@ class PageFetch:
210
210
  "browser_timeout": self.config.browser_timeout,
211
211
  "confidence_threshold": self.config.confidence_threshold,
212
212
  "headers": BROWSER_HEADERS,
213
+ "http_timeout": self.config.http_timeout,
213
214
  "max_content_size": self.config.max_content_size,
215
+ "max_redirects": self.config.max_redirects,
214
216
  "retries_browser": self.config.retries_browser,
217
+ "retries_http": self.config.retries_http,
215
218
  },
216
219
  )
217
220
  fetch_warnings = list(self._startup_warnings)
@@ -319,8 +322,14 @@ class PageFetch:
319
322
  raise_on_error=_raise_on_error,
320
323
  )
321
324
  except PageFetchError as exc:
325
+ # Use the raw item string — normalize_url would re-raise
326
+ # ValueError for the same invalid URL that caused the error.
327
+ try:
328
+ result_url = normalize_url(item)
329
+ except ValueError:
330
+ result_url = str(item)
322
331
  return FetchResult(
323
- url=normalize_url(item),
332
+ url=result_url,
324
333
  success=False,
325
334
  proxy_provider=proxy or self.config.proxy,
326
335
  error=exc.error,
@@ -330,7 +339,16 @@ class PageFetch:
330
339
 
331
340
  fetched = await asyncio.gather(*(one(item) for item in unique))
332
341
  by_url = dict(zip(unique, fetched, strict=True))
333
- return [by_url[item] for item in ordered]
342
+ # For duplicate URLs, return independent copies so callers
343
+ # do not accidentally alias mutable fields across entries.
344
+ from copy import deepcopy
345
+ results: list[FetchResult] = []
346
+ for item in ordered:
347
+ result = by_url[item]
348
+ if results and results[-1] is result:
349
+ result = deepcopy(result)
350
+ results.append(result)
351
+ return results
334
352
 
335
353
  async def _fetch_http_or_auto(self, url: str, mode: Literal["auto", "http", "browser"], proxy: str) -> FetchResult:
336
354
  try:
@@ -358,6 +376,15 @@ class PageFetch:
358
376
  return self._result_from_xml(url, response, proxy)
359
377
  if content_type.startswith("text/plain"):
360
378
  return self._result_from_text(url, response, proxy)
379
+ if not self._is_html_like(content_type):
380
+ raise TransportFailure(
381
+ FetchErrorInfo(
382
+ "unsupported_content_type",
383
+ f"Content type {content_type!r} is not HTML; cannot process with HTTP/auto mode",
384
+ False,
385
+ ),
386
+ status_code=response.status_code,
387
+ )
361
388
  html = self._decode(response)
362
389
  raw_soup = BeautifulSoup(html, "lxml")
363
390
  report = analyze_html(html)
@@ -616,7 +643,16 @@ class PageFetch:
616
643
 
617
644
  @staticmethod
618
645
  def _is_xml(content_type: str) -> bool:
619
- return any(value in content_type for value in XML_TYPES)
646
+ if content_type in ("application/xml", "text/xml"):
647
+ return True
648
+ # Match XML-based subtypes like application/atom+xml, image/svg+xml
649
+ # but NOT application/xhtml+xml (which is HTML5, not generic XML).
650
+ return "+xml" in content_type and content_type != "application/xhtml+xml"
651
+
652
+ @staticmethod
653
+ def _is_html_like(content_type: str) -> bool:
654
+ """Return True when *content_type* should be processed as HTML."""
655
+ return content_type in ("text/html", "application/xhtml+xml")
620
656
 
621
657
  @staticmethod
622
658
  def _validate_fetch_options(mode: str, proxy: str) -> None:
@@ -123,8 +123,28 @@ class HTTPFetcher:
123
123
 
124
124
  @staticmethod
125
125
  async def _backoff(attempt: int, retry_after: str | None = None) -> None:
126
- if retry_after and retry_after.isdigit():
127
- delay = min(float(retry_after), 10.0)
126
+ if retry_after:
127
+ seconds = HTTPFetcher._parse_retry_after(retry_after)
128
+ if seconds is not None:
129
+ delay = min(seconds, 10.0)
130
+ else:
131
+ delay = min(0.25 * (2**attempt) + random.uniform(0, 0.15), 3.0)
128
132
  else:
129
133
  delay = min(0.25 * (2**attempt) + random.uniform(0, 0.15), 3.0)
130
134
  await asyncio.sleep(delay)
135
+
136
+ @staticmethod
137
+ def _parse_retry_after(value: str) -> float | None:
138
+ """Parse a Retry-After header as delta-seconds or HTTP-date."""
139
+ if value.isdigit():
140
+ return float(value)
141
+ # Try HTTP-date (RFC 7231), e.g. "Wed, 21 Oct 2015 07:28:00 GMT"
142
+ try:
143
+ from email.utils import parsedate_to_datetime
144
+ from datetime import UTC, datetime
145
+ retry_dt = parsedate_to_datetime(value)
146
+ now = datetime.now(UTC)
147
+ delta = (retry_dt - now).total_seconds()
148
+ return max(0.0, delta)
149
+ except (ValueError, TypeError, OverflowError):
150
+ return None
@@ -66,6 +66,18 @@ def _render_results(results: list[FetchResult], output_format: str, include_html
66
66
  return render_results(results, output_format, include_html=include_html)
67
67
 
68
68
 
69
+ def _apply_debug(settings: dict) -> None:
70
+ """Configure the pagefetch logger to match the current debug setting."""
71
+ logger = logging.getLogger("pagefetch")
72
+ if settings.get("debug"):
73
+ if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
74
+ handler = logging.StreamHandler()
75
+ logger.addHandler(handler)
76
+ logger.setLevel(logging.DEBUG)
77
+ else:
78
+ logger.setLevel(logging.WARNING)
79
+
80
+
69
81
  async def _fetch_url(client: PageFetch, url: str, settings: dict) -> list[FetchResult]:
70
82
  """Fetch a single URL."""
71
83
  result = await client.fetch(
@@ -98,7 +110,7 @@ async def _fetch_file(client: PageFetch, filepath: str, settings: dict) -> list[
98
110
  )
99
111
 
100
112
 
101
- def _handle_fetch(client: PageFetch, settings: dict) -> None:
113
+ def _handle_fetch(client: PageFetch, settings: dict, loop: asyncio.AbstractEventLoop) -> None:
102
114
  """Interactive fetch flow: URL or file."""
103
115
  _clear_screen()
104
116
  _banner()
@@ -114,11 +126,12 @@ def _handle_fetch(client: PageFetch, settings: dict) -> None:
114
126
  if choice == "3":
115
127
  return
116
128
 
129
+ _apply_debug(settings)
117
130
  if choice == "2":
118
131
  filepath = _prompt(" File path")
119
132
  if not filepath:
120
133
  return
121
- results = asyncio.run(_fetch_file(client, filepath, settings))
134
+ results = loop.run_until_complete(_fetch_file(client, filepath, settings))
122
135
  else:
123
136
  url = _prompt(" URL")
124
137
  if not url:
@@ -127,7 +140,7 @@ def _handle_fetch(client: PageFetch, settings: dict) -> None:
127
140
  if not url.startswith(("http://", "https://")):
128
141
  if _confirm(f" No scheme detected. Use 'https://{url}'?", default=True):
129
142
  url = f"https://{url}"
130
- results = asyncio.run(_fetch_url(client, url, settings))
143
+ results = loop.run_until_complete(_fetch_url(client, url, settings))
131
144
 
132
145
  if not results:
133
146
  return
@@ -244,6 +257,7 @@ def _settings_menu(settings: dict) -> None:
244
257
  settings["include_html"] = _confirm(" Include raw HTML in output?", default=settings.get("include_html", False))
245
258
  elif choice == "7":
246
259
  settings["debug"] = _confirm(" Enable debug logging?", default=settings.get("debug", False))
260
+ _apply_debug(settings)
247
261
  elif choice == "8":
248
262
  settings["no_cache"] = _confirm(" Disable cache?", default=settings.get("no_cache", False))
249
263
  if settings.get("no_cache"):
@@ -326,56 +340,58 @@ def interactive_main() -> int:
326
340
  "config_file": None,
327
341
  }
328
342
 
329
- if settings.get("debug"):
330
- handler = logging.StreamHandler()
331
- logging.getLogger("pagefetch").addHandler(handler)
332
- logging.getLogger("pagefetch").setLevel(logging.DEBUG)
343
+ _apply_debug(settings)
333
344
 
345
+ loop = asyncio.new_event_loop()
346
+ asyncio.set_event_loop(loop)
334
347
  client = _init_client(settings)
335
348
 
336
- while True:
337
- _clear_screen()
338
- _banner()
339
-
340
- print(" 1. Fetch URL(s)")
341
- print(" 2. Settings")
342
- print(" 3. View current config")
343
- print(" 4. Exit")
344
- print()
345
-
346
- choice = _prompt(" Choose", "1")
347
-
348
- try:
349
- if choice == "1":
350
- _handle_fetch(client, settings)
351
- # Re-create client in case settings changed
352
- try:
353
- asyncio.run(client.close())
354
- except Exception:
355
- pass
356
- client = _init_client(settings)
357
- elif choice == "2":
358
- _settings_menu(settings)
359
- # Re-create client with new settings
360
- try:
361
- asyncio.run(client.close())
362
- except Exception:
363
- pass
364
- client = _init_client(settings)
365
- elif choice == "3":
366
- _view_config(settings)
367
- elif choice == "4":
368
- print("\n Goodbye!")
349
+ try:
350
+ while True:
351
+ _clear_screen()
352
+ _banner()
353
+
354
+ print(" 1. Fetch URL(s)")
355
+ print(" 2. Settings")
356
+ print(" 3. View current config")
357
+ print(" 4. Exit")
358
+ print()
359
+
360
+ choice = _prompt(" Choose", "1")
361
+
362
+ try:
363
+ if choice == "1":
364
+ _handle_fetch(client, settings, loop)
365
+ # Re-create client in case settings changed
366
+ try:
367
+ loop.run_until_complete(client.close())
368
+ except Exception:
369
+ pass
370
+ client = _init_client(settings)
371
+ elif choice == "2":
372
+ _settings_menu(settings)
373
+ # Re-create client with new settings
374
+ try:
375
+ loop.run_until_complete(client.close())
376
+ except Exception:
377
+ pass
378
+ client = _init_client(settings)
379
+ elif choice == "3":
380
+ _view_config(settings)
381
+ elif choice == "4":
382
+ print("\n Goodbye!")
383
+ break
384
+ except KeyboardInterrupt:
385
+ print("\n\n Interrupted. Goodbye!")
369
386
  break
370
- except KeyboardInterrupt:
371
- print("\n\n Interrupted. Goodbye!")
372
- break
373
- except Exception as exc:
374
- print(f"\n Unexpected error: {exc}")
375
- input(" Press Enter to continue...")
387
+ except Exception as exc:
388
+ print(f"\n Unexpected error: {exc}")
389
+ input(" Press Enter to continue...")
376
390
 
377
- try:
378
- asyncio.run(client.close())
379
- except Exception:
380
- pass
391
+ try:
392
+ loop.run_until_complete(client.close())
393
+ except Exception:
394
+ pass
395
+ finally:
396
+ loop.close()
381
397
  return 0
@@ -1,164 +1,191 @@
1
- """Multi-signal HTML completeness analysis."""
2
-
3
- from __future__ import annotations
4
-
5
- import math
6
- import re
7
- from dataclasses import dataclass
8
-
9
- from bs4 import BeautifulSoup
10
-
11
- CHALLENGE_PATTERNS = (
12
- "checking your browser",
13
- "verify you are human",
14
- "attention required",
15
- "access denied",
16
- "unusual traffic",
17
- "cf-chl-",
18
- "captcha",
19
- )
20
- JS_PATTERNS = (
21
- "enable javascript",
22
- "javascript is required",
23
- "you need to enable javascript",
24
- "please turn on javascript",
25
- )
26
- FRAMEWORK_PATTERNS = (
27
- "__next_data__",
28
- "__nuxt__",
29
- "data-reactroot",
30
- "data-v-app",
31
- "ng-version",
32
- "__svelte",
33
- "astro-island",
34
- "__remixcontext",
35
- )
36
- PLACEHOLDER_PATTERNS = ("loading...", "loading…", "skeleton", "spinner", "please wait")
37
- WALL_PATTERNS = ("sign in to continue", "log in to continue", "consent required", "accept cookies to continue")
38
-
39
-
40
- @dataclass(slots=True, frozen=True)
41
- class ConfidenceReport:
42
- score: float
43
- reasons: tuple[str, ...]
44
- challenge: bool = False
45
- javascript_shell: bool = False
46
-
47
-
48
- def analyze_html(html: str, *, soup: BeautifulSoup | None = None) -> ConfidenceReport:
49
- """Estimate document completeness from several positive and negative signals."""
50
- if not html or not html.strip():
51
- return ConfidenceReport(0.0, ("empty document",), javascript_shell=True)
52
- if soup is None:
53
- soup = BeautifulSoup(html, "lxml")
54
- title = soup.title.get_text(" ", strip=True) if soup.title else ""
55
-
56
- # Single pass: capture script payload size AND decompose noise tags.
57
- script_size = 0
58
- for tag in soup(["script", "style", "template", "noscript"]):
59
- if tag.name == "script":
60
- script_size += len(str(tag))
61
- tag.decompose()
62
- text = " ".join((soup.body or soup).stripped_strings)
63
- lowered_text = text.lower()
64
- lowered_html = html.lower()
65
- text_len = len(text)
66
- word_count = len(re.findall(r"\w+", text, re.UNICODE))
67
- reasons: list[str] = []
68
- score = 0.20
69
-
70
- # Smooth length contribution: useful up to roughly 1,500 visible characters.
71
- score += min(0.38, math.log1p(text_len) / math.log(1501) * 0.38)
72
- # Single unified traversal for tag-name-based element collections.
73
- paragraphs: list = []
74
- headings: list = []
75
- semantic: list = []
76
- structured: list = []
77
- nav_texts: list[str] = []
78
- main_texts: list[str] = []
79
- for tag in soup.find_all(
80
- ["p", "h1", "h2", "h3", "h4", "h5", "h6",
81
- "main", "article", "section",
82
- "ul", "ol", "table", "pre", "blockquote", "nav"]
83
- ):
84
- name = tag.name
85
- if name == "p":
86
- paragraphs.append(tag)
87
- elif len(name) == 2 and name[0] == "h" and name[1].isdigit():
88
- headings.append(tag)
89
- elif name in {"main", "article", "section"}:
90
- semantic.append(tag)
91
- if name in {"main", "article"}:
92
- main_texts.append(tag.get_text(" ", strip=True))
93
- elif name in {"ul", "ol", "table", "pre", "blockquote"}:
94
- structured.append(tag)
95
- elif name == "nav":
96
- nav_texts.append(tag.get_text(" ", strip=True))
97
- if paragraphs:
98
- score += min(0.12, len(paragraphs) * 0.025)
99
- if headings:
100
- score += min(0.07, len(headings) * 0.02)
101
- if semantic and any(len(tag.get_text(" ", strip=True)) > 120 for tag in semantic):
102
- score += 0.10
103
- if structured:
104
- score += min(0.06, len(structured) * 0.015)
105
- if word_count > 100 and len(set(text.lower().split())) / max(word_count, 1) > 0.35:
106
- score += 0.05
107
- if text_len > 500 and len(paragraphs) >= 3:
108
- score += 0.05
109
- title_tokens = {token for token in re.findall(r"\w+", title.lower()) if len(token) > 3}
110
- if title_tokens and title_tokens.intersection(set(re.findall(r"\w+", lowered_text))):
111
- score += 0.03
112
- if soup.find("meta", attrs={"name": re.compile(r"description", re.I)}):
113
- score += 0.02
114
-
115
- challenge = any(pattern in lowered_html for pattern in CHALLENGE_PATTERNS)
116
- explicit_js = any(pattern in lowered_text for pattern in JS_PATTERNS)
117
- framework = any(pattern in lowered_html for pattern in FRAMEWORK_PATTERNS)
118
- mounts = soup.select("#app:empty, #root:empty, #__next:empty, [data-reactroot]:empty")
119
- shell = explicit_js or bool(mounts) or (framework and text_len < 250) or (
120
- script_size > max(10_000, text_len * 8) and text_len < 400
121
- )
122
- placeholder = any(pattern in lowered_text for pattern in PLACEHOLDER_PATTERNS)
123
- wall = any(pattern in lowered_text for pattern in WALL_PATTERNS)
124
- refresh = bool(soup.find("meta", attrs={"http-equiv": re.compile(r"^refresh$", re.I)}))
125
- navigation_text = " ".join(nav_texts)
126
- main_text = " ".join(main_texts)
127
-
128
- if text_len < 80:
129
- score -= 0.30
130
- reasons.append("very little visible text")
131
- elif text_len < 250:
132
- score -= 0.14
133
- reasons.append("short visible text")
134
- if challenge:
135
- score = min(score, 0.08)
136
- reasons.append("challenge or anti-bot page detected")
137
- if explicit_js:
138
- score -= 0.35
139
- reasons.append("document asks for JavaScript")
140
- if mounts:
141
- score -= 0.22
142
- reasons.append("empty application mount point")
143
- if framework and text_len < 400:
144
- score -= 0.15
145
- reasons.append("framework bootstrap with little rendered text")
146
- if script_size > max(10_000, text_len * 8) and text_len < 400:
147
- score -= 0.12
148
- reasons.append("large script payload with little visible content")
149
- if placeholder and text_len < 400:
150
- score -= 0.18
151
- shell = True
152
- reasons.append("loading placeholder detected")
153
- if wall and len(main_text) < 200:
154
- score -= 0.20
155
- reasons.append("login or consent wall detected")
156
- if refresh and text_len < 400:
157
- score -= 0.15
158
- reasons.append("suspicious redirect document")
159
- if text_len and len(navigation_text) / text_len > 0.75 and len(main_text) < 120:
160
- score -= 0.12
161
- reasons.append("document is mostly navigation")
162
- if not reasons:
163
- reasons.append("document contains substantial rendered content")
164
- return ConfidenceReport(round(max(0.0, min(1.0, score)), 3), tuple(reasons), challenge, shell)
1
+ """Multi-signal HTML completeness analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ from bs4 import BeautifulSoup
10
+
11
+ CHALLENGE_PATTERNS = (
12
+ "checking your browser",
13
+ "verify you are human",
14
+ "attention required",
15
+ "access denied",
16
+ "unusual traffic",
17
+ "cf-chl-",
18
+ "captcha",
19
+ )
20
+ JS_PATTERNS = (
21
+ "enable javascript",
22
+ "javascript is required",
23
+ "you need to enable javascript",
24
+ "please turn on javascript",
25
+ )
26
+ FRAMEWORK_PATTERNS = (
27
+ "__next_data__",
28
+ "__nuxt__",
29
+ "data-reactroot",
30
+ "data-v-app",
31
+ "ng-version",
32
+ "__svelte",
33
+ "astro-island",
34
+ "__remixcontext",
35
+ )
36
+ PLACEHOLDER_PATTERNS = ("loading...", "loading…", "skeleton", "spinner", "please wait")
37
+ WALL_PATTERNS = ("sign in to continue", "log in to continue", "consent required", "accept cookies to continue")
38
+
39
+
40
+ @dataclass(slots=True, frozen=True)
41
+ class ConfidenceReport:
42
+ score: float
43
+ reasons: tuple[str, ...]
44
+ challenge: bool = False
45
+ javascript_shell: bool = False
46
+
47
+
48
+ def analyze_html(html: str, *, soup: BeautifulSoup | None = None) -> ConfidenceReport:
49
+ """Estimate document completeness from several positive and negative signals."""
50
+ if not html or not html.strip():
51
+ return ConfidenceReport(0.0, ("empty document",), javascript_shell=True)
52
+ if soup is None:
53
+ soup = BeautifulSoup(html, "lxml")
54
+ title = soup.title.get_text(" ", strip=True) if soup.title else ""
55
+
56
+ # Single pass: capture script payload size AND decompose noise tags.
57
+ script_size = 0
58
+ for tag in soup(["script", "style", "template", "noscript"]):
59
+ if tag.name == "script":
60
+ script_size += len(str(tag))
61
+ tag.decompose()
62
+ text = " ".join((soup.body or soup).stripped_strings)
63
+ lowered_text = text.lower()
64
+ lowered_html = html.lower()
65
+ text_len = len(text)
66
+ word_count = len(re.findall(r"\w+", text, re.UNICODE))
67
+ reasons: list[str] = []
68
+ score = 0.20
69
+
70
+ # Smooth length contribution: useful up to roughly 1,500 visible characters.
71
+ score += min(0.38, math.log1p(text_len) / math.log(1501) * 0.38)
72
+ # Single unified traversal for tag-name-based element collections.
73
+ paragraphs: list = []
74
+ headings: list = []
75
+ semantic: list = []
76
+ structured: list = []
77
+ nav_texts: list[str] = []
78
+ main_texts: list[str] = []
79
+ for tag in soup.find_all(
80
+ ["p", "h1", "h2", "h3", "h4", "h5", "h6",
81
+ "main", "article", "section",
82
+ "ul", "ol", "table", "pre", "blockquote", "nav"]
83
+ ):
84
+ name = tag.name
85
+ if name == "p":
86
+ paragraphs.append(tag)
87
+ elif len(name) == 2 and name[0] == "h" and name[1].isdigit():
88
+ headings.append(tag)
89
+ elif name in {"main", "article", "section"}:
90
+ semantic.append(tag)
91
+ if name in {"main", "article"}:
92
+ main_texts.append(tag.get_text(" ", strip=True))
93
+ elif name in {"ul", "ol", "table", "pre", "blockquote"}:
94
+ structured.append(tag)
95
+ elif name == "nav":
96
+ nav_texts.append(tag.get_text(" ", strip=True))
97
+ if paragraphs:
98
+ score += min(0.12, len(paragraphs) * 0.025)
99
+ if headings:
100
+ score += min(0.07, len(headings) * 0.02)
101
+ if semantic and any(len(tag.get_text(" ", strip=True)) > 120 for tag in semantic):
102
+ score += 0.10
103
+ if structured:
104
+ score += min(0.06, len(structured) * 0.015)
105
+ if word_count > 100 and len(set(text.lower().split())) / max(word_count, 1) > 0.35:
106
+ score += 0.05
107
+ if text_len > 500 and len(paragraphs) >= 3:
108
+ score += 0.05
109
+ title_tokens = {token for token in re.findall(r"\w+", title.lower()) if len(token) > 3}
110
+ body_tokens = set(re.findall(r"\w+", lowered_text))
111
+ title_overlap = bool(title_tokens and title_tokens.intersection(body_tokens))
112
+ if title_overlap:
113
+ score += 0.03
114
+ if soup.find("meta", attrs={"name": re.compile(r"description", re.I)}):
115
+ score += 0.02
116
+
117
+ challenge = any(pattern in lowered_html for pattern in CHALLENGE_PATTERNS)
118
+ explicit_js = any(pattern in lowered_text for pattern in JS_PATTERNS)
119
+ framework = any(pattern in lowered_html for pattern in FRAMEWORK_PATTERNS)
120
+ mounts = soup.select("#app:empty, #root:empty, #__next:empty, [data-reactroot]:empty")
121
+ shell = explicit_js or bool(mounts) or (framework and text_len < 250) or (
122
+ script_size > max(10_000, text_len * 8) and text_len < 400
123
+ )
124
+ placeholder = any(pattern in lowered_text for pattern in PLACEHOLDER_PATTERNS)
125
+ wall = any(pattern in lowered_text for pattern in WALL_PATTERNS)
126
+ refresh = bool(soup.find("meta", attrs={"http-equiv": re.compile(r"^refresh$", re.I)}))
127
+ navigation_text = " ".join(nav_texts)
128
+ main_text = " ".join(main_texts)
129
+
130
+ # Complete short static pages (e.g. example.com) are fully server-rendered and
131
+ # should not force an expensive browser fallback just because they are brief.
132
+ coherent_short = (
133
+ not shell
134
+ and not challenge
135
+ and not placeholder
136
+ and not wall
137
+ and not refresh
138
+ and not mounts
139
+ and not explicit_js
140
+ and script_size < 2_000
141
+ and 80 <= text_len < 400
142
+ and word_count >= 12
143
+ and (bool(paragraphs) or bool(headings))
144
+ and (title_overlap or bool(headings))
145
+ )
146
+ if coherent_short:
147
+ score += 0.28
148
+ if headings and paragraphs:
149
+ score += 0.08
150
+ if title_overlap:
151
+ score += 0.05
152
+
153
+ if text_len < 80:
154
+ score -= 0.30
155
+ reasons.append("very little visible text")
156
+ elif text_len < 250 and not coherent_short:
157
+ score -= 0.14
158
+ reasons.append("short visible text")
159
+ elif coherent_short and text_len < 250:
160
+ reasons.append("short but complete static document")
161
+ if challenge:
162
+ score = min(score, 0.08)
163
+ reasons.append("challenge or anti-bot page detected")
164
+ if explicit_js:
165
+ score -= 0.35
166
+ reasons.append("document asks for JavaScript")
167
+ if mounts:
168
+ score -= 0.22
169
+ reasons.append("empty application mount point")
170
+ if framework and text_len < 400:
171
+ score -= 0.15
172
+ reasons.append("framework bootstrap with little rendered text")
173
+ if script_size > max(10_000, text_len * 8) and text_len < 400:
174
+ score -= 0.12
175
+ reasons.append("large script payload with little visible content")
176
+ if placeholder and text_len < 400:
177
+ score -= 0.18
178
+ shell = True
179
+ reasons.append("loading placeholder detected")
180
+ if wall and len(main_text) < 200:
181
+ score -= 0.20
182
+ reasons.append("login or consent wall detected")
183
+ if refresh and text_len < 400:
184
+ score -= 0.15
185
+ reasons.append("suspicious redirect document")
186
+ if text_len and len(navigation_text) / text_len > 0.75 and len(main_text) < 120:
187
+ score -= 0.12
188
+ reasons.append("document is mostly navigation")
189
+ if not reasons:
190
+ reasons.append("document contains substantial rendered content")
191
+ return ConfidenceReport(round(max(0.0, min(1.0, score)), 3), tuple(reasons), challenge, shell)
@@ -38,7 +38,7 @@ def process_html(
38
38
  """Extract a high-fidelity structured representation from HTML."""
39
39
  raw_soup = soup or BeautifulSoup(html, "lxml")
40
40
  title, metadata, warnings = extract_metadata(raw_soup, base_url, response_headers)
41
- confidence = analyze_html(html, soup=raw_soup)
41
+ confidence = analyze_html(html)
42
42
  cleaned = clean_html(raw_soup)
43
43
  links = extract_links(cleaned, base_url)
44
44
  images = extract_images(cleaned, base_url)
@@ -15,7 +15,7 @@ def parse_duration(value: str | int | float) -> int:
15
15
  if isinstance(value, (int, float)):
16
16
  if value < 0:
17
17
  raise ValueError("duration cannot be negative")
18
- return int(value)
18
+ return round(value)
19
19
  if not isinstance(value, str):
20
20
  raise TypeError("duration must be a number or string")
21
21
  match = _DURATION_RE.fullmatch(value.strip())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pagefetch
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pagefetch"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Asynchronous HTTP-first web fetching with conservative content extraction and browser fallback"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -47,6 +47,17 @@ def test_processing_preserves_content_and_converts_markdown():
47
47
  assert result.images[0].url == "https://example.com/hero.jpg"
48
48
 
49
49
 
50
+ EXAMPLE_DOMAIN_HTML = (
51
+ '<!doctype html><html lang="en"><head><title>Example Domain</title>'
52
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
53
+ "<style>body{background:#eee}</style></head><body><div><h1>Example Domain</h1>"
54
+ "<p>This domain is for use in documentation examples without needing permission. "
55
+ "Avoid use in operations.</p>"
56
+ '<p><a href="https://iana.org/domains/example">Learn more</a></p>'
57
+ "</div></body></html>"
58
+ )
59
+
60
+
50
61
  def test_detector_distinguishes_rich_content_spa_and_challenge():
51
62
  rich = analyze_html(RICH_HTML)
52
63
  spa = analyze_html("<html><body><div id='root'></div><script>" + "x" * 12000 + "</script></body></html>")
@@ -56,6 +67,18 @@ def test_detector_distinguishes_rich_content_spa_and_challenge():
56
67
  assert challenge.score <= 0.08 and challenge.challenge
57
68
 
58
69
 
70
+ def test_detector_scores_short_static_pages_highly():
71
+ """Simple complete pages like example.com must clear the HTTP confidence threshold."""
72
+ report = analyze_html(EXAMPLE_DOMAIN_HTML)
73
+ assert report.score >= 0.80
74
+ assert not report.javascript_shell
75
+ assert not report.challenge
76
+ assert "short but complete static document" in report.reasons
77
+
78
+ thin = analyze_html("<html><body><p>Small but usable notice.</p></body></html>")
79
+ assert thin.score < 0.50
80
+
81
+
59
82
  def test_external_link_is_identified():
60
83
  result = process_html('<html><body><a href="https://other.test/x">Other</a></body></html>', "https://example.com/")
61
84
  assert result.links[0].internal is False
File without changes
File without changes
File without changes
File without changes
File without changes