Coverage for src/lilbee/server/handlers/crawl.py: 100%
19 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Crawl streaming handler."""
3from __future__ import annotations
5import asyncio
6from collections.abc import AsyncGenerator
7from pathlib import Path
9from lilbee.core.config.enums import CrawlRenderMode
10from lilbee.server.handlers.sse import SseStream
13async def crawl_stream(
14 url: str,
15 depth: int | None = None,
16 max_pages: int | None = None,
17 render_mode: CrawlRenderMode | None = None,
18 include_subdomains: bool = False,
19) -> AsyncGenerator[str, None]:
20 """Stream crawl progress as SSE events.
22 Emits crawl_start, crawl_page, crawl_done events, then a final done event
23 with the list of files written. On error emits crawl_error.
24 Sets a cancel event on client disconnect so the crawl stops between pages.
26 A browser crawl that finds no Chromium installed inlines
27 setup_start/progress/done events before the crawl begins, so a consumer can
28 render a matching 'setup' progress indicator. These are not part of every
29 stream: an http crawl never launches a browser, and that is the default
30 render mode, so a client must not block waiting for a setup phase.
31 """
32 sse = SseStream()
34 async def _run_crawl() -> list[Path]:
35 from lilbee.crawler import crawl_and_save
37 # crawl_and_save runs the Chromium bootstrap itself on first use,
38 # relaying setup_* events through the same on_progress callback
39 # so the SSE stream carries them before any crawl_* events.
40 try:
41 return await crawl_and_save(
42 url,
43 depth=depth,
44 max_pages=max_pages,
45 on_progress=sse.callback,
46 cancel=sse.cancel,
47 include_subdomains=include_subdomains,
48 render_mode=render_mode,
49 )
50 finally:
51 sse.queue.put_nowait(None)
53 task = asyncio.create_task(_run_crawl())
54 async for event in sse.drain(task, "Crawl stream"):
55 yield event
56 frame = sse.terminal_frame(task, lambda paths: {"files_written": [str(p) for p in paths]})
57 if frame is not None:
58 yield frame