# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/girescom.py : connecteur Girescom (girescom.com) # Site RentSync rendu serveur. Liste /residential : une carte par immeuble # (adresse avec ville, prix « À partir de », fourchette de chambres). # Page immeuble /residential/ : tableau des unités disponibles # (li.unit-table-desktop__units--unit, attributs column-field structurés : # numéro, chambres, sdb, prix, pi², photos, plan d'étage, disponibilité), # description « À propos de la propriété », commodités (immeuble + unité), # téléphone de l'immeuble et galerie. Une annonce Lou-Ka = une unité. # Périmètre : tout le portefeuille — Québec/Lévis, proche périphérie # (Sainte-Brigitte-de-Laval, Shannon…) et Beauce (Saint-Georges, # Sainte-Marie, Vallée-Jonction…) depuis l'expansion provinciale. # Fiches immeuble via self.detail() (cache BD, max max_details/sync). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.girescom.com" LIST_URL = f"{BASE}/residential" # Noms canoniques (accents/abréviations) des villes rencontrées dans les # adresses — clé = forme normalisée (_city_key). _CITY_CANON = { "quebec": "Québec", "levis": "Lévis", "saint-georges": "Saint-Georges", "st-georges": "Saint-Georges", "sainte-marie": "Sainte-Marie", "ste-marie": "Sainte-Marie", "vallee-jonction": "Vallée-Jonction", "saint-joseph-de-beauce": "Saint-Joseph-de-Beauce", "st-joseph-de-beauce": "Saint-Joseph-de-Beauce", "beauceville": "Beauceville", "saint-victor": "Saint-Victor", "east-broughton": "East Broughton", "tring-jonction": "Tring-Jonction", "saints-anges": "Saints-Anges", "scott": "Scott", "saint-elzear": "Saint-Elzéar", "st-elzear": "Saint-Elzéar", "frampton": "Frampton", "sainte-brigitte-de-laval": "Sainte-Brigitte-de-Laval", "shannon": "Shannon", } # téléphone du siège social (affiché sur toutes les pages) — pas celui # de l'immeuble _CORPORATE_PHONE = "4186863040" def _city_key(city: str) -> str: return re.sub(r"[\s']+", "-", strip_accents(city.strip().lower())) def _unit_type(bed: str) -> str: """Chambres (colonne structurée) -> type Lou-Ka : 0 = Studio, n = (n+2)½.""" b = (bed or "").strip() if b == "0": return "Studio" if b.isdigit(): return normalize_unit_type(f"{b} chambres") return "" class GirescomConnector(BaseConnector): source_id = "girescom" request_delay = 0.6 max_details = 30 # garde-fou pages immeuble (vraies requêtes par sync) def fetch(self) -> list[Listing]: # le serveur n'annonce pas charset=utf-8 : forcer le décodage resp = self.get(LIST_URL) resp.encoding = "utf-8" html = resp.text soup = BeautifulSoup(html, "html.parser") self._fetched = 0 listings: list[Listing] = [] for card in soup.select("ul.card-grouping-grid li.card-grouping-grid__item"): try: listings.extend(self._parse_building(card)) except Exception: continue return listings # -- carte immeuble (liste /residential) ------------------------------------- def _parse_building(self, card) -> list[Listing]: link = card.select_one('a[href^="/residential/"]') if not link: return [] slug = link["href"].rstrip("/").rsplit("/", 1)[-1] url = f"{BASE}/residential/{slug}" title_el = card.select_one(".card-property__title") title = title_el.get_text(strip=True) if title_el else slug addr_el = card.select_one(".card-property__address--text") address = addr_el.get_text(" ", strip=True) if addr_el else "" # « 7615 Rue des Métis, Quebec, QC » -> ville = avant-dernier segment parts = [p.strip() for p in address.split(",") if p.strip()] city = parts[-2] if len(parts) >= 2 else "" city = _CITY_CANON.get(_city_key(city), city) or "Québec" rate_el = card.select_one(".card-property__rate-min") beds_el = card.select_one(".card-property__beds-text") card_sig = (f"{address}|{rate_el.get_text(strip=True) if rate_el else ''}" f"|{beds_el.get_text(strip=True) if beds_el else ''}") # page immeuble (cache BD, invalidée quand la carte liste change) key = hashlib.sha1(card_sig.encode("utf-8")).hexdigest() def fetch_fn(): if self._fetched >= self.max_details: raise RuntimeError("budget de pages immeuble atteint") self._fetched += 1 return self._fetch_building(url) payload = self.detail(slug, key, fetch_fn) if not payload: return [] building_addr = payload.get("address") or address details_base: dict = {"building": title} if payload.get("phone"): details_base["contact"] = {"phone": payload["phone"]} out: list[Listing] = [] for u in payload.get("units", []): numero = u.get("unit") or "" if not numero: continue amenities = list(payload.get("amenities", [])) if u.get("bed"): amenities.insert(0, f"{u['bed']} chambre(s)") if u.get("bath"): amenities.insert(1, f"{u['bath']} salle(s) de bain") area = None sqft = re.sub(r"[\s,  ]", "", u.get("sqft") or "") if re.fullmatch(r"\d{2,5}", sqft) and 80 <= float(sqft) <= 20000: area = float(sqft) details = dict(details_base) if u.get("floorplan"): details["floorplan"] = u["floorplan"] out.append(Listing( source=self.source_id, external_id=f"{slug}--{numero}", url=url, title=f"{title} — unité {numero}", address=building_addr, city=city, unit_type=_unit_type(u.get("bed", "")), price=parse_price(u.get("rate", "")), price_label=u.get("rate", ""), availability=u.get("availability", ""), area_sqft=area, description=payload.get("description", ""), amenities=amenities, details=details, images=(u.get("photos") or payload.get("gallery") or [])[:20], )) return out # -- page immeuble ------------------------------------------------------------ def _fetch_building(self, url: str) -> dict: """Payload JSON-sérialisable : unités disponibles + infos immeuble.""" resp = self.get(url) resp.encoding = "utf-8" # charset absent des en-têtes html = resp.text soup = BeautifulSoup(html, "html.parser") payload: dict = {} # adresse de la bannière (identique à la liste, sans code postal) addr = soup.select_one(".banner-property__address--text") if addr: payload["address"] = addr.get_text(" ", strip=True) # description « À propos de la propriété » desc = soup.select_one(".property-about__content") if desc: payload["description"] = re.sub( r"\s+", " ", desc.get_text(" ", strip=True)).strip()[:1200] # commodités : immeuble + unité (blocs dupliqués desktop/mobile) amenities: list[str] = [] for li in soup.select(".amenities-default li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t and t not in amenities: amenities.append(t) payload["amenities"] = amenities # téléphone de l'immeuble (liens tel:, en excluant le siège social) for a in soup.select('a[href^="tel:"]'): digits = re.sub(r"\D", "", a["href"]) if len(digits) >= 10 and digits[-10:] != _CORPORATE_PHONE: d = digits[-10:] payload["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}" break # galerie de l'immeuble : préférer la variante /1152/, dédupliquer gallery: list[str] = [] seen: set[str] = set() urls = re.findall( r"https://s3\.amazonaws\.com/lws_lift/girescom/images/gallery/[^\"'\s]+", html) for u in urls: fname = u.rsplit("/", 1)[-1] if fname in seen: continue big = u.replace("/512/", "/1152/") if big in urls or big == u: seen.add(fname) gallery.append(big if big in urls else u) payload["gallery"] = gallery[:20] # tableau des unités disponibles (colonnes structurées column-field) units: list[dict] = [] for row in soup.select("li.unit-table-desktop__units--unit"): cols = {li.get("column-field"): li for li in row.select("li[column-field]")} def txt(field: str) -> str: el = cols.get(field) return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else "" unit = { "unit": txt("typeName"), "bed": txt("bed"), "bath": txt("bath"), "rate": txt("rate"), "sqft": txt("sqFt"), "availability": txt("availability"), } photos_el = cols.get("photos") if photos_el: unit["photos"] = list(dict.fromkeys( a["href"] for a in photos_el.select("a[href^='http']"))) plan_el = cols.get("floorplans") if plan_el: plan = plan_el.select_one("a[href^='http']") if plan: unit["floorplan"] = plan["href"] if unit["unit"] and unit["unit"] != "-": units.append(unit) payload["units"] = units return payload