arborist/aborist/sources/html_page.py
russell@unturf.com 856b3116d7
phase 0 explore: aborist core + sources + distill + evict
A content-addressed, Merkle-committed document store implementing the
runtime spec from Merkle Providence Reverse RAG (April 2026 whitepaper)
and Merkle-AGI v9.8 admissibility ledger. Ports proxy.unturf.com Go
merkle conventions to Python: non-commutative HashCombine with 0x03
prefix, explicit IsLeft per sibling, self-duplicate odd elements.

What's in:

- merkle.py — proof generation/verification, JSON serialization
- store.py — v9.8 SQLite schema: 8-dim providence_cache key,
  falsification_state, append-only audit chain, surface/core kind,
  hot/warm/cold tier, derivations, edges
- ingest.py — Source -> normalize -> chunk -> merkle -> upsert,
  idempotent on document_root collision
- search/ — SearchBackend ABC with explicit AuditMode (STRICT/HYBRID/
  VISUAL), FTS5 backend returning VISUAL hits
- sources/ — wikipedia.py (streaming bz2/MySQL extended-INSERT parser
  for 2003-era cur dumps); html_page.py (selectolax + httpx, robots.txt
  honored automatically)
- distill/ — Distiller ABC + first-sentence-v1 stub. Runner generates
  per-contributing-chunk Merkle proofs binding cores back to source
  document_root.
- evict.py — hot->cold demote (NULLs content, drops FTS row, retains
  leaf_hash). rehydrate() refetches via source pipeline; matching root
  restores content, mismatching root marks providence stale and writes
  rehydrate_drift event. Cores never evict.
- cli.py — ingest / search / verify / stats / distill / evict /
  rehydrate
- 31 tests covering merkle round-trip, ingest+audit, chunker version
  binding, html parse, distillation proof verification, evict+
  rehydrate including drift detection.

Smoke: 503 Wikipedia 2003-05-16 + 3 fox-owned HTML pages ingested,
478 cores produced (24 surface->core merkle dedups), 7 chunks evicted
to cold and round-tripped via rehydrate, 987 audit events chained 0
breaks.
2026-04-27 07:53:18 -04:00

146 lines
4.7 KiB
Python

"""HTML page source.
Fetches URLs, honors robots.txt automatically, strips noise (script/style/nav/
footer/header), extracts main body text + outbound `<a href>` links as edges.
Optional dependency. Install with `pip install aborist[html]`.
"""
from __future__ import annotations
import re
import urllib.parse
import urllib.robotparser
from pathlib import Path
from typing import Iterable, Iterator
try:
import httpx
from selectolax.parser import HTMLParser
except ImportError as e: # pragma: no cover
raise ImportError(
"HTML source requires extras: pip install 'aborist[html]'"
) from e
from aborist.document import Document, Edge
from aborist.source import Source
USER_AGENT = "aborist/0.0.1 (+https://unturf.com)"
NOISE_SELECTORS = ("script", "style", "noscript", "nav", "header", "footer", "aside")
def _normalize_text(text: str) -> str:
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def parse_html(url: str, html: str, source_type: str = "html") -> Document | None:
"""Pure parse function. Separated so tests can run without network."""
tree = HTMLParser(html)
for sel in NOISE_SELECTORS:
for node in tree.css(sel):
node.decompose()
body = tree.css_first("body") or tree.root
if body is None:
return None
text = _normalize_text(body.text(separator="\n", strip=True))
if not text:
return None
title_node = tree.css_first("title")
title = title_node.text(strip=True) if title_node is not None else None
edges: list[Edge] = []
seen: set[tuple[str, str]] = set()
for a in tree.css("a[href]"):
href = (a.attributes.get("href") or "").strip()
if not href or href.startswith(("javascript:", "mailto:", "tel:", "#")):
continue
absolute = urllib.parse.urljoin(url, href)
split = urllib.parse.urlsplit(absolute)
if split.scheme not in ("http", "https"):
continue
anchor = split.fragment or ""
dst_uri = urllib.parse.urlunsplit(split._replace(fragment=""))
key = (dst_uri, anchor)
if key in seen:
continue
seen.add(key)
edges.append(Edge(edge_type="hyperlink", dst_uri=dst_uri, anchor=anchor or None))
return Document(
uri=url,
content=text,
source_type=source_type,
title=title,
edges=edges,
)
class HtmlPageSource(Source):
"""Iterates a list of URLs, fetching and parsing each as HTML."""
source_type = "html"
def __init__(
self,
urls: Iterable[str],
*,
respect_robots: bool = True,
timeout: float = 30.0,
):
self.urls = list(urls)
self.respect_robots = respect_robots
self.timeout = timeout
self._robots_cache: dict[str, urllib.robotparser.RobotFileParser] = {}
@classmethod
def from_file(cls, path: str | Path, **kwargs) -> HtmlPageSource:
urls = [
line.strip()
for line in Path(path).read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
return cls(urls, **kwargs)
def iter_documents(self) -> Iterator[Document]:
with httpx.Client(
headers={"User-Agent": USER_AGENT},
timeout=self.timeout,
follow_redirects=True,
) as client:
for url in self.urls:
if self.respect_robots and not self._allowed(client, url):
continue
try:
resp = client.get(url)
resp.raise_for_status()
except httpx.HTTPError:
continue
ctype = resp.headers.get("content-type", "").lower()
if "html" not in ctype and "xml" not in ctype:
continue
doc = parse_html(str(resp.url), resp.text, self.source_type)
if doc is not None:
yield doc
def _allowed(self, client: "httpx.Client", url: str) -> bool:
parsed = urllib.parse.urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}"
rp = self._robots_cache.get(origin)
if rp is None:
rp = urllib.robotparser.RobotFileParser()
try:
resp = client.get(f"{origin}/robots.txt")
except httpx.HTTPError:
resp = None
if resp is not None and resp.status_code == 200:
rp.parse(resp.text.splitlines())
else:
# Missing robots.txt = no rules per RFC 9309.
rp.allow_all = True
self._robots_cache[origin] = rp
return rp.can_fetch(USER_AGENT, url)