SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
5.2 KB · 134 lines python
Raw Blame History
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