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%
7.2 KB · 174 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/connectors/tastet.py4# Desc:   Connecteur de DÉCOUVERTE Tastet (Palier 4, média food québécois) —5#         les critiques de tastet.ca couvrent des milliers de restos (surtout6#         Montréal/Québec) avec nom, adresse complète et téléphone. Chaque7#         page critique embarque un lien Google Maps8#         (…/maps/search/?api=1&query=Nom+Adresse) et un lien tel: — pas de9#         menu structuré (média éditorial). Émet des fiches de découverte10#         SANS menu ; la déduplication les fusionne avec OSM/UEAT et la fiche11#         canonique gagne le lien éditorial.12#13#         Mode d'extraction : requêtes directes polies (pas d'anti-bot),14#         sitemaps Yoast reviews-sitemap*.xml -> pages critiques, cache BD15#         par page (re-crawl seulement si <lastmod> change), budget16#         TASTET_BUDGET (défaut 80) nouvelles pages/cycle — le corpus se17#         construit incrémentalement de cycle en cycle.18#         Base légale §15 : découverte minimale (nom, adresse, téléphone,19#         lien vers la critique), attribution par lien sortant vers Tastet.20#21#         Contexte de prix produit : AUCUN (pas de menu — fiche de découverte).22# ==============================================================================23from __future__ import annotations2425import html as html_mod26import re27import os28import sys29import urllib.parse3031from ..normalize import normalize_phone32from ..regions import VILLE_REGION, _key33from ..schema import Restaurant34from .base import BaseConnector3536SITEMAP_INDEX = "https://tastet.ca/sitemap_index.xml"37_REVIEWS_SM_RE = re.compile(r"(https://tastet\.ca/reviews-sitemap\d*\.xml)")38MAX_BUDGET = int(os.environ.get("TASTET_BUDGET", "80"))    # nouvelles pages/cycle3940_URL_ENTRY_RE = re.compile(41    r"<url>\s*<loc>([^<]+)</loc>(?:.*?<lastmod>([^<]+)</lastmod>)?.*?</url>",42    re.S)43_MAPS_RE = re.compile(44    r'href="https://www\.google\.com/maps/search/\?api=1&(?:amp;)?query=([^"]+)"')45_TEL_RE = re.compile(r'href="tel:([^"]+)"')46_OGIMG_RE = re.compile(47    r'<meta property="og:image" content="([^"]+)"')48_POSTAL_RE = re.compile(r"([A-Za-z]\d[A-Za-z])\s?(\d[A-Za-z]\d)\s*$")495051def parse_maps_query(query: str) -> tuple[str, str, str, str]:52    """« Nom+1556 Avenue Laurier Est Montréal H2J 1H9 » ->53    (nom, adresse-rue, ville, code postal). Le nom est séparé de l'adresse54    par le premier « + » ; la ville est le plus long suffixe de tokens connu55    de la table VILLE_REGION ; le code postal termine la chaîne."""56    # « + » est le SÉPARATEUR nom/adresse (les espaces sont littéraux ou %20)57    q = html_mod.unescape(urllib.parse.unquote(query)).strip()58    name, _, rest = q.partition("+")59    name, rest = name.strip(), rest.strip()60    postal = ""61    m = _POSTAL_RE.search(rest)62    if m:63        postal = (m.group(1) + m.group(2)).upper()64        rest = rest[:m.start()].strip().rstrip(",")65    city = ""66    tokens = rest.split()67    for n in range(min(4, len(tokens) - 1), 0, -1):    # garder >=1 token d'adresse68        cand = " ".join(tokens[-n:])69        if _key(cand) in VILLE_REGION:70            city = cand71            rest = " ".join(tokens[:-n]).rstrip(",").strip()72            break73    return name, rest, city, postal747576def parse_review(page_html: str) -> dict | None:77    """Extrait {name, address, city, postal_code, phone, image} d'une page78    critique Tastet. None si la page n'a pas de fiche resto (pas de lien79    Google Maps — liste, éditorial générique…)."""80    m = _MAPS_RE.search(page_html)81    if not m:82        return None83    name, address, city, postal = parse_maps_query(m.group(1))84    if not name:85        return None86    tel = _TEL_RE.search(page_html)87    img = _OGIMG_RE.search(page_html)88    return {89        "name": name,90        "address": address,91        "city": city,92        "postal_code": postal,93        "phone": normalize_phone(tel.group(1)) if tel else "",94        "image": html_mod.unescape(img.group(1)) if img else "",95    }969798class TastetConnector(BaseConnector):99    source_id = "tastet"100    request_delay = 1.2               # politesse : média indépendant101    timeout = 30102    use_detail_cache = False          # cache géré à la main (clé lastmod)103104    def _review_urls(self) -> list[tuple[str, str]]:105        """[(url, lastmod)] des pages critiques, via l'index Yoast."""106        index = self.get(SITEMAP_INDEX).text107        sitemaps = _REVIEWS_SM_RE.findall(index)108        if not sitemaps:109            raise RuntimeError("index sitemap Tastet sans reviews-sitemap")110        out: list[tuple[str, str]] = []111        seen: set[str] = set()112        for sm in sitemaps:113            xml = self.get(sm).text114            for loc, lastmod in _URL_ENTRY_RE.findall(xml):115                loc = loc.strip()116                if "/en/" in loc or loc in seen:      # doublons anglophones117                    continue118                seen.add(loc)119                out.append((loc, (lastmod or "").strip()))120        return out121122    @staticmethod123    def _ext_id(url: str) -> str:124        return url.rstrip("/").rsplit("/", 1)[-1]125126    def fetch(self) -> list[Restaurant]:127        from .. import db128        con = db.connect()129        urls = self._review_urls()130        budget = MAX_BUDGET131        fetched = 0132        out: list[Restaurant] = []133        for url, lastmod in urls:134            ext_id = self._ext_id(url)135            key = f"page-v1:{lastmod or 'v1'}"136            payload = db.get_cached_detail(con, self.source_id, ext_id, key)137            if payload is None:138                if budget <= 0:139                    continue          # au prochain cycle (corpus incrémental)140                budget -= 1141                fetched += 1142                try:143                    page = self.get(url).text144                except Exception as exc:145                    print(f"[resto-ka] tastet: {url} erreur: {exc}",146                          file=sys.stderr)147                    continue148                payload = parse_review(page) or {"no_restaurant": True}149                db.put_cached_detail(con, self.source_id, ext_id, key, payload)150            if payload.get("no_restaurant") or not payload.get("name"):151                continue152            out.append(Restaurant(153                source=self.source_id,154                external_id=ext_id,155                name=payload["name"],156                url=url,157                address=payload.get("address") or "",158                city=payload.get("city") or "",159                postal_code=payload.get("postal_code") or "",160                phone=payload.get("phone") or "",161                images=[payload["image"]] if payload.get("image") else [],162                languages=["fr"],163                menu=None,            # média éditorial : pas de menu structuré164            ))165        con.close()166        # le corpus se construit page par page : tant que des critiques167        # restent à crawler, ne jamais retirer les fiches pas encore émises168        if budget <= 0:169            self.partial_run = True170        print(f"[resto-ka] tastet: {len(urls)} critiques au sitemap, "171              f"{fetched} page(s) crawlée(s) ce cycle, "172              f"{len(out)} fiches resto émises")173        return out174