scrapefold 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.
- scrapefold/__init__.py +109 -0
- scrapefold/_host_utils.py +58 -0
- scrapefold/cache.py +197 -0
- scrapefold/cli.py +184 -0
- scrapefold/crawler/__init__.py +230 -0
- scrapefold/crawler/filters.py +138 -0
- scrapefold/crawler/result.py +33 -0
- scrapefold/crawler/sitemap.py +346 -0
- scrapefold/crawler/stitcher.py +44 -0
- scrapefold/detection.py +210 -0
- scrapefold/engines/__init__.py +123 -0
- scrapefold/engines/anysite.py +191 -0
- scrapefold/engines/apify_linkedin.py +159 -0
- scrapefold/engines/base.py +229 -0
- scrapefold/engines/cloakbrowser.py +238 -0
- scrapefold/engines/cloudflare.py +224 -0
- scrapefold/engines/crawl4ai.py +245 -0
- scrapefold/engines/firecrawl.py +265 -0
- scrapefold/engines/jina.py +178 -0
- scrapefold/engines/outscraper.py +153 -0
- scrapefold/engines/requests.py +219 -0
- scrapefold/engines/scrapingbee.py +213 -0
- scrapefold/engines/scrapingdog.py +114 -0
- scrapefold/engines/scrapling_fast.py +145 -0
- scrapefold/engines/scrapling_stealth.py +168 -0
- scrapefold/engines/selenium.py +203 -0
- scrapefold/html_to_text.py +222 -0
- scrapefold/ladders.py +599 -0
- scrapefold/mcp_server.py +33 -0
- scrapefold/options.py +163 -0
- scrapefold/pool.py +65 -0
- scrapefold/py.typed +0 -0
- scrapefold/result.py +95 -0
- scrapefold/router.py +486 -0
- scrapefold/vision.py +135 -0
- scrapefold-0.1.0.dist-info/METADATA +183 -0
- scrapefold-0.1.0.dist-info/RECORD +40 -0
- scrapefold-0.1.0.dist-info/WHEEL +4 -0
- scrapefold-0.1.0.dist-info/entry_points.txt +3 -0
- scrapefold-0.1.0.dist-info/licenses/LICENSE +21 -0
scrapefold/__init__.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""scrapefold — unified interface for web scraping engines.
|
|
2
|
+
|
|
3
|
+
Public API (v0.1 — scaffold; engines land in later PRs):
|
|
4
|
+
|
|
5
|
+
from scrapefold import scrape, crawl_site, ScrapeOptions, ScrapeResult, ScrapeEngine
|
|
6
|
+
|
|
7
|
+
res = await scrape("https://example.com")
|
|
8
|
+
res = await scrape(url, opts=ScrapeOptions(language="ru", stealth=True))
|
|
9
|
+
md_path = await crawl_site("https://docs.example.com", opts=ScrapeOptions(max_pages=50))
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from scrapefold.crawler.result import CrawlResult
|
|
15
|
+
from scrapefold.engines.base import (
|
|
16
|
+
EngineCapabilities,
|
|
17
|
+
EngineError,
|
|
18
|
+
RedirectScopeViolation,
|
|
19
|
+
ScrapeEngine,
|
|
20
|
+
)
|
|
21
|
+
from scrapefold.ladders import (
|
|
22
|
+
AllEnginesFailed,
|
|
23
|
+
BudgetExceeded,
|
|
24
|
+
Policy,
|
|
25
|
+
RaceStep,
|
|
26
|
+
SequentialStep,
|
|
27
|
+
SiteClass,
|
|
28
|
+
WalkBudget,
|
|
29
|
+
classify_url,
|
|
30
|
+
get_ladder,
|
|
31
|
+
)
|
|
32
|
+
from scrapefold.options import ScrapeOptions
|
|
33
|
+
from scrapefold.pool import EnginePool
|
|
34
|
+
from scrapefold.result import ScrapeResult
|
|
35
|
+
from scrapefold.router import walk as _walk
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"AllEnginesFailed",
|
|
41
|
+
"BudgetExceeded",
|
|
42
|
+
"CrawlResult",
|
|
43
|
+
"EngineCapabilities",
|
|
44
|
+
"EngineError",
|
|
45
|
+
"EnginePool",
|
|
46
|
+
"Policy",
|
|
47
|
+
"RaceStep",
|
|
48
|
+
"RedirectScopeViolation",
|
|
49
|
+
"ScrapeEngine",
|
|
50
|
+
"ScrapeOptions",
|
|
51
|
+
"ScrapeResult",
|
|
52
|
+
"SequentialStep",
|
|
53
|
+
"SiteClass",
|
|
54
|
+
"WalkBudget",
|
|
55
|
+
"__version__",
|
|
56
|
+
"classify_url",
|
|
57
|
+
"crawl_site",
|
|
58
|
+
"get_ladder",
|
|
59
|
+
"scrape",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def scrape(
|
|
64
|
+
url: str,
|
|
65
|
+
opts: ScrapeOptions | None = None,
|
|
66
|
+
pool: EnginePool | None = None,
|
|
67
|
+
) -> ScrapeResult:
|
|
68
|
+
"""Single-URL scrape with engine auto-selection.
|
|
69
|
+
|
|
70
|
+
Walks the per-site-class ladder via ``scrapefold.router.walk``. Raises
|
|
71
|
+
``AllEnginesFailed`` if no step in the ladder succeeds.
|
|
72
|
+
|
|
73
|
+
Pass a caller-owned ``pool`` to reuse engine instances across scrape calls
|
|
74
|
+
(e.g. during a ``crawl_site`` run). When ``None`` (default), an ephemeral
|
|
75
|
+
pool is created and closed for each call.
|
|
76
|
+
"""
|
|
77
|
+
return await _walk(url, opts, pool=pool)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def crawl_site(
|
|
81
|
+
url: str,
|
|
82
|
+
opts: ScrapeOptions | None = None,
|
|
83
|
+
output: object | None = None,
|
|
84
|
+
**_unused: object,
|
|
85
|
+
) -> CrawlResult:
|
|
86
|
+
"""Whole-site crawl → single markdown file.
|
|
87
|
+
|
|
88
|
+
Discovers URLs from ``url`` (sitemap → robots → BFS), scrapes each
|
|
89
|
+
via ``scrape()``, and writes a stitched .md file at ``output``
|
|
90
|
+
(defaults to a unique temp path under ``/tmp``). Returns a
|
|
91
|
+
:class:`CrawlResult` with the per-URL pages, the stitched markdown
|
|
92
|
+
path, and per-URL failure strings.
|
|
93
|
+
|
|
94
|
+
Pass ``opts.extra["cache_dir"]`` to enable disk caching. The cache
|
|
95
|
+
TTL is read from ``opts.extra["cache_ttl_days"]`` (default: 7 days).
|
|
96
|
+
Set ``opts.skip_cache=True`` to bypass the cache for both reads and writes.
|
|
97
|
+
|
|
98
|
+
Unknown keyword arguments are accepted and silently ignored for
|
|
99
|
+
forward compatibility (logged at DEBUG).
|
|
100
|
+
"""
|
|
101
|
+
import logging as _logging
|
|
102
|
+
|
|
103
|
+
if _unused:
|
|
104
|
+
_logging.getLogger(__name__).debug(
|
|
105
|
+
"crawl_site: ignoring unimplemented kwargs: %s", sorted(_unused.keys())
|
|
106
|
+
)
|
|
107
|
+
from scrapefold.crawler import crawl
|
|
108
|
+
|
|
109
|
+
return await crawl(url, opts=opts, output=output) # type: ignore[arg-type]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Host-comparison utilities shared by the crawler and the requests engine.
|
|
2
|
+
|
|
3
|
+
Kept in a top-level private module to avoid a circular dependency between
|
|
4
|
+
``scrapefold.crawler.sitemap`` (which needs host checking) and
|
|
5
|
+
``scrapefold.engines.requests`` (which also needs host checking for redirect
|
|
6
|
+
scope enforcement).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import tldextract
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_invalid_location_error(exc: httpx.RemoteProtocolError) -> bool:
|
|
18
|
+
"""Return True if a RemoteProtocolError came from a malformed Location header.
|
|
19
|
+
|
|
20
|
+
httpx's message for this case is stable across versions tested:
|
|
21
|
+
'Invalid URL in location header: ...'. Any other RemoteProtocolError
|
|
22
|
+
(server disconnects, malformed response bodies) returns False.
|
|
23
|
+
"""
|
|
24
|
+
return "Invalid URL in location header" in str(exc)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def same_host(url: str, root: str, follow_subdomains: bool) -> bool:
|
|
28
|
+
"""Return True if *url* is on the same host as *root*.
|
|
29
|
+
|
|
30
|
+
When *follow_subdomains* is ``False`` (the default), only an exact
|
|
31
|
+
host match (www/apex-normalised) passes. When ``True``, all subdomains
|
|
32
|
+
of the same registered domain pass.
|
|
33
|
+
|
|
34
|
+
Returns ``False`` for non-HTTP/HTTPS URLs and for unparseable inputs.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
url_parsed = urlparse(url)
|
|
38
|
+
root_parsed = urlparse(root)
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
if url_parsed.scheme not in ("http", "https"):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
if follow_subdomains:
|
|
46
|
+
|
|
47
|
+
def _reg_domain(u: str) -> str:
|
|
48
|
+
ext = tldextract.extract(u)
|
|
49
|
+
return f"{ext.domain}.{ext.suffix}" if ext.suffix else ext.domain
|
|
50
|
+
|
|
51
|
+
return _reg_domain(url) == _reg_domain(root)
|
|
52
|
+
else:
|
|
53
|
+
url_host = url_parsed.netloc.lower().removeprefix("www.")
|
|
54
|
+
root_host = root_parsed.netloc.lower().removeprefix("www.")
|
|
55
|
+
return url_host == root_host
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ["_is_invalid_location_error", "same_host"]
|
scrapefold/cache.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Disk-backed TTL cache for ScrapeResult.
|
|
2
|
+
|
|
3
|
+
Key derivation: sha256(url) + sha256(canonical-json(opts)). One file per
|
|
4
|
+
key under ``<cache_dir>/<first-2-of-key>/<rest-of-key>.json``. TTL via
|
|
5
|
+
file mtime. Atomic writes via ``os.replace``. Corrupt files are treated
|
|
6
|
+
as misses and removed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import asdict, is_dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from scrapefold.options import ScrapeOptions
|
|
23
|
+
from scrapefold.result import ScrapeResult
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_DEFAULT_TTL_DAYS = 7
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_PRIMITIVES: tuple[type, ...] = (str, int, float, bool, type(None))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _canonicalize(obj: Any) -> Any:
|
|
34
|
+
"""Recursively transform *obj* into a JSON-serializable, dict-key-sorted form.
|
|
35
|
+
|
|
36
|
+
Strict by design (Codex round-1 HIGH #3): unknown types raise
|
|
37
|
+
``ValueError`` so the caller can bypass the cache with a logged
|
|
38
|
+
warning rather than producing a non-deterministic key via
|
|
39
|
+
``default=str``.
|
|
40
|
+
"""
|
|
41
|
+
if isinstance(obj, _PRIMITIVES):
|
|
42
|
+
return obj
|
|
43
|
+
if isinstance(obj, (list, tuple)):
|
|
44
|
+
return [_canonicalize(x) for x in obj]
|
|
45
|
+
if isinstance(obj, (set, frozenset)):
|
|
46
|
+
items = [_canonicalize(x) for x in obj]
|
|
47
|
+
try:
|
|
48
|
+
return sorted(items)
|
|
49
|
+
except TypeError as exc:
|
|
50
|
+
raise ValueError(f"set with mixed-comparable elements: {exc}") from exc
|
|
51
|
+
if isinstance(obj, dict):
|
|
52
|
+
out: dict[str, Any] = {}
|
|
53
|
+
for k in sorted(obj):
|
|
54
|
+
if not isinstance(k, str):
|
|
55
|
+
raise ValueError(f"non-string dict key: {k!r} ({type(k).__name__})")
|
|
56
|
+
out[k] = _canonicalize(obj[k])
|
|
57
|
+
return out
|
|
58
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
59
|
+
return _canonicalize(asdict(obj))
|
|
60
|
+
raise ValueError(f"opts contains non-canonicalizable type: {type(obj).__name__}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _canonical_opts(opts: ScrapeOptions) -> str:
|
|
64
|
+
"""Produce a stable JSON string for opts. Raises ``ValueError`` on failure."""
|
|
65
|
+
return json.dumps(_canonicalize(asdict(opts)), sort_keys=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_key(url: str, opts: ScrapeOptions) -> str | None:
|
|
69
|
+
"""Return the 64-char hex sha256 key for (url, opts), or ``None`` if opts
|
|
70
|
+
contains a non-canonicalizable value (cache must bypass).
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
canonical = _canonical_opts(opts)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
logger.warning(
|
|
76
|
+
"cache: opts not canonicalizable (%s); cache will bypass for url=%s",
|
|
77
|
+
exc,
|
|
78
|
+
url,
|
|
79
|
+
)
|
|
80
|
+
return None
|
|
81
|
+
h = hashlib.sha256()
|
|
82
|
+
h.update(url.encode("utf-8"))
|
|
83
|
+
h.update(b"\x00")
|
|
84
|
+
h.update(canonical.encode("utf-8"))
|
|
85
|
+
return h.hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_text_sync(path: Path) -> str:
|
|
89
|
+
"""Synchronous file read — intended for use via asyncio.to_thread."""
|
|
90
|
+
return path.read_text()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _stat_and_read_sync(path: Path) -> tuple[float, str] | None:
|
|
94
|
+
"""Stat + read in one syscall pair. Returns ``None`` if the file is gone."""
|
|
95
|
+
try:
|
|
96
|
+
mtime = path.stat().st_mtime
|
|
97
|
+
return mtime, path.read_text()
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_atomic_sync(tmp: Path, path: Path, content: str) -> None:
|
|
103
|
+
"""Synchronous atomic write — intended for use via asyncio.to_thread."""
|
|
104
|
+
tmp.write_text(content)
|
|
105
|
+
os.replace(tmp, path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Cache:
|
|
109
|
+
"""sha256-keyed disk cache for ScrapeResult, TTL'd via file mtime."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, dir: Path | str, ttl_days: int = _DEFAULT_TTL_DAYS) -> None:
|
|
112
|
+
self.dir = Path(dir)
|
|
113
|
+
self.ttl_days = ttl_days
|
|
114
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
def _path_for(self, key: str) -> Path:
|
|
117
|
+
# Sharded by first two hex chars to avoid one giant flat directory.
|
|
118
|
+
return self.dir / key[:2] / f"{key[2:]}.json"
|
|
119
|
+
|
|
120
|
+
async def get(self, key: str, opts: ScrapeOptions | None = None) -> ScrapeResult | None:
|
|
121
|
+
"""Return cached result for *key*, or ``None`` on miss / expiry / skip.
|
|
122
|
+
|
|
123
|
+
Pass *opts* to honour ``opts.skip_cache=True`` — in that case this
|
|
124
|
+
method always returns ``None`` without touching the disk.
|
|
125
|
+
"""
|
|
126
|
+
if opts is not None and opts.skip_cache:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
path = self._path_for(key)
|
|
130
|
+
try:
|
|
131
|
+
stat_read = await asyncio.to_thread(_stat_and_read_sync, path)
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
logger.debug("cache: read failed %s (%s)", path, exc)
|
|
134
|
+
return None
|
|
135
|
+
if stat_read is None:
|
|
136
|
+
return None
|
|
137
|
+
mtime, raw = stat_read
|
|
138
|
+
if time.time() - mtime > self.ttl_days * 86400:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
data = json.loads(raw)
|
|
143
|
+
except json.JSONDecodeError as exc:
|
|
144
|
+
logger.debug("cache: corrupt file %s (%s); removing", path, exc)
|
|
145
|
+
with contextlib.suppress(OSError):
|
|
146
|
+
path.unlink(missing_ok=True)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
return _result_from_dict(data)
|
|
151
|
+
except (KeyError, TypeError) as exc:
|
|
152
|
+
logger.debug("cache: shape mismatch in %s (%s); removing", path, exc)
|
|
153
|
+
path.unlink(missing_ok=True)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
async def set(self, key: str, result: ScrapeResult, opts: ScrapeOptions | None = None) -> None:
|
|
157
|
+
"""Store *result* under *key*.
|
|
158
|
+
|
|
159
|
+
Pass *opts* to honour ``opts.skip_cache=True`` — in that case this
|
|
160
|
+
method is a no-op and does **not** write to disk.
|
|
161
|
+
"""
|
|
162
|
+
if opts is not None and opts.skip_cache:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if not isinstance(result, ScrapeResult):
|
|
166
|
+
raise TypeError(f"Cache.set expects ScrapeResult, got {type(result).__name__}")
|
|
167
|
+
|
|
168
|
+
path = self._path_for(key)
|
|
169
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
172
|
+
content = json.dumps(_result_to_dict(result), default=str)
|
|
173
|
+
await asyncio.to_thread(_write_atomic_sync, tmp, path, content)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _result_to_dict(r: ScrapeResult) -> dict[str, Any]:
|
|
177
|
+
return asdict(r)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _result_from_dict(d: dict[str, Any]) -> ScrapeResult:
|
|
181
|
+
# Be strict about the required fields; tolerate missing optional ones
|
|
182
|
+
return ScrapeResult(
|
|
183
|
+
url=d["url"],
|
|
184
|
+
text=d["text"],
|
|
185
|
+
markdown=d["markdown"],
|
|
186
|
+
html=d.get("html"),
|
|
187
|
+
json=d.get("json"),
|
|
188
|
+
screenshot_b64=d.get("screenshot_b64"),
|
|
189
|
+
engine=d["engine"],
|
|
190
|
+
elapsed_ms=d["elapsed_ms"],
|
|
191
|
+
cost_usd=d.get("cost_usd", 0.0),
|
|
192
|
+
meta=d.get("meta", {}),
|
|
193
|
+
failures=d.get("failures", []),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
__all__ = ["Cache", "make_key"]
|
scrapefold/cli.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""scrapefold — Typer CLI entry point.
|
|
2
|
+
|
|
3
|
+
Four subcommands: scrape, crawl, list-engines, classify.
|
|
4
|
+
--json flag everywhere; errors fatal with non-zero exit code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
import scrapefold
|
|
19
|
+
from scrapefold import AllEnginesFailed, ScrapeOptions, classify_url
|
|
20
|
+
from scrapefold.engines import list_engine_names
|
|
21
|
+
from scrapefold.result import ScrapeResult
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
help="scrapefold — unified web scraping CLI",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _version_callback(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
typer.echo(scrapefold.__version__)
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def _root(
|
|
38
|
+
version: bool = typer.Option(
|
|
39
|
+
False,
|
|
40
|
+
"--version",
|
|
41
|
+
callback=_version_callback,
|
|
42
|
+
is_eager=True,
|
|
43
|
+
help="Print version and exit.",
|
|
44
|
+
),
|
|
45
|
+
) -> None:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _engines_arg(engines: str | None) -> tuple[str, ...] | None:
|
|
50
|
+
if engines is None:
|
|
51
|
+
return None
|
|
52
|
+
return tuple(e.strip() for e in engines.split(",") if e.strip())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# scrape
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def scrape(
|
|
62
|
+
url: str = typer.Argument(..., help="URL to scrape."),
|
|
63
|
+
engines: str | None = typer.Option(
|
|
64
|
+
None,
|
|
65
|
+
"--engines",
|
|
66
|
+
help="Comma-separated engine override (e.g. 'jina,firecrawl').",
|
|
67
|
+
),
|
|
68
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
|
|
69
|
+
output: Path | None = typer.Option( # noqa: B008
|
|
70
|
+
None, "--output", help="Write markdown to PATH instead of stdout."
|
|
71
|
+
),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Scrape a single URL and print the markdown (or full JSON with --json)."""
|
|
74
|
+
opts = ScrapeOptions(engines=_engines_arg(engines))
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result: ScrapeResult = asyncio.run(scrapefold.scrape(url, opts))
|
|
78
|
+
except AllEnginesFailed as exc:
|
|
79
|
+
typer.echo(f"all engines failed: {exc}", err=True)
|
|
80
|
+
raise typer.Exit(code=1) from exc
|
|
81
|
+
|
|
82
|
+
if json_out:
|
|
83
|
+
typer.echo(json.dumps(asdict(result), default=str))
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if output is not None:
|
|
87
|
+
output.write_text(result.markdown)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
typer.echo(result.markdown)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# crawl
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def crawl(
|
|
100
|
+
url: str = typer.Argument(..., help="Root URL to crawl."),
|
|
101
|
+
max_pages: int = typer.Option(100, "--max-pages", help="Maximum pages to fetch (default 100)."),
|
|
102
|
+
output: Path | None = typer.Option( # noqa: B008
|
|
103
|
+
None,
|
|
104
|
+
"--output",
|
|
105
|
+
help="Output .md file path for the stitched crawl result.",
|
|
106
|
+
),
|
|
107
|
+
per_page_dir: Path | None = typer.Option( # noqa: B008
|
|
108
|
+
None,
|
|
109
|
+
"--per-page-dir",
|
|
110
|
+
help="Write each crawled page as <sha256(url)[:16]>.md into DIR.",
|
|
111
|
+
),
|
|
112
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON summary to stdout."),
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Crawl a site → produce one stitched markdown file and/or per-page files."""
|
|
115
|
+
opts = ScrapeOptions(max_pages=max_pages)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
crawl_result: Any = asyncio.run(scrapefold.crawl_site(url, opts=opts, output=output))
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
typer.echo(f"crawl failed for {url}: {exc}", err=True)
|
|
121
|
+
raise typer.Exit(code=1) from exc
|
|
122
|
+
|
|
123
|
+
# Write per-page files if requested
|
|
124
|
+
if per_page_dir is not None:
|
|
125
|
+
per_page_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
for page in crawl_result.pages:
|
|
127
|
+
slug = hashlib.sha256(page.url.encode()).hexdigest()[:16]
|
|
128
|
+
page_path = per_page_dir / f"{slug}.md"
|
|
129
|
+
page_path.write_text(page.markdown)
|
|
130
|
+
typer.echo(f"wrote {page_path} ({page.url})", err=True)
|
|
131
|
+
|
|
132
|
+
stitched_path: Path | None = getattr(crawl_result, "stitched_path", None)
|
|
133
|
+
|
|
134
|
+
if json_out:
|
|
135
|
+
typer.echo(json.dumps({"output": str(stitched_path) if stitched_path else None}))
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if stitched_path is not None:
|
|
139
|
+
typer.echo(str(stitched_path))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# list-engines
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("list-engines")
|
|
148
|
+
def list_engines_cmd(
|
|
149
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON list to stdout."),
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Print every engine registered in the lazy registry."""
|
|
152
|
+
names = list_engine_names()
|
|
153
|
+
if json_out:
|
|
154
|
+
typer.echo(json.dumps(names))
|
|
155
|
+
return
|
|
156
|
+
for name in names:
|
|
157
|
+
typer.echo(name)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# classify
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command()
|
|
166
|
+
def classify(
|
|
167
|
+
url: str = typer.Argument(..., help="URL to classify."),
|
|
168
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Print the SiteClass scrapefold's router would assign to a URL."""
|
|
171
|
+
site_class = classify_url(url)
|
|
172
|
+
if json_out:
|
|
173
|
+
typer.echo(json.dumps({"url": url, "site_class": site_class}))
|
|
174
|
+
return
|
|
175
|
+
typer.echo(site_class)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main() -> None:
|
|
179
|
+
"""Console-script entry point — referenced by pyproject `[project.scripts]`."""
|
|
180
|
+
app()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|