| 15 | import json |
15 | import json |
| 16 | import math |
16 | import math |
| 17 | import re |
17 | import re |
| |
18 | +import time |
| |
19 | +import unicodedata |
| 18 | from datetime import date, datetime, timezone |
20 | from datetime import date, datetime, timezone |
| 19 | from pathlib import Path |
21 | from pathlib import Path |
| 20 | from xml.sax.saxutils import escape as xml_escape |
22 | from xml.sax.saxutils import escape as xml_escape |
| 38 | # restos « canoniques » : actifs et non-doublons inter-sources |
40 | # restos « canoniques » : actifs et non-doublons inter-sources |
| 39 | ACTIVE = "active=1 AND dup_of IS NULL" |
41 | ACTIVE = "active=1 AND dup_of IS NULL" |
| 40 | |
42 | |
| |
43 | +# seuil d'inclusion d'une page ville dans le sitemap (pages trop maigres exclues) |
| |
44 | +MIN_RESTOS_PAGE = 3 |
| |
45 | + |
| 41 | _CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)} |
46 | _CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)} |
| 42 | |
47 | |
| 43 | _CTX_LABEL = {"dine-in": "prix en salle", "takeout": "prix pour emporter", |
48 | _CTX_LABEL = {"dine-in": "prix en salle", "takeout": "prix pour emporter", |
| 146 | f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>') |
151 | f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>') |
| 147 | |
152 | |
| 148 | |
153 | |
| |
154 | +def slugify(text: str) -> str: |
| |
155 | + """« Trois-Rivières » → trois-rivieres, « L'Île-Perrot » → l-ile-perrot.""" |
| |
156 | + t = text.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae") |
| |
157 | + t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode() |
| |
158 | + t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") |
| |
159 | + return t |
| |
160 | + |
| |
161 | + |
| |
162 | +_villes_cache: dict = {"ts": 0.0, "by_slug": {}} |
| |
163 | + |
| |
164 | + |
| |
165 | +def _villes_index() -> dict[str, dict]: |
| |
166 | + """slug → {city (libellé majoritaire), cities (variantes), n, with_menu} — |
| |
167 | + les variantes de casse/accents d'une même ville sont regroupées par slug. |
| |
168 | + Reconstruit au plus toutes les 10 minutes.""" |
| |
169 | + if time.time() - _villes_cache["ts"] > 600: |
| |
170 | + con = db.connect() |
| |
171 | + rows = con.execute( |
| |
172 | + f"""SELECT city, COUNT(*) n, |
| |
173 | + SUM(CASE WHEN uid IN (SELECT uid FROM menus) |
| |
174 | + THEN 1 ELSE 0 END) with_menu |
| |
175 | + FROM restaurants WHERE {ACTIVE} AND city<>'' |
| |
176 | + GROUP BY city""").fetchall() |
| |
177 | + con.close() |
| |
178 | + by_slug: dict[str, dict] = {} |
| |
179 | + for r in rows: |
| |
180 | + s = slugify(r["city"]) |
| |
181 | + if not s: |
| |
182 | + continue |
| |
183 | + e = by_slug.setdefault(s, {"slug": s, "city": r["city"], |
| |
184 | + "cities": [], "n": 0, "with_menu": 0, |
| |
185 | + "_best": -1}) |
| |
186 | + e["cities"].append(r["city"]) |
| |
187 | + e["n"] += r["n"] |
| |
188 | + e["with_menu"] += r["with_menu"] or 0 |
| |
189 | + if r["n"] > e["_best"]: |
| |
190 | + e["_best"] = r["n"] |
| |
191 | + e["city"] = r["city"] |
| |
192 | + _villes_cache["by_slug"] = by_slug |
| |
193 | + _villes_cache["ts"] = time.time() |
| |
194 | + return _villes_cache["by_slug"] |
| |
195 | + |
| |
196 | + |
| |
197 | +def _villes_rows(_con=None, minimum: int = 1) -> list[dict]: |
| |
198 | + rows = [{"city": e["city"], "slug": e["slug"], "n": e["n"], |
| |
199 | + "with_menu": e["with_menu"]} |
| |
200 | + for e in _villes_index().values() if e["n"] >= minimum] |
| |
201 | + return sorted(rows, key=lambda r: -r["n"]) |
| |
202 | + |
| |
203 | + |
| 149 | def _breadcrumb(items: list[tuple[str, str]]) -> dict: |
204 | def _breadcrumb(items: list[tuple[str, str]]) -> dict: |
| 150 | return {"@context": "https://schema.org", "@type": "BreadcrumbList", |
205 | return {"@context": "https://schema.org", "@type": "BreadcrumbList", |
| 151 | "itemListElement": [ |
206 | "itemListElement": [ |
| 165 | status=404) |
220 | status=404) |
| 166 | |
221 | |
| 167 | |
222 | |
| |
223 | +# --- API JSON pour les pages villes du frontend -------------------------------- |
| |
224 | + |
| |
225 | +@router.get("/api/seo/villes") |
| |
226 | +def api_villes(): |
| |
227 | + con = db.connect() |
| |
228 | + rows = _villes_rows(con) |
| |
229 | + con.close() |
| |
230 | + return {"villes": rows} |
| |
231 | + |
| |
232 | + |
| |
233 | +@router.get("/api/seo/ville/{slug}") |
| |
234 | +def api_ville(slug: str): |
| |
235 | + e = _villes_index().get(slug) |
| |
236 | + if not e: |
| |
237 | + raise HTTPException(404, "Ville inconnue") |
| |
238 | + neighbors = [v for v in _villes_rows(minimum=MIN_RESTOS_PAGE) |
| |
239 | + if v["slug"] != slug][:12] |
| |
240 | + return {"city": e["city"], "slug": slug, "n": e["n"], |
| |
241 | + "with_menu": e["with_menu"], "neighbors": neighbors} |
| |
242 | + |
| |
243 | + |
| 168 | # --- robots.txt & sitemaps ---------------------------------------------------- |
244 | # --- robots.txt & sitemaps ---------------------------------------------------- |
| 169 | |
245 | |
| 170 | @router.get("/robots.txt", include_in_schema=False) |
246 | @router.get("/robots.txt", include_in_schema=False) |
| 200 | total = con.execute(f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"] |
276 | total = con.execute(f"SELECT COUNT(*) c FROM restaurants WHERE {ACTIVE}").fetchone()["c"] |
| 201 | con.close() |
277 | con.close() |
| 202 | chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) |
278 | chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) |
| 203 | − names = ["sitemap-pages.xml"] + [ |
279 | + names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [ |
| 204 | f"sitemap-restos-{i}.xml" for i in range(1, chunks + 1)] |
280 | f"sitemap-restos-{i}.xml" for i in range(1, chunks + 1)] |
| 205 | today = date.today().isoformat() |
281 | today = date.today().isoformat() |
| 206 | rows = "\n".join( |
282 | rows = "\n".join( |
| 213 | @router.get("/sitemap-pages.xml", include_in_schema=False) |
289 | @router.get("/sitemap-pages.xml", include_in_schema=False) |
| 214 | def sitemap_pages(): |
290 | def sitemap_pages(): |
| 215 | urls: list[tuple[str, str | None]] = [ |
291 | urls: list[tuple[str, str | None]] = [ |
| 216 | − (f"{BASE_URL}/", None), (f"{BASE_URL}/stats", None), |
292 | + (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None), |
| |
293 | + (f"{BASE_URL}/stats", None), |
| 217 | (f"{BASE_URL}/sources", None), (f"{BASE_URL}/contact", None)] |
294 | (f"{BASE_URL}/sources", None), (f"{BASE_URL}/contact", None)] |
| 218 | return _urlset(urls) |
295 | return _urlset(urls) |
| 219 | |
296 | |
| 220 | |
297 | |
| |
298 | +@router.get("/sitemap-villes.xml", include_in_schema=False) |
| |
299 | +def sitemap_villes(): |
| |
300 | + con = db.connect() |
| |
301 | + villes = _villes_rows(con, MIN_RESTOS_PAGE) |
| |
302 | + con.close() |
| |
303 | + today = date.today().isoformat() |
| |
304 | + return _urlset([(f"{BASE_URL}/ville/{v['slug']}", today) for v in villes]) |
| |
305 | + |
| |
306 | + |
| 221 | @router.get("/sitemap-restos-{num}.xml", include_in_schema=False) |
307 | @router.get("/sitemap-restos-{num}.xml", include_in_schema=False) |
| 222 | def sitemap_restos(num: int): |
308 | def sitemap_restos(num: int): |
| 223 | if num < 1: |
309 | if num < 1: |
| 253 | FROM restaurants r JOIN menus m ON m.uid=r.uid |
339 | FROM restaurants r JOIN menus m ON m.uid=r.uid |
| 254 | WHERE r.{ACTIVE} |
340 | WHERE r.{ACTIVE} |
| 255 | GROUP BY r.uid ORDER BY r.updated_at DESC LIMIT 24""").fetchall() |
341 | GROUP BY r.uid ORDER BY r.updated_at DESC LIMIT 24""").fetchall() |
| |
342 | + villes = _villes_rows(con, MIN_RESTOS_PAGE) |
| 256 | con.close() |
343 | con.close() |
| 257 | |
344 | |
| 258 | title = (f"Resto-Ka — {_fmt_n(total)} restaurants du Québec : " |
345 | title = (f"Resto-Ka — {_fmt_n(total)} restaurants du Québec : " |
| 271 | + "<h2>Restaurants par région</h2><ul>" |
358 | + "<h2>Restaurants par région</h2><ul>" |
| 272 | + "".join(f"<li>{_e(r['region'])} — {_fmt_n(r['n'])} restaurants</li>" |
359 | + "".join(f"<li>{_e(r['region'])} — {_fmt_n(r['n'])} restaurants</li>" |
| 273 | for r in regions) |
360 | for r in regions) |
| 274 | − + "</ul><h2>Menus récemment mis à jour</h2><ul>" |
361 | + + "</ul><h2>Restaurants par ville</h2><ul>" |
| |
362 | + + "".join(f'<li><a href="/ville/{v["slug"]}">Restaurants à ' |
| |
363 | + f'{_e(v["city"])}</a> — {_fmt_n(v["n"])} établissements</li>' |
| |
364 | + for v in villes[:30]) |
| |
365 | + + f'</ul><p><a href="/villes">Toutes les villes ({len(villes)})</a></p>' |
| |
366 | + + "<h2>Menus récemment mis à jour</h2><ul>" |
| 275 | + "".join(_resto_li(r) for r in recents) |
367 | + "".join(_resto_li(r) for r in recents) |
| 276 | + "</ul>" |
368 | + "</ul>" |
| 277 | + '<p><a href="/stats">Statistiques</a> · <a href="/sources">Sources des ' |
369 | + '<p><a href="/stats">Statistiques</a> · <a href="/sources">Sources des ' |
| 360 | if d["website"]: |
452 | if d["website"]: |
| 361 | body.append(f'<p><a href="{_e(d["website"])}" rel="nofollow">' |
453 | body.append(f'<p><a href="{_e(d["website"])}" rel="nofollow">' |
| 362 | "Site web du restaurant</a></p>") |
454 | "Site web du restaurant</a></p>") |
| |
455 | + if d["city"]: |
| |
456 | + body.append(f'<p><a href="/ville/{slugify(d["city"])}">' |
| |
457 | + f"Autres restaurants à {_e(d['city'])}</a></p>") |
| 363 | body.append('<p><a href="/">Tous les restaurants du Québec</a></p>') |
458 | body.append('<p><a href="/">Tous les restaurants du Québec</a></p>') |
| 364 | |
459 | |
| 365 | resto_ld: dict = { |
460 | resto_ld: dict = { |
| 394 | og_image=d["images"][0] if d["images"] else None) |
489 | og_image=d["images"][0] if d["images"] else None) |
| 395 | |
490 | |
| 396 | |
491 | |
| |
492 | +# --- Pages programmatiques villes : /villes + /ville/{slug} ------------------- |
| |
493 | + |
| |
494 | +@router.get("/villes", include_in_schema=False) |
| |
495 | +def villes_ssr(): |
| |
496 | + con = db.connect() |
| |
497 | + villes = _villes_rows(con) |
| |
498 | + con.close() |
| |
499 | + total = sum(v["n"] for v in villes) |
| |
500 | + title = f"Restaurants par ville au Québec ({len(villes)} villes) | Resto-Ka" |
| |
501 | + description = ( |
| |
502 | + f"Toutes les villes du Québec où Resto-Ka recense des restaurants : " |
| |
503 | + f"{_fmt_n(total)} établissements dans {len(villes)} villes, avec menus " |
| |
504 | + "complets et prix réels quand ils sont publiés.") |
| |
505 | + body = (f"<h1>Restaurants par ville — {len(villes)} villes au Québec</h1><ul>" |
| |
506 | + + "".join( |
| |
507 | + f'<li><a href="/ville/{v["slug"]}">Restaurants à {_e(v["city"])}</a>' |
| |
508 | + f' — {_fmt_n(v["n"])} établissements' |
| |
509 | + + (f", {_fmt_n(v['with_menu'])} menus" if v["with_menu"] else "") |
| |
510 | + + "</li>" for v in villes) |
| |
511 | + + "</ul>") |
| |
512 | + jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]), |
| |
513 | + {"@context": "https://schema.org", "@type": "ItemList", |
| |
514 | + "name": "Restaurants par ville au Québec", |
| |
515 | + "numberOfItems": len(villes), |
| |
516 | + "itemListElement": [ |
| |
517 | + {"@type": "ListItem", "position": i + 1, |
| |
518 | + "name": f"Restaurants à {v['city']}", |
| |
519 | + "url": f"{BASE_URL}/ville/{v['slug']}"} |
| |
520 | + for i, v in enumerate(villes[:100])]}] |
| |
521 | + return _render(title=title, description=description, path="/villes", |
| |
522 | + jsonld=jsonld, body=body) |
| |
523 | + |
| |
524 | + |
| |
525 | +@router.get("/ville/{slug}", include_in_schema=False) |
| |
526 | +def ville_ssr(slug: str): |
| |
527 | + path = f"/ville/{slug}" |
| |
528 | + e = _villes_index().get(slug) |
| |
529 | + if not e: |
| |
530 | + return _not_found("Aucun restaurant recensé pour cette ville", path) |
| |
531 | + city, n, with_menu = e["city"], e["n"], e["with_menu"] |
| |
532 | + marks = ",".join("?" * len(e["cities"])) |
| |
533 | + con = db.connect() |
| |
534 | + rows = con.execute( |
| |
535 | + f"""SELECT uid, name, city, cuisines, price_range |
| |
536 | + FROM restaurants WHERE {ACTIVE} AND city IN ({marks}) |
| |
537 | + ORDER BY uid IN (SELECT uid FROM menus) DESC, updated_at DESC |
| |
538 | + LIMIT 100""", e["cities"]).fetchall() |
| |
539 | + con.close() |
| |
540 | + neighbors = [v for v in _villes_rows(minimum=MIN_RESTOS_PAGE) |
| |
541 | + if v["slug"] != slug][:12] |
| |
542 | + if n == 0: |
| |
543 | + return _not_found(f"Aucun restaurant actif à {city}", path) |
| |
544 | + |
| |
545 | + title = f"Restaurants à {city} — {_fmt_n(n)} établissements | Resto-Ka" |
| |
546 | + description = ( |
| |
547 | + f"{_fmt_n(n)} restaurants à {city}" |
| |
548 | + + (f", dont {_fmt_n(with_menu)} avec menu complet et prix réels" |
| |
549 | + if with_menu else "") |
| |
550 | + + ". Cuisines, coordonnées, fourchettes de prix et menus, " |
| |
551 | + "agrégés et mis à jour en continu par Resto-Ka.") |
| |
552 | + body = [f"<h1>Restaurants à {_e(city)} — {_fmt_n(n)} établissements</h1>"] |
| |
553 | + if with_menu: |
| |
554 | + body.append(f"<p>{_fmt_n(with_menu)} restaurants de {_e(city)} ont leur " |
| |
555 | + "menu complet avec prix réels sur Resto-Ka.</p>") |
| |
556 | + body.append("<h2>Établissements</h2><ul>" |
| |
557 | + + "".join(_resto_li(r) for r in rows) + "</ul>") |
| |
558 | + if neighbors: |
| |
559 | + body.append("<h2>Autres villes</h2><ul>" + "".join( |
| |
560 | + f'<li><a href="/ville/{v["slug"]}">Restaurants à {_e(v["city"])}</a>' |
| |
561 | + f' — {_fmt_n(v["n"])}</li>' for v in neighbors) + "</ul>") |
| |
562 | + body.append('<p><a href="/villes">Toutes les villes</a> · ' |
| |
563 | + '<a href="/">Tous les restaurants du Québec</a></p>') |
| |
564 | + crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, path)] |
| |
565 | + jsonld = [_breadcrumb(crumbs), |
| |
566 | + {"@context": "https://schema.org", "@type": "ItemList", |
| |
567 | + "name": f"Restaurants à {city}", "numberOfItems": n, |
| |
568 | + "itemListElement": [ |
| |
569 | + {"@type": "ListItem", "position": i + 1, |
| |
570 | + "name": r["name"] or r["uid"], |
| |
571 | + "url": f"{BASE_URL}/resto/{r['uid']}"} |
| |
572 | + for i, r in enumerate(rows[:50])]}] |
| |
573 | + return _render(title=title, description=description, path=path, |
| |
574 | + jsonld=jsonld, body="".join(body)) |
| |
575 | + |
| |
576 | + |
| 397 | # pages statiques de l'app : head unique, contenu rendu par React |
577 | # pages statiques de l'app : head unique, contenu rendu par React |
| 398 | _STATIC_META = { |
578 | _STATIC_META = { |
| 399 | "/stats": ("Statistiques des restaurants du Québec | Resto-Ka", |
579 | "/stats": ("Statistiques des restaurants du Québec | Resto-Ka", |
| 400 | |
580 | |