| title | Transition from Firecrawl to ScrapeGraph v2 |
|---|---|
| description | A practical, end-to-end guide for migrating your scraping workflows from Firecrawl to ScrapeGraph v2 |
ScrapeGraph v2 offers AI-powered scraping, extraction, search, crawling, and first-class scheduled monitoring through a unified API. If you're coming from Firecrawl, this page maps every endpoint, SDK method, parameter, and response shape to its ScrapeGraph equivalent so you can migrate quickly and confidently.
The migration is mechanical for most workloads: change a header, swap an import, and adjust one or two argument names. The places that need genuine rethinking are change tracking (now a first-class monitor resource) and browser actions (replaced by a simpler fetchConfig model).
| Capability | Firecrawl | ScrapeGraph v2 |
|---|---|---|
| Single-page scrape (markdown, html, screenshotβ¦) | POST /v2/scrape |
POST /api/scrape |
| Structured extraction (prompt + schema) | POST /v2/extract |
POST /api/extract |
| Web search with optional extraction | POST /v2/search |
POST /api/search |
| Async multi-page crawl | POST /v2/crawl β GET /v2/crawl/{id} |
POST /api/crawl β GET /api/crawl/{id} |
| URL discovery (sitemap + links) | POST /v2/map |
Use crawl.start with patterns (no one-shot map) |
| Batch scrape a list of URLs | POST /v2/batch/scrape |
Loop concurrent scrape calls, or crawl.start with a URL list |
| Change tracking | changeTracking format on scrape/crawl |
First-class monitor resource with cron scheduling (POST /api/monitor) |
| Browser interactions before scrape | actions array on /v2/scrape (click/scroll/type/wait) |
fetchConfig (mode="js", stealth, wait, scrolls) on scrape/extract/search/crawl |
| Webhooks | Crawl webhooks | Monitor + crawl webhooks (webhookUrl) |
| Async SDK | AsyncFirecrawl |
AsyncScrapeGraphAI |
| Response shape | Direct values (raises on error) | ApiResult envelope (status + data + error) |
| Firecrawl | ScrapeGraph v2 | |
|---|---|---|
| Header | Authorization: Bearer fc-... |
SGAI-APIKEY: sgai-... |
| Env var | FIRECRAWL_API_KEY |
SGAI_API_KEY |
| Base URL | https://api.firecrawl.dev/v2 |
https://v2-api.scrapegraphai.com/api |
| Key format | fc- prefix, 32-char hex |
sgai- prefix, UUID-style |
The header name is the most common source of migration bugs β SGAI-APIKEY is not a Bearer token.
| Firecrawl | ScrapeGraph v2 | |
|---|---|---|
| Python | pip install firecrawl-py |
pip install scrapegraph-py (β₯ 2.1.0, Python β₯ 3.12) |
| Node.js | npm i @mendable/firecrawl-js |
npm i scrapegraph-js (β₯ 2.1.0, Node β₯ 22) |
| CLI | npm i -g firecrawl |
npm i -g just-scrape |
| MCP server | Available | pip install scrapegraph-mcp |
# Remove Firecrawl
pip uninstall firecrawl-py # Python
npm uninstall @mendable/firecrawl-js # Node.js
# Install ScrapeGraph
pip install -U "scrapegraph-py>=2.1.0" # Python (3.12+)
npm install scrapegraph-js@latest # Node.js (22+)# Replace
# FIRECRAWL_API_KEY=fc-...
# With
SGAI_API_KEY=sgai-...Get your API key from the dashboard.
# Before (Firecrawl)
from firecrawl import Firecrawl
fc = Firecrawl(api_key="fc-...")
# After (ScrapeGraph v2)
from scrapegraph_py import ScrapeGraphAI
# reads SGAI_API_KEY from env, or pass explicitly: ScrapeGraphAI(api_key="...")
sgai = ScrapeGraphAI()// Before (Firecrawl)
import Firecrawl from "@mendable/firecrawl-js";
const fc = new Firecrawl({ apiKey: "fc-..." });
// After (ScrapeGraph v2)
import { ScrapeGraphAI } from "scrapegraph-js";
// reads SGAI_API_KEY from env, or pass explicitly: ScrapeGraphAI({ apiKey: "..." })
const sgai = new ScrapeGraphAI();Firecrawl's scrape fetches a page in one or more formats. ScrapeGraph's scrape mirrors that, with typed format configs in Python and plain objects in JS.
| Firecrawl format | ScrapeGraph format | Notes |
|---|---|---|
"markdown" |
MarkdownFormatConfig(mode="normal" | "reader" | "prune") |
reader strips chrome, prune is aggressive |
"html" |
HtmlFormatConfig(mode=...) |
Same mode options as markdown |
"rawHtml" |
HtmlFormatConfig(mode="normal") |
No separate raw variant β normal mode is the unprocessed page |
"links" |
LinksFormatConfig() |
Returns every outbound link |
"screenshot" / "screenshot@fullPage" |
ScreenshotFormatConfig(full_page=True, width=..., height=..., quality=...) |
Width 320β3840, height 200β2160, quality 1β100 |
{"type": "json", ...} |
JsonFormatConfig(prompt="...", schema={...}) |
Inline LLM extraction during scrape |
| (n/a) | ImagesFormatConfig() |
Every image URL on the page |
| (n/a) | SummaryFormatConfig() |
LLM-generated TL;DR |
| (n/a) | BrandingFormatConfig() |
Logo, palette, fonts |
{"type": "changeTracking"} |
Use monitor.create instead |
See Change tracking below |
You can request several formats in a single call β they share the page fetch, so it costs one navigation.
# Before (Firecrawl)
doc = fc.scrape("https://example.com", formats=["markdown"])
print(doc.markdown)
# After (ScrapeGraph v2 β scrapegraph-py β₯ 2.1.0)
from scrapegraph_py import MarkdownFormatConfig
res = sgai.scrape(
"https://example.com",
formats=[MarkdownFormatConfig()],
)
if res.status == "success":
print(res.data.results["markdown"]["data"][0])// Before (Firecrawl)
const doc = await fc.scrape("https://example.com", { formats: ["markdown"] });
console.log(doc.markdown);
// After (ScrapeGraph v2)
const res = await sgai.scrape({
url: "https://example.com",
formats: [{ type: "markdown" }],
});
if (res.status === "success") {
console.log(res.data?.results.markdown?.data?.[0]);
}# Before (Firecrawl)
doc = fc.scrape("https://example.com", formats=["markdown", "html", "links", "screenshot"])
print(doc.markdown, doc.html, doc.links, doc.screenshot)
# After (ScrapeGraph v2)
from scrapegraph_py import (
MarkdownFormatConfig, HtmlFormatConfig,
LinksFormatConfig, ScreenshotFormatConfig,
)
res = sgai.scrape(
"https://example.com",
formats=[
MarkdownFormatConfig(),
HtmlFormatConfig(mode="reader"),
LinksFormatConfig(),
ScreenshotFormatConfig(full_page=True, width=1440, height=900),
],
)
results = res.data.results
print(results["markdown"]["data"][0])
print(results["html"]["data"][0])
print(results["links"]["data"])
print(results["screenshot"]["data"][0]) # base64 PNG// After (ScrapeGraph v2)
const res = await sgai.scrape({
url: "https://example.com",
formats: [
{ type: "markdown" },
{ type: "html", mode: "reader" },
{ type: "links" },
{ type: "screenshot", fullPage: true, width: 1440, height: 900 },
],
});
const r = res.data?.results;
console.log(r?.markdown?.data?.[0]);
console.log(r?.screenshot?.data?.[0]); // base64 PNGFirecrawl exposes an actions array (click, scroll, wait, type, press, screenshot) executed before the page is captured. ScrapeGraph replaces this with a declarative fetchConfig:
| Firecrawl action | ScrapeGraph equivalent |
|---|---|
{"type": "wait", "milliseconds": 2000} |
fetch_config=FetchConfig(wait=2000) |
{"type": "scroll", ...} (repeated) |
fetch_config=FetchConfig(scrolls=5) |
{"type": "click", "selector": "..."} |
Not supported β split into two scrapes, or use a webhook-driven workflow |
{"type": "screenshot"} |
Add ScreenshotFormatConfig() to formats |
| Mobile / desktop UA toggle | headers={"User-Agent": "..."} |
| Geolocation / proxy region | country="US" (ISO 3166-1 alpha-2) |
fetchConfig accepts: mode ("auto" / "fast" / "js"), stealth (bool, residential proxy + anti-bot headers), headers, cookies, scrolls (0β100), wait (0β30000 ms), timeout (1000β60000 ms), country (2-letter ISO code).
# Before (Firecrawl β actions array)
doc = fc.scrape(
"https://example.com",
formats=["markdown"],
actions=[
{"type": "wait", "milliseconds": 2000},
{"type": "scroll", "direction": "down"},
{"type": "scroll", "direction": "down"},
],
)
# After (ScrapeGraph v2 β declarative fetchConfig)
from scrapegraph_py import MarkdownFormatConfig, FetchConfig
res = sgai.scrape(
"https://example.com",
formats=[MarkdownFormatConfig()],
fetch_config=FetchConfig(
mode="js", # render JavaScript
stealth=True, # rotate residential proxy
wait=2000, # ms after navigation
scrolls=2, # programmatic scroll ticks
country="US",
),
)// After (ScrapeGraph v2)
const res = await sgai.scrape({
url: "https://example.com",
formats: [{ type: "markdown" }],
fetchConfig: {
mode: "js",
stealth: true,
wait: 2000,
scrolls: 2,
country: "US",
},
});Same shape: URL + natural-language prompt + optional JSON schema. ScrapeGraph also accepts inline html or markdown instead of a URL β useful when you already have the content.
# Before (Firecrawl)
result = fc.extract(
urls=["https://example.com"],
prompt="Extract the main heading",
schema={"type": "object", "properties": {"title": {"type": "string"}}},
)
# After (ScrapeGraph v2 β scrapegraph-py β₯ 2.1.0)
res = sgai.extract(
"Extract the main heading",
url="https://example.com",
schema={"type": "object", "properties": {"title": {"type": "string"}}},
)
if res.status == "success":
print(res.data.json_data)// Before (Firecrawl)
const result = await fc.extract({
urls: ["https://example.com"],
prompt: "Extract the main heading",
schema: { type: "object", properties: { title: { type: "string" } } },
});
// After (ScrapeGraph v2)
const res = await sgai.extract({
url: "https://example.com",
prompt: "Extract the main heading",
schema: { type: "object", properties: { title: { type: "string" } } },
});
if (res.status === "success") {
console.log(res.data?.json);
}scrapegraph-py accepts any dict that conforms to JSON Schema, so Pydantic models work via model_json_schema():
from pydantic import BaseModel, Field
from scrapegraph_py import ScrapeGraphAI
class Product(BaseModel):
name: str
price_usd: float = Field(description="Price in US dollars")
in_stock: bool
sgai = ScrapeGraphAI()
res = sgai.extract(
"Extract product details",
url="https://example.com/product/42",
schema=Product.model_json_schema(),
)
if res.status == "success":
product = Product.model_validate(res.data.json_data)
print(product.name, product.price_usd)Skip the fetch when you already have the content (e.g., a cached page, an internal CMS document):
res = sgai.extract(
"Extract the author and publication date",
html="<html>...</html>", # or markdown="# Article\n..."
schema={"type": "object", "properties": {
"author": {"type": "string"},
"published_at": {"type": "string", "format": "date-time"},
}},
)Firecrawl accepts a list of URLs or wildcards in one call. On ScrapeGraph, call extract once per URL (run them concurrently) or use crawl.start to discover pages first and then extract from each.
ScrapeGraph's search supports the same query-and-limit pattern, plus optional LLM extraction in a single call (Firecrawl's scrapeOptions parameter).
# Before (Firecrawl)
hits = fc.search(query="best programming languages 2026", limit=5)
# After (ScrapeGraph v2 β scrapegraph-py β₯ 2.1.0)
res = sgai.search(
"best programming languages 2026",
num_results=5,
)
if res.status == "success":
for r in res.data.results:
print(r.title, "-", r.url)// Before (Firecrawl)
const hits = await fc.search({ query: "best programming languages 2026", limit: 5 });
// After (ScrapeGraph v2)
const res = await sgai.search({
query: "best programming languages 2026",
numResults: 5,
});
if (res.status === "success") {
for (const r of res.data?.results ?? []) console.log(r.title, "-", r.url);
}Firecrawl exposes scrapeOptions to scrape each result; ScrapeGraph fuses search and structured extraction with a prompt + schema:
res = sgai.search(
"open-source vector databases",
num_results=10,
prompt="Extract the project name, GitHub URL, and primary license",
schema={
"type": "object",
"properties": {
"name": {"type": "string"},
"github_url": {"type": "string"},
"license": {"type": "string"},
},
},
)| Firecrawl | ScrapeGraph (Python) | ScrapeGraph (JS) |
|---|---|---|
query |
query (positional) |
query |
limit |
num_results (1β20) |
numResults |
tbs (time filter) |
time_range="past_hour" | "past_24_hours" | "past_week" | "past_month" | "past_year" |
timeRange |
location |
location_geo_code (ISO country code) |
locationGeoCode |
scrapeOptions.formats |
format="markdown" | "html" + mode |
format + mode |
scrapeOptions (full page scrape) |
prompt + schema for inline extraction |
same |
sources=["web","news","images"] |
Web only (use time_range for recency) |
same |
Firecrawl's crawl() blocks until completion; start_crawl() returns a job id. ScrapeGraph's crawl is always async β start, then poll (or stop, resume, delete).
# Before (Firecrawl β blocking)
job = fc.crawl("https://example.com", limit=50)
# Or non-blocking:
started = fc.start_crawl("https://example.com", limit=50)
status = fc.get_crawl_status(started.id)
# After (ScrapeGraph v2 β scrapegraph-py β₯ 2.1.0)
start = sgai.crawl.start(
"https://example.com",
max_depth=2,
max_pages=50,
include_patterns=["/blog/*"],
exclude_patterns=["/admin/*"],
)
status = sgai.crawl.get(start.data.id)
print(status.data.status, status.data.finished, "/", status.data.total)// Before (Firecrawl)
const job = await fc.crawl("https://example.com", { limit: 50 });
// Or non-blocking:
const started = await fc.startCrawl("https://example.com", { limit: 50 });
const status = await fc.getCrawlStatus(started.id);
// After (ScrapeGraph v2)
const start = await sgai.crawl.start({
url: "https://example.com",
maxDepth: 2,
maxPages: 50,
includePatterns: ["/blog/*"],
excludePatterns: ["/admin/*"],
});
const status = await sgai.crawl.get(start.data.id);Attach a JsonFormatConfig to every crawled page so each result already has structured fields:
from scrapegraph_py import MarkdownFormatConfig, JsonFormatConfig
start = sgai.crawl.start(
"https://docs.example.com",
max_depth=3,
max_pages=200,
formats=[
MarkdownFormatConfig(mode="reader"),
JsonFormatConfig(
prompt="Extract the page title and the list of code samples",
schema={
"type": "object",
"properties": {
"title": {"type": "string"},
"code_samples": {"type": "array", "items": {"type": "string"}},
},
},
),
],
)| Firecrawl | ScrapeGraph (Python) | ScrapeGraph (JS) |
|---|---|---|
limit |
max_pages (1β1000, default 50) |
maxPages |
maxDepth |
max_depth (default 2) |
maxDepth |
maxDiscoveryDepth |
n/a β use max_depth |
n/a |
includePaths |
include_patterns (glob) |
includePatterns |
excludePaths |
exclude_patterns (glob) |
excludePatterns |
allowExternalLinks |
allow_external (default false) |
allowExternal |
allowBackwardLinks |
always allowed inside max_depth |
same |
webhook |
Not on crawl β use a monitor for delivery |
n/a |
scrapeOptions.formats |
formats=[...] |
formats |
# Pause an in-flight crawl
sgai.crawl.stop(start.data.id)
# Resume it later
sgai.crawl.resume(start.data.id)
# Drop a finished crawl and free retained pages
sgai.crawl.delete(start.data.id)await sgai.crawl.stop(id);
await sgai.crawl.resume(id);
await sgai.crawl.delete(id);Firecrawl's /map returns a list of URLs quickly. ScrapeGraph doesn't have a one-shot map; use crawl.start with pattern filters and a shallow max_depth to discover URLs cheaply:
from scrapegraph_py import LinksFormatConfig
start = sgai.crawl.start(
"https://example.com",
max_depth=1,
max_pages=500,
max_links_per_page=50,
include_patterns=["/docs/*", "/blog/*"],
formats=[LinksFormatConfig()], # cheapest format β just URL discovery
)
status = sgai.crawl.get(start.data.id)
urls = [p.url for p in status.data.pages]For batch scraping a fixed list of URLs, fan out concurrent scrape calls β the SDK's AsyncScrapeGraphAI is the easiest path (see Async / concurrency below).
Firecrawl ships change tracking as a changeTracking format bolted onto scrape/crawl. ScrapeGraph makes monitoring a first-class resource with cron scheduling, webhook delivery, and a queryable activity log.
# Before (Firecrawl β add changeTracking to formats)
doc = fc.scrape(
"https://example.com",
formats=["markdown", {"type": "changeTracking", "modes": ["git-diff"], "tag": "hourly"}],
)
# After (ScrapeGraph v2 β scheduled monitor, scrapegraph-py β₯ 2.1.0)
from scrapegraph_py import MarkdownFormatConfig
res = sgai.monitor.create(
"https://example.com",
"*/30 * * * *", # cron expression (positional)
name="Homepage watch",
formats=[MarkdownFormatConfig()],
webhook_url="https://your-app.example.com/hooks/sgai",
)
cron_id = res.data.cron_id// Before (Firecrawl)
const doc = await fc.scrape("https://example.com", {
formats: ["markdown", { type: "changeTracking", modes: ["git-diff"], tag: "hourly" }],
});
// After (ScrapeGraph v2)
const res = await sgai.monitor.create({
url: "https://example.com",
name: "Homepage watch",
interval: "*/30 * * * *",
formats: [{ type: "markdown" }],
webhookUrl: "https://your-app.example.com/hooks/sgai",
});
const cronId = res.data?.cronId;| Operation | Python | JavaScript |
|---|---|---|
| List all monitors | sgai.monitor.list() |
sgai.monitor.list() |
| Get one | sgai.monitor.get(cron_id) |
sgai.monitor.get(cronId) |
| Update | sgai.monitor.update(cron_id, interval="0 * * * *") |
sgai.monitor.update(cronId, { interval: ... }) |
| Pause / resume | sgai.monitor.pause(cron_id) / .resume(cron_id) |
same |
| Recent ticks | sgai.monitor.activity(cron_id) |
same |
| Delete | sgai.monitor.delete(cron_id) |
same |
Each tick in monitor.activity returns status, created_at, elapsed_ms, plus a changed flag and a diffs field when content has moved since the previous run β same job as Firecrawl's git-diff mode, persisted by ScrapeGraph for you.
Both SDKs ship an async client. The shape is identical β just await every call.
import asyncio
from scrapegraph_py import AsyncScrapeGraphAI, MarkdownFormatConfig
async def fetch_many(urls):
async with AsyncScrapeGraphAI() as sgai:
return await asyncio.gather(*[
sgai.scrape(u, formats=[MarkdownFormatConfig()]) for u in urls
])
results = asyncio.run(fetch_many([
"https://example.com",
"https://example.org",
]))// The default `ScrapeGraphAI` client is already promise-based.
const urls = ["https://example.com", "https://example.org"];
const results = await Promise.all(urls.map((url) =>
sgai.scrape({ url, formats: [{ type: "markdown" }] })
));The ScrapeGraphAI Python and JS SDKs wrap every response in an ApiResult β no exceptions to catch on HTTP errors. Check status before reading data:
result = sgai.extract("...", url="https://example.com")
if result.status == "success":
data = result.data.json_data
else:
print(f"Error: {result.error}")const result = await sgai.extract({ url: "https://example.com", prompt: "..." });
if (result.status === "success") {
console.log(result.data?.json);
} else {
console.error(result.error);
}Direct HTTP callers (curl, fetch) receive the unwrapped response body β the envelope is applied client-side by the SDKs.
| Field | Type | Notes |
|---|---|---|
status |
"success" | "error" |
Always set |
data |
T | None |
The endpoint's normal response body when status == "success" |
error |
str | None |
Present when status == "error" |
elapsed_ms (Py) / elapsedMs (JS) |
int |
Client-measured round-trip time |
Firecrawl raises exceptions on HTTP errors; ScrapeGraph returns a non-success ApiResult. The HTTP status codes map cleanly:
| HTTP | ScrapeGraph error type | Retryable? | Typical cause |
|---|---|---|---|
| 400 | validation (with details[]) |
No | Bad request body |
| 401 | auth_missing_key |
No | SGAI-APIKEY header missing |
| 402 | insufficient_credits |
No | Top up at the dashboard |
| 403 | auth_invalid_key |
No | Key revoked or malformed |
| 404 | not_found |
No | Wrong endpoint or unknown job id |
| 429 | rate_limited |
Yes (backoff) | SDKs already retry with backoff |
| 5xx | internal_error |
Yes (backoff) | Transient β SDKs retry |
A defensive wrapper looks the same as the one you wrote around Firecrawl, with one fewer except branch:
res = sgai.scrape("https://example.com", formats=[MarkdownFormatConfig()])
if res.status != "success":
raise RuntimeError(f"scrape failed: {res.error}")
markdown = res.data.results["markdown"]["data"][0]Run your existing test suite and compare outputs. ScrapeGraph returns equivalent data structures β the main differences are:
- The
ApiResultenvelope in the SDKs (no exceptions on error) - The split
crawl.start/crawl.getflow (always async) - The dedicated
monitorresource in place of change-tracking formats fetchConfig(declarative) in place ofactions(imperative)
A quick equivalence script for a single URL:
from firecrawl import Firecrawl
from scrapegraph_py import ScrapeGraphAI, MarkdownFormatConfig
fc = Firecrawl()
sgai = ScrapeGraphAI()
URL = "https://example.com"
fc_md = fc.scrape(URL, formats=["markdown"]).markdown
sgai_md = sgai.scrape(URL, formats=[MarkdownFormatConfig()]).data.results["markdown"]["data"][0]
print("len(firecrawl)=", len(fc_md), "len(scrapegraph)=", len(sgai_md))curl -X POST https://v2-api.scrapegraphai.com/api/scrape \
-H "SGAI-APIKEY: $SGAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","formats":[{"type":"markdown"}]}'Response (note: no ApiResult envelope on the raw HTTP endpoint β the SDKs add that client-side):
{
"id": "3b1c81d9-3f3b-42b0-9cf7-6926d9ebc7f5",
"results": {
"markdown": { "data": ["# Example Domain\n\n..."] }
},
"metadata": { "contentType": "text/html" }
}- Header name. It's
SGAI-APIKEY: sgai-..., notAuthorization: Bearer .... Watch for proxies that normalize header casing β the API tolerates any case, but some HTTP libraries strip non-standard headers in redirects. schemafield name in Python.JsonFormatConfigandextractuseschema=(the field is internally aliased fromschema_to avoid shadowing theBaseModel.schemamethod β passschema=from your code and it works).- No
actionsarray. If you relied onclick/type/pressactions, you'll need to either split the flow into two scrapes (one to trigger a navigation that produces a stable URL, one to scrape the result) or contact support about the upcominginteractionsAPI. - Crawl is always async. There is no blocking
sgai.crawl(...)β callcrawl.startand poll, or pass awebhookUrlvia amonitorinstead. changeTrackingis gone as a format. Usemonitor.createβ it gets you cron scheduling, persistent history, and webhook delivery in one resource.- Response shape per format. Each requested format lives under
results[<format>].data(always an array). For most formats the array has one element; forlinksandimagesit's the full list. numResultscaps at 20 for search. Firecrawl'slimitaccepts higher values β split the query (e.g., bytimeRange) if you need more.