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/mercille.py : connecteur Groupe Mercille (groupemercille.com)5# Quatre immeubles résidentiels à Longueuil, secteur du métro (Le D'Assigny6# 110 rue de la Barre, Le Neuville Nord/Sud, Le Sérigny). Site custom7# (Orangium) rendu serveur : la page « Appartements résidentiels » expose8# une table .table-listings — une ligne par unité disponible : type +9# disponibilité (« 3.5 1er septembre »), secteur, prix, étage (« 11 de10# 22 »), immeuble (lien vers sa page) et plan PDF. Les lignes du formulaire11# de contact (repliées sous chaque unité) sont ignorées. La page de chaque12# immeuble (via cache BD) fournit l'adresse civique et une photo.13# ⚠️ Le volet espaces commerciaux/bureaux du site n'est pas visité.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re19import time2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://groupemercille.com"27LIST_URL = f"{BASE}/appartements-residentiels"2829# type + disponibilité dans la 1re colonne : « 3.5 1er septembre »,30# « 4.5 Disponible maintenant »31TYPE_RE = re.compile(r"^(\d(?:\.5|½)?)\s*(.*)$")32FLOOR_RE = re.compile(r"^(\w+)\s*de\s*(\d+)$")33ADDR_RE = re.compile(34 r"\d{1,5}(?:-\d{1,5})?,?\s+(?:rue|avenue|boulevard|boul\.?|chemin|place)"35 r"\s+[^,|]{2,40},?\s*Longueuil(?:\s*\(Qu[ée]bec\))?"36 r"(?:\s*,?\s*(?:QC\s*)?[A-Z]\d[A-Z]\s?\d[A-Z]\d)?", re.I)37IMG_RE = re.compile(38 r"https://assets\.orangium\.com/api/assets/groupemercille/"39 r"[^\"\s\\']+\.(?:jpe?g|png|webp)", re.I)404142class MercilleConnector(BaseConnector):43 source_id = "mercille"44 request_delay = 0.64546 def fetch(self) -> list[Listing]:47 # Un raté transitoire de la page liste (vu 2026-08-29 ~23:20)48 # produisait un faux « 0 trouvé ok » : retry court (backoff 5/10 s),49 # puis erreur franche plutôt qu'un zéro silencieux.50 listings: list[Listing] = []51 table = None52 for attempt in range(3):53 try:54 html = self.get(LIST_URL).text55 except Exception:56 if attempt == 2:57 raise58 else:59 soup = BeautifulSoup(html, "html.parser")60 table = soup.select_one("table.table-listings")61 if table is not None:62 break63 if attempt < 2:64 time.sleep(5 * (attempt + 1))65 if table is None:66 raise RuntimeError(67 "table .table-listings introuvable après 3 essais")6869 seen: dict[str, int] = {}70 for tr in table.select("tbody tr"):71 try:72 tds = tr.find_all("td")73 if len(tds) < 5:74 continue # ligne formulaire de contact75 cells = [td.get_text(" ", strip=True) for td in tds]76 m = TYPE_RE.match(cells[0])77 if not m:78 continue79 unit_type = normalize_unit_type(m.group(1))80 availability = m.group(2).strip()81 sector = cells[1]82 price_label = cells[2]83 floor_label = cells[3]84 building = cells[4]85 if not re.match(r"^\d½$", unit_type) or "$" not in price_label:86 continue8788 # lien vers la page de l'immeuble + plan PDF de l'unité89 bldg_href = ""90 pdf = ""91 for a in tr.find_all("a", href=True):92 href = a["href"]93 if href.lower().endswith(".pdf"):94 pdf = href95 elif href.startswith("/"):96 bldg_href = href97 bslug = bldg_href.strip("/").split("/")[-1] or \98 re.sub(r"[^a-z0-9]+", "-", building.lower()).strip("-")99100 # identifiant stable : immeuble + type + étage101 floor_key = re.sub(r"[^\w]+", "", floor_label.lower())102 base_id = f"{bslug}-{unit_type.replace('½', '.5')}-{floor_key}"103 seen[base_id] = seen.get(base_id, 0) + 1104 ext_id = base_id if seen[base_id] == 1 else \105 f"{base_id}-{seen[base_id]}"106107 # page de l'immeuble (cache BD) : adresse civique + photo108 d = {}109 if bldg_href:110 key = hashlib.sha1(bldg_href.encode()).hexdigest()[:16]111 d = self.detail(f"bldg:{bslug}", key,112 lambda h=bldg_href: self._building(h))113114 details: dict = {}115 mfloor = FLOOR_RE.match(floor_label)116 if mfloor:117 details["floor_label"] = f"Étage {floor_label}"118 if pdf:119 details["plan_pdf"] = pdf120121 listings.append(Listing(122 source=self.source_id,123 external_id=ext_id,124 url=f"{BASE}{bldg_href}" if bldg_href else LIST_URL,125 title=f"{unit_type} — {building}",126 address=d.get("address", ""),127 sector=sector,128 city="Longueuil",129 unit_type=unit_type,130 price=parse_price(price_label),131 price_label=price_label,132 availability=availability,133 description=d.get("description", ""),134 details=details,135 images=d.get("images") or [],136 ))137 except Exception:138 continue139 return listings140141 def _building(self, href: str) -> dict:142 """Page immeuble : adresse civique (Longueuil), photo, description."""143 out: dict = {}144 try:145 html = self.get(f"{BASE}{href}").text146 except Exception:147 return out148 soup = BeautifulSoup(html, "html.parser")149 text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))150 m = ADDR_RE.search(text)151 if m:152 out["address"] = m.group(0).strip().rstrip(",")153 # description : premiers paragraphes substantiels de la page154 paras = [p.get_text(" ", strip=True) for p in soup.find_all("p")155 if len(p.get_text(strip=True)) > 80]156 if paras:157 out["description"] = " ".join(paras)[:700]158 out["images"] = list(dict.fromkeys(IMG_RE.findall(html)))[:10]159 return out160