# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immomarketing.py : connecteur IMMOMARKETING / Immoappart # (immoappart.ca). Le portail couvre la région de Québec ET le Grand # Montréal (île de Montréal, Laval, Longueuil/Rive-Sud) : on crawle les # pages de secteurs (les plus précises d'abord pour un meilleur secteur). # Une annonce par unité (fiche /appartement//) ; les fiches passent # par le cache BD self.detail() (clé = hash de la carte liste) : seules # les fiches nouvelles ou modifiées sont re-téléchargées. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import (Listing, infer_city, normalize_unit_type, parse_price, strip_accents) from .base import BaseConnector BASE = "https://immoappart.ca" # page de secteur -> (secteur par défaut, ville par défaut). Ordre important : # pages précises d'abord (le premier secteur rencontré est conservé). SECTOR_PAGES = { # Région de Québec f"{BASE}/appartements-a-louer/quebec/lebourgneuf/": ("Lebourgneuf", "Québec"), f"{BASE}/appartements-a-louer/quebec/saint-roch-2/": ("Saint-Roch", "Québec"), # Île de Montréal f"{BASE}/appartements-a-louer/montreal/ahuntsic-cartierville/": ("Ahuntsic-Cartierville", "Montréal"), f"{BASE}/appartements-a-louer/montreal/cote-des-neiges/": ("Côte-des-Neiges", "Montréal"), f"{BASE}/appartements-a-louer/montreal/saint-leonard/": ("Saint-Léonard", "Montréal"), f"{BASE}/appartements-a-louer/saint-laurent/": ("Saint-Laurent", "Montréal"), f"{BASE}/appartements-a-louer/montreal/": ("", "Montréal"), # Laval f"{BASE}/appartements-a-louer/laval/": ("", "Laval"), # Rive-Sud (page agrégée en dernier : villes déduites fiche par fiche) f"{BASE}/appartements-a-louer/saint-lambert/": ("", "Saint-Lambert"), f"{BASE}/appartements-a-louer/longueuil/": ("", "Longueuil"), f"{BASE}/appartements-a-louer/rive-sud/": ("", ""), } # secteur affiché sur la fiche (bandeau .city) -> vraie ville, hors région Qc _SECTOR_CITY = { "montreal": "Montréal", "ahuntsic": "Montréal", "ahuntsic-cartierville": "Montréal", "cote-des-neiges": "Montréal", "saint-leonard": "Montréal", "saint-laurent": "Montréal", "ville saint-laurent": "Montréal", "laval": "Laval", "longueuil": "Longueuil", "saint-hubert": "Longueuil", "greenfield park": "Longueuil", "vieux-longueuil": "Longueuil", "saint-lambert": "Saint-Lambert", "brossard": "Brossard", "boucherville": "Boucherville", "chambly": "Chambly", "candiac": "Candiac", "la prairie": "La Prairie", "richelieu": "Richelieu", "mcmasterville": "McMasterville", "saint-bruno": "Saint-Bruno-de-Montarville", "saint-bruno-de-montarville": "Saint-Bruno-de-Montarville", "saint-hilaire": "Mont-Saint-Hilaire", "mont-saint-hilaire": "Mont-Saint-Hilaire", "sainte-julie": "Sainte-Julie", "varennes": "Varennes", "beloeil": "Belœil", } IMG_RE = re.compile(r"https://immoappart\.ca/wp-content/uploads/" r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I) class ImmomarketingConnector(BaseConnector): source_id = "immomarketing" request_delay = 0.6 max_details = 120 # garde-fou de fetch des fiches (Qc + Grand Mtl) def fetch(self) -> list[Listing]: # 1) Fiches référencées par les pages de secteurs (Qc + Grand Mtl). # La carte liste (adresse, prix, type, badge) sert de clé de cache : # la fiche n'est re-téléchargée que si sa carte a changé. pages: dict[str, tuple[str, str, str]] = {} # url -> (secteur, ville, clé) for sector_url, (sector, city) in SECTOR_PAGES.items(): try: html = self.get(sector_url).text except Exception: continue soup = BeautifulSoup(html, "html.parser") for a in soup.select('a[href*="/appartement/"]'): href = (a.get("href") or "").split("?")[0] if not re.search(r"/appartement/[a-z0-9\-]+/?$", href): continue card_txt = re.sub(r"\s+", " ", a.get_text(" ", strip=True)) key = hashlib.sha1(card_txt.encode("utf-8")).hexdigest()[:20] pages.setdefault(href.rstrip("/") + "/", (sector, city, key)) # 2) Fiches détaillées (via le cache BD ; budget de vraies requêtes) self._fetched = 0 listings: list[Listing] = [] for url, (sector, city, key) in pages.items(): slug = url.rstrip("/").split("/")[-1] try: payload = self.detail(slug, key, lambda u=url: self._fetch_detail(u)) except Exception: continue if not payload: # fiche commerciale / stationnement / rangement continue try: listings.append(self._build(url, slug, sector, city, payload)) except Exception: continue return listings # -- fiche détaillée -> payload JSON-sérialisable (mis en cache) ------------ def _fetch_detail(self, url: str) -> dict: if self._fetched >= self.max_details: raise RuntimeError("budget de requêtes détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") h1 = soup.find("h1") title = h1.get_text(" ", strip=True) if h1 else "" if re.search(r"Commercial|Stationnement|Rangement", title, re.I): return {} # Bandeau : secteur / type / prix / disponibilité band = soup.select_one("p.selected-appart-title") band_sector = unit_type = price_label = "" if band: size = band.select_one(".size") unit_type = size.get_text(strip=True) if size else "" price = band.select_one(".price") price_label = price.get_text(strip=True) if price else "" city_el = band.select_one(".city") if city_el: band_sector = city_el.get_text(strip=True) availability = "" for s in soup.find_all(string=re.compile("Date de disponibilité")): parent = s.find_parent("p") if parent: cand = re.sub(r".*Date de disponibilité\s*:?\s*", "", parent.get_text(" ", strip=True)).strip() if cand: availability = cand break # Adresse : h2 sous le h1 address = "" for h2 in soup.find_all("h2"): t = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if re.match(r"^\d{1,5},?\s+\S[^,]{1,60},", t): address = t break if not address: # h2 « numéro + rue » sans virgule finale (ex. « 570, Charest est ») for h2 in soup.find_all("h2"): t = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if len(t) <= 70 and re.match(r"^\d{1,5},?\s+\S", t) \ and not re.search(r"mois|gratuit|offre|promo|rabais", t, re.I): address = t break if not address: m = re.search(r"(?]{2,50},\s*[^,<>]{2,40},\s*QC", re.sub(r"\s+", " ", soup.get_text(" ", strip=True)), re.I) if m: address = m.group(0) if not address: # dernier recours : le titre commence parfois par l'adresse m = re.match(r"^(\d{1,5},?\s+.{3,60}?)\s+[-–]\s+\d\s*½", title) if m: address = m.group(1).strip() # Photos : galerie de la fiche (éviter les fiches « même secteur ») images: list[str] = [] gallery = soup.select_one(".appart-gallery") or soup for el in gallery.select("img[src], a[href]"): u = (el.get("src") or el.get("href") or "").split("?")[0] if IMG_RE.match(u) and not re.search(r"logo|icon|favicon", u, re.I): images.append(u) banner = soup.select_one(".banner-appart img[src]") if banner: images.insert(0, banner["src"].split("?")[0]) images = list(dict.fromkeys(images))[:25] # Description complète (.the-content : promotions, inclusions de # loisirs, informations supplémentaires — animaux, fumeur, contact) desc = "" content = soup.select_one(".the-content") if content: desc = re.sub(r"\s+", " ", content.get_text(" ", strip=True)).strip()[:1800] if not desc: og = soup.find("meta", attrs={"property": "og:description"}) desc = og["content"].strip()[:600] if og and og.get("content") else "" # Commodités : liste « Inclusions » (dropdown dédié de la fiche) amenities: list[str] = [] specs: dict[str, str] = {} for dd in soup.select(".single-dropdown"): trig = dd.select_one(".trigger") label = trig.get_text(strip=True).lower() if trig else "" for li in dd.select("li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if not t: continue m = re.match(r"^([\w' éèêàâîôû-]{2,25})\s*:\s*(.+)$", t) if "caractéristiques" in label and m: specs[m.group(1).strip().lower()] = m.group(2).strip() elif "inclusion" in label and 3 < len(t) < 80: amenities.append(t) if not amenities: # repli : listes à puces de la section descriptive for li in soup.select("main li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t and 3 < len(t) < 80 and "http" not in t and ":" not in t: amenities.append(t) amenities = list(dict.fromkeys(amenities))[:15] # Coordonnées GPS de la carte interactive de la fiche lat = lng = None map_el = soup.select_one("[data-lat][data-lng]") if map_el: try: lat = float(map_el["data-lat"]) lng = float(map_el["data-lng"]) except (ValueError, KeyError): lat = lng = None return { "title": title, "band_sector": band_sector, "unit_type": unit_type, "price_label": price_label, "availability": availability, "address": address, "description": desc, "amenities": amenities, "specs": specs, "images": images, "lat": lat, "lng": lng, } # -- payload -> Listing ------------------------------------------------------ def _build(self, url: str, slug: str, sector: str, page_city: str, payload: dict) -> Listing: if payload.get("band_sector"): sector = payload["band_sector"] address = payload.get("address", "") # ville : secteur du bandeau s'il correspond à une ville du Grand # Montréal, sinon ville de la page de secteur, sinon adresse/Québec key = strip_accents((sector or "").strip().lower()) if key in _SECTOR_CITY: city = _SECTOR_CITY[key] elif page_city: city = infer_city(sector, default=page_city) else: # page agrégée (ex. Rive-Sud) : dernier segment de l'adresse city = "" if address: parts = [p.strip() for p in address.split(",") if p.strip() and not re.fullmatch( r"QC|Qu[ée]bec", p.strip(), re.I)] if len(parts) >= 2 and not parts[-1][:1].isdigit(): city = parts[-1] city = city or infer_city(sector, default="Québec") if sector and sector.lower() == city.lower(): sector = "" # éviter secteur == ville (redondant) # spécifications structurées (chambres, salles de bain) -> description specs = payload.get("specs") or {} desc = payload.get("description", "") bits = [] if specs.get("chambre"): bits.append(f"{specs['chambre']} chambre(s)") if specs.get("salle de bain"): bits.append(f"{specs['salle de bain']} salle(s) de bain") if bits: desc = (desc + " — " if desc else "") + ", ".join(bits) + "." price_label = payload.get("price_label") or specs.get("prix", "") unit_type = payload.get("unit_type") or specs.get("pièces", "") return Listing( source=self.source_id, external_id=slug, url=url, title=payload.get("title") or slug, address=address, sector=sector, city=city, unit_type=normalize_unit_type(unit_type), price=parse_price(price_label), price_label=price_label, availability=payload.get("availability", ""), description=desc, amenities=list(payload.get("amenities") or []), images=list(payload.get("images") or []), lat=payload.get("lat"), lng=payload.get("lng"), )