SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.7 KB · 190 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/groupe_robin.py : connecteur Groupe Robin (grouperobin.com)5#   WordPress Elementor multi-lignes d'affaires. La page /appartement/ charge6#   ses immeubles via admin-ajax.php (action get_list_immeubles_ajx,7#   explicitement permis par robots.txt) : JSON riche par immeuble (adresse8#   + GPS, photos, descriptions, résumé par grandeur avec loyer « à partir9#   de », superficie, statut et dates de disponibilité). On ne garde que la10#   catégorie 126 = appartements locatifs résidentiels ; 1 annonce =11#   1 immeuble × grandeur (le grain affiché par le site). ~2 requêtes/sync.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re16import time1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://grouperobin.com"24AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"25CAT_APPARTEMENT = "126"      # taxonomie « type » : Appartement (résidentiel)26PER_PAGE = 402728# villes où Groupe Robin exploite du locatif résidentiel (multi-régions :29# District 55 à Trois-Rivières + parc historique à Saint-Hyacinthe)30_CITY_RE = re.compile(31    r"(Trois-Rivi[eè]res|Saint-Hyacinthe|St-Hyacinthe|"32    r"Saint-Bruno(?:-de-Montarville)?|Mont-Saint-Hilaire|Belo?eil)", re.I)33_CITY_CANON = {34    "st-hyacinthe": "Saint-Hyacinthe",35    "saint-bruno": "Saint-Bruno-de-Montarville",36}373839def _city_name(raw: str) -> str:40    k = raw.strip().lower().replace("é", "e").replace("è", "e")41    for pref, canon in _CITY_CANON.items():42        if k.startswith(pref):43            return canon44    return raw.strip()454647def _strip_html(raw: str, limit: int = 1200) -> str:48    if not raw:49        return ""50    txt = BeautifulSoup(raw, "html.parser").get_text("\n", strip=True)51    return re.sub(r"[ \t]+", " ", txt).strip()[:limit]525354def _num(raw) -> float | None:55    """'1625' -> 1625.0 ; '' / '0' / non numérique -> None (jamais deviné)."""56    s = str(raw or "").strip().replace(",", ".")57    if re.fullmatch(r"\d+(\.\d+)?", s):58        val = float(s)59        return val if val > 0 else None60    return None616263class GroupeRobinConnector(BaseConnector):64    source_id = "groupe_robin"65    request_delay = 0.866    max_pages = 5        # garde-fou (2 pages réelles à 40 immeubles/page)6768    def _post_ajax(self, offset: int) -> list:69        """POST admin-ajax paginé, avec la même politesse que get()."""70        wait = self.request_delay - (time.time() - self._last_request)71        if wait > 0:72            time.sleep(wait)73        resp = self.session.post(AJAX_URL, data={74            "action": "get_list_immeubles_ajx",75            "offset": str(offset),76            "immeubles_per_page": str(PER_PAGE),77            "categories[]": CAT_APPARTEMENT,78        }, timeout=self.timeout)79        self._last_request = time.time()80        resp.raise_for_status()81        return resp.json()8283    def fetch(self) -> list[Listing]:84        listings: dict[str, Listing] = {}85        total_pages = 186        for page in range(self.max_pages):87            if page >= total_pages:88                break89            data = self._post_ajax(page * PER_PAGE)90            immeubles = data[0] if data else []91            opts = data[1] if len(data) > 1 else {}92            total_pages = min(int(opts.get("total_pages") or 1), self.max_pages)93            if not immeubles:94                break95            for imm in immeubles:96                try:97                    self._parse_immeuble(imm, listings)98                except Exception:99                    continue100        return list(listings.values())101102    # -- un immeuble = n annonces (une par grandeur, le grain du site) --------103    def _parse_immeuble(self, imm: dict, listings: dict[str, Listing]) -> None:104        imm_id = str(imm.get("id") or "")105        titre = (imm.get("titre") or "").strip()106        if not imm_id or not titre:107            return108        # défense : autres lignes d'affaires (résidences aînés, commercial…)109        if re.search(r"r[ée]sidence|retrait[ée]|commercial|bureau|h[oô]tel",110                     titre, re.I):111            return112        resumer = imm.get("appartements_resumer")113        if not isinstance(resumer, dict) or not resumer:114            return           # immeuble sans unité annoncée115116        url = imm.get("lien") or f"{BASE}/appartement/"117        adresse = imm.get("adresse_postale") or {}118        full_addr = adresse.get("address") or "" if isinstance(adresse, dict) else ""119        address = full_addr.split(",")[0].strip() if full_addr else ""120        lat = adresse.get("lat") if isinstance(adresse, dict) else None121        lng = adresse.get("lng") if isinstance(adresse, dict) else None122123        # ville réelle : cherchée dans l'adresse Google, sinon dans le terme124        # d'emplacement des unités (« Trois-Rivières (District 55) »)125        city, sector = "", ""126        m = _CITY_RE.search(full_addr) or _CITY_RE.search(titre)127        if m:128            city = _city_name(m.group(1))129        for apt in imm.get("appartements") or []:130            for term in apt.get("emplacement") or []:131                name = term.get("name") or ""132                mm = re.match(r"(.+?)\s*\((.+)\)", name)133                if mm:134                    city = city or _city_name(mm.group(1))135                    sector = mm.group(2)136                elif not city:137                    city = _city_name(name)138                break139            if city:140                break141142        description = _strip_html(imm.get("description_generale") or "")143144        images: list[str] = []145        main_img = imm.get("image_immeuble")146        if isinstance(main_img, str) and main_img.startswith("http"):147            images.append(main_img)148        for ph in imm.get("photos") or []:149            u = ((ph or {}).get("photo") or {}).get("url") or ""150            if u.startswith("http") and u not in images:151                images.append(u)152153        for grandeur, grp in resumer.items():154            if not isinstance(grp, dict):155                continue156            g = (grp.get("grandeur") or grandeur or "").strip()157            loyer = _num(grp.get("loyer"))158            # rendu du site : « à partir de 1625$ » ou « Prix sur demande »159            price_label = f"à partir de {int(loyer)}$" if loyer else "Prix sur demande"160            labels = [l for l in (grp.get("dates_disponibilite_label") or []) if l]161            availability = ", ".join(dict.fromkeys(labels))162            if not availability:163                availability = str(grp.get("statut_disponible") or "")164165            amenities = [a for a in (grp.get("inclusions") or [])166                         if isinstance(a, str) and a]167168            ext_id = f"{imm_id}-{re.sub(r'[^0-9a-zA-Z½]+', '', g) or 'u'}"169            if ext_id in listings:170                continue171            listings[ext_id] = Listing(172                source=self.source_id,173                external_id=ext_id,       # id WP de l'immeuble + grandeur174                url=url,175                title=f"{g}{titre}" if g else titre,176                address=address,177                sector=sector,178                city=city,179                unit_type=normalize_unit_type(g),180                price=loyer,181                price_label=price_label,182                availability=availability,183                area_sqft=_num(grp.get("superficie")),184                description=description,185                amenities=amenities,186                images=images[:15],187                lat=lat if isinstance(lat, (int, float)) else None,188                lng=lng if isinstance(lng, (int, float)) else None,189            )190