"""Sitemap connector (spec ยง9.2): `urlset` and `sitemapindex` (plain or .gz), `lastmod`, bounded by `settings.discovery_max_sitemap_urls`.
The observable change is the *set of URLs* (new / gone since the previous snapshot โ the pipeline diffs `extracted.urls`) plus `lastmod`
moves; every URL is classified with `urls.classify_url` and emitted as `DiscoveredUrl` so the discovery feedback loop can add sensors.
Child sitemaps of an index are fetched breadth-first (bounded `MAX_CHILD_SITEMAPS`)."""
from __future__ import annotations
import gzip
import hashlib
import logging
import re
from collections.abc import Mapping
from typing import Any
from urllib.parse import urlparse
from companyatlas.config import settings
from companyatlas.connectors._util import merged_result
from companyatlas.fetch import Fetcher, FetchResult
from companyatlas.sdk.connector import Connector, ConnectorContext, ConnectorMeta, register
from companyatlas.sdk.models import Block, DiscoveredUrl, Extraction
from companyatlas.sdk.normalize import simhash
from companyatlas.taxonomy import FetchMode, Surface
from companyatlas.urls import canonicalize_url, classify_url, is_static_asset, looks_like_trap
log = logging.getLogger(__name__)
MAX_CHILD_SITEMAPS = 25
LOC_RE = re.compile(r"\s*(?:)?\s*", re.IGNORECASE)
ENTRY_RE = re.compile(r"<(url|sitemap)>(.*?)\1>", re.IGNORECASE | re.DOTALL)
LASTMOD_RE = re.compile(r"\s*([^<\s]+)\s*", re.IGNORECASE)
# Sitemap children whose names suggest low-value bulk content (products/tags/images) are visited last.
LOW_VALUE_CHILD_RE = re.compile(r"(image|video|tag|category|author|product|shop|store|collection|attachment|page-\d+|post-\d+)", re.IGNORECASE)
HIGH_VALUE_CHILD_RE = re.compile(r"(page|pages|static|main|site|news|press|blog|careers?|jobs?|misc|general)", re.IGNORECASE)
def _decode(content: bytes) -> str:
if content[:2] == b"\x1f\x8b":
try:
content = gzip.decompress(content)
except (OSError, EOFError):
return ""
return content.decode("utf-8", errors="replace")
def parse_sitemap(text: str) -> tuple[list[tuple[str, str | None]], list[tuple[str, str | None]]]:
"""โ (page_urls[(loc, lastmod)], child_sitemaps[(loc, lastmod)])."""
pages: list[tuple[str, str | None]] = []
children: list[tuple[str, str | None]] = []
for m in ENTRY_RE.finditer(text):
body = m.group(2)
loc = LOC_RE.search(body)
if not loc:
continue
lm = LASTMOD_RE.search(body)
item = (loc.group(1).strip(), lm.group(1).strip() if lm else None)
(children if m.group(1).lower() == "sitemap" else pages).append(item)
if not pages and not children: # tolerate sitemaps without wrappers / text sitemaps
for m in LOC_RE.finditer(text):
pages.append((m.group(1).strip(), None))
if not pages:
for line in text.splitlines():
line = line.strip()
if line.startswith(("http://", "https://")):
pages.append((line, None))
return pages, children
@register
class SitemapConnector(Connector):
meta = ConnectorMeta(connector_id="sitemap-v1", name="XML sitemap", version="1", category=Surface.SITEMAP, fetch_mode=FetchMode.SITEMAP,
supports_discovery=True, default_interval_s=24 * 3600, url_pattern=r"sitemap[^/]*\.xml(\.gz)?$|/sitemap_index\.xml$|/sitemaps?/",
priority=40, accept="application/xml,text/xml,*/*;q=0.5", description="urlset / sitemapindex with lastmod, bounded")
async def fetch(self, ctx: ConnectorContext, sensor: Mapping[str, Any], fetcher: Fetcher) -> FetchResult:
first = await fetcher.get(str(sensor["url"]), etag=sensor.get("etag"), last_modified=sensor.get("last_modified"), accept=self.meta.accept)
pages, children = parse_sitemap(_decode(first.content))
if not children:
return first
limit = settings.discovery_max_sitemap_urls
ordered = sorted(children, key=lambda c: (0 if HIGH_VALUE_CHILD_RE.search(c[0]) else (2 if LOW_VALUE_CHILD_RE.search(c[0]) else 1), c[0]))
merged: list[dict[str, Any]] = [{"loc": u, "lastmod": lm} for u, lm in pages]
visited: list[str] = []
for child_url, _lm in ordered[:MAX_CHILD_SITEMAPS]:
if len(merged) >= limit:
break
try:
res = await fetcher.get(child_url, accept=self.meta.accept)
except Exception as exc: # noqa: BLE001 - one broken child must not fail the whole sitemap
log.info("child sitemap skipped", extra={"child": child_url, "error": str(exc)[:200]})
continue
p, _c = parse_sitemap(_decode(res.content))
merged.extend({"loc": u, "lastmod": lm} for u, lm in p[: max(0, limit - len(merged))])
visited.append(child_url)
payload = {"index": first.final_url, "children": [c[0] for c in ordered], "visited": visited, "urls": merged[:limit]}
return merged_result(first, payload, pages=1 + len(visited))
def extract(self, sensor: Mapping[str, Any], result: FetchResult) -> Extraction:
limit = settings.discovery_max_sitemap_urls
if result.headers.get("x-companyatlas-pages"):
data = result.json()
entries = [(e.get("loc"), e.get("lastmod")) for e in data.get("urls", []) if e.get("loc")]
children = list(data.get("children", []))
else:
entries, kids = parse_sitemap(_decode(result.content))
children = [c[0] for c in kids]
entries = entries[:limit]
canonical_domain = str((sensor.get("config") or {}).get("canonical_domain") or urlparse(str(sensor["url"])).hostname or "")
seen: set[str] = set()
blocks: list[Block] = []
discovered: list[DiscoveredUrl] = []
by_surface: dict[str, int] = {}
urls_out: list[dict[str, Any]] = []
for loc, lastmod in entries:
if not loc or is_static_asset(loc):
continue
canon = canonicalize_url(loc)
if canon in seen:
continue
seen.add(canon)
surface, conf = classify_url(loc, canonical_domain=canonical_domain)
by_surface[str(surface)] = by_surface.get(str(surface), 0) + 1
urls_out.append({"url": canon, "lastmod": lastmod, "surface": str(surface), "confidence": conf})
key = f"url:{hashlib.blake2b(canon.encode('utf-8'), digest_size=8).hexdigest()}"
blocks.append(Block(key=key, kind="list", text=canon, path="Sitemap", hash=hashlib.sha256(canon.encode()).hexdigest()[:16],
simhash=simhash(canon), weight=0.6, order=len(blocks), attrs={"lastmod": lastmod}))
if conf >= settings.discovery_min_confidence and surface not in (Surface.OTHER, Surface.SITEMAP, Surface.HOMEPAGE) and not looks_like_trap(loc):
discovered.append(DiscoveredUrl(url=loc, surface=surface, confidence=conf, method="sitemap"))
text = "\n".join(u["url"] for u in urls_out)
meta = {"url_count": len(urls_out), "child_sitemaps": children[:50], "by_surface": by_surface, "truncated": len(entries) >= limit,
"urls": urls_out, "structured": True}
return Extraction(text=text, blocks=blocks, title=f"Sitemap โ {len(urls_out)} URLs", meta=meta, discovered=discovered[:500])
__all__ = ["SitemapConnector", "parse_sitemap"]