SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
15.0 KB · 359 lines python
Raw Blame History
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