Coverage for src/lilbee/crawler/crawl4ai_fetcher.py: 100%

195 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""crawl4ai-backed implementation of :class:`lilbee.crawler.fetcher.WebFetcher`.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import functools 

7import inspect 

8import io 

9import logging 

10import math 

11from collections.abc import AsyncGenerator, AsyncIterator 

12from dataclasses import dataclass 

13from typing import TYPE_CHECKING, Any 

14from urllib.parse import urlparse 

15 

16import anyio.to_thread 

17from anyio import CapacityLimiter 

18 

19from lilbee.core.config import cfg 

20from lilbee.core.config.enums import CrawlRenderMode 

21from lilbee.crawler import bootstrap 

22from lilbee.crawler.bootstrap import CrawlerBrowserError 

23from lilbee.crawler.markdown import base_url_for, html_to_markdown 

24from lilbee.crawler.models import ( 

25 CancelToken, 

26 ConcurrencySpec, 

27 FetchedPage, 

28 FilterSpec, 

29) 

30from lilbee.crawler.url_filter import host_in_scope, validate_crawl_url 

31 

32if TYPE_CHECKING: 

33 from lilbee.crawler.fetcher import WebFetcher 

34 

35log = logging.getLogger(__name__) 

36 

37 

38def _build_inner_crawler(*, verbose: bool, render_mode: CrawlRenderMode) -> Any: 

39 """Construct a crawl4ai ``AsyncWebCrawler`` for the requested render mode. 

40 

41 HTTP mode swaps in the browserless HTTP strategy; browser mode tunes 

42 Chromium for memory (light/text/memory-saving + periodic process recycle), 

43 reading the recycle threshold and launch flags from config. 

44 """ 

45 from crawl4ai import AsyncWebCrawler 

46 

47 if render_mode is CrawlRenderMode.HTTP: 

48 from crawl4ai.async_crawler_strategy import AsyncHTTPCrawlerStrategy 

49 

50 return AsyncWebCrawler(crawler_strategy=AsyncHTTPCrawlerStrategy(), verbose=verbose) 

51 

52 from crawl4ai import BrowserConfig 

53 

54 config = BrowserConfig( 

55 light_mode=True, 

56 text_mode=True, 

57 memory_saving_mode=True, 

58 max_pages_before_recycle=cfg.crawl_browser_recycle_pages, 

59 extra_args=list(cfg.crawl_browser_extra_args), 

60 verbose=verbose, 

61 ) 

62 return AsyncWebCrawler(config=config, verbose=verbose) 

63 

64 

65def _conversion_limiter() -> CapacityLimiter | None: 

66 """How many pages may convert off the event loop at once, or ``None`` for on it. 

67 

68 Read once per crawl, so a crawl keeps the setting it started with. 

69 """ 

70 workers = cfg.crawl_convert_workers 

71 return CapacityLimiter(workers) if workers >= 1 else None 

72 

73 

74def _silent_markdown_generator() -> Any | None: 

75 """A generator that produces nothing, or ``None`` when crawl4ai's base is absent. 

76 

77 crawl4ai converts every page inside its own async call stack, with no setting 

78 to skip it. Handing it a generator that returns immediately leaves the HTML 

79 for :func:`html_to_markdown` to convert where lilbee can await it. 

80 """ 

81 try: 

82 from crawl4ai.markdown_generation_strategy import MarkdownGenerationStrategy 

83 from crawl4ai.models import MarkdownGenerationResult 

84 except (ImportError, AttributeError): 

85 return None 

86 

87 class _SilentMarkdownGenerator(MarkdownGenerationStrategy): # type: ignore[misc] 

88 def generate_markdown(self, *args: Any, **kwargs: Any) -> Any: 

89 return MarkdownGenerationResult( 

90 raw_markdown="", 

91 markdown_with_citations="", 

92 references_markdown="", 

93 fit_markdown="", 

94 fit_html="", 

95 ) 

96 

97 return _SilentMarkdownGenerator() 

98 

99 

100@dataclass(frozen=True) 

101class _Conversion: 

