Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/sithotels.py : « Hôtels — SIT Québec » — le répertoire OFFICIEL4# des hôtels du Québec (ministère du Tourisme / CITQ / AHQ), publié sur5# Données Québec et syndiqué par Tourinsoft. ~1 910 établissements, JSON6# public sans auth (~11 Mo), mis à jour quotidiennement.7#8# Méthode :9# 1. UNE requête : le dataset complet vit derrière une seule URL Tourinsoft10# ({"value": [fiches…]}) — pas de pagination, pas de pages détail, donc11# pas de cache BD (use_detail_cache = False).12# 2. Chaque fiche Tourinsoft est un agrégat de blocs (Adresses,13# Geolocalisations, SiteInternets, Unitess, CapaciteMaximaleHebergements,14# NumeroEnregistrementHebergements, Services*…) dont les libellés vivent15# dans des dicts {ThesID, ThesLibelle} imbriqués — extraction générique16# via _libelles().17# 3. La région touristique s'obtient en joignant ZonesGeographiquess et18# ZonesGeographiquesTypes sur ZoneId (type « Région touristique ») ;19# variantes du dataset ramenées au canon Lou-Ka (Côte-Nord (Duplessis/20# Manicouagan) → Côte-Nord, Baie-James & Eeyou Istchee → Eeyou Istchee21# Baie-James, «--» → tiret cadratin pour Saguenay–Lac-Saint-Jean).22#23# Particularités assumées :24# - PAS de prix ni de photos dans le dataset (squelette d'inventaire25# officiel) → price_night=None, price_label="" (« Prix sur demande »).26# - `capacity` (personnes PAR unité sur le site) reste vide : le nombre27# d'unités et la capacité totale de l'hôtel vont dans details["units"] /28# details["total_capacity"] et dans la description.29# - URL : site web de l'établissement si présent (~61 % des fiches, parfois30# directement un moteur Reservit) ; sinon repli sur la recherche31# BonjourQuébec (?keys=<nom> <ville>, paramètre Drupal vérifié) — lien32# public stable qui présente l'établissement ; aucune fiche abandonnée.33# - Noms livrés EN MAJUSCULES → recasage français (_titre) en gardant les34# particules (de, du, des, l'…) en minuscules.35# - external_id = SyndicObjectID (stable, unique — vérifié) ; le numéro36# d'enregistrement CITQ (100 % des fiches) va dans `citq`.37# - Animaux : « admis gratuitement » → oui ; « admis payant » → conditions.38# - Description : court paragraphe éditorial assemblé à partir des faits39# (type, unités, ville/région, environnement, services, animaux, période40# d'exploitation, no CITQ) — rien d'inventé.41# -----------------------------------------------------------------------------42from __future__ import annotations4344import json45import re46import sys47from pathlib import Path48from urllib.parse import quote_plus4950from ..schema import StListing51from .base import StConnector5253# table hôtel → moteur de résa (scripts/detect_hotel_engines.py) : les hôtels54# couverts par un connecteur riche (mews, reservit) sont sautés ici pour ne55# pas doubler leur fiche avec un squelette sans prix ni photos56ENGINES = Path(__file__).resolve().parents[3] / "data" / "hotels_engines.json"57ENGINES_COUVERTS = {"mews", "reservit"}5859DATASET = ("https://api-v3.tourinsoft.com/api/syndications/mto.tourinsoft.com/"60 "535f2f61-f91a-47c1-827d-ecc908c17dba?format=json")6162BQ_RECHERCHE = "https://www.bonjourquebec.com/fr-ca/recherche?keys="6364# blocs Tourinsoft dont les libellés deviennent des commodités65GROUPES_SERVICES = [66 "ServicesDisponibless", "ServicesRestaurations", "ServicesTransports",67 "ActivitesSportivesNautiquess", "ActivitesSportivesTerrestress",68 "ActivitesRecreativesCulturelless", "LocationEquipements",69]7071# variantes de région du dataset → canon Lou-Ka (le reste passe par72# normalize_region dans finalize())73REGION_FIX = {74 "Baie-James": "Eeyou Istchee Baie-James",75 "Eeyou Istchee": "Eeyou Istchee Baie-James",76}7778# particules françaises gardées en minuscules lors du recasage des noms79_PARTICULES = {80 "de", "du", "des", "la", "le", "les", "l", "d", "a", "à", "au", "aux",81 "et", "en", "sur", "sous", "chez", "un", "une", "par", "pour",82 "the", "of", "and", "at", "on", "by",83}8485_MOIS = ["", "janvier", "février", "mars", "avril", "mai", "juin", "juillet",86 "août", "septembre", "octobre", "novembre", "décembre"]8788_SEG_RE = re.compile(r"(['’\-])")899091def _titre(nom: str) -> str:92 """Recase un nom livré en MAJUSCULES (« HÔTEL L'OIE DES NEIGES » →93 « Hôtel l'Oie des Neiges ») ; les noms déjà en casse mixte sont gardés."""94 nom = re.sub(r"\s+", " ", (nom or "").strip())95 if not nom or nom != nom.upper():96 return nom97 mots = []98 for i, mot in enumerate(nom.lower().split(" ")):99 segs = _SEG_RE.split(mot) # « l'oie » → [l, ', oie]100 for j in range(0, len(segs), 2):101 seg = segs[j]102 if not seg:103 continue104 if (i == 0 and j == 0) or seg not in _PARTICULES:105 segs[j] = seg[0].upper() + seg[1:]106 mots.append("".join(segs))107 return " ".join(mots)108109110def _libelles(rec: dict, key: str) -> list[str]:111 """Extrait les ThesLibelle d'un bloc Tourinsoft (listes de dicts dont la112 clé interne varie : Servicesdisponibles, Environnement…)."""113 out: list[str] = []114 for blk in rec.get(key) or []:115 if not isinstance(blk, dict):116 continue117 for v in blk.values():118 if isinstance(v, list):119 for it in v:120 if isinstance(it, dict) and it.get("ThesLibelle"):121 lib = str(it["ThesLibelle"]).strip()122 if lib and lib not in out:123 out.append(lib)124 return out125126127def _region(rec: dict) -> str:128 """Jointure ZonesGeographiquess × ZonesGeographiquesTypes → région129 touristique, ramenée vers le canon Lou-Ka."""130 types = {z.get("ZoneId"): z.get("ZoneTypeLibelle")131 for z in rec.get("ZonesGeographiquesTypes") or []}132 for z in rec.get("ZonesGeographiquess") or []:133 if types.get(z.get("ZoneId")) == "Région touristique":134 raw = re.sub(r"\s*\([^)]*\)\s*$", "", str(z.get("ZoneLibelle") or ""))135 raw = raw.replace("--", "–").strip()136 return REGION_FIX.get(raw, raw)137 return ""138139140def _coordonnees(rec: dict, key: str) -> str:141 for c in rec.get(key) or []:142 v = str(c.get("Coordonnees") or "").strip()143 if v:144 return v145 return ""146147148def _date_fr(iso: str) -> str:149 m = re.match(r"(\d{4})-(\d{2})-(\d{2})", str(iso or ""))150 if not m:151 return ""152 return f"{int(m.group(3))} {_MOIS[int(m.group(2))]}"153154155def _property_type(nom: str) -> str:156 bas = nom.lower()157 if "motel" in bas:158 return "Motel"159 if "auberge" in bas:160 return "Auberge"161 return "Hôtel"162163164class SitHotels(StConnector):165 source_id = "sithotels"166 use_detail_cache = False # une seule requête : rien à cacher167 timeout = 120 # le JSON fait ~11 Mo168169 # -- hôtels déjà couverts par un connecteur moteur (fiche plus riche) ------170 @staticmethod171 def _couverts() -> set[str]:172 try:173 table = json.loads(ENGINES.read_text(encoding="utf-8"))174 except (OSError, ValueError) as exc:175 print(f"[sithotels] table moteurs illisible ({exc}) — aucun saut",176 file=sys.stderr)177 return set()178 return {179 str(h.get("sit_id") or "")180 for h in table.get("hotels") or []181 if h.get("engine") in ENGINES_COUVERTS182 and (h.get("engine_ids") or {}).get(183 "distributor" if h.get("engine") == "mews" else "hotelid")184 }185186 # -- contrat ---------------------------------------------------------------187 def fetch(self) -> list[StListing]:188 couverts = self._couverts()189 data = self.get(DATASET).json()190 listings: list[StListing] = []191 sautes = 0192 for rec in data.get("value") or []:193 ext_id = str(rec.get("SyndicObjectID") or "").strip()194 titre = _titre(str(rec.get("SyndicObjectName") or ""))195 if not ext_id or not titre:196 continue197 if ext_id in couverts:198 sautes += 1199 continue200201 adresse = (rec.get("Adresses") or [{}])[0]202 ville = str(adresse.get("Municipalite") or "").strip()203 rue = str(adresse.get("Numerovoie") or "").strip()204 region = _region(rec)205206 geo = (rec.get("Geolocalisations") or [{}])[0]207 try:208 lat, lng = float(geo.get("Latitude")), float(geo.get("Longitude"))209 except (TypeError, ValueError):210 lat = lng = None211212 # site web de l'hôtel, sinon recherche BonjourQuébec (lien stable)213 url = _coordonnees(rec, "SiteInternets")214 if url.startswith("www."):215 url = "https://" + url216 details: dict = {}217 if not url.startswith(("http://", "https://")):218 url = BQ_RECHERCHE + quote_plus(f"{titre} {ville}".strip())219 details["lien"] = "recherche-bonjourquebec"220221 citq = ""222 for n in rec.get("NumeroEnregistrementHebergements") or []:223 v = str(n.get("Numerodenregistrementdhebergement") or "").strip()224 if v:225 citq = v226 break227228 # unités et capacité TOTALE de l'hôtel → details (pas `capacity`)229 cap = (rec.get("CapaciteMaximaleHebergements") or [{}])[0]230 unites = cap.get("Nombredunitescommercialisablesseparement")231 cap_totale = cap.get("Capacitemaximaleenhebergementnombredepersonnes")232 types_unites = []233 for u in rec.get("Unitess") or []:234 n, lib = u.get("Nombredunites"), \235 (u.get("Typedunite") or {}).get("ThesLibelle") or ""236 if n and lib:237 lib = lib.lower()238 plur = {"chambre": "chambres", "suite": "suites",239 "appartement": "appartements"}.get(lib, lib)240 types_unites.append(f"{n} {plur if n > 1 else lib}")241 if unites:242 details["units"] = unites243 if cap_totale:244 details["total_capacity"] = cap_totale245 if types_unites:246 details["unit_types"] = ", ".join(types_unites)247248 # services → commodités ; environnement → contexte + drapeau249 amenities = []250 for g in GROUPES_SERVICES:251 for lib in _libelles(rec, g):252 if lib not in amenities:253 amenities.append(lib)254 environnement = _libelles(rec, "Environnements")255 if any("bord d'une rivière" in e.lower() for e in environnement):256 details["waterfront"] = True257258 # accessibilité Kéroul (bloc à part, pas la forme générique)259 for a in rec.get("Accessibilites") or []:260 cote = (a.get("CotedaccessibiliteselonKeroul") or {}) \261 .get("ThesLibelle")262 if cote:263 details["keroul"] = cote264 break265266 pets_libs = _libelles(rec, "ServicesAnimauxCompagnies")267 pets = None268 if any("gratuit" in l.lower() for l in pets_libs):269 pets = "oui"270 elif pets_libs:271 pets = "conditions"272273 # période d'exploitation274 exploitation, saison = "", ""275 for p in rec.get("PeriodeOuvertures") or []:276 exploitation = (p.get("Typedexploitation") or {}) \277 .get("ThesLibelle") or ""278 if exploitation:279 deb, fin = _date_fr(p.get("Datedebut")), \280 _date_fr(p.get("Datefin"))281 if exploitation.lower().startswith("saison") and deb and fin:282 saison = f"du {deb} au {fin}"283 break284 if exploitation:285 details["exploitation"] = (f"{exploitation} {saison}".strip()286 if saison else exploitation)287288 tel = _coordonnees(rec, "TelephonePrincipals")289 courriel = _coordonnees(rec, "Courriels")290 if tel:291 details["phone"] = tel292 if courriel:293 details["email"] = courriel294 cp = str(adresse.get("CodePostal") or "").strip()295 if cp:296 details["postal_code"] = cp297298 ptype = _property_type(titre)299300 # description éditoriale assemblée à partir des faits301 feminin = ptype == "Auberge"302 phrases = []303 p1 = f"{titre} est un{'e' if feminin else ''} {ptype.lower()}"304 if unites:305 p1 += f" de {unites} unité{'s' if unites > 1 else ''}"306 if types_unites:307 p1 += f" ({', '.join(types_unites)})"308 if ville:309 p1 += f" situé{'e' if feminin else ''} à {ville}"310 if region:311 p1 += f", dans la région {region}"312 phrases.append(p1 + ".")313 if cap_totale:314 phrases.append("L'établissement peut accueillir jusqu'à "315 f"{cap_totale} personnes au total.")316 if environnement:317 phrases.append("Emplacement : "318 + ", ".join(e[0].lower() + e[1:]319 for e in environnement) + ".")320 if amenities:321 aff = [a[0].lower() + a[1:] for a in amenities[:10]]322 phrases.append("Sur place : " + ", ".join(aff) + ".")323 if pets == "oui":324 phrases.append("Animaux de compagnie admis gratuitement.")325 elif pets == "conditions":326 phrases.append("Animaux de compagnie admis avec frais.")327 if saison:328 phrases.append(f"Exploitation saisonnière, {saison}.")329 elif exploitation.lower().startswith("annuel"):330 phrases.append("Ouvert à l'année.")331 if citq:332 phrases.append("Établissement d'hébergement touristique "333 f"enregistré (no CITQ {citq}).")334335 listings.append(StListing(336 source=self.source_id,337 external_id=ext_id,338 url=url,339 title=titre,340 property_type=ptype,341 address=rue,342 city=ville,343 region=region,344 price_night=None,345 price_label="",346 pets=pets,347 citq=citq,348 description=" ".join(phrases),349 amenities=amenities,350 details=details,351 lat=lat,352 lng=lng,353 ))354 if sautes:355 print(f"[sithotels] {sautes} hôtels sautés (couverts par un "356 f"connecteur moteur), {len(listings)} fiches squelettes",357 file=sys.stderr)358 return listings359