Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com
Python 51.9%
TypeScript 32.7%
JavaScript 4.8%
CSS 4.1%
Shell 2.9%
HTML 1.8%
SQL 1.5%
1# Trouve-KA — ingestion des sitemaps du Groupe KA (pivot 2026-08-23)2# Author: Simon-Pierre Boucher3# Contact: contact@spboucher.ai45"""Alimentation principale du moteur : enfile dans le frontier TOUTES les URLs6déclarées par les sitemaps des sites du Groupe KA.78Usage : .venv/bin/python scripts/ka-sitemaps/ingest.py [--sites lou-ka.com,…]9 [--max-per-site N] [--shallow-priority 0.9] [--deep-priority 0.5]1011- Découverte des sitemaps via robots.txt (lignes « Sitemap: »), repli sur12 /sitemap.xml. Suit les index de sitemaps (1 niveau, le pattern seo.py KA).13- Traitement en STREAMING chunk par chunk (vrai-prix déclare ~3,75 M d'URLs14 sur 84 chunks : jamais tout en mémoire).15- Priorité par URL : les pages peu profondes (accueil, villes, catégories)16 passent avant les fiches — le moteur sert l'essentiel d'abord, la longue17 traîne s'indexe en continu.18- Ré-exécutable à volonté (les URLs connues sont ignorées par la base) :19 planifié quotidiennement via PM2 (process tk-sitemaps) pour ramasser les20 nouvelles fiches publiées.21"""2223import argparse24import asyncio25import gzip26import re27import sys28import urllib.request2930from trouveka.config import get_settings31from trouveka.database import Database32from trouveka.logging import get_logger33from trouveka.shared import KA_DOMAINS, canonicalize_url, extract_domain, is_ka_domain3435log = get_logger("crawler.ingest_ka_sitemaps")3637LOC_RE = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>")38SITEMAP_LINE_RE = re.compile(r"(?im)^\s*sitemap\s*:\s*(\S+)")39DB_CHUNK = 10004041# Domaines à ingérer (sous-ensemble de l'allowlist : forma-ka/api-ka exclus42# tant qu'ils n'exposent pas de sitemap réel).43INGEST_DOMAINS: tuple[str, ...] = tuple(sorted(KA_DOMAINS))444546def fetch(url: str, user_agent: str, timeout: float = 30.0) -> str:47 req = urllib.request.Request(url, headers={"User-Agent": user_agent})48 with urllib.request.urlopen(req, timeout=timeout) as resp:49 body = resp.read(50_000_000)50 if resp.headers.get("Content-Encoding") == "gzip" or url.endswith(".gz"):51 try:52 body = gzip.decompress(body)53 except OSError:54 pass55 return body.decode("utf-8", errors="replace")565758def discover_sitemaps(domain: str, user_agent: str) -> list[str]:59 """Sitemaps déclarés par robots.txt, sinon /sitemap.xml."""60 base = f"https://www.{domain}"61 try:62 robots = fetch(f"{base}/robots.txt", user_agent, timeout=15.0)63 declared = [u for u in SITEMAP_LINE_RE.findall(robots) if is_ka_domain(extract_domain(u) or "")]64 if declared:65 return declared66 except Exception as exc: # noqa: BLE001 — robots absent/cassé : on tente le chemin standard67 log.info("robots.txt illisible", extra={"ctx": {"domain": domain, "err": str(exc)}})68 return [f"{base}/sitemap.xml"]697071def path_depth(url: str) -> int:72 return len([s for s in url.split("/", 3)[-1].split("/") if s])737475async def ingest_domain(76 db: Database,77 domain: str,78 *,79 user_agent: str,80 max_urls_per_domain: int,81 max_per_site: int | None,82 shallow_priority: float,83 deep_priority: float,84) -> tuple[int, int]:85 """Ingestion streaming d'un domaine. Retourne (urls_vues, urls_ajoutées)."""86 seen_total = 087 added_total = 08889 async def enqueue_batch(locs: list[str]) -> None:90 nonlocal seen_total, added_total91 items: list[tuple[str, str, float]] = []92 for loc in locs:93 url = canonicalize_url(loc)94 if not url:95 continue96 d = extract_domain(url)97 if not d or not is_ka_domain(d):98 continue # garde-fou : on reste dans le périmètre KA99 prio = shallow_priority if path_depth(url) <= 2 else deep_priority100 items.append((url, d, prio))101 seen_total += len(items)102 for i in range(0, len(items), DB_CHUNK):103 _, n = await db.enqueue_urls_bulk(104 items[i : i + DB_CHUNK], depth=1, source_url_id=None,105 max_urls_per_domain=max_urls_per_domain,106 )107 added_total += n108109 for sitemap_url in discover_sitemaps(domain, user_agent):110 try:111 body = fetch(sitemap_url, user_agent)112 except Exception as exc: # noqa: BLE001113 log.info("sitemap illisible", extra={"ctx": {"url": sitemap_url, "err": str(exc)}})114 continue115 if "<sitemapindex" in body:116 children = LOC_RE.findall(body)117 log.info("index de sitemaps", extra={"ctx": {"domain": domain, "chunks": len(children)}})118 for child in children:119 if max_per_site and seen_total >= max_per_site:120 break121 try:122 child_body = fetch(child, user_agent)123 except Exception as exc: # noqa: BLE001 — un chunk cassé ne bloque pas le reste124 log.info("chunk illisible", extra={"ctx": {"url": child, "err": str(exc)}})125 continue126 if "<sitemapindex" in child_body:127 continue # un seul niveau d'indirection (pattern seo.py)128 await enqueue_batch(LOC_RE.findall(child_body))129 else:130 await enqueue_batch(LOC_RE.findall(body))131 if max_per_site and seen_total >= max_per_site:132 break133134 log.info("domaine ingéré", extra={"ctx": {"domain": domain, "vues": seen_total, "ajoutées": added_total}})135 return seen_total, added_total136137138async def run(sites: list[str], max_per_site: int | None, shallow: float, deep: float) -> None:139 settings = get_settings()140 db = Database(settings.database_url, pool_min=1, pool_max=3)141 await db.connect()142 grand_seen = grand_added = 0143 try:144 for domain in sites:145 seen, added = await ingest_domain(146 db, domain,147 user_agent=settings.crawler_user_agent,148 max_urls_per_domain=settings.max_urls_per_domain,149 max_per_site=max_per_site,150 shallow_priority=shallow, deep_priority=deep,151 )152 grand_seen += seen153 grand_added += added154 print(f"{domain}: {seen} URLs vues, {added} ajoutées au frontier", file=sys.stderr)155 finally:156 await db.close()157 print(f"TOTAL : {grand_seen} URLs vues, {grand_added} ajoutées au frontier", file=sys.stderr)158159160def main() -> None:161 ap = argparse.ArgumentParser(description=__doc__)162 ap.add_argument("--sites", help="domaines à ingérer, séparés par des virgules (défaut : tous)")163 ap.add_argument("--max-per-site", type=int, default=None, help="plafond d'URLs par site (défaut : illimité)")164 ap.add_argument("--shallow-priority", type=float, default=0.9, help="priorité des pages peu profondes")165 ap.add_argument("--deep-priority", type=float, default=0.5, help="priorité des fiches profondes")166 args = ap.parse_args()167168 if args.sites:169 sites = [s.strip().removeprefix("www.") for s in args.sites.split(",") if s.strip()]170 unknown = [s for s in sites if s not in KA_DOMAINS]171 if unknown:172 ap.error(f"domaines hors périmètre KA : {', '.join(unknown)}")173 else:174 sites = list(INGEST_DOMAINS)175176 asyncio.run(run(sites, args.max_per_site, args.shallow_priority, args.deep_priority))177178179if __name__ == "__main__":180 main()181