102 """The markdown seam for one crawl: crawl4ai's own generator silenced so lilbee re-converts. 

103 

104 ``generator`` is the silent generator handed to crawl4ai, or ``None`` when its markdown 

105 base class is unavailable and the crawl converts itself. ``limiter`` bounds how many pages 

106 convert on the thread pool at once, or ``None`` to convert inline on the loop. 

107 """ 

108 

109 generator: Any | None 

110 limiter: CapacityLimiter | None 

111 

112 @property 

113 def config_kwargs(self) -> dict[str, Any]: 

114 """``CrawlerRunConfig`` kwargs that install the silent generator, if there is one.""" 

115 return {} if self.generator is None else {"markdown_generator": self.generator} 

116 

117 async def markdown_for(self, result: Any) -> str: 

118 """The page's markdown, re-converted off the loop only when the backend was silenced. 

119 

120 An un-silenced backend already converted the page, so re-converting would 

121 duplicate the work this exists to move. Converts ``cleaned_html`` only, the 

122 same source crawl4ai's own generator uses, so an empty cleaned page stays 

123 empty rather than turning raw nav/boilerplate into content. 

124 """ 

125 html = result.cleaned_html or "" 

126 if self.generator is None or not html: 

127 return str(result.markdown or "") 

128 base_url = base_url_for(result.html or "", result.url, result.redirected_url) 

129 if self.limiter is None: 

130 return html_to_markdown(html, base_url) 

131 return await anyio.to_thread.run_sync( 

132 html_to_markdown, html, base_url, limiter=self.limiter 

133 ) 

134 

135 

136def _new_conversion() -> _Conversion: 

137 """Build the conversion seam for one crawl, reading ``crawl_convert_workers`` once.""" 

138 generator = _silent_markdown_generator() 

139 limiter = _conversion_limiter() if generator is not None else None 

140 return _Conversion(generator, limiter) 

141 

142 

143def _build_rate_limited_dispatcher( 

144 concurrency: ConcurrencySpec, render_mode: CrawlRenderMode 

145) -> Any: 

146 """Build the recursive-crawl dispatcher from a ConcurrencySpec, or None. 

147 

148 BFSDeepCrawlStrategy calls ``crawler.arun_many()`` without a dispatcher 

149 kwarg, so per-domain rate limiting is only reachable by threading a 

150 dispatcher through AsyncWebCrawler itself. Browser mode uses a 

151 MemoryAdaptiveDispatcher so a crawl backs off when system memory is tight 

152 rather than steamrolling the machine; HTTP mode is light enough to stay on 

153 the plain semaphore path. 

154 """ 

155 if not concurrency.retry_on_rate_limit: 

156 return None 

157 from crawl4ai.async_dispatcher import RateLimiter 

158 

159 rate_limiter = RateLimiter( 

160 base_delay=(concurrency.retry_base_delay_min, concurrency.retry_base_delay_max), 

161 max_delay=concurrency.retry_max_backoff, 

162 max_retries=concurrency.retry_max_attempts, 

163 ) 

164 if render_mode is CrawlRenderMode.BROWSER: 

165 from crawl4ai.async_dispatcher import MemoryAdaptiveDispatcher 

166 

167 return MemoryAdaptiveDispatcher( 

168 max_session_permit=concurrency.semaphore_count, 

169 rate_limiter=rate_limiter, 

170 ) 

171 from crawl4ai.async_dispatcher import SemaphoreDispatcher 

172 

173 return SemaphoreDispatcher( 

174 semaphore_count=concurrency.semaphore_count, 

175 rate_limiter=rate_limiter, 

176 ) 

177 

178 

179class _LilbeeAsyncCrawler: 

180 """AsyncWebCrawler wrapper that injects a default dispatcher on ``arun_many``. 

181 

182 BFSDeepCrawlStrategy calls ``arun_many`` without a dispatcher kwarg, so the 

183 wrapper supplies one to make rate limiting and 429/503 retries reachable. 

184 An explicit ``dispatcher=`` on the call still wins. 

185 """ 

186 

187 def __init__(self, inner: Any, *, dispatcher: Any) -> None: 

188 self._inner = inner 

189 self._dispatcher = dispatcher 

190 

191 async def __aenter__(self) -> _LilbeeAsyncCrawler: 

192 await self._inner.__aenter__() 

