Skip to content

feat: fetch_page returns Markdown, links and tables kept - #7

Merged
samgozman merged 4 commits into
mainfrom
feat/markdown
Oct 24, 2026
Merged

samgozman merged 4 commits into
mainfrom
feat/markdown

Conversation

@samgozman

@samgozman samgozman commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

fetch_page and refetch get now return the page as Markdown by default. Before, a page came back as one flat run of text: links lost their URLs, tables lost their columns, and code lost its whitespace. Claude could see a search result or a "next page" link but couldn't follow it. format: "text" (CLI: --format text) gives the old plain text.

What the Markdown keeps

  • Headings, lists, blockquotes and rules.
  • Links
    • Made absolute, resolved against the address after redirects (or <base href>).
    • Search engines' click-through links (DuckDuckGo's uddg, Google's /url?q=) point at their real targets.
    • Tracking parameters (utm_*, fbclid, gclid and so on) are dropped.
    • A target repeated right after itself shows only its text. A search result links its title, URL and snippet to the same place, so the URL appears once instead of three times.
    • Only http, https and mailto links keep a target. javascript: and data: links show only their text.
  • Tables
    • A data table becomes a pipe table, without rows and columns that are empty throughout (spacer rows, vote arrows).
    • A layout table, one with nested tables, lists or headings in its cells, becomes its cells' text.
  • Code blocks: whitespace and language kept, fenced longer than any backtick run inside them.
  • Elements laid out like a browser does: an element with a block inside it (a custom element such as <react-app>, or a span around a div) is a block. Without this, GitHub's releases page came out as one line. A link stays one line, which keeps a card as one link.
  • Dropped: scripts, styles, forms, images, and hidden elements (hidden, aria-hidden, display:none).
  • Unchanged: the detection of JavaScript-only and empty pages still measures plain text.
  • Saved pages: save: true writes .md, or .txt with format: "text".

Real pages (Markdown vs plain text size)

Page Markdown Text Notes
DuckDuckGo results 3.8 KB 3.7 KB each result: a title with its real target, the URL, the snippet
GitHub releases 11 KB 5.5 KB release notes with headings and commit links
Hacker News 15 KB 4.1 KB the story list as a table, with every link
Wikipedia (Go) not measured not measured the infobox as a two-column table, code samples fenced
go.dev tutorial 10 KB 7.5 KB shell and Go code blocks inside the numbered steps

Links cost space. A link-heavy page is two to four times its plain-text size, so it reaches the default max_chars (20,000) sooner.

Sessions (e2e/run.py, Sonnet)

There's a new case, links: an index page that refuses plain clients and names its notes pages only by link text. The secret word is on the page behind one link, under a path that can't be guessed.

Setup main this branch
alone 7/8. links fails: Claude guessed paths with 2 more fetch_page calls and Bash, and gave no answer 8/8
context-mode n/a 8/8

The other seven cases (ok, 403, challenge, challenge-403, challenge-js, js, slow) pass in every run.

Checks

  • make test
  • make lint, on macOS and with GOOS=linux
  • make cover-check: 89.7%
  • claude plugin validate . --strict

Review fixes (6dfc0fa)

A code review found one critical issue: crafted markup could make the output grow with the square of the page. A 280 KB table with one 4,000-cell row gave 225 MB of Markdown. Deep quotes and lists grew the same way, because each level prefixed every line again.

  • Fix: tables wider than 64 columns or with more than 20,000 cells are rendered as their cells' text, and quotes and lists stop adding prefixes after six levels.
  • Result: at 2 MB, the largest page refetch reads, every shape I probed (16 of them) converts in under 0.2 s to at most three times its size. A regression test checks this.

Smaller fixes from the same review:

  • Links in layout tables lost their target.
  • Code blocks lost blank lines.
  • Hidden table rows and cells were shown.
  • A code block's Copy button got into its code.
  • Dropping a tracking parameter re-sorted the rest of the query.
  • A saved page that isn't HTML is now saved as .txt.

One known limit: when headless Chrome reads a page after the HTTP fetch failed, links resolve against the URL that was asked for, not the address Chrome ended up on after redirects. --dump-dom doesn't report that address.

