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

176 statements  

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

1"""Crawl orchestration: build specs from ``cfg``, drive a :class:`WebFetcher`. 

2 

3By default a recursive crawl is scoped to the exact starting host so a 

4Wikipedia article does not wander into other language editions. Callers 

5opt into subdomain scope via ``include_subdomains=True``. 

6""" 

7 

8from __future__ import annotations 

9 

10import asyncio 

11import logging 

12import time 

13from collections.abc import Awaitable, Callable 

14from pathlib import Path 

15from typing import Any 

16 

17from lilbee.app.ingest import remove_sources_and_forget_in_wiki 

18from lilbee.app.services import get_services 

19from lilbee.core.config import cfg 

20from lilbee.core.config.enums import CrawlRenderMode 

21from lilbee.core.crawl_patterns import exclude_patterns_refusal 

22from lilbee.crawler import availability, bootstrap, save, sitemap 

23from lilbee.crawler.availability import CRAWLER_MISSING_MESSAGE 

24from lilbee.crawler.backends.crawlberg import fetcher_from_config 

25from lilbee.crawler.bootstrap import ( 

26 ChromiumMissingError, 

27 CrawlEngineRefusedError, 

28 CrawlerBrowserError, 

29) 

30from lilbee.crawler.discovery import build_concurrency_spec, build_filter_spec 

31from lilbee.crawler.events import ( 

32 _browser_warm_up, 

33 _drain_page_stream, 

34 _emit_page_failure, 

35 _fetched_to_result, 

36 _handle_crawl_teardown_error, 

37 _pages_cap, 

38) 

39from lilbee.crawler.messages import CHROMIUM_MISSING_MESSAGE 

40from lilbee.crawler.models import ( 

41 CRAWL_PAGES_UNLIMITED, 

42 NO_LOGIN, 

43 CancelToken, 

44 CrawlLogin, 

45 CrawlResult, 

46) 

47from lilbee.crawler.save import CrawlRecord 

48from lilbee.crawler.url_filter import validate_crawl_url, without_userinfo 

49from lilbee.runtime.progress import ( 

50 CrawlDoneEvent, 

51 CrawlPageEvent, 

52 CrawlStartEvent, 

53 DetailedProgressCallback, 

54 EventType, 

55) 

56 

57log = logging.getLogger(__name__) 

58 

59# A crawl limited to this many pages fetches only its seed. 

60_ONE_PAGE = 1 

61 

62 

63def _get_crawl_semaphore() -> asyncio.Semaphore | None: 

64 """Return the process-wide crawl semaphore, or None when unlimited.""" 

65 return get_services().crawler_semaphore 

66 

67 

68def _resolve_depth(value: int | None, cfg_ceiling: int | None) -> int | None: 

69 """Resolve a crawl depth to the value the dispatcher consumes. 

70 

71 Depth has its own contract, distinct from the page-count limit: ``0`` is a 

72 valid "seed only / single page" depth, not "unbounded". (Page counts use 

73 :func:`_resolve_page_limit`, where ``0`` means "no limit".) 

74 

75 None -> cfg_ceiling (itself may be None; ``None`` means unbounded) 

76 n >= 0 -> n (0 = seed only; explicit caller intent overrides cfg) 

77 n < 0 -> ValueError (use None for unbounded) 

78 """ 

79 effective = value if value is not None else cfg_ceiling 

80 if effective is None: 

81 return None 

82 if effective < 0: 

83 raise ValueError("crawl depth must be 0 (seed only) or a positive int") 

84 return effective 

85 

86 

87def _resolve_page_limit(max_pages: int | None) -> int | None: 

88 """Resolve the page bound the fetcher consumes (None means unbounded). 

89 

90 ``CRAWL_PAGES_UNLIMITED`` (0) is an explicit "no limit" and returns None. 

91 ``None`` is unspecified: it falls back to ``cfg.crawl_max_pages`` if set, 

92 else the protective default ``cfg.crawl_safety_max_pages`` so a hostile site 

93 can't exhaust the disk on a crawl nobody bounded. A positive int is honored 

94 as-is, even above the default. 

95 """ 

96 if max_pages == CRAWL_PAGES_UNLIMITED: 

