spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/ledomaine.py : connecteur Les Habitations Le Domaine5# (ledomaine.ca — grand ensemble locatif du quartier Mercier,6# arrondissement Mercier–Hochelaga-Maisonneuve, Montréal).7# Site WordPress rendu serveur : une page par typologie8# (/appartement/appartement-3-et-demi/, etc.) avec prix « à partir de »,9# description, galerie photos et plan. Une annonce par typologie.10# Les alias (ex. /appartement-2/) sont dédupliqués via <link rel=canonical>.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, normalize_unit_type, parse_price19from .base import BaseConnector2021BASE = "https://www.ledomaine.ca"2223ADDRESS = "2990, Avenue de Granby, Montréal"24SECTOR = "Mercier"25CITY = "Montréal"2627_APT_LINK_RE = re.compile(r"https?://www\.ledomaine\.ca/appartement/([a-z0-9-]+)/?")28_IMG_HREF_RE = re.compile(29 r'href="(https://www\.ledomaine\.ca/wp-content/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"',30 re.I)31_PRICE_RE = re.compile(r"[àa] partir de\s*([\d\s ]+)\s*\$", re.I)323334class LeDomaineConnector(BaseConnector):35 source_id = "ledomaine"36 request_delay = 0.637 max_pages = 12 # garde-fou de crawl3839 def fetch(self) -> list[Listing]:40 listings: dict[str, Listing] = {}41 try:42 home = self.get(BASE + "/").text43 except Exception:44 return []4546 slugs = list(dict.fromkeys(_APT_LINK_RE.findall(home)))47 for slug in slugs[: self.max_pages]:48 try:49 lst = self._parse_page(slug)50 except Exception:51 continue52 if lst and lst.external_id not in listings:53 listings[lst.external_id] = lst54 return list(listings.values())5556 def _parse_page(self, slug: str) -> Listing | None:57 url = f"{BASE}/appartement/{slug}/"58 html = self.get(url).text59 soup = BeautifulSoup(html, "html.parser")6061 # Déduplication des alias (/appartement-2/ -> /appartement-3-et-demi/)62 canon = soup.find("link", rel="canonical")63 if canon and canon.get("href"):64 m = _APT_LINK_RE.search(canon["href"])65 if m:66 slug = m.group(1)67 url = f"{BASE}/appartement/{slug}/"6869 h1 = soup.find("h1")70 title = h1.get_text(" ", strip=True) if h1 else slug.replace("-", " ")7172 # « Appartement 4 et demi sous-sol » -> 4½ (mention conservée au titre)73 tm = re.search(r"(\d)\s*et\s*demi", title, re.I)74 unit_type = f"{tm.group(1)}½" if tm else normalize_unit_type(title)7576 # Prix « à partir de NNN $ par mois » (meta description ou corps)77 price = None78 price_label = ""79 meta = soup.find("meta", attrs={"name": "description"})80 sources = [meta.get("content", "") if meta else "",81 soup.get_text(" ", strip=True)]82 for txt in sources:83 m = _PRICE_RE.search(txt or "")84 if m:85 amount = re.sub(r"[\s ]", "", m.group(1))86 price_label = f"À partir de {amount} $/mois"87 price = parse_price(f"{amount}$")88 break8990 # Description : bloc .text-wrapper sous le h2 « Description de91 # l'appartement » (contient inclusions, balcon, politique animaux…)92 description = ""93 for h2 in soup.find_all("h2"):94 if "description" in h2.get_text(" ", strip=True).lower():95 wrapper = h2.find_parent(class_="text-wrapper") or h2.parent96 parts = []97 for p in wrapper.find_all("p"):98 t = " ".join(p.get_text(" ", strip=True).split())99 if t and not t.lower().startswith("consulter"):100 parts.append(t)101 description = " ".join(parts).strip()[:600]102 break103 if not description: # repli : ancien découpage textuel104 body_txt = soup.get_text("|", strip=True)105 dm = re.search(106 r"Description\|de l'appartement\|(.{20,900}?)\|Consulter",107 body_txt, re.S)108 if dm:109 description = re.sub(r"\s*\|\s*", " ", dm.group(1))110 description = re.sub(r"\s+", " ", description).strip()[:600]111112 # Toutes les images (galerie + plan) de la page113 images = [u for u in dict.fromkeys(_IMG_HREF_RE.findall(html))114 if not re.search(r"logo|icon|favicon", u, re.I)]115 if not images:116 return None117118 return Listing(119 source=self.source_id,120 external_id=slug,121 url=url,122 title=f"{title} — Les Habitations Le Domaine",123 address=ADDRESS,124 sector=SECTOR,125 city=CITY,126 unit_type=unit_type,127 price=price,128 price_label=price_label,129 availability="",130 description=description,131 amenities=[],132 images=images,133 )134