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 +67 -0
- scrapefold/cli.py +67 -0
- scrapefold/detection.py +206 -0
- scrapefold/engines/__init__.py +123 -0
- scrapefold/engines/anysite.py +191 -0
- scrapefold/engines/apify_linkedin.py +159 -0
- scrapefold/engines/base.py +213 -0
- scrapefold/engines/cloakbrowser.py +238 -0
- scrapefold/engines/cloudflare.py +225 -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 +120 -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/py.typed +0 -0
- scrapefold/result.py +95 -0
- scrapefold/router.py +386 -0
- scrapefold/vision.py +135 -0
- scrapefold-0.1.0a2.dist-info/METADATA +183 -0
- scrapefold-0.1.0a2.dist-info/RECORD +32 -0
- scrapefold-0.1.0a2.dist-info/WHEEL +4 -0
- scrapefold-0.1.0a2.dist-info/entry_points.txt +3 -0
- scrapefold-0.1.0a2.dist-info/licenses/LICENSE +21 -0
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()
|
scrapefold/detection.py
ADDED
|
@@ -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"]
|