| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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})") |