SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.2 KB · 255 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/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