Website to LLM text: a site into clean pages with Python
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#
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]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 siteOn 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:
{
"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
- 3 min read
robots.txt readers disagree, and the built-in one is the odd one out
Python's built-in reader takes the first matching rule and ignores wildcards. The standard takes the longest match. We ran both on 12,000 random files and listed where they split.
- 2 min read
The price is a string: reading money off a web page
Shops print the same number a dozen ways. A careful parser reads what is clear, leaves the rest empty and never fills a gap with a plausible guess.
- 3 min read
A timestamp without a zone is a guess
Scraped dates arrive as "2026-03-08 02:30" with nothing else. Some of those times never happened, some happened twice, and the gap between two cities moves during the year.