193 return self 

194 

195 async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> Any: 

196 return await self._inner.__aexit__(exc_type, exc, tb) 

197 

198 async def arun(self, *args: Any, **kwargs: Any) -> Any: 

199 return await self._inner.arun(*args, **kwargs) 

200 

201 async def arun_many( 

202 self, urls: Any, config: Any = None, dispatcher: Any = None, **kwargs: Any 

203 ) -> Any: 

204 return await self._inner.arun_many( 

205 urls, 

206 config=config, 

207 dispatcher=dispatcher if dispatcher is not None else self._dispatcher, 

208 **kwargs, 

209 ) 

210 

211 

212@contextlib.asynccontextmanager 

213async def _open_crawler( 

214 *, quiet: bool = False, render_mode: CrawlRenderMode, dispatcher: Any = None 

215) -> AsyncIterator[Any]: 

216 """Open an AsyncWebCrawler for ``render_mode``, wrapping with the dispatcher. 

217 

218 Browser mode requires the Chromium binary and raises 

219 :class:`CrawlerBrowserError` if it is missing, so Playwright's ASCII install 

220 banner does not leak into the TUI. HTTP mode needs no browser at all. 

221 """ 

222 if render_mode is CrawlRenderMode.BROWSER and not bootstrap.chromium_installed(): 

223 raise CrawlerBrowserError( 

224 "Playwright Chromium browser not installed. " 

225 "Run 'uv run playwright install chromium' to enable browser-mode crawling." 

226 ) 

227 

228 inner = _build_inner_crawler(verbose=not quiet, render_mode=render_mode) 

229 

230 stdout_ctx = contextlib.redirect_stdout(io.StringIO()) if quiet else contextlib.nullcontext() 

231 stderr_ctx = contextlib.redirect_stderr(io.StringIO()) if quiet else contextlib.nullcontext() 

232 with stdout_ctx, stderr_ctx: 

233 if dispatcher is not None: 

234 async with _LilbeeAsyncCrawler(inner, dispatcher=dispatcher) as crawler: 

235 yield crawler 

236 else: 

237 async with inner as crawler: 

238 yield crawler 

239 

240 

241def _safe_strategy_cancel(strategy: Any) -> None: 

242 """Call ``strategy.cancel()`` if available; swallow only the known SDK shapes. 

243 

244 Narrow catch: ``AttributeError`` covers a missing nested attribute mid-call; 

245 ``RuntimeError`` covers cancel-on-closed-strategy. Anything else propagates. 

246 """ 

247 cancel_method = getattr(strategy, "cancel", None) 

248 if callable(cancel_method): 

249 try: 

250 cancel_method() 

251 except (AttributeError, RuntimeError) as exc: 

252 log.debug("strategy.cancel() raised: %s", exc) 

253 

254 

255async def _safe_aclose(stream: Any) -> None: 

256 """Close an async generator stream; no-op for list / single-result shapes. 

257 

258 aclose() runs the generator's own cleanup, which can surface arbitrary 

259 downstream errors. A teardown failure must not mask the crawl's real result, 

260 so it is logged at debug rather than propagated or silently dropped. 

261 """ 

262 if stream is None: 

263 return 

264 if inspect.isasyncgen(stream): 

265 try: 

266 await stream.aclose() 

267 except Exception as exc: 

268 log.debug("crawl stream aclose() raised during teardown: %s", exc) 

269 

270 

271async def _iter_crawl_stream(stream: Any) -> AsyncIterator[Any]: 

272 """Normalize crawl4ai's ``arun()`` return (async generator, list, or single result).""" 

273 if inspect.isasyncgen(stream): 

274 async for item in stream: 

275 yield item 

276 return 

277 # A list is the batch-mode shape; iterate and yield each item. 

278 if isinstance(stream, list): 

279 for item in stream: 

280 yield item 

281 return 

282 yield stream 

283 

284 

285def _link_passes_ssrf(url: str) -> bool: 

286 """Return True when a discovered link resolves to a public, http(s) target. 

287 

288 Re-validates every followed link against the IP blocklist so a discovered 

289 link to a private/metadata host is dropped before fetch. This is a 

290 best-effort check at filter time, not DNS-rebinding protection: the fetcher 

291 resolves the host again when it connects, so a record that rebinds between 

292 this check and the fetch is a TOCTOU window this does not close. 

293 """ 

