SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
11.3 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/nomade.py : connecteur Nomade Gestion Immobilière5#   (nomadegestion.com — 16+ immeubles à Québec : Beauport, Charlesbourg,6#    Val-Bélair, Lebourgneuf… dont Le Melozio, L'Astral, Le Nordet ph. 1-2 et7#    Le Bélair qui ont leurs microsites). Site Squarespace : la liste des prix8#    et disponibilités est publiée dans un PDF dont le NOM CHANGE chaque mois9#    (ex. /s/Liste-site-web-juillet-26.pdf) — on découvre le lien depuis la10#    page d'accueil (bouton « Liste de prix et disponibilités »), puis on11#    parse le tableau du PDF avec pdfplumber : en-tête d'immeuble12#    (« L'Astral - 2500 de Beaubassin - Info ou visite 418-952-2701 »),13#    rangée « Inclus dans le prix : … », rangées d'unités (type | étage(s) |14#    prix ou fourchette | disponibilité) ou « Aucun logement disponible dans15#    cet immeuble ». Photos : page de l'immeuble (interne ou microsite) via16#    le cache BD des pages détail.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import io22import re2324from ..schema import Listing, normalize_unit_type, strip_accents25from .base import BaseConnector2627BASE = "https://nomadegestion.com"28HOME_URL = f"{BASE}/"2930PDF_LINK_RE = re.compile(r'href="(/s/[^"]+\.pdf)"', re.I)31PHONE_RE = re.compile(r"(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})")32# en-tête d'immeuble : « <nom/adresse> - … <téléphone> » (ou « - www.site - »)33HEADER_RE = re.compile(r"^(.{4,70}?)\s*-\s*(?:Info ou visit\w*|www\.)", re.I)34UNIT_TYPE_RE = re.compile(r"^\d\s*1/2(\s*\+\s*\w+)?|^Studio|^Loft", re.I)35FLOOR_RE = re.compile(r"[ÉEé]tages?\s+([\d,\set]+\d|\d)", re.I)36PRICE_RE = re.compile(r"(\d{3,4})\s*\$")37IMG_RE = re.compile(r"https://images\.squarespace-cdn\.com/[^\"'\s?)]+", re.I)38GENERIC_IMG_RE = re.compile(r'(?:src|data-src|content)="(https?://[^"]+'39                            r"\.(?:jpe?g|png|webp)(?:\?[^\"]*)?)\"", re.I)4041# immeubles connus (mot-clé de l'en-tête PDF -> métadonnées stables) :42# adresse complète, secteur (si connu avec certitude), page de l'annonce43BUILDINGS = {44    "astral": {"slug": "astral", "name": "L'Astral",45               "address": "2500, rue de Beaubassin, Québec",46               "sector": "Beauport", "url": "https://www.lastral.ca"},47    "lajeunesse": {"slug": "nordet1", "name": "Le Nordet — Phase 1",48                           "address": "2430, rue Gabriel-Lajeunesse, Québec",49                           "sector": "Beauport",50                           "url": "https://www.lenordet.com"},51    "cajuns": {"slug": "nordet2", "name": "Le Nordet — Phase 2",52               "address": "2500, rue des Cajuns, Québec",53               "sector": "Beauport", "url": "https://www.lenordet.com"},54    "pie xi": {"slug": "belair", "name": "Le Bélair",55               "address": "1200, boulevard Pie-XI Sud, Québec",56               "sector": "Val-Bélair", "url": "https://www.lebelair.ca"},57    "melozio": {"slug": "melozio", "name": "Le Melozio",58                "address": "4020, rue Adrien-Pouliot, Québec",59                "sector": "", "url": "https://www.melozio.ca"},60    "gros-pin": {"slug": "grospin", "name": "4155, rue de Gros-Pin",61                 "address": "4155, rue de Gros-Pin, Québec",62                 "sector": "Charlesbourg", "url": f"{BASE}/grospin"},63    "griffon": {"slug": "griffon", "name": "6395-6425, rue du Griffon",64                "address": "6395-6425, rue du Griffon, Québec",65                "sector": "", "url": f"{BASE}/griffon"},66    "belanger": {"slug": "belanger", "name": "445, avenue Bélanger",67                 "address": "445, avenue Bélanger, Québec",68                 "sector": "", "url": f"{BASE}/belanger"},69    "6311": {"slug": "6311", "name": "6311, avenue du Costebelle",70             "address": "6311, avenue du Costebelle, Québec",71             "sector": "", "url": f"{BASE}/6311"},72    "6315": {"slug": "6315", "name": "6315, avenue du Costebelle",73             "address": "6315, avenue du Costebelle, Québec",74             "sector": "", "url": f"{BASE}/6315"},75    "chutes": {"slug": "deschutes", "name": "Boul. des Chutes",76               "address": "1114-1140, boulevard des Chutes, Québec",77               "sector": "Beauport", "url": f"{BASE}/dc1"},78    "chapelier": {"slug": "mariechap", "name": "Marie-Chapelier",79                  "address": "281-340, rue Marie-Chapelier, Québec",80                  "sector": "", "url": f"{BASE}/mariechap"},81    "garnier": {"slug": "fgarnier", "name": "Françoise-Garnier",82                "address": "155-267, rue Françoise-Garnier, Québec",83                "sector": "Beauport", "url": f"{BASE}/fgarnier"},84    "louis xiv": {"slug": "edith", "name": "Boul. Louis-XIV et rue Édith",85                  "address": "Boul. Louis-XIV et rue Édith, Québec",86                  "sector": "Charlesbourg", "url": f"{BASE}/edith"},87}888990class NomadeConnector(BaseConnector):91    source_id = "nomade"92    request_delay = 0.69394    # -- découverte du PDF (le nom de fichier change chaque mois) --------------------95    def _discover_pdf_url(self) -> str:96        html = self.get(HOME_URL).text97        m = PDF_LINK_RE.search(html)98        if not m:99            raise RuntimeError("lien PDF « Liste de prix » introuvable "100                               "sur nomadegestion.com")101        return BASE + m.group(1)102103    def fetch(self) -> list[Listing]:104        import pdfplumber105106        pdf_url = self._discover_pdf_url()107        raw = self.get(pdf_url).content108        listings: list[Listing] = []109110        building: dict | None = None111        amenities: list[str] = []112        phone = ""113        seen: dict[str, int] = {}114115        with pdfplumber.open(io.BytesIO(raw)) as pdf:116            rows: list[list[str]] = []117            for page in pdf.pages:118                for table in page.extract_tables():119                    rows.extend(table)120121        for cells in rows:122            texts = [re.sub(r"\s+", " ", (c or "").strip()) for c in cells]123            first = texts[0]124            joined = " | ".join(t for t in texts if t)125            if not joined:126                continue127128            hm = HEADER_RE.match(first)129            if hm and PHONE_RE.search(first):130                # nouvel immeuble : retrouver ses métadonnées connues131                key = strip_accents(first.lower())132                building = None133                for kw, meta in BUILDINGS.items():134                    if strip_accents(kw) in key:135                        building = dict(meta)136                        break137                if building is None:      # immeuble inconnu : repli générique138                    name = hm.group(1).strip()139                    building = {"slug": re.sub(r"[^a-z0-9]+", "-",140                                               strip_accents(name.lower()))141                                .strip("-"),142                                "name": name, "address": f"{name}, Québec",143                                "sector": "", "url": f"{BASE}/immeubles"}144                pm = PHONE_RE.search(first)145                phone = f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}" if pm else ""146                amenities = []147                continue148149            if first.lower().startswith("inclus dans le prix"):150                amenities = [a.strip(" .").capitalize()151                             for a in re.split(r",| et ",152                                               first.split(":", 1)[-1])153                             if a.strip(" .")]154                continue155156            if "aucun logement disponible" in joined.lower():157                continue158159            if building is not None and UNIT_TYPE_RE.match(first):160                try:161                    lst = self._unit_listing(building, texts, joined,162                                             amenities, phone, seen)163                    if lst is not None:164                        listings.append(lst)165                except Exception:166                    continue167168        return listings169170    # -- rangée d'unité (type | étage(s) | prix | disponibilité) ---------------------171    def _unit_listing(self, building: dict, texts: list[str], joined: str,172                      amenities: list[str], phone: str,173                      seen: dict[str, int]) -> Listing | None:174        unit_type = normalize_unit_type(texts[0])175        if not unit_type:176            return None177178        fm = FLOOR_RE.search(joined)179        floor = re.sub(r"\s+", " ", fm.group(1)).strip() if fm else ""180181        # prix : montants plausibles (élimine « 50$ RABAIS / MOIS »)182        prices = [float(p) for p in PRICE_RE.findall(joined)183                  if 300 <= float(p) <= 20000]184        price = min(prices) if prices else None185        price_label = ""186        for t in texts:187            if PRICE_RE.search(t) and not re.search(r"rabais", t, re.I):188                price_label = t189                break190191        # disponibilité : dernière cellule non vide sans prix ni étage192        availability = ""193        for t in reversed(texts):194            if t and not PRICE_RE.search(t) and not FLOOR_RE.match(t):195                availability = t196                break197198        extras = [t for t in texts[1:]199                  if t and re.search(r"rabais|bail", t, re.I)]200201        base_id = re.sub(r"[^\w.\-]", "",202                         f"{building['slug']}-{unit_type.replace('½', '.5')}"203                         f"-{re.sub(r'[^0-9]+', '-', floor).strip('-') or 'x'}")204        seen[base_id] = seen.get(base_id, 0) + 1205        ext_id = base_id if seen[base_id] == 1 else f"{base_id}-{seen[base_id]}"206207        details: dict = {}208        if phone:209            details["contact"] = {"phone": phone}210211        lst = Listing(212            source=self.source_id,213            external_id=ext_id,214            url=building["url"],215            title=f"{unit_type} — {building['name']}",216            address=building["address"],217            sector=building.get("sector", ""),218            city="Québec",219            unit_type=unit_type,220            price=price,221            price_label=price_label,222            availability=availability,223            description=" | ".join(224                [f"Étage(s) : {floor}" if floor else ""] + extras).strip(" |"),225            amenities=list(amenities),226            details=details,227        )228        if floor:229            lst.amenities = lst.amenities + [f"Étage : {floor}"]230231        # photos : page de l'immeuble (interne ou microsite), via cache BD232        try:233            d = self.detail(building["slug"],234                            hashlib.sha1(building["url"].encode()).hexdigest(),235                            lambda u=building["url"]: self._fetch_images(u))236            if d.get("images"):237                lst.images = d["images"]238        except Exception:239            pass240        return lst241242    def _fetch_images(self, url: str) -> dict:243        """Photos de la page immeuble (Squarespace CDN ou microsite)."""244        try:245            html = self.get(url).text246        except Exception:247            return {}248        urls = IMG_RE.findall(html) + GENERIC_IMG_RE.findall(html)249        images = []250        for u in dict.fromkeys(urls):251            if re.search(r"logo|icon|favicon|\.svg", u, re.I):252                continue253            images.append(u)254        return {"images": images[:20]}255