SPB Git

spb/resto-ka

Public

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

site-resto : extraction des photos des restos depuis leur site web

Le connecteur menus maison n emettait aucune image (231/231 fiches sans
photo). Nouveau _extract_images (page d accueil) : og:image/twitter:image
puis <img> (src/srcset/lazy-load, avif inclus), filtres logos/icones/
placeholders/miniatures (-LxH < 300 px, width < 200), dedup des variantes
de taille (WordPress -LxH, Webflow -p-NNN), max 6 photos.

- fiche site-resto emise avec photos + cache detail (reemission comprise)
- fiche OSM d origine enrichie si elle n a pas d images (COALESCE), y
  compris quand le crawl ne trouve ni menu ni contact
- scripts/backfill_images.py : rattrapage ponctuel des fiches deja captees
  (execute 2026-08-23 : 207/231 fiches enrichies, + jumelles OSM)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 23, 2026) parent 9e498cb Browse files

2 changed files +174 −8

modified restoka/connectors/siteresto.py +104 −8
@@ -153,6 +153,84 @@ class SiteRestoConnector(BaseConnector):
153 pages.append(full) 153 pages.append(full)
154 return pages[:2], pdfs[:2] 154 return pages[:2], pdfs[:2]
155 155
156+ # -- extraction des photos du resto (page d'accueil) ------------------------------
157+ @staticmethod
158+ def _extract_images(html: str, base_url: str) -> list[str]:
159+ """Jusqu'à 6 photos du resto extraites de sa page d'accueil.
160+
161+ og:image/twitter:image d'abord (l'image que le resto met lui-même de
162+ l'avant), puis les <img> de la page (src/srcset/lazy-load). Filtres :
163+ logos, icônes, pictos de paiement, placeholders et miniatures
164+ (suffixe WordPress -LxH < 300 px) sont écartés ; les variantes de
165+ taille d'une même image sont dédupliquées sur le nom de base.
166+ """
167+ from bs4 import BeautifulSoup
168+ if not html:
169+ return []
170+ _BAD_RE = re.compile(
171+ r"logo|icon|favicon|placeholder|sprite|badge|paiement|payment|"
172+ r"visa|mastercard|interac|avatar|emoji|pixel|spinner|loader|"
173+ r"footer|bandeau|widget|captcha", re.I)
174+ _EXT = r"(?:jpe?g|png|webp|avif)"
175+ _EXT_RE = re.compile(rf"\.{_EXT}(?:\?|$)", re.I)
176+ _THUMB_RE = re.compile(rf"-(\d{{2,4}})x(\d{{2,4}})\.{_EXT}", re.I)
177+
178+ def _ok(u: str) -> bool:
179+ if not u.startswith("http") or not _EXT_RE.search(u):
180+ return False
181+ if _BAD_RE.search(u):
182+ return False
183+ m = _THUMB_RE.search(u)
184+ if m and max(int(m.group(1)), int(m.group(2))) < 300:
185+ return False
186+ return True
187+
188+ def _base_key(u: str) -> str:
189+ # même photo en plusieurs tailles : clé = URL sans suffixe de
190+ # variante (-LxH WordPress, -p-500 Webflow) ni extension
191+ u = u.split("?")[0].lower()
192+ u = re.sub(rf"-\d{{2,4}}x\d{{2,4}}(?=\.{_EXT}$)", "", u)
193+ u = re.sub(rf"-p-\d{{2,4}}(?=\.{_EXT}$)", "", u)
194+ return re.sub(rf"\.{_EXT}$", "", u)
195+
196+ soup = BeautifulSoup(html, "html.parser")
197+ found: list[str] = []
198+ for name, attrs in (("meta", {"property": "og:image"}),
199+ ("meta", {"name": "twitter:image"})):
200+ for tag in soup.find_all(name, attrs=attrs):
201+ u = urllib.parse.urljoin(base_url, (tag.get("content") or "").strip())
202+ if _ok(u):
203+ found.append(u)
204+ for img in soup.find_all("img"):
205+ try: # pictos et flèches déclarés étroits dans le markup
206+ if img.get("width") and int(str(img["width"]).rstrip("px")) < 200:
207+ continue
208+ except (ValueError, TypeError):
209+ pass
210+ cands = [img.get("src") or "", img.get("data-src") or "",
211+ img.get("data-lazy-src") or ""]
212+ srcset = img.get("srcset") or img.get("data-srcset") or ""
213+ if srcset: # prendre la plus grande variante du srcset
214+ parts = [p.strip().split(" ")[0] for p in srcset.split(",")]
215+ if parts:
216+ cands.append(parts[-1])
217+ for c in cands:
218+ u = urllib.parse.urljoin(base_url, c.strip())
219+ if _ok(u):
220+ found.append(u)
221+ break
222+ out: list[str] = []
223+ seen: set[str] = set()
224+ for u in found:
225+ k = _base_key(u)
226+ if k in seen:
227+ continue
228+ seen.add(k)
229+ out.append(u)
230+ if len(out) >= 6:
231+ break
232+ return out
233+
156 # -- extraction contact/horaires/réservation (page d'accueil) --------------------- 234 # -- extraction contact/horaires/réservation (page d'accueil) ---------------------
157 @staticmethod 235 @staticmethod
158 def _extract_contact(html: str, base_url: str) -> dict: 236 def _extract_contact(html: str, base_url: str) -> dict:
@@ -256,9 +334,10 @@ class SiteRestoConnector(BaseConnector):
256 if not home: 334 if not home:
257 return None 335 return None
258 contact = self._extract_contact(home, website) 336 contact = self._extract_contact(home, website)
337+ images = self._extract_images(home, website)
259 guid = self._discover_ueat(home, website) 338 guid = self._discover_ueat(home, website)
260 if guid: 339 if guid:
261− return {"ueat": guid, "contact": contact} 340+ return {"ueat": guid, "contact": contact, "images": images}
262 pages, pdfs = self._menu_links(home, website) 341 pages, pdfs = self._menu_links(home, website)
263 # 1) pages HTML de menu 342 # 1) pages HTML de menu
264 for page_url in pages: 343 for page_url in pages:
@@ -276,7 +355,7 @@ class SiteRestoConnector(BaseConnector):
276 menu = menullm.menu_from_text(text) 355 menu = menullm.menu_from_text(text)
277 if menu: 356 if menu:
278 return {"sections": menu["sections"], "url": page_url, 357 return {"sections": menu["sections"], "url": page_url,
279− "contact": contact} 358+ "contact": contact, "images": images}
280 # 2) PDF de menu 359 # 2) PDF de menu
281 for pdf_url in pdfs: 360 for pdf_url in pdfs:
282 pdf = self._get_pdf(pdf_url) 361 pdf = self._get_pdf(pdf_url)
@@ -284,16 +363,17 @@ class SiteRestoConnector(BaseConnector):
284 menu = menullm.menu_from_pdf(pdf) 363 menu = menullm.menu_from_pdf(pdf)
285 if menu: 364 if menu:
286 return {"sections": menu["sections"], "url": pdf_url, 365 return {"sections": menu["sections"], "url": pdf_url,
287− "contact": contact} 366+ "contact": contact, "images": images}
288 # 3) la page d'accueil elle-même contient parfois le menu 367 # 3) la page d'accueil elle-même contient parfois le menu
289 text = self._html_text(home) 368 text = self._html_text(home)
290 if len(_PRICE_HINT_RE.findall(text)) >= 8: 369 if len(_PRICE_HINT_RE.findall(text)) >= 8:
291 menu = menullm.menu_from_text(text) 370 menu = menullm.menu_from_text(text)
292 if menu: 371 if menu:
293 return {"sections": menu["sections"], "url": website, 372 return {"sections": menu["sections"], "url": website,
294− "contact": contact} 373+ "contact": contact, "images": images}
295− if any(v for v in contact.values()): 374+ if any(v for v in contact.values()) or images:
296− return {"contact": contact} # pas de menu, mais du contact 375+ # pas de menu, mais du contact et/ou des photos captés
376+ return {"contact": contact, "images": images}
297 return None 377 return None
298 378
299 # -- reconstruction des fiches déjà captées ------------------------------------------ 379 # -- reconstruction des fiches déjà captées ------------------------------------------
@@ -347,7 +427,8 @@ class SiteRestoConnector(BaseConnector):
347 427
348 firecrawl_budget = [MAX_FIRECRAWL_PER_RUN 428 firecrawl_budget = [MAX_FIRECRAWL_PER_RUN
349 if os.environ.get("FIRECRAWL_API_KEY") else 0] 429 if os.environ.get("FIRECRAWL_API_KEY") else 0]
350− stats = {"crawled": 0, "menus": 0, "ueat": 0, "vides": 0, "contacts": 0} 430+ stats = {"crawled": 0, "menus": 0, "ueat": 0, "vides": 0,
431+ "contacts": 0, "photos": 0}
351 now = time.time() 432 now = time.time()
352 # enrichissements à appliquer APRÈS sync_source (la fiche site-resto 433 # enrichissements à appliquer APRÈS sync_source (la fiche site-resto
353 # n'existe en base qu'après l'upsert) — consommé par ingest.run 434 # n'existe en base qu'après l'upsert) — consommé par ingest.run
@@ -373,6 +454,8 @@ class SiteRestoConnector(BaseConnector):
373 resto = self._row_to_restaurant(row, {}) 454 resto = self._row_to_restaurant(row, {})
374 resto.source = self.source_id 455 resto.source = self.source_id
375 resto.url = status.get("url") or website 456 resto.url = status.get("url") or website
457+ if status.get("images") and not resto.images:
458+ resto.images = status["images"]
376 resto.menu = { 459 resto.menu = {
377 "currency": "CAD", "price_context": "dine-in", 460 "currency": "CAD", "price_context": "dine-in",
378 "price_source": self.source_id, 461 "price_source": self.source_id,
@@ -421,6 +504,16 @@ class SiteRestoConnector(BaseConnector):
421 if contact.get("reservation_url") else None) 504 if contact.get("reservation_url") else None)
422 con.commit() 505 con.commit()
423 stats["contacts"] += 1 506 stats["contacts"] += 1
507+ # photos captées sur le site du resto : posées sur la fiche
508+ # OSM d'origine si elle n'en a pas (COALESCE conservateur)
509+ images = (result or {}).get("images") or []
510+ if images:
511+ con.execute(
512+ "UPDATE restaurants SET images=? WHERE uid=? AND"
513+ " (images IS NULL OR images='' OR images='[]')",
514+ (json.dumps(images), row["uid"]))
515+ con.commit()
516+ stats["photos"] += 1
424 if result and result.get("ueat"): 517 if result and result.get("ueat"):
425 self._record_ueat(result["ueat"], row["name"], domain) 518 self._record_ueat(result["ueat"], row["name"], domain)
426 self._set_status(domain, {"status": "ueat", 519 self._set_status(domain, {"status": "ueat",
@@ -446,6 +539,8 @@ class SiteRestoConnector(BaseConnector):
446 resto.cuisines = [] # reclassées avec le texte du menu 539 resto.cuisines = [] # reclassées avec le texte du menu
447 resto.price_range = "" 540 resto.price_range = ""
448 resto.dietary_options = [] 541 resto.dietary_options = []
542+ if images and not resto.images:
543+ resto.images = images
449 # contact capté sur le site : complète la fiche émise (COALESCE) 544 # contact capté sur le site : complète la fiche émise (COALESCE)
450 if contact.get("phone") and not (resto.phone or "").strip(): 545 if contact.get("phone") and not (resto.phone or "").strip():
451 resto.phone = contact["phone"] 546 resto.phone = contact["phone"]
@@ -458,7 +553,8 @@ class SiteRestoConnector(BaseConnector):
458 out[resto.uid] = resto 553 out[resto.uid] = resto
459 self._set_status(domain, {"status": "menu", "url": result["url"], 554 self._set_status(domain, {"status": "menu", "url": result["url"],
460 "captured_at": captured_at, 555 "captured_at": captured_at,
461− "sections": result["sections"]}) 556+ "sections": result["sections"],
557+ "images": images})
462 stats["menus"] += 1 558 stats["menus"] += 1
463 559
464 print(f"[resto-ka] site-resto: {stats} (workers={WORKERS})") 560 print(f"[resto-ka] site-resto: {stats} (workers={WORKERS})")
added scripts/backfill_images.py +70 −0
@@ -0,0 +1,70 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: scripts/backfill_images.py
4+# Desc: Rattrapage ponctuel — photos des restos site-resto captés AVANT
5+# l'extraction d'images du connecteur (2026-08-23) : re-visite la page
6+# d'accueil du site de chaque fiche site-resto sans images (et de sa
7+# fiche OSM jumelle) et pose jusqu'à 6 photos via
8+# SiteRestoConnector._extract_images. Idempotent : ne touche que les
9+# fiches où images est vide.
10+# Usage : .venv/bin/python scripts/backfill_images.py [max_sites]
11+# ==============================================================================
12+from __future__ import annotations
13+
14+import json
15+import sys
16+from concurrent.futures import ThreadPoolExecutor
17+
18+sys.path.insert(0, ".")
19+from restoka import db # noqa: E402
20+from restoka.connectors.siteresto import SiteRestoConnector, _domain # noqa: E402
21+
22+MAX = int(sys.argv[1]) if len(sys.argv) > 1 else 400
23+WORKERS = 6
24+
25+con = db.connect()
26+rows = con.execute(
27+ """SELECT uid, external_id, name, website, url FROM restaurants
28+ WHERE source='site-resto' AND active=1
29+ AND (images IS NULL OR images='' OR images='[]')
30+ LIMIT ?""", (MAX,)).fetchall()
31+print(f"{len(rows)} fiches site-resto sans images à rattraper")
32+
33+
34+def _one(row):
35+ worker = SiteRestoConnector()
36+ site = (row["website"] or "").strip()
37+ if not site:
38+ # la fiche site-resto garde le site dans la fiche OSM jumelle ; url =
39+ # page du menu, son domaine suffit pour retrouver l'accueil
40+ site = "https://" + _domain(row["url"] or "")
41+ if not site.startswith("http"):
42+ site = "https://" + site
43+ try:
44+ html = worker._get_html(site)
45+ imgs = worker._extract_images(html, site)
46+ return row["uid"], row["external_id"], imgs
47+ except Exception as exc:
48+ print(f" {row['name']}: erreur {exc}", file=sys.stderr)
49+ return row["uid"], row["external_id"], []
50+
51+
52+done = with_img = 0
53+with ThreadPoolExecutor(max_workers=WORKERS) as pool:
54+ for uid, ext_id, imgs in pool.map(_one, rows):
55+ done += 1
56+ if not imgs:
57+ continue
58+ blob = json.dumps(imgs)
59+ # fiche site-resto + fiche OSM jumelle (même external_id), si vides
60+ for target in (uid, f"osm:{ext_id.split(':', 1)[-1]}", f"osm:{ext_id}"):
61+ con.execute(
62+ "UPDATE restaurants SET images=? WHERE uid=? AND"
63+ " (images IS NULL OR images='' OR images='[]')",
64+ (blob, target))
65+ con.commit()
66+ with_img += 1
67+ if with_img % 25 == 0:
68+ print(f" {with_img}/{done} fiches avec photos…")
69+
70+print(f"terminé : {with_img}/{done} fiches enrichies de photos")
71
Menu

Theme