# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/tastet.py # Desc: Connecteur de DÉCOUVERTE Tastet (Palier 4, média food québécois) — # les critiques de tastet.ca couvrent des milliers de restos (surtout # Montréal/Québec) avec nom, adresse complète et téléphone. Chaque # page critique embarque un lien Google Maps # (…/maps/search/?api=1&query=Nom+Adresse) et un lien tel: — pas de # menu structuré (média éditorial). Émet des fiches de découverte # SANS menu ; la déduplication les fusionne avec OSM/UEAT et la fiche # canonique gagne le lien éditorial. # # Mode d'extraction : requêtes directes polies (pas d'anti-bot), # sitemaps Yoast reviews-sitemap*.xml -> pages critiques, cache BD # par page (re-crawl seulement si change), budget # TASTET_BUDGET (défaut 80) nouvelles pages/cycle — le corpus se # construit incrémentalement de cycle en cycle. # Base légale §15 : découverte minimale (nom, adresse, téléphone, # lien vers la critique), attribution par lien sortant vers Tastet. # # Contexte de prix produit : AUCUN (pas de menu — fiche de découverte). # ============================================================================== from __future__ import annotations import html as html_mod import re import os import sys import urllib.parse from ..normalize import normalize_phone from ..regions import VILLE_REGION, _key from ..schema import Restaurant from .base import BaseConnector SITEMAP_INDEX = "https://tastet.ca/sitemap_index.xml" _REVIEWS_SM_RE = re.compile(r"(https://tastet\.ca/reviews-sitemap\d*\.xml)") MAX_BUDGET = int(os.environ.get("TASTET_BUDGET", "80")) # nouvelles pages/cycle _URL_ENTRY_RE = re.compile( r"\s*([^<]+)(?:.*?([^<]+))?.*?", re.S) _MAPS_RE = re.compile( r'href="https://www\.google\.com/maps/search/\?api=1&(?:amp;)?query=([^"]+)"') _TEL_RE = re.compile(r'href="tel:([^"]+)"') _OGIMG_RE = re.compile( r' tuple[str, str, str, str]: """« Nom+1556 Avenue Laurier Est Montréal H2J 1H9 » -> (nom, adresse-rue, ville, code postal). Le nom est séparé de l'adresse par le premier « + » ; la ville est le plus long suffixe de tokens connu de la table VILLE_REGION ; le code postal termine la chaîne.""" # « + » est le SÉPARATEUR nom/adresse (les espaces sont littéraux ou %20) q = html_mod.unescape(urllib.parse.unquote(query)).strip() name, _, rest = q.partition("+") name, rest = name.strip(), rest.strip() postal = "" m = _POSTAL_RE.search(rest) if m: postal = (m.group(1) + m.group(2)).upper() rest = rest[:m.start()].strip().rstrip(",") city = "" tokens = rest.split() for n in range(min(4, len(tokens) - 1), 0, -1): # garder >=1 token d'adresse cand = " ".join(tokens[-n:]) if _key(cand) in VILLE_REGION: city = cand rest = " ".join(tokens[:-n]).rstrip(",").strip() break return name, rest, city, postal def parse_review(page_html: str) -> dict | None: """Extrait {name, address, city, postal_code, phone, image} d'une page critique Tastet. None si la page n'a pas de fiche resto (pas de lien Google Maps — liste, éditorial générique…).""" m = _MAPS_RE.search(page_html) if not m: return None name, address, city, postal = parse_maps_query(m.group(1)) if not name: return None tel = _TEL_RE.search(page_html) img = _OGIMG_RE.search(page_html) return { "name": name, "address": address, "city": city, "postal_code": postal, "phone": normalize_phone(tel.group(1)) if tel else "", "image": html_mod.unescape(img.group(1)) if img else "", } class TastetConnector(BaseConnector): source_id = "tastet" request_delay = 1.2 # politesse : média indépendant timeout = 30 use_detail_cache = False # cache géré à la main (clé lastmod) def _review_urls(self) -> list[tuple[str, str]]: """[(url, lastmod)] des pages critiques, via l'index Yoast.""" index = self.get(SITEMAP_INDEX).text sitemaps = _REVIEWS_SM_RE.findall(index) if not sitemaps: raise RuntimeError("index sitemap Tastet sans reviews-sitemap") out: list[tuple[str, str]] = [] seen: set[str] = set() for sm in sitemaps: xml = self.get(sm).text for loc, lastmod in _URL_ENTRY_RE.findall(xml): loc = loc.strip() if "/en/" in loc or loc in seen: # doublons anglophones continue seen.add(loc) out.append((loc, (lastmod or "").strip())) return out @staticmethod def _ext_id(url: str) -> str: return url.rstrip("/").rsplit("/", 1)[-1] def fetch(self) -> list[Restaurant]: from .. import db con = db.connect() urls = self._review_urls() budget = MAX_BUDGET fetched = 0 out: list[Restaurant] = [] for url, lastmod in urls: ext_id = self._ext_id(url) key = f"page-v1:{lastmod or 'v1'}" payload = db.get_cached_detail(con, self.source_id, ext_id, key) if payload is None: if budget <= 0: continue # au prochain cycle (corpus incrémental) budget -= 1 fetched += 1 try: page = self.get(url).text except Exception as exc: print(f"[resto-ka] tastet: {url} erreur: {exc}", file=sys.stderr) continue payload = parse_review(page) or {"no_restaurant": True} db.put_cached_detail(con, self.source_id, ext_id, key, payload) if payload.get("no_restaurant") or not payload.get("name"): continue out.append(Restaurant( source=self.source_id, external_id=ext_id, name=payload["name"], url=url, address=payload.get("address") or "", city=payload.get("city") or "", postal_code=payload.get("postal_code") or "", phone=payload.get("phone") or "", images=[payload["image"]] if payload.get("image") else [], languages=["fr"], menu=None, # média éditorial : pas de menu structuré )) con.close() # le corpus se construit page par page : tant que des critiques # restent à crawler, ne jamais retirer les fiches pas encore émises if budget <= 0: self.partial_run = True print(f"[resto-ka] tastet: {len(urls)} critiques au sitemap, " f"{fetched} page(s) crawlée(s) ce cycle, " f"{len(out)} fiches resto émises") return out