SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
6.6 KB · 168 lines python
Raw Blame History
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