SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.6 KB · 241 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/cap_gestion.py : connecteur CAP Gestion / CAP Immobilier5#   (capgestion.ca) — Saint-Jérôme, Lachute, Mirabel, Joliette, Louiseville,6#   Repentigny, Lavaltrie, Brownsburg-Chatham…7#   WordPress + plugin immobilier Estatik 4 Pro : la page « Logements à8#   louer » (rendu serveur, ~10 fiches/page, pagination ?paged-1=N) expose9#   des cartes .es-listing avec data-post-id (id STABLE), prix, adresse10#   complète et type de logement. Chaque fiche /propriete/<slug>/ fournit11#   les champs STRUCTURÉS Estatik (.es-property-field : type, salles de12#   bain, chambres, superficie, animaux, chauffage, stationnement, étage,13#   période de location…) + description et galerie.14#   Les fiches étiquetées « Loué » (es_label/loue) sont exclues : le15#   logement n'est plus offert. robots.txt permissif (Yoast).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import json21import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, parse_price26from .base import BaseConnector2728BASE = "https://capgestion.ca"29LIST_URL = f"{BASE}/gestion-immobiliere-logements-a-louer/"3031# adresse Estatik : « 412 Av. Barron, Lachute, QC J8H 0W2, Canada »32# (la ville est la composante qui précède « QC » ; certaines fiches omettent33# la province — on prend alors la dernière composante non postale)34_QC_RE = re.compile(r"^(?:QC|Qc|Québec|Quebec)\b", re.I)35_POSTAL_RE = re.compile(r"[A-Z]\d[A-Z]\s*\d[A-Z]\d", re.I)36# le site écrit tantôt « Brownsburg », tantôt « Brownsburg-Chatham »37# (même municipalité) : canonicalisation factuelle38_CITY_FIX = {"Brownsburg": "Brownsburg-Chatham"}394041def _city_from_address(address: str) -> str:42    parts = [p.strip() for p in address.split(",") if p.strip()]43    for i, p in enumerate(parts):44        if _QC_RE.match(p) and i > 0:45            return _CITY_FIX.get(parts[i - 1], parts[i - 1])46    # pas de « QC » : dernière composante non postale/pays (ex.47    # « 181 de la Scierie, Brownsburg-Chatham »)48    for p in reversed(parts[1:]):49        if p.lower() == "canada" or _POSTAL_RE.search(p) or re.search(r"\d", p):50            continue51        return _CITY_FIX.get(p, p)52    return ""53# images de la galerie (uploads WordPress), variantes redimensionnées exclues54_IMG_RE = re.compile(55    r"https://capgestion\.ca/wp-content/uploads/[^\"'\s\)]+\.(?:jpe?g|png|webp)",56    re.I)57_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", re.I)585960class _CapAtteint(Exception):61    """Plafond de requêtes détail atteint pour cette synchronisation."""626364class CapGestionConnector(BaseConnector):65    source_id = "cap_gestion"66    request_delay = 0.767    max_list_pages = 25          # garde-fou (13 pages observées)68    max_real_details = 60        # vraies requêtes fiches par sync (cache exclu)6970    # -- liste --------------------------------------------------------------------71    def _parse_page(self, html: str, cards: dict[str, dict]) -> int:72        """Cartes es-listing d'une page ; retourne le nombre total de pages."""73        soup = BeautifulSoup(html, "html.parser")74        for el in soup.select(".js-es-listing[data-post-id]"):75            pid = el.get("data-post-id", "").strip()76            if not pid or pid in cards:77                continue78            labels = [a.get_text(" ", strip=True)79                      for a in el.select(".es-badges a")]80            link = el.select_one("a.es-listings__image__link") or \81                el.select_one(".es-listing__body a[href]") or \82                el.find("a", href=re.compile(r"/propriete/|/property/"))83            url = (link.get("href") or "") if link else ""84            title_el = el.select_one(".es-listing__title, h3 a, h2 a")85            title = title_el.get_text(" ", strip=True) if title_el else ""86            addr_el = el.select_one(".es-address")87            address = addr_el.get_text(" ", strip=True) if addr_el else ""88            price_el = el.select_one(".es-price")89            price_label = price_el.get_text(" ", strip=True) if price_el else ""90            badge = el.select_one(".es-badge--normal")91            if badge:                       # ex. « Par mois »92                price_label = f"{price_label} {badge.get_text(strip=True)}".strip()93            unit_el = el.select_one(".es-listing__meta-types-de-logement")94            unit_type = unit_el.get_text(" ", strip=True) if unit_el else ""95            imgs = []96            for im in el.select(".es-listing__image img"):97                u = im.get("data-lazy") or im.get("src") or ""98                if u.startswith("http") and u not in imgs:99                    imgs.append(u)100            cards[pid] = {101                "url": url, "title": title, "address": address,102                "price_label": price_label, "unit_type": unit_type,103                "labels": labels, "images": imgs,104            }105        # pagination : « 1 - 10 of 124 properties » + liens ?paged-1=N106        pages = 1107        for a in soup.select(".js-es-pagination a.page-numbers"):108            m = re.search(r"paged-1=(\d+)", a.get("href") or "")109            if m:110                pages = max(pages, int(m.group(1)))111        return pages112113    # -- fiche (champs structurés Estatik) ------------------------------------------114    def _unit_detail(self, pid: str, url: str, key: str) -> dict:115        def _fetch() -> dict:116            if self._real_details >= self.max_real_details:117                raise _CapAtteint()118            self._real_details += 1119            html = self.get(url).text120            soup = BeautifulSoup(html, "html.parser")121            out: dict = {"fields": {}}122            for f in soup.select(".es-property-field"):123                label_el = f.select_one(".es-property-field__label")124                value_el = f.select_one(".es-property-field__value")125                if label_el is None or value_el is None:126                    txt = f.get_text(" ", strip=True)127                    if ":" in txt:128                        lab, _, val = txt.partition(":")129                    else:130                        continue131                else:132                    lab = label_el.get_text(" ", strip=True)133                    val = value_el.get_text(" ", strip=True)134                lab = re.sub(r"\s*:\s*$", "", re.sub(r"\s+", " ", lab)).strip()135                val = re.sub(r"\s+", " ", val).strip()136                if lab and val:137                    out["fields"][lab] = val138            imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))139                    if not _SKIP_IMG.search(u)]140            out["images"] = imgs[:20]141            return out142143        try:144            return self.detail(pid, key, _fetch)145        except Exception:146            return {}147148    # -- fetch --------------------------------------------------------------------149    def fetch(self) -> list[Listing]:150        self._real_details = 0151        cards: dict[str, dict] = {}152153        pages = self._parse_page(self.get(LIST_URL).text, cards)154        for n in range(2, min(pages, self.max_list_pages) + 1):155            try:156                self._parse_page(157                    self.get(LIST_URL, params={"paged-1": n}).text, cards)158            except Exception:159                continue160161        listings: list[Listing] = []162        for pid, c in cards.items():163            if "Loué" in c["labels"]:      # plus disponible : exclu164                continue165            if not c["url"].startswith("http"):166                continue167            key = hashlib.sha1(json.dumps(c, sort_keys=True, ensure_ascii=False)168                               .encode("utf-8")).hexdigest()[:16]169            det = self._unit_detail(pid, c["url"], key)170            fields = det.get("fields") or {}171172            # ville : composante de l'adresse Estatik (structurée)173            city = _city_from_address(c["address"])174175            unit_type = fields.get("Type de logement") or c["unit_type"]176            desc = fields.get("La description") or ""177            area = None178            try:179                v = float(str(fields.get("Superficie", "")).replace(",", "")180                          .replace(" ", ""))181                if 80 <= v <= 20000:182                    area = v183            except ValueError:184                pass185186            details: dict = {}187            beds = fields.get("Chambres totales")188            if beds and beds.isdigit():189                details["bedrooms"] = int(beds)190            baths = fields.get("Salle(s) de bain")191            if baths and baths.isdigit():192                details["bathrooms"] = int(baths)193            if fields.get("Étage"):194                details["floor_label"] = fields["Étage"]195            if fields.get("Période de location"):196                details["lease"] = fields["Période de location"]197198            pets = None199            pets_raw = fields.get("Animaux acceptés", "")200            if re.search(r"non|aucun|interdit", pets_raw, re.I):201                pets = "non"202            elif pets_raw:203                pets = "conditions" if re.search(204                    r"frais|maximum|condition|stéril", pets_raw, re.I) else "oui"205206            amenities = []207            for lab in ("Animaux acceptés", "Type d'animal autorisé",208                        "Électros inclus", "Chauffage et électricité",209                        "Nb de stationnements", "Type de stationnement",210                        "Enquête de prélocation complète"):211                if fields.get(lab):212                    amenities.append(f"{lab} : {fields[lab]}")213214            # disponibilité : uniquement si affichée (souvent dans le titre,215            # ex. « … – 1ER AOÛT » ou « Disponible maintenant ») — rien d'inventé216            availability = ""217            m = re.search(r"Disponible[^.|]*", desc, re.I)218            if m:219                availability = m.group(0).strip()220221            listings.append(Listing(222                source=self.source_id,223                external_id=pid,224                url=c["url"],225                title=c["title"],226                address=c["address"],227                sector="",228                city=city,229                unit_type=unit_type,230                price=parse_price(c["price_label"]),231                price_label=c["price_label"],232                availability=availability,233                area_sqft=area,234                pets=pets,235                description=desc[:600],236                amenities=amenities,237                details=details,238                images=(det.get("images") or c["images"])[:20],239            ))240        return listings241