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.
- {pagefetch-0.3.0 → pagefetch-0.4.0}/PKG-INFO +1 -1
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/cli.py +7 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/client.py +40 -4
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/fetching/http.py +22 -2
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/interactive.py +66 -50
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/detector.py +191 -164
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/html.py +1 -1
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/utils/durations.py +1 -1
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/PKG-INFO +1 -1
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pyproject.toml +1 -1
- {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_processing.py +23 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/LICENSE +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/README.md +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/__main__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/bootstrap.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/cache/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/cache/keys.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/cache/sqlite.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/config.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/constants.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/exceptions.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/fetching/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/fetching/browser.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/fetching/readiness.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/models.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/cleaner.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/images.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/links.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/markdown.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/metadata.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/processing/non_html.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/proxy/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/proxy/providers.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/utils/__init__.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/utils/rendering.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch/utils/urls.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/SOURCES.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/dependency_links.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/entry_points.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/requires.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/pagefetch.egg-info/top_level.txt +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/setup.cfg +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_bootstrap.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_browser.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_client.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_config_cli.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_non_html.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_proxy_cache.py +0 -0
- {pagefetch-0.3.0 → pagefetch-0.4.0}/tests/test_utils_models.py +0 -0
|
@@ -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=
|
|
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
|
|
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
|
-
|
|
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
|
|
127
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
if
|
|
113
|
-
score += 0.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if
|
|
147
|
-
score
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if
|
|
154
|
-
score -= 0.
|
|
155
|
-
reasons.append("
|
|
156
|
-
|
|
157
|
-
score -= 0.
|
|
158
|
-
reasons.append("
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
reasons.append("
|
|
164
|
-
|
|
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
|
|
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
|
|
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())
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pagefetch"
|
|
7
|
-
version = "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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|