scrapefold 0.1.0a2__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 ADDED
@@ -0,0 +1,67 @@
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.engines.base import EngineCapabilities, EngineError, ScrapeEngine
15
+ from scrapefold.ladders import (
16
+ AllEnginesFailed,
17
+ BudgetExceeded,
18
+ Policy,
19
+ RaceStep,
20
+ SequentialStep,
21
+ SiteClass,
22
+ WalkBudget,
23
+ classify_url,
24
+ get_ladder,
25
+ )
26
+ from scrapefold.options import ScrapeOptions
27
+ from scrapefold.result import ScrapeResult
28
+ from scrapefold.router import walk as _walk
29
+
30
+ __version__ = "0.1.0a2"
31
+
32
+ __all__ = [
33
+ "AllEnginesFailed",
34
+ "BudgetExceeded",
35
+ "EngineCapabilities",
36
+ "EngineError",
37
+ "Policy",
38
+ "RaceStep",
39
+ "ScrapeEngine",
40
+ "ScrapeOptions",
41
+ "ScrapeResult",
42
+ "SequentialStep",
43
+ "SiteClass",
44
+ "WalkBudget",
45
+ "__version__",
46
+ "classify_url",
47
+ "crawl_site",
48
+ "get_ladder",
49
+ "scrape",
50
+ ]
51
+
52
+
53
+ async def scrape(url: str, opts: ScrapeOptions | None = None) -> ScrapeResult:
54
+ """Single-URL scrape with engine auto-selection.
55
+
56
+ Walks the per-site-class ladder via ``scrapefold.router.walk``. Raises
57
+ ``AllEnginesFailed`` if no step in the ladder succeeds.
58
+ """
59
+ return await _walk(url, opts)
60
+
61
+
62
+ async def crawl_site(url: str, opts: ScrapeOptions | None = None, **kwargs: object) -> str:
63
+ """Whole-site crawl → single markdown file.
64
+
65
+ Scaffold stub — crawler module lands in S8.
66
+ """
67
+ raise NotImplementedError("crawl_site() lands in S8 (crawler module).")
scrapefold/cli.py ADDED
@@ -0,0 +1,67 @@
1
+ """scrapefold CLI — Typer entry point.
2
+
3
+ Scaffold (S1) — subcommands are stubs. Real implementations land in S9.
4
+
5
+ Usage examples (post-S9):
6
+
7
+ scrapefold scrape https://example.com --engine firecrawl --language ru
8
+ scrapefold crawl https://docs.example.com --max-pages 50 --output site.md
9
+ scrapefold list-engines
10
+ scrapefold inspect-opts firecrawl
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+
17
+ import typer
18
+
19
+ app = typer.Typer(
20
+ name="scrapefold",
21
+ help="Unified web scraping CLI. Single URL or whole-site → markdown.",
22
+ no_args_is_help=True,
23
+ add_completion=False,
24
+ )
25
+
26
+
27
+ @app.command()
28
+ def scrape(url: str, engine: str = "auto") -> None:
29
+ """Scrape a single URL (stub — lands in S9)."""
30
+ typer.echo(f"[stub] would scrape {url} with engine={engine}", err=True)
31
+ sys.exit(2)
32
+
33
+
34
+ @app.command()
35
+ def crawl(url: str, max_pages: int = 50, output: str = "site.md") -> None:
36
+ """Crawl a whole site → markdown (stub — lands in S9)."""
37
+ typer.echo(f"[stub] would crawl {url} max_pages={max_pages} output={output}", err=True)
38
+ sys.exit(2)
39
+
40
+
41
+ @app.command("list-engines")
42
+ def list_engines_cmd() -> None:
43
+ """List registered engines and their availability (stub — lands in S9)."""
44
+ from scrapefold.engines import list_engine_names
45
+
46
+ names = list_engine_names()
47
+ if not names:
48
+ typer.echo("(no engines registered yet — scaffold only)")
49
+ else:
50
+ for name in names:
51
+ typer.echo(name)
52
+
53
+
54
+ @app.command("inspect-opts")
55
+ def inspect_opts(engine: str) -> None:
56
+ """Show which ScrapeOptions an engine supports (stub — lands in S9)."""
57
+ typer.echo(f"[stub] would inspect opts of {engine}", err=True)
58
+ sys.exit(2)
59
+
60
+
61
+ def main() -> None:
62
+ """Console-script entry point for ``scrapefold``."""
63
+ app()
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -0,0 +1,206 @@
1
+ """Response-quality detection for the scrapefold router.
2
+
3
+ Two public functions:
4
+
5
+ * :func:`is_suspicious` — decides whether a :class:`~scrapefold.result.ScrapeResult`
6
+ looks blocked / empty and should trigger ladder escalation.
7
+ * :func:`reclassify_from_response` — matches response signals against
8
+ :data:`~scrapefold.ladders.SIGNATURES` to determine whether the site should
9
+ be reclassified into a vendor anti-bot :data:`~scrapefold.ladders.SiteClass`.
10
+
11
+ Both are pure sync functions; no I/O, no network calls.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+
19
+ from scrapefold.ladders import SIGNATURES, Signature, SiteClass
20
+ from scrapefold.result import ScrapeResult
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Default antibot phrases
26
+ # ---------------------------------------------------------------------------
27
+
28
+ DEFAULT_ANTIBOT_PHRASES: tuple[str, ...] = (
29
+ "Just a moment...",
30
+ "Verify you are human",
31
+ "Checking your browser",
32
+ "Access denied",
33
+ "Please enable JavaScript",
34
+ "cf-browser-verification",
35
+ )
36
+
37
+ # Pre-compiled patterns used by the HTML heuristics.
38
+ _RE_NOSCRIPT = re.compile(r"<noscript[^>]*>.*?</noscript>", re.IGNORECASE | re.DOTALL)
39
+ _RE_SCRIPT = re.compile(r"<script[^>]*>.*?</script>", re.IGNORECASE | re.DOTALL)
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Public API
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ def is_suspicious(
48
+ result: ScrapeResult,
49
+ *,
50
+ min_text_chars: int = 200,
51
+ antibot_phrases: tuple[str, ...] = DEFAULT_ANTIBOT_PHRASES,
52
+ ) -> bool:
53
+ """Return True if the scrape result looks like an anti-bot block or empty page.
54
+
55
+ Heuristics applied (any one is sufficient):
56
+
57
+ 1. **Short text + failure signal** — ``len(result.text) < min_text_chars`` AND
58
+ either: (a) ``result.text`` is empty / whitespace-only, OR (b)
59
+ ``meta["status_code"]`` indicates an HTTP error (4xx or 5xx).
60
+ Short but non-empty text with a 2xx status is NOT suspicious under this
61
+ rule alone — other rules (antibot phrases, noscript/script domination) can
62
+ still flag it.
63
+ 2. **Anti-bot phrase** — any phrase in *antibot_phrases* appears (case-
64
+ insensitive) in ``result.text`` or ``result.html``.
65
+ 3. **Noscript domination** — the ``<noscript>`` content exceeds 50 % of the
66
+ raw HTML length after stripping noscript tags.
67
+ 4. **Script domination** — the ratio of non-script visible text to total HTML
68
+ is below 0.1 (script tags make up more than 90 % of the document).
69
+ 5. **Error status + empty text** — ``meta["status_code"]`` is 403 or 503 AND
70
+ ``result.text`` is empty / whitespace-only.
71
+ """
72
+ text: str = result.text or ""
73
+ html: str | None = result.html
74
+
75
+ if len(text) < min_text_chars:
76
+ status_code = result.status_code
77
+ is_error_status = status_code is not None and status_code >= 400
78
+ is_empty = not text.strip()
79
+ if is_empty or is_error_status:
80
+ logger.debug(
81
+ "is_suspicious: short text (%d < %d chars) with %s",
82
+ len(text),
83
+ min_text_chars,
84
+ "empty text" if is_empty else f"status_code={status_code}",
85
+ )
86
+ return True
87
+
88
+ text_lower = text.lower()
89
+ html_lower = (html or "").lower()
90
+ phrases_lower = tuple(p.lower() for p in antibot_phrases)
91
+ for phrase, phrase_lower in zip(antibot_phrases, phrases_lower, strict=True):
92
+ if phrase_lower in text_lower or phrase_lower in html_lower:
93
+ logger.debug("is_suspicious: antibot phrase %r found", phrase)
94
+ return True
95
+
96
+ if html:
97
+ html_len = len(html)
98
+ # finditer over findall to avoid allocating the matched block strings —
99
+ # noscript/script blocks can be MB on script-heavy SPAs.
100
+ noscript_total = sum(m.end() - m.start() for m in _RE_NOSCRIPT.finditer(html))
101
+ if noscript_total and html_len - noscript_total < html_len * 0.5:
102
+ logger.debug(
103
+ "is_suspicious: noscript domination (noscript=%d, total=%d)",
104
+ noscript_total,
105
+ html_len,
106
+ )
107
+ return True
108
+
109
+ if html_len > 0:
110
+ script_total = sum(m.end() - m.start() for m in _RE_SCRIPT.finditer(html))
111
+ ratio = (html_len - script_total) / html_len
112
+ if ratio < 0.1:
113
+ logger.debug(
114
+ "is_suspicious: script domination (ratio=%.3f, script=%d, total=%d)",
115
+ ratio,
116
+ script_total,
117
+ html_len,
118
+ )
119
+ return True
120
+
121
+ status_code = result.status_code
122
+ if status_code in (403, 503) and not text.strip():
123
+ logger.debug("is_suspicious: status_code=%d with empty text", status_code)
124
+ return True
125
+
126
+ return False
127
+
128
+
129
+ def reclassify_from_response(
130
+ *,
131
+ body: str | None = None,
132
+ cookies: dict[str, str] | None = None,
133
+ headers: dict[str, str] | None = None,
134
+ status_code: int | None = None,
135
+ signatures: tuple[Signature, ...] = SIGNATURES,
136
+ ) -> SiteClass | None:
137
+ """Match the response against *signatures*; return the target SiteClass if
138
+ any :class:`~scrapefold.ladders.Signature` meets its ``min_matches``
139
+ threshold, else ``None``.
140
+
141
+ Walk order matters — the first matching signature wins. SIGNATURES is
142
+ ordered so that narrow vendor signatures (Datadome, PerimeterX, Akamai)
143
+ beat the broader Cloudflare one.
144
+
145
+ Matching rules per signature field:
146
+
147
+ * ``cookie_names`` — each cookie **name** present in *cookies* scores 1.
148
+ * ``header_names`` — each header name (compared lower-case) present in
149
+ *headers* scores 1.
150
+ * ``body_phrases_all`` — if non-empty, **all** phrases must appear in
151
+ *body* (case-insensitive); if they do, scores 1 collectively.
152
+ * ``body_phrases_any`` — if non-empty, at least one phrase must appear in
153
+ *body* (case-insensitive); scores 1.
154
+ * ``status_codes`` — if non-empty, *status_code* must be in the set; if it
155
+ is, scores 1.
156
+
157
+ If ``status_codes`` is non-empty and *status_code* is NOT in it, the
158
+ signature is skipped entirely (acts as a filter, not just a scorer).
159
+ """
160
+ body_lower = (body or "").lower()
161
+ cookies_norm: dict[str, str] = cookies or {}
162
+ # Normalise header names to lower-case once.
163
+ headers_lower: dict[str, str] = {k.lower(): v for k, v in (headers or {}).items()}
164
+
165
+ for sig in signatures:
166
+ # status_codes acts as a hard filter when non-empty.
167
+ if sig.status_codes and status_code not in sig.status_codes:
168
+ continue
169
+
170
+ # status_codes match (or absent) counts as 1 contribution toward score.
171
+ score = 1 if sig.status_codes else 0
172
+
173
+ for name in sig.cookie_names:
174
+ if name in cookies_norm:
175
+ score += 1
176
+
177
+ for name in sig.header_names:
178
+ if name.lower() in headers_lower:
179
+ score += 1
180
+
181
+ if sig.body_phrases_all and all(
182
+ phrase.lower() in body_lower for phrase in sig.body_phrases_all
183
+ ):
184
+ score += 1
185
+
186
+ if sig.body_phrases_any and any(
187
+ phrase.lower() in body_lower for phrase in sig.body_phrases_any
188
+ ):
189
+ score += 1
190
+
191
+ if score >= sig.min_matches:
192
+ logger.debug(
193
+ "reclassify_from_response: matched signature %r (score=%d)",
194
+ sig.target,
195
+ score,
196
+ )
197
+ return sig.target
198
+
199
+ return None
200
+
201
+
202
+ __all__ = [
203
+ "DEFAULT_ANTIBOT_PHRASES",
204
+ "is_suspicious",
205
+ "reclassify_from_response",
206
+ ]
@@ -0,0 +1,123 @@
1
+ """Engine registry.
2
+
3
+ Engines are imported lazily so that an optional vendor SDK missing on the
4
+ machine (e.g. ``selenium`` not installed) does not break import of
5
+ ``scrapefold`` itself. ``get_engine(name)`` returns a class on demand.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from scrapefold.engines.base import ScrapeEngine
15
+
16
+ # Lazy registry: name -> import-and-return-class function.
17
+ # Each lambda imports the engine module on first call so missing extras
18
+ # only error out when that engine is actually requested.
19
+ _REGISTRY: dict[str, Callable[[], type[ScrapeEngine]]] = {
20
+ "requests": lambda: (
21
+ __import__("scrapefold.engines.requests", fromlist=["RequestsEngine"]).RequestsEngine
22
+ ),
23
+ "firecrawl": lambda: (
24
+ __import__("scrapefold.engines.firecrawl", fromlist=["FirecrawlEngine"]).FirecrawlEngine
25
+ ),
26
+ "scrapingbee": lambda: (
27
+ __import__(
28
+ "scrapefold.engines.scrapingbee", fromlist=["ScrapingbeeEngine"]
29
+ ).ScrapingbeeEngine
30
+ ),
31
+ "scrapingdog": lambda: (
32
+ __import__(
33
+ "scrapefold.engines.scrapingdog", fromlist=["ScrapingdogEngine"]
34
+ ).ScrapingdogEngine
35
+ ),
36
+ "jina": lambda: __import__("scrapefold.engines.jina", fromlist=["JinaEngine"]).JinaEngine,
37
+ "apify_linkedin": lambda: (
38
+ __import__(
39
+ "scrapefold.engines.apify_linkedin", fromlist=["ApifyLinkedInEngine"]
40
+ ).ApifyLinkedInEngine
41
+ ),
42
+ "anysite": lambda: (
43
+ __import__("scrapefold.engines.anysite", fromlist=["AnySiteEngine"]).AnySiteEngine
44
+ ),
45
+ "outscraper": lambda: (
46
+ __import__("scrapefold.engines.outscraper", fromlist=["OutscraperEngine"]).OutscraperEngine
47
+ ),
48
+ "scrapling_stealth": lambda: (
49
+ __import__(
50
+ "scrapefold.engines.scrapling_stealth", fromlist=["ScraplingStealthEngine"]
51
+ ).ScraplingStealthEngine
52
+ ),
53
+ "scrapling_fast": lambda: (
54
+ __import__(
55
+ "scrapefold.engines.scrapling_fast", fromlist=["ScraplingFastEngine"]
56
+ ).ScraplingFastEngine
57
+ ),
58
+ "cloudflare": lambda: (
59
+ __import__("scrapefold.engines.cloudflare", fromlist=["CloudflareEngine"]).CloudflareEngine
60
+ ),
61
+ "crawl4ai": lambda: (
62
+ __import__("scrapefold.engines.crawl4ai", fromlist=["Crawl4AIEngine"]).Crawl4AIEngine
63
+ ),
64
+ "cloakbrowser": lambda: (
65
+ __import__(
66
+ "scrapefold.engines.cloakbrowser", fromlist=["CloakBrowserEngine"]
67
+ ).CloakBrowserEngine
68
+ ),
69
+ "selenium": lambda: (
70
+ __import__("scrapefold.engines.selenium", fromlist=["SeleniumEngine"]).SeleniumEngine
71
+ ),
72
+ }
73
+
74
+
75
+ # User-facing aliases for multi-mode engines so ``opts.engines=["scrapling"]``
76
+ # resolves to the canonical ``scrapling_stealth``, while
77
+ # ``WalkBudget.engines_tried`` stays keyed by unambiguous canonical names.
78
+ ENGINE_ALIASES: dict[str, str] = {}
79
+
80
+
81
+ def register(name: str, loader: Callable[[], type[ScrapeEngine]]) -> None:
82
+ _REGISTRY[name] = loader
83
+
84
+
85
+ def register_alias(alias: str, canonical: str) -> None:
86
+ """Register ``alias`` as a user-facing name for canonical engine ``canonical``."""
87
+ ENGINE_ALIASES[alias] = canonical
88
+
89
+
90
+ def resolve_alias(name: str) -> str:
91
+ """Return the canonical engine name for ``name``, or ``name`` if no alias."""
92
+ return ENGINE_ALIASES.get(name, name)
93
+
94
+
95
+ def get_engine(name: str) -> type[ScrapeEngine]:
96
+ """Return the engine class for ``name`` (alias-resolved). Raises KeyError if unknown."""
97
+ canonical = resolve_alias(name)
98
+ try:
99
+ loader = _REGISTRY[canonical]
100
+ except KeyError as exc:
101
+ raise KeyError(
102
+ f"unknown engine: {name!r} (resolved to {canonical!r}). known: {sorted(_REGISTRY)}"
103
+ ) from exc
104
+ return loader()
105
+
106
+
107
+ def list_engine_names() -> list[str]:
108
+ return sorted(_REGISTRY)
109
+
110
+
111
+ # Register user-facing aliases for multi-mode engines.
112
+ # "scrapling" resolves to "scrapling_stealth" (default / most capable mode).
113
+ register_alias("scrapling", "scrapling_stealth")
114
+
115
+
116
+ __all__ = [
117
+ "ENGINE_ALIASES",
118
+ "get_engine",
119
+ "list_engine_names",
120
+ "register",
121
+ "register_alias",
122
+ "resolve_alias",
123
+ ]
@@ -0,0 +1,191 @@
1
+ """AnySiteEngine — paid REST scraping via api.anysite.com.
2
+
3
+ AnySite specialises in protected targets: LinkedIn, Twitter/X, Instagram, etc.
4
+ There is no official Python SDK. This engine is a pure REST adapter over
5
+ ``httpx.AsyncClient``.
6
+
7
+ Pinned API contract (as of 2026-05):
8
+ Method : POST
9
+ Endpoint : https://api.anysite.com/v1/scrape
10
+ Auth : Authorization: Bearer <api_key> (request header)
11
+ Body : JSON — see ``_adapt()`` for full field mapping.
12
+ Response : {"data": {"html": "...", "markdown": "...",
13
+ "screenshot_b64": null | "<b64>"},
14
+ "meta": {"status_code": <int>}}
15
+
16
+ Target-site headers (Accept-Language, User-Agent, Cookie, custom_headers)
17
+ are sent inside the request body under the ``headers`` and ``cookies`` keys
18
+ rather than on the AnySite HTTP call itself. This mirrors how similar
19
+ residential-proxy APIs separate "headers for AnySite" from "headers for the
20
+ target site".
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import os
27
+
28
+ import httpx
29
+
30
+ from scrapefold.engines.base import EngineCapabilities, ScrapeEngine
31
+ from scrapefold.html_to_text import html_to_both, markdown_to_text
32
+ from scrapefold.options import (
33
+ ScrapeOptions,
34
+ build_target_headers,
35
+ cookies_to_header,
36
+ strip_extra_prefix,
37
+ )
38
+ from scrapefold.result import ScrapeResult
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ _ENDPOINT = "https://api.anysite.com/v1/scrape"
43
+
44
+
45
+ def _adapt(opts: ScrapeOptions, url: str) -> dict:
46
+ """Map unified ScrapeOptions to the AnySite POST JSON body.
47
+
48
+ Target-site headers (Accept-Language, User-Agent, custom_headers) are
49
+ collected via ``build_target_headers`` and placed in ``body["headers"]``
50
+ so AnySite can forward them to the destination site rather than
51
+ interpreting them on its own API call.
52
+
53
+ Cookies are serialised to a ``"Cookie: k=v; …"`` string and placed in
54
+ ``body["cookies"]``.
55
+
56
+ Extra keys prefixed ``anysite_`` are stripped of the prefix and merged
57
+ into the body at the top level (escape hatch for undocumented AnySite
58
+ params).
59
+ """
60
+ body: dict = {
61
+ "url": url,
62
+ "render_js": opts.render_js,
63
+ "wait_ms": opts.wait_ms,
64
+ }
65
+
66
+ if opts.country is not None:
67
+ body["country"] = opts.country
68
+
69
+ if opts.stealth:
70
+ body["stealth"] = opts.stealth
71
+
72
+ if opts.premium_proxy:
73
+ body["premium_proxy"] = opts.premium_proxy
74
+
75
+ if opts.take_screenshot:
76
+ body["take_screenshot"] = opts.take_screenshot
77
+
78
+ # Target-site headers: language, user-agent, custom_headers.
79
+ # build_target_headers also handles cookies-to-Cookie-header, but we send
80
+ # cookies separately via the body["cookies"] key, so skip that here.
81
+ target_headers = build_target_headers(opts, include_cookies=False)
82
+ if target_headers:
83
+ body["headers"] = target_headers
84
+
85
+ # Cookies become a serialised Cookie header value in the body.
86
+ cookie_str = cookies_to_header(opts.cookies)
87
+ if cookie_str:
88
+ body["cookies"] = cookie_str
89
+
90
+ # Forward anysite_* extras (prefix stripped) into the body.
91
+ extra_params = strip_extra_prefix(opts.extra, "anysite_")
92
+ body.update(extra_params)
93
+
94
+ return body
95
+
96
+
97
+ class AnySiteEngine(ScrapeEngine):
98
+ """Scraping engine backed by the AnySite REST API.
99
+
100
+ API key is read from the constructor argument or the ``ANYSITE_API_KEY``
101
+ environment variable. ``is_available()`` returns ``False`` when neither is set.
102
+ """
103
+
104
+ NAME = "anysite"
105
+ CAPABILITIES = EngineCapabilities(
106
+ requires_api_key=True,
107
+ estimated_cost_usd=0.002,
108
+ billing_unit="call",
109
+ proxy_type="residential",
110
+ js_rendering=True,
111
+ stealth=True,
112
+ output_native_markdown=True,
113
+ )
114
+ SUPPORTED_OPTIONS = frozenset(
115
+ {
116
+ "language",
117
+ "country",
118
+ "render_js",
119
+ "wait_ms",
120
+ "stealth",
121
+ "premium_proxy",
122
+ "user_agent",
123
+ "custom_headers",
124
+ "cookies",
125
+ "output_format",
126
+ "take_screenshot",
127
+ "timeout_s",
128
+ "extra",
129
+ }
130
+ )
131
+
132
+ def __init__(self, api_key: str | None = None) -> None:
133
+ super().__init__(api_key or os.getenv("ANYSITE_API_KEY"))
134
+
135
+ async def _fetch(self, url: str, opts: ScrapeOptions) -> ScrapeResult:
136
+ """Fetch *url* via the AnySite API and return a ``ScrapeResult``."""
137
+ body = _adapt(opts, url)
138
+ headers = {
139
+ "Authorization": f"Bearer {self.api_key or ''}",
140
+ "Content-Type": "application/json",
141
+ }
142
+
143
+ async with httpx.AsyncClient(timeout=float(opts.timeout_s)) as client:
144
+ response = await client.post(_ENDPOINT, json=body, headers=headers)
145
+
146
+ # Surface upstream failures (401 bad key, 429 throttle, 5xx) — without
147
+ # this, the engine returned an empty ScrapeResult for any error and
148
+ # the router could not distinguish "blank page" from "API down".
149
+ response.raise_for_status()
150
+
151
+ payload = response.json()
152
+ data = payload.get("data", {})
153
+ meta_block = payload.get("meta", {})
154
+
155
+ raw_html: str | None = data.get("html") or None
156
+ raw_markdown: str | None = data.get("markdown") or None
157
+ screenshot_b64: str | None = data.get("screenshot_b64") or None
158
+
159
+ # Populate text and markdown from whichever form the engine returned.
160
+ # Golden rule: both slots must always be non-empty when scrape succeeds.
161
+ text_out: str
162
+ markdown_out: str
163
+ html_out: str | None = raw_html
164
+
165
+ if raw_html:
166
+ text_out, markdown_out = html_to_both(raw_html, base_url=url)
167
+ elif raw_markdown:
168
+ markdown_out = raw_markdown
169
+ text_out = markdown_to_text(raw_markdown)
170
+ html_out = None
171
+ else:
172
+ text_out = ""
173
+ markdown_out = ""
174
+ html_out = None
175
+
176
+ upstream_status = meta_block.get("status_code")
177
+
178
+ return ScrapeResult(
179
+ url=url,
180
+ text=text_out,
181
+ markdown=markdown_out,
182
+ html=html_out,
183
+ engine=self.NAME,
184
+ elapsed_ms=0, # base class fills this in
185
+ cost_usd=self.CAPABILITIES.estimated_cost_usd,
186
+ screenshot_b64=screenshot_b64,
187
+ meta={"status_code": upstream_status},
188
+ )
189
+
190
+
191
+ __all__ = ["AnySiteEngine"]