crawler: live stderr heartbeat during BFS + ingest (rate-limited)

A no-cap crawl could take many seconds with no feedback. Wires the
existing aborist.progress.Progress into both phases so stderr shows
heartbeats every 2s (Progress's default interval).

Discovery phase: prints prefix='crawl', counts discovered URLs,
shows queue depth as the secondary number ('inserted' slot in the
Progress format — works fine, semantically "still to do").

Ingest phase: prefix='ingest', total_estimate=len(urls) so the user
sees percent + ETA. Threads through ingest_source's existing
progress= parameter.

Plus three banner lines to stderr at phase boundaries (start crawl,
end discovery, start ingest) so even sub-2s crawls show signs of
life. All flushed via Progress's flush=True path; stderr is
line-buffered by default so this works without PYTHONUNBUFFERED.

Tests stay green (Progress goes to stderr, pytest captures only the
stdout summary). 19 bridge tests + 273 default suite, all passing.
This commit is contained in:
russell@unturf.com 2026-04-28 21:05:45 -04:00
parent 552dc0def2
commit d2ceba201e
No known key found for this signature in database
2 changed files with 52 additions and 6 deletions

View file

@ -2120,17 +2120,41 @@ def _cmd_crawl(args: argparse.Namespace) -> int:
print(f"error: {e}", file=sys.stderr)
return 2
urls = crawl_seed(
args.seed_url, max_depth=args.depth, max_pages=args.max_pages
from aborist.progress import Progress
cap = "no cap" if args.max_pages == 0 else f"max {args.max_pages}"
print(
f" crawl: seed={args.seed_url} depth={args.depth} {cap}",
file=sys.stderr,
flush=True,
)
crawl_progress = Progress(prefix="crawl ")
urls = crawl_seed(
args.seed_url,
max_depth=args.depth,
max_pages=args.max_pages,
progress=crawl_progress,
)
print(
f" crawl: discovery done — {len(urls)} URLs",
file=sys.stderr,
flush=True,
)
if not args.ingest:
for u in urls:
print(u)
return 0
print(
f" ingest: starting on {len(urls)} URLs",
file=sys.stderr,
flush=True,
)
ingest_progress = Progress(prefix="ingest ", total_estimate=len(urls))
conn = connect(args.db)
try:
result = ingest_crawled(conn, urls)
result = ingest_crawled(conn, urls, progress=ingest_progress)
finally:
conn.close()
print(json.dumps(

View file

@ -30,6 +30,7 @@ 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
@ -53,12 +54,18 @@ async def _crawl_seed_async(
max_depth: int,
max_pages: int,
fetcher: AsyncWebFetcher | None = None,
progress: Progress | None = None,
) -> 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.
"""
fetcher = fetcher or AsyncWebFetcher(user_agent=USER_AGENT)
seen: set[str] = set()
@ -85,6 +92,8 @@ async def _crawl_seed_async(
continue
discovered.append(url)
if progress is not None:
progress.tick(len(discovered), inserted=len(queue))
if depth >= max_depth:
continue
@ -102,6 +111,8 @@ async def _crawl_seed_async(
continue
queue.append((normalized, depth + 1))
if progress is not None:
progress.done(len(discovered), inserted=len(queue))
return discovered
@ -110,13 +121,20 @@ def crawl_seed(
*,
max_depth: int = 2,
max_pages: int = 0,
progress: Progress | None = None,
) -> list[str]:
"""Sync wrapper around the async crawler. Returns same-domain URLs.
``max_pages=0`` means "no cap" depth is the only bound.
``max_pages=0`` means "no cap" depth is the only bound. Pass
``progress`` for stderr heartbeats while BFS runs.
"""
return asyncio.run(
_crawl_seed_async(seed_url, max_depth=max_depth, max_pages=max_pages)
_crawl_seed_async(
seed_url,
max_depth=max_depth,
max_pages=max_pages,
progress=progress,
)
)
@ -168,6 +186,7 @@ def ingest_crawled(
urls: Iterable[str],
*,
timeout: float = 30.0,
progress: Progress | None = None,
) -> dict:
"""Fetch each URL, ingest into `conn`, record ETag + Last-Modified.
@ -177,9 +196,12 @@ def ingest_crawled(
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)
stats = ingest_source(conn, src, progress=progress)
written: list[dict] = []
if not src.http_meta:
return {