DroidCrunch
  • About
  • Blog
  • Categories
    • Automation
    • Tech
    • SEO
    • WordPress
    • Software
    • Hosting
    • Proxy
  • AI
    • AI Writing
    • AI Image
    • AI Tool
    • AI Video
  • Deals
  • Contact
Subscribe
No Result
View All Result
DroidCrunch
  • About
  • Blog
  • Categories
    • Automation
    • Tech
    • SEO
    • WordPress
    • Software
    • Hosting
    • Proxy
  • AI
    • AI Writing
    • AI Image
    • AI Tool
    • AI Video
  • Deals
  • Contact
Subscribe
DroidCrunch

How to Scrape Websites for LLMs 2026: Turning Pages into Clean Markdown

Lokesh Kapoor by Lokesh Kapoor
October 30, 2026
3
0

Your RAG bot just quoted a cookie banner back to a customer.

Cleaner input fixes that. To scrape websites for LLMs, take the site’s own Markdown when it has one. Otherwise run the HTML through trafilatura, check what survived, and chunk by heading.

On eight real pages, our RAG explainer shrank from 119,965 tokens of HTML to 2,921 of clean Markdown.

The catch: even the best extractor lost content on half the pages.

Firecrawl, Apify and n8n links are affiliate links. They never changed a result.

What does LLM-ready Markdown actually look like?

LLM-ready Markdown is the page’s main content and nothing else, with its headings, tables and code still intact. The URL and title travel with it as metadata.

Four things decide whether a scraped page helps your model or quietly hurts it.

  • Main content only. No menus, footers, cookie notices or “related posts” lists.
  • Structure kept. Headings, tables and code fences survive, because your chunker and your model both lean on them.
  • Few inline links. A URL inside a sentence costs tokens and adds nothing to an embedding.
  • Metadata on the side. Page URL, title, section and fetch date, so answers can cite a source.

Why fuss? Noise isn’t harmless. Converted whole, our plain-English RAG explainer came to 6,933 tokens. “View All Result”, “Subscribe” and the privacy-policy footer were all in there.

The clean version was 2,921 tokens. You’d embed, store and search the other 4,000 for nothing.

How we tested this (and what we didn’t)

On 30 October 2026 we fetched six pages: two DroidCrunch posts, Wikipedia’s web scraping article, a Python docs page, MDN’s Fetch guide and a GitHub README. Stripe’s and Firecrawl’s docs joined for Step 1.

Each page went through five open-source converters and Jina’s free Reader. We counted tokens with OpenAI’s o200k tokenizer. Then we checked that five passages per page survived, and that the menus didn’t.

Next we chunked the output two ways, embedded it with the open-source bge-small model, and asked 14 questions with known answers. It’s a small test. Read the retrieval numbers as a direction, not a benchmark.

Not tested: Firecrawl with a paid key, pages that only render in a browser, and PDFs.

The pipeline at a glance

Six steps and about 130 lines of Python. Each step fixes a failure we hit.

  1. 1
    Step 1 of 6

    Check for native Markdown

    Try the page URL with .md on the end, and look for an llms.txt file at the site root.

  2. 2
    Step 2 of 6

    Fetch the HTML politely

    Read robots.txt with your own user agent, identify your bot, and fix the page encoding.

  3. 3
    Step 3 of 6

    Extract the main content

    Run trafilatura first. Fall back to a main-content selector when its output looks thin.

  4. 4
    Step 4 of 6

    Check what survived

    Compare headings and code blocks against the HTML before anything gets embedded.

  5. 5
    Step 5 of 6

    Chunk by heading

    Pack paragraphs under each heading into chunks of about 300 tokens, and never split a code block.

  6. 6
    Step 6 of 6

    Store chunks with metadata

    Keep the URL, the heading path, the fetch date and a hash for cheap re-crawls.

Step 1: does the site already give you Markdown?

More docs sites do than you’d guess. Try the page URL with .md on the end, then check for /llms.txt at the root.

