# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/iparc.py : connecteur iParc (iparc.ca) — logements à louer à # Montréal. WordPress + plugin Estatik (type « property » non exposé au # REST) : on parcourt les pages de la catégorie /property-category/a-louer/ # (+ /page/N/) dont les cartes .es-listing portent data-post-id (external_id # stable) et le lien /property//. La fiche fournit les champs Estatik # (« Taille : 4 1/2 », Chambres, Salle de bain), le prix (.es-price), les # équipements/caractéristiques, les coordonnées GPS (data-latitude/ # longitude), la galerie (wp-content/uploads, miniatures -WxH retirées) et # une description bilingue avec l'adresse (📍 …) et la disponibilité. # Fiches via le cache détail (clé = hash du texte de la carte). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://iparc.ca" CAT_URL = f"{BASE}/property-category/a-louer/" _MAX_PAGES = 10 # garde-fou pagination FIELD_RE = re.compile(r"^([^:]{2,30})\s*:\s*(.+)$", re.S) # adresse civique après le 📍 (doit commencer par un numéro) ADDR_RE = re.compile(r"📍\s*(\d[\w\s.,'°é½-]{5,80}?)\s*[–—(]") DISPO_RE = re.compile( r"Disponible\s*(?:le\s*)?(?:immédiatement|d[èe]s maintenant" r"|\d{1,2}\s*(?:er)?\s*(?:janvier|février|mars|avril|mai|juin|juillet" r"|août|septembre|octobre|novembre|décembre)(?:\s*20\d\d)?)", re.I) # repli adresse : « 5187 rue Berri », « 6050, 30e Avenue »… ADDR_FALLBACK_RE = re.compile( r"(\d{2,5}(?:\s*,)?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|" r"place|\d{1,2}e\s+Avenue)\s*[A-ZÀ-Ü0-9][\w'’.é½-]*(?:\s+[A-ZÀ-Ü][\w'’é-]+)?)") THUMB_RE = re.compile(r"-\d+x\d+(\.\w+)$") IMG_BAD_RE = re.compile(r"logo|icon|favicon|iparc20\d\d", re.I) class IparcConnector(BaseConnector): source_id = "iparc" request_delay = 0.7 def fetch(self) -> list[Listing]: listings: list[Listing] = [] seen: set[str] = set() for page in range(1, _MAX_PAGES + 1): url = CAT_URL if page == 1 else f"{CAT_URL}page/{page}/" try: html = self.get(url).text except Exception: break # 404 = fin de la pagination soup = BeautifulSoup(html, "html.parser") cards = soup.select(".es-listing[data-post-id]") if not cards: break for card in cards: try: pid = str(card["data-post-id"]) if pid in seen: continue seen.add(pid) a = card.find("a", href=re.compile(r"/property/")) if not a: continue href = a["href"] card_text = card.get_text(" ", strip=True) key = hashlib.sha1( card_text.encode("utf-8")).hexdigest() d = self.detail(pid, key, lambda h=href: self._property(h)) lst = self._listing(pid, href, d) if lst: listings.append(lst) except Exception: continue return listings def _property(self, url: str) -> dict: """Scrape la fiche Estatik : champs, prix, GPS, galerie, description.""" out: dict = {"title": "", "price": "", "fields": {}, "desc": "", "amenities": [], "lat": None, "lng": None, "images": []} page = self.get(url).text soup = BeautifulSoup(page, "html.parser") if soup.h1: out["title"] = soup.h1.get_text(" ", strip=True) pr = soup.select_one(".es-price") if pr: out["price"] = pr.get_text(" ", strip=True) for f in soup.select(".es-property-field"): t = re.sub(r"\s+", " ", f.get_text(" ", strip=True)) m = FIELD_RE.match(t) if not m: continue label, value = m.group(1).strip(), m.group(2).strip() if label.lower().startswith("la description"): out["desc"] = value[:3000] elif label.lower().startswith(("équipements", "caractéristiques")): out["amenities"].extend( x.strip() for x in value.split(" ") if x.strip()) else: out["fields"][label] = value geo = soup.select_one("[data-latitude][data-longitude]") if geo: try: out["lat"] = float(geo["data-latitude"]) out["lng"] = float(geo["data-longitude"]) except (TypeError, ValueError): pass images = [] for img in soup.select(".es-single img, .es-mobile-gallery img"): u = img.get("src") or img.get("data-lazy") or "" if not u.startswith("http") or "/uploads/" not in u \ or IMG_BAD_RE.search(u): continue u = THUMB_RE.sub(r"\1", u) # miniature -> pleine taille if u not in images: images.append(u) out["images"] = images[:15] return out def _listing(self, pid: str, url: str, d: dict) -> Listing | None: fields = d.get("fields", {}) desc = d.get("desc", "") title = d.get("title") or "" taille = fields.get("Taille", "") unit_type = normalize_unit_type(taille) if taille else "" bedrooms = bathrooms = None try: if fields.get("Chambres"): bedrooms = float(fields["Chambres"]) except ValueError: pass try: if fields.get("Salle de bain"): bathrooms = float(fields["Salle de bain"]) except ValueError: pass address = "" am = ADDR_RE.search(desc) or ADDR_FALLBACK_RE.search(desc) if am: address = re.sub(r"\s+,", ",", am.group(1)).strip() availability = "" dm = DISPO_RE.search(desc) if dm: availability = re.sub(r"\s+", " ", dm.group(0)).strip() sector = "" poi = fields.get("centre d'intéret") or fields.get("centre d'intérêt") if poi: premier = poi.split(",")[0].strip() # noms propres seulement (écarte « commerces », « métro »…) if premier[:1].isupper(): sector = premier amenities = list(dict.fromkeys(d.get("amenities", []))) pieces = fields.get("Nombre de pièces") if pieces: amenities.append(f"{pieces} pièces") furnished = None blob = " ".join(amenities).lower() if "non meublé" in blob: furnished = False elif "meublé" in blob or "meublé" in title.lower(): furnished = True return Listing( source=self.source_id, external_id=pid, url=url, title=title, address=address, sector=sector, city="Montréal", unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price=parse_price(d.get("price", "")), price_label=d.get("price", ""), availability=availability, furnished=furnished, description=desc, amenities=amenities, images=d.get("images", []), lat=d.get("lat"), lng=d.get("lng"), )