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/pourvoiries.py : Fédération des pourvoiries du Québec4# (pourvoiries.com) — ~340 pourvoiries avec hébergement (chalets, camps,5# pavillons, prêt-à-camper) partout en région.6#7# Méthode (TYPO3, aucun anti-bot) :8# 1. sitemap officiel des établissements (/sitemap/outfitters/sitemap.xml)9# → inventaire complet des fiches /pourvoiries/<slug>-<zone>-<permis> ;10# 2. fiche établissement (cache self.detail, clé mensuelle) : nom, ville +11# région (bandeau), description, coordonnées GPS (« Latitude : 48.805 »),12# no d'établissement CITQ, période d'ouverture, type de restauration,13# unités d'hébergement de l'onglet Hébergements (Pavillon / Chalet /14# Camp / Prêt-à-camper… avec capacité et chambres), photos du carrousel ;15# 3. prix : les fiches n'affichent pas de tarif d'hébergement ; le sitemap16# des forfaits (/sitemap/packages/sitemap.xml, slug préfixé du no de17# permis) donne un prix « par personne / nuit » pour ~80 pourvoiries →18# price_label du forfait le plus bas (à défaut d'un vrai prix/nuit).19#20# Une annonce = un établissement (les unités individuelles ne sont pas21# réservables en ligne — la liste des unités va dans details["unites"]).22# Seules les fiches avec au moins une unité d'hébergement sont retenues.23# Réglage env : LOUKA_POURVOIRIES_LIMIT (nb max de fiches, 0 = tout).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re29import sys30import time3132from ..schema import StListing, normalize_region33from .base import StConnector3435BASE = "https://www.pourvoiries.com"36SITEMAP_FICHES = f"{BASE}/sitemap/outfitters/sitemap.xml"37SITEMAP_FORFAITS = f"{BASE}/sitemap/packages/sitemap.xml"38PREFIX_FICHE = f"{BASE}/pourvoiries/"39PREFIX_FORFAIT = f"{BASE}/forfaits/"4041_LOC_RE = re.compile(r"<loc>\s*(https?://[^<\s]+)")42_PERMIS_RE = re.compile(r"(\d{2}-\d{3})$") # fin du slug établissement43_FORFAIT_PERMIS_RE = re.compile(r"^(\d{2}-\d{3})-") # début du slug forfait44_LAT_RE = re.compile(r"Latitude\s*:\s*(-?\d+\.\d+)")45_LNG_RE = re.compile(r"Longitude\s*:\s*(-?\d+\.\d+)")46_CAP_RE = re.compile(r"Pour\s+(\d+)\s+personne", re.I)47_CH_RE = re.compile(r"(\d+)\s+chambre", re.I)48_PRIX_FORFAIT_RE = re.compile(49 r'<strong class="price">\s*([\d\s,. ]+)\s*\$', re.S)5051# Régions FPQ → région touristique canonique (le reste passe par52# normalize_region : Mauricie, Outaouais, Côte-Nord…)53_REGIONS_FPQ = {54 "gaspésie et îles-de-la-madeleine": "Gaspésie",55 "gaspesie et iles-de-la-madeleine": "Gaspésie",56 "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean",57 "nord-du-québec": "Nord-du-Québec",58 "baie-james": "Eeyou Istchee Baie-James",59}6061# Titre de section d'hébergement → type canonique Lou-Ka62_TYPE_UNITE = {63 "chalet": "Chalet", "pavillon": "Auberge", "auberge": "Auberge",64 "camp": "Refuge", "refuge": "Refuge", "yourte": "Yourte",65 "dôme": "Dôme", "tente": "Prêt-à-camper",66 "prêt-à-camper": "Prêt-à-camper", "camping": "Camping",67 "chambre": "Chambre", "maison": "Maison", "condo": "Condo",68}697071def _property_type(types_unites: list[str]) -> str:72 """Type dominant de l'établissement — le chalet prime (offre principale)."""73 canon = [_TYPE_UNITE.get(t.strip().lower(), "") for t in types_unites]74 for pref in ("Chalet", "Auberge", "Yourte", "Dôme", "Prêt-à-camper",75 "Refuge", "Maison", "Condo", "Chambre", "Camping"):76 if pref in canon:77 return pref78 return "Chalet"798081class Pourvoiries(StConnector):82 source_id = "pourvoiries"83 request_delay = 0.88485 # -- fiche établissement --------------------------------------------------86 def _fetch_fiche(self, slug: str) -> dict:87 from bs4 import BeautifulSoup88 html = self.get(PREFIX_FICHE + slug).text89 soup = BeautifulSoup(html, "html.parser")90 d: dict = {}9192 h1 = soup.select_one("h1.page-title")93 if not h1:94 return {}95 d["nom"] = h1.get_text(" ", strip=True)9697 # bandeau : « Rivière-Bonjour, Gaspésie et Îles-de-la-Madeleine »98 banner = soup.select_one(".banner-single .region")99 if banner:100 loc = banner.get_text(" ", strip=True)101 ville, _, region = loc.partition(", ")102 d["ville"], d["region"] = ville.strip(), region.strip()103104 # description (premier bloc sous le h2 « Description »)105 for h2 in soup.find_all("h2"):106 if h2.get_text(strip=True).lower() == "description":107 paras = [p.get_text(" ", strip=True)108 for p in h2.find_all_next("p", limit=4)]109 d["description"] = "\n".join(x for x in paras if x)[:2500]110 break111112 # onglet Informations : paires h3 → p113 infos: dict[str, str] = {}114 for h3 in soup.find_all("h3"):115 p = h3.find_next_sibling("p")116 if p is not None:117 infos[h3.get_text(" ", strip=True).lower()] = \118 p.get_text(" ", strip=True)119 for label, key in (("numéro d'établissement", "citq"),120 ("période d'ouverture", "ouverture"),121 ("type de restauration", "restauration"),122 ("type de pourvoirie", "type_pourvoirie"),123 ("langue de service", "langues")):124 for k, v in infos.items():125 if k.startswith(label):126 d[key] = v127 break128129 m = _LAT_RE.search(html)130 if m:131 d["lat"] = float(m.group(1))132 m = _LNG_RE.search(html)133 if m:134 d["lng"] = float(m.group(1))135136 # onglet Hébergements : sections (h2.block-title) → cartes d'unités137 unites: list[dict] = []138 heb = soup.find(id="hebergements")139 if heb is not None:140 for bloc in heb.select(".block-slides"):141 t = bloc.select_one("h2.block-title")142 type_u = t.get_text(" ", strip=True) if t else ""143 for card in bloc.select(".card"):144 titre = card.select_one(".card-title")145 if titre is None:146 continue147 txt = card.get_text(" ", strip=True)148 cap = _CAP_RE.search(txt)149 ch = _CH_RE.search(txt)150 unites.append({151 "type": type_u,152 "nom": titre.get_text(" ", strip=True),153 "capacite": int(cap.group(1)) if cap else None,154 "chambres": int(ch.group(1)) if ch else None,155 "etoiles": len(card.select(".card-icons-stars "156 ".icon-star")) or None,157 })158 d["unites"] = unites159160 # photos du carrousel principal161 imgs: list[str] = []162 slider = soup.select_one(".block-slider-img")163 if slider is not None:164 for img in slider.find_all("img"):165 u = img.get("data-src") or img.get("src") or ""166 if u.startswith("/"):167 u = BASE + u168 if u.startswith("https://") and u not in imgs:169 imgs.append(u)170 d["images"] = imgs[:12]171 return d172173 # -- page forfait (prix « par personne / nuit ») ---------------------------174 def _fetch_forfait(self, slug: str) -> dict:175 html = self.get(PREFIX_FORFAIT + slug).text176 m = _PRIX_FORFAIT_RE.search(html)177 if not m:178 return {}179 try:180 prix = float(re.sub(r"[\s ]", "", m.group(1)).replace(",", "."))181 except ValueError:182 return {}183 unite = ""184 mm = re.search(r'class="card-pricing">.*?<p>([^<]+)</p>', html, re.S)185 if mm:186 unite = mm.group(1).strip()187 return {"prix": prix, "unite": unite}188189 # -- inventaire ------------------------------------------------------------190 def fetch(self) -> list[StListing]:191 limit = int(os.environ.get("LOUKA_POURVOIRIES_LIMIT", "0") or 0)192 month = time.strftime("%Y-%m") # re-visite mensuelle des fiches193194 xml = self.get(SITEMAP_FICHES).text195 slugs = sorted({loc[len(PREFIX_FICHE):].strip("/")196 for loc in _LOC_RE.findall(xml)197 if loc.startswith(PREFIX_FICHE)})198199 # forfaits groupés par no de permis (slug « 01-501-… »)200 forfaits: dict[str, list[str]] = {}201 try:202 xmlf = self.get(SITEMAP_FORFAITS).text203 for loc in _LOC_RE.findall(xmlf):204 if not loc.startswith(PREFIX_FORFAIT):205 continue206 fslug = loc[len(PREFIX_FORFAIT):].strip("/")207 m = _FORFAIT_PERMIS_RE.match(fslug)208 if m:209 forfaits.setdefault(m.group(1), []).append(fslug)210 except Exception as exc: # noqa: BLE001 — les forfaits sont optionnels211 print(f"[pourvoiries] sitemap forfaits : {exc}", file=sys.stderr)212213 listings: list[StListing] = []214 for slug in slugs:215 try:216 d = self.detail(slug, month, lambda s=slug: self._fetch_fiche(s))217 except Exception as exc: # noqa: BLE001218 print(f"[pourvoiries] fiche {slug} : {exc}", file=sys.stderr)219 continue220 unites = d.get("unites") or []221 if not d.get("nom") or not unites: # pas d'hébergement → hors sujet222 continue223224 # prix : forfait le moins cher de la pourvoirie (par pers. / nuit)225 price_label = ""226 m = _PERMIS_RE.search(slug)227 permis = m.group(1) if m else ""228 best: dict = {}229 for fslug in forfaits.get(permis, []):230 try:231 f = self.detail(f"forfait:{fslug}", month,232 lambda s=fslug: self._fetch_forfait(s))233 except Exception as exc: # noqa: BLE001234 print(f"[pourvoiries] forfait {fslug} : {exc}",235 file=sys.stderr)236 continue237 # seuls les forfaits tarifés à la nuit (ou au jour) sont238 # comparables — un prix « par personne / séjour » fausserait239 # le prix/nuit dérivé par finalize(). Attention : « séjour »240 # contient « jour », d'où l'exclusion explicite.241 unite = f.get("unite", "").lower()242 if "jour" not in unite and "nuit" not in unite:243 continue244 if "séjour" in unite or "sejour" in unite:245 continue246 if f.get("prix") and (not best or f["prix"] < best["prix"]):247 best = f248 if best:249 unite = best.get("unite") or "par personne / nuit"250 price_label = (f"forfait à partir de {best['prix']:.0f} $ "251 f"{unite}")252253 region = d.get("region", "")254 region = _REGIONS_FPQ.get(region.lower(), normalize_region(region))255256 caps = [u["capacite"] for u in unites if u.get("capacite")]257 chs = [u["chambres"] for u in unites if u.get("chambres")]258 details = {k: v for k, v in {259 "permis": permis,260 "nb_unites": len(unites),261 "unites": unites[:40],262 "ouverture": d.get("ouverture", ""),263 "restauration": d.get("restauration", ""),264 "type_pourvoirie": d.get("type_pourvoirie", ""),265 "langues": d.get("langues", ""),266 }.items() if v}267268 listings.append(StListing(269 source=self.source_id,270 external_id=slug,271 url=PREFIX_FICHE + slug,272 title=d["nom"],273 property_type=_property_type([u["type"] for u in unites]),274 city=d.get("ville", ""),275 region=region,276 price_label=price_label,277 capacity=float(max(caps)) if caps else None,278 bedrooms=float(max(chs)) if chs else None,279 citq=d.get("citq", ""),280 description=d.get("description", ""),281 details=details,282 images=d.get("images") or [],283 lat=d.get("lat"),284 lng=d.get("lng"),285 ))286 if limit and len(listings) >= limit:287 break288 return listings289