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.
423 lines
14 KiB
Python
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,
|
|
}
|