# Trouve-KA — ingestion des sitemaps du Groupe KA (pivot 2026-08-23) # Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai """Alimentation principale du moteur : enfile dans le frontier TOUTES les URLs déclarées par les sitemaps des sites du Groupe KA. Usage : .venv/bin/python scripts/ka-sitemaps/ingest.py [--sites lou-ka.com,…] [--max-per-site N] [--shallow-priority 0.9] [--deep-priority 0.5] - Découverte des sitemaps via robots.txt (lignes « Sitemap: »), repli sur /sitemap.xml. Suit les index de sitemaps (1 niveau, le pattern seo.py KA). - Traitement en STREAMING chunk par chunk (vrai-prix déclare ~3,75 M d'URLs sur 84 chunks : jamais tout en mémoire). - Priorité par URL : les pages peu profondes (accueil, villes, catégories) passent avant les fiches — le moteur sert l'essentiel d'abord, la longue traîne s'indexe en continu. - Ré-exécutable à volonté (les URLs connues sont ignorées par la base) : planifié quotidiennement via PM2 (process tk-sitemaps) pour ramasser les nouvelles fiches publiées. """ import argparse import asyncio import gzip import re import sys import urllib.request from trouveka.config import get_settings from trouveka.database import Database from trouveka.logging import get_logger from trouveka.shared import KA_DOMAINS, canonicalize_url, extract_domain, is_ka_domain log = get_logger("crawler.ingest_ka_sitemaps") LOC_RE = re.compile(r"\s*([^<\s]+)\s*") SITEMAP_LINE_RE = re.compile(r"(?im)^\s*sitemap\s*:\s*(\S+)") DB_CHUNK = 1000 # Domaines à ingérer (sous-ensemble de l'allowlist : forma-ka/api-ka exclus # tant qu'ils n'exposent pas de sitemap réel). INGEST_DOMAINS: tuple[str, ...] = tuple(sorted(KA_DOMAINS)) def fetch(url: str, user_agent: str, timeout: float = 30.0) -> str: req = urllib.request.Request(url, headers={"User-Agent": user_agent}) with urllib.request.urlopen(req, timeout=timeout) as resp: body = resp.read(50_000_000) if resp.headers.get("Content-Encoding") == "gzip" or url.endswith(".gz"): try: body = gzip.decompress(body) except OSError: pass return body.decode("utf-8", errors="replace") def discover_sitemaps(domain: str, user_agent: str) -> list[str]: """Sitemaps déclarés par robots.txt, sinon /sitemap.xml.""" base = f"https://www.{domain}" try: robots = fetch(f"{base}/robots.txt", user_agent, timeout=15.0) declared = [u for u in SITEMAP_LINE_RE.findall(robots) if is_ka_domain(extract_domain(u) or "")] if declared: return declared except Exception as exc: # noqa: BLE001 — robots absent/cassé : on tente le chemin standard log.info("robots.txt illisible", extra={"ctx": {"domain": domain, "err": str(exc)}}) return [f"{base}/sitemap.xml"] def path_depth(url: str) -> int: return len([s for s in url.split("/", 3)[-1].split("/") if s]) async def ingest_domain( db: Database, domain: str, *, user_agent: str, max_urls_per_domain: int, max_per_site: int | None, shallow_priority: float, deep_priority: float, ) -> tuple[int, int]: """Ingestion streaming d'un domaine. Retourne (urls_vues, urls_ajoutées).""" seen_total = 0 added_total = 0 async def enqueue_batch(locs: list[str]) -> None: nonlocal seen_total, added_total items: list[tuple[str, str, float]] = [] for loc in locs: url = canonicalize_url(loc) if not url: continue d = extract_domain(url) if not d or not is_ka_domain(d): continue # garde-fou : on reste dans le périmètre KA prio = shallow_priority if path_depth(url) <= 2 else deep_priority items.append((url, d, prio)) seen_total += len(items) for i in range(0, len(items), DB_CHUNK): _, n = await db.enqueue_urls_bulk( items[i : i + DB_CHUNK], depth=1, source_url_id=None, max_urls_per_domain=max_urls_per_domain, ) added_total += n for sitemap_url in discover_sitemaps(domain, user_agent): try: body = fetch(sitemap_url, user_agent) except Exception as exc: # noqa: BLE001 log.info("sitemap illisible", extra={"ctx": {"url": sitemap_url, "err": str(exc)}}) continue if "= max_per_site: break try: child_body = fetch(child, user_agent) except Exception as exc: # noqa: BLE001 — un chunk cassé ne bloque pas le reste log.info("chunk illisible", extra={"ctx": {"url": child, "err": str(exc)}}) continue if "= max_per_site: break log.info("domaine ingéré", extra={"ctx": {"domain": domain, "vues": seen_total, "ajoutées": added_total}}) return seen_total, added_total async def run(sites: list[str], max_per_site: int | None, shallow: float, deep: float) -> None: settings = get_settings() db = Database(settings.database_url, pool_min=1, pool_max=3) await db.connect() grand_seen = grand_added = 0 try: for domain in sites: seen, added = await ingest_domain( db, domain, user_agent=settings.crawler_user_agent, max_urls_per_domain=settings.max_urls_per_domain, max_per_site=max_per_site, shallow_priority=shallow, deep_priority=deep, ) grand_seen += seen grand_added += added print(f"{domain}: {seen} URLs vues, {added} ajoutées au frontier", file=sys.stderr) finally: await db.close() print(f"TOTAL : {grand_seen} URLs vues, {grand_added} ajoutées au frontier", file=sys.stderr) def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--sites", help="domaines à ingérer, séparés par des virgules (défaut : tous)") ap.add_argument("--max-per-site", type=int, default=None, help="plafond d'URLs par site (défaut : illimité)") ap.add_argument("--shallow-priority", type=float, default=0.9, help="priorité des pages peu profondes") ap.add_argument("--deep-priority", type=float, default=0.5, help="priorité des fiches profondes") args = ap.parse_args() if args.sites: sites = [s.strip().removeprefix("www.") for s in args.sites.split(",") if s.strip()] unknown = [s for s in sites if s not in KA_DOMAINS] if unknown: ap.error(f"domaines hors périmètre KA : {', '.join(unknown)}") else: sites = list(INGEST_DOMAINS) asyncio.run(run(sites, args.max_per_site, args.shallow_priority, args.deep_priority)) if __name__ == "__main__": main()