Website Content Extractor - Markdown for LLM, RAG, Vector DB avatar

Website Content Extractor - Markdown for LLM, RAG, Vector DB

Pricing

$2.20 / 1,000 pages

Go to Apify Store
Website Content Extractor - Markdown for LLM, RAG, Vector DB

Website Content Extractor - Markdown for LLM, RAG, Vector DB

Readable text from 73.1% of 52 live pages over plain HTTP, including 10 of 10 documentation sites. Website to LLM text and a website to markdown converter in one run: clean Markdown, chunks with overlap for a vector database, 46 flat fields, and what changed since the last run.

Pricing

$2.20 / 1,000 pages

Rating

0.0

(0)

Developer

Snow Leo Data

Snow Leo Data

Maintained by Community

Actor stats

0

Bookmarked

3

Total users

2

Monthly active users

7 days ago

Last modified

Share

Website Text Extractor for AI, RAG and LLM

Give it a list of links or one address plus a crawl depth. Get back one row per page: the readable text with the menus and footers gone, the same content as Markdown, the page metadata, and — if you ask for it — the page already split into chunks with overlap, ready for a vector database.

No browser. No proxies. No account anywhere. Plain HTTP and the Python standard library, which is why it is cheap, and also why it has one honest limit — read the next section before you buy.

The number you should decide on: 73.1 %

This Actor reads what the server sends. It does not run JavaScript. So there is a part of the web it cannot read, and here is how big that part measured out.

52 live pages, seven kinds of site, fetched from Apify servers on 2026-09-17 (run 7fpElZkFZAnx8DObw, the same code that ships here):

outcomepagessharewhat it means
readable text3873.1 %120 words or more. HTTP was enough.
refused (401/403)611.5 %apnews, reuters, etsy, ebay, nestle, tesla
empty shell35.8 %the JavaScript case — we got nothing
thin (under 120 words)23.8 %a real page, but mostly headlines
network error23.8 %amazon answered 500, bestbuy dropped the connection
disallowed by robots.txt11.9 %sap.com said no, so we did not download it

The same measurement from a laptop gave 73.1 % as well, so this is not an Apify-network effect.

It depends enormously on what you crawl:

kind of sitereadablepages
documentation10 / 10docs.python.org, MDN, Django, Kubernetes, React, GitHub, FastAPI, Apify, PostgreSQL, nginx
reference4 / 4Wikipedia, arXiv, GitHub repo, Hacker News
blogs7 / 8Simon Willison, Martin Fowler, Cloudflare, danluu, Paul Graham, Rust, jvns
news7 / 10BBC, Guardian, TechCrunch, Ars Technica, Wired, NPR, CNBC
government3 / 4IRS, gov.uk, European Commission
corporate4 / 8Stripe, IBM, Salesforce, Siemens
online shops3 / 8the worst case by a distance

So: for documentation sites, knowledge bases, blogs and news this Actor is a straight fit. For large retail, for sites behind Cloudflare's stricter modes, and for single-page apps that render everything client-side, use a browser-based Actor instead — that is what they are for, and we are not going to pretend otherwise.

Median text on a page that worked: 1480 words.

What one row contains

46 fields. Flat — no nested objects to unwrap before a CSV or a BigQuery load.

Address — url, final_url (after redirects), canonical_url, domain, depth, redirected, dedup_key, canonical_key.

Response — http_status, content_type, charset, response_bytes, response_time_ms, error, scraped_at.

Page card — title, description, author, published_at, modified_at, language, site_name, page_type, image_url, keywords, robots_meta, og (every og: and twitter: tag), jsonld_types, alternate_languages, feeds.

The content — text, markdown, word_count, char_count, reading_time_min, and html when you switch it on.

Structure — headings (level and text, in document order), heading_count, links (url, anchor text, rel), link_count, images (url, alt), image_count.

For RAG — chunks and chunk_count.

For monitoring — change_type (NEW / UPDATED / UNCHANGED) and content_hash.

Dates and authors, honestly

Measured on the same 52 pages: an author came back on 28.8 % and a publication date on 17.3 %. That is not a parsing failure — most of those 52 are section fronts and documentation pages, which carry no byline and no date. Where a page states them (article:published_time, JSON-LD, parsely-pub-date, or a <time datetime> inside the article) they are read and normalised to ISO-8601, including Unix-epoch values. What is deliberately not done is guessing: "2 days ago" and "March 3" stay empty, because a guessed date is worse than a missing one — it looks just as trustworthy.

Chunking, which is the point for a vector database

Turn on Split into chunks and every row gains a chunks array. The split follows the document, not a character counter:

  • a heading always starts a new chunk;
  • then paragraph boundaries, then sentence ends, and only as a last resort a space;
  • a fenced code block is never cut while it fits in one chunk;
  • chunkOverlap repeats the tail of the previous chunk, cut at a sentence boundary, so a thought that lands on the seam is still findable whole. It is capped at half the chunk size.

Each chunk carries index, text, chars, words, start_char, url, title and heading_path — the chain of headings above it. That last one is the difference between storing a nameless paragraph about limits and storing one you can cite as Pricing → Enterprise → Limits.

Monitor mode: re-index only what changed

