Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
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