97 return None 

98 if max_pages is not None: 

99 return max_pages 

100 if cfg.crawl_max_pages is not None: 

101 return cfg.crawl_max_pages 

102 return cfg.crawl_safety_max_pages 

103 

104 

105def _require_crawler_extra() -> None: 

106 """Raise :class:`CrawlerBackendError` when the crawler extra is not installed.""" 

107 if not availability.crawler_available(): 

108 raise bootstrap.CrawlerBackendError(CRAWLER_MISSING_MESSAGE) 

109 

110 

111async def _fetch_single_page( 

112 url: str, 

113 render_mode: CrawlRenderMode, 

114 on_progress: DetailedProgressCallback | None, 

115 login: CrawlLogin, 

116) -> CrawlResult: 

117 """One single-URL fetch; a failure to fetch the page becomes a failed result.""" 

118 try: 

119 async with fetcher_from_config(render_mode, login) as fetcher: 

120 warm_up_done = _browser_warm_up(on_progress, render_mode) 

121 page = await fetcher.fetch_single( 

122 url, timeout=cfg.crawl_timeout, concurrency=build_concurrency_spec() 

123 ) 

124 if warm_up_done is not None: 

125 warm_up_done() 

126 return _fetched_to_result(page) 

127 except (CrawlerBrowserError, CrawlEngineRefusedError): 

128 raise 

129 except Exception as exc: 

130 shown = without_userinfo(url) 

131 log.warning("Failed to crawl %s: %s", shown, exc) 

132 return CrawlResult(url=shown, success=False, error=str(exc)) 

133 

134 

135async def crawl_single( 

136 url: str, 

137 *, 

138 render_mode: CrawlRenderMode = CrawlRenderMode.BROWSER, 

139 on_progress: DetailedProgressCallback | None = None, 

140 login: CrawlLogin = NO_LOGIN, 

141) -> CrawlResult: 

142 """Fetch a single URL, sending *login* to its host. 

143 

144 ``render_mode`` defaults to ``BROWSER`` for direct callers; the public 

145 entry point :func:`crawl_and_save` resolves it from ``cfg.crawl_render_mode`` 

146 and passes the canonical value down. ``on_progress`` receives the setup 

147 bracket of a browser crawl's warm-up. 

148 

149 Raises :class:`CrawlerBackendError` if the crawler extra isn't installed. 

150 When the headless Chromium shell is missing at launch, runs the bootstrap 

151 once and retries. 

152 """ 

153 validate_crawl_url(url) 

154 _require_crawler_extra() 

155 try: 

156 return await _fetch_single_page(url, render_mode, on_progress, login) 

157 except ChromiumMissingError: 

158 log.warning("Chromium missing for %s; bootstrapping then retrying", without_userinfo(url)) 

159 await bootstrap.bootstrap_chromium(on_progress=None) 

160 return await _fetch_single_page(url, render_mode, on_progress, login) 

161 

162 

163async def crawl_recursive( 

164 url: str, 

165 max_depth: int | None = None, 

166 max_pages: int | None = None, 

167 on_progress: DetailedProgressCallback | None = None, 

168 cancel: CancelToken | None = None, 

169 *, 

170 include_subdomains: bool = False, 

171 on_result: Callable[[CrawlResult], Any] | None = None, 

172 render_mode: CrawlRenderMode = CrawlRenderMode.BROWSER, 

173 login: CrawlLogin = NO_LOGIN, 

174) -> int: 

175 """Crawl a URL recursively using BFS, streaming per-page progress; the pages it yielded. 

176 

177 The *login* goes to the seed host only, in the crawl and in the sitemap lookup. 

178 

179 ``render_mode`` defaults to ``BROWSER`` for direct callers; the public 

180 entry point :func:`crawl_and_save` resolves it from ``cfg.crawl_render_mode`` 

181 and passes the canonical value down. 

182 

183 ``max_depth`` of None means unbounded depth. ``max_pages`` of 

184 ``CRAWL_PAGES_UNLIMITED`` (0) means no page limit; a positive int is that 

185 cap; None is unspecified and falls back to ``cfg.crawl_safety_max_pages`` so 

186 a hostile site can't exhaust the disk on a crawl nobody bounded. 

187 ``CRAWL_PAGE`` events fire as each page completes; total is 

188 ``CRAWL_TOTAL_UNKNOWN`` by default and promoted to the sitemap count 

189 when available. 

190 

191 Pass ``include_subdomains=True`` to broaden scope from the exact host to the 

192 host plus any subdomains. If ``on_result`` is provided, it's called for each 

193 streamed ``CrawlResult`` the moment it arrives so callers can flush pages to 

194 disk incrementally and keep partial output across cancellation. The crawl keeps 

195 no page after its ``on_result`` call returns. 

196 """ 

