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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
4
+
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
@@ -0,0 +1,7 @@
1
+ """Persistent result cache."""
2
+
3
+ from .keys import build_cache_key
4
+ from .sqlite import SQLiteCache
5
+
6
+ __all__ = ["SQLiteCache", "build_cache_key"]
7
+
@@ -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())