If you feed web pages to a search index or a retrieval-augmented generation (RAG) pipeline, you usually want three things: only the main content, as plain Markdown, split into sections that have stable names. When the page is updated, you also want to know which sections changed, so you re-embed a few chunks instead of the whole page.

This post builds that with Python's standard library only. The output is CommonMark, plus GitHub Flavored Markdown pipe tables for HTML tables. The script:

  1. parses HTML into a small tree,
  2. keeps the main content and drops navigation, scripts and decorative noise,
  3. renders headings, paragraphs, lists, code blocks, tables and links as Markdown,
  4. splits the result into one chunk per section, named by its heading path,
  5. compares two versions and sorts each section into added, removed, renamed, changed, links-only or unchanged.

The real run below uses two versions of the same page: the json module documentation for Python 3.13 and for Python 3.14, fetched on 27 September 2026 (UTC). docs.python.org/robots.txt disallows /dev, /release and end-of-life versions such as /3.9/; /3.13/ and /3.14/ are allowed. That gives a genuine before and after without waiting for a page to change.

Step 1: parse into a small tree

html.parser is an event parser: it reports start tags, end tags and text. Building a tree from those events takes a stack. Void elements such as <br> and <img> never get an end tag, so they are not pushed. An end tag closes the nearest open element with the same name; a stray end tag with no match is ignored.

Excerpt of page_to_chunks.py, lines 29-50:

class TreeBuilder(HTMLParser): """Build a small tree of {"tag", "attrs", "children"} dicts; text stays as plain strings.""" def __init__(self): super().__init__(convert_charrefs=True) self.root = {"tag": "#root", "attrs": {}, "children": []} self.stack = [self.root] def handle_starttag(self, tag, attrs): node = {"tag": tag, "attrs": {k: v or "" for k, v in attrs}, "children": []} self.stack[-1]["children"].append(node) if tag not in VOID: self.stack.append(node) def handle_endtag(self, tag): for depth in range(len(self.stack) - 1, 0, -1): # close the nearest open match, if any  if self.stack[depth]["tag"] == tag: del self.stack[depth:] break def handle_data(self, data): self.stack[-1]["children"].append(data) 

This is deliberately simple. It is not a full HTML5 parser: it doesn't apply the rules that implicitly close a <p> when a <div> starts, for example. For well-formed pages, such as most documentation sites, that is fine. For messy HTML, parse with html5lib or lxml and keep the rendering part of this post.

Step 2: keep the main content, drop the noise

The script looks for <main>, then <article>, then any element with role="main", then <body>. Inside it, whole elements are skipped by tag (scripts, styles, navigation, footers, forms) and by class.

Excerpt of page_to_chunks.py, lines 20-27:

USER_AGENT = "html-to-chunks-example/1.0 (tutorial script; one request per page)" VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"} SKIP = {"script", "style", "noscript", "template", "svg", "nav", "footer", "form", "button", "iframe", "aside"} NOISE_CLASSES = {"headerlink"} # e.g. the pilcrow permalinks Sphinx adds after headings BLOCKS = {"p", "div", "section", "article", "main", "blockquote", "pre", "ul", "ol", "table", "dl", "dt", "dd", "hr", "figure", "h1", "h2", "h3", "h4", "h5", "h6"} | SKIP 

Excerpt of page_to_chunks.py, lines 64-78:

def main_content(root): for test in (lambda n: n["tag"] == "main", lambda n: n["tag"] == "article", lambda n: n["attrs"].get("role") == "main", lambda n: n["tag"] == "body"): hit = find(root, test) if hit: return hit return root def ignored(node): return node["tag"] in SKIP or bool(NOISE_CLASSES & set(node["attrs"].get("class", "").split())) def collapse(text): return re.sub(r"\s+", " ", text).strip() 

The class list is where site-specific cleanup goes. Sphinx, the documentation generator behind the Python docs, puts a permalink after every heading with the class headerlink. Without that one entry, every heading in the output would end in and every chunk name would carry it.

Step 3: inline Markdown

