Skip to main content
Glama
README.md
<!-- mcp-name: io.github.yail259/frankensurf -->

<p align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/yail259/frankensurf/main/site/src/assets/stitched-wave-mint.svg">
    <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/yail259/frankensurf/main/site/src/assets/stitched-wave-ink.svg">
    <img src="https://raw.githubusercontent.com/yail259/frankensurf/main/site/src/assets/stitched-wave-ink.svg" alt="Frankensurf" width="120">
  </picture>
</p>

<h1 align="center">Frankensurf</h1>

<p align="center">
  <strong>Access 96% of the web, cheaply and accurately.</strong><br>
  A free, self-hosted alternative to Firecrawl: one container, the same API, and every page checked before your agent reads it.
</p>

<p align="center">
  <a href="https://pypi.org/project/frankensurf/"><img alt="PyPI" src="https://img.shields.io/pypi/v/frankensurf?style=flat-square&logo=pypi&logoColor=white&label=pypi&color=blue"></a>
  <a href="https://pypi.org/project/frankensurf/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/frankensurf?style=flat-square&logo=python&logoColor=white"></a>
  <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-green?style=flat-square"></a>
  <a href="https://github.com/yail259/frankensurf/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/yail259/frankensurf?style=flat-square&logo=github&color=lightgrey"></a>
  <a href="https://frankensurf.dev/docs/agents/"><img alt="MCP server" src="https://img.shields.io/badge/MCP-server-8A2BE2?style=flat-square"></a>
</p>

<p align="center">
  <a href="https://frankensurf.dev"><strong>Website</strong></a> ·
  <a href="https://frankensurf.dev/docs/"><strong>Docs</strong></a> ·
  <a href="#quick-start"><strong>Quick start</strong></a> ·
  <a href="#benchmark"><strong>Benchmark</strong></a> ·
  <a href="https://frankensurf.dev/blog/i-just-wanted-a-cheap-film-camera/"><strong>Why I built it</strong></a>
</p>

---

Most scrapers count a 200 as a success, so your agent ends up reading challenge pages, empty app
shells and menus, and answering anyway. Frankensurf reads each page with the cheapest tool that
works, checks it's the real page, and climbs to stealth browsers (or paid APIs, if you allow them)
only when a site pushes back. It speaks Firecrawl's API, so anything with a Firecrawl URL setting
can point at it.

<table>
  <tr>
    <td width="33%" valign="top"><strong>Gets past walls</strong><br><sub>41 web tools behind one call: plain HTTP, Jina Reader, browsers, free stealth browsers, then paid APIs only if you allow them.</sub></td>
    <td width="33%" valign="top"><strong>Every page checked</strong><br><sub>A challenge page, a login wall or a search page whose results never loaded counts as a failure, with a receipt saying why.</sub></td>
    <td width="33%" valign="top"><strong>Drop-in for Firecrawl</strong><br><sub>Same API, one Docker container. Check it on your own URLs first with <code>frankensurf compare</code>.</sub></td>
  </tr>
</table>

<p align="center">
  <img src="https://frankensurf.dev/blog/head-to-head-set7.png" alt="Share of 150 sites read correctly: Frankensurf 96%, Zyte 86%, Firecrawl 85%, free tools only 88%" width="820">
</p>

