spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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