Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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