spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/immomarketing.py : connecteur IMMOMARKETING / Immoappart5# (immoappart.ca). Le portail couvre la région de Québec ET le Grand6# Montréal (île de Montréal, Laval, Longueuil/Rive-Sud) : on crawle les7# pages de secteurs (les plus précises d'abord pour un meilleur secteur).8# Une annonce par unité (fiche /appartement/<slug>/) ; les fiches passent9# par le cache BD self.detail() (clé = hash de la carte liste) : seules10# les fiches nouvelles ou modifiées sont re-téléchargées.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import re1617from bs4 import BeautifulSoup1819from ..schema import (Listing, infer_city, normalize_unit_type, parse_price,20 strip_accents)21from .base import BaseConnector2223BASE = "https://immoappart.ca"24# page de secteur -> (secteur par défaut, ville par défaut). Ordre important :25# pages précises d'abord (le premier secteur rencontré est conservé).26SECTOR_PAGES = {27 # Région de Québec28 f"{BASE}/appartements-a-louer/quebec/lebourgneuf/": ("Lebourgneuf", "Québec"),29 f"{BASE}/appartements-a-louer/quebec/saint-roch-2/": ("Saint-Roch", "Québec"),30 # Île de Montréal31 f"{BASE}/appartements-a-louer/montreal/ahuntsic-cartierville/":32 ("Ahuntsic-Cartierville", "Montréal"),33 f"{BASE}/appartements-a-louer/montreal/cote-des-neiges/":34 ("Côte-des-Neiges", "Montréal"),35 f"{BASE}/appartements-a-louer/montreal/saint-leonard/":36 ("Saint-Léonard", "Montréal"),37 f"{BASE}/appartements-a-louer/saint-laurent/": ("Saint-Laurent", "Montréal"),38 f"{BASE}/appartements-a-louer/montreal/": ("", "Montréal"),39 # Laval40 f"{BASE}/appartements-a-louer/laval/": ("", "Laval"),41 # Rive-Sud (page agrégée en dernier : villes déduites fiche par fiche)42 f"{BASE}/appartements-a-louer/saint-lambert/": ("", "Saint-Lambert"),43 f"{BASE}/appartements-a-louer/longueuil/": ("", "Longueuil"),44 f"{BASE}/appartements-a-louer/rive-sud/": ("", ""),45}46# secteur affiché sur la fiche (bandeau .city) -> vraie ville, hors région Qc47_SECTOR_CITY = {48 "montreal": "Montréal", "ahuntsic": "Montréal",49 "ahuntsic-cartierville": "Montréal", "cote-des-neiges": "Montréal",50 "saint-leonard": "Montréal", "saint-laurent": "Montréal",51 "ville saint-laurent": "Montréal",52 "laval": "Laval",53 "longueuil": "Longueuil", "saint-hubert": "Longueuil",54 "greenfield park": "Longueuil", "vieux-longueuil": "Longueuil",55 "saint-lambert": "Saint-Lambert", "brossard": "Brossard",56 "boucherville": "Boucherville", "chambly": "Chambly",57 "candiac": "Candiac", "la prairie": "La Prairie",58 "richelieu": "Richelieu", "mcmasterville": "McMasterville",59 "saint-bruno": "Saint-Bruno-de-Montarville",60 "saint-bruno-de-montarville": "Saint-Bruno-de-Montarville",61 "saint-hilaire": "Mont-Saint-Hilaire",62 "mont-saint-hilaire": "Mont-Saint-Hilaire",63 "sainte-julie": "Sainte-Julie", "varennes": "Varennes",64 "beloeil": "Belœil",65}66IMG_RE = re.compile(r"https://immoappart\.ca/wp-content/uploads/"67 r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)686970class ImmomarketingConnector(BaseConnector):71 source_id = "immomarketing"72 request_delay = 0.673 max_details = 120 # garde-fou de fetch des fiches (Qc + Grand Mtl)7475 def fetch(self) -> list[Listing]:76 # 1) Fiches référencées par les pages de secteurs (Qc + Grand Mtl).77 # La carte liste (adresse, prix, type, badge) sert de clé de cache :78 # la fiche n'est re-téléchargée que si sa carte a changé.79 pages: dict[str, tuple[str, str, str]] = {} # url -> (secteur, ville, clé)80 for sector_url, (sector, city) in SECTOR_PAGES.items():81 try:82 html = self.get(sector_url).text83 except Exception:84 continue85 soup = BeautifulSoup(html, "html.parser")86 for a in soup.select('a[href*="/appartement/"]'):87 href = (a.get("href") or "").split("?")[0]88 if not re.search(r"/appartement/[a-z0-9\-]+/?$", href):89 continue90 card_txt = re.sub(r"\s+", " ", a.get_text(" ", strip=True))91 key = hashlib.sha1(card_txt.encode("utf-8")).hexdigest()[:20]92 pages.setdefault(href.rstrip("/") + "/", (sector, city, key))9394 # 2) Fiches détaillées (via le cache BD ; budget de vraies requêtes)95 self._fetched = 096 listings: list[Listing] = []97 for url, (sector, city, key) in pages.items():98 slug = url.rstrip("/").split("/")[-1]99 try:100 payload = self.detail(slug, key,101 lambda u=url: self._fetch_detail(u))102 except Exception:103 continue104 if not payload: # fiche commerciale / stationnement / rangement105 continue106 try:107 listings.append(self._build(url, slug, sector, city, payload))108 except Exception:109 continue110 return listings111112 # -- fiche détaillée -> payload JSON-sérialisable (mis en cache) ------------113 def _fetch_detail(self, url: str) -> dict:114 if self._fetched >= self.max_details:115 raise RuntimeError("budget de requêtes détail atteint")116 self._fetched += 1117 html = self.get(url).text118 soup = BeautifulSoup(html, "html.parser")119120 h1 = soup.find("h1")121 title = h1.get_text(" ", strip=True) if h1 else ""122 if re.search(r"Commercial|Stationnement|Rangement", title, re.I):123 return {}124125 # Bandeau : secteur / type / prix / disponibilité126 band = soup.select_one("p.selected-appart-title")127 band_sector = unit_type = price_label = ""128 if band:129 size = band.select_one(".size")130 unit_type = size.get_text(strip=True) if size else ""131 price = band.select_one(".price")132 price_label = price.get_text(strip=True) if price else ""133 city_el = band.select_one(".city")134 if city_el:135 band_sector = city_el.get_text(strip=True)136137 availability = ""138 for s in soup.find_all(string=re.compile("Date de disponibilité")):139 parent = s.find_parent("p")140 if parent:141 cand = re.sub(r".*Date de disponibilité\s*:?\s*", "",142 parent.get_text(" ", strip=True)).strip()143 if cand:144 availability = cand145 break146147 # Adresse : h2 sous le h1148 address = ""149 for h2 in soup.find_all("h2"):150 t = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))151 if re.match(r"^\d{1,5},?\s+\S[^,]{1,60},", t):152 address = t153 break154 if not address:155 # h2 « numéro + rue » sans virgule finale (ex. « 570, Charest est »)156 for h2 in soup.find_all("h2"):157 t = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))158 if len(t) <= 70 and re.match(r"^\d{1,5},?\s+\S", t) \159 and not re.search(r"mois|gratuit|offre|promo|rabais",160 t, re.I):161 address = t162 break163 if not address:164 m = re.search(r"(?<![\d½])\b\d{2,5}\s+(?:rue|boulevard|avenue|"165 r"chemin|côte)[^,<>]{2,50},\s*[^,<>]{2,40},\s*QC",166 re.sub(r"\s+", " ", soup.get_text(" ", strip=True)),167 re.I)168 if m:169 address = m.group(0)170 if not address:171 # dernier recours : le titre commence parfois par l'adresse172 m = re.match(r"^(\d{1,5},?\s+.{3,60}?)\s+[-–]\s+\d\s*½", title)173 if m:174 address = m.group(1).strip()175176 # Photos : galerie de la fiche (éviter les fiches « même secteur »)177 images: list[str] = []178 gallery = soup.select_one(".appart-gallery") or soup179 for el in gallery.select("img[src], a[href]"):180 u = (el.get("src") or el.get("href") or "").split("?")[0]181 if IMG_RE.match(u) and not re.search(r"logo|icon|favicon", u, re.I):182 images.append(u)183 banner = soup.select_one(".banner-appart img[src]")184 if banner:185 images.insert(0, banner["src"].split("?")[0])186 images = list(dict.fromkeys(images))[:25]187188 # Description complète (.the-content : promotions, inclusions de189 # loisirs, informations supplémentaires — animaux, fumeur, contact)190 desc = ""191 content = soup.select_one(".the-content")192 if content:193 desc = re.sub(r"\s+", " ",194 content.get_text(" ", strip=True)).strip()[:1800]195 if not desc:196 og = soup.find("meta", attrs={"property": "og:description"})197 desc = og["content"].strip()[:600] if og and og.get("content") else ""198199 # Commodités : liste « Inclusions » (dropdown dédié de la fiche)200 amenities: list[str] = []201 specs: dict[str, str] = {}202 for dd in soup.select(".single-dropdown"):203 trig = dd.select_one(".trigger")204 label = trig.get_text(strip=True).lower() if trig else ""205 for li in dd.select("li"):206 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))207 if not t:208 continue209 m = re.match(r"^([\w' éèêàâîôû-]{2,25})\s*:\s*(.+)$", t)210 if "caractéristiques" in label and m:211 specs[m.group(1).strip().lower()] = m.group(2).strip()212 elif "inclusion" in label and 3 < len(t) < 80:213 amenities.append(t)214 if not amenities:215 # repli : listes à puces de la section descriptive216 for li in soup.select("main li"):217 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))218 if t and 3 < len(t) < 80 and "http" not in t and ":" not in t:219 amenities.append(t)220 amenities = list(dict.fromkeys(amenities))[:15]221222 # Coordonnées GPS de la carte interactive de la fiche223 lat = lng = None224 map_el = soup.select_one("[data-lat][data-lng]")225 if map_el:226 try:227 lat = float(map_el["data-lat"])228 lng = float(map_el["data-lng"])229 except (ValueError, KeyError):230 lat = lng = None231232 return {233 "title": title, "band_sector": band_sector,234 "unit_type": unit_type, "price_label": price_label,235 "availability": availability, "address": address,236 "description": desc, "amenities": amenities, "specs": specs,237 "images": images, "lat": lat, "lng": lng,238 }239240 # -- payload -> Listing ------------------------------------------------------241 def _build(self, url: str, slug: str, sector: str, page_city: str,242 payload: dict) -> Listing:243 if payload.get("band_sector"):244 sector = payload["band_sector"]245 address = payload.get("address", "")246247 # ville : secteur du bandeau s'il correspond à une ville du Grand248 # Montréal, sinon ville de la page de secteur, sinon adresse/Québec249 key = strip_accents((sector or "").strip().lower())250 if key in _SECTOR_CITY:251 city = _SECTOR_CITY[key]252 elif page_city:253 city = infer_city(sector, default=page_city)254 else:255 # page agrégée (ex. Rive-Sud) : dernier segment de l'adresse256 city = ""257 if address:258 parts = [p.strip() for p in address.split(",")259 if p.strip() and not re.fullmatch(260 r"QC|Qu[ée]bec", p.strip(), re.I)]261 if len(parts) >= 2 and not parts[-1][:1].isdigit():262 city = parts[-1]263 city = city or infer_city(sector, default="Québec")264 if sector and sector.lower() == city.lower():265 sector = "" # éviter secteur == ville (redondant)266267 # spécifications structurées (chambres, salles de bain) -> description268 specs = payload.get("specs") or {}269 desc = payload.get("description", "")270 bits = []271 if specs.get("chambre"):272 bits.append(f"{specs['chambre']} chambre(s)")273 if specs.get("salle de bain"):274 bits.append(f"{specs['salle de bain']} salle(s) de bain")275 if bits:276 desc = (desc + " — " if desc else "") + ", ".join(bits) + "."277278 price_label = payload.get("price_label") or specs.get("prix", "")279 unit_type = payload.get("unit_type") or specs.get("pièces", "")280281 return Listing(282 source=self.source_id,283 external_id=slug,284 url=url,285 title=payload.get("title") or slug,286 address=address,287 sector=sector,288 city=city,289 unit_type=normalize_unit_type(unit_type),290 price=parse_price(price_label),291 price_label=price_label,292 availability=payload.get("availability", ""),293 description=desc,294 amenities=list(payload.get("amenities") or []),295 images=list(payload.get("images") or []),296 lat=payload.get("lat"),297 lng=payload.get("lng"),298 )299