SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.6 KB · 209 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/garic.py : connecteur Garic Gestion Immobilière (garic.ca)5#   Gestionnaire de Gatineau (Hull, Vieux-Gatineau, secteur est). WordPress6#   (Oxygen) : la grille filtrable de /a-louer/ se nourrit d'un endpoint7#   admin-ajax MAISON `garic_get_properties` qui renvoie TOUT l'inventaire8#   en JSON structuré : ID, adresse (post_title), prix/mois, type (« 4 ½ »),9#   chambres, salles de bain, superficie, disponibilité, image, URL de la10#   fiche et géocodage complet (lat/lng + quartier OpenStreetMap). Le nonce11#   de sécurité est lu sur la page /a-louer/ à chaque sync.12#   L'unique propriété d'OTTAWA est exclue (périmètre Québec). La fiche13#   /a-louer/<slug> (cache BD) ajoute description, inclusions, « À14#   proximité » et scores de mobilité.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://garic.ca"27LIST_URL = f"{BASE}/a-louer/"28AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"2930_NONCE_RE = re.compile(r'nonce"\s*:\s*"([0-9a-f]+)"')31# « 74, Rue Saint-Paul, Vieux-Gatineau, Gatineau, Outaouais, … » -> secteur32_GENERIC_SEG = re.compile(33    r"(?i)^(gatineau|outaouais|quebec|québec|canada|j\d[a-z]\s?\d[a-z]\d|"34    r"\(secteur\).*|urban agglomeration.*|papineau|les collines-de-l'outaouais)$"35    r"|^(rue|avenue|av\.?|boul\.?|boulevard|chemin|mont[ée]e|impasse|place)\b")36# municipalités distinctes de la couronne parfois présentes dans le géocodage37_MUNICIPALITIES = {"thurso": "Thurso", "chelsea": "Chelsea",38                   "cantley": "Cantley", "val-des-monts": "Val-des-Monts"}394041class GaricConnector(BaseConnector):42    source_id = "garic"43    request_delay = 1.044    max_details = 2045    max_images = 104647    # -- fiche détail ------------------------------------------------------------48    def _fetch_detail(self, url: str) -> dict:49        self._fetched += 150        soup = BeautifulSoup(self.get(url).text, "html.parser")51        out: dict = {"amenities": [], "nearby": [], "scores": {}}5253        def _section(title: str) -> list[str]:54            """Bloc `div.meta-info` : « <h3>Inclusions</h3> 🍳 Cuisinière<br/>… »55            — une entrée par segment séparé par <br/>."""56            h = soup.find("h3", string=re.compile(rf"^\s*{title}\s*$"))57            if not (h and h.parent):58                return []59            items = [re.sub(r"\s+", " ", t).strip()60                     for t in h.parent.get_text("\n", strip=True).split("\n")]61            return [t for t in items62                    if t and t != title and 2 < len(t) < 90][:15]6364        out["amenities"] = _section("Inclusions")65        out["nearby"] = _section("À proximité")6667        h = soup.find("h3", string=re.compile(r"^\s*Description\s*$"))68        if h and h.parent:69            txt = h.parent.get_text("\n", strip=True)70            txt = re.sub(r"^Description\s*\n?", "", txt)71            txt = re.sub(r"\n{2,}", "\n", txt)72            out["description"] = txt.strip()[:2000]7374        text = soup.get_text(" ", strip=True)75        for label, key in (("Walk Score", "walk"), ("Transit Score", "transit"),76                           ("Bike Score", "bike")):77            m = re.search(rf"{label}\s*®?\s*(\d{{1,3}})", text)78            if m:79                out["scores"][key] = int(m.group(1))80        return out8182    # -- fetch -----------------------------------------------------------------83    def fetch(self) -> list[Listing]:84        # 1) nonce de la grille (rafraîchi à chaque sync)85        page = self.get(LIST_URL).text86        m = _NONCE_RE.search(page)87        if not m:88            return []89        # 2) inventaire JSON complet (POST via la session -> rejouable en test)90        r = self.session.post(91            AJAX_URL,92            data={"action": "garic_get_properties", "security": m.group(1)},93            timeout=self.timeout)94        r.raise_for_status()95        data = (r.json() or {}).get("data") or []9697        self._fetched = 098        listings: dict[str, Listing] = {}99        for rec in data:100            try:101                self._parse_record(rec, listings)102            except Exception:103                continue104        return list(listings.values())105106    def _parse_record(self, rec: dict, listings: dict[str, Listing]) -> None:107        ville = (rec.get("ville") or "").strip()108        if ville.lower() != "gatineau":109            return   # Ottawa (Ontario) : hors périmètre110        ext = str(rec.get("ID") or "")111        url = (rec.get("url") or "").split("?")[0]112        if not ext or not url or ext in listings:113            return114115        title = re.sub(r"\s+", " ", rec.get("post_title") or "").strip()116        price_amt = (rec.get("prix_montant") or "").strip()117        per = (rec.get("prix_par") or "mois").strip()118        price_label = f"{price_amt}$ par {per}" if price_amt else ""119120        # superficie déclarée (souvent vide) — champ « superficie » brut121        area = None122        sup = (rec.get("superficie") or "").strip()123        if sup:124            try:125                v = float(re.sub(r"[^\d.]", "", sup))126                if 80 <= v <= 20000:127                    area = v128            except ValueError:129                area = None130131        # géocodage publié : lat/lng + quartier OSM (« Vieux-Gatineau »)132        lat = lng = None133        sector = ""134        emp = rec.get("emplacement") or {}135        markers = emp.get("markers") or []136        if markers:137            lat, lng = markers[0].get("lat"), markers[0].get("lng")138            geos = markers[0].get("geocode") or []139            disp = ""140            if geos:141                disp = (geos[0].get("display_name")142                        or (geos[0].get("properties") or {})143                        .get("display_name") or "")144            segs = [s.strip() for s in disp.split(",")]145            for seg in segs[2:5]:146                if seg and not _GENERIC_SEG.match(seg) \147                        and not re.match(r"^\d", seg):148                    sector = seg149                    break150        # le géocodage révèle parfois une municipalité distincte (Thurso…) :151        # elle devient la ville, sans secteur152        city = "Gatineau"153        if sector.lower() in _MUNICIPALITIES:154            city, sector = _MUNICIPALITIES[sector.lower()], ""155        if lat is None:156            lat, lng = emp.get("lat"), emp.get("lng")157158        images: list[str] = []159        img = rec.get("image") or {}160        for k in ("full", "large", "thumbnail"):161            u = img.get(k) or ""162            if u.startswith("http"):163                images.append(re.sub(r"-\d{2,4}x\d{2,4}(?=\.\w+$)", "", u))164                break165166        details: dict = {}167        sdb = (rec.get("pieces_salles_de_bain") or "").strip()168        if sdb.isdigit():169            details["bathrooms"] = int(sdb)170171        # fiche détail (description, inclusions, proximité, scores)172        payload: dict = {}173        key = hashlib.sha1(f"{title}|{price_label}|{rec.get('availability')}"174                           .encode("utf-8")).hexdigest()[:20]175        if self._fetched < self.max_details:176            try:177                payload = self.detail(ext, key,178                                      lambda u=url: self._fetch_detail(u))179            except Exception:180                payload = {}181        desc_bits = []182        if payload.get("description"):183            desc_bits.append(payload["description"])184        if payload.get("nearby"):185            desc_bits.append("À proximité : " + ", ".join(payload["nearby"]))186        for k, v in (payload.get("scores") or {}).items():187            details[f"{k}_score"] = v188189        listings[ext] = Listing(190            source=self.source_id,191            external_id=ext,192            url=url,193            title=title,194            address=title,          # le titre EST l'adresse civique195            sector=sector,196            city=city,197            unit_type=normalize_unit_type(rec.get("type") or ""),198            price=parse_price(price_label),199            price_label=price_label,200            availability=(rec.get("availability") or "").strip(),201            area_sqft=area,202            description="\n".join(desc_bits)[:2200],203            amenities=list(payload.get("amenities") or []),204            details=details,205            lat=float(lat) if lat is not None else None,206            lng=float(lng) if lng is not None else None,207            images=images[: self.max_images],208        )209