Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: restoka/hours.py4# Desc: Parseur d'horaires — convertit la syntaxe OSM `opening_hours`5# (« Mo-Fr 11:00-22:00; Sa-Su 10:00-23:00 ») et les horaires JSON-LD6# schema.org (openingHoursSpecification) en horaires STRUCTURÉS par7# jour : {"mon": [["11:00","22:00"]], ...}. Les syntaxes exotiques8# (sunrise, mois, conditions) ne sont PAS devinées : on retourne None9# et le tag brut reste dans hours["osm"] (jamais de perte).10# ==============================================================================11from __future__ import annotations1213import re1415__all__ = ["parse_opening_hours", "parse_jsonld_hours", "DAYS"]1617DAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")1819_OSM_DAY = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6}2021# schema.org : "Monday", "https://schema.org/Monday", "Mo"…22_SCHEMA_DAY = {23 "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,24 "friday": 4, "saturday": 5, "sunday": 6,25 "mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6,26}2728_TIME_RANGE_RE = re.compile(r"^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$")29# marqueurs de syntaxe OSM avancée que l'on refuse de deviner (le brut suffit)30_EXOTIC_RE = re.compile(31 r"sunrise|sunset|dawn|dusk|week\s|\bweek\b|easter|\[|\"|\|\||"32 r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b", re.I)333435def _expand_days(spec: str) -> list[int] | None:36 """« Mo-Fr », « Sa,Su », « Mo,We-Fr » -> indices 0-6 ; None si inconnu."""37 out: list[int] = []38 for part in spec.split(","):39 part = part.strip().lower()40 if not part:41 continue42 if part in ("ph", "sh"): # jours fériés/vacances : ignorés43 continue44 m = re.match(r"^([a-z]{2})\s*-\s*([a-z]{2})$", part)45 if m:46 a, b = _OSM_DAY.get(m.group(1)), _OSM_DAY.get(m.group(2))47 if a is None or b is None:48 return None49 i = a50 while True:51 if i not in out:52 out.append(i)53 if i == b:54 break55 i = (i + 1) % 7 # Sa-Mo enroule la semaine56 elif part in _OSM_DAY:57 if _OSM_DAY[part] not in out:58 out.append(_OSM_DAY[part])59 else:60 return None61 return out626364def _parse_times(spec: str) -> list[list[str]] | None:65 """« 11:00-14:00,17:00-22:00 » -> [["11:00","14:00"],["17:00","22:00"]]."""66 if spec.strip().lower() in ("off", "closed"):67 return []68 out: list[list[str]] = []69 for chunk in spec.split(","):70 m = _TIME_RANGE_RE.match(chunk.strip())71 if not m:72 return None73 h1, m1, h2, m2 = (int(g) for g in m.groups())74 if h1 > 24 or h2 > 48 or m1 > 59 or m2 > 59:75 return None76 # « 17:00-01:00 » (ferme après minuit) est conservé tel quel77 out.append([f"{h1:02d}:{m1:02d}", f"{h2 % 24:02d}:{m2:02d}"78 if h2 > 24 else f"{h2:02d}:{m2:02d}"])79 return out808182def parse_opening_hours(raw: str | None) -> dict[str, list[list[str]]] | None:83 """Tag OSM `opening_hours` -> {"mon": [["11:00","22:00"]], ...} ou None.8485 Gère les syntaxes courantes : plages de jours (Mo-Fr), listes (Sa,Su),86 plusieurs plages horaires (11:00-14:00,17:00-22:00), « off », « 24/7 »,87 PH/SH ignorés. Tout le reste (sunrise, mois, semaines, conditions) est88 refusé — le tag brut reste disponible dans hours["osm"].89 """90 if not raw or not isinstance(raw, str):91 return None92 raw = raw.strip()93 if raw == "24/7":94 return {d: [["00:00", "24:00"]] for d in DAYS}95 if _EXOTIC_RE.search(raw):96 return None97 out: dict[str, list[list[str]]] = {}98 for rule in raw.split(";"):99 rule = rule.strip().rstrip(",")100 if not rule:101 continue102 # séparer le spécificateur de jours du spécificateur d'heures103 m = re.match(r"^([A-Za-z ,\-]+?)\s+(.+)$", rule)104 if m and re.search(r"[a-zA-Z]", m.group(1)):105 days_spec, times_spec = m.group(1), m.group(2)106 elif re.match(r"^\d", rule): # heures seules -> tous les jours107 days_spec, times_spec = "Mo-Su", rule108 elif rule.strip().lower() in ("ph off", "sh off", "ph closed"):109 continue110 else:111 return None112 # « PH off » collé à une règle de jours (ex. « PH 10:00-… ») : ignorer113 if days_spec.strip().lower() in ("ph", "sh"):114 continue115 days = _expand_days(days_spec)116 times = _parse_times(times_spec)117 if days is None or times is None:118 return None # syntaxe non couverte : brut119 for d in days:120 out[DAYS[d]] = times121 return out or None122123124def parse_jsonld_hours(node: dict) -> dict[str, list[list[str]]] | None:125 """Horaires d'un nœud JSON-LD schema.org (LocalBusiness/Restaurant).126127 Gère `openingHoursSpecification` (liste de {dayOfWeek, opens, closes})128 et `openingHours` (chaînes façon OSM « Mo-Fr 11:00-22:00 »).129 """130 spec = node.get("openingHoursSpecification")131 if isinstance(spec, dict):132 spec = [spec]133 if isinstance(spec, list) and spec:134 out: dict[str, list[list[str]]] = {}135 for entry in spec:136 if not isinstance(entry, dict):137 continue138 days = entry.get("dayOfWeek") or []139 if isinstance(days, str):140 days = [days]141 opens = str(entry.get("opens") or "")[:5]142 closes = str(entry.get("closes") or "")[:5]143 if not re.match(r"^\d{1,2}:\d{2}$", opens) or \144 not re.match(r"^\d{1,2}:\d{2}$", closes):145 continue146 rng = [f"{int(opens.split(':')[0]):02d}:{opens.split(':')[1]}",147 f"{int(closes.split(':')[0]):02d}:{closes.split(':')[1]}"]148 for d in days:149 key = str(d).rsplit("/", 1)[-1].strip().lower()150 idx = _SCHEMA_DAY.get(key)151 if idx is None:152 continue153 out.setdefault(DAYS[idx], []).append(rng)154 if out:155 return out156 oh = node.get("openingHours")157 if isinstance(oh, str):158 oh = [oh]159 if isinstance(oh, list) and oh:160 merged: dict[str, list[list[str]]] = {}161 for chunk in oh:162 parsed = parse_opening_hours(str(chunk))163 if parsed:164 merged.update(parsed)165 if merged:166 return merged167 return None168