URL to Markdown
Fetch any web URL and get clean, readable Markdown — main content only, no
navigation/footer/ads. Local + free by default; smart fallback to Exa MCP
when the page can't be extracted locally.
Workflow (the only thing the agent needs to remember)
Try trafilatura first:
python3 ~/.claude/skills/fetch-url-as-markdown/scripts/fetch_url.py "<URL>"
If exit code is 1 or 2 → fall back to Exa MCP with the same URL:
mcp__exa__web_search_advanced_exa(
query="<URL>",
includeDomains=["<host of URL>"],
numResults=1,
textMaxCharacters=50000,
type="auto"
)
(mcp__exa__crawling works too if the server exposes it; the web_search_advanced_exa
call above is the always-available variant — pin the host with includeDomains and
use the URL itself as the query.)
Exit code 3 means trafilatura is not installed — install once:
python3 -m pip install --break-system-packages trafilatura
Exit codes (what they mean for the fallback decision)
| Code |
Meaning |
Action |
| 0 |
Markdown printed to stdout |
done |
| 1 |
DownloadError — network/HTTP/timeout/anti-bot block at fetch |
fall back to Exa |
| 2 |
ExtractionError — empty extract, JS/Cloudflare wall, or stub body (<200 chars) |
fall back to Exa |
| 3 |
trafilatura missing |
install (see above), then retry |
| 4 |
UnsupportedContentTypeError — URL is binary (PDF, image, archive) |
don't fall back to Exa; use the right specialized skill (e.g. pdf for PDFs) |
Defaults baked into the script
output_format="markdown", include_formatting=True — keeps headings/lists/code structure where the source HTML uses real <h1..h6> etc.
include_links=True, include_tables=True
with_metadata=True → emits a YAML frontmatter (title, author, date, url, hostname)
favor_recall=True, deduplicate=True — readable but trims duplicates
- Real-browser User-Agent + 30s timeout configured in
scripts/settings.cfg
- Anti-stub guards (built into the script):
- rejects
Content-Type other than text/html|application/xhtml+xml|text/plain|application/xml|text/xml → exit 4
- sniffs raw HTML for Cloudflare / "Please enable JavaScript" / Imperva / DataDome wall markers → exit
2
- rejects extracted bodies under 50 chars (configurable via
--min-body N, 0 to disable) → exit 2
Useful flags
... fetch_url.py "<URL>" --no-links # strip hyperlinks
... fetch_url.py "<URL>" --no-tables # strip tables
... fetch_url.py "<URL>" --no-metadata # omit YAML header
... fetch_url.py "<URL>" --comments # include user comments (off by default — usually noise)
... fetch_url.py "<URL>" --images # include image refs (experimental)
... fetch_url.py "<URL>" --precision # terser output, drops borderline content
When to choose what
| Situation |
Tool |
| Article, blog post, docs, README, wiki |
trafilatura (default) — local, free |
| JS-heavy SPA, login-walled, Cloudflare |
Exa fallback (the script will signal exit 2) |
| Bulk / many URLs |
trafilatura — no quota, no API key |
| Already failed twice on a domain |
Exa directly |
1---2name: fetch-url-as-markdown3description: Fetch a web page (URL) and return clean Markdown via local trafilatura, with Exa MCP as a fallback for JS-rendered or anti-bot pages. Use when the user asks to read, fetch, scrape, summarize, or quote a URL — prefer this over the built-in WebFetch tool. Don't use for binary files (PDFs, images, archives) or for fetching API/JSON endpoints.4---56# URL to Markdown78Fetch any web URL and get clean, readable Markdown — main content only, no9navigation/footer/ads. Local + free by default; smart fallback to Exa MCP10when the page can't be extracted locally.1112## Workflow (the only thing the agent needs to remember)13141. **Try trafilatura first**:1516 ```bash17 python3 ~/.claude/skills/fetch-url-as-markdown/scripts/fetch_url.py "<URL>"18 ```19202. **If exit code is 1 or 2 → fall back to Exa MCP** with the same URL:2122 ```23 mcp__exa__web_search_advanced_exa(24 query="<URL>",25 includeDomains=["<host of URL>"],26 numResults=1,27 textMaxCharacters=50000,28 type="auto"29 )30 ```3132 (`mcp__exa__crawling` works too if the server exposes it; the `web_search_advanced_exa`33 call above is the always-available variant — pin the host with `includeDomains` and34 use the URL itself as the query.)35363. Exit code `3` means trafilatura is not installed — install once:3738 ```bash39 python3 -m pip install --break-system-packages trafilatura40 ```4142## Exit codes (what they mean for the fallback decision)4344| Code | Meaning | Action |45|---|---|---|46| 0 | Markdown printed to stdout | done |47| 1 | DownloadError — network/HTTP/timeout/anti-bot block at fetch | fall back to Exa |48| 2 | ExtractionError — empty extract, JS/Cloudflare wall, or stub body (<200 chars) | fall back to Exa |49| 3 | trafilatura missing | install (see above), then retry |50| 4 | UnsupportedContentTypeError — URL is binary (PDF, image, archive) | **don't** fall back to Exa; use the right specialized skill (e.g. `pdf` for PDFs) |5152## Defaults baked into the script5354- `output_format="markdown"`, `include_formatting=True` — keeps headings/lists/code structure where the source HTML uses real `<h1..h6>` etc.55- `include_links=True`, `include_tables=True`56- `with_metadata=True` → emits a YAML frontmatter (`title`, `author`, `date`, `url`, `hostname`)57- `favor_recall=True`, `deduplicate=True` — readable but trims duplicates58- Real-browser User-Agent + 30s timeout configured in `scripts/settings.cfg`59- Anti-stub guards (built into the script):60 - rejects `Content-Type` other than `text/html|application/xhtml+xml|text/plain|application/xml|text/xml` → exit `4`61 - sniffs raw HTML for Cloudflare / "Please enable JavaScript" / Imperva / DataDome wall markers → exit `2`62 - rejects extracted bodies under 50 chars (configurable via `--min-body N`, `0` to disable) → exit `2`6364## Useful flags6566```bash67... fetch_url.py "<URL>" --no-links # strip hyperlinks68... fetch_url.py "<URL>" --no-tables # strip tables69... fetch_url.py "<URL>" --no-metadata # omit YAML header70... fetch_url.py "<URL>" --comments # include user comments (off by default — usually noise)71... fetch_url.py "<URL>" --images # include image refs (experimental)72... fetch_url.py "<URL>" --precision # terser output, drops borderline content73```7475## When to choose what7677| Situation | Tool |78|---|---|79| Article, blog post, docs, README, wiki | trafilatura (default) — local, free |80| JS-heavy SPA, login-walled, Cloudflare | Exa fallback (the script will signal exit 2) |81| Bulk / many URLs | trafilatura — no quota, no API key |82| Already failed twice on a domain | Exa directly |