# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/sithotels.py : « Hôtels — SIT Québec » — le répertoire OFFICIEL # des hôtels du Québec (ministère du Tourisme / CITQ / AHQ), publié sur # Données Québec et syndiqué par Tourinsoft. ~1 910 établissements, JSON # public sans auth (~11 Mo), mis à jour quotidiennement. # # Méthode : # 1. UNE requête : le dataset complet vit derrière une seule URL Tourinsoft # ({"value": [fiches…]}) — pas de pagination, pas de pages détail, donc # pas de cache BD (use_detail_cache = False). # 2. Chaque fiche Tourinsoft est un agrégat de blocs (Adresses, # Geolocalisations, SiteInternets, Unitess, CapaciteMaximaleHebergements, # NumeroEnregistrementHebergements, Services*…) dont les libellés vivent # dans des dicts {ThesID, ThesLibelle} imbriqués — extraction générique # via _libelles(). # 3. La région touristique s'obtient en joignant ZonesGeographiquess et # ZonesGeographiquesTypes sur ZoneId (type « Région touristique ») ; # variantes du dataset ramenées au canon Lou-Ka (Côte-Nord (Duplessis/ # Manicouagan) → Côte-Nord, Baie-James & Eeyou Istchee → Eeyou Istchee # Baie-James, «--» → tiret cadratin pour Saguenay–Lac-Saint-Jean). # # Particularités assumées : # - PAS de prix ni de photos dans le dataset (squelette d'inventaire # officiel) → price_night=None, price_label="" (« Prix sur demande »). # - `capacity` (personnes PAR unité sur le site) reste vide : le nombre # d'unités et la capacité totale de l'hôtel vont dans details["units"] / # details["total_capacity"] et dans la description. # - URL : site web de l'établissement si présent (~61 % des fiches, parfois # directement un moteur Reservit) ; sinon repli sur la recherche # BonjourQuébec (?keys= , paramètre Drupal vérifié) — lien # public stable qui présente l'établissement ; aucune fiche abandonnée. # - Noms livrés EN MAJUSCULES → recasage français (_titre) en gardant les # particules (de, du, des, l'…) en minuscules. # - external_id = SyndicObjectID (stable, unique — vérifié) ; le numéro # d'enregistrement CITQ (100 % des fiches) va dans `citq`. # - Animaux : « admis gratuitement » → oui ; « admis payant » → conditions. # - Description : court paragraphe éditorial assemblé à partir des faits # (type, unités, ville/région, environnement, services, animaux, période # d'exploitation, no CITQ) — rien d'inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import sys from pathlib import Path from urllib.parse import quote_plus from ..schema import StListing from .base import StConnector # table hôtel → moteur de résa (scripts/detect_hotel_engines.py) : les hôtels # couverts par un connecteur riche (mews, reservit) sont sautés ici pour ne # pas doubler leur fiche avec un squelette sans prix ni photos ENGINES = Path(__file__).resolve().parents[3] / "data" / "hotels_engines.json" ENGINES_COUVERTS = {"mews", "reservit"} DATASET = ("https://api-v3.tourinsoft.com/api/syndications/mto.tourinsoft.com/" "535f2f61-f91a-47c1-827d-ecc908c17dba?format=json") BQ_RECHERCHE = "https://www.bonjourquebec.com/fr-ca/recherche?keys=" # blocs Tourinsoft dont les libellés deviennent des commodités GROUPES_SERVICES = [ "ServicesDisponibless", "ServicesRestaurations", "ServicesTransports", "ActivitesSportivesNautiquess", "ActivitesSportivesTerrestress", "ActivitesRecreativesCulturelless", "LocationEquipements", ] # variantes de région du dataset → canon Lou-Ka (le reste passe par # normalize_region dans finalize()) REGION_FIX = { "Baie-James": "Eeyou Istchee Baie-James", "Eeyou Istchee": "Eeyou Istchee Baie-James", } # particules françaises gardées en minuscules lors du recasage des noms _PARTICULES = { "de", "du", "des", "la", "le", "les", "l", "d", "a", "à", "au", "aux", "et", "en", "sur", "sous", "chez", "un", "une", "par", "pour", "the", "of", "and", "at", "on", "by", } _MOIS = ["", "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"] _SEG_RE = re.compile(r"(['’\-])") def _titre(nom: str) -> str: """Recase un nom livré en MAJUSCULES (« HÔTEL L'OIE DES NEIGES » → « Hôtel l'Oie des Neiges ») ; les noms déjà en casse mixte sont gardés.""" nom = re.sub(r"\s+", " ", (nom or "").strip()) if not nom or nom != nom.upper(): return nom mots = [] for i, mot in enumerate(nom.lower().split(" ")): segs = _SEG_RE.split(mot) # « l'oie » → [l, ', oie] for j in range(0, len(segs), 2): seg = segs[j] if not seg: continue if (i == 0 and j == 0) or seg not in _PARTICULES: segs[j] = seg[0].upper() + seg[1:] mots.append("".join(segs)) return " ".join(mots) def _libelles(rec: dict, key: str) -> list[str]: """Extrait les ThesLibelle d'un bloc Tourinsoft (listes de dicts dont la clé interne varie : Servicesdisponibles, Environnement…).""" out: list[str] = [] for blk in rec.get(key) or []: if not isinstance(blk, dict): continue for v in blk.values(): if isinstance(v, list): for it in v: if isinstance(it, dict) and it.get("ThesLibelle"): lib = str(it["ThesLibelle"]).strip() if lib and lib not in out: out.append(lib) return out def _region(rec: dict) -> str: """Jointure ZonesGeographiquess × ZonesGeographiquesTypes → région touristique, ramenée vers le canon Lou-Ka.""" types = {z.get("ZoneId"): z.get("ZoneTypeLibelle") for z in rec.get("ZonesGeographiquesTypes") or []} for z in rec.get("ZonesGeographiquess") or []: if types.get(z.get("ZoneId")) == "Région touristique": raw = re.sub(r"\s*\([^)]*\)\s*$", "", str(z.get("ZoneLibelle") or "")) raw = raw.replace("--", "–").strip() return REGION_FIX.get(raw, raw) return "" def _coordonnees(rec: dict, key: str) -> str: for c in rec.get(key) or []: v = str(c.get("Coordonnees") or "").strip() if v: return v return "" def _date_fr(iso: str) -> str: m = re.match(r"(\d{4})-(\d{2})-(\d{2})", str(iso or "")) if not m: return "" return f"{int(m.group(3))} {_MOIS[int(m.group(2))]}" def _property_type(nom: str) -> str: bas = nom.lower() if "motel" in bas: return "Motel" if "auberge" in bas: return "Auberge" return "Hôtel" class SitHotels(StConnector): source_id = "sithotels" use_detail_cache = False # une seule requête : rien à cacher timeout = 120 # le JSON fait ~11 Mo # -- hôtels déjà couverts par un connecteur moteur (fiche plus riche) ------ @staticmethod def _couverts() -> set[str]: try: table = json.loads(ENGINES.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: print(f"[sithotels] table moteurs illisible ({exc}) — aucun saut", file=sys.stderr) return set() return { str(h.get("sit_id") or "") for h in table.get("hotels") or [] if h.get("engine") in ENGINES_COUVERTS and (h.get("engine_ids") or {}).get( "distributor" if h.get("engine") == "mews" else "hotelid") } # -- contrat --------------------------------------------------------------- def fetch(self) -> list[StListing]: couverts = self._couverts() data = self.get(DATASET).json() listings: list[StListing] = [] sautes = 0 for rec in data.get("value") or []: ext_id = str(rec.get("SyndicObjectID") or "").strip() titre = _titre(str(rec.get("SyndicObjectName") or "")) if not ext_id or not titre: continue if ext_id in couverts: sautes += 1 continue adresse = (rec.get("Adresses") or [{}])[0] ville = str(adresse.get("Municipalite") or "").strip() rue = str(adresse.get("Numerovoie") or "").strip() region = _region(rec) geo = (rec.get("Geolocalisations") or [{}])[0] try: lat, lng = float(geo.get("Latitude")), float(geo.get("Longitude")) except (TypeError, ValueError): lat = lng = None # site web de l'hôtel, sinon recherche BonjourQuébec (lien stable) url = _coordonnees(rec, "SiteInternets") if url.startswith("www."): url = "https://" + url details: dict = {} if not url.startswith(("http://", "https://")): url = BQ_RECHERCHE + quote_plus(f"{titre} {ville}".strip()) details["lien"] = "recherche-bonjourquebec" citq = "" for n in rec.get("NumeroEnregistrementHebergements") or []: v = str(n.get("Numerodenregistrementdhebergement") or "").strip() if v: citq = v break # unités et capacité TOTALE de l'hôtel → details (pas `capacity`) cap = (rec.get("CapaciteMaximaleHebergements") or [{}])[0] unites = cap.get("Nombredunitescommercialisablesseparement") cap_totale = cap.get("Capacitemaximaleenhebergementnombredepersonnes") types_unites = [] for u in rec.get("Unitess") or []: n, lib = u.get("Nombredunites"), \ (u.get("Typedunite") or {}).get("ThesLibelle") or "" if n and lib: lib = lib.lower() plur = {"chambre": "chambres", "suite": "suites", "appartement": "appartements"}.get(lib, lib) types_unites.append(f"{n} {plur if n > 1 else lib}") if unites: details["units"] = unites if cap_totale: details["total_capacity"] = cap_totale if types_unites: details["unit_types"] = ", ".join(types_unites) # services → commodités ; environnement → contexte + drapeau amenities = [] for g in GROUPES_SERVICES: for lib in _libelles(rec, g): if lib not in amenities: amenities.append(lib) environnement = _libelles(rec, "Environnements") if any("bord d'une rivière" in e.lower() for e in environnement): details["waterfront"] = True # accessibilité Kéroul (bloc à part, pas la forme générique) for a in rec.get("Accessibilites") or []: cote = (a.get("CotedaccessibiliteselonKeroul") or {}) \ .get("ThesLibelle") if cote: details["keroul"] = cote break pets_libs = _libelles(rec, "ServicesAnimauxCompagnies") pets = None if any("gratuit" in l.lower() for l in pets_libs): pets = "oui" elif pets_libs: pets = "conditions" # période d'exploitation exploitation, saison = "", "" for p in rec.get("PeriodeOuvertures") or []: exploitation = (p.get("Typedexploitation") or {}) \ .get("ThesLibelle") or "" if exploitation: deb, fin = _date_fr(p.get("Datedebut")), \ _date_fr(p.get("Datefin")) if exploitation.lower().startswith("saison") and deb and fin: saison = f"du {deb} au {fin}" break if exploitation: details["exploitation"] = (f"{exploitation} {saison}".strip() if saison else exploitation) tel = _coordonnees(rec, "TelephonePrincipals") courriel = _coordonnees(rec, "Courriels") if tel: details["phone"] = tel if courriel: details["email"] = courriel cp = str(adresse.get("CodePostal") or "").strip() if cp: details["postal_code"] = cp ptype = _property_type(titre) # description éditoriale assemblée à partir des faits feminin = ptype == "Auberge" phrases = [] p1 = f"{titre} est un{'e' if feminin else ''} {ptype.lower()}" if unites: p1 += f" de {unites} unité{'s' if unites > 1 else ''}" if types_unites: p1 += f" ({', '.join(types_unites)})" if ville: p1 += f" situé{'e' if feminin else ''} à {ville}" if region: p1 += f", dans la région {region}" phrases.append(p1 + ".") if cap_totale: phrases.append("L'établissement peut accueillir jusqu'à " f"{cap_totale} personnes au total.") if environnement: phrases.append("Emplacement : " + ", ".join(e[0].lower() + e[1:] for e in environnement) + ".") if amenities: aff = [a[0].lower() + a[1:] for a in amenities[:10]] phrases.append("Sur place : " + ", ".join(aff) + ".") if pets == "oui": phrases.append("Animaux de compagnie admis gratuitement.") elif pets == "conditions": phrases.append("Animaux de compagnie admis avec frais.") if saison: phrases.append(f"Exploitation saisonnière, {saison}.") elif exploitation.lower().startswith("annuel"): phrases.append("Ouvert à l'année.") if citq: phrases.append("Établissement d'hébergement touristique " f"enregistré (no CITQ {citq}).") listings.append(StListing( source=self.source_id, external_id=ext_id, url=url, title=titre, property_type=ptype, address=rue, city=ville, region=region, price_night=None, price_label="", pets=pets, citq=citq, description=" ".join(phrases), amenities=amenities, details=details, lat=lat, lng=lng, )) if sautes: print(f"[sithotels] {sautes} hôtels sautés (couverts par un " f"connecteur moteur), {len(listings)} fiches squelettes", file=sys.stderr) return listings