Coverage for src/lilbee/crawler/sitemap.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"""Best-effort ``/sitemap.xml`` lookup used as a progress-hint denominator."""
3from __future__ import annotations
5import re
6from http import HTTPStatus
7from typing import TYPE_CHECKING
8from urllib.parse import unquote, urlparse
9from urllib.robotparser import RobotFileParser
11from lilbee.crawler.models import NO_LOGIN, CrawlLogin
12from lilbee.crawler.url_filter import host_in_scope, parsed_url, require_valid_crawl_url
13from lilbee.runtime.progress import CRAWL_TOTAL_UNKNOWN
15# ada-url is in the optional ``crawler`` extra, so it is imported on call.
16if TYPE_CHECKING:
17 import ada_url
18 import httpx
20# Sitemap lookups are best-effort progress hints; never block the actual crawl.
21_SITEMAP_FETCH_TIMEOUT_SECONDS = 5.0
22_SITEMAP_MAX_URLS = 10_000
23_SITEMAP_URL_TAG_RE = re.compile(r"<loc>\s*([^<]+?)\s*</loc>", re.IGNORECASE)
24_USER_AGENT_HEADER = "User-Agent"
25_SITEMAP_PATH = "/sitemap.xml"
26_ROBOTS_PATH = "/robots.txt"
27_ROBOTS_ABSENT_STATUSES = frozenset({HTTPStatus.NOT_FOUND, HTTPStatus.GONE})
28_ANY_USER_AGENT = "*"
31def _basic_credentials(seed: ada_url.URL) -> tuple[str, str] | None:
32 """The decoded username and password of *seed*, or None when it has neither."""
33 if not seed.username and not seed.password:
34 return None
35 return unquote(seed.username), unquote(seed.password)
38def _get(url: str, seed: ada_url.URL, user_agent: str, login: CrawlLogin) -> httpx.Response | None:
39 """One GET of *url* with the seed's credentials and *login*, or None on a transport failure.
41 The seed's own username and password win over *login*'s, as in crawlberg.
43 Redirects are not followed: a 3xx could steer this best-effort request to a
44 private or metadata host (SSRF) before the body is inspected.
45 """
46 import httpx
48 try:
49 return httpx.get(
50 url,
51 auth=_basic_credentials(seed) or login.basic_credentials,
52 headers=_headers(user_agent, login),
53 timeout=_SITEMAP_FETCH_TIMEOUT_SECONDS,
54 follow_redirects=False,
55 )
56 except (httpx.HTTPError, OSError):
57 return None
60def _headers(user_agent: str, login: CrawlLogin) -> dict[str, str] | None:
61 """The user agent, when set, and the login's authorization and cookie headers, or None."""
62 agent = {_USER_AGENT_HEADER: user_agent} if user_agent else {}
63 return {**agent, **login.authorization_header, **login.cookie_header} or None
66def _is_success(resp: httpx.Response) -> bool:
67 return HTTPStatus.OK <= resp.status_code < HTTPStatus.MULTIPLE_CHOICES
70def _robots_allow_sitemap(
71 seed: ada_url.URL, sitemap_url: str, user_agent: str, login: CrawlLogin
72) -> bool:
73 """True when the site's robots.txt lets *user_agent* fetch *sitemap_url*.
75 A missing robots.txt (404 or 410) allows everything. A robots.txt that cannot
76 be read, or that answers with any other status, allows nothing.
77 """
78 resp = _get(f"{seed.origin}{_ROBOTS_PATH}", seed, user_agent, login)
79 if resp is None:
80 return False
81 if resp.status_code in _ROBOTS_ABSENT_STATUSES:
82 return True
83 if not _is_success(resp):
84 return False
85 rules = RobotFileParser()
86 rules.parse(resp.text.splitlines())
87 return rules.can_fetch(user_agent or _ANY_USER_AGENT, sitemap_url)
90def _fetch_sitemap_text(
91 start_url: str, *, user_agent: str = "", respect_robots_txt: bool, login: CrawlLogin = NO_LOGIN
92) -> str | None:
93 """Return sitemap.xml body or None on any fetch/status failure.
95 A blank *user_agent* leaves the user agent to the HTTP client. With
96 *respect_robots_txt* the sitemap is requested only when robots.txt allows it.
97 """
98 parsed = parsed_url(start_url)
99 if parsed is None:
100 return None
101 sitemap_url = f"{parsed.origin}{_SITEMAP_PATH}"
102 # Validate the seed before any connection. This is only a progress hint, so
103 # an unvalidated or redirecting sitemap simply yields an unknown total.
104 try:
105 require_valid_crawl_url(sitemap_url)
106 except ValueError:
107 return None
108 if respect_robots_txt and not _robots_allow_sitemap(parsed, sitemap_url, user_agent, login):
109 return None
110 resp = _get(sitemap_url, parsed, user_agent, login)
111 if resp is None or not _is_success(resp):
112 return None
113 return resp.text
116def _count_sitemap_urls(
117 start_url: str,
118 *,
119 include_subdomains: bool,
120 respect_robots_txt: bool,
121 user_agent: str = "",
122 login: CrawlLogin = NO_LOGIN,
123) -> int:
124 """Best-effort count of URLs in the host's /sitemap.xml that match the crawl scope.
126 Returns ``CRAWL_TOTAL_UNKNOWN`` on any failure (missing sitemap, timeout,
127 parse error, redirect away from the starting host). This is purely a
128 progress-hint denominator, so correctness is not load-bearing.
130 Only fetches sitemap.xml directly at the root of the starting host; does
131 not follow robots.txt references or nested sitemap indexes.
132 """
133 host = (urlparse(start_url).hostname or "").lower()
134 if not host:
135 return CRAWL_TOTAL_UNKNOWN
136 text = _fetch_sitemap_text(
137 start_url, user_agent=user_agent, respect_robots_txt=respect_robots_txt, login=login
138 )
139 if text is None:
140 return CRAWL_TOTAL_UNKNOWN
142 count = 0
143 for match in _SITEMAP_URL_TAG_RE.finditer(text):
144 link_host = (urlparse(match.group(1).strip()).hostname or "").lower()
145 if host_in_scope(link_host, host, include_subdomains=include_subdomains):
146 count += 1
147 if count >= _SITEMAP_MAX_URLS:
148 break
149 return count if count > 0 else CRAWL_TOTAL_UNKNOWN