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/panoraloges.py : Panora Loges Fluviales (panoraloges.com) —4# 10 loges (mini-chalets) en bord de mer à Sainte-Anne-des-Monts (Gaspésie),5# déclinées en DEUX gammes identiques à l'intérieur d'une même gamme :6# loges simples (nos 8-10, 2 pers.) et loges doubles (nos 1-7, 4 pers.).7#8# Méthode : site Squarespace ; l'API ?format=json de la page /fr/loges est9# vide (items=0) → on parse les DEUX pages de gamme statiques10# /fr/logessimples et /fr/logesdoubles. Chaque page expose en <h2-4> :11# prix (« 175 $ » sous « À partir de »), « N invités », « N chambres »,12# « N lits Queen », « 1 salle de bain », puis la liste des commodités13# jusqu'à « Stationnements ». 2 fiches « gamme » (multi_unit) plutôt que14# 10 fiches par unité artificiellement identiques — le site lui-même15# laisse le libre choix de la loge à l'intérieur d'une gamme.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import html as _html20import re2122from ..schema import StListing23from .base import StConnector2425SITE = "https://www.panoraloges.com"2627_PAGES = [28 # (external_id, chemin, titre, unités)29 ("loges-doubles", "/fr/logesdoubles", "Loges doubles — Panora Loges "30 "Fluviales", "Loges 1 à 7"),31 ("loges-simples", "/fr/logessimples", "Loges simples — Panora Loges "32 "Fluviales", "Loges 8 à 10"),33]3435_TAG_RE = re.compile(r"<[^>]+>")3637# images de gabarit à exclure de la galerie38_IMG_EXCLUDE = ("favicon", "logo", "icon", "texture", "panora_ecran",39 "Fond+de+page", "plan")404142def _text(fragment: str) -> str:43 return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()444546class PanoraLoges(StConnector):47 source_id = "panoraloges"48 request_delay = 1.04950 def _parse(self, path: str) -> dict:51 h = self.get(SITE + path).text52 d: dict = {}5354 heads = [t for t in (_text(x) for x in re.findall(55 r"(?s)<h[1-4][^>]*>(.*?)</h[1-4]>", h)) if t]5657 m = re.search(r"(\d+)\s*\$", " ".join(heads))58 if m:59 d["price_night"] = float(m.group(1))6061 # commodités : entêtes entre « N invités » et « Stationnements »62 start = next((i for i, t in enumerate(heads)63 if re.fullmatch(r"\d+\s+invités?", t)), None)64 end = next((i for i, t in enumerate(heads)65 if t.lower().startswith("stationnement")), None)66 amen: list[str] = []67 if start is not None and end is not None and start < end:68 amen = heads[start:end + 1]69 blob = " | ".join(amen)70 for pat, key in ((r"(\d+)\s+invités?", "capacity"),71 (r"(\d+)\s+chambres?", "bedrooms"),72 (r"(\d+)\s+lits?\s+Queen", "beds"),73 (r"(\d+)\s+salles?\s+de\s+bain", "bathrooms")):74 m = re.search(pat, blob, re.I)75 if m:76 d[key] = float(m.group(1))77 d["amenities"] = [a for a in amen78 if not re.fullmatch(r"\d+\s+(invités?|chambres?"79 r"|salles?\s+de\s+bain)", a)]8081 # description : sous-titre de gamme + infos pratiques (les <p> du82 # gabarit Squarespace contiennent beaucoup de CSS → filtrés)83 paras = [_text(p) for p in re.findall(r"(?s)<p[^>]*>(.*?)</p>", h)]84 keep = [p for p in paras85 if 60 < len(p) < 600 and "{" not in p86 and not p.startswith(("FR:", "0 "))87 and "Infolettre" not in p and "418-" not in p]88 if keep:89 d["description"] = "\n".join(keep[:6])[:3000]9091 imgs: list[str] = []92 for u in re.findall(r"https://images\.squarespace-cdn\.com/[^\"\s?]+",93 h):94 if any(x.lower() in u.lower() for x in _IMG_EXCLUDE):95 continue96 u = u.rstrip("\\,&")97 if not re.search(r"\.(?:jpe?g|png|webp)$", u, re.I):98 continue99 if u not in imgs:100 imgs.append(u)101 if len(imgs) >= 15:102 break103 d["images"] = imgs104 return d105106 # -- contrat --------------------------------------------------------------107 def fetch(self) -> list[StListing]:108 listings: list[StListing] = []109 for ext_id, path, title, units in _PAGES:110 d = self._parse(path)111 if not d.get("price_night") and not d.get("capacity"):112 continue113 price = d.get("price_night")114 listings.append(StListing(115 source=self.source_id,116 external_id=ext_id,117 url=SITE + path,118 title=title,119 property_type="Chalet",120 address="610 boul. Sainte-Anne E",121 city="Sainte-Anne-des-Monts",122 region="Gaspésie",123 price_night=price,124 price_label=f"À partir de {price:g} $ / nuit" if price else "",125 capacity=d.get("capacity"),126 bedrooms=d.get("bedrooms"),127 beds=d.get("beds"),128 bathrooms=d.get("bathrooms"),129 description=d.get("description") or "",130 amenities=d.get("amenities") or [],131 details={"multi_unit": True, "units": units,132 "domain": "Panora Loges Fluviales"},133 images=d.get("images") or [],134 ))135 return listings136