SFOLN

Catalogue / Web toolkit for agents

Website to Markdown Crawler (llms.txt)

sfoln/website-to-markdown · AI, Agents, Developer tools

Crawl a website or docs section into clean, LLM-ready markdown: sitemap first, then links, one row per page with title, depth, word and token counts, plus llms.txt and llms-full.txt files for AI agents and RAG. Polite, no browser needed. $1.50 per 1,000 pages.

About

What it does

Crawl a website or a docs section into clean, LLM-ready markdown, one row per page, and get llms.txt and llms-full.txt files for it. Give one or more start URLs; the Actor reads the start page, then the site's sitemap, then the links it finds (breadth-first, within the site or the section you started in), extracts each page's main content as markdown (menus, footers and ads removed, tables kept) and reports title, description, depth, word count and token estimate per page. Built for RAG pipelines, AI agents, documentation bots and anyone who needs a site's content as text. $1.50 per 1,000 pages; pages without content, duplicates and the llms.txt files are free.

Price

Price per delivered record

EventPricePer 1,000Charged for
Crawled page
crawled-page
$0.0015$1.5 / 1,000Charged once per page delivered with readable content as markdown.

Apify free plan: up to 10 items per run. Paid plans have no per-run cap; set maxTotalChargeUsd to cap spend.

Run

Call it from code, an agent or the Console

curl · synchronous run, rows in the response
curl -X POST "https://api.apify.com/v2/actors/sfoln~website-to-markdown/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"startUrls":["https://crawlee.dev/js/docs/introduction"],"maxPagesPerSite":3}'
mcp.json · hosted Apify MCP server
{
  "mcpServers": {
    "apify": {
      "type": "http",
      "url": "https://mcp.apify.com?tools=sfoln/website-to-markdown"
    }
  }
}
asynchronous run · for large batches
POST https://api.apify.com/v2/actors/sfoln~website-to-markdown/runs?maxTotalChargeUsd=1
GET  https://api.apify.com/v2/actor-runs/{runId}            # until status is SUCCEEDED
GET  https://api.apify.com/v2/datasets/{defaultDatasetId}/items?format=json

Authenticate with an Apify API token. MCP clients can instead sign in with OAuth when prompted. Example prompt for an agent: Crawl https://crawlee.dev/js/docs/introduction (at most 10 pages) and give me the pages as markdown plus the llms.txt file.

Input

Input fields

Start URLs of the sites (or site sections) to crawl. Each start URL gives one row per page read (markdown) plus one summary row with the crawl statistics and links to llms.txt and llms-full.txt.

