arborist/aborist/sources/crawler/bridge.py
russell@unturf.com 43529328fd
crawler: skip feeds + sitemaps at ingest (they're discovery, not knowledge)
Per fox: a query for "who is Russell Ballestrini" classified STRICT by
grounding 6/6 quotes against `feeds/all.atom.xml` — a 230-chunk dump
of post metadata. The verifier was technically correct (every quoted
string IS in the feed) but the result was hollow: feeds list URLs,
they don't carry knowledge. The ACTUAL bios live at the linked posts.

Two-pass filter in _CrawledHtmlSource.iter_documents:

1. Pre-fetch URL-pattern check (_looks_like_feed_url):
   - suffix matches: .atom, .atom.xml, .rss, .rss.xml, .rdf,
     /feed.xml, /atom.xml, /rss.xml, /rss2.xml, wp-rss2.xml,
     wp-atom.xml, wp-rdf.xml, wp-rss.xml
   - substring matches in path: /feed/, /feeds/, /atom, /rss, /sitemap
2. Post-fetch Content-Type check (_looks_like_feed_response):
   - rejects: application/atom+xml, application/rss+xml,
     application/rdf+xml, application/xml, text/xml
   - keeps: application/xhtml+xml (xhtml IS html, just stricter syntax)

Defense in depth — a feed served at a non-feed path (e.g.
/index.html returning text/xml) still gets dropped on Content-Type.

What stays out of the corpus:
  /feeds/all.atom.xml      (Atom feeds)
  /sitemap.xml             (XML sitemaps)
  /wp-rss2.xml             (WordPress RSS)
  /feed/                   (any feed alias)

What's still allowed:
  /post-name/              (real prose pages)
  /index.html              (HTML)
  /page.xhtml              (xhtml)

Tests: 12 new parametric cases (8 path patterns + 6 Content-Types +
xhtml positive case). 36 bridge tests + 273 default suite, all
passing.

For fox: the existing feed entry already in the crawl shard was burned
manually via `aborist burn --kind document --root eaf7c8d5...` — 230
chunks gone. Re-running `make crawl-ingest` won't re-introduce it.
2026-04-29 05:55:28 -04:00

423 lines
14 KiB
Python

"""Bridge: aborist sources/crawler ↔ aborist ingest pipeline.
Two operations:
1. **crawl_seed(seed_url, depth, max_pages)** — BFS-discovers same-domain
URLs from a seed, respecting robots.txt + crawl delays. Returns a
list of URLs ready for ingest.
2. **ingest_crawled(conn, urls)** — fetches each URL with httpx,
captures ETag + Last-Modified, parses HTML, runs through the
standard ingest_source path so the documents land in the same
tables as any other source. After ingest, writes one row per
document into ``document_http_meta`` so a future recrawl-check can
send conditional HEAD requests.
Off by default — both operations require ``aborist[crawler]`` extras.
"""
from __future__ import annotations
import asyncio
import time
import urllib.parse
import urllib.robotparser
from collections import deque
from pathlib import Path
from typing import Iterable, Iterator
import httpx
from aborist.document import Document
from aborist.ingest import ingest_source
from aborist.progress import Progress
from aborist.sources.crawler.async_web_fetcher import AsyncWebFetcher
from aborist.sources.html_page import USER_AGENT, parse_html
from aborist.store import transaction
def _same_domain(a: str, b: str) -> bool:
"""True iff both URLs share an exact hostname (no subdomain crossover)."""
return urllib.parse.urlparse(a).netloc == urllib.parse.urlparse(b).netloc
def _normalize(url: str) -> str:
"""Canonical form for dedup: scheme + netloc + path + query, no fragment."""
parsed = urllib.parse.urlparse(url)
cleaned = parsed._replace(fragment="")
return urllib.parse.urlunparse(cleaned)
# Feeds, sitemaps, and similar XML discovery artifacts are crawl
# infrastructure — they tell you which URLs exist on a site. They are
# NOT knowledge content, so they must not enter the Merkle tree as
# documents. We exclude them in two passes: (1) URL pattern match on
# the path so we skip even before the response arrives, (2) content-
# type check on the response so feeds served at unconventional paths
# still get rejected. xhtml is preserved because it IS HTML.
_FEED_URL_SUFFIXES = (
".atom",
".atom.xml",
".rss",
".rss.xml",
".rdf",
# Common feed filename patterns regardless of where they live.
"/feed.xml",
"/atom.xml",
"/rss.xml",
"/rss2.xml",
"wp-rss2.xml", # WordPress legacy
"wp-rss.xml",
"wp-atom.xml",
"wp-rdf.xml",
)
_FEED_URL_SUBSTRINGS = (
"/feed/",
"/feeds/",
"/atom",
"/rss",
"/sitemap", # sitemap.xml, sitemap_index.xml, sitemaps/foo.xml
)
_FEED_CONTENT_TYPES = (
"application/atom+xml",
"application/rss+xml",
"application/rdf+xml",
"application/xml",
"text/xml",
)
def _looks_like_feed_url(url: str) -> bool:
"""True if the URL path matches a known feed/sitemap pattern."""
path = urllib.parse.urlparse(url).path.lower()
if any(path.endswith(s) for s in _FEED_URL_SUFFIXES):
return True
if any(s in path for s in _FEED_URL_SUBSTRINGS):
return True
if path.endswith("/sitemap.xml") or path.endswith("sitemapindex.xml"):
return True
return False
def _looks_like_feed_response(content_type: str) -> bool:
"""True if a response Content-Type is a feed/sitemap rather than HTML."""
ct = content_type.lower().split(";", 1)[0].strip()
return ct in _FEED_CONTENT_TYPES
async def _crawl_seed_async(
seed_url: str,
*,
max_depth: int,
max_pages: int,
fetcher: AsyncWebFetcher | None = None,
progress: Progress | None = None,
fast: bool = False,
) -> list[str]:
"""BFS from `seed_url`, staying on the same hostname.
Discovery only — fetches each page enough to extract links, no
aborist-side ingestion happens here. Returns a deduplicated list
of URLs in BFS order, capped at `max_pages`.
Pass ``progress`` (an `aborist.progress.Progress`) for stderr
heartbeats while the BFS runs. Lines are rate-limited by the
Progress instance (default 2s) so a fast crawl prints once or
twice; a slow one prints every interval.
``fast=True`` flips the verbatim AsyncWebFetcher into fast_mode:
5s timeouts (vs 15-60s), CPU*3 parallel page workers, and the
target's robots.txt ``crawl-delay`` directive is ignored. Robots
Disallow is still honored. Use only against domains where
aggressive fetching is acceptable (your own sites, dumps you've
been authorized to mirror, etc.).
"""
fetcher = fetcher or AsyncWebFetcher(user_agent=USER_AGENT, fast_mode=fast)
seen: set[str] = set()
discovered: list[str] = []
queue: deque[tuple[str, int]] = deque([(_normalize(seed_url), 0)])
# max_pages == 0 means "no cap" — depth is the only bound. Useful for
# exhaustive same-domain crawls where the operator trusts depth to
# bound the crawl naturally.
while queue and (max_pages == 0 or len(discovered) < max_pages):
url, depth = queue.popleft()
if url in seen:
continue
seen.add(url)
try:
# extract_links=True is REQUIRED for BFS — the verbatim
# AsyncWebFetcher defaults it to False (caller opts in for the
# link-graph use case). Without it BFS terminates at the seed.
html, links = await fetcher.fetch_webpage(url, extract_links=True)
except Exception:
continue
if html is None:
continue
discovered.append(url)
if progress is not None:
progress.tick(len(discovered), inserted=len(queue))
if depth >= max_depth:
continue
for link in links or []:
link_url = link[0] if isinstance(link, tuple) else link
if not isinstance(link_url, str):
continue
absolute = urllib.parse.urljoin(url, link_url)
normalized = _normalize(absolute)
if normalized in seen:
continue
if not _same_domain(seed_url, normalized):
continue
if not normalized.startswith(("http://", "https://")):
continue
queue.append((normalized, depth + 1))
if progress is not None:
progress.done(len(discovered), inserted=len(queue))
return discovered
def crawl_seed(
seed_url: str,
*,
max_depth: int = 2,
max_pages: int = 0,
progress: Progress | None = None,
fast: bool = False,
) -> list[str]:
"""Sync wrapper around the async crawler. Returns same-domain URLs.
``max_pages=0`` means "no cap" — depth is the only bound. Pass
``progress`` for stderr heartbeats while BFS runs. ``fast=True``
enables AsyncWebFetcher's fast_mode (5s timeouts, CPU*3 workers,
ignores robots crawl-delay).
"""
return asyncio.run(
_crawl_seed_async(
seed_url,
max_depth=max_depth,
max_pages=max_pages,
progress=progress,
fast=fast,
)
)
class _CrawledHtmlSource:
"""Source that fetches each URL synchronously, captures HTTP metadata,
parses HTML, and yields one Document. The metadata is recorded in a
side dict so the caller can write it to ``document_http_meta`` after
ingest commits the document_root.
"""
source_type = "html"
def __init__(self, urls: Iterable[str], *, timeout: float = 30.0):
self.urls = list(urls)
self.timeout = timeout
# Filled in during iter_documents — keyed by document_uri (request
# URL pre-redirect) so the caller can map document_root → metadata
# via the documents.document_uri column after ingest.
self.http_meta: dict[str, dict[str, object]] = {}
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:
# Cheap pre-flight: skip feed/sitemap URLs without a fetch.
if _looks_like_feed_url(url):
continue
try:
resp = client.get(url)
resp.raise_for_status()
except httpx.HTTPError:
continue
ctype = resp.headers.get("content-type", "").lower()
# Defense in depth: a feed served at a non-feed path still
# gets rejected by Content-Type.
if _looks_like_feed_response(ctype):
continue
if "html" not in ctype:
continue
doc = parse_html(str(resp.url), resp.text, self.source_type)
if doc is None:
continue
self.http_meta[doc.uri] = {
"etag": resp.headers.get("etag"),
"last_modified": resp.headers.get("last-modified"),
"last_fetched_at": int(time.time()),
}
yield doc
def ingest_crawled(
conn,
urls: Iterable[str],
*,
timeout: float = 30.0,
progress: Progress | None = None,
) -> dict:
"""Fetch each URL, ingest into `conn`, record ETag + Last-Modified.
Returns a summary dict with counts and the list of (uri, document_root)
pairs written. The caller's connection must be writable; metadata is
persisted in the same connection so an interrupted run leaves either
the document AND its http_meta or neither (FK guarantees this on
cascade delete; the upsert happens inside the same transaction
boundary as the audit event).
Pass ``progress`` for stderr heartbeats while ingest runs (the
underlying ingest_source supports it natively).
"""
src = _CrawledHtmlSource(urls, timeout=timeout)
stats = ingest_source(conn, src, progress=progress)
written: list[dict] = []
if not src.http_meta:
return {
"seen": stats.seen,
"inserted": stats.inserted,
"http_meta_written": 0,
"documents": [],
}
# Map document_uri → document_root via the documents table (URI is
# the post-redirect URL the source set on the Document, which is what
# parse_html stored). The map is stable as long as URIs are unique
# within the corpus, which is enforced at ingest time.
uri_list = list(src.http_meta.keys())
placeholders = ",".join(["?"] * len(uri_list))
rows = conn.execute(
f"SELECT document_root, document_uri FROM documents "
f"WHERE document_uri IN ({placeholders})",
uri_list,
).fetchall()
with transaction(conn):
for r in rows:
meta = src.http_meta.get(r["document_uri"])
if meta is None:
continue
conn.execute(
"INSERT OR REPLACE INTO document_http_meta "
"(document_root, etag, last_modified, last_fetched_at, "
" last_status, last_checked_at) "
"VALUES (?, ?, ?, ?, 200, ?)",
(
r["document_root"],
meta["etag"],
meta["last_modified"],
meta["last_fetched_at"],
meta["last_fetched_at"],
),
)
written.append(
{
"document_root": r["document_root"],
"document_uri": r["document_uri"],
}
)
return {
"seen": stats.seen,
"inserted": stats.inserted,
"http_meta_written": len(written),
"documents": written,
}
def recrawl_check(
conn,
*,
domain: str | None = None,
limit: int = 100,
timeout: float = 10.0,
) -> dict:
"""Send conditional HEAD requests for ingested documents and classify
each as fresh/stale/gone/unreachable.
Fast path: ETag → If-None-Match. Server returns 304 with no body =
skip. Slow path: full re-fetch only if the server says 200. We do
NOT re-fetch the body in this verb — that's recrawl's job. We just
answer "needs recrawl: yes/no" without doing the recrawl.
"""
sql = (
"SELECT d.document_root, d.document_uri, m.etag, m.last_modified "
"FROM documents d "
"JOIN document_http_meta m ON m.document_root = d.document_root"
)
args: list[object] = []
if domain:
sql += " WHERE d.document_uri LIKE ?"
args.append(f"%//{domain}%")
sql += " ORDER BY m.last_checked_at ASC NULLS FIRST LIMIT ?"
args.append(limit)
rows = conn.execute(sql, args).fetchall()
fresh, stale, gone, unreachable = 0, 0, 0, 0
items: list[dict] = []
now = int(time.time())
with httpx.Client(
headers={"User-Agent": USER_AGENT},
timeout=timeout,
follow_redirects=True,
) as client:
for r in rows:
headers: dict[str, str] = {}
if r["etag"]:
headers["If-None-Match"] = r["etag"]
if r["last_modified"]:
headers["If-Modified-Since"] = r["last_modified"]
try:
resp = client.head(r["document_uri"], headers=headers)
status = resp.status_code
except httpx.HTTPError:
status = 0 # network failure / DNS / TLS
if status == 304:
fresh += 1
verdict = "fresh"
elif status in (200,):
stale += 1
verdict = "stale"
elif status in (404, 410):
gone += 1
verdict = "gone"
else:
unreachable += 1
verdict = "unreachable"
with transaction(conn):
conn.execute(
"UPDATE document_http_meta "
"SET last_status = ?, last_checked_at = ? "
"WHERE document_root = ?",
(status, now, r["document_root"]),
)
items.append(
{
"document_root": r["document_root"],
"document_uri": r["document_uri"],
"status": status,
"verdict": verdict,
}
)
return {
"examined": len(rows),
"fresh": fresh,
"stale": stale,
"gone": gone,
"unreachable": unreachable,
"items": items,
}