Stripe’s payments overview shows why this comes first. The HTML weighed 233,625 tokens. Trafilatura squeezed it to 81, and that’s the problem: it kept the headings and threw away nearly everything under them.

The site’s own payments.md was 1,184 tokens, and every word was there.

SiteNative .md pagellms.txt
docs.stripe.comYesYes
docs.firecrawl.devYesYes, plus llms-full.txt
docs.n8n.io and docs.apify.comNot checkedYes
droidcrunch.comNoYes, generated by Rank Math
docs.python.org and developer.mozilla.orgNoNo (404)

An llms.txt file is a Markdown index of a site’s key pages, written for AI tools. Ours lists 63 posts with a one-line summary each. That’s a ready-made crawl list.

No Markdown version? Most sites won’t have one. Fetch the HTML.

Step 2: fetch the HTML politely, and dodge the robots.txt trap

Send a user agent that names your bot, honour robots.txt, and keep the rate low. This script handles the first two and tries the .md version before the HTML. The loop in Step 6 adds a two-second pause between pages.

fetch_page.py Python
import requests
from urllib.parse import urlparse
from urllib.robotparser import RobotFileParser

UA = "MyRAGBot/1.0 (+https://example.com/bot)"

def allowed(url):
    root = "{0.scheme}://{0.netloc}".format(urlparse(url))
    # rp.read() would fetch as "Python-urllib", which many sites answer with 403
    r = requests.get(root + "/robots.txt", headers={"User-Agent": UA}, timeout=20)
    rp = RobotFileParser()
    rp.parse(r.text.splitlines() if r.ok else [])
    return rp.can_fetch(UA, url)

def fetch(url):
    if not allowed(url):
        raise PermissionError(f"robots.txt blocks {url}")
    # 1. Does the site already publish Markdown?
    md = requests.get(url.rstrip("/") + ".md", headers={"User-Agent": UA}, timeout=20)
    if md.ok and "markdown" in md.headers.get("content-type", ""):
        return "markdown", md.text
    # 2. Otherwise take the HTML
    r = requests.get(url, headers={"User-Agent": UA}, timeout=20)
    r.raise_for_status()
    if "charset" not in r.headers.get("content-type", "").lower():
        r.encoding = r.apparent_encoding   # stops "—" turning into "—"
    return "html", r.text

Our first version read robots.txt the textbook way, with RobotFileParser.read(). It blocked our own blog.

Why? That method fetches robots.txt as Python-urllib. DroidCrunch and Wikipedia both answer that user agent with a 403, and Python treats a 403 as “everything is disallowed”. Fetching the file yourself with your real user agent fixes it.

Common mistake: trusting r.text on pages that don’t declare a charset. Python’s docs send a bare text/html header, so requests guessed the wrong encoding and every dash in the title turned into —. The apparent_encoding line stops that.

Scraping more than a few hundred pages from one site? Slow down first. If you still get blocked, our guide to rotating proxies in Python covers the next step. And before you point a crawler at someone else’s content, read whether web scraping is legal for your use.

Step 3: which extractor gives you the cleanest Markdown?

Trafilatura, with a simple fallback for the pages it mangles. That pairing kept every test passage on all six HTML pages.

Here’s what each converter produced, in tokens. Fewer is better only when nothing important went missing.

PageRaw HTMLWhole page as MarkdownTrafilaturaMain-content selectorReadabilityJina Reader
Our RAG explainer119,9656,9332,9212,9543,0913,212
Our Firecrawl pricing post131,7738,4774,0674,1784,3244,655
Wikipedia: Web scraping71,61215,8566,2816,5832,886 ✗12,342
Python docs: robotparser7,6221,421623 ✗6947551,068
MDN: Using Fetch48,9819,9635,0965,2995,265 ✗4,730 ✗
GitHub README116,3536,3821,307 ✗1,8612,696 ✗blocked

