# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/ledomaine.py : connecteur Les Habitations Le Domaine # (ledomaine.ca — grand ensemble locatif du quartier Mercier, # arrondissement Mercier–Hochelaga-Maisonneuve, Montréal). # Site WordPress rendu serveur : une page par typologie # (/appartement/appartement-3-et-demi/, etc.) avec prix « à partir de », # description, galerie photos et plan. Une annonce par typologie. # Les alias (ex. /appartement-2/) sont dédupliqués via . # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.ledomaine.ca" ADDRESS = "2990, Avenue de Granby, Montréal" SECTOR = "Mercier" CITY = "Montréal" _APT_LINK_RE = re.compile(r"https?://www\.ledomaine\.ca/appartement/([a-z0-9-]+)/?") _IMG_HREF_RE = re.compile( r'href="(https://www\.ledomaine\.ca/wp-content/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"', re.I) _PRICE_RE = re.compile(r"[àa] partir de\s*([\d\s ]+)\s*\$", re.I) class LeDomaineConnector(BaseConnector): source_id = "ledomaine" request_delay = 0.6 max_pages = 12 # garde-fou de crawl def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} try: home = self.get(BASE + "/").text except Exception: return [] slugs = list(dict.fromkeys(_APT_LINK_RE.findall(home))) for slug in slugs[: self.max_pages]: try: lst = self._parse_page(slug) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst return list(listings.values()) def _parse_page(self, slug: str) -> Listing | None: url = f"{BASE}/appartement/{slug}/" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") # Déduplication des alias (/appartement-2/ -> /appartement-3-et-demi/) canon = soup.find("link", rel="canonical") if canon and canon.get("href"): m = _APT_LINK_RE.search(canon["href"]) if m: slug = m.group(1) url = f"{BASE}/appartement/{slug}/" h1 = soup.find("h1") title = h1.get_text(" ", strip=True) if h1 else slug.replace("-", " ") # « Appartement 4 et demi sous-sol » -> 4½ (mention conservée au titre) tm = re.search(r"(\d)\s*et\s*demi", title, re.I) unit_type = f"{tm.group(1)}½" if tm else normalize_unit_type(title) # Prix « à partir de NNN $ par mois » (meta description ou corps) price = None price_label = "" meta = soup.find("meta", attrs={"name": "description"}) sources = [meta.get("content", "") if meta else "", soup.get_text(" ", strip=True)] for txt in sources: m = _PRICE_RE.search(txt or "") if m: amount = re.sub(r"[\s ]", "", m.group(1)) price_label = f"À partir de {amount} $/mois" price = parse_price(f"{amount}$") break # Description : bloc .text-wrapper sous le h2 « Description de # l'appartement » (contient inclusions, balcon, politique animaux…) description = "" for h2 in soup.find_all("h2"): if "description" in h2.get_text(" ", strip=True).lower(): wrapper = h2.find_parent(class_="text-wrapper") or h2.parent parts = [] for p in wrapper.find_all("p"): t = " ".join(p.get_text(" ", strip=True).split()) if t and not t.lower().startswith("consulter"): parts.append(t) description = " ".join(parts).strip()[:600] break if not description: # repli : ancien découpage textuel body_txt = soup.get_text("|", strip=True) dm = re.search( r"Description\|de l'appartement\|(.{20,900}?)\|Consulter", body_txt, re.S) if dm: description = re.sub(r"\s*\|\s*", " ", dm.group(1)) description = re.sub(r"\s+", " ", description).strip()[:600] # Toutes les images (galerie + plan) de la page images = [u for u in dict.fromkeys(_IMG_HREF_RE.findall(html)) if not re.search(r"logo|icon|favicon", u, re.I)] if not images: return None return Listing( source=self.source_id, external_id=slug, url=url, title=f"{title} — Les Habitations Le Domaine", address=ADDRESS, sector=SECTOR, city=CITY, unit_type=unit_type, price=price, price_label=price_label, availability="", description=description, amenities=[], images=images, )