FieldTypeDefaultMeaning
startUrls *arrayrequiredStart URLs
Websites or site sections to crawl, one per line, e.g. https://docs.example.com or https://example.com/blog. Accounts on the Apify free plan can process up to 10 start URLs per run (20 pages each).
maxPagesPerSiteinteger50Max pages per site
Pages with content to deliver per start URL (1–10,000). Pages without content, duplicates and filtered pages do not count. Range 1–10000.
maxDepthinteger5Max link depth
How many links away from the start page to follow (0–20). Sitemap URLs count as depth 1; 0 reads only the start page. Range 0–20.
crawlScopestring"auto"Crawl scope
auto: under the start URL's path when it has one, else the whole domain. path: the start URL's host, under its path. host: the start URL's host only. domain: every subdomain of the start URL's registrable domain. One of "auto", "path", "host", "domain".
useSitemapbooleantrueUse the sitemap
Queue the site's sitemap URLs that are in scope before the links found on pages (sitemap from robots.txt or /sitemap.xml).
includePatternsarray—Include URL patterns
Deliver only pages whose URL matches one of these, one per line: a glob where * matches anything (e.g. */blog/*) or a regular expression in slashes (e.g. /\/docs\/v2\//). Other pages are still read for their links. A regular expression that backtracks catastrophically is refused (prefer globs or character classes).
excludePatternsarray—Exclude URL patterns
Do not deliver pages whose URL matches one of these (same syntax as the include patterns), e.g. */tag/* or */page/*.
maxCharsPerPageinteger50000Max characters per page
Markdown kept per page (1,000–200,000 characters), cut at a paragraph boundary. About 4 characters make one LLM token. Range 1000–200000.
llmsTxtbooleantrueWrite llms.txt and llms-full.txt
Write llms.txt (an index of the delivered pages) and llms-full.txt (all their markdown, up to 8 MB) per start URL to the run's key-value store. Free.
maxSitesinteger100Max start URLs
Safety cap on how many start URLs are crawled in this run; the rest are ignored and counted in the OUTPUT record. Range 1–1000.
concurrencyinteger2Sites in parallel
Start URLs crawled at the same time (1–5). Each site is read at most 2 requests per second. Range 1–5.
Example input
{
  "startUrls": [
    "https://crawlee.dev/js/docs/introduction"
  ],
  "maxPagesPerSite": 3
}

Output

One record per input item

Records land in the run's dataset; a synchronous call returns them directly. Each has a status (ok is charged; everything else is free) and a charged flag. The run's OUTPUT record totals items and charges.

FieldTypeMeaning
inputstring | objectInput
The start URL this record belongs to.
recordTypestringRecord type
page = one page read (charged when contentStatus is ok); summary = one record per start URL with the crawl statistics. e.g. "page"
positionintegerPosition
page rows with content: 1-based position among the pages delivered for the start URL.
urlstring | nullURL
page: the page URL (after redirects).
requestedUrlstring | nullRequested URL
page: the URL that was requested (differs from url after a redirect).
titlestring | nullTitle
page: the page's headline or title.
siteNamestring | nullSite name
page: the site's name (Open Graph), when the page states it.
descriptionstring | nullDescription
page: the meta description, else the opening of the content.
languagestring | nullLanguage
page: the declared language (BCP 47), when the page has one.
publishedAtstring | nullPublished at
page: ISO 8601 publication time, when the page states one.
canonicalUrlstring | nullCanonical URL
page: the page's rel=canonical URL, when it has one.
depthinteger | nullDepth
page: links away from the start page (0 = the start page; sitemap URLs are depth 1).
discoveredViastring | nullDiscovered via
page: start | sitemap | link.
referrerstring | nullReferrer
page: the page whose link led here (null for the start page and sitemap URLs).
markdownstring | nullMarkdown
page: the main content as GitHub-flavoured markdown (navigation, ads and page chrome removed), at most maxCharsPerPage characters.
markdownTruncatedboolean | nullMarkdown truncated
page: true when the content was cut at maxCharsPerPage.
wordCountinteger | nullWord count
page: words of the extracted content.
tokenEstimateinteger | nullToken estimate
page: rough LLM token count of the markdown (about 4 characters per token).
contentStatusstring | nullContent status
page: ok | blocked | js_required | not_html | too_large | error. Only ok pages are charged.
startUrlstring | nullStart URL
summary: the start URL as given.
finalStartUrlstring | nullFinal start URL
summary: the start page after redirects.
crawlScopestring | nullCrawl scope
summary: path | host | domain (what auto resolved to).
scopeLabelstring | nullScope
summary: what was in scope: host + path, a host, or a registrable domain.
pagesFailedinteger | nullPages without content
summary: pages read without usable content (uncharged page rows).
duplicatePagesinteger | nullDuplicate pages
summary: pages whose URL or content repeated a page already read (not delivered, not charged).
pagesFilteredinteger | nullFiltered pages
summary: pages left out by includePatterns / excludePatterns (read for their links, not delivered).
offsiteRedirectsinteger | nullOff-site redirects
summary: pages that redirected out of the crawl scope.
urlsQueuedinteger | nullURLs queued
summary: URLs queued in scope (start, sitemap and links).
sitemapUrlsinteger | nullSitemap URLs
summary: in-scope URLs queued from the sitemap.
sitemapobject | nullSitemap
summary: { discoveredVia (robots | probe), sitemapsRead, sitemapsFailed }, or null when the sitemap was not used.
requestsinteger | nullPage requests
summary: page requests sent (at most 2 × maxPagesPerSite + 20; 60 on the free plan).
maxDepthReachedinteger | nullDeepest page
summary: the largest depth read.
llmsTxtobject | nullllms.txt
summary: { key, url, pages, bytes } of the llms.txt record in the run's key-value store, or null.
llmsFullTxtobject | nullllms-full.txt
summary: { key, url, pages, bytes, truncated } of the llms-full.txt record, or null.
resultsDeliveredinteger | nullPages delivered
summary: page rows delivered (and charged) for this start URL.
stopReasonstring | nullStop reason
summary: limit (maxPagesPerSite reached) | end (nothing more in scope) | budget | free_plan | deadline | aborted | error (an include/exclude pattern was too slow on a URL: error.code pattern_too_slow).
statusstringStatus
ok | not_found | error | skipped_invalid | skipped_budget | skipped_free_plan | skipped_unmetered. Page rows without content have status error and charged false. e.g. "ok"
chargedbooleanCharged
page rows: true when the page was charged (contentStatus ok); summary rows are never charged.
chargedEventstringCharged event
crawled-page on charged page rows.
chargedCountintegerEvents charged
Events actually charged when a charge came back short (present only then).
sourcestringSource
website: pages fetched by the Actor.
fetchedAtstringFetched at
ISO timestamp.
errorobjectError
{ code, message, status? } when status is not ok. Page codes: blocked, js_required, not_html, unexpected_type, too_large, thin_content, not_found, http_error, timeout, network, dns_not_found, tls_error, blocked_url; summary codes: invalid_url, blocked_url, dns_not_found, not_found, no_content; runtime codes: budget_exhausted, free_plan_limit, unmetered, aborted, charge_failed, record_too_large, invalid_record, dataset_push_failed.
warningsarrayWarnings
Notes about this record (caps, sitemap problems, llms.txt files).
Example record
Example record
[
  {
    "input": "https://crawlee.dev/js/docs/introduction",
    "recordType": "page",
    "position": 2,
    "url": "https://crawlee.dev/js/docs/introduction/setting-up",
    "requestedUrl": "https://crawlee.dev/js/docs/introduction/setting-up",
    "title": "Setting up",
    "siteName": "Crawlee",
    "description": "To run Crawlee on your computer, you need Node.js and npm.",
    "language": "en",
    "publishedAt": null,
    "canonicalUrl": "https://crawlee.dev/js/docs/introduction/setting-up",
    "depth": 1,
    "discoveredVia": "sitemap",
    "referrer": null,
    "markdown": "# Setting up\n\nIn this guide, we will set up Crawlee and run our first crawler. …",
    "markdownTruncated": false,
    "wordCount": 423,
    "tokenEstimate": 712,
    "contentStatus": "ok",
    "status": "ok",
    "charged": true,
    "chargedEvent": "crawled-page",
    "source": "website",
    "fetchedAt": "2026-10-14T10:00:04.000Z"
  },
  {
    "input": "https://crawlee.dev/js/docs/introduction",
    "recordType": "page",
    "url": "https://crawlee.dev/js/docs/introduction/old-page",
    "requestedUrl": "https://crawlee.dev/js/docs/introduction/old-page",
    "title": null,
    "depth": 1,
    "discoveredVia": "link",
    "referrer": "https://crawlee.dev/js/docs/introduction",
    "contentStatus": "error",
    "status": "error",
    "charged": false,
    "source": "website",
    "fetchedAt": "2026-10-14T10:00:05.000Z",
    "error": {
      "code": "not_found",
      "message": "HTTP 404 from crawlee.dev",
      "status": 404
    }
  },
  {
    "input": "https://crawlee.dev/js/docs/introduction",
    "startUrl": "https://crawlee.dev/js/docs/introduction",
    "finalStartUrl": "https://crawlee.dev/js/docs/introduction",
    "crawlScope": "path",
    "scopeLabel": "crawlee.dev/js/docs/introduction",
    "pagesFailed": 1,
    "duplicatePages": 0,
    "pagesFiltered": 0,
    "offsiteRedirects": 0,
    "urlsQueued": 10,
    "sitemapUrls": 9,
    "sitemap": {
      "discoveredVia": "robots",
      "sitemapsRead": 2,
      "sitemapsFailed": 0
    },
    "requests": 11,
    "maxDepthReached": 1,
    "llmsTxt": {
      "key": "llms-crawlee.dev-js-docs-introduction.txt",
      "url": "https://api.apify.com/v2/key-value-stores/<storeId>/records/llms-crawlee.dev-js-docs-introduction.txt",
      "pages": 9,
      "bytes": 1460
    },
    "llmsFullTxt": {
      "key": "llms-full-crawlee.dev-js-docs-introduction.txt",
      "url": "https://api.apify.com/v2/key-value-stores/<storeId>/records/llms-full-crawlee.dev-js-docs-introduction.txt",
      "pages": 9,
      "bytes": 61234,
      "truncated": false
    },
    "recordType": "summary",
    "resultsDelivered": 9,
    "stopReason": "end",
    "status": "ok",
    "charged": false,
    "source": "website",
    "fetchedAt": "2026-10-14T10:00:09.000Z"
  }
]

FAQ

Questions

Which pages are crawled?
The start page first, then the URLs of the site's sitemap (found through robots.txt or /sitemap.xml) that are in scope, shallow paths first, then the links found on each page, breadth-first up to maxDepth. crawlScope auto stays under the start URL's path when it has one (https://example.com/docs crawls /docs and below) and otherwise covers the whole registrable domain, subdomains included. includePatterns and excludePatterns narrow it further. Files and media (PDF, images, archives) are skipped, and each page is read once even when several URLs lead to it.
What are llms.txt and llms-full.txt?
llms.txt (llmstxt.org) is a markdown index of a site for language models: the site's name and description, then one section per top-level path listing every page as a link with a short description. llms-full.txt contains the markdown of every page one after the other (up to 8 MB). Both are written to the run's key-value store (`llms-<site>.txt` and `llms-full-<site>.txt`); the summary row of each start URL links to them. They are free.
Why do some pages have contentStatus blocked or js_required?
No browser is used. blocked: the site answered with a bot check, an access-denied page or a rate limit. js_required: the page builds its content with JavaScript and the HTML has no text. Such pages are written as uncharged rows so you can see what was skipped; their links (if any) are still followed. For JavaScript-heavy sites the sitemap often still leads to readable pages.
How polite is the crawl?
At most 2 requests per second and 2 at a time per host, shared by every start URL of the run, with a 20-second timeout and a 5 MB cap per page. Each site gets at most 2 page requests per page it may deliver (maxPagesPerSite, 20 on the free plan) plus 20, so failing links cannot make a crawl run away.

Machine-readable

This page for agents