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