pagefetch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pagefetch/__init__.py +29 -0
- pagefetch/__main__.py +4 -0
- pagefetch/bootstrap.py +113 -0
- pagefetch/cache/__init__.py +7 -0
- pagefetch/cache/keys.py +28 -0
- pagefetch/cache/sqlite.py +103 -0
- pagefetch/cli.py +165 -0
- pagefetch/client.py +643 -0
- pagefetch/config.py +167 -0
- pagefetch/constants.py +32 -0
- pagefetch/exceptions.py +15 -0
- pagefetch/fetching/__init__.py +7 -0
- pagefetch/fetching/browser.py +261 -0
- pagefetch/fetching/http.py +130 -0
- pagefetch/fetching/readiness.py +81 -0
- pagefetch/models.py +100 -0
- pagefetch/processing/__init__.py +6 -0
- pagefetch/processing/cleaner.py +39 -0
- pagefetch/processing/detector.py +142 -0
- pagefetch/processing/html.py +47 -0
- pagefetch/processing/images.py +31 -0
- pagefetch/processing/links.py +40 -0
- pagefetch/processing/markdown.py +217 -0
- pagefetch/processing/metadata.py +84 -0
- pagefetch/processing/non_html.py +74 -0
- pagefetch/proxy/__init__.py +6 -0
- pagefetch/proxy/providers.py +84 -0
- pagefetch/utils/__init__.py +2 -0
- pagefetch/utils/durations.py +25 -0
- pagefetch/utils/urls.py +68 -0
- pagefetch-0.1.0.dist-info/METADATA +423 -0
- pagefetch-0.1.0.dist-info/RECORD +36 -0
- pagefetch-0.1.0.dist-info/WHEEL +5 -0
- pagefetch-0.1.0.dist-info/entry_points.txt +2 -0
- pagefetch-0.1.0.dist-info/licenses/LICENSE +22 -0
- pagefetch-0.1.0.dist-info/top_level.txt +1 -0
pagefetch/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Public PageFetch API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .bootstrap import RuntimeBootstrapError, ensure_runtime_requirements
|
|
6
|
+
|
|
7
|
+
# NOTE: ensure_runtime_requirements() is NOT called here to avoid side effects
|
|
8
|
+
# on import (network calls / subprocess execution). It is called lazily inside
|
|
9
|
+
# PageFetch.start() before the first fetch operation. If you use the library
|
|
10
|
+
# without calling PageFetch.start() directly, call
|
|
11
|
+
# pagefetch.ensure_runtime_requirements()
|
|
12
|
+
# explicitly, or set PAGEFETCH_AUTO_INSTALL=0 to disable auto-installation.
|
|
13
|
+
logging.getLogger("pagefetch").addHandler(logging.NullHandler())
|
|
14
|
+
|
|
15
|
+
from .client import PageFetch # noqa: E402
|
|
16
|
+
from .exceptions import PageFetchError # noqa: E402
|
|
17
|
+
from .models import FetchErrorInfo, FetchResult, ImageInfo, LinkInfo # noqa: E402
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"FetchErrorInfo",
|
|
21
|
+
"FetchResult",
|
|
22
|
+
"ImageInfo",
|
|
23
|
+
"LinkInfo",
|
|
24
|
+
"PageFetch",
|
|
25
|
+
"PageFetchError",
|
|
26
|
+
"RuntimeBootstrapError",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
pagefetch/__main__.py
ADDED
pagefetch/bootstrap.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Runtime dependency bootstrap for direct source and CLI execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
_RUNTIME_REQUIREMENTS = (
|
|
14
|
+
("aiosqlite", "aiosqlite>=0.20"),
|
|
15
|
+
("bs4", "beautifulsoup4>=4.12"),
|
|
16
|
+
("camoufox", "camoufox>=0.4"),
|
|
17
|
+
("httpx", "httpx[http2,socks]>=0.27"),
|
|
18
|
+
("h2", "httpx[http2,socks]>=0.27"),
|
|
19
|
+
("socksio", "httpx[http2,socks]>=0.27"),
|
|
20
|
+
("lxml", "lxml>=5.2"),
|
|
21
|
+
("platformdirs", "platformdirs>=4.2"),
|
|
22
|
+
("pypdf", "pypdf>=5.0"),
|
|
23
|
+
("yaml", "pyyaml>=6.0"),
|
|
24
|
+
("tldextract", "tldextract>=5.1"),
|
|
25
|
+
)
|
|
26
|
+
_FALSE_VALUES = frozenset({"0", "false", "no", "off"})
|
|
27
|
+
_bootstrap_lock = threading.Lock()
|
|
28
|
+
_bootstrap_complete = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RuntimeBootstrapError(RuntimeError):
|
|
32
|
+
"""Raised when PageFetch cannot install a required runtime component."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def auto_install_enabled() -> bool:
|
|
36
|
+
"""Return whether automatic startup installation is enabled."""
|
|
37
|
+
value = os.getenv("PAGEFETCH_AUTO_INSTALL", "1").strip().lower()
|
|
38
|
+
return value not in _FALSE_VALUES
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _missing_requirements(
|
|
42
|
+
find_spec: Callable[[str], object | None] = importlib.util.find_spec,
|
|
43
|
+
) -> list[str]:
|
|
44
|
+
missing: list[str] = []
|
|
45
|
+
for module, requirement in _RUNTIME_REQUIREMENTS:
|
|
46
|
+
if find_spec(module) is None and requirement not in missing:
|
|
47
|
+
missing.append(requirement)
|
|
48
|
+
return missing
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def install_python_requirements() -> None:
|
|
52
|
+
"""Install Python runtime packages that are absent from this interpreter."""
|
|
53
|
+
missing = _missing_requirements()
|
|
54
|
+
if not missing:
|
|
55
|
+
return
|
|
56
|
+
print(
|
|
57
|
+
f"[pagefetch] Installing missing Python requirements: {', '.join(missing)}",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
command = [
|
|
61
|
+
sys.executable,
|
|
62
|
+
"-m",
|
|
63
|
+
"pip",
|
|
64
|
+
"install",
|
|
65
|
+
"--disable-pip-version-check",
|
|
66
|
+
*missing,
|
|
67
|
+
]
|
|
68
|
+
try:
|
|
69
|
+
subprocess.run(command, check=True)
|
|
70
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
71
|
+
raise RuntimeBootstrapError(
|
|
72
|
+
"PageFetch could not install its Python requirements. "
|
|
73
|
+
"Run 'python -m pip install .' manually or set PAGEFETCH_AUTO_INSTALL=0 "
|
|
74
|
+
"to disable automatic installation."
|
|
75
|
+
) from exc
|
|
76
|
+
importlib.invalidate_caches()
|
|
77
|
+
unresolved = _missing_requirements()
|
|
78
|
+
if unresolved:
|
|
79
|
+
raise RuntimeBootstrapError(
|
|
80
|
+
f"Requirements remain unavailable after installation: {', '.join(unresolved)}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def install_camoufox_browser() -> None:
|
|
85
|
+
"""Download or update the Camoufox browser binary when it is unavailable."""
|
|
86
|
+
try:
|
|
87
|
+
from camoufox.pkgman import camoufox_path
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
camoufox_path(download_if_missing=False)
|
|
91
|
+
return
|
|
92
|
+
except Exception:
|
|
93
|
+
print("[pagefetch] Installing the Camoufox browser runtime...", file=sys.stderr)
|
|
94
|
+
camoufox_path(download_if_missing=True)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise RuntimeBootstrapError(
|
|
97
|
+
"PageFetch could not install the Camoufox browser runtime. "
|
|
98
|
+
"Run 'python -m camoufox fetch' manually or set PAGEFETCH_AUTO_INSTALL=0 "
|
|
99
|
+
"to disable automatic installation."
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ensure_runtime_requirements() -> None:
|
|
104
|
+
"""Install all runtime requirements once at process startup."""
|
|
105
|
+
global _bootstrap_complete
|
|
106
|
+
if not auto_install_enabled():
|
|
107
|
+
return
|
|
108
|
+
with _bootstrap_lock:
|
|
109
|
+
if _bootstrap_complete:
|
|
110
|
+
return
|
|
111
|
+
install_python_requirements()
|
|
112
|
+
install_camoufox_browser()
|
|
113
|
+
_bootstrap_complete = True
|
pagefetch/cache/keys.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Stable, credential-safe cache keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..utils.urls import normalize_url
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_cache_key(
|
|
13
|
+
url: str,
|
|
14
|
+
*,
|
|
15
|
+
mode: str,
|
|
16
|
+
proxy: str,
|
|
17
|
+
settings: dict[str, Any] | None = None,
|
|
18
|
+
) -> str:
|
|
19
|
+
payload = {
|
|
20
|
+
"url": normalize_url(url),
|
|
21
|
+
"mode": mode,
|
|
22
|
+
"proxy": proxy,
|
|
23
|
+
"processing_version": 1,
|
|
24
|
+
"settings": settings or {},
|
|
25
|
+
}
|
|
26
|
+
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
27
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
28
|
+
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Async SQLite cache implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import aiosqlite
|
|
10
|
+
|
|
11
|
+
from ..models import FetchResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SQLiteCache:
|
|
15
|
+
"""Persistent cache for successful fetch results."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path: Path) -> None:
|
|
18
|
+
self.path = path
|
|
19
|
+
self._db: aiosqlite.Connection | None = None
|
|
20
|
+
|
|
21
|
+
async def start(self) -> None:
|
|
22
|
+
if self._db is not None:
|
|
23
|
+
return
|
|
24
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
self._db = await aiosqlite.connect(self.path)
|
|
26
|
+
await self._db.execute("PRAGMA journal_mode=WAL")
|
|
27
|
+
await self._db.execute(
|
|
28
|
+
"""
|
|
29
|
+
CREATE TABLE IF NOT EXISTS fetch_cache (
|
|
30
|
+
cache_key TEXT PRIMARY KEY,
|
|
31
|
+
payload TEXT NOT NULL,
|
|
32
|
+
created_at REAL NOT NULL,
|
|
33
|
+
expires_at REAL NOT NULL
|
|
34
|
+
)
|
|
35
|
+
"""
|
|
36
|
+
)
|
|
37
|
+
await self._db.commit()
|
|
38
|
+
|
|
39
|
+
async def get(self, key: str) -> FetchResult | None:
|
|
40
|
+
"""Return the cached result for *key*, or ``None`` when the key is missing
|
|
41
|
+
or the cached entry has expired.
|
|
42
|
+
|
|
43
|
+
Expired entries are automatically deleted from the database before
|
|
44
|
+
returning ``None``.
|
|
45
|
+
"""
|
|
46
|
+
if self._db is None:
|
|
47
|
+
raise RuntimeError("cache has not been started")
|
|
48
|
+
cursor = await self._db.execute(
|
|
49
|
+
"SELECT payload, expires_at FROM fetch_cache WHERE cache_key = ?", (key,)
|
|
50
|
+
)
|
|
51
|
+
row = await cursor.fetchone()
|
|
52
|
+
await cursor.close()
|
|
53
|
+
if not row:
|
|
54
|
+
return None
|
|
55
|
+
if row[1] <= time.time():
|
|
56
|
+
await self._db.execute("DELETE FROM fetch_cache WHERE cache_key = ?", (key,))
|
|
57
|
+
await self._db.commit()
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
result = FetchResult.from_dict(json.loads(row[0]))
|
|
61
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
62
|
+
await self._db.execute("DELETE FROM fetch_cache WHERE cache_key = ?", (key,))
|
|
63
|
+
await self._db.commit()
|
|
64
|
+
return None
|
|
65
|
+
result.from_cache = True
|
|
66
|
+
result.fetch_method = "cache"
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
async def set(self, key: str, result: FetchResult, ttl: int) -> None:
|
|
70
|
+
"""Persist *result* in the cache with the given *ttl* (in seconds).
|
|
71
|
+
|
|
72
|
+
Only successful results are cached. If the result cannot be serialized
|
|
73
|
+
a :exc:`RuntimeError` is raised with details about the failure.
|
|
74
|
+
"""
|
|
75
|
+
if self._db is None:
|
|
76
|
+
raise RuntimeError("cache has not been started")
|
|
77
|
+
if not result.success:
|
|
78
|
+
return
|
|
79
|
+
now = time.time()
|
|
80
|
+
try:
|
|
81
|
+
payload = result.json(include_html=True)
|
|
82
|
+
except (TypeError, ValueError) as exc:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"Failed to serialize fetch result for cache key {key!r}: {exc}"
|
|
85
|
+
) from exc
|
|
86
|
+
await self._db.execute(
|
|
87
|
+
"""
|
|
88
|
+
INSERT INTO fetch_cache(cache_key, payload, created_at, expires_at)
|
|
89
|
+
VALUES (?, ?, ?, ?)
|
|
90
|
+
ON CONFLICT(cache_key) DO UPDATE SET
|
|
91
|
+
payload=excluded.payload,
|
|
92
|
+
created_at=excluded.created_at,
|
|
93
|
+
expires_at=excluded.expires_at
|
|
94
|
+
""",
|
|
95
|
+
(key, payload, now, now + ttl),
|
|
96
|
+
)
|
|
97
|
+
await self._db.commit()
|
|
98
|
+
|
|
99
|
+
async def close(self) -> None:
|
|
100
|
+
if self._db is not None:
|
|
101
|
+
await self._db.close()
|
|
102
|
+
self._db = None
|
|
103
|
+
|
pagefetch/cli.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Small command-line interface for testing PageFetch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .client import PageFetch
|
|
13
|
+
from .config import PageFetchConfig
|
|
14
|
+
from .models import FetchResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(prog="pagefetch", description="Fetch complete web page content")
|
|
19
|
+
parser.add_argument("input", metavar="URL_OR_FILE")
|
|
20
|
+
parser.add_argument("--format", choices=("markdown", "json", "html"), default="markdown")
|
|
21
|
+
parser.add_argument("-c", "--config", type=Path, metavar="PATH", help="Path to config.yaml")
|
|
22
|
+
parser.add_argument("--mode", choices=("auto", "http", "browser"), default=argparse.SUPPRESS)
|
|
23
|
+
parser.add_argument("--proxy", choices=("none", "decodo", "dataimpulse"), default=argparse.SUPPRESS)
|
|
24
|
+
parser.add_argument("-o", "--output", type=Path)
|
|
25
|
+
parser.add_argument("--include-html", action="store_true")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--cache-ttl",
|
|
28
|
+
metavar="DURATION",
|
|
29
|
+
default=argparse.SUPPRESS,
|
|
30
|
+
help="Cache time-to-live, e.g. 30s, 15m, 24h, 7d (default: 24h)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--no-cache", action="store_true")
|
|
33
|
+
parser.add_argument("--http-concurrency", type=int, default=argparse.SUPPRESS)
|
|
34
|
+
parser.add_argument("--browser-concurrency", type=int, default=argparse.SUPPRESS)
|
|
35
|
+
parser.add_argument("--timeout", type=float, default=argparse.SUPPRESS)
|
|
36
|
+
parser.add_argument("--browser-timeout", type=float, default=argparse.SUPPRESS)
|
|
37
|
+
parser.add_argument("--debug", action="store_true")
|
|
38
|
+
return parser
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _inputs(value: str) -> list[str]:
|
|
42
|
+
if value.startswith(("http://", "https://")):
|
|
43
|
+
return [value]
|
|
44
|
+
path = Path(value)
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
# If it looks like a file path (has an extension or a directory separator)
|
|
47
|
+
# but doesn't exist, give a clear error instead of treating it as a URL.
|
|
48
|
+
if path.suffix or "/" in value or "\\" in value:
|
|
49
|
+
raise ValueError(f"file not found: {value!r}")
|
|
50
|
+
# Otherwise assume it's a URL (downstream validation will catch bad ones).
|
|
51
|
+
return [value]
|
|
52
|
+
return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _render(results: list[FetchResult], output_format: str, include_html: bool) -> str:
|
|
56
|
+
if output_format == "json":
|
|
57
|
+
values = [result.to_dict(include_html=include_html) for result in results]
|
|
58
|
+
return json.dumps(values, ensure_ascii=False, indent=2)
|
|
59
|
+
if output_format == "html":
|
|
60
|
+
documents = [result.html or result.text or "" for result in results]
|
|
61
|
+
else:
|
|
62
|
+
documents = [result.markdown or result.json() for result in results]
|
|
63
|
+
return "\n\n---\n\n".join(documents)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_config(args: argparse.Namespace) -> PageFetchConfig:
|
|
67
|
+
"""Build configuration from YAML file (if provided) with CLI overrides."""
|
|
68
|
+
# Start from YAML if --config is given, otherwise use built-in defaults
|
|
69
|
+
if args.config:
|
|
70
|
+
config = PageFetchConfig.from_yaml(args.config)
|
|
71
|
+
else:
|
|
72
|
+
config = PageFetchConfig()
|
|
73
|
+
|
|
74
|
+
# CLI overrides — only apply when the user explicitly set the argument
|
|
75
|
+
overrides: dict[str, object] = {}
|
|
76
|
+
if hasattr(args, "mode"):
|
|
77
|
+
overrides["mode"] = args.mode
|
|
78
|
+
if hasattr(args, "proxy"):
|
|
79
|
+
overrides["proxy"] = args.proxy
|
|
80
|
+
if hasattr(args, "cache_ttl"):
|
|
81
|
+
overrides["cache_ttl"] = args.cache_ttl
|
|
82
|
+
if hasattr(args, "http_concurrency"):
|
|
83
|
+
overrides["http_concurrency"] = args.http_concurrency
|
|
84
|
+
if hasattr(args, "browser_concurrency"):
|
|
85
|
+
overrides["browser_concurrency"] = args.browser_concurrency
|
|
86
|
+
if hasattr(args, "timeout"):
|
|
87
|
+
overrides["http_timeout"] = args.timeout
|
|
88
|
+
if hasattr(args, "browser_timeout"):
|
|
89
|
+
overrides["browser_timeout"] = args.browser_timeout
|
|
90
|
+
|
|
91
|
+
if not overrides and not args.no_cache:
|
|
92
|
+
return config
|
|
93
|
+
|
|
94
|
+
# Rebuild with overrides applied
|
|
95
|
+
return PageFetchConfig.build(
|
|
96
|
+
mode=str(overrides.get("mode", config.mode)),
|
|
97
|
+
proxy=str(overrides.get("proxy", config.proxy)),
|
|
98
|
+
http_concurrency=int(overrides.get("http_concurrency", config.http_concurrency)),
|
|
99
|
+
browser_concurrency=int(overrides.get("browser_concurrency", config.browser_concurrency)),
|
|
100
|
+
cache_enabled=not args.no_cache if args.no_cache else config.cache_enabled,
|
|
101
|
+
cache_ttl=overrides.get("cache_ttl", config.cache_ttl),
|
|
102
|
+
cache_path=config.cache_path,
|
|
103
|
+
http_timeout=float(overrides.get("http_timeout", config.http_timeout)),
|
|
104
|
+
browser_timeout=float(overrides.get("browser_timeout", config.browser_timeout)),
|
|
105
|
+
retries_http=config.retries_http,
|
|
106
|
+
retries_browser=config.retries_browser,
|
|
107
|
+
max_redirects=config.max_redirects,
|
|
108
|
+
max_content_size=config.max_content_size,
|
|
109
|
+
confidence_threshold=config.confidence_threshold,
|
|
110
|
+
raise_on_error=config.raise_on_error,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _run(args: argparse.Namespace) -> int:
|
|
115
|
+
urls = _inputs(args.input)
|
|
116
|
+
if not urls:
|
|
117
|
+
raise ValueError("the input file does not contain any URLs")
|
|
118
|
+
config = _build_config(args)
|
|
119
|
+
async with PageFetch(
|
|
120
|
+
mode=config.mode,
|
|
121
|
+
proxy=config.proxy,
|
|
122
|
+
cache_enabled=config.cache_enabled,
|
|
123
|
+
cache_ttl=config.cache_ttl,
|
|
124
|
+
http_concurrency=config.http_concurrency,
|
|
125
|
+
browser_concurrency=config.browser_concurrency,
|
|
126
|
+
http_timeout=config.http_timeout,
|
|
127
|
+
browser_timeout=config.browser_timeout,
|
|
128
|
+
) as client:
|
|
129
|
+
results = await client.fetch_many(urls)
|
|
130
|
+
rendered = _render(results, args.format, args.include_html)
|
|
131
|
+
if args.output:
|
|
132
|
+
args.output.write_text(rendered, encoding="utf-8")
|
|
133
|
+
else:
|
|
134
|
+
try:
|
|
135
|
+
print(rendered)
|
|
136
|
+
except UnicodeEncodeError:
|
|
137
|
+
sys.stdout.buffer.write((rendered + "\n").encode("utf-8"))
|
|
138
|
+
total = len(results)
|
|
139
|
+
success_count = sum(1 for r in results if r.success)
|
|
140
|
+
cache_count = sum(1 for r in results if r.from_cache)
|
|
141
|
+
print(f"pagefetch: {total} URL(s) — {success_count} succeeded ({cache_count} from cache), {total - success_count} failed", file=sys.stderr)
|
|
142
|
+
if not results:
|
|
143
|
+
return 0
|
|
144
|
+
if success_count == 0:
|
|
145
|
+
return 1
|
|
146
|
+
if success_count < total:
|
|
147
|
+
return 3 # partial failure
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main(argv: list[str] | None = None) -> int:
|
|
152
|
+
args = build_parser().parse_args(argv)
|
|
153
|
+
if args.debug:
|
|
154
|
+
handler = logging.StreamHandler()
|
|
155
|
+
logging.getLogger("pagefetch").addHandler(handler)
|
|
156
|
+
logging.getLogger("pagefetch").setLevel(logging.DEBUG)
|
|
157
|
+
try:
|
|
158
|
+
return asyncio.run(_run(args))
|
|
159
|
+
except (ValueError, OSError) as exc:
|
|
160
|
+
print(f"pagefetch: {exc}", file=sys.stderr)
|
|
161
|
+
return 2
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
raise SystemExit(main())
|