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/girescom.py : connecteur Girescom (girescom.com)5# Site RentSync rendu serveur. Liste /residential : une carte par immeuble6# (adresse avec ville, prix « À partir de », fourchette de chambres).7# Page immeuble /residential/<slug> : tableau des unités disponibles8# (li.unit-table-desktop__units--unit, attributs column-field structurés :9# numéro, chambres, sdb, prix, pi², photos, plan d'étage, disponibilité),10# description « À propos de la propriété », commodités (immeuble + unité),11# téléphone de l'immeuble et galerie. Une annonce Lou-Ka = une unité.12# Périmètre : tout le portefeuille — Québec/Lévis, proche périphérie13# (Sainte-Brigitte-de-Laval, Shannon…) et Beauce (Saint-Georges,14# Sainte-Marie, Vallée-Jonction…) depuis l'expansion provinciale.15# Fiches immeuble via self.detail() (cache BD, max max_details/sync).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price, strip_accents25from .base import BaseConnector2627BASE = "https://www.girescom.com"28LIST_URL = f"{BASE}/residential"2930# Noms canoniques (accents/abréviations) des villes rencontrées dans les31# adresses — clé = forme normalisée (_city_key).32_CITY_CANON = {33 "quebec": "Québec",34 "levis": "Lévis",35 "saint-georges": "Saint-Georges", "st-georges": "Saint-Georges",36 "sainte-marie": "Sainte-Marie", "ste-marie": "Sainte-Marie",37 "vallee-jonction": "Vallée-Jonction",38 "saint-joseph-de-beauce": "Saint-Joseph-de-Beauce",39 "st-joseph-de-beauce": "Saint-Joseph-de-Beauce",40 "beauceville": "Beauceville",41 "saint-victor": "Saint-Victor",42 "east-broughton": "East Broughton",43 "tring-jonction": "Tring-Jonction",44 "saints-anges": "Saints-Anges",45 "scott": "Scott",46 "saint-elzear": "Saint-Elzéar", "st-elzear": "Saint-Elzéar",47 "frampton": "Frampton",48 "sainte-brigitte-de-laval": "Sainte-Brigitte-de-Laval",49 "shannon": "Shannon",50}5152# téléphone du siège social (affiché sur toutes les pages) — pas celui53# de l'immeuble54_CORPORATE_PHONE = "4186863040"555657def _city_key(city: str) -> str:58 return re.sub(r"[\s']+", "-", strip_accents(city.strip().lower()))596061def _unit_type(bed: str) -> str:62 """Chambres (colonne structurée) -> type Lou-Ka : 0 = Studio, n = (n+2)½."""63 b = (bed or "").strip()64 if b == "0":65 return "Studio"66 if b.isdigit():67 return normalize_unit_type(f"{b} chambres")68 return ""697071class GirescomConnector(BaseConnector):72 source_id = "girescom"73 request_delay = 0.674 max_details = 30 # garde-fou pages immeuble (vraies requêtes par sync)7576 def fetch(self) -> list[Listing]:77 # le serveur n'annonce pas charset=utf-8 : forcer le décodage78 resp = self.get(LIST_URL)79 resp.encoding = "utf-8"80 html = resp.text81 soup = BeautifulSoup(html, "html.parser")8283 self._fetched = 084 listings: list[Listing] = []85 for card in soup.select("ul.card-grouping-grid li.card-grouping-grid__item"):86 try:87 listings.extend(self._parse_building(card))88 except Exception:89 continue90 return listings9192 # -- carte immeuble (liste /residential) -------------------------------------93 def _parse_building(self, card) -> list[Listing]:94 link = card.select_one('a[href^="/residential/"]')95 if not link:96 return []97 slug = link["href"].rstrip("/").rsplit("/", 1)[-1]98 url = f"{BASE}/residential/{slug}"99100 title_el = card.select_one(".card-property__title")101 title = title_el.get_text(strip=True) if title_el else slug102 addr_el = card.select_one(".card-property__address--text")103 address = addr_el.get_text(" ", strip=True) if addr_el else ""104105 # « 7615 Rue des Métis, Quebec, QC » -> ville = avant-dernier segment106 parts = [p.strip() for p in address.split(",") if p.strip()]107 city = parts[-2] if len(parts) >= 2 else ""108 city = _CITY_CANON.get(_city_key(city), city) or "Québec"109110 rate_el = card.select_one(".card-property__rate-min")111 beds_el = card.select_one(".card-property__beds-text")112 card_sig = (f"{address}|{rate_el.get_text(strip=True) if rate_el else ''}"113 f"|{beds_el.get_text(strip=True) if beds_el else ''}")114115 # page immeuble (cache BD, invalidée quand la carte liste change)116 key = hashlib.sha1(card_sig.encode("utf-8")).hexdigest()117118 def fetch_fn():119 if self._fetched >= self.max_details:120 raise RuntimeError("budget de pages immeuble atteint")121 self._fetched += 1122 return self._fetch_building(url)123124 payload = self.detail(slug, key, fetch_fn)125 if not payload:126 return []127128 building_addr = payload.get("address") or address129 details_base: dict = {"building": title}130 if payload.get("phone"):131 details_base["contact"] = {"phone": payload["phone"]}132133 out: list[Listing] = []134 for u in payload.get("units", []):135 numero = u.get("unit") or ""136 if not numero:137 continue138 amenities = list(payload.get("amenities", []))139 if u.get("bed"):140 amenities.insert(0, f"{u['bed']} chambre(s)")141 if u.get("bath"):142 amenities.insert(1, f"{u['bath']} salle(s) de bain")143144 area = None145 sqft = re.sub(r"[\s, ]", "", u.get("sqft") or "")146 if re.fullmatch(r"\d{2,5}", sqft) and 80 <= float(sqft) <= 20000:147 area = float(sqft)148149 details = dict(details_base)150 if u.get("floorplan"):151 details["floorplan"] = u["floorplan"]152153 out.append(Listing(154 source=self.source_id,155 external_id=f"{slug}--{numero}",156 url=url,157 title=f"{title} — unité {numero}",158 address=building_addr,159 city=city,160 unit_type=_unit_type(u.get("bed", "")),161 price=parse_price(u.get("rate", "")),162 price_label=u.get("rate", ""),163 availability=u.get("availability", ""),164 area_sqft=area,165 description=payload.get("description", ""),166 amenities=amenities,167 details=details,168 images=(u.get("photos") or payload.get("gallery") or [])[:20],169 ))170 return out171172 # -- page immeuble ------------------------------------------------------------173 def _fetch_building(self, url: str) -> dict:174 """Payload JSON-sérialisable : unités disponibles + infos immeuble."""175 resp = self.get(url)176 resp.encoding = "utf-8" # charset absent des en-têtes177 html = resp.text178 soup = BeautifulSoup(html, "html.parser")179 payload: dict = {}180181 # adresse de la bannière (identique à la liste, sans code postal)182 addr = soup.select_one(".banner-property__address--text")183 if addr:184 payload["address"] = addr.get_text(" ", strip=True)185186 # description « À propos de la propriété »187 desc = soup.select_one(".property-about__content")188 if desc:189 payload["description"] = re.sub(190 r"\s+", " ", desc.get_text(" ", strip=True)).strip()[:1200]191192 # commodités : immeuble + unité (blocs dupliqués desktop/mobile)193 amenities: list[str] = []194 for li in soup.select(".amenities-default li"):195 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))196 if t and t not in amenities:197 amenities.append(t)198 payload["amenities"] = amenities199200 # téléphone de l'immeuble (liens tel:, en excluant le siège social)201 for a in soup.select('a[href^="tel:"]'):202 digits = re.sub(r"\D", "", a["href"])203 if len(digits) >= 10 and digits[-10:] != _CORPORATE_PHONE:204 d = digits[-10:]205 payload["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}"206 break207208 # galerie de l'immeuble : préférer la variante /1152/, dédupliquer209 gallery: list[str] = []210 seen: set[str] = set()211 urls = re.findall(212 r"https://s3\.amazonaws\.com/lws_lift/girescom/images/gallery/[^\"'\s]+",213 html)214 for u in urls:215 fname = u.rsplit("/", 1)[-1]216 if fname in seen:217 continue218 big = u.replace("/512/", "/1152/")219 if big in urls or big == u:220 seen.add(fname)221 gallery.append(big if big in urls else u)222 payload["gallery"] = gallery[:20]223224 # tableau des unités disponibles (colonnes structurées column-field)225 units: list[dict] = []226 for row in soup.select("li.unit-table-desktop__units--unit"):227 cols = {li.get("column-field"): li228 for li in row.select("li[column-field]")}229230 def txt(field: str) -> str:231 el = cols.get(field)232 return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else ""233234 unit = {235 "unit": txt("typeName"),236 "bed": txt("bed"),237 "bath": txt("bath"),238 "rate": txt("rate"),239 "sqft": txt("sqFt"),240 "availability": txt("availability"),241 }242 photos_el = cols.get("photos")243 if photos_el:244 unit["photos"] = list(dict.fromkeys(245 a["href"] for a in photos_el.select("a[href^='http']")))246 plan_el = cols.get("floorplans")247 if plan_el:248 plan = plan_el.select_one("a[href^='http']")249 if plan:250 unit["floorplan"] = plan["href"]251 if unit["unit"] and unit["unit"] != "-":252 units.append(unit)253 payload["units"] = units254 return payload255