Turn on Monitor mode and the Actor remembers a fingerprint of each page's text between runs, in a named key-value store. Every row gets change_type:

  • NEW — never seen before;
  • UPDATED — the text changed;
  • UNCHANGED — identical, and by default not delivered and not charged.

The fingerprint covers the text only. A view counter, a fresh "related posts" block or a new ad slot is not a change — otherwise a monitor would report everything as changed every day, and quietly become a plain re-crawl.

For a scheduled pipeline this is money twice over: you do not pay us for the unchanged page, and you do not pay your embedding provider to vectorise it again.

Crawling a site

Leave Crawl depth at 0 to open exactly the addresses you gave. Raise it to walk a site breadth-first from the entry point: depth 1 adds the links on those pages, depth 2 one level further.

  • Stay on the starting domain and Allow subdomains keep the crawl from wandering off a news site into the whole internet.
  • Only crawl URLs matching / Never crawl URLs matching take wildcard patterns such as https://example.com/docs/*. They apply to links found while crawling, never to the addresses you supplied yourself.
  • Maximum pages is the budget for the run, counted in requests actually sent. Maximum pages per domain stops one big site from eating it all.
  • Seed the crawl from sitemap.xml queues the site's own sitemap. This is how you reach a site whose menu is drawn by JavaScript: the links are not in the HTML, but the sitemap lists every page anyway.

rel="nofollow" links are not followed, and duplicate addresses are collapsed before you are charged: ?utm_source=..., a trailing slash, www., http vs https and a declared canonical_url all resolve to one row.

robots.txt is always obeyed

There is no switch. Before a page is requested, the site's robots.txt is read once per host and matched by the RFC 9309 rules — longest matching rule wins, Allow beats Disallow on a tie, * and $ supported. A Crawl-delay is obeyed too.

This is not only the correct thing to do. The disallowed sections of a site are usually search results, carts and calendars — infinite URL spaces where a crawl budget burns away for nothing.

A page refused this way still gets a row, with error: robots-disallowed, so you can see which address was skipped and why.

Empty output always names its reason

A green run with nothing in it is the worst thing that can happen to a buyer. So the Actor never stays silent about it: the REPORT record carries an empty_reason and the log carries the same sentence in words. Eleven named reasons:

no-start-urls, all-urls-invalid, nothing-requested, patterns-exclude-start-urls, charge-limit-reached, all-pages-refused, all-pages-blocked-by-robots, no-text-found, filtered-out, nothing-changed, nothing-collected.

The first five are decided from your input alone, and are checked before a single byte is downloaded — you are not charged time for pages that the next line of code would have thrown away.

Compared with the three Apify Actors in this niche

Measured from the Apify API on 2026-09-17.

this Actorapify/web-fetchapify/website-content-crawlerapify/rag-web-browser
pricesee the store listing$0.0015 per fetch (Free/Bronze plan)you pay platform usageyou pay platform usage
runs JavaScriptnoyesyesyes
proxies, anti-blockingnoyesyesyes
Google search built innononoyes
crawls a whole siteyesno, single URLyesno
chunking for a vector DByes, with overlap and heading pathnonono
change detection between runsyesnonono
publication date and author fieldsyesnonono
heading outline, link and image listsyesnopartlyno
default memory1024 MB—8192 MB—
output shapeflat, 46 fieldsformats you picknested (crawl, metadata)nested

Where they are ahead and we are not going to catch up: JavaScript rendering, proxy rotation and anti-blocking, screenshots, LLM summaries, file downloads, and full CSS-selector control. Those need a browser or a paid service; this Actor has neither, by design. On the 52-page measurement above that cost us 3 pages out of 52 to empty shells and 6 to outright refusals.

Where this Actor is ahead: chunking, change detection, dates and authors, a flat schema, named empty reasons, a per-domain budget, and a price you can calculate before the run instead of a compute bill you find out about after it.

removeElementsSelector and keepElementsSelector accept simple selectors only — a tag (aside), a class (.cookie-bar) or an id (#sidebar). Combinators and pseudo-classes are not supported. A half-implemented selector engine would fail silently and look like a broken Actor, so it is refused instead.

Cost and speed

Measured on Apify, 1024 MB, explicit memory on every run:

  • the 52-page measurement run took 76 seconds for 52 pages across 52 different hosts, at 512 MB;
  • pages are fetched in parallel (default 5 at a time), but requests to the same host are spaced out, and a Crawl-delay in robots.txt is respected.

Maximum rows caps what you are charged. It limits the output, not the crawl: rows removed by a filter do not consume it.

Notes for the fine print

  • Non-HTML responses (PDF, images, archives) are recorded with their content_type and an error of not-text. They are never parsed as text.
  • A 200 response with an empty body is treated as a failure, not a success — that is what a polite refusal looks like, and a buyer should not pay for a row with no words in it.
  • Character encoding is taken from the header, then the meta tag, then tried as UTF-8 and cp1252. Expired or self-signed certificates do not stop a read: you supplied the address, and nothing is sent to it.
  • Pages that fail still produce a row, with the status and the error, so a list of 1000 addresses comes back with 1000 answers. That row is a row like any other, so it is charged like any other — said plainly rather than buried. Switch on Drop pages that did not open and they are neither delivered nor charged. On the 52-page measurement the failures were 15.4 % of the list.