197 validate_crawl_url(url) 

198 # ``_run_crawl`` already resolved the depth ceiling (and routed a seed-only 

199 # 0 to the single-page path), so the recursive path takes ``max_depth`` as 

200 # given: None = unbounded, a positive int = the cap. 

201 depth = max_depth 

202 pages = _resolve_page_limit(max_pages) 

203 

204 # Fail fast when the ``crawler`` extra wasn't installed so SSE 

205 # callers see ``event: error`` instead of a silent zero-results run. 

206 _require_crawler_extra() 

207 

208 # HTTP mode needs no browser, so the guard only applies to browser-mode crawls. 

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

210 raise ChromiumMissingError(CHROMIUM_MISSING_MESSAGE) 

211 

212 # Best-effort sitemap lookup so the TUI / CLI can render a real page-count 

213 # denominator instead of [n/-1]. Falls back to CRAWL_TOTAL_UNKNOWN on any 

214 # failure; off the hot path so a slow/missing sitemap never blocks the crawl. 

215 sitemap_total = await asyncio.to_thread( 

216 sitemap._count_sitemap_urls, 

217 url, 

218 include_subdomains=include_subdomains, 

219 respect_robots_txt=cfg.crawl_respect_robots_txt, 

220 user_agent=cfg.crawl_user_agent, 

221 login=login, 

222 ) 

223 

224 concurrency = build_concurrency_spec() 

225 filters = build_filter_spec(include_subdomains=include_subdomains) 

226 

227 pages_seen = 0 

228 try: 

229 async with fetcher_from_config(render_mode, login) as fetcher: 

230 # The explicit reference lets the stream close, and the crawl stop, 

231 # the moment the drain breaks on max_pages or cancel. 

232 page_stream = fetcher.fetch_recursive( 

233 url, 

234 depth=depth, 

235 max_pages=pages, 

236 timeout=cfg.crawl_timeout, 

237 concurrency=concurrency, 

238 filters=filters, 

239 cancel=cancel, 

240 ) 

241 try: 

242 pages_seen = await _drain_page_stream( 

243 page_stream, 

244 on_progress=on_progress, 

245 on_result=on_result, 

246 sitemap_total=sitemap_total, 

247 pages_cap=_pages_cap(pages), 

248 cancel=cancel, 

249 render_mode=render_mode, 

250 ) 

251 finally: 

252 await page_stream.aclose() 

253 except (CrawlerBrowserError, CrawlEngineRefusedError): 

254 raise 

255 except Exception as exc: 

256 _handle_crawl_teardown_error(without_userinfo(url), exc, cancel=cancel) 

257 

258 return pages_seen 

259 

260 

261async def _maybe_periodic_sync(tasks: set[asyncio.Task[None]], cancel: CancelToken | None) -> None: 

262 """Fire off a background sync if the ``crawl_sync_interval`` has elapsed. 

263 

264 Skips when periodic sync is disabled (``interval=0``) or another sync 

265 is already running. The spawned task is added to ``tasks`` so the 

266 caller can drain it before returning. The sync stops on the crawl's *cancel*. 

267 """ 

268 interval = cfg.crawl_sync_interval 

269 sync_state = get_services().crawler_sync_state 

270 if interval <= 0 or not sync_state.lock.acquire(blocking=False): 

271 return 

272 

273 now = time.monotonic() 

274 if now - sync_state.last_run < interval: 

275 sync_state.lock.release() 

276 return 

277 

278 sync_state.last_run = now 

279 

