""" Convert HTML to clean Markdown-ish text for LLM extraction. Strips scripts, styles, nav chrome where possible, and avoids feeding raw nested
trees to the model. """ from __future__ import annotations import re from typing import Optional from urllib.parse import urljoin from bs4 import BeautifulSoup, NavigableString, Tag _STRIP_TAGS = ( "script", "style", "noscript", "svg", "template", "iframe", "object", "embed", "link", "meta", "head", ) def html_to_markdown( html: str, *, source_url: str = "", max_chars: int = 120_000, ) -> str: """ Produce compact Markdown-like text from HTML. Uses BeautifulSoup only (no html2text dependency). Truncates to ``max_chars``. """ soup = BeautifulSoup(html or "", "html.parser") for tag in soup(list(_STRIP_TAGS)): tag.decompose() root = soup.find("main") or soup.find("article") or soup.find(id="content") or soup.body or soup parts: list[str] = [] if source_url: parts.append(f"# Source\n\n{source_url}\n") _walk(root, parts, base_url=source_url) body = "\n\n".join(p for p in parts if p.strip()) body = re.sub(r"\n{3,}", "\n\n", body).strip() if len(body) > max_chars: body = body[:max_chars] + "\n\n...[truncated for LLM context]..." return body def _walk(node: Tag | NavigableString, parts: list[str], *, base_url: str, depth: int = 0) -> None: if isinstance(node, NavigableString): text = str(node).strip() if text: parts.append(text) return if not isinstance(node, Tag): return name = (node.name or "").lower() if name in _STRIP_TAGS: return if name in ("h1", "h2", "h3", "h4", "h5", "h6"): level = int(name[1]) inner = _inline_text(node).strip() if inner: parts.append(f"{'#' * level} {inner}") return if name == "a": href = (node.get("href") or "").strip() label = _inline_text(node).strip() or href if href and label: abs_url = urljoin(base_url, href) if base_url else href parts.append(f"[{label}]({abs_url})") elif label: parts.append(label) return if name == "li": inner = _inline_text(node).strip() if inner: parts.append(f"- {inner}") return if name in ("p", "div", "section", "article", "main", "td", "th", "tr", "ul", "ol", "table"): for child in node.children: _walk(child, parts, base_url=base_url, depth=depth + 1) return for child in node.children: _walk(child, parts, base_url=base_url, depth=depth + 1) def _inline_text(node: Tag) -> str: return node.get_text(" ", strip=True) def readable_txt_to_markdown(text: str, *, max_chars: int = 120_000) -> str: """Use an existing ``page_*.readable.txt`` sidecar (already stripped) as LLM input.""" lines = text.splitlines() if lines and lines[0].startswith("Source:"): body = "\n".join(lines[2:]).strip() if len(lines) > 2 else text else: body = text.strip() if len(body) > max_chars: body = body[:max_chars] + "\n\n...[truncated]..." return body