SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
13.1 KB · 279 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/connectors/sitefinder.py4# Desc:   Connecteur d'ENRICHISSEMENT « site-finder » — découvre le SITE WEB5#         officiel des restaurants OSM qui n'en publient pas, via l'API Serper6#         (google.serper.dev/maps, fiches Google Maps). Mode d'extraction :7#         API JSON commerciale (aucun scraping). Contexte de prix : AUCUN —8#         ce connecteur n'émet ni fiche ni prix, il remplit la colonne9#         `website` (et `phone` si vide) des fiches OSM existantes.10#11#         Pourquoi : le connecteur `site-resto` (Palier 3, menus maison à prix12#         dine-in) ne peut crawler QUE les restos dont OSM connaît le site web13#         (~3 400 sur ~14 000). Les ~10 600 restants ont pourtant très souvent14#         un site : ce connecteur le retrouve via la fiche Google Maps du15#         commerce et débloque ainsi le pipeline menus maison à l'échelle.16#17#         Pipeline par resto (budget SITE_FINDER_BUDGET, incrémental) :18#           1. Requête /maps : q = nom du resto, ll = @lat,lng,15z (centrée sur19#              les coordonnées OSM — décisif pour les chaînes multi-succursales).20#           2. Croisement CONSERVATEUR : nom similaire (norm_name/_name_similar21#              d'inspections.py) ET distance GPS <= 400 m ; ambigu (2 fiches22#              distinctes qui matchent) -> aucun enrichissement.23#           3. Classification du site : domaines sociaux/agrégateurs/livraison24#              REJETÉS (un lien Uber Eats n'est pas « le site du resto » — et25#              produirait des prix delivery mal étiquetés dine-in en aval) ;26#              plateformes de réservation -> details.reservation_url ;27#              sinon -> colonne `website` + details.site_finder.28#           4. `phone` (E.164) complété si la fiche OSM n'en a pas (COALESCE).29#30#         Durabilité : sync_source ré-écrase `website` quand la fiche OSM31#         change -> le site découvert est AUSSI consigné dans32#         details.site_finder.website et RÉ-APPLIQUÉ à chaque passage (0 requête33#         API). Les échecs sont horodatés et re-sondés après REFRESH_DAYS.34# ==============================================================================35from __future__ import annotations3637import datetime38import json39import math40import os41import re42import sys43import urllib.parse4445from ..inspections import _name_similar, norm_name46from ..regions import strip_accents47from ..schema import Restaurant48from .base import BaseConnector, SkipSource49from .siteresto import _RESERVATION_DOMAINS, _domain5051SERPER_MAPS_URL = "https://google.serper.dev/maps"52MAX_BUDGET = int(os.environ.get("SITE_FINDER_BUDGET", "3000"))  # requêtes/cycle53REFRESH_DAYS = 180                # re-sonde des échecs (un site apparaît rarement)54MAX_CONSECUTIVE_FAILURES = 5      # API en panne / quota épuisé -> arrêt du cycle55MAX_DIST_M = 400.0                # rayon de croisement fiche OSM <-> fiche Google5657# Domaines qui ne sont PAS « le site du resto » : réseaux sociaux, agrégateurs,58# annuaires et plateformes de LIVRAISON (leurs menus sont en prix delivery59# majorés — les laisser passer contaminerait le pipeline dine-in de site-resto).60_BAD_DOMAINS = (61    "facebook.com", "instagram.com", "linktr.ee", "linkin.bio", "tiktok.com",62    "ubereats.com", "doordash.com", "order.online", "skipthedishes.com",63    "just-eat.ca", "restoloco.com", "restoloco.ca",64    "yelp.com", "yelp.ca", "tripadvisor.com", "tripadvisor.ca", "tripadvisor.fr",65    "google.com", "goo.gl", "business.site", "restomontreal.ca", "tastet.ca",66    "pagesjaunes.ca", "yellowpages.ca", "411.ca", "wikipedia.org",67)6869# paramètres de pistage à retirer des URLs (fiches Google truffées d'utm_*)70_TRACKING_RE = re.compile(r"^(utm_|fbclid$|gclid$|mc_cid$|mc_eid$|ref$)", re.I)717273def clean_website(url: str) -> str:74    """URL de site web nettoyée (schéma http(s), sans fragment ni utm_*) ou ''."""75    try:76        p = urllib.parse.urlsplit((url or "").strip())77    except ValueError:78        return ""79    if p.scheme not in ("http", "https") or not p.netloc:80        return ""81    query = urllib.parse.urlencode(82        [(k, v) for k, v in urllib.parse.parse_qsl(p.query, keep_blank_values=True)83         if not _TRACKING_RE.match(k)])84    return urllib.parse.urlunsplit((p.scheme, p.netloc, p.path, query, ""))858687def classify_website(url: str) -> tuple[str, str]:88    """('site'|'reservation'|'rejected', url_nettoyée) pour le champ website89    d'une fiche Google Maps."""90    cleaned = clean_website(url)91    if not cleaned:92        return "rejected", ""93    dom = _domain(cleaned)94    if any(dom == d or dom.endswith("." + d) for d in _RESERVATION_DOMAINS):95        return "reservation", cleaned96    if any(dom == d or dom.endswith("." + d) for d in _BAD_DOMAINS):97        return "rejected", cleaned98    return "site", cleaned99100101def _dist_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:102    """Distance équirectangulaire en mètres (suffisant à l'échelle d'un pâté)."""103    dx = math.radians(lng2 - lng1) * math.cos(math.radians((lat1 + lat2) / 2))104    dy = math.radians(lat2 - lat1)105    return math.hypot(dx, dy) * 6_371_000.0106107108def _names_match(rname: str, cname: str, addr_tokens: set[str]) -> bool:109    """Nom similaire (helper conservateur d'inspections.py) OU quasi-identique110    à une coquille près (« Pizzaria Amos » vs « Pizzéria Amos Inc ») — le repli111    flou est sûr ici car la distance GPS <= 400 m est déjà exigée."""112    if _name_similar(rname, cname, addr_tokens):113        return True114    if not rname or not cname:115        return False116    import difflib117    return difflib.SequenceMatcher(None, rname, cname).ratio() >= 0.85118119120def match_place(row, places: list[dict]) -> dict | None:121    """Croisement CONSERVATEUR d'un resto OSM avec les fiches Google Maps :122    nom similaire ET distance GPS <= 400 m. Deux fiches distinctes (cid) qui123    matchent = ambigu -> None (jamais deviner entre deux commerces)."""124    rname = norm_name(row["name"])125    addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents(126        f"{row['address'] or ''} {row['city'] or ''}".lower())).split())127    hits = []128    for p in places or []:129        lat, lng = p.get("latitude"), p.get("longitude")130        if not isinstance(lat, (int, float)) or not isinstance(lng, (int, float)):131            continue132        d = _dist_m(row["lat"], row["lng"], lat, lng)133        if d > MAX_DIST_M:134            continue135        if not _names_match(rname, norm_name(p.get("title") or ""), addr_tokens):136            continue137        hits.append((d, p))138    cids = {str(p.get("cid") or id(p)) for _, p in hits}139    if len(cids) != 1:140        return None                   # rien, ou ambigu : on s'abstient141    return min(hits, key=lambda t: t[0])[1]142143144class SiteFinderConnector(BaseConnector):145    source_id = "site-finder"146    request_delay = 0.15              # API commerciale (quota), pas un site à ménager147    timeout = 30148    use_detail_cache = False149    enrichment_only = True            # n'émet aucune fiche (ingest.run)150151    def _now(self) -> str:152        return datetime.datetime.now(datetime.timezone.utc) \153            .strftime("%Y-%m-%dT%H:%M:%SZ")154155    def _fresh(self, stamp: str, now: float, stale_s: float) -> bool:156        try:157            ts = datetime.datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ") \158                .replace(tzinfo=datetime.timezone.utc).timestamp()159            return ts > now - stale_s160        except (ValueError, TypeError):161            return False162163    def _reapply(self, con) -> int:164        """Ré-applique les sites déjà découverts dont la colonne `website` a165        été ré-écrasée par un sync OSM (0 requête API — details fait foi)."""166        restored = 0167        for row in con.execute(168                "SELECT uid, json_extract(details,'$.site_finder.website') AS w"169                " FROM restaurants WHERE source='osm' AND active=1 AND website=''"170                " AND json_extract(details,'$.site_finder.website') IS NOT NULL"171        ).fetchall():172            con.execute("UPDATE restaurants SET website=? WHERE uid=?",173                        (row["w"], row["uid"]))174            restored += 1175        return restored176177    def _lookup(self, name: str, lat: float, lng: float) -> list[dict]:178        """Fiches Google Maps autour des coordonnées OSM (API Serper /maps)."""179        key = os.environ["SERPER_API_KEY"]180        resp = self.post(SERPER_MAPS_URL,181                         json={"q": name[:96], "ll": f"@{lat},{lng},15z",182                               "gl": "ca", "hl": "fr"},183                         headers={"X-API-KEY": key,184                                  "Content-Type": "application/json"})185        return resp.json().get("places") or []186187    def fetch(self) -> list[Restaurant]:188        if not os.environ.get("SERPER_API_KEY"):189            raise SkipSource("SERPER_API_KEY manquant (.env) — découverte de "190                             "sites web impossible sans l'API Serper")191        import time as _time192        from .. import db193        from ..normalize import normalize_phone194        con = db.connect()195        now = _time.time()196        stale_s = REFRESH_DAYS * 86400.0197        budget = MAX_BUDGET198        restored = self._reapply(con)199        con.commit()200        enriched = reservations = phones = misses = failures_row = 0201        rows = con.execute(202            "SELECT uid, name, address, city, lat, lng, phone, details"203            " FROM restaurants WHERE source='osm' AND active=1"204            " AND dup_of IS NULL AND website='' AND name<>''"205            " AND lat IS NOT NULL AND lng IS NOT NULL"206            # indépendants d'abord : c'est là que vivent les menus maison que207            # site-resto pourra capter (les chaînes ont leurs propres paliers)208            " ORDER BY chain IS NULL DESC, region<>'' DESC, city"209        ).fetchall()210        for row in rows:211            if budget <= 0:212                break213            if failures_row >= MAX_CONSECUTIVE_FAILURES:214                print("[resto-ka] site-finder: API Serper en échec "215                      f"{failures_row} fois de suite — arrêt du cycle",216                      file=sys.stderr)217                break218            try:219                details = json.loads(row["details"] or "{}")220            except ValueError:221                details = {}222            probe = details.get("site_finder") or {}223            if probe.get("website"):224                continue              # déjà trouvé (ré-appliqué plus haut)225            if self._fresh(probe.get("fetched_at", ""), now, stale_s):226                continue              # sondé récemment : re-visite dans 180 j227            budget -= 1228            try:229                places = self._lookup(row["name"], row["lat"], row["lng"])230            except Exception as exc:231                failures_row += 1232                print(f"[resto-ka] site-finder: {row['uid']} erreur: {exc}",233                      file=sys.stderr)234                continue235            failures_row = 0236            hit = match_place(row, places)237            stamp = self._now()238            if hit is None:239                db.merge_details(con, row["uid"], {"site_finder": {240                    "miss": "introuvable ou ambigu", "fetched_at": stamp}})241                misses += 1242                con.commit()243                continue244            # téléphone de la fiche Google : COALESCE si la fiche OSM n'en a pas245            phone = normalize_phone(hit.get("phoneNumber") or "")246            if phone and not (row["phone"] or "").strip():247                db.enrich_contact(con, row["uid"], phone=phone)248                phones += 1249            kind, url = classify_website(hit.get("website") or "")250            if kind == "site":251                con.execute("UPDATE restaurants SET website=? WHERE uid=?",252                            (url, row["uid"]))253                db.merge_details(con, row["uid"], {"site_finder": {254                    "website": url, "cid": str(hit.get("cid") or ""),255                    "title": hit.get("title") or "", "fetched_at": stamp}})256                enriched += 1257            elif kind == "reservation":258                db.enrich_contact(con, row["uid"],259                                  details={"reservation_url": url})260                db.merge_details(con, row["uid"], {"site_finder": {261                    "miss": "site = plateforme de réservation",262                    "cid": str(hit.get("cid") or ""), "fetched_at": stamp}})263                reservations += 1264            else:265                db.merge_details(con, row["uid"], {"site_finder": {266                    "miss": "fiche Google sans site web (ou domaine rejeté)",267                    "cid": str(hit.get("cid") or ""), "fetched_at": stamp}})268                misses += 1269            con.commit()270        con.commit()271        con.close()272        self.enriched_count = enriched273        self.enrich_message = (274            f"{enriched} site(s) web découverts, {restored} ré-appliqué(s), "275            f"{phones} téléphone(s), {reservations} lien(s) de réservation, "276            f"{misses} sans site, budget restant {max(budget, 0)} requête(s)")277        print(f"[resto-ka] site-finder: {self.enrich_message}")278        return []279