✗ marks output that lost content we checked for. “Whole page as Markdown” is html2text run on the full page. Markdownify and Microsoft’s MarkItDown came within 2% of it across the six pages, menus included.

HTML to Markdown for LLMs: one page drops from 119,965 tokens of raw HTML to 6,933 as full-page Markdown and 2,921 as clean Markdown
Our own RAG explainer, measured with the o200k tokenizer. Most of the HTML is markup and scripts, and most of the full-page Markdown is menus and footer.

So the whole-page converters are out. They keep everything, junk included. The real contest is between the tools that hunt for the article.

Where each extractor broke

  • Readability (the readability-lxml port) kept one section of the Wikipedia article and dropped the rest: 1 of our 5 test passages. It also stripped every heading from MDN’s guide.
  • Trafilatura flattened the GitHub README to zero headings and skipped one method on the Python docs page. On Firecrawl’s docs it dropped all 17 code samples.
  • Jina Reader returned MDN’s guide with none of its 23 code samples. With the X-Engine: direct header and caching off, 22 came back. GitHub refused it outright: someone else’s traffic had got anonymous access to github.com blocked.
  • The main-content selector never lost a test passage. It did keep MDN’s “Was this page helpful?” footer, because that sits inside the article.

Trafilatura still gets the default slot, because its misses are easy to detect and its output is the tightest. The selector is the safety net.

to_markdown.py Python
import re
import trafilatura
from bs4 import BeautifulSoup
from markdownify import markdownify

DROP = "nav, header, footer, aside, script, style, form, svg, [role=navigation]"

def main_content(html):
    soup = BeautifulSoup(html, "lxml")
    root = soup.select_one("main, article, [role=main], #content") or soup.body
    for tag in root.select(DROP):
        tag.decompose()
    return markdownify(str(root), heading_style="ATX", strip=["a", "img"],
                       escape_underscores=False, escape_asterisks=False)

def looks_broken(md, html):
    code_in_html = html.count("<pre")
    code_in_md = md.count("```") // 2
    return (len(md) < 500                         # almost nothing came out
            or (code_in_html and code_in_md == 0)  # code samples vanished
            or md.count("\n#") < 2)                # headings vanished

def to_markdown(html):
    md = trafilatura.extract(html, output_format="markdown",
                             include_tables=True, include_formatting=True) or ""
    used = "trafilatura"
    if looks_broken(md, html):
        md, used = main_content(html), "fallback"
    title = BeautifulSoup(html, "lxml").title
    title = title.get_text(strip=True) if title else ""
    return title, re.sub(r"\n{3,}", "\n\n", md).strip(), used

Speed doesn’t decide it. Every local extractor finished a page in under a tenth of a second. Jina’s hosted reader took 1.5 to 2.6 seconds, since it fetches and renders the page itself.

Step 4: check the Markdown before it reaches your vector store

Three cheap checks catch most broken extractions: almost no text, code blocks that vanished, or headings that vanished. That’s the looks_broken() function above.

On our run it flagged the GitHub README and the Python docs page, then swapped in the selector output. Both came back complete, skipped method included.

It flags trafilatura’s Stripe and Firecrawl output too. We never needed it there, because Step 1 had already found their Markdown.

Then come the quiet errors that no size check will catch.

  • Escaped underscores. Markdownify’s default turns site_maps() into site\_maps(). A search for the function name then misses. Set escape_underscores=False.
  • Hidden emails. Cloudflare’s email protection meant the support address in our own Firecrawl FAQ came out as “[email protected]” in every extractor.
  • Wiki leftovers. Wikipedia text arrives with [edit] links and citation numbers like [11]. They’re harmless, but a regex pass trims them.

Real-world tip: open three converted pages from each site template and read them top to bottom. That’s how we spotted the escaped underscores and the hidden email. No check flagged either one.

Clean pages are half the job. How you cut them up decides what the model actually sees.

Step 5: chunk by heading, not by token count

Heading-aware chunks put the right answer first more often, sent less text to the model and never broke a code block.

