SPB Git

spb/lou-ka Public

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

HTML 99.7%
11.2 KB · 273 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/gio.py : connecteur Gestion Immobilière de l'Outaouais (gioinc.ca)5#   Gestionnaire du Plateau à Gatineau (boul. du Plateau, av. des Oliviers…).6#   WordPress/Elementor : la page /a-louer/ rend côté serveur une boucle7#   Elementor (e-loop-item-<postid>, CPT « proprietes ») avec prix, ville,8#   adresse, nombre de pièces et lien vers la fiche /proprietes/<slug>/.9#   La fiche détail (via cache BD) ajoute : titre, mention « Toutes les10#   charges comprises », type (Résidentiel/Commercial — le commercial est11#   exclu), salles de bain, description bilingue et le bloc « Aperçu »12#   structuré (stationnement, durée du bail, animaux, meublé, électroménagers,13#   taille) ainsi que la galerie de photos quand elle existe.14#   robots.txt : Crawl-delay 3 (respecté via request_delay) et Disallow /*?15#   — le connecteur ne visite que des URLs sans chaîne de requête.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price, strip_accents25from .base import BaseConnector2627BASE = "https://gioinc.ca"28LIST_URL = f"{BASE}/a-louer/"2930_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)31# image générique du site (og:image), jamais une photo du logement32_GENERIC_IMG = re.compile(r"GIO-1200x628|logo", re.I)33_BG_URL_RE = re.compile(r"url\((['\"]?)(https?://[^)'\"]+)\1\)")3435# libellés du bloc « Aperçu » : une valeur qui est elle-même un libellé36# signifie que le champ précédent est vide sur la fiche37_APERCU_LABELS = re.compile(38    r"^(Services inclus|Stationnement inclus|Durée du bail|Date d’emménagement|"39    r"Animaux acceptés|L’unité d’habitation|Taille \(pieds carrés\)|Meublé|"40    r"Électroménagers|Air conditionné|Espace extérieur privé|Fumeurs acceptés|"41    r"Immeuble|Commodités dans l’immeuble)$")424344def _oui_non(raw: str) -> bool | None:45    k = strip_accents((raw or "").strip().lower())46    if k.startswith("oui") or k in ("yes",):47        return True48    if k.startswith("non") or k in ("no",):49        return False50    return None515253class GioConnector(BaseConnector):54    source_id = "gio"55    request_delay = 3.0     # robots.txt : Crawl-delay 356    max_details = 40        # garde-fou fiches détail (vraies requêtes)5758    def fetch(self) -> list[Listing]:59        soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")60        listings: dict[str, Listing] = {}61        for card in soup.select('div[data-elementor-type="loop-item"]'):62            try:63                self._parse_card(card, listings)64            except Exception:65                continue6667        # fiches détail (cache BD) : description, aperçu structuré, photos68        self._fetched = 069        for lst in listings.values():70            key = hashlib.sha1(71                f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}"72                .encode("utf-8")).hexdigest()73            try:74                payload = self.detail(lst.external_id, key,75                                      lambda u=lst.url: self._fetch_detail(u))76            except Exception:77                continue78            self._apply_detail(lst, payload)7980        # le commercial n'est identifiable que sur la fiche -> filtre final81        return [l for l in listings.values()82                if l.details.get("property_kind") != "Commercial"]8384    # -- carte de la boucle Elementor ------------------------------------------85    def _parse_card(self, card, listings: dict[str, Listing]) -> None:86        link = card.select_one('a.elementor-button[href*="/proprietes/"]')87        if not link:88            return89        url = link["href"]90        # id de billet WordPress stable : classe « post-2320 »91        ext_id = ""92        for cls in card.get("class", []):93            m = re.match(r"^post-(\d+)$", cls)94            if m:95                ext_id = m.group(1)96        if not ext_id or ext_id in listings:97            return9899        status = link.get_text(strip=True)          # « Disponible »100        if re.search(r"lou[ée]", status, re.I):101            return102103        # widgets texte dans l'ordre : prix, ville, adresse, « Pièces: n »104        price_label = city = address = rooms_label = ""105        for w in card.select(".elementor-widget-text-editor .elementor-widget-container"):106            txt = re.sub(r"\s+", " ", w.get_text(" ", strip=True))107            if not txt:108                continue109            if "$" in txt and not price_label:110                price_label = txt111            elif re.search(r"pi[eè]ces?\s*:", txt, re.I):112                rooms_label = txt113            elif not city:114                city = txt115            elif not address:116                address = txt117118        unit_type = ""119        m = re.search(r"pi[eè]ces?\s*:\s*(\d+)", rooms_label, re.I)120        rooms = int(m.group(1)) if m else None121        # « Pièces » du site = nombre de chambres (671-102 « 1 chambre » -> 1)122        if rooms:123            unit_type = normalize_unit_type(f"{rooms} chambres")124125        details: dict = {}126        if rooms:127            details["bedrooms"] = rooms128129        listings[ext_id] = Listing(130            source=self.source_id,131            external_id=ext_id,132            url=url,133            title=address or url.rstrip("/").rsplit("/", 1)[-1],134            address=f"{address}, {city}" if address and city else address,135            city=city or "Gatineau",136            unit_type=unit_type,137            price=parse_price(price_label),138            price_label=price_label,139            availability=status,140            details=details,141        )142143    # -- fiche détail ------------------------------------------------------------144    def _fetch_detail(self, url: str) -> dict:145        if self._fetched >= self.max_details:146            raise RuntimeError("budget de fiches détail atteint")147        self._fetched += 1148        html = self.get(url).text149        soup = BeautifulSoup(html, "html.parser")150        out: dict = {}151152        h1 = soup.find("h1")153        if h1:154            out["title"] = h1.get_text(" ", strip=True)155156        text = soup.get_text("\n", strip=True)157158        # adresse complète avec code postal : « 671 boulevard du Plateau,159        # Gatineau, QC  J9J 4K5 (Afficher sur la carte) »160        m = re.search(r"\n([^\n]+,\s*[^\n]+?,\s*QC[^\n(]*)", text)161        if m:162            out["address"] = re.sub(r"\s+", " ", m.group(1)).strip()163164        # « Résidentiel » ou « Commercial » + « Pièces: n | Salles de bain: n »165        m = re.search(r"\b(Résidentiel|Commercial)\b", text)166        if m:167            out["kind"] = m.group(1)168        m = re.search(r"Salles? de bain\s*:?\s*(\d+)", text)169        if m:170            out["bathrooms"] = int(m.group(1))171        if re.search(r"Toutes les charges comprises", text, re.I):172            out["all_inclusive"] = True173174        # description : entre « Description » et « Aperçu »175        m = re.search(r"\nDescription\n(.*?)\nAperçu\n", text, re.S)176        if m:177            desc = re.sub(r"[ \t]+", " ", m.group(1)).strip()178            out["description"] = desc[:2500]179180        # bloc « Aperçu » : paires libellé/valeur rendues en lignes successives181        m = re.search(r"\nAperçu\n(.*?)\n(?:Êtes-vous|Comment pouvons)", text, re.S)182        apercu = m.group(1) if m else ""183        for label, key in [184                (r"Animaux acceptés", "pets_raw"),185                (r"Meublé", "furnished_raw"),186                (r"Fumeurs acceptés", "smoking_raw"),187                (r"Stationnement inclus", "parking_raw"),188                (r"Durée du bail", "lease_raw"),189                (r"Électroménagers", "appliances_raw"),190                (r"Services inclus", "services_raw"),191                (r"Taille \(pieds carrés\)", "area_raw")]:192            mm = re.search(label + r"\n([^\n]+)", apercu)193            if mm and not _APERCU_LABELS.match(mm.group(1).strip()):194                out[key] = mm.group(1).strip()195196        # galerie : diaporama de la fiche (div.slide, background-image) +197        # images du contenu ; jamais l'og:image générique ni les icônes198        images: list[str] = []199200        def _add(u: str) -> None:201            if not u.startswith("http"):202                u = BASE + u203            if _GENERIC_IMG.search(u) or u.lower().endswith(".svg"):204                return205            if "/wp-content/uploads/" not in u:206                return207            u = _SIZE_SUFFIX.sub("", u)208            if u not in images:209                images.append(u)210211        for div in soup.select("div.slide div[style], [style*='background']"):212            m_bg = _BG_URL_RE.search(div.get("style", ""))213            if m_bg:214                _add(m_bg.group(2))215        for im in soup.select('img[src*="/wp-content/uploads/"]'):216            _add(im["src"])217        out["images"] = images[:30]218        return out219220    def _apply_detail(self, lst: Listing, d: dict) -> None:221        if not d:222            return223        if d.get("title"):224            lst.title = d["title"]225        if d.get("address"):226            lst.address = d["address"]227        if d.get("kind"):228            lst.details["property_kind"] = d["kind"]229        if d.get("bathrooms"):230            lst.details["bathrooms"] = d["bathrooms"]231        if d.get("description"):232            lst.description = d["description"]233        # le nombre de chambres écrit par l'agence (titre ou en-tête de la234        # description) prime sur le compteur « Pièces » de la carte, parfois235        # en décalage (ex. fiche 3 chambres avec Pièces: 2)236        head = f"{lst.title} | {(lst.description or '')[:80]}"237        m = re.search(r"(\d+)\s*chambres?\b", head, re.I)238        if m:239            lst.unit_type = normalize_unit_type(f"{m.group(1)} chambres")240            lst.details["bedrooms"] = int(m.group(1))241        if d.get("images"):242            lst.images = d["images"]243        pets = _oui_non(d.get("pets_raw", ""))244        if pets is not None:245            lst.pets = "oui" if pets else "non"246        furn = _oui_non(d.get("furnished_raw", ""))247        if furn is not None:248            lst.furnished = furn249        smoking = _oui_non(d.get("smoking_raw", ""))250        if smoking is not None:251            lst.details["smoking"] = smoking252        if d.get("parking_raw"):253            lst.details["parking"] = {"raw": d["parking_raw"]}254        if d.get("lease_raw"):255            lst.details["lease"] = d["lease_raw"]256        amen: list[str] = []257        if d.get("all_inclusive"):258            amen.append("Toutes les charges comprises")259        if d.get("services_raw"):260            amen.append(f"Services inclus : {d['services_raw']}")261        if _oui_non(d.get("appliances_raw", "")):262            amen.append("Électroménagers inclus")263        if amen:264            lst.amenities = list(dict.fromkeys(lst.amenities + amen))265        m = re.match(r"^([\d\s,.]+)$", d.get("area_raw", "") or "")266        if m:267            try:268                area = float(m.group(1).replace(" ", "").replace(",", "."))269                if 80 <= area <= 20000:270                    lst.area_sqft = area271            except ValueError:272                pass273