Coverage for src/lilbee/crawler/events.py: 100%
68 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Per-page event emission, result translation, and cancel-teardown classification."""
3from __future__ import annotations
5import inspect
6import logging
7import math
8from collections.abc import Callable
9from typing import Any
11from lilbee.core.config.enums import CrawlRenderMode
12from lilbee.core.text import collapse_whitespace
13from lilbee.crawler.models import CancelToken, CrawlResult, FetchedPage
14from lilbee.runtime.progress import (
15 CrawlPageEvent,
16 CrawlPageFailedEvent,
17 DetailedProgressCallback,
18 EventType,
19 SetupDoneEvent,
20 SetupStartEvent,
21)
23log = logging.getLogger(__name__)
25# The setup component of a browser crawl's warm-up: the wait for the browser's first page.
26BROWSER_SETUP_COMPONENT = "browser"
27# The most characters of a page title lilbee keeps; a page controls its title.
28_MAX_TITLE_CHARS = 512
31def _browser_warm_up(
32 on_progress: DetailedProgressCallback | None, render_mode: CrawlRenderMode
33) -> Callable[[], None] | None:
34 """Report the start of a browser crawl's warm-up; the call that reports its end, or None."""
35 if on_progress is None or render_mode is not CrawlRenderMode.BROWSER:
36 return None
37 on_progress(EventType.SETUP_START, SetupStartEvent(component=BROWSER_SETUP_COMPONENT))
38 done = SetupDoneEvent(component=BROWSER_SETUP_COMPONENT, success=True)
39 return lambda: on_progress(EventType.SETUP_DONE, done)
42def _emit_page_failure(on_progress: DetailedProgressCallback | None, result: CrawlResult) -> None:
43 """Log and emit ``CRAWL_PAGE_FAILED`` for a page that yields nothing to save."""
44 reason = result.failure_reason()
45 if reason is None:
46 return
47 log.warning("Crawled page yields no content: %s: %s", result.url, reason)
48 if on_progress:
49 on_progress(
50 EventType.CRAWL_PAGE_FAILED,
51 CrawlPageFailedEvent(url=result.url, reason=reason),
52 )
55def _page_title(title: str) -> str:
56 """A page's ``<title>`` on one line, cut at ``_MAX_TITLE_CHARS``."""
57 return collapse_whitespace(title)[:_MAX_TITLE_CHARS]
60def _fetched_to_result(page: FetchedPage) -> CrawlResult:
61 """Translate the fetcher's value type to the public ``CrawlResult`` shape."""
62 return CrawlResult(
63 url=page.url,
64 markdown=page.markdown,
65 success=page.success,
66 error=page.error,
67 title=_page_title(page.title),
68 )
71def _pages_cap(pages: int | None) -> float:
72 """Return the per-result counter ceiling for visible progress.
74 ``None`` (unbounded) maps to ``math.inf`` so the streaming loop's hard
75 cap check is a pure numeric compare with no branching.
76 """
77 return math.inf if pages is None else pages
80async def _flush(on_result: Callable[[CrawlResult], Any] | None, result: CrawlResult) -> None:
81 """Hand *result* to *on_result*; a disk-side flush failure is logged, not a crawl failure."""
82 if on_result is None:
83 return
84 try:
85 rv = on_result(result)
86 if inspect.isawaitable(rv):
87 await rv
88 except OSError:
89 log.exception("Flush callback failed for %s", result.url)
92async def _drain_page_stream(
93 page_stream: Any,
94 *,
95 on_progress: DetailedProgressCallback | None,
96 on_result: Callable[[CrawlResult], Any] | None,
97 sitemap_total: int,
98 pages_cap: float,
99 cancel: CancelToken | None,
100 render_mode: CrawlRenderMode,
101) -> int:
102 """Consume a fetcher's page stream, emitting events and flushing per page.
104 Returns the number of pages the stream yielded; no page outlives its flush. The
105 stream is closed deterministically by the caller; this helper only iterates. A
106 browser crawl's wait for its first page is bracketed by setup events.
107 """
108 counter = 0
109 warm_up_done = _browser_warm_up(on_progress, render_mode)
111 def _should_cancel() -> bool:
112 return cancel is not None and cancel.is_set()
114 async for page in page_stream:
115 if warm_up_done is not None:
116 warm_up_done()
117 warm_up_done = None
118 if _should_cancel():
119 break
120 counter += 1
121 if on_progress:
122 on_progress(
123 EventType.CRAWL_PAGE,
124 CrawlPageEvent(url=page.url, current=counter, total=sitemap_total),
125 )
126 new_result = _fetched_to_result(page)
127 _emit_page_failure(on_progress, new_result)
128 await _flush(on_result, new_result)
129 # The user-visible count never exceeds the number the caller asked for.
130 if counter >= pages_cap:
131 break
132 if warm_up_done is not None:
133 warm_up_done()
134 return counter
137def _handle_crawl_teardown_error(url: str, exc: Exception, *, cancel: CancelToken | None) -> None:
138 """Log a recursive-crawl exception: at debug during a cancel's teardown, else a warning."""
139 if cancel is not None and cancel.is_set():
140 log.debug("Recursive crawl of %s ended during cancel teardown: %s", url, exc)
141 return
142 log.warning("Recursive crawl of %s failed: %s", url, exc)