We cut the same six pages two ways. Fixed chunks are 300-token windows with a 30-token overlap. Heading-aware chunks pack whole paragraphs under each heading, up to 300 tokens, with the page title and heading path on top.

Corpus and chunkingChunksRight chunk ranked firstRight chunk in top 3Tokens sent per answer (top 3)Code blocks cut in half
Whole page, fixed windows18410 of 1412 of 14900n/a
Clean Markdown, fixed windows8210 of 1414 of 1484812
Clean Markdown, by heading14912 of 1414 of 145330

Cleaning did the first job. The whole-page corpus was 54,372 tokens against 23,849 clean, and it missed two questions entirely.

Chunking by heading did the second. It sent 37% fewer tokens per answer. Only 4 of its 149 chunks opened on a lowercase word, the mark of a mid-sentence cut, against 43 of 82 fixed windows.

Website to Markdown for RAG: fixed 300-token chunks split 12 code blocks, while heading-aware chunks split none and send fewer tokens per answer
Same six pages, same embedding model. Chunking by heading kept every code block whole and cut the context per answer from 848 to 533 tokens.
chunk_markdown.py Python
import re
import tiktoken

enc = tiktoken.get_encoding("o200k_base")
tokens = lambda s: len(enc.encode(s, disallowed_special=()))

def blocks(md):
    # split on blank lines, but never inside a ``` code fence
    out, cur, fence = [], [], False
    for line in md.split("\n"):
        if line.strip().startswith("```"):
            fence = not fence
        if not fence and not line.strip() and cur:
            out.append("\n".join(cur)); cur = []
        elif line.strip() or fence:
            cur.append(line)
    return out + (["\n".join(cur)] if cur else [])

def split_long(text, max_tokens):
    # break an oversized paragraph or list at sentence or line ends
    parts, cur = [], ""
    for s in re.split(r"(?<=[.!?])\s+|\n", text):
        if cur and tokens(cur + " " + s) > max_tokens:
            parts.append(cur); cur = s
        else:
            cur = (cur + " " + s).strip()
    return parts + ([cur] if cur else [])

def chunk(md, title, url, max_tokens=300):
    path, buf, chunks = {}, [], []
    def flush():
        if buf:
            crumb = " > ".join([title] + [path[k] for k in sorted(path)])
            chunks.append({"url": url, "section": crumb,
                           "text": crumb + "\n\n" + "\n\n".join(buf)})
            buf.clear()
    for b in blocks(md):
        h = re.match(r"^(#{1,6})\s+(.+)$", b)
        if h:
            flush()
            level = len(h.group(1))
            path = {k: v for k, v in path.items() if k < level}
            path[level] = h.group(2).strip()
        else:
            pieces = [b]
            if tokens(b) > max_tokens and not b.startswith("```"):
                pieces = split_long(b, max_tokens)   # code blocks stay whole
            for p in pieces:
                if buf and tokens("\n\n".join(buf + [p])) > max_tokens:
                    flush()
                buf.append(p)
    flush()
    return chunks

Why 300 tokens? The bge-small model cuts off anything past 512 of its own tokens, so 300 by OpenAI’s count leaves headroom. With a bigger embedding model, go to 500 or so, then rerun your test questions.

Look at what the breadcrumb does. A Wikipedia chunk opens with Web scraping - Wikipedia > Web scraping > Legal issues. Even a short chunk now tells the embedding what it’s about.

Step 6: store each chunk with the metadata your RAG app needs

Every chunk should carry its URL, heading path, fetch date and a content hash. The URL lets the model cite its source. The hash lets you skip re-embedding pages that haven’t changed.

pipeline.py Python
import json, hashlib, time
from fetch_page import fetch
from to_markdown import to_markdown
from chunk_markdown import chunk