294 try: 

295 validate_crawl_url(url) 

296 except ValueError: 

297 return False 

298 return True 

299 

300 

301def _host_scope_filter(start_url: str, *, include_subdomains: bool) -> Any: 

302 """Build a URLFilter that scopes a crawl to the starting URL's host. 

303 

304 Default behavior (``include_subdomains=False``) restricts link-following to 

305 the exact host of *start_url*. When ``include_subdomains=True`` the host 

306 plus any subdomain is in scope. Either way every followed link is also 

307 re-validated against the SSRF blocklist, since the host scope check alone 

308 would let a same-host link that resolves to a private IP through. 

309 """ 

310 from crawl4ai.deep_crawling.filters import URLFilter 

311 

312 host = (urlparse(start_url).hostname or "").lower() 

313 if not host: 

314 return None 

315 

316 class _ScopedSsrfFilter(URLFilter): # type: ignore[misc] 

317 def apply(self, url: str) -> bool: 

318 link_host = (urlparse(url).hostname or "").lower() 

319 ok = host_in_scope( 

320 link_host, host, include_subdomains=include_subdomains 

321 ) and _link_passes_ssrf(url) 

322 self._update_stats(ok) 

323 return ok 

324 

325 return _ScopedSsrfFilter() 

326 

327 

328class Crawl4aiFetcher: 

329 """:class:`WebFetcher` implementation backed by crawl4ai.""" 

330 

331 def __init__(self, *, quiet: bool = False, render_mode: CrawlRenderMode) -> None: 

332 self._quiet = quiet 

333 self._render_mode = render_mode 

334 

335 async def __aenter__(self) -> Crawl4aiFetcher: 

336 # Crawl4ai opens a fresh ``AsyncWebCrawler`` per operation because 

337 # ``fetch_recursive`` needs a per-call dispatcher (which depends on 

338 # the :class:`ConcurrencySpec` for that call). Nothing to set up here. 

339 return self 

340 

341 async def __aexit__( 

342 self, 

343 exc_type: type[BaseException] | None, 

344 exc: BaseException | None, 

345 tb: Any, 

346 ) -> None: 

347 return None 

348 

349 async def fetch_single(self, url: str, *, timeout: float) -> FetchedPage: 

350 """Fetch a single URL via crawl4ai's ``arun``.""" 

351 from crawl4ai import CrawlerRunConfig 

352 

353 conversion = _new_conversion() 

354 config = CrawlerRunConfig(page_timeout=int(timeout * 1000), **conversion.config_kwargs) 

355 async with _open_crawler(quiet=self._quiet, render_mode=self._render_mode) as crawler: 

356 result = await crawler.arun(url=url, config=config) 

357 markdown = (await conversion.markdown_for(result)).strip() 

358 if markdown: 

359 return FetchedPage(url=url, markdown=markdown, success=True) 

360 return FetchedPage( 

361 url=url, 

362 success=False, 

363 error=result.error_message or "No content extracted", 

364 ) 

365 

366 async def fetch_recursive( 

367 self, 

368 seed_url: str, 

369 *, 

370 depth: int | None, 

371 max_pages: int | None, 

372 timeout: float, 

373 concurrency: ConcurrencySpec, 

374 filters: FilterSpec, 

375 cancel: CancelToken | None = None, 

376 ) -> AsyncGenerator[FetchedPage, None]: 

377 """Stream pages discovered by crawl4ai's native BFS. 

378 

379 ``depth`` / ``max_pages`` of ``None`` mean unbounded; the adapter 

380 translates to ``math.inf`` for crawl4ai's BFSDeepCrawlStrategy, which 

381 is the sentinel it understands. 

382 """ 

383 

384 def _should_cancel() -> bool: 

385 return cancel is not None and cancel.is_set() 

386 

387 from crawl4ai import CrawlerRunConfig 

388 from crawl4ai.deep_crawling import BFSDeepCrawlStrategy 

389 from crawl4ai.deep_crawling.filters import FilterChain, URLPatternFilter 

390 

