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%
10.3 KB · 239 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/connectors/yelp_scrape.py4# Desc:   Connecteur d'ENRICHISSEMENT Yelp SANS clé API (avis/notes par5#         scraping léger). Une seule page de RECHERCHE yelp.ca par resto6#         (find_desc=<nom>&find_loc=<ville>) via Scrapfly ASP : la page embarque7#         un cache Apollo (scripts data-apollo-state) avec, PAR commerce,8#         alias, nom, note, nombre d'avis, fourchette de prix, catégories et9#         adresse (ville + civique). Croisement CONSERVATEUR : nom similaire10#         + même numéro civique + ville compatible (jamais fusionner deux11#         établissements). Résultat -> details.yelp {rating, review_count,12#         price, categories, url} (mêmes clés que Yelp Fusion — le connecteur13#         officiel `yelp` reprend la main dès qu'une YELP_API_KEY existe).14#15#         Économe : budget YELP_SCRAPE_BUDGET (défaut 80) pages/cycle,16#         re-visite 30 jours (hit comme miss), priorité aux restos avec menu.17#         Base légale : pages publiques, extraction minimale (note agrégée +18#         lien vers la fiche Yelp, attribution affichée) — voir CLAUDE.md §15.19# ==============================================================================20from __future__ import annotations2122import datetime23import html as html_mod24import json25import os26import re27import sys28import urllib.parse2930from ..inspections import _CIVIC_RE, _name_similar, norm_name31from ..regions import strip_accents32from ..schema import Restaurant33from .base import BaseConnector, SkipSource3435SEARCH_URL = "https://www.yelp.ca/search?find_desc={desc}&find_loc={loc}"36MAX_BUDGET = int(os.environ.get("YELP_SCRAPE_BUDGET", "80"))  # pages/cycle37REFRESH_DAYS = 30                    # re-visite des notes ET des échecs38MAX_CONSECUTIVE_FAILURES = 3         # Yelp bloque même Scrapfly -> on arrête3940_APOLLO_RE = re.compile(41    r"<script[^>]*data-apollo-state[^>]*>\s*<!--(.*?)-->\s*</script>", re.S)424344def parse_apollo_businesses(page_html: str) -> list[dict]:45    """Extrait les commerces du cache Apollo embarqué dans une page de46    recherche yelp.ca : [{alias, name, rating, review_count, price,47    categories, city, address, closed}]."""48    cache: dict = {}49    for m in _APOLLO_RE.finditer(page_html):50        try:51            cache.update(json.loads(html_mod.unescape(m.group(1))))52        except ValueError:53            continue54    if not cache:55        return []56    categories = {k.split(":", 1)[1]: (v.get("title") or "")57                  for k, v in cache.items()58                  if k.startswith("BusinessCategory:") and isinstance(v, dict)}59    locations = {k.split(":", 1)[1]: (v.get("address") or {})60                 for k, v in cache.items()61                 if k.startswith("BusinessLocation:") and isinstance(v, dict)}62    out = []63    for k, v in cache.items():64        if not k.startswith("Business:") or not isinstance(v, dict):65            continue66        if not v.get("alias") or not v.get("name"):67            continue68        loc_ref = ((v.get("location") or {}).get("__ref") or "")69        addr = locations.get(loc_ref.split(":", 1)[-1], {})70        cats = []71        for c in v.get("categories") or []:72            ref = (c.get("__ref") or "").split(":", 1)[-1]73            if categories.get(ref):74                cats.append(categories[ref])75        closed = any((a or {}).get("type") == "permclosed"76                     for a in [v.get(kk) for kk in v77                               if kk.startswith("activeAlert")])78        price = v.get("priceRange")79        out.append({80            "alias": v["alias"],81            "name": v["name"],82            "rating": v.get("rating"),83            "review_count": v.get("reviewCount") or 0,84            "price": (price or {}).get("display") if isinstance(price, dict)85            else price,86            "categories": cats,87            "city": (addr or {}).get("city") or "",88            "address": (addr or {}).get("addressLine1") or "",89            "closed": closed,90        })91    return out929394def _city_key(city: str) -> str:95    """« Quebec City » / « Québec » -> « quebec » ; « Montréal » -> « montreal »."""96    s = strip_accents((city or "").lower())97    s = re.sub(r"[^a-z0-9]+", " ", s).strip()98    return re.sub(r"\bcity\b", "", s).strip()99100101def match_business(row, candidates: list[dict]) -> dict | None:102    """Croisement CONSERVATEUR d'un resto avec les résultats Yelp :103    nom similaire ET même numéro civique ET ville compatible. Sans civique104    des deux côtés : nom normalisé IDENTIQUE + même ville. Ambigu -> None."""105    rname = norm_name(row["name"])106    rcity = _city_key(row["city"])107    rcivic_m = _CIVIC_RE.match(row["address"] or "")108    rcivic = rcivic_m.group(1) if rcivic_m else ""109    addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents(110        (row["address"] or "").lower())).split()) | set(rcity.split())111    hits = []112    for c in candidates:113        if c["closed"]:114            continue115        ccity = _city_key(c["city"])116        if rcity and ccity and rcity != ccity \117                and rcity not in ccity and ccity not in rcity:118            continue119        cname = norm_name(c["name"])120        ccivic_m = _CIVIC_RE.match(c["address"] or "")121        ccivic = ccivic_m.group(1) if ccivic_m else ""122        if rcivic and ccivic:123            if rcivic == ccivic and _name_similar(rname, cname, addr_tokens):124                hits.append(c)125        elif rname and rname == cname and rcity and ccity:126            hits.append(c)            # nom exact + ville, sans civique127    aliases = {h["alias"] for h in hits}128    if len(aliases) == 1:129        return hits[0]130    return None                       # rien ou ambigu : on ne fusionne pas131132133class YelpScrapeConnector(BaseConnector):134    source_id = "yelp-scrape"135    request_delay = 1.0136    timeout = 60137    use_detail_cache = False138    enrichment_only = True            # n'émet aucune fiche (ingest.run)139140    def _now(self) -> str:141        return datetime.datetime.now(datetime.timezone.utc) \142            .strftime("%Y-%m-%dT%H:%M:%SZ")143144    def _fresh(self, stamp: str, now: float, stale_s: float) -> bool:145        try:146            ts = datetime.datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ") \147                .replace(tzinfo=datetime.timezone.utc).timestamp()148            return ts > now - stale_s149        except (ValueError, TypeError):150            return False151152    def fetch(self) -> list[Restaurant]:153        if not os.environ.get("SCRAPFLY_KEY"):154            raise SkipSource("SCRAPFLY_KEY manquant (.env) — scraping Yelp "155                             "impossible sans contournement anti-bot")156        import time as _time157        from .. import db158        con = db.connect()159        now = _time.time()160        stale_s = REFRESH_DAYS * 86400.0161        budget = MAX_BUDGET162        enriched = misses = failures_row = 0163        rows = con.execute(164            "SELECT uid, name, address, city, details,"165            " EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"166            "   AS has_menu"167            " FROM restaurants WHERE active=1 AND dup_of IS NULL"168            " AND name<>'' AND city<>''"169            # indépendants d'abord : les succursales de chaînes n'ont presque170            # jamais d'avis Yelp (budget mieux investi ailleurs)171            " ORDER BY has_menu DESC, chain IS NULL DESC, phone<>'' DESC,"172            " updated_at DESC"173        ).fetchall()174        for row in rows:175            if budget <= 0:176                break177            if failures_row >= MAX_CONSECUTIVE_FAILURES:178                print("[resto-ka] yelp-scrape: Scrapfly bloqué "179                      f"{failures_row} fois de suite — arrêt du cycle",180                      file=sys.stderr)181                break182            try:183                details = json.loads(row["details"] or "{}")184            except ValueError:185                details = {}186            yelp = details.get("yelp") or {}187            if self._fresh(yelp.get("fetched_at", ""), now, stale_s):188                continue              # note déjà fraîche (<30 j)189            probe = details.get("yelp_scrape") or {}190            if self._fresh(probe.get("fetched_at", ""), now, stale_s):191                continue              # échec récent : re-visite dans 30 j192            url = SEARCH_URL.format(193                desc=urllib.parse.quote(row["name"][:64]),194                loc=urllib.parse.quote(f"{row['city']}, QC"))195            budget -= 1196            try:197                result = self.scrapfly(url, render_js=False)198            except Exception as exc:199                failures_row += 1200                print(f"[resto-ka] yelp-scrape: {row['uid']} erreur: {exc}",201                      file=sys.stderr)202                continue203            if (result.get("status_code") or 0) != 200:204                failures_row += 1205                continue206            failures_row = 0207            candidates = parse_apollo_businesses(result.get("content") or "")208            hit = match_business(row, candidates)209            if hit and hit.get("rating") is not None:210                db.merge_details(con, row["uid"], {"yelp": {211                    "url": f"https://www.yelp.ca/biz/"212                           f"{urllib.parse.quote(hit['alias'])}",213                    "name": hit["name"],214                    "rating": hit["rating"],215                    "review_count": hit["review_count"],216                    "price": hit.get("price"),217                    "categories": hit.get("categories") or [],218                    "matched_by": "nom+adresse",219                    "via": "scrape",220                    "fetched_at": self._now(),221                }})222                enriched += 1223            else:224                reason = ("fiche sans note" if hit else225                          "introuvable ou ambigu")226                db.merge_details(con, row["uid"],227                                 {"yelp_scrape": {"miss": reason,228                                                  "fetched_at": self._now()}})229                misses += 1230            con.commit()231        con.commit()232        con.close()233        self.enriched_count = enriched234        self.enrich_message = (f"{enriched} resto(s) notés (scraping), "235                               f"{misses} sans correspondance, "236                               f"budget restant {max(budget, 0)} page(s)")237        print(f"[resto-ka] yelp-scrape: {self.enrich_message}")238        return []239