Coverage for src/lilbee/crawler/backends/crawlberg/fetcher.py: 100%
73 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"""crawlberg-backed implementation of :class:`lilbee.crawler.fetcher.WebFetcher`."""
3from __future__ import annotations
5import asyncio
6import logging
7from collections.abc import AsyncGenerator, Sequence
8from contextlib import aclosing
9from typing import TYPE_CHECKING, Any
11from lilbee.core.config.enums import CrawlRenderMode
12from lilbee.crawler import url_filter
13from lilbee.crawler.backends.crawlberg.config import _crawl_config, _CrawlSpec
14from lilbee.crawler.backends.crawlberg.events import (
15 _SEED_DEPTH,
16 _Event,
17 _EventKind,
18 _events,
19)
20from lilbee.crawler.backends.crawlberg.links import _ExcludedLinks
21from lilbee.crawler.models import (
22 NO_LOGIN,
23 CancelToken,
24 ConcurrencySpec,
25 CrawlLogin,
26 FetchedPage,
27 FilterSpec,
28)
30if TYPE_CHECKING:
31 from lilbee.crawler.fetcher import WebFetcher
33log = logging.getLogger(__name__)
36_NO_CONTENT = "No content extracted"
39def _url_allowed(url: str) -> bool:
40 """True when lilbee's own URL policy admits *url*."""
41 try:
42 url_filter.validate_crawl_url(url)
43 except ValueError:
44 return False
45 return True
48async def _to_page(event: _Event, seed_url: str) -> FetchedPage | None:
49 """The fetched page for *event*, or None when lilbee's URL policy refuses its URL."""
50 if event.kind is _EventKind.ERROR:
51 return FetchedPage(url=event.url, success=False, error=event.error)
52 if not await asyncio.to_thread(_url_allowed, event.url):
53 log.warning("Dropped %s: lilbee's URL policy refuses it", event.url)
54 return None
55 url = seed_url if event.depth == _SEED_DEPTH else event.url
56 return FetchedPage(url=url, markdown=event.markdown, title=event.title)
59def _single_result(page: FetchedPage | None, url: str) -> FetchedPage:
60 """A single-URL fetch result under the requested URL, failed when it has no text."""
61 if page is not None and not page.success:
62 return FetchedPage(url=url, success=False, error=page.error)
63 if page is not None and page.markdown.strip():
64 return FetchedPage(url=url, markdown=page.markdown.strip(), title=page.title)
65 return FetchedPage(url=url, success=False, error=_NO_CONTENT)
68class CrawlbergFetcher:
69 """:class:`WebFetcher` implementation backed by crawlberg."""
71 def __init__(
72 self,
73 *,
74 render_mode: CrawlRenderMode,
75 chrome_args: Sequence[str] = (),
76 user_agent: str = "",
77 respect_robots_txt: bool = True,
78 extra_wait_ms: int = 0,
79 login: CrawlLogin = NO_LOGIN,
80 ) -> None:
81 self._render_mode = render_mode
82 self._chrome_args = tuple(chrome_args)
83 self._user_agent = user_agent
84 self._respect_robots_txt = respect_robots_txt
85 self._extra_wait_ms = extra_wait_ms
86 self._login = login
88 async def __aenter__(self) -> CrawlbergFetcher:
89 return self
91 async def __aexit__(
92 self,
93 exc_type: type[BaseException] | None,
94 exc: BaseException | None,
95 tb: Any,
96 ) -> None:
97 return None
99 def _stream(
100 self, spec: _CrawlSpec, seed_url: str, cancel: CancelToken | None
101 ) -> AsyncGenerator[_Event, None]:
102 """This crawl's events; browser mode finds the headless shell before any engine starts."""
103 config = _crawl_config(
104 self._render_mode,
105 spec,
106 self._chrome_args,
107 self._user_agent,
108 self._respect_robots_txt,
109 self._extra_wait_ms,
110 self._login,
111 )
112 return _events(config, seed_url, cancel)
114 async def fetch_single(
115 self, url: str, *, timeout: float, concurrency: ConcurrencySpec
116 ) -> FetchedPage:
117 """Fetch *url* alone; a redirected page keeps the requested URL, without its userinfo."""
118 spec = _CrawlSpec(depth=_SEED_DEPTH, max_pages=1, timeout=timeout, concurrency=concurrency)
119 shown = url_filter.without_userinfo(url)
120 async with aclosing(self._stream(spec, url, None)) as events:
121 async for event in events:
122 return _single_result(await _to_page(event, shown), shown)
123 return _single_result(None, shown)
125 async def fetch_recursive(
126 self,
127 seed_url: str,
128 *,
129 depth: int | None,
130 max_pages: int | None,
131 timeout: float,
132 concurrency: ConcurrencySpec,
133 filters: FilterSpec,
134 cancel: CancelToken | None = None,
135 ) -> AsyncGenerator[FetchedPage, None]:
136 """Stream pages crawlberg reaches breadth-first from *seed_url*.
138 A discovered link crawlberg's SSRF policy refuses is dropped without a page,
139 like a link filtered out before it is fetched; a refused seed is a failed page.
140 """
141 spec = _CrawlSpec(depth, max_pages, timeout, concurrency, filters)
142 shown = url_filter.without_userinfo(seed_url)
143 excluded = _ExcludedLinks(spec, shown)
144 if excluded.record_seed(shown):
145 return
146 try:
147 async with aclosing(self._stream(spec, seed_url, cancel)) as events:
148 async for event in events:
149 excluded.record(event)
150 if event.refused_by_ssrf and event.url != shown:
151 log.debug("crawlberg refused %s: %s", event.url, event.error)
152 continue
153 page = await _to_page(event, shown)
154 if page is not None:
155 yield page
156 finally:
157 excluded.summarize()
160# Protocol conformance check: CrawlbergFetcher is structurally a WebFetcher.
161if TYPE_CHECKING:
162 _: WebFetcher = CrawlbergFetcher(render_mode=CrawlRenderMode.HTTP)