def ingest(url, out="chunks.jsonl"):
    kind, body = fetch(url)
    if kind == "markdown":
        title, md, used = url, body, "native .md"
    else:
        title, md, used = to_markdown(body)
    with open(out, "a") as f:
        for c in chunk(md, title or url, url):
            c["fetched_at"] = time.strftime("%Y-%m-%d")
            c["hash"] = hashlib.sha256(c["text"].encode()).hexdigest()[:16]
            f.write(json.dumps(c, ensure_ascii=False) + "\n")
    return used

for url in open("urls.txt").read().split():
    print(url, ingest(url))
    time.sleep(2)   # be polite

Our eight URLs produced 170 chunks and 26,096 tokens in 11.9 seconds. Two pages came from native Markdown, two from the fallback and four straight from trafilatura.

That JSONL file loads into any vector store. Rather not write the retrieval side? Our RAG chatbot tutorial in n8n builds it without code, and n8n self-hosts for free.

On a re-crawl, hash each new chunk and compare. Only the changed ones need new embeddings, which matters once you’re past a few thousand pages.

When is a hosted scraper worth paying for?

Pay when pages need a real browser, when the site fights bots, or when a crawl runs to thousands of pages. For a docs site that serves plain HTML, the scripts above cost nothing.

ToolBest forCost per 1,000 pagesWhat we saw
FirecrawlJavaScript-heavy pages and API-first crawls1 credit a page: about $3.80 on Hobby, $0.99 on Standard. Free plan: 1,000 pages a monthKeyless calls from our server got a 403 because the IP looked like a proxy. The error says a free key lifts that.
Jina ReaderQuick one-off conversionsFree at 20 requests a minute without a keyLost MDN’s code until we switched engines. Blocked github.com for anonymous users.
Apify Website Content CrawlerWhole-site crawls with sitemaps and llms.txtAbout $0.20 over plain HTTP, $0.50 to $5 in a browser. $5 free credit a monthNot run for this test

Firecrawl is the one we’d pick for an AI app, because one API call returns Markdown and the credit maths is simple. Our Firecrawl pricing breakdown shows which plan fits your volume, and the Firecrawl tutorial walks through your first scrape and crawl.

Apify fits when you want a whole site crawled on a schedule without writing code. Its crawler uses Mozilla’s Readability by default, so if pages come back thin, switch the HTML transformer to Defuddle and compare. Our guide to Apify Actors shows how runs and datasets work.

Torn between Firecrawl and Apify? Our Firecrawl vs Cloro vs Apify comparison weighs them, plus a third option, on cost and output.

Our take: start local. A hosted tool doesn’t excuse you from Step 4. Jina’s missing code samples looked fine until we counted them.

Frequently asked questions

Can this pipeline scrape JavaScript-heavy pages?

Not on its own. Requests only sees the HTML the server sends, so a React app can arrive as an empty shell. Render the page in a headless browser such as Playwright, pass the rendered HTML to the same to_markdown function, or use a hosted tool that renders pages for you.

Should I keep links in the Markdown I feed an LLM?

For embeddings, no. Jina’s version of the Wikipedia article kept 311 inline links and came to 12,342 tokens, against 6,281 for trafilatura without them. Keep the page URL in metadata instead. Keep inline links only when an agent needs to follow them to the next page.

Can I ask ChatGPT or Claude to convert HTML to Markdown instead?

You can, but you’d pay to read the noise. Our RAG explainer’s HTML was 119,965 tokens, so you’d send all of that to get about 2,921 back, page after page. Trafilatura did the same job locally in about 0.03 seconds for free. Save the model for pages your checks flag.

How often should I re-scrape pages for a RAG chatbot?

Match the site’s rhythm: weekly for busy docs, monthly for most blogs, and on release day for product docs you support. Use the sitemap to find new URLs, but don’t trust its dates alone, because some sites never update them. A cheap re-fetch tells you more than a stale timestamp.

Does the same approach work for PDFs?

No. Trafilatura only reads HTML, so a PDF needs a PDF parser such as PyMuPDF or Docling, or a hosted reader. Firecrawl charges one credit per PDF page, and Jina Reader accepts PDF links too. We didn’t test PDF extraction for this guide, so check tables and multi-column layouts by hand.

