# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/hours.py # Desc: Parseur d'horaires — convertit la syntaxe OSM `opening_hours` # (« Mo-Fr 11:00-22:00; Sa-Su 10:00-23:00 ») et les horaires JSON-LD # schema.org (openingHoursSpecification) en horaires STRUCTURÉS par # jour : {"mon": [["11:00","22:00"]], ...}. Les syntaxes exotiques # (sunrise, mois, conditions) ne sont PAS devinées : on retourne None # et le tag brut reste dans hours["osm"] (jamais de perte). # ============================================================================== from __future__ import annotations import re __all__ = ["parse_opening_hours", "parse_jsonld_hours", "DAYS"] DAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") _OSM_DAY = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6} # schema.org : "Monday", "https://schema.org/Monday", "Mo"… _SCHEMA_DAY = { "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6, "mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6, } _TIME_RANGE_RE = re.compile(r"^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$") # marqueurs de syntaxe OSM avancée que l'on refuse de deviner (le brut suffit) _EXOTIC_RE = re.compile( r"sunrise|sunset|dawn|dusk|week\s|\bweek\b|easter|\[|\"|\|\||" r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b", re.I) def _expand_days(spec: str) -> list[int] | None: """« Mo-Fr », « Sa,Su », « Mo,We-Fr » -> indices 0-6 ; None si inconnu.""" out: list[int] = [] for part in spec.split(","): part = part.strip().lower() if not part: continue if part in ("ph", "sh"): # jours fériés/vacances : ignorés continue m = re.match(r"^([a-z]{2})\s*-\s*([a-z]{2})$", part) if m: a, b = _OSM_DAY.get(m.group(1)), _OSM_DAY.get(m.group(2)) if a is None or b is None: return None i = a while True: if i not in out: out.append(i) if i == b: break i = (i + 1) % 7 # Sa-Mo enroule la semaine elif part in _OSM_DAY: if _OSM_DAY[part] not in out: out.append(_OSM_DAY[part]) else: return None return out def _parse_times(spec: str) -> list[list[str]] | None: """« 11:00-14:00,17:00-22:00 » -> [["11:00","14:00"],["17:00","22:00"]].""" if spec.strip().lower() in ("off", "closed"): return [] out: list[list[str]] = [] for chunk in spec.split(","): m = _TIME_RANGE_RE.match(chunk.strip()) if not m: return None h1, m1, h2, m2 = (int(g) for g in m.groups()) if h1 > 24 or h2 > 48 or m1 > 59 or m2 > 59: return None # « 17:00-01:00 » (ferme après minuit) est conservé tel quel out.append([f"{h1:02d}:{m1:02d}", f"{h2 % 24:02d}:{m2:02d}" if h2 > 24 else f"{h2:02d}:{m2:02d}"]) return out def parse_opening_hours(raw: str | None) -> dict[str, list[list[str]]] | None: """Tag OSM `opening_hours` -> {"mon": [["11:00","22:00"]], ...} ou None. Gère les syntaxes courantes : plages de jours (Mo-Fr), listes (Sa,Su), plusieurs plages horaires (11:00-14:00,17:00-22:00), « off », « 24/7 », PH/SH ignorés. Tout le reste (sunrise, mois, semaines, conditions) est refusé — le tag brut reste disponible dans hours["osm"]. """ if not raw or not isinstance(raw, str): return None raw = raw.strip() if raw == "24/7": return {d: [["00:00", "24:00"]] for d in DAYS} if _EXOTIC_RE.search(raw): return None out: dict[str, list[list[str]]] = {} for rule in raw.split(";"): rule = rule.strip().rstrip(",") if not rule: continue # séparer le spécificateur de jours du spécificateur d'heures m = re.match(r"^([A-Za-z ,\-]+?)\s+(.+)$", rule) if m and re.search(r"[a-zA-Z]", m.group(1)): days_spec, times_spec = m.group(1), m.group(2) elif re.match(r"^\d", rule): # heures seules -> tous les jours days_spec, times_spec = "Mo-Su", rule elif rule.strip().lower() in ("ph off", "sh off", "ph closed"): continue else: return None # « PH off » collé à une règle de jours (ex. « PH 10:00-… ») : ignorer if days_spec.strip().lower() in ("ph", "sh"): continue days = _expand_days(days_spec) times = _parse_times(times_spec) if days is None or times is None: return None # syntaxe non couverte : brut for d in days: out[DAYS[d]] = times return out or None def parse_jsonld_hours(node: dict) -> dict[str, list[list[str]]] | None: """Horaires d'un nœud JSON-LD schema.org (LocalBusiness/Restaurant). Gère `openingHoursSpecification` (liste de {dayOfWeek, opens, closes}) et `openingHours` (chaînes façon OSM « Mo-Fr 11:00-22:00 »). """ spec = node.get("openingHoursSpecification") if isinstance(spec, dict): spec = [spec] if isinstance(spec, list) and spec: out: dict[str, list[list[str]]] = {} for entry in spec: if not isinstance(entry, dict): continue days = entry.get("dayOfWeek") or [] if isinstance(days, str): days = [days] opens = str(entry.get("opens") or "")[:5] closes = str(entry.get("closes") or "")[:5] if not re.match(r"^\d{1,2}:\d{2}$", opens) or \ not re.match(r"^\d{1,2}:\d{2}$", closes): continue rng = [f"{int(opens.split(':')[0]):02d}:{opens.split(':')[1]}", f"{int(closes.split(':')[0]):02d}:{closes.split(':')[1]}"] for d in days: key = str(d).rsplit("/", 1)[-1].strip().lower() idx = _SCHEMA_DAY.get(key) if idx is None: continue out.setdefault(DAYS[idx], []).append(rng) if out: return out oh = node.get("openingHours") if isinstance(oh, str): oh = [oh] if isinstance(oh, list) and oh: merged: dict[str, list[list[str]]] = {} for chunk in oh: parsed = parse_opening_hours(str(chunk)) if parsed: merged.update(parsed) if merged: return merged return None