SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
4.7 KB · 128 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/plantation.py : connecteur Domaine de la Plantation5#   (domainedelaplantation.ca) — Shawinigan, secteur Saint-Georges-de-Champlain6#   (entre Grand-Mère et le Lac-à-la-Tortue). Nouveau quartier de 4½ neufs de7#   Construction Michael Massicotte. Site vitrine Pixpa page unique rendu8#   serveur : section « Prix » avec paires <h3> typologie (« Demi sous-sol9#   4 ½ » / « Étages 4 ½ ») + <h4> prix (« 1295$ par mois »), section10#   « Inclusions » en items, photos Pixpa en data-src (lazy-load).11#   Granularité = typologie (pas d'inventaire par unité publié).12#   external_id = typologie en slug — stable.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type, strip_accents21from .base import BaseConnector2223BASE = "https://www.domainedelaplantation.ca"24PAGE_URL = f"{BASE}/"2526ADDRESS = "Saint-Georges-de-Champlain, Shawinigan"2728PRICE_RE = re.compile(r"([\d\s  ]{3,7})\$\s*par\s*mois", re.I)29UNIT_RE = re.compile(r"(\d)\s*(?:½|1/2|&frac12;)")30IMG_RE = re.compile(31    r"https://px-web-images[\w.-]*\.pixpa\.com/[^\"'\s)]+", re.I)323334def _slug(text: str) -> str:35    s = strip_accents(text.lower())36    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")373839def _num(txt: str) -> float | None:40    n = re.sub(r"[\s  ]", "", txt or "")41    try:42        return float(n)43    except ValueError:44        return None454647class PlantationConnector(BaseConnector):48    source_id = "plantation"49    request_delay = 0.85051    def fetch(self) -> list[Listing]:52        html = self.get(PAGE_URL).text53        soup = BeautifulSoup(html, "html.parser")5455        # Photos (lazy-load Pixpa) — communes au projet56        images = [u for u in dict.fromkeys(57            m.group(0) for el in soup.select("[data-src]")58            for m in [IMG_RE.match(el["data-src"])] if m)][:12]5960        # Inclusions : items de la section « Inclusions »61        amenities: list[str] = []62        incl = soup.find("h2", string=re.compile(r"Inclusions", re.I))63        if incl:64            sec = incl.parent65            for _ in range(4):66                if sec is None:67                    break68                txt = sec.get_text("\n", strip=True)69                if len(txt) > 60:70                    break71                sec = sec.parent72            if sec is not None:73                for line in sec.get_text("\n", strip=True).split("\n"):74                    line = re.sub(r"\s+", " ", line).strip()75                    if line and not re.match(r"Inclusions", line, re.I) \76                            and len(line) < 60 and line not in amenities:77                        amenities.append(line)7879        # Accroche du projet (texte héro)80        blurb = ""81        h3 = soup.find("h3", string=re.compile(r"Grand-Mère|Lac-à-la-Tortue"))82        if h3:83            blurb = re.sub(r"\s+", " ", h3.get_text(" ", strip=True))[:600]8485        # Section « Prix » : paires h3 (typologie) + h4 (prix)86        listings: list[Listing] = []87        prix_h2 = soup.find("h2", string=re.compile(r"^\s*Prix\s*$", re.I))88        if not prix_h2:89            return listings90        sec = prix_h291        for _ in range(4):92            sec = sec.parent93            if sec is None:94                return listings95            if sec.find("h4"):96                break9798        current_label = ""99        for el in sec.find_all(["h3", "h4"]):100            text = re.sub(r"\s+", " ", el.get_text(" ", strip=True))101            if el.name == "h3":102                current_label = text103                continue104            pm = PRICE_RE.search(text)105            if not pm or not current_label:106                continue107            unit_type = ""108            um = UNIT_RE.search(current_label)109            if um:110                unit_type = normalize_unit_type(f"{um.group(1)} ½")111            listings.append(Listing(112                source=self.source_id,113                external_id=_slug(current_label),114                url=PAGE_URL,115                title=f"{current_label} — Domaine de la Plantation",116                address=ADDRESS,117                sector="Saint-Georges-de-Champlain",118                city="Shawinigan",119                unit_type=unit_type,120                price=_num(pm.group(1)),121                price_label=text,122                description=blurb,123                amenities=list(amenities),124                images=list(images),125            ))126            current_label = ""127        return listings128