SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.9 KB · 212 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/carat_immobilier.py : connecteur Carat Immobilier5#   (caratimmobilier.ca — Rouyn-Noranda, Abitibi-Témiscamingue). WordPress +6#   thème immobilier Houzez (cartes v2), tout rendu serveur — même famille que7#   gimcote.py / immeubles_bc.py. Archive /appartements/ : cartes8#   `.item-listing-wrap` (statut « Disponible… », prix « 1 ,058$ », adresse9#   complète, galerie dans data-images). Fiche détail /property/<slug>/ (via10#   cache BD) : description, commodités (#property-features-wrap), bloc11#   « Détails » structuré (type 4 1/2, chambres, salle de bain, superficie) et12#   bloc adresse (ville, quartier, immeuble). Pas de GPS réel (carte Houzez13#   sur coordonnées par défaut). robots.txt Yoast ouvert, sitemap XML.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import html as htmllib19import json20import re2122from bs4 import BeautifulSoup2324from ..schema import (Listing, normalize_unit_type, parse_area_sqft,25                      parse_price, strip_accents)26from .base import BaseConnector2728BASE = "https://caratimmobilier.ca"29LIST_URL = f"{BASE}/appartements/"3031# suffixe de redimensionnement WordPress (« -584x438.jpg » -> pleine taille)32_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)333435def _clean_price_label(label: str) -> str:36    """'1 ,058$' (Houzez) -> '1058$' compatible parse_price (virgule = milliers)."""37    label = re.sub(r"(\d)\s*,\s*(\d{3})", r"\1\2", label)38    return re.sub(r"\s+", " ", label).strip()394041class CaratImmobilierConnector(BaseConnector):42    source_id = "carat_immobilier"43    request_delay = 0.644    max_details = 30     # garde-fou fiches détail (vraies requêtes par sync)4546    def fetch(self) -> list[Listing]:47        html = self.get(LIST_URL).text48        soup = BeautifulSoup(html, "html.parser")4950        listings: dict[str, Listing] = {}51        for card in soup.select("div.item-listing-wrap"):52            try:53                self._parse_card(card, listings)54            except Exception:55                continue5657        # fiches détail (cache BD) : description, commodités, type, superficie58        self._fetched = 059        for lst in listings.values():60            key = hashlib.sha1(61                f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}"62                .encode("utf-8")).hexdigest()63            try:64                payload = self.detail(lst.external_id, key,65                                      lambda u=lst.url: self._fetch_detail(u))66            except Exception:67                continue68            self._apply_detail(lst, payload)69        return list(listings.values())7071    # -- carte Houzez v2 ---------------------------------------------------------------72    def _parse_card(self, card, listings: dict[str, Listing]) -> None:73        link = card.select_one("h2.item-title a[href]") \74            or card.select_one('a[href*="/property/"]')75        if not link:76            return77        url = link["href"]78        m = re.search(r"/property/([^/?#]+)", url)79        slug = m.group(1) if m else ""80        ext_id = card.get("data-hz-id") or slug81        if not ext_id or str(ext_id) in listings:82            return83        title = link.get_text(strip=True)8485        # exclusions : commercial / stationnement / rangement86        if re.search(r"commercial|bureau|local|stationnement|garage|entrep[oô]t",87                     title, re.I):88            return8990        # statut (« Disponible 1er septembre », « Loué ») — on saute les loués91        status_el = card.select_one(".label-status")92        availability = status_el.get_text(strip=True) if status_el else ""93        if re.search(r"lou[ée]", availability, re.I):94            return9596        # adresse complète : « 769 Av. Murdoch, Rouyn-Noranda, QC J9X 1H9, Canada »97        addr_el = card.select_one("address.item-address")98        address = addr_el.get_text(" ", strip=True) if addr_el else ""99        city = "Rouyn-Noranda"               # tout le parc est à Rouyn-Noranda100        parts = [p.strip() for p in address.split(",") if p.strip()]101        for p in parts[1:]:102            if not re.match(r"^(QC|Québec|Quebec|Canada|[A-Z]\d[A-Z])", p, re.I):103                city = re.sub(r"\s+(QC|Québec|Quebec).*$", "", p, flags=re.I).strip() or city104                break105106        price_el = card.select_one("li.item-price")107        price_label = _clean_price_label(108            price_el.get_text(strip=True)) if price_el else ""109110        # galerie complète : attribut data-images (JSON, URLs redimensionnées)111        images: list[str] = []112        raw = card.get("data-images") or ""113        if raw:114            try:115                entries = json.loads(htmllib.unescape(raw))116                urls = [e.get("image", "") for e in entries if isinstance(e, dict)]117            except Exception:118                urls = re.findall(r"https?:[^\"',\\]+", htmllib.unescape(raw))119            for u in urls:120                u = u.replace("\\/", "/").strip()121                if not u.startswith("http"):122                    continue123                u = _SIZE_SUFFIX.sub("", u)   # version pleine taille (WordPress)124                if u not in images:125                    images.append(u)126        if not images:127            thumb = card.select_one("img.wp-post-image[src]")128            if thumb:129                images = [_SIZE_SUFFIX.sub("", thumb["src"])]130131        # type d'unité : « 4 ½ » présent dans le titre de l'annonce132        unit_type = ""133        m_type = re.search(r"\b(\d)\s*(?:1/2|½)", title)134        if m_type:135            unit_type = normalize_unit_type(f"{m_type.group(1)} 1/2")136137        listings[str(ext_id)] = Listing(138            source=self.source_id,139            external_id=str(ext_id),140            url=url,141            title=title,142            address=address,143            city=city,144            unit_type=unit_type,145            price=parse_price(price_label),146            price_label=price_label,147            availability=availability,148            images=images[:30],149        )150151    # -- fiche détail (Houzez) -----------------------------------------------------152    def _fetch_detail(self, url: str) -> dict:153        """Description, commodités, bloc « Détails » et bloc adresse."""154        if self._fetched >= self.max_details:155            raise RuntimeError("budget de fiches détail atteint")156        self._fetched += 1157        html = self.get(url).text158        soup = BeautifulSoup(html, "html.parser")159        out: dict = {}160161        desc_el = soup.select_one("#property-description-wrap")162        if desc_el:163            txt = desc_el.get_text("\n", strip=True)164            txt = re.sub(r"^Description\n", "", txt)165            out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]166167        out["amenities"] = [a.get_text(" ", strip=True)168                            for a in soup.select("#property-features-wrap li")169                            if a.get_text(strip=True)][:20]170171        # bloc « Détails » : paires <strong>label</strong><span>valeur</span>172        fields: dict[str, str] = {}173        for li in soup.select("#property-detail-wrap li"):174            strong = li.select_one("strong")175            span = li.select_one("span")176            if strong and span:177                key = strip_accents(strong.get_text(strip=True).lower())178                fields[key] = span.get_text(" ", strip=True)179        out["fields"] = fields180181        # bloc adresse : « Ville: Rouyn Noranda », « Quartier: Rouyn »…182        for li in soup.select("#property-address-wrap li"):183            txt = li.get_text(" ", strip=True)184            m = re.match(r"(Ville|Quartier)\s*:\s*(.+)$", txt, re.I)185            if m:186                out[strip_accents(m.group(1).lower())] = m.group(2).strip()187        return out188189    def _apply_detail(self, lst: Listing, d: dict) -> None:190        """Reporte le payload (frais ou en cache) sur l'annonce."""191        if not d:192            return193        desc = d.get("description") or ""194        if desc and desc.strip() != lst.address.strip():195            lst.description = desc196197        fields = d.get("fields") or {}198        extra: list[str] = []199        if fields.get("chambres"):200            extra.append(f"{fields['chambres']} chambre(s)")201        if fields.get("salle de bain"):202            extra.append(f"{fields['salle de bain']} salle(s) de bain")203        if d.get("amenities") or extra:204            lst.amenities = list(dict.fromkeys(205                lst.amenities + extra + (d.get("amenities") or [])))206        if not lst.unit_type and fields.get("type de propriete"):207            lst.unit_type = normalize_unit_type(fields["type de propriete"])208        if lst.area_sqft is None and fields.get("superficie"):209            lst.area_sqft = parse_area_sqft(fields["superficie"])210        if d.get("quartier"):211            lst.sector = d["quartier"]212