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%

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 1 mo ago (Aug 23, 2026) parent 9e498cb

2 changed files +174 −8

modified restoka/connectors/siteresto.py +104 −8
@@ -153,6 +153,84 @@ class SiteRestoConnector(BaseConnector):
153 153 pages.append(full)
154 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 234 # -- extraction contact/horaires/réservation (page d'accueil) ---------------------
157 235 @staticmethod
158 236 def _extract_contact(html: str, base_url: str) -> dict:
@@ -256,9 +334,10 @@ class SiteRestoConnector(BaseConnector):
256 334 if not home:
257 335 return None
258 336 contact = self._extract_contact(home, website)
337 + images = self._extract_images(home, website)
259 338 guid = self._discover_ueat(home, website)
260 339 if guid:
261 − return {"ueat": guid, "contact": contact}
340 + return {"ueat": guid, "contact": contact, "images": images}
262 341 pages, pdfs = self._menu_links(home, website)
263 342 # 1) pages HTML de menu
264 343 for page_url in pages:
@@ -276,7 +355,7 @@ class SiteRestoConnector(BaseConnector):
276 355 menu = menullm.menu_from_text(text)
277 356 if menu:
278 357 return {"sections": menu["sections"], "url": page_url,
279 − "contact": contact}
358 + "contact": contact, "images": images}
280 359 # 2) PDF de menu
281 360 for pdf_url in pdfs:
282 361 pdf = self._get_pdf(pdf_url)
@@ -284,16 +363,17 @@ class SiteRestoConnector(BaseConnector):
284 363 menu = menullm.menu_from_pdf(pdf)
285 364 if menu:
286 365 return {"sections": menu["sections"], "url": pdf_url,
287 − "contact": contact}
366 + "contact": contact, "images": images}
288 367 # 3) la page d'accueil elle-même contient parfois le menu
289 368 text = self._html_text(home)
290 369 if len(_PRICE_HINT_RE.findall(text)) >= 8:
291 370 menu = menullm.menu_from_text(text)
292 371 if menu:
293 372 return {"sections": menu["sections"], "url": website,
294 − "contact": contact}
295 − if any(v for v in contact.values()):
296 − return {"contact": contact} # pas de menu, mais du contact
373 + "contact": contact, "images": images}
374 + if any(v for v in contact.values()) or images:
375 + # pas de menu, mais du contact et/ou des photos captés
376 + return {"contact": contact, "images": images}
297 377 return None
298 378
299 379 # -- reconstruction des fiches déjà captées ------------------------------------------
@@ -347,7 +427,8 @@ class SiteRestoConnector(BaseConnector):
347 427
348 428 firecrawl_budget = [MAX_FIRECRAWL_PER_RUN
349 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 432 now = time.time()
352 433 # enrichissements à appliquer APRÈS sync_source (la fiche site-resto
353 434 # n'existe en base qu'après l'upsert) — consommé par ingest.run
@@ -373,6 +454,8 @@ class SiteRestoConnector(BaseConnector):
373 454 resto = self._row_to_restaurant(row, {})
374 455 resto.source = self.source_id
375 456 resto.url = status.get("url") or website
457 + if status.get("images") and not resto.images:
458 + resto.images = status["images"]
376 459 resto.menu = {
377 460 "currency": "CAD", "price_context": "dine-in",
378 461 "price_source": self.source_id,
@@ -421,6 +504,16 @@ class SiteRestoConnector(BaseConnector):
421 504 if contact.get("reservation_url") else None)
422 505 con.commit()
423 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 517 if result and result.get("ueat"):
425 518 self._record_ueat(result["ueat"], row["name"], domain)
426 519 self._set_status(domain, {"status": "ueat",
@@ -446,6 +539,8 @@ class SiteRestoConnector(BaseConnector):
446 539 resto.cuisines = [] # reclassées avec le texte du menu
447 540 resto.price_range = ""
448 541 resto.dietary_options = []
542 + if images and not resto.images:
543 + resto.images = images
449 544 # contact capté sur le site : complète la fiche émise (COALESCE)
450 545 if contact.get("phone") and not (resto.phone or "").strip():
451 546 resto.phone = contact["phone"]
@@ -458,7 +553,8 @@ class SiteRestoConnector(BaseConnector):
458 553 out[resto.uid] = resto
459 554 self._set_status(domain, {"status": "menu", "url": result["url"],
460 555 "captured_at": captured_at,
461 − "sections": result["sections"]})
556 + "sections": result["sections"],
557 + "images": images})
462 558 stats["menus"] += 1
463 559
464 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