Is it OK to scrape websites for LLMs if robots.txt allows it?

Robots.txt tells crawlers where they may go. It isn’t a licence for the content. Terms of service and copyright still apply, and they matter most if you republish text or train a model on it. Using public docs to answer your own team’s questions is the lower-risk end.

What chunk size works best for website content?

There’s no universal number. Stay safely under your embedding model’s input limit, then test with 10 to 20 real questions whose answers you know. If the right chunk keeps missing the top three, try smaller chunks first. Huge chunks blur the embedding, and tiny ones lose the context around a fact.

Which setup should you use to scrape websites for LLMs?

When you scrape websites for LLMs, ask for the site’s own Markdown first. Nothing you build will beat the author’s version.

For everything else, run trafilatura, keep the selector fallback, and run the three checks on every page. Then chunk by heading, so each piece of clean Markdown carries its own context into your RAG pipeline.

Reach for Firecrawl or Apify when pages need a browser or the crawl gets big.

One warning: no extractor is safe unchecked. Trafilatura silently lost code, headings or whole sections on half our pages.

Your next step: run pipeline.py on ten of your own URLs and read three of the outputs before you embed anything.

Share4Tweet2Share1Share
Previous Post

AI Voice Agents Explained 2026: How Vapi and Retell Work, and What a Minute Costs

Next Post

Topical Authority 2026: How to Plan Topic Clusters for a Blog

Lokesh Kapoor

Lokesh Kapoor

I am Lokesh Kapoor who loves to write blogs, create videos and watch sci-fi movies on Netflix and Jio Cinema. DroidCrunch is my first love and a crucial part of my life.

Related Posts

Topical authority title card: a pillar page connected to its cluster pages by two-way links

Topical Authority 2026: How to Plan Topic Clusters for a Blog

October 30, 2026
298
AI voice agents explained title card: a phone call flowing through speech-to-text, an AI model and a voice

AI Voice Agents Explained 2026: How Vapi and Retell Work, and What a Minute Costs

October 29, 2026
299
How to speed up a WordPress site title card: a laptop showing a fast-loading page with a speed gauge in the green

How to Speed Up a WordPress Site 2026: 7 Fixes We Measured on Our Own Blog

October 29, 2026
299
RAG in n8n title card: documents flowing into a vector store and a chat window answering from them

How to Build a RAG Chatbot on Your Own Docs in n8n (No Code) 2026

October 28, 2026
299
Load More

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

I agree to the Terms & Conditions and Privacy Policy.

Youtube Instagram Telegram Discord Facebook Whatsapp Twitter LinkedIn

About

DroidCrunch Logo Dark

DroidCrunch is your trusted blog & YouTube channel dedicated to SAAS lovers, WordPress enthusiasts, and Technology Freaks.

Quick Links

  • About Us
  • Editorial Policy
  • Contact Us
  • Help & Support
  • RSS Feed

© 2026 DroidCrunch - Best Place for Tech NomadPrivacy PolicyTerms & ConditionsCookie PolicyGDPR RightsAffiliate Disclosure

Welcome Back!

Login to your account below

Forgotten Password?

Retrieve your password

Please enter your username or email address to reset your password.

Log In

Add New Playlist

No Result
View All Result
Subscribe
  • About
  • Blog
  • Categories
    • Automation
    • Tech
    • SEO
    • WordPress
    • Software
    • Hosting
    • Proxy
  • AI
    • AI Writing
    • AI Image
    • AI Tool
    • AI Video
  • Deals
  • Contact

© 2026 DroidCrunch - Best Place for Tech NomadPrivacy PolicyTerms & ConditionsCookie PolicyGDPR RightsAffiliate Disclosure

DroidCrunch Giveaway

Amazon Gift Card worth $15

Participate now and get a chance to become a lucky winner to receive the reward.​

Join Giveaway