Web Page to Markdown for LLMs (robots.txt-safe)
Pricing
Pay per event
Web Page to Markdown for LLMs (robots.txt-safe)
Clean Markdown and RAG chunks from public pages or whole doc sites. Sitemap-first polite crawl: robots.txt, Crawl-delay, named bot, hard page/time budgets. Flat per-page price; cheap unchanged-page recrawls. No browser, no logins.
Pricing
Pay per event
Rating
0.0
(0)
Developer
Benjamin Cojocaru
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
6 days ago
Last modified
Categories
Share
Web Page to Markdown for LLMs (polite crawler, robots.txt-respecting)
Turn public web pages, or a whole documentation site, into clean Markdown for LLMs and RAG: headings, lists, links, code blocks and tables kept; navigation, cookie banners, share bars, newsletter boxes, related-post lists and comments removed. Optional token-budgeted chunks with the source URL, heading path and character offsets, ready for citations.
It is built to be a well-behaved bot: it reads each site's robots.txt before fetching anything (sitemaps included), re-checks on every redirect, sends an identifiable User-Agent, waits between requests to the same site and honours Crawl-delay, and skips pages that opt out of AI use with noai. You pay a flat price per page, never for compute, and nothing for pages that fail.
Why this one
- Cleaner main content, biased towards keeping text. When in doubt it keeps a block: losing content is worse than leaking a menu. On the 25 hand-written test pages in BENCHMARK.md (benchmark pages only, not a guarantee for every site) it found every expected content block. It leaked 21% of the boilerplate strings on the 8 pages written after the rules were frozen; v0.1 leaked 39% and lost 10% of the content. Every rule is listed below.
- Never loops, never times out silently. Hard
maxPagesandmaxRunMinutesbudgets, a stop before the platform run timeout, and each request's deadline shrunk to fit the time left. URLs are de-duplicated after normalisation, and crawler traps (repeating paths, endless query strings, binary files) are refused. Every run ends with aSUMMARYthat says why it stopped. - Predictable price. Pay per page (
page-converted). In incremental mode an unchanged page costs onlypage-unchanged. Failed and skipped pages are free. The charge is taken in the same call that stores the item. - Chunks that keep order. Chunks follow the page in order. Every heading starts a new chunk, and long sections are split at paragraphs, then sentences. Code fences stay whole while they fit. Overlap never reaches back across the chunk's heading, so
headingPathis right for the whole chunk. Each chunk is an exact slicemarkdown[charStart:charEnd].
What it does and does not do
- Fetches plain HTTP(S) pages with a normal GET request. No browser, no JavaScript, no logins, no cookies, no proxies, no CAPTCHA solving, no search-engine results. Pages that need JavaScript to show their text come back as
empty_contenterrors (free). - Respects
robots.txtper RFC 9309 for the user-agent tokenDataFeedBot(falls back to the*group). A site whose robots.txt answers with a server error is treated as "disallow everything", as the RFC says. Honours<meta name="robots" content="nofollow">andrel="nofollow"when crawling. - Skips pages marked
<meta name="robots" content="noai">orX-Robots-Tag: noai. - Refuses URLs pointing at private or internal network addresses (DNS is resolved once and the connection is pinned to the checked address).
- You are responsible for having the right to use the content you convert.
Modes
list (default) converts exactly the URLs you give (up to 500).
crawl starts from up to 20 start URLs and works in this order:
- It fetches the start URL.
- It reads the site's sitemaps: the ones
robots.txtnames, otherwise/sitemap.xml. It reads at most 20 sitemap files, and only from the same site. - It follows links on the crawled pages, breadth-first, up to
maxDepthclicks.
The crawl stays on the start URL's host and inside its directory (https://x.com/docs/intro stays under /docs/), unless you set includePaths. Links that robots.txt forbids are never fetched and never appear in the dataset. They are counted in SUMMARY.rejectedUrls. If a site's Crawl-delay is longer than 10 s, the Actor fetches one page from it and drops the rest of that site, with a note in the warnings.
Extraction rules (main-content mode)
- Root:
<main>, or the single top-level<article>(widgets rendered as<article>are ignored), accepted only if it holds the page's<h1>or at least 30% of the page's prose. Several articles: the one holding 60%+ of article text, otherwise all of them. Thenrole=main. Otherwise a readability-style density score, widened over neighbouring prose (not menus) and to include the page's<h1>. - Always removed: scripts, styles, forms with little text, buttons,
hidden/aria-hidden/display:none/visibility:hiddenelements. Exception: hidden tab panels (role=tabpanel, e.g. code in other languages) are kept and labelled with their tab name. A<form>that wraps the whole page (ASP.NET) is kept. - Page chrome:
<nav>,<footer>,<header>,<aside>, and ARIA rolesnavigation,banner,contentinfo,complementary,search,dialog,menu,toolbar. Kept anyway: an article's own<header>with its title, an<aside>that is a note/warning or holds real prose, and a role-marked section with its own heading and prose. - Boilerplate class and id names: a curated list matched against each whole class name, for example
cookie-banner,share-buttons,related-posts,newsletter,ad-slot,sidebar,comments,breadcrumb,toc,sr-only. A word inside a longer name never counts (social-security,related-work,menu-planningandad-hocstay). A block is never dropped by this rule if it holds the<h1>, more than half of the content, or 200+ characters of prose. - Cookie and consent banners by text: a short block mentioning cookies plus "accept all", "we use cookies", "consent", "manage preferences" and the like.
- Link farms: a
div/sectionwith 4+ links that is 70%+ link text and small compared with the page. - "Related posts", "Read next", "More from ..." headings, only when followed by a list of real links (not in-page anchors). The heading and that list are dropped; a heading followed by prose is kept.
- Heading permalink anchors (
¶,#,hash-link) and "Edit this page" links. - Kept intact: tables (GFM tables plus structured
tables;colspanandrowspanexpanded; code in a cell becomes inline code), code blocks (language read fromlanguage-*,highlight-*ordata-lang; a real line-number gutter table becomes one code block), lists (nested and ordered), definition lists (code inside<dd>stays fenced), blockquotes, images with alt text. - Crawl mode only (
removeRepeatedBlocks): a short paragraph that already appeared on 3 earlier pages of the same site is dropped. Headings, code and tables are never dropped by this rule.
Input
{"urls": ["https://docs.example.com/guide/"],"mode": "crawl","maxPages": 200,"maxDepth": 3,"maxRunMinutes": 30,"incremental": true,"chunk": true,"chunkTokens": 512}
All fields except urls are optional. The full list, with defaults, is in the input schema: mainContentOnly, includeTables, maxChars, maxPageMb, useSitemaps, followLinks, includePaths, excludePaths, removeRepeatedBlocks, chunkOverlapTokens.
Output (one dataset item per page)
{"url": "https://docs.example.com/guide/limits","finalUrl": "https://docs.example.com/guide/limits","canonicalUrl": "https://docs.example.com/guide/limits","status": "ok","change": "changed","depth": 1,"source": "sitemap","title": "Rate limits | Example Docs","description": "How many requests you may send","language": "en","markdown": "# Rate limits\n\nEvery API key may send 600 requests per minute...","truncated": false,"wordCount": 412,"approxTokens": 690,"contentType": "text/html","boilerplateRemoved": 7,"contentHash": "sha256:5f1c...","tables": [{"index": 0, "caption": null, "headers": ["Header", "Description"], "headerRows": [["Header", "Description"]], "rows": [["Retry-After", "Seconds to wait after a 429"]]}],"chunks": [{"index": 0, "chunkId": "a1b2c3d4e5f60718", "url": "https://docs.example.com/guide/limits", "headingPath": ["Rate limits"], "charStart": 0, "charEnd": 1630, "approxTokens": 408, "text": "# Rate limits\n\n..."}],"fetchedAt": "2026-10-14T00:00:00+00:00","error": null}
statuscan be:ok: converted.unchanged: incremental mode only. The item has no Markdown and costs onlypage-unchanged.skipped: robots.txt, anoaiopt-out, or a Crawl-delay that is too long.error: an HTTP error, a page that is too large, not HTML, or empty.
- Skipped and error items explain why in
error.code/error.messageand are not charged. changeappears in incremental mode only:new,changedorunchanged.depthandsource(seed/sitemap/link/state) appear in crawl mode only.approxTokensis characters / 4, an estimate rather than a real tokenizer count. Chunk budgets use the same estimate.contentHashis taken from the extracted Markdown, before the repeated-block step. A rotating ad or a timestamp in the page chrome therefore does not count as a change.
The SUMMARY record in the run's key-value store contains:
- the counts per status
stopReason:null,page_budget,time_budget,charge_limitorrequest_budgetnotFetched: pages discovered but not fetcheddiscoveredandrejectedUrls, with the reason for each rejected URL- the incremental state
- any warnings
Run-summary row (free). When a run produces no other row (for example a monitor run where nothing changed, or a run stopped at once by your maximum charge or the run timeout), the Actor adds one row {"type": "run_summary", "status": "run_summary", "message": "...", "stopReason": ..., "warnings": [...], "summary": {...}} (summary is the same as the SUMMARY record), so the dataset is never empty. It is never charged. Skip it with type != "run_summary".
Incremental recrawls
With incremental: true, each page's ETag, Last-Modified and content hash are saved in a named key-value store. By default the name is derived from the mode, start URLs and paths (wpm-state-...), so different jobs never share state; set stateStoreName to choose it. On the next run, the Actor sends If-None-Match / If-Modified-Since. A page is reported unchanged when the server answers 304, or when it sends the page again and the extracted Markdown (hashed in full, before maxChars truncation) is the same. In crawl mode, newly discovered pages are fetched first; pages known from earlier runs (source: "state") are re-checked with the budget that is left, so re-checks can never stop new pages from being found. Pages that now answer 404/410 or are disallowed by robots.txt are dropped from the state (counted in SUMMARY.incremental.removedPages for 404/410). A 304 page that now sends X-Robots-Tag: noai is skipped and not charged. If the extraction settings (mainContentOnly, includeTables, maxChars, Actor version) changed since the state was written, all pages are converted again and a warning says so. If the store cannot be read or written, the run does not fail: every page is treated as new and a warning says so.
Restarts. The run records which pages it has already stored in its own key-value store (PROGRESS). If the platform migrates or restarts the run, those pages are not fetched or charged again (SUMMARY.resumedSkipped).
Pricing
Pay per event:
| Event | When | Suggested price |
|---|---|---|
page-converted | A page with status: ok was stored (chunks included, free) | $0.0015 |
page-unchanged | Incremental mode: a page checked and found unchanged was stored | $0.0002 |
Nothing else is charged by this Actor: no compute surcharge, no start fee of its own, no charge for skipped or failed pages, robots.txt or sitemaps. Each charge is taken in the same call that stores the item, so you are never charged for an item that was not stored. Before each page, the Actor checks that a page-converted still fits your maximum charge per run. If it does not, the Actor stops and SUMMARY says so.
Using it from an AI agent
The input is a plain URL list and the output is one JSON object per page, so the Actor works as a tool via the Apify API or Apify's MCP server. Turn on chunk to get embedding-ready chunks with citation offsets directly. You can also feed markdown into RAG Text Chunker for other chunking options.
Limits
- List mode: 500 URLs per run. Crawl mode: 20 start URLs and 1,000 pages per run.
- 10 MB per page.
- 2,000 HTTP requests per run (robots.txt and sitemaps included). Sitemaps: 20 files per site.
- 5 redirects.
- At least 1 s between two requests to one site, and at most 10 s of waiting for
Crawl-delay. - Tables:
colspanandrowspanare expanded. Several header rows are merged per column ("Price / Min") and also returned raw inheaderRows. - No JavaScript rendering. Single-page apps whose HTML has no text come back as
empty_content(free).