# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/nomade.py : connecteur Nomade Gestion Immobilière # (nomadegestion.com — 16+ immeubles à Québec : Beauport, Charlesbourg, # Val-Bélair, Lebourgneuf… dont Le Melozio, L'Astral, Le Nordet ph. 1-2 et # Le Bélair qui ont leurs microsites). Site Squarespace : la liste des prix # et disponibilités est publiée dans un PDF dont le NOM CHANGE chaque mois # (ex. /s/Liste-site-web-juillet-26.pdf) — on découvre le lien depuis la # page d'accueil (bouton « Liste de prix et disponibilités »), puis on # parse le tableau du PDF avec pdfplumber : en-tête d'immeuble # (« L'Astral - 2500 de Beaubassin - Info ou visite 418-952-2701 »), # rangée « Inclus dans le prix : … », rangées d'unités (type | étage(s) | # prix ou fourchette | disponibilité) ou « Aucun logement disponible dans # cet immeuble ». Photos : page de l'immeuble (interne ou microsite) via # le cache BD des pages détail. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import io import re from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector BASE = "https://nomadegestion.com" HOME_URL = f"{BASE}/" PDF_LINK_RE = re.compile(r'href="(/s/[^"]+\.pdf)"', re.I) PHONE_RE = re.compile(r"(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})") # en-tête d'immeuble : « - … » (ou « - www.site - ») HEADER_RE = re.compile(r"^(.{4,70}?)\s*-\s*(?:Info ou visit\w*|www\.)", re.I) UNIT_TYPE_RE = re.compile(r"^\d\s*1/2(\s*\+\s*\w+)?|^Studio|^Loft", re.I) FLOOR_RE = re.compile(r"[ÉEé]tages?\s+([\d,\set]+\d|\d)", re.I) PRICE_RE = re.compile(r"(\d{3,4})\s*\$") IMG_RE = re.compile(r"https://images\.squarespace-cdn\.com/[^\"'\s?)]+", re.I) GENERIC_IMG_RE = re.compile(r'(?:src|data-src|content)="(https?://[^"]+' r"\.(?:jpe?g|png|webp)(?:\?[^\"]*)?)\"", re.I) # immeubles connus (mot-clé de l'en-tête PDF -> métadonnées stables) : # adresse complète, secteur (si connu avec certitude), page de l'annonce BUILDINGS = { "astral": {"slug": "astral", "name": "L'Astral", "address": "2500, rue de Beaubassin, Québec", "sector": "Beauport", "url": "https://www.lastral.ca"}, "lajeunesse": {"slug": "nordet1", "name": "Le Nordet — Phase 1", "address": "2430, rue Gabriel-Lajeunesse, Québec", "sector": "Beauport", "url": "https://www.lenordet.com"}, "cajuns": {"slug": "nordet2", "name": "Le Nordet — Phase 2", "address": "2500, rue des Cajuns, Québec", "sector": "Beauport", "url": "https://www.lenordet.com"}, "pie xi": {"slug": "belair", "name": "Le Bélair", "address": "1200, boulevard Pie-XI Sud, Québec", "sector": "Val-Bélair", "url": "https://www.lebelair.ca"}, "melozio": {"slug": "melozio", "name": "Le Melozio", "address": "4020, rue Adrien-Pouliot, Québec", "sector": "", "url": "https://www.melozio.ca"}, "gros-pin": {"slug": "grospin", "name": "4155, rue de Gros-Pin", "address": "4155, rue de Gros-Pin, Québec", "sector": "Charlesbourg", "url": f"{BASE}/grospin"}, "griffon": {"slug": "griffon", "name": "6395-6425, rue du Griffon", "address": "6395-6425, rue du Griffon, Québec", "sector": "", "url": f"{BASE}/griffon"}, "belanger": {"slug": "belanger", "name": "445, avenue Bélanger", "address": "445, avenue Bélanger, Québec", "sector": "", "url": f"{BASE}/belanger"}, "6311": {"slug": "6311", "name": "6311, avenue du Costebelle", "address": "6311, avenue du Costebelle, Québec", "sector": "", "url": f"{BASE}/6311"}, "6315": {"slug": "6315", "name": "6315, avenue du Costebelle", "address": "6315, avenue du Costebelle, Québec", "sector": "", "url": f"{BASE}/6315"}, "chutes": {"slug": "deschutes", "name": "Boul. des Chutes", "address": "1114-1140, boulevard des Chutes, Québec", "sector": "Beauport", "url": f"{BASE}/dc1"}, "chapelier": {"slug": "mariechap", "name": "Marie-Chapelier", "address": "281-340, rue Marie-Chapelier, Québec", "sector": "", "url": f"{BASE}/mariechap"}, "garnier": {"slug": "fgarnier", "name": "Françoise-Garnier", "address": "155-267, rue Françoise-Garnier, Québec", "sector": "Beauport", "url": f"{BASE}/fgarnier"}, "louis xiv": {"slug": "edith", "name": "Boul. Louis-XIV et rue Édith", "address": "Boul. Louis-XIV et rue Édith, Québec", "sector": "Charlesbourg", "url": f"{BASE}/edith"}, } class NomadeConnector(BaseConnector): source_id = "nomade" request_delay = 0.6 # -- découverte du PDF (le nom de fichier change chaque mois) -------------------- def _discover_pdf_url(self) -> str: html = self.get(HOME_URL).text m = PDF_LINK_RE.search(html) if not m: raise RuntimeError("lien PDF « Liste de prix » introuvable " "sur nomadegestion.com") return BASE + m.group(1) def fetch(self) -> list[Listing]: import pdfplumber pdf_url = self._discover_pdf_url() raw = self.get(pdf_url).content listings: list[Listing] = [] building: dict | None = None amenities: list[str] = [] phone = "" seen: dict[str, int] = {} with pdfplumber.open(io.BytesIO(raw)) as pdf: rows: list[list[str]] = [] for page in pdf.pages: for table in page.extract_tables(): rows.extend(table) for cells in rows: texts = [re.sub(r"\s+", " ", (c or "").strip()) for c in cells] first = texts[0] joined = " | ".join(t for t in texts if t) if not joined: continue hm = HEADER_RE.match(first) if hm and PHONE_RE.search(first): # nouvel immeuble : retrouver ses métadonnées connues key = strip_accents(first.lower()) building = None for kw, meta in BUILDINGS.items(): if strip_accents(kw) in key: building = dict(meta) break if building is None: # immeuble inconnu : repli générique name = hm.group(1).strip() building = {"slug": re.sub(r"[^a-z0-9]+", "-", strip_accents(name.lower())) .strip("-"), "name": name, "address": f"{name}, Québec", "sector": "", "url": f"{BASE}/immeubles"} pm = PHONE_RE.search(first) phone = f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}" if pm else "" amenities = [] continue if first.lower().startswith("inclus dans le prix"): amenities = [a.strip(" .").capitalize() for a in re.split(r",| et ", first.split(":", 1)[-1]) if a.strip(" .")] continue if "aucun logement disponible" in joined.lower(): continue if building is not None and UNIT_TYPE_RE.match(first): try: lst = self._unit_listing(building, texts, joined, amenities, phone, seen) if lst is not None: listings.append(lst) except Exception: continue return listings # -- rangée d'unité (type | étage(s) | prix | disponibilité) --------------------- def _unit_listing(self, building: dict, texts: list[str], joined: str, amenities: list[str], phone: str, seen: dict[str, int]) -> Listing | None: unit_type = normalize_unit_type(texts[0]) if not unit_type: return None fm = FLOOR_RE.search(joined) floor = re.sub(r"\s+", " ", fm.group(1)).strip() if fm else "" # prix : montants plausibles (élimine « 50$ RABAIS / MOIS ») prices = [float(p) for p in PRICE_RE.findall(joined) if 300 <= float(p) <= 20000] price = min(prices) if prices else None price_label = "" for t in texts: if PRICE_RE.search(t) and not re.search(r"rabais", t, re.I): price_label = t break # disponibilité : dernière cellule non vide sans prix ni étage availability = "" for t in reversed(texts): if t and not PRICE_RE.search(t) and not FLOOR_RE.match(t): availability = t break extras = [t for t in texts[1:] if t and re.search(r"rabais|bail", t, re.I)] base_id = re.sub(r"[^\w.\-]", "", f"{building['slug']}-{unit_type.replace('½', '.5')}" f"-{re.sub(r'[^0-9]+', '-', floor).strip('-') or 'x'}") seen[base_id] = seen.get(base_id, 0) + 1 ext_id = base_id if seen[base_id] == 1 else f"{base_id}-{seen[base_id]}" details: dict = {} if phone: details["contact"] = {"phone": phone} lst = Listing( source=self.source_id, external_id=ext_id, url=building["url"], title=f"{unit_type} — {building['name']}", address=building["address"], sector=building.get("sector", ""), city="Québec", unit_type=unit_type, price=price, price_label=price_label, availability=availability, description=" | ".join( [f"Étage(s) : {floor}" if floor else ""] + extras).strip(" |"), amenities=list(amenities), details=details, ) if floor: lst.amenities = lst.amenities + [f"Étage : {floor}"] # photos : page de l'immeuble (interne ou microsite), via cache BD try: d = self.detail(building["slug"], hashlib.sha1(building["url"].encode()).hexdigest(), lambda u=building["url"]: self._fetch_images(u)) if d.get("images"): lst.images = d["images"] except Exception: pass return lst def _fetch_images(self, url: str) -> dict: """Photos de la page immeuble (Squarespace CDN ou microsite).""" try: html = self.get(url).text except Exception: return {} urls = IMG_RE.findall(html) + GENERIC_IMG_RE.findall(html) images = [] for u in dict.fromkeys(urls): if re.search(r"logo|icon|favicon|\.svg", u, re.I): continue images.append(u) return {"images": images[:20]}