# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/groupe_robin.py : connecteur Groupe Robin (grouperobin.com) # WordPress Elementor multi-lignes d'affaires. La page /appartement/ charge # ses immeubles via admin-ajax.php (action get_list_immeubles_ajx, # explicitement permis par robots.txt) : JSON riche par immeuble (adresse # + GPS, photos, descriptions, résumé par grandeur avec loyer « à partir # de », superficie, statut et dates de disponibilité). On ne garde que la # catégorie 126 = appartements locatifs résidentiels ; 1 annonce = # 1 immeuble × grandeur (le grain affiché par le site). ~2 requêtes/sync. # ----------------------------------------------------------------------------- from __future__ import annotations import re import time from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://grouperobin.com" AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php" CAT_APPARTEMENT = "126" # taxonomie « type » : Appartement (résidentiel) PER_PAGE = 40 # villes où Groupe Robin exploite du locatif résidentiel (multi-régions : # District 55 à Trois-Rivières + parc historique à Saint-Hyacinthe) _CITY_RE = re.compile( r"(Trois-Rivi[eè]res|Saint-Hyacinthe|St-Hyacinthe|" r"Saint-Bruno(?:-de-Montarville)?|Mont-Saint-Hilaire|Belo?eil)", re.I) _CITY_CANON = { "st-hyacinthe": "Saint-Hyacinthe", "saint-bruno": "Saint-Bruno-de-Montarville", } def _city_name(raw: str) -> str: k = raw.strip().lower().replace("é", "e").replace("è", "e") for pref, canon in _CITY_CANON.items(): if k.startswith(pref): return canon return raw.strip() def _strip_html(raw: str, limit: int = 1200) -> str: if not raw: return "" txt = BeautifulSoup(raw, "html.parser").get_text("\n", strip=True) return re.sub(r"[ \t]+", " ", txt).strip()[:limit] def _num(raw) -> float | None: """'1625' -> 1625.0 ; '' / '0' / non numérique -> None (jamais deviné).""" s = str(raw or "").strip().replace(",", ".") if re.fullmatch(r"\d+(\.\d+)?", s): val = float(s) return val if val > 0 else None return None class GroupeRobinConnector(BaseConnector): source_id = "groupe_robin" request_delay = 0.8 max_pages = 5 # garde-fou (2 pages réelles à 40 immeubles/page) def _post_ajax(self, offset: int) -> list: """POST admin-ajax paginé, avec la même politesse que get().""" wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.post(AJAX_URL, data={ "action": "get_list_immeubles_ajx", "offset": str(offset), "immeubles_per_page": str(PER_PAGE), "categories[]": CAT_APPARTEMENT, }, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return resp.json() def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} total_pages = 1 for page in range(self.max_pages): if page >= total_pages: break data = self._post_ajax(page * PER_PAGE) immeubles = data[0] if data else [] opts = data[1] if len(data) > 1 else {} total_pages = min(int(opts.get("total_pages") or 1), self.max_pages) if not immeubles: break for imm in immeubles: try: self._parse_immeuble(imm, listings) except Exception: continue return list(listings.values()) # -- un immeuble = n annonces (une par grandeur, le grain du site) -------- def _parse_immeuble(self, imm: dict, listings: dict[str, Listing]) -> None: imm_id = str(imm.get("id") or "") titre = (imm.get("titre") or "").strip() if not imm_id or not titre: return # défense : autres lignes d'affaires (résidences aînés, commercial…) if re.search(r"r[ée]sidence|retrait[ée]|commercial|bureau|h[oô]tel", titre, re.I): return resumer = imm.get("appartements_resumer") if not isinstance(resumer, dict) or not resumer: return # immeuble sans unité annoncée url = imm.get("lien") or f"{BASE}/appartement/" adresse = imm.get("adresse_postale") or {} full_addr = adresse.get("address") or "" if isinstance(adresse, dict) else "" address = full_addr.split(",")[0].strip() if full_addr else "" lat = adresse.get("lat") if isinstance(adresse, dict) else None lng = adresse.get("lng") if isinstance(adresse, dict) else None # ville réelle : cherchée dans l'adresse Google, sinon dans le terme # d'emplacement des unités (« Trois-Rivières (District 55) ») city, sector = "", "" m = _CITY_RE.search(full_addr) or _CITY_RE.search(titre) if m: city = _city_name(m.group(1)) for apt in imm.get("appartements") or []: for term in apt.get("emplacement") or []: name = term.get("name") or "" mm = re.match(r"(.+?)\s*\((.+)\)", name) if mm: city = city or _city_name(mm.group(1)) sector = mm.group(2) elif not city: city = _city_name(name) break if city: break description = _strip_html(imm.get("description_generale") or "") images: list[str] = [] main_img = imm.get("image_immeuble") if isinstance(main_img, str) and main_img.startswith("http"): images.append(main_img) for ph in imm.get("photos") or []: u = ((ph or {}).get("photo") or {}).get("url") or "" if u.startswith("http") and u not in images: images.append(u) for grandeur, grp in resumer.items(): if not isinstance(grp, dict): continue g = (grp.get("grandeur") or grandeur or "").strip() loyer = _num(grp.get("loyer")) # rendu du site : « à partir de 1625$ » ou « Prix sur demande » price_label = f"à partir de {int(loyer)}$" if loyer else "Prix sur demande" labels = [l for l in (grp.get("dates_disponibilite_label") or []) if l] availability = ", ".join(dict.fromkeys(labels)) if not availability: availability = str(grp.get("statut_disponible") or "") amenities = [a for a in (grp.get("inclusions") or []) if isinstance(a, str) and a] ext_id = f"{imm_id}-{re.sub(r'[^0-9a-zA-Z½]+', '', g) or 'u'}" if ext_id in listings: continue listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, # id WP de l'immeuble + grandeur url=url, title=f"{g} — {titre}" if g else titre, address=address, sector=sector, city=city, unit_type=normalize_unit_type(g), price=loyer, price_label=price_label, availability=availability, area_sqft=_num(grp.get("superficie")), description=description, amenities=amenities, images=images[:15], lat=lat if isinstance(lat, (int, float)) else None, lng=lng if isinstance(lng, (int, float)) else None, )