🤖 Generated with Claude Code

samgozman and others added 2 commits October 24, 2026 11:50
fetch_page and refetch get now return the page as Markdown by default:
headings, lists, links, tables and code blocks kept, scripts and hidden
elements dropped. format: "text" (get --format text) gives the plain text
as before, and the JavaScript and empty-page detection keeps measuring
plain text.

- Links are absolute, resolved against the address after redirects (or
  <base href>). Search engines' click-through links (DuckDuckGo's uddg,
  Google's /url?q=) point at their targets, tracking parameters are
  dropped, and a target repeated right after itself shows only its text.
  Only http, https and mailto links keep a target.
- A data table becomes a pipe table, without rows and columns that are
  empty throughout; a layout table becomes its cells' text.
- Code blocks keep their whitespace and language, fenced longer than any
  backtick run inside them.
- An element with a block inside (a custom element, a span around a
  div) is laid out as a block, as a browser does; a link stays one line.
- save: true writes a .md file (.txt with format: "text").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
/links names three notes pages by link text only and refuses plain
clients; the word is on the page its release-notes link leads to, under
a path that cannot be guessed. A session passes only if fetch_page's
result carries the link's URL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.78698% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.47%. Comparing base (b5996a6) to head (2bca6b1).

Files with missing lines Patch % Lines
fetch/markdown.go 93.48% 20 Missing ⚠️
cmd/refetch/mcp.go 94.11% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main       #7      +/-   ##
==========================================
+ Coverage   89.75%   90.47%   +0.71%     
==========================================
  Files          18       19       +1     
  Lines        1689     2015     +326     
==========================================
+ Hits         1516     1823     +307     
- Misses        172      191      +19     
  Partials        1        1              
Files with missing lines Coverage Δ
cmd/refetch/get.go 95.45% <100.00%> (+5.21%) ⬆️
fetch/clients.go 86.27% <100.00%> (ø)
fetch/fetch.go 93.60% <100.00%> (ø)
fetch/text.go 97.14% <100.00%> (+0.36%) ⬆️
cmd/refetch/mcp.go 88.70% <94.11%> (+0.31%) ⬆️
fetch/markdown.go 93.48% <93.48%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

samgozman and others added 2 commits October 24, 2026 12:20
From a review of the Markdown converter:
- A crafted table could make the output grow with the square of the page
  (one 4,000-cell row padded every other row: 280 KB of HTML gave 225 MB
  of Markdown), and so could deep quotes and lists, which prefixed every
  line once per level. A table wider than 64 columns or with more than
  20,000 cells is now laid out as its cells' text, and quotes and lists
  stop adding prefixes after six levels. At 2 MB, the largest page
  refetch reads, every probed shape now converts in under 0.2 s to at
  most three times its size.
- Links in a layout table lost their target: the cells were rendered
  twice and the second pass took each link for a repeat.
- Code blocks lost blank lines to a pass over the whole document.
- Hidden table rows and cells were shown; a code block's Copy button
  and other skipped elements reached its text, which is now gathered in
  one pass without recursion.
- Dropping a tracking parameter re-sorted and re-encoded the rest of the
  query; the other pairs now keep their order and encoding.
- A saved page that is not HTML is .txt, not .md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From a security review of the Markdown converter:
- A list item outside a list (<li> inside <section>, nested about 95
  deep) indented every line of its content once per level, past the
  six-level cap that counted only <ul>/<ol>: 1 MB of HTML gave 40 MB of
  Markdown in 1.2 s. List items now count their own nesting.
- <ol start="-9223372036854775808"> made 22-character markers on every
  line; a start outside 0 to 999,999 is ignored.
- A link target keeps no userinfo, so https://good.example@evil.example/
  shows as https://evil.example/.
- Clip walks the text up to its limit instead of converting the whole of
  it to runes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@samgozman samgozman added the ci:macos Run the macOS tests once label Oct 24, 2026
@github-actions github-actions Bot removed the ci:macos Run the macOS tests once label Oct 24, 2026
@samgozman
samgozman merged commit 714fd7f into main Oct 24, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant