← blog  /  scrapers

Website to LLM text: a site into clean pages with Python

2026-10-26  ·  6 min read  ·  scrapers

For search over your docs or a chat with a site, a model needs the words of each page, not its menus. About seventy lines of standard-library Python do it for a small site. We ran them on this one: 46 pages, 33,009 words, 132 chunks.

Feeding a website to a language model, for retrieval (RAG) or a chat over your docs, is mostly not about the model. It is about getting each page's own words out, once, without the menu, the footer and the cookie banner repeated on every page.

For a small site, the Python standard library is enough. Below is the whole thing, and what it gave on voiddo.com on 25 October 2026.

Start from the sitemap#

Following links works, but a sitemap is quicker and tells you which pages the owner wants found. Most sites name it in robots.txt, and Python's urllib.robotparser reads that line for you (site_maps()). A big site splits its list: one sitemap file may hold at most 50,000 addresses, so large sites publish a sitemap index that lists the other files. Read the index, then each file.

Keep only addresses on the same host, and only those robots.txt leaves open. One warning about that check: Python's built-in robots.txt reader takes the first matching rule and ignores wildcards, unlike the standard. On a site whose robots.txt uses * or $, use a reader that follows the standard. We tested both on 12,000 files.

Keep the page, drop the furniture#

Most templates put the page's own content in <main>. Take that, and when a page has none, take <body> without nav, header, footer, aside, forms and scripts.

It matters more than it looks. On this site the menus and footers come to 4,465 of the 37,474 words, 12%. On most pages that is the same 70 to 100 words, stored again with every page, and every one of those pages then partly matches any question about the menu.

The code#

Site to clean text: sitemap, pages, chunkspython
import re, urllib.request, urllib.robotparser
from html.parser import HTMLParser
from urllib.parse import urljoin, urlparse

UA = "site-to-text/1.0"
SKIP = {"script", "style", "noscript", "svg", "nav", "header", "footer", "aside", "form", "template"}
BLOCK = {"p", "li", "h1", "h2", "h3", "h4", "pre", "tr", "blockquote", "div", "section", "article", "br"}

def fetch(url):
    with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": UA}), timeout=30) as r:
        return r.read().decode(r.headers.get_content_charset() or "utf-8", "replace")

class Page(HTMLParser):
    """Title, first h1 and the text of <main> (or <body>), without menus, footers and scripts."""
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.title, self.h1, self.text, self.main_text = "", "", [], []
        self.stack, self.skip, self.in_main = [], 0, 0
    def handle_starttag(self, tag, attrs):
        if tag in ("br", "img", "hr", "meta", "link", "input"):
            return
        self.stack.append(tag)
        self.skip += tag in SKIP
        self.in_main += tag == "main"
        if tag in BLOCK:
            self.text.append("\n")
            if self.in_main: self.main_text.append("\n")
    def handle_endtag(self, tag):
        while self.stack:
            t = self.stack.pop()
            self.skip -= t in SKIP
            self.in_main -= t == "main"
            if t == tag:
                break
    def handle_data(self, data):
        here = self.stack[-1] if self.stack else ""
        if here == "title": self.title += data
        if self.skip: return
        if here == "h1": self.h1 += data
        self.text.append(data)
        if self.in_main: self.main_text.append(data)

def clean(parts):
    lines = (" ".join(l.split()) for l in "".join(parts).split("\n"))
    return "\n".join(l for l in lines if l)

def page_row(url):
    p = Page(); p.feed(fetch(url))
    text = clean(p.main_text) or clean(p.text)
    return {"url": url, "title": p.title.strip(), "h1": " ".join(p.h1.split()), "text": text,
            "words": len(re.findall(r"\w+", text))}

def sitemap_urls(site):
    robots = urllib.robotparser.RobotFileParser(urljoin(site, "/robots.txt")); robots.read()
    maps = robots.site_maps() or [urljoin(site, "/sitemap.xml")]
    urls = []
    while maps:
        xml = fetch(maps.pop())
        locs = re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", xml)
        if "<sitemapindex" in xml: maps += locs          # an index of sitemaps: read each one
        else: urls += locs
    host = urlparse(site).netloc
    return [u for u in dict.fromkeys(urls) if urlparse(u).netloc == host and robots.can_fetch(UA, u)]

def chunks(row, size=300):
    """Pieces of about `size` words, cut between lines, each carrying its page's address and title."""
    out, cur = [], []
    for line in row["text"].split("\n"):
        if cur and len(" ".join(cur + [line]).split()) > size:
            out.append(cur); cur = []
        cur.append(line)
    if cur: out.append(cur)
    return [{"url": row["url"], "title": row["title"], "text": "\n".join(c)} for c in out]
Run it, one page a secondpython
urls = sitemap_urls("https://voiddo.com/")
rows = []
for u in urls:
    rows.append(page_row(u))
    time.sleep(1)                                    # one page a second is plenty for a small site

On voiddo.com that took 47 seconds and gave 46 pages, 33,009 words, a median page of 680 words and no page under 60 words. chunks() cut them into 132 pieces of at most 300 words. One row, with its text shortened here:

One pagejson
{
  "url": "https://voiddo.com/blog/job-board-apis-greenhouse-lever-ashby-workday",
  "title": "Greenhouse, Lever, Ashby and Workday job board APIs · vøiddo",
  "h1": "Greenhouse, Lever, Ashby and Workday job board APIs",
  "text": "← blog / scrapers\nGreenhouse, Lever, Ashby and Workday job board APIs\n7 min read · scrapers\nFour hiring systems publish their open jobs as JSON ...",
  "words": 1665
}

What is still wrong with that text#

Look at the first lines: a breadcrumb ("← blog / scrapers") and a reading time. <main> holds some furniture of its own, and so does every site, each in its own way. You cut it with one or two rules per site, after reading a few pages, not with a smarter general rule.

Three more things the short version does not handle:

  • Pages drawn by JavaScript. If a page comes back with a few dozen words but your browser shows a full article, the text arrives later through JavaScript. Plain HTTP will not see it, and you need a browser for those pages.
  • Sites without a sitemap, or with one that is years old. Then you follow links from a start page and must decide how deep to go and which sections to stay in.
  • Duplicates. The same page under two addresses (with and without a trailing slash, or with tracking parameters) gives the same text twice. Compare the text, not only the address.

When to hand it over#

For your own site or a few docs sites, keep the code above. For sites you do not control, on a schedule, without writing the per-site plumbing, there is our Website Content Crawler on Apify. You give it start pages. It follows links within the same site and the same path (so docs.example.com/guide/ stays in the guide), to the depth you set, with a page cap per start page and include or exclude patterns. It reads only what robots.txt leaves open.

Each page comes back as one row: address, title, h1, breadcrumbs, description, clean text and word count. That is clean text, not Markdown. You can export the rows as JSON, CSV or Excel, read them through the API, or let an agent call it over MCP. It is billed per page.

written