On 150 sites picked blind: 96% read right with paid fallbacks against 85% for Firecrawl's cloud on its
own, and 88% with free tools only. On a harder blind set of 148 less-known sites: 88.5% against 70.3%,
and 81.8% free. Against a self-hosted Firecrawl on the same sites, 126 read against
89 ([details](#against-self-hosted-firecrawl), losses included).

## Quick start

**As a Firecrawl-compatible server** (one container, free tools inside, no account needed):

```bash
docker run -d -p 3002:3002 --shm-size=1g -e FRANKENSURF_API_KEY=pick-a-key ghcr.io/yail259/frankensurf
```

Then point your tool's Firecrawl URL at `http://localhost:3002` with that key
([which tools, and how](https://frankensurf.dev/docs/firecrawl-compatible/)).

**As a Python package and CLI** (Python 3.12+, on Linux, macOS, or Windows through WSL):

```bash
pip install "frankensurf[mcp]"
frankensurf install        # every free tool: browsers, stealth browsers, local search, Crawl4AI
frankensurf read "https://www.walmart.com/search?q=air+fryer" --explain
```

The read climbs until it gets the real page:

```text
https://www.walmart.com/search?q=air+fryer
  ✗ http                   CAPTCHA                0.4s
  ✗ local                  CAPTCHA                1.5s
  ✓ camoufox               got the page           15.6s

air fryer - Walmart.com
24,192 characters via camoufox, complete (107 results), free
```

`frankensurf install` works like `playwright install`: name just the tools you want
(`frankensurf install search chromium`), add `--with-deps` for the browsers' system libraries on
Linux, `--list` to see what's installed where, and `frankensurf uninstall all` to remove it. No
Docker needed. Each release is installed from PyPI on fresh Ubuntu and macOS machines and checked.

## Use it

**From an agent**, as an MCP server ([setup for each client](https://frankensurf.dev/docs/agents/)):

```bash
claude mcp add frankensurf -- frankensurf-mcp
```

**From Python:**

```python
import frankensurf as fs

page = fs.read("https://example.com/pricing")
if page.ok:
    print(page.title, page.text[:500])
else:
    print("couldn't read it:", page.failure)    # {"code": "CAPTCHA", "message": ...}

pages = fs.batch(urls, links=True)               # paced per site, in order
hits = fs.search("playwright python tutorial", recency="month")
page = await fs.aread(url)                       # aread, abatch, asearch inside async code
```

For a long-running service, keep one `Runtime` open: `async with Runtime() as web: await web.read(url)`.

**Map and crawl:** `fs.map(url)` lists a site's URLs; `fs.crawl(url, limit=50)` reads a section of it as markdown.

**Agent mode** (`pip install "frankensurf[agent]"`, any LiteLLM model): `fs.ask("question")` searches, reads and
answers with sources, and says what it couldn't read; `fs.extract(urls, schema=...)` and `fs.summarize(url)`.
[Agent mode](https://frankensurf.dev/docs/agent-mode/).

**In Docker**, one install for your whole network: `docker run -p 3002:3002 -e FRANKENSURF_API_KEY=... ghcr.io/yail259/frankensurf`
([Docker](https://frankensurf.dev/docs/docker/)).

**From any tool that takes a Firecrawl URL** (GPT Researcher, Hermes Agent, OpenClaw, Open WebUI, Dify, LibreChat, n8n, LangChain, LlamaIndex and others):
`frankensurf serve`, then point it at `http://127.0.0.1:3002` ([setup](https://frankensurf.dev/docs/firecrawl-compatible/)).

**From the command line:**

```bash
frankensurf read https://example.com --markdown
frankensurf search playwright python tutorial --recency week
frankensurf watch https://news.ycombinator.com/ --link-pattern 'item\?id=\d+'
```

**In Claude Code as a plugin** (the MCP server plus the skill that teaches Claude to use it):

```
/plugin marketplace add yail259/frankensurf
/plugin install frankensurf@frankensurf
```

**From LangChain, the OpenAI Agents SDK or CrewAI** (`pip install "frankensurf[langchain]"`, and so on):

```python
from frankensurf.agent_tools import langchain_tools   # or openai_agents_tools, crewai_tools
tools = langchain_tools()                              # frankensurf_read, frankensurf_search
```

You get raw page data, not answers: text or markdown, JSON-LD, the page's own JSON, images, and
the receipt. Your agent decides what the page means. If it isn't what it wanted, it asks again with
`retry_of=<trace_id>` and Frankensurf skips every tool it already tried.

## Benchmark

**Cost per 1,000 correct pages:** Frankensurf's free tools $0 in fees, about $0.01 of a $5/month
server (87–88% right); with paid fallbacks $0.22 (96%); Firecrawl on its own $0.71 on its Scale plan,
$0.98 on Standard (85%).
[How that's worked out](https://frankensurf.dev/docs/more/cost/).

`scripts/toolbench.py` runs each tool on its own, then Frankensurf, on the same pages with the same
content checks. The newest set: 148 less-known sites in 15 categories, picked before any was read,
every tool on its own, paid ones included (28 October 2026, rows and corrections in
[`benchmarks/2026-10-28-heldout9/`](benchmarks/2026-10-28-heldout9/)).

| Tool | Sites read | Median | Cost per 1k |
| --- | ---: | ---: | ---: |
| Plain HTTP only | 41.9% | 0.9 s | free |
| Jina Reader only | 68.2% | 3.3 s | free |
| Firecrawl only | 70.3% | 4.3 s | $5.22 |
| Zyte only | 70.3% | 9.5 s | not reported |
| Frankensurf, free tools only | 81.8% | 4.8 s | $0 |
| **Frankensurf, with paid fallbacks** | **88.5%** | **4.9 s** | **$0.96** |

- Frankensurf read 28 sites Firecrawl missed; Firecrawl read 1 it missed.
- Every tool's reads put together come to 84.5%; Frankensurf reads more than that, through retries
  and rescues.
- Its slowest tenth takes 33.0 s against Firecrawl's 10.9 s.
- This set is harder than the last: every tool read 10 to 20 points less. A rerun the same evening
  gave 87.2% and 79.7%.

The set before (150 sites, 11 October 2026, rows in [`benchmarks/2026-10-11/`](benchmarks/2026-10-11/)):

| Tool | Sites read | Median | Cost per 1k |
| --- | ---: | ---: | ---: |
| Plain HTTP only | 48.7% | 1.2 s | free |
| Camoufox only | 68.7% | 10.9 s | free |
| Jina Reader only | 73.3% | 1.5 s | free |
| Firecrawl only | 84.7% | 4.6 s | $4.28 |
| Zyte only | 86.0% | 10.7 s | not reported |
| Frankensurf, free tools only | 88.0% | 3.6 s | $0 |
| **Frankensurf, with paid fallbacks** | **96.0%** | **2.9 s** | **$0.98** |

- Frankensurf read 17 sites Firecrawl missed; Firecrawl read none it missed.
- With no paid tools at all it is level with the best paid tool on its own (88.0% against 86.0% and
  84.7%; at 150 sites that gap is within the noise).
- Its slowest reads are slower: the slowest tenth takes 25.7 s against Firecrawl's 10.6 s, when a
  read climbs several tools.
- Not a one-off: 95.3% vs 85.3% on the set the day before; 89.5% vs 80.3% and 88.5% vs 74.1% on
  earlier fresh sets.

<details>
<summary>Every tool on the 11 October set</summary>

| Tool | Sites read | Median | Cost per 1k |
| --- | ---: | ---: | ---: |
| Patchright only | 38.7% | 4.1 s | free |
| Headless Chromium only | 40.7% | 2.7 s | free |
| Plain HTTP only | 48.7% | 1.2 s | free |
| Camoufox only | 68.7% | 10.9 s | free |
| Scrapling only | 68.7% | 8.5 s | free |
| Jina Reader only | 73.3% | 1.5 s | free |
| Firecrawl only | 84.7% | 4.6 s | $4.28 |
| Zyte only | 86.0% | 10.7 s | not reported |
| Frankensurf, free tools only | 88.0% | 3.6 s | $0 |
| Frankensurf, with paid fallbacks | 96.0% | 2.9 s | $0.98 |

Medians are over pages read; cost is per 1,000 pages read right. ZenRows and Scrapfly are left out
(their plans ran out of credits). From a fresh cloud VM, whose data-centre address sites wall more
often: free tools 82.0%, lightning mode 84.0% at a 1.2 s median.
See [Benchmarks](https://frankensurf.dev/docs/more/benchmarks/).
</details>

### Against self-hosted Firecrawl

Firecrawl is open source too, so the fair question for anyone running their own is how the Firecrawl
you'd self-host does. Its docs say self-hosted instances don't get Fire-engine, the anti-bot layer of
its cloud. Same 150 sites, 23 October 2026, both servers in Docker on one machine, each site read by
both with the first reader alternating site by site:

| | Self-hosted Firecrawl 2.11.604 | Frankensurf 0.52.0 (free tools) |
| --- | ---: | ---: |
| Pages read right | 89 of 150 | **126 of 150** |
| Wrong page passed on as the page | 18 | **5** |
| Nothing back | 43 | 19 |
| Memory while reading | about 4.2 GB, six containers | **1.4 GB, one container** |
| Median, pages both read | **4.2 s** | 4.8 s |
| Slowest tenth, pages both read | **9.8 s** | 17.8 s |
| Time to give up on a page (median) | **1.5 s** | 16.5 s |

- Frankensurf read 38 sites the self-hosted Firecrawl didn't; Firecrawl read 1 it didn't.
- The slower tail and slower give-ups are the cost of the extra pages: where self-hosted Firecrawl
  tries once and stops, Frankensurf climbs to stealth browsers and checks each page before handing it
  back.
- Rows, scripts and setup: [`benchmarks/2026-10-23-self-hosted-firecrawl/`](benchmarks/2026-10-23-self-hosted-firecrawl/).
  Run it on your own pages with `frankensurf compare urls.txt --against http://localhost:3002`.

## Search

`search` uses free sources first and checks every result: on topic, the right kind of page, inside
the date window. Exa, Brave, Tavily, Serper and other paid sources join only when you allow paid
tools. Judged blind (results pooled and shuffled, which tool found them hidden), useful results in
the top five:

| Blind set 7 (120 queries) | Useful results (of 5) | Cost per 1k |
| --- | ---: | ---: |
| Frankensurf, paid allowed | 4.67 | $9.38 |
| Exa only | 4.66 | $9.50 |
| Frankensurf, free (with the local search engine) | 3.83 | $0 |

Free, it beats Firecrawl's search: better on 35 queries, worse on 5. With paid allowed it is level
with Exa. The local search engine (`frankensurf install search`, SearXNG without Docker) makes the
free tier much stronger. v0.41 improved free search again: on a fresh blind set of 120
queries it beat v0.40 on 41 and lost on 12 (3.42 against 2.71 useful results of 5,
[`benchmarks/2026-10-14-search-v041/`](benchmarks/2026-10-14-search-v041/README.md)). Details in
[`benchmarks/2026-10-13-search/`](benchmarks/2026-10-13-search/README.md).

## What else is in the box

<details>
<summary><strong>Reading</strong>: quality grades, main content, racing slow reads, PDFs</summary>

- Every read carries `receipt.quality`: `good`, `partial` or `poor`, with flags such as `paywall`,
  `menus` or `placeholder`, so an agent knows when a page didn't really come through.
- `--schema product.json` (MCP `schema`) fills a JSON Schema from the page's JSON-LD, microdata,
  meta tags and own JSON into `extracted`, and climbs to a stronger tool while a required field is
  missing ([Structured fields](https://frankensurf.dev/docs/read/structured/)).
- `--main` (MCP `main_content=True`) adds `main_text`: the article without menus, footers and
  banners (best with `pip install "frankensurf[main]"`).
- A read with no page after 3 seconds starts two more free tools beside it and keeps the first
  complete page (`--lightning` races from the start).
- A URL that answers "not found" is looked up with the site's own search; `receipt.rescued` says so.
- Still walled? Allow a real Chrome on your machine (`--real-browser`, with its own profile, never
  yours) or a second connection (`FRANKENSURF_EGRESS`).
</details>

<details>
<summary><strong>Search</strong>: verticals, boolean queries, local engine</summary>

- `--site`, `--exclude-domain`, `--recency` and `--region` on every source, and a `--vertical`
  (news, reference, discussions, qa, code, papers, books) backed by official free APIs.
- Monitoring-style queries (`"Acme" OR "Acme Corp" (acquisition OR layoffs)`) are judged by the
  name they're about, and a weak answer is retried once with just the name.
- `--mode merge` asks every source at once and fuses the results.
- `frankensurf search-engine start|stop|status` manages the local SearXNG; search restarts it after
  a reboot.
</details>

<details>
<summary><strong>Agents</strong>: skill, profiles, handoff, site modules</summary>

- A bundled skill teaches agents the tools: the MCP server sends it as its instructions, and
  `frankensurf skill install` adds it to `~/.claude/skills`.
- **Profiles:** log in once and any tool can use the session; the model never sees cookies or keys.
- **Human handoff:** for a CAPTCHA or 2FA only a person can clear, it opens the page for you, then
  carries on.
- **Site modules:** save what your agent learns about a site as data. `frankensurf module discover
  URL --query "desk lamp" --save` finds the site's search, reads the results and saves a module;
  after that, searches on that site come back as structured `items`.
- **Shared site knowledge:** `frankensurf share sync` starts from which tool reads which site, as the
  community learned it; `share export` writes yours to review and contribute (opt in both ways).
- `frankensurf watch-sites URL… --since DATE` lists new pages from the feeds and sitemaps sites
  publish.
</details>

<details>
<summary><strong>Paid tools and budgets</strong></summary>

Everything works with no keys. Paid tools are opt-in and only come in once a site has walled a free
tool, cheapest likely success first. Set their keys (for example `FIRECRAWL_API_KEY`) and pass
`--paid`; `--max-cost USD` caps a read. From Python: `allow_paid_fallbacks` and `max_cost_usd`.
See [Paid tools and budgets](https://frankensurf.dev/docs/blocked/paid-tools/).
</details>

## How it works

Every way to fetch a page is a rung: plain HTTP and markdown negotiation, browsers, stealth
browsers and signed requests, hosted browsers, paid APIs, and finally a person clearing the wall in
a visible browser. A read starts at the cheapest rung and climbs only when a page pushes back. Every
tool is a plugin, so new ones slot in as another rung.

<p align="center">
  <img src="https://frankensurf.dev/blog/fallback-chain.png" alt="One read of Crunchbase: plain HTTP, headless Chrome, Steel and Camoufox were blocked; Firecrawl got through" width="760">
</p>

See [How escalation works](https://frankensurf.dev/docs/blocked/escalation/) and the
[full list of integrations](https://frankensurf.dev/docs/integrations/).

## Known walls

It's an alpha, and some sites still win:

- **Need the paid tools:** in a free-only spot check, Etsy, Home Depot, Yelp, Crunchbase and G2
  blocked every free tool.
- **Beat everything so far:** Shopee, Temu and Idealista.
- **Social sites without a login:** LinkedIn, X, YouTube, Threads, Bluesky and Reddit read fine.
  Instagram, TikTok, Facebook and Pinterest show only the public shell; the posts need a login.
- **Logged-in pages** (your feeds, your orders) need a
  [profile](https://frankensurf.dev/docs/read/profiles/).
- **A slow tail:** most reads take a few seconds, but one in ten climbs several tools and takes 25 s
  or more.

Found a site it can't read?
[Report it](https://github.com/yail259/frankensurf/issues/new?template=site-it-cant-read.yml); every
report becomes a test case.

## Develop

```bash
python3 -m venv .venv
.venv/bin/pip install -e '.[test,mcp]'
.venv/bin/frankensurf install
PYTHONPATH=.:src .venv/bin/pytest -q
```

The product design is in `SPEC.rst` and contributor rules in `AGENTS.md`. New tools are plugins,
never branches in Core: see [Writing a plugin](https://frankensurf.dev/docs/more/plugins/) and the
example in `examples/frankensurf-plugin-example`. The website and docs live in `site/`.

## Licence

Apache-2.0. See [`LICENSE`](LICENSE).