280 async def _run_sync() -> None: 

281 try: 

282 from lilbee.data.ingest import sync 

283 

284 await sync(quiet=True, cancel=cancel, started_by="a crawl") 

285 except Exception as exc: 

286 log.warning("Periodic sync during crawl failed: %s", exc) 

287 finally: 

288 sync_state.lock.release() 

289 

290 task = asyncio.create_task(_run_sync()) 

291 tasks.add(task) 

292 task.add_done_callback(tasks.discard) 

293 

294 

295def _make_flush_page( 

296 record: CrawlRecord, written_paths: list[Path] 

297) -> Callable[[CrawlResult], Any]: 

298 """A flush closure that saves a page at its name and records it in *record*.""" 

299 

300 def _sync_flush(result: CrawlResult) -> list[Path]: 

301 path = save.save_page(result, record, remove_sources_and_forget_in_wiki) 

302 return [] if path is None else [path] 

303 

304 async def flush_page(result: CrawlResult) -> list[Path]: 

305 paths = await asyncio.to_thread(_sync_flush, result) 

306 written_paths.extend(paths) 

307 return paths 

308 

309 return flush_page 

310 

311 

312def _compact_pages(record: CrawlRecord) -> None: 

313 """Fold the crawl's commits to the page table into one version; a failure only warns.""" 

314 try: 

315 save.finish_crawl(record) 

316 except Exception: 

317 log.warning("Compacting the crawl page table failed", exc_info=True) 

318 

319 

320def _require_valid_exclude_patterns() -> None: 

321 """Raise :class:`CrawlEngineRefusedError` naming each exclude pattern crawlberg refuses.""" 

322 refusal = exclude_patterns_refusal(cfg.crawl_exclude_patterns) 

323 if refusal is not None: 

324 raise CrawlEngineRefusedError(refusal) 

325 

326 

327async def _ensure_crawler_ready( 

328 on_progress: DetailedProgressCallback | None, 

329 render_mode: CrawlRenderMode, 

330) -> None: 

331 """Reject early when the extra is missing or a setting is invalid; bootstrap Chromium. 

332 

333 Runs before the Chromium bootstrap so a user without [crawler] doesn't pay 

334 the ~160 MB download just to hit the same error afterward. Only browser mode 

335 needs Chromium; HTTP mode skips the bootstrap entirely. The bootstrap 

336 short-circuits when Chromium is already installed; any progress is forwarded 

337 through ``on_progress`` so downstream UIs surface a 'setup' stage. An exclude pattern 

338 crawlberg refuses fails both a single-page and a recursive crawl before any setup. 

339 """ 

340 _require_crawler_extra() 

341 await asyncio.to_thread(_require_valid_exclude_patterns) 

342 

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

344 await bootstrap.bootstrap_chromium(on_progress=on_progress) 

345 

346 

347async def _run_crawl( 

348 url: str, 

349 *, 

350 depth: int | None, 

351 max_pages: int | None, 

352 on_progress: DetailedProgressCallback | None, 

353 cancel: CancelToken | None, 

354 include_subdomains: bool, 

355 flush_page: Callable[[Any], Awaitable[list[Path]]], 

356 render_mode: CrawlRenderMode, 

357 login: CrawlLogin, 

358) -> int: 

359 """Run the single-URL or recursive crawl. Returns ``pages_seen``. 

360 

361 Resolves the depth ceiling here (permitting the seed-only ``0``) so a 

362 ``cfg.crawl_max_depth`` of 0 routes to the single-page path instead of 

363 blowing up inside the recursive resolver. A page limit of one also takes 

364 the single-page path, so the crawl reads no sitemap. A crawl whose 

365 ``cancel`` is already set fetches nothing and builds no engine, at any depth. 

366 """ 

367 depth = _resolve_depth(depth, cfg.crawl_max_depth) 

368 if cancel is not None and cancel.is_set(): 

369 return 0 

370 if depth == 0 or _resolve_page_limit(max_pages) == _ONE_PAGE: 

371 result = await crawl_single( 

372 url, render_mode=render_mode, on_progress=on_progress, login=login 

373 ) 

374 try: 

375 await flush_page(result) 

