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/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