Skip to content

Commit b91b724

Browse files
authored
Merge pull request #6 from vasylenko/fix/http1-fallback-on-cloudflare-block
Default to HTTP/1.1 transport + add --raw passthrough
2 parents bab7251 + 5afb458 commit b91b724

12 files changed

Lines changed: 239 additions & 69 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.7.0] - 2026-07-06
11+
12+
### Added
13+
- `--raw` (CLI) / `raw` (MCP): return the unprocessed body verbatim — no Readability, no content-type gate; `MARKFETCH_MAX_BYTES` still applies.
14+
15+
### Fixed
16+
- Cloudflare-class CDNs 403 valid Chrome headers over HTTP/2 but pass them over HTTP/1.1 — markfetch now defaults to HTTP/1.1.
17+
- Empty-heading pruning truncated headings containing an inline `#` (`## Step 1 # download` became `# download`).
18+
1019
## [0.6.0] - 2026-05-14
1120

1221
### Added

README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[![node](https://img.shields.io/node/v/markfetch.svg?color=10b981)](https://nodejs.org/)
88
[![license](https://img.shields.io/npm/l/markfetch.svg?color=10b981)](https://github.com/vasylenko/markfetch/blob/main/LICENSE)
99

10-
The built-in fetch tools that ship with AI coding agents return raw HTML, broken markdown, or `403` from Cloudflare more often than you'd like. `markfetch` sends **HTTP/2 with a coherent Chrome header set** so bot-detection systems see a real browser, then runs the response through the **same Reader View pipeline your browser uses** (Mozilla's Readability → turndown). The output is markdown indistinguishable from a human running "Save as Markdown" — on sites that would block a naive curl.
10+
The built-in fetch tools that ship with AI coding agents return raw HTML, broken markdown, or `403` from Cloudflare more often than you'd like. `markfetch` sends **a coherent Chrome header set** so bot-detection systems see a real browser, then runs the response through the **same Reader View pipeline your browser uses** (Mozilla's Readability → turndown). The output is markdown indistinguishable from a human running "Save as Markdown" — on sites that would block a naive curl.
1111

1212
One command, two surfaces:
1313

@@ -61,13 +61,13 @@ gemini mcp add -s user markfetch npx -y markfetch
6161
| CloudFlare `/markdown` |||| paid |
6262
| **`markfetch`** | **** | **** | **✓ (8 codes)** | **** |
6363

64-
- **Real-browser HTTP/2 + Chrome fingerprint.** ALPN-negotiated h2, `User-Agent`, `Sec-CH-UA-*`, `Sec-Fetch-*`, `Accept-*`. A Chrome UA with no client hints is a *stronger* automation signal than curl `markfetch` sends the full coherent set, derived from the UA at startup so an override stays internally consistent.
64+
- **Real-browser request fingerprint.** `User-Agent`, `Sec-CH-UA-*`, `Sec-Fetch-*`, `Accept-*` — a coherent Chrome header set. A Chrome UA with no client hints is a *stronger* automation signal than curl, so `markfetch` sends the full set, derived from the UA at startup so an override stays internally consistent. HTTP/1.1 over TLS: some CDNs fingerprint undici's HTTP/2 connection and 403 it.
6565

6666
- **Reader-View-quality extraction.** [linkedom](https://github.com/WebReflection/linkedom)[@mozilla/readability](https://github.com/mozilla/readability)[turndown](https://github.com/mixmark-io/turndown) with GFM tables, strikethrough, and task lists. Code fences preserve `language-X` hints. Sphinx-style bare `<pre>` blocks render as code, not escaped prose. Intraword underscores stay un-escaped — no more `list\_tools`.
6767

68-
- **One tool, one shape (MCP).** `fetch_markdown(url, savePath?)` returns markdown in `content[0].text`. No `structuredContent`, no frontmatter, no metadata fields. Several major MCP clients (Claude Code CLI, VS Code/Copilot) forward only `structuredContent` to the model and drop `content[]` when both are present — `markfetch` deliberately stays on the channel your LLM can actually read.
68+
- **One tool, one shape (MCP).** `fetch_markdown(url, savePath?, raw?)` returns markdown in `content[0].text`. No `structuredContent`, no frontmatter, no metadata fields. Several major MCP clients (Claude Code CLI, VS Code/Copilot) forward only `structuredContent` to the model and drop `content[]` when both are present — `markfetch` deliberately stays on the channel your LLM can actually read.
6969

70-
- **`savePath` / `-o` escape valve.** Pass an absolute path (MCP `savePath`) or `-o <path>` (CLI) and the markdown lands on disk instead of the response channel. Use it when your client's inline tool-result cap would truncate large responses, or to redirect output from a shell pipeline. The file is only ever the markdown of the URL — fetch errors return a `[code]` string and never touch the disk.
70+
- **`savePath` / `-o` escape valve.** Pass an absolute path (MCP `savePath`) or `-o <path>` (CLI) and the output lands on disk instead of the response channel. Use it when your client's inline tool-result cap would truncate large responses, or to redirect output from a shell pipeline. The file is only ever the fetched output (extracted markdown, or the raw body with `--raw`) — fetch errors return a `[code]` string and never touch the disk.
7171

7272
- **Whole document or honest failure.** No pagination, no truncation. If the document doesn't fit in `MARKFETCH_MAX_BYTES`, you get `too_large` — never a half-truth.
7373

@@ -88,6 +88,9 @@ npx -y markfetch https://example.com/article -o article.md
8888

8989
# Pipe into another tool
9090
npx -y markfetch https://example.com/article | pandoc -o article.pdf
91+
92+
# Fetch JSON / APIs / page source verbatim
93+
npx -y markfetch --raw https://api.github.com/repos/vasylenko/markfetch
9194
```
9295

9396
For repeat use, install once:
@@ -102,7 +105,8 @@ Flags:
102105

103106
| Flag | Purpose |
104107
|---|---|
105-
| `-o, --output <path>` | Save markdown to a file (absolute or relative path). Default is stdout. |
108+
| `-o, --output <path>` | Save the output to a file (absolute or relative path). Default is stdout. |
109+
| `--raw` | Return the unprocessed response body as UTF-8 text — skips Readability and the content-type gate. For JSON, XML, plain text, or page source (binary is not byte-preserved). |
106110
| `-V, --version` | Print version and exit. |
107111
| `-h, --help` | Print usage and exit. |
108112

@@ -115,8 +119,8 @@ Errors carry one of eight deterministic codes:
115119
| `network_error` | DNS / TCP / TLS failure, or an unexpected internal error from the fetcher. |
116120
| `http_error` | Upstream returned a non-2xx status. |
117121
| `timeout` | Per-request budget `MARKFETCH_TIMEOUT_MS` exceeded. |
118-
| `unsupported_content_type` | Response was not `text/html` or `application/xhtml+xml`. |
119-
| `extraction_failed` | Readability returned no article content (typical for pure client-rendered SPAs). |
122+
| `unsupported_content_type` | Response was not `text/html` or `application/xhtml+xml` (not raised with `--raw` / `raw`). |
123+
| `extraction_failed` | Readability returned no article content (typical for pure client-rendered SPAs). Not raised with `--raw` / `raw`. |
120124
| `too_large` | Response body or extracted markdown exceeded `MARKFETCH_MAX_BYTES`. |
121125
| `save_failed` | `savePath` was given but `writeFile` failed (parent directory missing, permission denied, etc.). |
122126
| `save_forbidden` | `savePath` resolves outside the allowed write roots — see [Write sandbox](#write-sandbox). MCP-only; the CLI has no sandbox. |

docs/SPEC.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ Text processing pipeline:
66

77
```
88
URL
9-
→ undici.fetch HTTP/2 via ALPN; full Chrome header set; Sec-CH-UA-* derived from UA
9+
→ undici.fetch HTTP/1.1; full Chrome header set; Sec-CH-UA-* derived from UA
1010
→ linkedom.parseHTML
1111
→ @mozilla/readability
1212
→ turndown + GFM HTML → markdown
1313
→ caller markdown body, or "Saved N bytes to /path" confirmation
1414
```
1515

16+
`raw` mode (`--raw` / MCP `raw`) returns the body straight from `undici.fetch`, skipping the parse → extract → convert steps.
17+
1618
Errors throw `MarkfetchError` uniformly from core; adapters catch once. Codes: `network_error`, `http_error`, `timeout`, `unsupported_content_type`, `extraction_failed`, `too_large`, `save_failed`; plus `save_forbidden`, emitted by the MCP adapter only (before `fetchMarkdown` runs — see "Asymmetric write sandbox" under Core Decisions). CLI emits `[code] message` to stderr and exits 1; MCP emits `{ isError: true, content: [{ text: "[code] message" }] }`.
1719

1820
## Core Decisions
@@ -23,7 +25,9 @@ Errors throw `MarkfetchError` uniformly from core; adapters catch once. Codes: `
2325

2426
- **Core throws, adapters translate.** Seven of the eight error codes surface from `core.ts` — five are thrown explicitly as `MarkfetchError`; `network_error`, `timeout`, and (sometimes) `http_error` are translated by `classifyError` from underlying-API errors (undici TypeErrors, AbortSignal timeouts). The eighth code, `save_forbidden`, is the exception — it's emitted by the MCP adapter before `fetchMarkdown` is invoked (see "Asymmetric write sandbox" below). New core codes need an `ErrorCode` union member + a throw site; adapters don't change.
2527

26-
- **HTTP/2 + coherent Chrome fingerprint.** Wire protocol, headers, and UA must agree — a Chrome UA over HTTP/1.1 or without `Sec-CH-UA-*` is *more* suspicious than curl. `Sec-CH-UA-*` is derived from `MARKFETCH_USER_AGENT` at startup so override-coherence is mechanical.
28+
- **HTTP/1.1 + coherent Chrome header fingerprint.** Headers and UA must agree — a Chrome UA without `Sec-CH-UA-*` is a stronger bot signal than curl, so `Sec-CH-UA-*` is derived from `MARKFETCH_USER_AGENT` at startup and override-coherence is mechanical. HTTP/1.1 is deliberate: undici's h2 path hands `node:http2` a pre-connected socket whose first-flight frames some CDNs (Cloudflare, seen on `openai.com`) score as a bot and 403; the identical request over h1.1 passes. h2 buys nothing for single-shot GETs.
29+
30+
- **`raw` passthrough.** Returns the fetched body verbatim — no Readability, no content-type gate; the fetch layer and `MARKFETCH_MAX_BYTES` cap stay. Same `fetchMarkdown` code path via a `raw` flag — no second entry point. Body is UTF-8 text; binary is not byte-preserved.
2731

2832
- **Single-channel MCP response.** `content[0].text` only. Several major MCP clients (Claude Code CLI, VS Code/Copilot) forward only `structuredContent` to the model and drop `content[]` when both are present — a single-channel response keeps the markdown reachable from those clients.
2933

@@ -40,6 +44,7 @@ Errors throw `MarkfetchError` uniformly from core; adapters catch once. Codes: `
4044
- **Authentication.** `MARKFETCH_AUTH_HEADER` env var (simple), or Chrome-cookie import for sites where the user is already logged in (frictionless, platform-specific, security-sensitive). Trigger: first useful internal / paywalled doc.
4145
- **JS rendering fallback for SPAs.** Playwright / headless Chrome as a companion package (`markfetch-heavy`) so the lean package stays lean. Trigger: enough useful sites returning `extraction_failed`.
4246
- **CloudFlare `/markdown` fallback.** Gated by `CF_AUTH_TOKEN`; fall back when Readability fails. Trigger: extraction failure rate stays high after Readability tuning.
47+
- **Browser-grade TLS + HTTP/2 impersonation.** Stricter CDN tiers fingerprint Node's TLS (JA3/JA4) and 403 every protocol. Apify's `impit` (BoringSSL-based) ships a Chrome-matching TLS + h2 fingerprint, near-drop-in for `undici.fetch`. Trigger: target sites 403 the current h1.1 + Chrome-header approach.
4348
- **Cookie reuse across redirects within a single fetch.** Currently none. Trigger: a target serves content only after a session-cookie redirect.
4449
- **Proxy support** (`MARKFETCH_PROXY_URL`) and **`Accept-Language` control** (`MARKFETCH_ACCEPT_LANGUAGE`). Trigger: corporate proxy / locale-specific content.
4550
- **Single-binary distribution.** Bun's `build --compile`, Node SEA, or similar. Trigger: `npx` first-run latency feedback, or an offline / airgapped need.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "markfetch",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Fetch a URL, return clean markdown. MCP server and CLI for AI agents.",
55
"license": "MIT",
66
"author": {

src/cli.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ program
2727
.argument("<url>", "absolute http(s) URL to fetch")
2828
.option(
2929
"-o, --output <path>",
30-
"save markdown to file (absolute or relative path); default is stdout",
30+
"save output to a file (absolute or relative path); default is stdout",
3131
)
32-
.action(async (url: string, options: { output?: string }) => {
32+
.option(
33+
"--raw",
34+
"return the unprocessed response body (skip Readability and the content-type gate)",
35+
)
36+
.action(async (url: string, options: { output?: string; raw?: boolean }) => {
3337
// CLI resolves relative output paths against cwd before calling core;
3438
// core requires an absolute path so the contract is unambiguous regardless
3539
// of which adapter invokes it. Tilde expansion is intentionally NOT done
@@ -43,9 +47,10 @@ program
4347
const { markdown, bytes, savedTo } = await fetchMarkdown({
4448
url,
4549
savePath,
50+
raw: options.raw,
4651
});
4752
if (savedTo === undefined) {
48-
// Raw markdown body — no added newline, matches MCP content[0].text.
53+
// Verbatim output — no added newline, matches MCP content[0].text.
4954
process.stdout.write(markdown);
5055
} else {
5156
// Confirmation message — the only stdout newline the CLI ever adds.

0 commit comments

Comments
 (0)