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%
5.2 KB · 132 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/ocartier.py : connecteur OCartier 2 (ocartier.ca — TDR5#   Développements). Tour locative de 200 appartements au 30, rue St-Hubert6#   à Laval (métro Cartier, secteur Pont-Viau), loyer tout inclus7#   (électricité, chauffage, climatisation, eau, internet).8#   WordPress/Elementor rendu serveur : la page /plans/ affiche une9#   fourchette de prix par TYPOLOGIE (« Studios / 3 1/2 / 4 1/2 / 5 1/2 »,10#   « À partir de X $ jusqu'à Y $ »). Granularité TYPOLOGIE.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, normalize_unit_type19from .base import BaseConnector2021BASE = "https://ocartier.ca"22PAGE_URL = f"{BASE}/plans/"2324ADDRESS = "30, rue St-Hubert"25CITY = "Laval"26SECTOR = "Pont-Viau"2728BUILDING_AMENITIES = [29    "Électricité incluse", "Chauffage inclus", "Climatisation incluse",30    "Eau incluse", "Internet inclus",31]3233TYPE_RE = re.compile(r"^(?:Studios?|\d\s*(?:1/2|½))$", re.I)34RANGE_RE = re.compile(35    r"À partir de\s*([\d\s  ,]+?)\s*\$.*?jusqu[’']à\s*([\d\s  ,]+?)\s*\$",36    re.I | re.S)37BED_RE = re.compile(r"(\d)\s*chambres?", re.I)38IMG_RE = re.compile(39    r"https://ocartier\.ca/wp-content/uploads/[^\"'\s\\)]+?"40    r"\.(?:jpg|jpeg|png|webp)", re.I)414243def _num(txt: str) -> float | None:44    digits = re.sub(r"[^\d]", "", txt or "")45    return float(digits) if digits else None464748class OCartierConnector(BaseConnector):49    source_id = "ocartier"50    request_delay = 0.65152    def fetch(self) -> list[Listing]:53        listings: list[Listing] = []54        try:55            html = self.get(PAGE_URL).text56        except Exception:57            return listings58        soup = BeautifulSoup(html, "html.parser")5960        # photos : la page /plans/ est pauvre en visuels, on complète avec la61        # galerie de la page /appartements/ (rendue serveur elle aussi)62        pool = IMG_RE.findall(html)63        try:64            pool += IMG_RE.findall(self.get(f"{BASE}/appartements/").text)65        except Exception:66            pass67        images = [u for u in dict.fromkeys(pool)68                  if not re.search(r"logo|favicon|icon|metro|-\d+x\d+\.", u,69                                   re.I)][:20]7071        seen: set[str] = set()72        # blocs typologie : <h4>Studios</h4> … « À partir de 1 630 $ jusqu'à … »73        # — le prix est dans un widget texte qui SUIT le heading dans l'ordre74        # du document (structure de conteneurs Elementor variable) : on75        # concatène le texte jusqu'au prochain heading de typologie.76        headings = [x for x in soup.select(".elementor-heading-title")77                    if TYPE_RE.match(x.get_text(" ", strip=True))]78        for heading in headings:79            label = heading.get_text(" ", strip=True)80            texts: list[str] = []81            for node in heading.find_all_next(string=True):82                t = str(node).strip()83                if not t:84                    continue85                if TYPE_RE.match(t) and t != label:86                    break87                texts.append(t)88                if len(texts) > 40:89                    break90            blob = " ".join(texts)91            m = RANGE_RE.search(blob)92            if not m:93                continue94            lo, hi = _num(m.group(1)), _num(m.group(2))95            if not lo:96                continue97            unit_type = ("Studio" if label.lower().startswith("studio")98                         else normalize_unit_type(label))99            ext_id = ("ocartier2-"100                      + unit_type.replace("½", ".5").replace(" ", "").lower())101            if ext_id in seen:      # les blocs sont répétés desktop/mobile102                continue103            seen.add(ext_id)104            bedrooms = None105            bm = BED_RE.search(blob.split("À partir")[0])106            if bm:107                bedrooms = float(bm.group(1))108            price_label = (f"À partir de {lo:.0f} $"109                           + (f" jusqu'à {hi:.0f} $" if hi else "")110                           + " /mois tout inclus")111            listings.append(Listing(112                source=self.source_id,113                external_id=ext_id,114                url=PAGE_URL,115                title=f"{unit_type} — OCartier 2, appartements locatifs à Laval",116                address=ADDRESS,117                sector=SECTOR,118                city=CITY,119                unit_type=unit_type,120                bedrooms=bedrooms,121                price=lo,122                price_label=price_label,123                description=("OCartier 2 propose 200 appartements locatifs "124                             "haut de gamme à Laval, du studio au 5½, à deux "125                             "pas du métro Cartier. Prix tout inclus : "126                             "électricité, chauffage, climatisation, eau et "127                             "internet."),128                amenities=list(BUILDING_AMENITIES),129                images=images,130            ))131        return listings132