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/memoriachalets.py : Memoria Chalets (memoriachalets.com)4# — gestionnaire de Magog, ~110 fiches FR (Cantons-de-l'Est surtout,5# quelques Laurentides / Centre-du-Québec).6#7# Méthode : WordPress, CPT `cpt_chalets`, HTML serveur. Sitemap8# /cpt_chalets-sitemap.xml (lastmod fiable ; doublons /en/ écartés) →9# pages /locations/<slug>/. La fiche est balisée par classes :10# h2.location-title (« Le Hibou – Magog »), p.location-specs11# (« 4 CHAMBRES - 1.5 SALLES DE BAIN - 8 PERSONNES »), p.location-adress12# (adresse complète), p.starting-price (« À partir de 199,00$ par jour » —13# une partie du parc est au mois/31 jours minimum : prix nuit alors absent,14# la mention est conservée dans details), p.citq (« CITQ #296459 … » ou15# « (Location minimum de 31 jours) ») et ul de .accomosdations-filters16# (commodités, dont « Animaux interdit/admis »). Pas de géo dans le HTML.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re2122from bs4 import BeautifulSoup2324from ...normalize import strip_accents25from ..schema import StListing, parse_price_night26from .base import StConnector2728SITEMAP = "https://memoriachalets.com/cpt_chalets-sitemap.xml"2930_URL_DETAIL = re.compile(31 r"https://(?:www\.)?memoriachalets\.com/locations/([\w%-]+)/?$")3233_SPECS_RE = re.compile(34 r"([\d.,]+)\s*CHAMBRES?\s*[-·—]\s*([\d.,]+)\s*SALLES?\s+DE\s+BAIN"35 r"\s*[-·—]\s*([\d.,]+)\s*PERSONNES?", re.I)3637_CP_RE = re.compile(r"^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$") # code postal3839# mot-clé (titre) → type canonique ; défaut : Chalet40_TYPE_HINTS = [("condo", "Condo"), ("appartement", "Appartement"),41 ("apt", "Appartement"), ("loft", "Loft"), ("studio", "Studio"),42 ("maison", "Maison")]4344# indices de région dans le texte (leur parc : Estrie surtout)45_REGION_HINTS = [46 ("estrie", "Cantons-de-l'Est"), ("cantons-de-l'est", "Cantons-de-l'Est"),47 ("laurentides", "Laurentides"), ("lanaudiere", "Lanaudière"),48 ("centre-du-quebec", "Centre-du-Québec"), ("mauricie", "Mauricie"),49 ("charlevoix", "Charlevoix"), ("monteregie", "Montérégie"),50]5152# villes connues du parc → région touristique53_VILLE_REGION = {54 "magog": "Cantons-de-l'Est", "orford": "Cantons-de-l'Est",55 "eastman": "Cantons-de-l'Est", "austin": "Cantons-de-l'Est",56 "ayer's cliff": "Cantons-de-l'Est", "ayers cliff": "Cantons-de-l'Est",57 "north hatley": "Cantons-de-l'Est", "sherbrooke": "Cantons-de-l'Est",58 "sainte-catherine-de-hatley": "Cantons-de-l'Est",59 "canton d'orford": "Cantons-de-l'Est", "bromont": "Cantons-de-l'Est",60 "sutton": "Cantons-de-l'Est", "stukely-sud": "Cantons-de-l'Est",61 "bolton-est": "Cantons-de-l'Est", "potton": "Cantons-de-l'Est",62 "mansonville": "Cantons-de-l'Est", "coaticook": "Cantons-de-l'Est",63 "piopolis": "Cantons-de-l'Est", "lac-megantic": "Cantons-de-l'Est",64}6566_RUE_RE = re.compile(r"^(rue|chemin|ch\.|avenue|av\.|route|rte|boulevard|"67 r"boul\.?|montee|impasse|allee|place)\b")686970def _num(raw: str) -> float | None:71 try:72 return float(raw.replace(",", "."))73 except (TypeError, ValueError):74 return None757677class MemoriaChalets(StConnector):78 source_id = "memoriachalets"7980 # -- inventaire (sitemap FR + lastmod) -----------------------------------81 def _sitemap_urls(self) -> dict[str, tuple[str, str]]:82 """slug -> (url détail FR, lastmod)."""83 xml = self.get(SITEMAP).text84 urls: dict[str, tuple[str, str]] = {}85 for bloc in re.findall(r"<url>(.*?)</url>", xml, re.S):86 m = re.search(r"<loc>([^<]+)</loc>", bloc)87 if not m:88 continue89 loc = m.group(1).strip()90 mu = _URL_DETAIL.match(loc)91 if not mu or mu.group(1) == "locations":92 continue93 lastmod = re.search(r"<lastmod>([^<]+)</lastmod>", bloc)94 urls.setdefault(mu.group(1),95 (loc, lastmod.group(1) if lastmod else ""))96 return urls9798 # -- page détail ---------------------------------------------------------99 def _detail(self, url: str) -> dict:100 html = self.get(url).text101 soup = BeautifulSoup(html, "html.parser")102 d: dict = {}103104 h2 = soup.select_one("h2.location-title") or soup.find("h2")105 if h2 is not None:106 d["title"] = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))107108 specs = soup.select_one("p.location-specs")109 if specs is not None:110 m = _SPECS_RE.search(specs.get_text(" ", strip=True))111 if m:112 d["bedrooms"] = _num(m.group(1))113 d["bathrooms"] = _num(m.group(2))114 d["capacity"] = _num(m.group(3))115116 # « 207, Rue des Pruches, Magog, Québec, J1X 0M9, Canada »117 adresse = soup.select_one("p.location-adress")118 if adresse is not None:119 texte = re.sub(r"\s+", " ", adresse.get_text(" ", strip=True))120 d["address"] = texte121 restants = []122 for p in texte.split(","):123 # code postal parfois collé à la ville (« Bromont J2L 2C1 »)124 p = re.sub(r"\b[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d\b", "",125 p).strip()126 k = strip_accents(p).lower()127 if (not p or _CP_RE.match(p) or k in ("quebec", "qc", "canada")128 or re.match(r"^(appartement|app|unite)\b", k)):129 continue130 restants.append(p)131 if restants:132 ville = restants[-1]133 # adresse sans virgule (« Rue des Noyers Orford ») :134 # la ville est le dernier mot135 if _RUE_RE.match(strip_accents(ville).lower()):136 ville = ville.split()[-1]137 d["city"] = ville138139 prix = soup.select_one("p.starting-price")140 if prix is not None:141 label = re.sub(r"\s+", " ", prix.get_text(" ", strip=True))142 label = re.sub(r"^(À partir de\s+)+", "À partir de ", label, flags=re.I)143 d["price_label"] = label144145 citq = soup.select_one("p.citq")146 if citq is not None:147 texte = citq.get_text(" ", strip=True)148 m = re.search(r"CITQ\s*#?\s*(\d{6})", texte)149 if m:150 d["citq"] = m.group(1)151 m = re.search(r"\(([^)]*[Ll]ocation minimum[^)]*)\)", texte)152 if m:153 d["location_minimum"] = m.group(1).strip()154155 # commodités (dont le statut animaux)156 amen: list[str] = []157 for span in soup.select(".accomosdations-filters li span"):158 t = re.sub(r"\s+", " ", span.get_text(" ", strip=True))159 if t and t not in amen:160 amen.append(t)161 if amen:162 d["amenities"] = amen163 for a in amen:164 k = strip_accents(a).lower()165 if "animaux" in k or "chien" in k:166 d["pets"] = "non" if ("interdit" in k or "refus" in k167 or "non admis" in k) else "oui"168169 # description : paragraphes de la 1re colonne de la fiche170 col = soup.select_one('[itemprop="articleBody"] div')171 if col is not None:172 morceaux = []173 for p in col.find_all("p"):174 classes = " ".join(p.get("class") or [])175 if any(c in classes for c in176 ("location-specs", "location-adress",177 "starting-price", "citq")):178 continue179 t = p.get_text(" ", strip=True)180 if t:181 morceaux.append(re.sub(r"\s+", " ", t))182 if morceaux:183 d["description"] = "\n".join(morceaux)[:5000]184185 # photos (uploads, sans logo/icônes ; dédoublonnage sur le nom de186 # base sans suffixe de taille -WxH)187 imgs, vus = [], set()188 for u in re.findall(r'(https://(?:www\.)?memoriachalets\.com/'189 r'wp-content/uploads/[^"\'\s>]+\.(?:jpe?g|webp))',190 html):191 base = re.sub(r"-\d+x\d+(?=\.\w+$)", "", u)192 nom = strip_accents(base).lower()193 if base in vus or "logo" in nom or "icon" in nom:194 continue195 vus.add(base)196 imgs.append(re.sub(r"-\d+x\d+(?=\.\w+$)", "", u))197 d["images"] = imgs[:20]198 return d199200 # -- contrat --------------------------------------------------------------201 def fetch(self) -> list[StListing]:202 listings: list[StListing] = []203 for slug, (url, lastmod) in self._sitemap_urls().items():204 try:205 d = self.detail(slug, lastmod or "sans-lastmod",206 lambda u=url: self._detail(u))207 except Exception: # une fiche cassée ≠ inventaire perdu208 d = {}209 if not d.get("title"):210 continue211212 hay = strip_accents(d["title"]).lower()213 ptype = next((c for n, c in _TYPE_HINTS if n in hay), "Chalet")214215 # région : ville connue, sinon indice textuel (« en Estrie »…)216 ville = d.get("city", "")217 region = _VILLE_REGION.get(strip_accents(ville).lower(), "")218 if not region:219 texte = strip_accents(" ".join(220 (d.get("address", ""), d.get("description", "")))).lower()221 region = next((r for n, r in _REGION_HINTS if n in texte), "")222223 details = {}224 if d.get("location_minimum"):225 details["location_minimum"] = d["location_minimum"]226227 listings.append(StListing(228 source=self.source_id,229 external_id=slug, # slug WP, stable230 url=url,231 title=d["title"],232 property_type=ptype,233 address=d.get("address", ""),234 city=ville,235 region=region,236 price_night=parse_price_night(d.get("price_label", "")),237 price_label=d.get("price_label", ""),238 capacity=d.get("capacity"),239 bedrooms=d.get("bedrooms"),240 bathrooms=d.get("bathrooms"),241 pets=d.get("pets"),242 citq=d.get("citq", ""),243 description=d.get("description", ""),244 amenities=d.get("amenities") or [],245 details=details,246 images=d.get("images") or [],247 ))248 return listings249