SPB Git forge

spb/trouve-ka

Public ★ Pinned

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

40commits 1branches 0releases
9.2 MBsize
maindefault branch
23 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%
7.2 KB · 181 lines python
Raw Blame History
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