Inline content becomes one line: bold and italics, inline code, image alt text, and links. Links are made absolute with urljoin, because a relative link is useless once the text leaves the page. Links to anchors on the same page (#...) keep only their text.

Excerpt of page_to_chunks.py, lines 81-108:

def text_of(node): """Raw text with whitespace kept (for <pre>), minus ignored elements.""" if isinstance(node, str): return node return "" if ignored(node) else "".join(text_of(child) for child in node["children"]) def inline(node, base): """Render inline content as Markdown on one line.""" if isinstance(node, str): return node if ignored(node): return "" tag, attrs = node["tag"], node["attrs"] if tag == "br": return " " if tag == "img": return attrs.get("alt", "") inner = "".join(inline(child, base) for child in node["children"]) if tag == "code": return f"`{collapse(text_of(node))}`" if collapse(text_of(node)) else "" if tag in ("strong", "b", "em", "i") and collapse(inner): mark = "**" if tag in ("strong", "b") else "*" return f"{mark}{collapse(inner)}{mark}" href = attrs.get("href", "") if tag == "a" and collapse(inner) and href and not href.startswith(("#", "javascript:")): return f"[{collapse(inner)}]({urllib.parse.urljoin(base, href)})" return inner 

Step 4: blocks, lists and tables

render() walks the children of a node. Text and inline elements are collected into a "run" until a block element appears; then the run is flushed as a paragraph. Headings, code blocks, lists, tables and definition lists each get their own branch. Code blocks keep their whitespace; everything else is collapsed.

Excerpt of page_to_chunks.py, lines 149-185:

def render(node, base, out): """Append Markdown blocks (strings) for the children of `node` to `out`.""" run = [] def flush(): text = collapse("".join(run)) run.clear() if text: out.append(text) for child in node["children"]: if isinstance(child, str) or child["tag"] not in BLOCKS: run.append(inline(child, base)) continue flush() tag = child["tag"] if ignored(child) or tag == "hr": continue if re.fullmatch(r"h[1-6]", tag): title = collapse(inline(child, base)) if title: out.append("#" * int(tag[1]) + " " + title) elif tag == "pre": out.append("``` \n" + text_of(child).strip("\n") + "\n ```") elif tag in ("ul", "ol"): render_list(child, base, out) elif tag == "table": out.append(render_table(child, base)) elif tag == "dt": out.append(collapse(text_of(child))) elif tag == "blockquote": quoted = [] render(child, base, quoted) out.extend("> " + block.replace("\n", "\n> ") for block in quoted) else: render(child, base, out) flush() 

Definition terms (<dt>) become plain text. In Sphinx output they hold function signatures, and the * in a signature such as json.dump(obj, fp, *, ...) would break Markdown emphasis if it were wrapped in **.

Tables become pipe tables. Pipes inside cells are escaped, short rows are padded, and colspan/rowspan are ignored, so complex tables lose their structure. That is a known limit.

Excerpt of page_to_chunks.py, lines 125-137:

def render_table(node, base): rows = [] for row in find_all(node, "tr"): cells = [collapse(inline(cell, base)).replace("|", "\\|") for cell in row["children"] if isinstance(cell, dict) and cell["tag"] in ("th", "td")] if cells: rows.append(cells) if not rows: return "" width = max(len(row) for row in rows) rows = [row + [""] * (width - len(row)) for row in rows] lines = ["| " + " | ".join(rows[0]) + " |", "|" + " --- |" * width] return "\n".join(lines + ["| " + " | ".join(row) + " |" for row in rows[1:]]) 

Step 5: one chunk per section

Each heading up to level 3 starts a new chunk. The chunk's id is its heading path, such as Guide > Setup. Deeper headings stay inside their parent's chunk. When two sections share a path, the second gets #2.

Every chunk gets two hashes:

  • sha256 of the exact text, to answer "is this byte-for-byte the same?",
  • fingerprint, the hash of the text with link targets removed and whitespace collapsed, to answer "did the words change?".

The real run shows why the second one matters.

Excerpt of page_to_chunks.py, lines 197-229:

def prose(text): """Text with link targets and whitespace differences removed.""" return collapse(re.sub(r"\]\([^)]*\)", "]", text)) def fingerprint(text): return hashlib.sha256(prose(text).encode("utf-8")).hexdigest()[:16] def chunk(blocks, max_level=3): """Group blocks under their nearest heading (h1..h{max_level}); one chunk per section.""" chunks, path, body, seen = [], [], [], {} def emit(): text = "\n\n".join(body).strip() body.clear() if text: name = " > ".join(path) or "(top)" seen[name] = seen.get(name, 0) + 1 chunk_id = name if seen[name] == 1 else f"{name} #{seen[name]}" chunks.append({"id": chunk_id, "words": len(text.split()), "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest()[:16], "fingerprint": fingerprint(text), "text": text}) for block in blocks: heading = re.match(r"(#{1,6}) (.*)", block) if heading and len(heading.group(1)) <= max_level: emit() del path[len(heading.group(1)) - 1:] path.append(heading.group(2)) body.append(block) emit() return chunks 

Step 6: compare two versions

Sections are matched by id. A renamed heading changes the id, so it first shows up as one removed and one added section. compare() pairs those up when their text is at least 60% similar according to difflib.SequenceMatcher, and reports them as renamed. The 0.6 threshold is a judgment call; the test below also checks that a strict threshold turns pairing off.

Excerpt of page_to_chunks.py, lines 232-253:

def compare(old, new, rename_threshold=0.6): """Classify chunks by id: added, removed, renamed, changed (prose), links-only, unchanged.""" before = {c["id"]: c for c in old} after = {c["id"]: c for cSource:https://dev.to/abdulwhab95/html-to-clean-markdown-chunks-in-python-and-spotting-what-really-changed-1ibm

Related posts