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.
Files changed (40) hide show
  1. scrapefold/__init__.py +109 -0
  2. scrapefold/_host_utils.py +58 -0
  3. scrapefold/cache.py +197 -0
  4. scrapefold/cli.py +184 -0
  5. scrapefold/crawler/__init__.py +230 -0
  6. scrapefold/crawler/filters.py +138 -0
  7. scrapefold/crawler/result.py +33 -0
  8. scrapefold/crawler/sitemap.py +346 -0
  9. scrapefold/crawler/stitcher.py +44 -0
  10. scrapefold/detection.py +210 -0
  11. scrapefold/engines/__init__.py +123 -0
  12. scrapefold/engines/anysite.py +191 -0
  13. scrapefold/engines/apify_linkedin.py +159 -0
  14. scrapefold/engines/base.py +229 -0
  15. scrapefold/engines/cloakbrowser.py +238 -0
  16. scrapefold/engines/cloudflare.py +224 -0
  17. scrapefold/engines/crawl4ai.py +245 -0
  18. scrapefold/engines/firecrawl.py +265 -0
  19. scrapefold/engines/jina.py +178 -0
  20. scrapefold/engines/outscraper.py +153 -0
  21. scrapefold/engines/requests.py +219 -0
  22. scrapefold/engines/scrapingbee.py +213 -0
  23. scrapefold/engines/scrapingdog.py +114 -0
  24. scrapefold/engines/scrapling_fast.py +145 -0
  25. scrapefold/engines/scrapling_stealth.py +168 -0
  26. scrapefold/engines/selenium.py +203 -0
  27. scrapefold/html_to_text.py +222 -0
  28. scrapefold/ladders.py +599 -0
  29. scrapefold/mcp_server.py +33 -0
  30. scrapefold/options.py +163 -0
  31. scrapefold/pool.py +65 -0
  32. scrapefold/py.typed +0 -0
  33. scrapefold/result.py +95 -0
  34. scrapefold/router.py +486 -0
  35. scrapefold/vision.py +135 -0
  36. scrapefold-0.1.0.dist-info/METADATA +183 -0
  37. scrapefold-0.1.0.dist-info/RECORD +40 -0
  38. scrapefold-0.1.0.dist-info/WHEEL +4 -0
  39. scrapefold-0.1.0.dist-info/entry_points.txt +3 -0
  40. 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()