391 filter_chain_items: list[Any] = [] 

392 host_filter = _host_scope_filter(seed_url, include_subdomains=filters.include_subdomains) 

393 if host_filter is not None: 

394 filter_chain_items.append(host_filter) 

395 if filters.exclude_patterns: 

396 filter_chain_items.append( 

397 URLPatternFilter(filters.exclude_patterns, use_glob=False, reverse=True) 

398 ) 

399 filter_chain = FilterChain(filter_chain_items) if filter_chain_items else FilterChain() 

400 

401 strategy = BFSDeepCrawlStrategy( 

402 max_depth=math.inf if depth is None else depth, 

403 max_pages=math.inf if max_pages is None else max_pages, 

404 should_cancel=_should_cancel, 

405 filter_chain=filter_chain, 

406 ) 

407 conversion = _new_conversion() 

408 config = CrawlerRunConfig( 

409 deep_crawl_strategy=strategy, 

410 page_timeout=int(timeout * 1000), 

411 mean_delay=concurrency.mean_delay, 

412 max_range=concurrency.max_delay_range, 

413 semaphore_count=concurrency.semaphore_count, 

414 stream=True, 

415 **conversion.config_kwargs, 

416 ) 

417 

418 dispatcher = _build_rate_limited_dispatcher(concurrency, self._render_mode) 

419 stream: Any = None 

420 strategy_cancelled = False 

421 # Exceptions propagate to the orchestration layer, which decides 

422 # whether to log cancel-teardown noise at debug vs surface a real 

423 # failure. The adapter's only housekeeping is stream close + BFS 

424 # strategy cancel so Playwright tears down in order. 

425 async with _open_crawler( 

426 quiet=self._quiet, render_mode=self._render_mode, dispatcher=dispatcher 

427 ) as crawler: 

428 stream = await crawler.arun(url=seed_url, config=config) 

429 try: 

430 async for cr in _iter_crawl_stream(stream): 

431 if _should_cancel(): 

432 _safe_strategy_cancel(strategy) 

433 strategy_cancelled = True 

434 break 

435 if cr.success: 

436 yield FetchedPage(url=cr.url, markdown=await conversion.markdown_for(cr)) 

437 else: 

438 yield FetchedPage( 

439 url=cr.url, 

440 success=False, 

441 error=cr.error_message or "Unknown error", 

442 ) 

443 finally: 

444 # If the consumer breaks out before we saw a cancel, still 

445 # short-circuit the BFS strategy so any in-flight arun_many 

446 # batch stops dispatching. Mirrors the orchestrator's 

447 # previous "hard cap on visible counter" behavior now that 

448 # the strategy object lives inside the adapter. 

449 if not strategy_cancelled: 

450 _safe_strategy_cancel(strategy) 

451 # Close the async generator (if it is one) before the 

452 # crawler context exits, so Playwright tears down 

453 # in-flight URLs in order. Skipping this is what produced 

454 # the "BrowserContext.new_page: Connection closed" spam 

455 # on cancel. 

456 await _safe_aclose(stream) 

457 

458 

459# Protocol conformance check: Crawl4aiFetcher is structurally a WebFetcher. 

460# We don't instantiate at import time so the check stays purely structural. 

461if TYPE_CHECKING: 

462 _: WebFetcher = Crawl4aiFetcher(render_mode=CrawlRenderMode.HTTP) 

463 

464 

465@functools.cache 

466def crawler_available() -> bool: 

467 """Check if the crawl4ai backend is importable (i.e. the extra is installed). 

468 

469 Uses ``importlib.util.find_spec`` rather than ``import crawl4ai`` so the 

470 check stays fast on the UI thread. ``crawl4ai`` is in AGENTS.md's 

471 known-heavy-imports list; executing it on Windows with Defender 

472 real-time scanning takes seconds, and the Settings screen's feature- 

473 gate call (``_FEATURE_GATED_GROUPS``) hits it synchronously during 

474 ``compose``. ``find_spec`` just walks ``sys.path`` to locate the 

475 package; the actual import runs later from the crawler bootstrap 

476 where the cost is expected. 

477 """ 

478 import importlib.util 

479 

480 return importlib.util.find_spec("crawl4ai") is not None