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%
10.3 KB · 250 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/gitespassant.py : Gîtes et Auberges du Passant certifiés4#   (réseau officiel de la Fédération des Agricotours du Québec — marques5#   déposées « Gîte du Passant », « Auberge du Passant », « Maison de6#   Campagne »). L'ancien giteetaubergedupassant.com ne répond plus : le7#   répertoire vit maintenant sur membres.terroiretsaveurs.com (plateforme8#   Yapla/MemboGo de Terroir et Saveurs du Québec).9#10# Méthode (annuaire Yapla, aucun anti-bot) :11#   1. LISTE : POST /fr/repertoire/pagination/pageNumber/1 avec12#      categorie_detablissement[]=36680 (« Hébergement ») → le script13#      memboGo.directory.scroll.list["organization"] contient TOUS les ids de14#      membres (une vingtaine). Le même appel filtré par regionadministrative15#      (19 régions touristiques) donne la région de chaque membre.16#   2. FICHE (cache self.detail, rafraîchie chaque mois) :17#      /fr/repertoire/detailorganization/id/<id> — HTML serveur : nom (h1),18#      description, « Types d'hébergement » (Gîte/Auberge du Passant, Maison19#      de Campagne…), numéro CITQ (#attestation), classification (soleils),20#      adresse postale (spans), téléphone, site web, photos du carrousel,21#      coordonnées lat/lng extraites de l'iframe Google Maps (paramètres22#      !2d<lng>!3d<lat> de l'embed — présent sur toutes les fiches),23#      chambres/capacité glanées dans la description quand mentionnées.24#   Pas de prix affiché (les tarifs sont chez chaque établissement).25# -----------------------------------------------------------------------------26from __future__ import annotations2728import html as _html29import json30import os31import re32import time3334from ..schema import StListing35from .base import StConnector3637BASE = "https://membres.terroiretsaveurs.com"38PAGINATION = BASE + "/fr/repertoire/pagination/pageNumber/1"39FICHE = BASE + "/fr/repertoire/detailorganization/id/{id}"4041CAT_HEBERGEMENT = "36680"          # option « Hébergement » du formulaire4243# valeur de l'option regionadministrative → région touristique canonique44_REGIONS = {45    "316581": "Abitibi-Témiscamingue",46    "316582": "Bas-Saint-Laurent",47    "316584": "Cantons-de-l'Est",48    "316585": "Centre-du-Québec",49    "316586": "Charlevoix",50    "316583": "Chaudière-Appalaches",51    "316587": "Côte-Nord",52    "591978": "Eeyou Istchee Baie-James",53    "316589": "Gaspésie",54    "316590": "Îles-de-la-Madeleine",55    "316588": "Lanaudière",56    "316591": "Laurentides",57    "316592": "Laval",58    "316593": "Mauricie",59    "316594": "Montérégie",60    "316595": "Outaouais",61    "316596": "Montréal",62    "316597": "Québec",63    "316598": "Saguenay–Lac-Saint-Jean",64}6566# « Types d'hébergement » de la fiche → type canonique Lou-Ka67_TYPES = [68    ("gite du passant", "Gîte"), ("gite", "Gîte"),69    ("auberge du passant", "Auberge"), ("auberge", "Auberge"),70    ("maison de campagne", "Maison"), ("maison de ville", "Maison"),71    ("chalet", "Chalet"),72]7374_TAG_RE = re.compile(r"<[^>]+>")75# iframe Google Maps : …/maps/embed?pb=…!2d<longitude>!3d<latitude>!…76_GMAP_RE = re.compile(r"google\.com/maps/embed\?pb=[^\"']*"77                      r"!2d(-?\d+\.\d+)!3d(-?\d+\.\d+)")78_CAP_RE = re.compile(r"(\d{1,2})\s*personnes")79_CHAMBRES_RE = re.compile(r"(\d{1,2})\s*chambres")808182def _text(fragment: str) -> str:83    return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()848586class GitesPassant(StConnector):87    source_id = "gites_passant"8889    # -- liste (ids de membres) --------------------------------------------90    def _ids(self, region_value: str = "") -> list[str]:91        data = {"activetab": "organization", "search_type": "advanced",92                "categorie_detablissement[]": CAT_HEBERGEMENT}93        if region_value:94            data["regionadministrative"] = region_value95        resp = self.post(PAGINATION, data=data,96                         headers={"X-Requested-With": "XMLHttpRequest"})97        m = re.search(r'scroll\.list\["organization"\]\s*=\s*(\[.*?\]);',98                      resp.text, re.S)99        if not m:100            return []101        try:102            groups = json.loads(m.group(1))103        except ValueError:104            return []105        out: list[str] = []106        for g in groups:107            out.extend(str(i) for i in (g or {}).get("items") or [])108        return out109110    # -- fiche ---------------------------------------------------------------111    def _fiche(self, member_id: str) -> dict:112        brut = self.get(FICHE.format(id=member_id)).text113        # les fiches truffent le HTML de <script> inline (carrousel, vidéo…)114        h = re.sub(r"(?s)<script[^>]*>.*?</script>", " ", brut)115        d: dict = {}116117        h1s = re.findall(r"<h1[^>]*>(.*?)</h1>", h, re.S)118        titres = [_text(x) for x in h1s if _text(x) and _text(x) != "Répertoire"]119        if titres:120            d["title"] = titres[-1]121122        m = re.search(r"(?s)<h2>\s*Description\s*</h2>(.*?)"123                      r'<(?:h2|div class="carousel|/section)', h)124        if m:125            d["description"] = _text(m.group(1))[:5000]126127        m = re.search(r'(?s)id="hebergement"[^>]*>(.*?)</div>', h)128        if m:129            d["types"] = _text(m.group(1)).replace("Types d'hébergement :",130                                                   "").strip()131        m = re.search(r'(?s)id="attestation"[^>]*>.*?<p>\s*(\d{4,8})\s*</p>', h)132        if m:133            d["citq"] = m.group(1)134        m = re.search(r'(?s)id="classification"[^>]*>.*?<p[^>]*>(.*?)</p>', h)135        if m:136            classement = _text(m.group(1))137            if classement not in ("", "0"):138                d["classification"] = classement139140        # adresse : <p><span>rue</span><br/><span>ville</span> <span>(Québec)…141        m = re.search(r"(?s)Coordonnées</h3>(.*?)</p>", h)142        if m:143            spans = [_text(s) for s in144                     re.findall(r"<span[^>]*>(.*?)</span>", m.group(1))]145            spans = [s for s in spans if s and not s.startswith("Site Web")]146            if spans:147                d["street"] = spans[0]148            if len(spans) > 1:149                d["city"] = spans[1]150            for s in spans[2:]:151                if re.match(r"^[A-Z]\d[A-Z]\s?\d[A-Z]\d$", s):152                    d["postal"] = s153            mm = re.search(r"Téléphone\s*:\s*([\d ().+-]{7,20})", m.group(1))154            if mm:155                d["phone"] = mm.group(1).strip()156        m = re.search(r'id="site"[^>]*>.*?href="([^"]+)"', h, re.S)157        if m:158            d["website"] = m.group(1)159160        # géolocalisation : centre de l'iframe Google Maps (brut : l'iframe161        # est parfois injectée par un <script> retiré de `h`)162        m = _GMAP_RE.search(brut)163        if m:164            d["lng"], d["lat"] = float(m.group(1)), float(m.group(2))165166        # photos : le carrousel de la fiche seulement (ailleurs sur la page,167        # les « Suggestions d'articles » ont aussi des images membres)168        logo = ""169        m = re.search(r'(?s)id="logo">\s*<img[^>]+src="([^"]+)"', brut)170        if m:171            logo = m.group(1)172        imgs: list[str] = []173        debut = brut.find('id="myCarousel"')174        if debut >= 0:175            fin = brut.find("carousel-control", debut)176            zone = brut[debut:fin if fin > debut else debut + 40000]177            for u in re.findall(r'(https://cdn\.ca\.yapla\.com/[^\s"\'{}]*'178                                r'member/organization/[^\s"\'{}]+'179                                r'\.(?:jpe?g|png|webp))', zone):180                if u != logo and u not in imgs:181                    imgs.append(u)182        if imgs:183            d["images"] = imgs[:15]184        return d185186    # -- contrat ------------------------------------------------------------187    def fetch(self) -> list[StListing]:188        limit = int(os.environ.get("LOUKA_GITES_LIMIT", "0") or 0)189190        ids = self._ids()191        regions: dict[str, str] = {}192        for value, canon in _REGIONS.items():193            for mid in self._ids(value):194                regions.setdefault(mid, canon)195            if limit and len(regions) >= len(ids):196                break197198        listings: list[StListing] = []199        for mid in ids:200            # fiche rafraîchie une fois par mois (pas de lastmod côté liste) ;201            # v2 : parseur géo (iframe Google Maps) ajouté 2026-08-25202            det = self.detail(mid, time.strftime("%Y-%m") + "-v2",203                              lambda i=mid: self._fiche(i))204            if not det.get("title"):205                continue206207            types = det.get("types") or ""208            tl = types.lower()209            ptype = next((canon for needle, canon in _TYPES if needle in tl),210                         "Gîte")211212            details = {k: v for k, v in {213                "types_hebergement": types,214                "classification_citq": det.get("classification"),215                "telephone": det.get("phone"),216                "site_web": det.get("website"),217                "reseau": "Gîtes et Auberges du Passant certifiés "218                          "(Terroir et Saveurs du Québec)",219            }.items() if v}220221            # capacité / chambres : mentions dans la description (rare)222            desc = det.get("description") or ""223            caps = [int(x) for x in _CAP_RE.findall(desc) if 1 <= int(x) <= 40]224            rooms = [int(x) for x in _CHAMBRES_RE.findall(desc)225                     if 1 <= int(x) <= 30]226227            street = det.get("street") or ""228            city = det.get("city") or ""229            listings.append(StListing(230                source=self.source_id,231                external_id=mid,232                url=FICHE.format(id=mid),233                title=det["title"],234                property_type=ptype,235                address=", ".join(p for p in (street, city) if p),236                city=city,237                region=regions.get(mid, ""),238                capacity=float(max(caps)) if caps else None,239                bedrooms=float(max(rooms)) if rooms else None,240                citq=det.get("citq") or "",241                description=desc,242                details=details,243                images=det.get("images") or [],244                lat=det.get("lat"),245                lng=det.get("lng"),246            ))247            if limit and len(listings) >= limit:248                break249        return listings250