376 except OSError: 

377 log.exception("Flush failed for %s", result.url) 

378 if on_progress: 

379 shown = without_userinfo(url) 

380 on_progress(EventType.CRAWL_PAGE, CrawlPageEvent(url=shown, current=1, total=1)) 

381 _emit_page_failure(on_progress, result) 

382 return 1 

383 return await crawl_recursive( 

384 url, 

385 max_depth=depth, 

386 max_pages=max_pages, 

387 on_progress=on_progress, 

388 cancel=cancel, 

389 include_subdomains=include_subdomains, 

390 on_result=flush_page, 

391 render_mode=render_mode, 

392 login=login, 

393 ) 

394 

395 

396async def crawl_and_save( 

397 url: str, 

398 *, 

399 depth: int | None = None, 

400 max_pages: int | None = None, 

401 on_progress: DetailedProgressCallback | None = None, 

402 cancel: CancelToken | None = None, 

403 include_subdomains: bool = False, 

404 render_mode: CrawlRenderMode | None = None, 

405 login: CrawlLogin = NO_LOGIN, 

406) -> list[Path]: 

407 """Crawl URL(s), save as markdown, record each saved page. Returns paths written. 

408 

409 ``login`` goes to the seed host only and is never saved or logged. 

410 

411 ``depth``: ``None`` = whole-site unbounded recursion (default). ``0`` = 

412 single URL, no recursion. ``N > 0`` = max link-follow depth. ``max_pages``: 

413 ``None`` (unspecified) defers to ``cfg.crawl_max_pages``, else the protective 

414 ``cfg.crawl_safety_max_pages`` cap. ``0`` (``CRAWL_PAGES_UNLIMITED``) is the 

415 only truly unbounded value; a positive int is honored as-is. 

416 ``cfg.crawl_max_depth`` acts as a ceiling applied only when ``depth`` is 

417 ``None``. 

418 

419 ``render_mode``: ``None`` resolves to ``cfg.crawl_render_mode`` (the single 

420 write-boundary for the default). ``http`` fetches without a browser; 

421 ``browser`` runs a tuned Chromium with JavaScript enabled. 

422 

423 Hash-based change detection: always fetches but only saves changed or new 

424 files. Pages flush to disk as they stream so a cancelled crawl preserves 

425 the pages already fetched. 

426 """ 

427 mode = render_mode if render_mode is not None else cfg.crawl_render_mode 

428 await _ensure_crawler_ready(on_progress, mode) 

429 

430 sem = _get_crawl_semaphore() 

431 if sem is not None: 

432 await sem.acquire() 

433 tasks: set[asyncio.Task[None]] = set() 

434 try: 

435 if on_progress: 

436 start_depth = depth if depth is not None else 0 

437 start = CrawlStartEvent(url=without_userinfo(url), depth=start_depth) 

438 on_progress(EventType.CRAWL_START, start) 

439 

440 record = await asyncio.to_thread(save.load_crawl_record, get_services().store) 

441 written_paths: list[Path] = [] 

442 flush_page = _make_flush_page(record, written_paths) 

443 

444 pages_seen = await _run_crawl( 

445 url, 

446 depth=depth, 

447 max_pages=max_pages, 

448 on_progress=on_progress, 

449 cancel=cancel, 

450 include_subdomains=include_subdomains, 

451 flush_page=flush_page, 

452 render_mode=mode, 

453 login=login, 

454 ) 

455 await asyncio.to_thread(_compact_pages, record) 

456 

457 cancelled = cancel is not None and cancel.is_set() 

458 if not cancelled: 

459 await _maybe_periodic_sync(tasks, cancel) 

460 

461 if on_progress: 

462 on_progress( 

463 EventType.CRAWL_DONE, 

464 CrawlDoneEvent(pages_crawled=pages_seen, files_written=len(written_paths)), 

465 ) 

466 

467 return written_paths 

468 finally: 

469 # Drain this call's periodic-sync tasks before returning so 

470 # asyncio.run() doesn't close the loop with a pending sync. 

471 if tasks: 

472 await asyncio.gather(*tasks, return_exceptions=True) 

473 if sem is not None: 

474 sem.release()