# 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()