# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/memoriachalets.py : Memoria Chalets (memoriachalets.com) # — gestionnaire de Magog, ~110 fiches FR (Cantons-de-l'Est surtout, # quelques Laurentides / Centre-du-Québec). # # Méthode : WordPress, CPT `cpt_chalets`, HTML serveur. Sitemap # /cpt_chalets-sitemap.xml (lastmod fiable ; doublons /en/ écartés) → # pages /locations//. La fiche est balisée par classes : # h2.location-title (« Le Hibou – Magog »), p.location-specs # (« 4 CHAMBRES - 1.5 SALLES DE BAIN - 8 PERSONNES »), p.location-adress # (adresse complète), p.starting-price (« À partir de 199,00$ par jour » — # une partie du parc est au mois/31 jours minimum : prix nuit alors absent, # la mention est conservée dans details), p.citq (« CITQ #296459 … » ou # « (Location minimum de 31 jours) ») et ul de .accomosdations-filters # (commodités, dont « Animaux interdit/admis »). Pas de géo dans le HTML. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ...normalize import strip_accents from ..schema import StListing, parse_price_night from .base import StConnector SITEMAP = "https://memoriachalets.com/cpt_chalets-sitemap.xml" _URL_DETAIL = re.compile( r"https://(?:www\.)?memoriachalets\.com/locations/([\w%-]+)/?$") _SPECS_RE = re.compile( r"([\d.,]+)\s*CHAMBRES?\s*[-·—]\s*([\d.,]+)\s*SALLES?\s+DE\s+BAIN" r"\s*[-·—]\s*([\d.,]+)\s*PERSONNES?", re.I) _CP_RE = re.compile(r"^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$") # code postal # mot-clé (titre) → type canonique ; défaut : Chalet _TYPE_HINTS = [("condo", "Condo"), ("appartement", "Appartement"), ("apt", "Appartement"), ("loft", "Loft"), ("studio", "Studio"), ("maison", "Maison")] # indices de région dans le texte (leur parc : Estrie surtout) _REGION_HINTS = [ ("estrie", "Cantons-de-l'Est"), ("cantons-de-l'est", "Cantons-de-l'Est"), ("laurentides", "Laurentides"), ("lanaudiere", "Lanaudière"), ("centre-du-quebec", "Centre-du-Québec"), ("mauricie", "Mauricie"), ("charlevoix", "Charlevoix"), ("monteregie", "Montérégie"), ] # villes connues du parc → région touristique _VILLE_REGION = { "magog": "Cantons-de-l'Est", "orford": "Cantons-de-l'Est", "eastman": "Cantons-de-l'Est", "austin": "Cantons-de-l'Est", "ayer's cliff": "Cantons-de-l'Est", "ayers cliff": "Cantons-de-l'Est", "north hatley": "Cantons-de-l'Est", "sherbrooke": "Cantons-de-l'Est", "sainte-catherine-de-hatley": "Cantons-de-l'Est", "canton d'orford": "Cantons-de-l'Est", "bromont": "Cantons-de-l'Est", "sutton": "Cantons-de-l'Est", "stukely-sud": "Cantons-de-l'Est", "bolton-est": "Cantons-de-l'Est", "potton": "Cantons-de-l'Est", "mansonville": "Cantons-de-l'Est", "coaticook": "Cantons-de-l'Est", "piopolis": "Cantons-de-l'Est", "lac-megantic": "Cantons-de-l'Est", } _RUE_RE = re.compile(r"^(rue|chemin|ch\.|avenue|av\.|route|rte|boulevard|" r"boul\.?|montee|impasse|allee|place)\b") def _num(raw: str) -> float | None: try: return float(raw.replace(",", ".")) except (TypeError, ValueError): return None class MemoriaChalets(StConnector): source_id = "memoriachalets" # -- inventaire (sitemap FR + lastmod) ----------------------------------- def _sitemap_urls(self) -> dict[str, tuple[str, str]]: """slug -> (url détail FR, lastmod).""" xml = self.get(SITEMAP).text urls: dict[str, tuple[str, str]] = {} for bloc in re.findall(r"(.*?)", xml, re.S): m = re.search(r"([^<]+)", bloc) if not m: continue loc = m.group(1).strip() mu = _URL_DETAIL.match(loc) if not mu or mu.group(1) == "locations": continue lastmod = re.search(r"([^<]+)", bloc) urls.setdefault(mu.group(1), (loc, lastmod.group(1) if lastmod else "")) return urls # -- page détail --------------------------------------------------------- def _detail(self, url: str) -> dict: html = self.get(url).text soup = BeautifulSoup(html, "html.parser") d: dict = {} h2 = soup.select_one("h2.location-title") or soup.find("h2") if h2 is not None: d["title"] = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) specs = soup.select_one("p.location-specs") if specs is not None: m = _SPECS_RE.search(specs.get_text(" ", strip=True)) if m: d["bedrooms"] = _num(m.group(1)) d["bathrooms"] = _num(m.group(2)) d["capacity"] = _num(m.group(3)) # « 207, Rue des Pruches, Magog, Québec, J1X 0M9, Canada » adresse = soup.select_one("p.location-adress") if adresse is not None: texte = re.sub(r"\s+", " ", adresse.get_text(" ", strip=True)) d["address"] = texte restants = [] for p in texte.split(","): # code postal parfois collé à la ville (« Bromont J2L 2C1 ») p = re.sub(r"\b[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d\b", "", p).strip() k = strip_accents(p).lower() if (not p or _CP_RE.match(p) or k in ("quebec", "qc", "canada") or re.match(r"^(appartement|app|unite)\b", k)): continue restants.append(p) if restants: ville = restants[-1] # adresse sans virgule (« Rue des Noyers Orford ») : # la ville est le dernier mot if _RUE_RE.match(strip_accents(ville).lower()): ville = ville.split()[-1] d["city"] = ville prix = soup.select_one("p.starting-price") if prix is not None: label = re.sub(r"\s+", " ", prix.get_text(" ", strip=True)) label = re.sub(r"^(À partir de\s+)+", "À partir de ", label, flags=re.I) d["price_label"] = label citq = soup.select_one("p.citq") if citq is not None: texte = citq.get_text(" ", strip=True) m = re.search(r"CITQ\s*#?\s*(\d{6})", texte) if m: d["citq"] = m.group(1) m = re.search(r"\(([^)]*[Ll]ocation minimum[^)]*)\)", texte) if m: d["location_minimum"] = m.group(1).strip() # commodités (dont le statut animaux) amen: list[str] = [] for span in soup.select(".accomosdations-filters li span"): t = re.sub(r"\s+", " ", span.get_text(" ", strip=True)) if t and t not in amen: amen.append(t) if amen: d["amenities"] = amen for a in amen: k = strip_accents(a).lower() if "animaux" in k or "chien" in k: d["pets"] = "non" if ("interdit" in k or "refus" in k or "non admis" in k) else "oui" # description : paragraphes de la 1re colonne de la fiche col = soup.select_one('[itemprop="articleBody"] div') if col is not None: morceaux = [] for p in col.find_all("p"): classes = " ".join(p.get("class") or []) if any(c in classes for c in ("location-specs", "location-adress", "starting-price", "citq")): continue t = p.get_text(" ", strip=True) if t: morceaux.append(re.sub(r"\s+", " ", t)) if morceaux: d["description"] = "\n".join(morceaux)[:5000] # photos (uploads, sans logo/icônes ; dédoublonnage sur le nom de # base sans suffixe de taille -WxH) imgs, vus = [], set() for u in re.findall(r'(https://(?:www\.)?memoriachalets\.com/' r'wp-content/uploads/[^"\'\s>]+\.(?:jpe?g|webp))', html): base = re.sub(r"-\d+x\d+(?=\.\w+$)", "", u) nom = strip_accents(base).lower() if base in vus or "logo" in nom or "icon" in nom: continue vus.add(base) imgs.append(re.sub(r"-\d+x\d+(?=\.\w+$)", "", u)) d["images"] = imgs[:20] return d # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: listings: list[StListing] = [] for slug, (url, lastmod) in self._sitemap_urls().items(): try: d = self.detail(slug, lastmod or "sans-lastmod", lambda u=url: self._detail(u)) except Exception: # une fiche cassée ≠ inventaire perdu d = {} if not d.get("title"): continue hay = strip_accents(d["title"]).lower() ptype = next((c for n, c in _TYPE_HINTS if n in hay), "Chalet") # région : ville connue, sinon indice textuel (« en Estrie »…) ville = d.get("city", "") region = _VILLE_REGION.get(strip_accents(ville).lower(), "") if not region: texte = strip_accents(" ".join( (d.get("address", ""), d.get("description", "")))).lower() region = next((r for n, r in _REGION_HINTS if n in texte), "") details = {} if d.get("location_minimum"): details["location_minimum"] = d["location_minimum"] listings.append(StListing( source=self.source_id, external_id=slug, # slug WP, stable url=url, title=d["title"], property_type=ptype, address=d.get("address", ""), city=ville, region=region, price_night=parse_price_night(d.get("price_label", "")), price_label=d.get("price_label", ""), capacity=d.get("capacity"), bedrooms=d.get("bedrooms"), bathrooms=d.get("bathrooms"), pets=d.get("pets"), citq=d.get("citq", ""), description=d.get("description", ""), amenities=d.get("amenities") or [], details=details, images=d.get("images") or [], )) return listings