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/iparc.py : connecteur iParc (iparc.ca) — logements à louer à5# Montréal. WordPress + plugin Estatik (type « property » non exposé au6# REST) : on parcourt les pages de la catégorie /property-category/a-louer/7# (+ /page/N/) dont les cartes .es-listing portent data-post-id (external_id8# stable) et le lien /property/<slug>/. La fiche fournit les champs Estatik9# (« Taille : 4 1/2 », Chambres, Salle de bain), le prix (.es-price), les10# équipements/caractéristiques, les coordonnées GPS (data-latitude/11# longitude), la galerie (wp-content/uploads, miniatures -WxH retirées) et12# une description bilingue avec l'adresse (📍 …) et la disponibilité.13# Fiches via le cache détail (clé = hash du texte de la carte).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://iparc.ca"26CAT_URL = f"{BASE}/property-category/a-louer/"27_MAX_PAGES = 10 # garde-fou pagination2829FIELD_RE = re.compile(r"^([^:]{2,30})\s*:\s*(.+)$", re.S)30# adresse civique après le 📍 (doit commencer par un numéro)31ADDR_RE = re.compile(r"📍\s*(\d[\w\s.,'°é½-]{5,80}?)\s*[–—(]")32DISPO_RE = re.compile(33 r"Disponible\s*(?:le\s*)?(?:immédiatement|d[èe]s maintenant"34 r"|\d{1,2}\s*(?:er)?\s*(?:janvier|février|mars|avril|mai|juin|juillet"35 r"|août|septembre|octobre|novembre|décembre)(?:\s*20\d\d)?)", re.I)36# repli adresse : « 5187 rue Berri », « 6050, 30e Avenue »…37ADDR_FALLBACK_RE = re.compile(38 r"(\d{2,5}(?:\s*,)?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|"39 r"place|\d{1,2}e\s+Avenue)\s*[A-ZÀ-Ü0-9][\w'’.é½-]*(?:\s+[A-ZÀ-Ü][\w'’é-]+)?)")40THUMB_RE = re.compile(r"-\d+x\d+(\.\w+)$")41IMG_BAD_RE = re.compile(r"logo|icon|favicon|iparc20\d\d", re.I)424344class IparcConnector(BaseConnector):45 source_id = "iparc"46 request_delay = 0.74748 def fetch(self) -> list[Listing]:49 listings: list[Listing] = []50 seen: set[str] = set()51 for page in range(1, _MAX_PAGES + 1):52 url = CAT_URL if page == 1 else f"{CAT_URL}page/{page}/"53 try:54 html = self.get(url).text55 except Exception:56 break # 404 = fin de la pagination57 soup = BeautifulSoup(html, "html.parser")58 cards = soup.select(".es-listing[data-post-id]")59 if not cards:60 break61 for card in cards:62 try:63 pid = str(card["data-post-id"])64 if pid in seen:65 continue66 seen.add(pid)67 a = card.find("a", href=re.compile(r"/property/"))68 if not a:69 continue70 href = a["href"]71 card_text = card.get_text(" ", strip=True)72 key = hashlib.sha1(73 card_text.encode("utf-8")).hexdigest()74 d = self.detail(pid, key,75 lambda h=href: self._property(h))76 lst = self._listing(pid, href, d)77 if lst:78 listings.append(lst)79 except Exception:80 continue81 return listings8283 def _property(self, url: str) -> dict:84 """Scrape la fiche Estatik : champs, prix, GPS, galerie, description."""85 out: dict = {"title": "", "price": "", "fields": {}, "desc": "",86 "amenities": [], "lat": None, "lng": None, "images": []}87 page = self.get(url).text88 soup = BeautifulSoup(page, "html.parser")89 if soup.h1:90 out["title"] = soup.h1.get_text(" ", strip=True)91 pr = soup.select_one(".es-price")92 if pr:93 out["price"] = pr.get_text(" ", strip=True)94 for f in soup.select(".es-property-field"):95 t = re.sub(r"\s+", " ", f.get_text(" ", strip=True))96 m = FIELD_RE.match(t)97 if not m:98 continue99 label, value = m.group(1).strip(), m.group(2).strip()100 if label.lower().startswith("la description"):101 out["desc"] = value[:3000]102 elif label.lower().startswith(("équipements",103 "caractéristiques")):104 out["amenities"].extend(105 x.strip() for x in value.split(" ") if x.strip())106 else:107 out["fields"][label] = value108 geo = soup.select_one("[data-latitude][data-longitude]")109 if geo:110 try:111 out["lat"] = float(geo["data-latitude"])112 out["lng"] = float(geo["data-longitude"])113 except (TypeError, ValueError):114 pass115 images = []116 for img in soup.select(".es-single img, .es-mobile-gallery img"):117 u = img.get("src") or img.get("data-lazy") or ""118 if not u.startswith("http") or "/uploads/" not in u \119 or IMG_BAD_RE.search(u):120 continue121 u = THUMB_RE.sub(r"\1", u) # miniature -> pleine taille122 if u not in images:123 images.append(u)124 out["images"] = images[:15]125 return out126127 def _listing(self, pid: str, url: str, d: dict) -> Listing | None:128 fields = d.get("fields", {})129 desc = d.get("desc", "")130 title = d.get("title") or ""131132 taille = fields.get("Taille", "")133 unit_type = normalize_unit_type(taille) if taille else ""134 bedrooms = bathrooms = None135 try:136 if fields.get("Chambres"):137 bedrooms = float(fields["Chambres"])138 except ValueError:139 pass140 try:141 if fields.get("Salle de bain"):142 bathrooms = float(fields["Salle de bain"])143 except ValueError:144 pass145146 address = ""147 am = ADDR_RE.search(desc) or ADDR_FALLBACK_RE.search(desc)148 if am:149 address = re.sub(r"\s+,", ",", am.group(1)).strip()150 availability = ""151 dm = DISPO_RE.search(desc)152 if dm:153 availability = re.sub(r"\s+", " ", dm.group(0)).strip()154155 sector = ""156 poi = fields.get("centre d'intéret") or fields.get("centre d'intérêt")157 if poi:158 premier = poi.split(",")[0].strip()159 # noms propres seulement (écarte « commerces », « métro »…)160 if premier[:1].isupper():161 sector = premier162163 amenities = list(dict.fromkeys(d.get("amenities", [])))164 pieces = fields.get("Nombre de pièces")165 if pieces:166 amenities.append(f"{pieces} pièces")167 furnished = None168 blob = " ".join(amenities).lower()169 if "non meublé" in blob:170 furnished = False171 elif "meublé" in blob or "meublé" in title.lower():172 furnished = True173174 return Listing(175 source=self.source_id,176 external_id=pid,177 url=url,178 title=title,179 address=address,180 sector=sector,181 city="Montréal",182 unit_type=unit_type,183 bedrooms=bedrooms,184 bathrooms=bathrooms,185 price=parse_price(d.get("price", "")),186 price_label=d.get("price", ""),187 availability=availability,188 furnished=furnished,189 description=desc,190 amenities=amenities,191 images=d.get("images", []),192 lat=d.get("lat"),193 lng=d.get("lng"),194 )195