# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/mercille.py : connecteur Groupe Mercille (groupemercille.com) # Quatre immeubles résidentiels à Longueuil, secteur du métro (Le D'Assigny # 110 rue de la Barre, Le Neuville Nord/Sud, Le Sérigny). Site custom # (Orangium) rendu serveur : la page « Appartements résidentiels » expose # une table .table-listings — une ligne par unité disponible : type + # disponibilité (« 3.5 1er septembre »), secteur, prix, étage (« 11 de # 22 »), immeuble (lien vers sa page) et plan PDF. Les lignes du formulaire # de contact (repliées sous chaque unité) sont ignorées. La page de chaque # immeuble (via cache BD) fournit l'adresse civique et une photo. # ⚠️ Le volet espaces commerciaux/bureaux du site n'est pas visité. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re import time from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://groupemercille.com" LIST_URL = f"{BASE}/appartements-residentiels" # type + disponibilité dans la 1re colonne : « 3.5 1er septembre », # « 4.5 Disponible maintenant » TYPE_RE = re.compile(r"^(\d(?:\.5|½)?)\s*(.*)$") FLOOR_RE = re.compile(r"^(\w+)\s*de\s*(\d+)$") ADDR_RE = re.compile( r"\d{1,5}(?:-\d{1,5})?,?\s+(?:rue|avenue|boulevard|boul\.?|chemin|place)" r"\s+[^,|]{2,40},?\s*Longueuil(?:\s*\(Qu[ée]bec\))?" r"(?:\s*,?\s*(?:QC\s*)?[A-Z]\d[A-Z]\s?\d[A-Z]\d)?", re.I) IMG_RE = re.compile( r"https://assets\.orangium\.com/api/assets/groupemercille/" r"[^\"\s\\']+\.(?:jpe?g|png|webp)", re.I) class MercilleConnector(BaseConnector): source_id = "mercille" request_delay = 0.6 def fetch(self) -> list[Listing]: # Un raté transitoire de la page liste (vu 2026-08-29 ~23:20) # produisait un faux « 0 trouvé ok » : retry court (backoff 5/10 s), # puis erreur franche plutôt qu'un zéro silencieux. listings: list[Listing] = [] table = None for attempt in range(3): try: html = self.get(LIST_URL).text except Exception: if attempt == 2: raise else: soup = BeautifulSoup(html, "html.parser") table = soup.select_one("table.table-listings") if table is not None: break if attempt < 2: time.sleep(5 * (attempt + 1)) if table is None: raise RuntimeError( "table .table-listings introuvable après 3 essais") seen: dict[str, int] = {} for tr in table.select("tbody tr"): try: tds = tr.find_all("td") if len(tds) < 5: continue # ligne formulaire de contact cells = [td.get_text(" ", strip=True) for td in tds] m = TYPE_RE.match(cells[0]) if not m: continue unit_type = normalize_unit_type(m.group(1)) availability = m.group(2).strip() sector = cells[1] price_label = cells[2] floor_label = cells[3] building = cells[4] if not re.match(r"^\d½$", unit_type) or "$" not in price_label: continue # lien vers la page de l'immeuble + plan PDF de l'unité bldg_href = "" pdf = "" for a in tr.find_all("a", href=True): href = a["href"] if href.lower().endswith(".pdf"): pdf = href elif href.startswith("/"): bldg_href = href bslug = bldg_href.strip("/").split("/")[-1] or \ re.sub(r"[^a-z0-9]+", "-", building.lower()).strip("-") # identifiant stable : immeuble + type + étage floor_key = re.sub(r"[^\w]+", "", floor_label.lower()) base_id = f"{bslug}-{unit_type.replace('½', '.5')}-{floor_key}" seen[base_id] = seen.get(base_id, 0) + 1 ext_id = base_id if seen[base_id] == 1 else \ f"{base_id}-{seen[base_id]}" # page de l'immeuble (cache BD) : adresse civique + photo d = {} if bldg_href: key = hashlib.sha1(bldg_href.encode()).hexdigest()[:16] d = self.detail(f"bldg:{bslug}", key, lambda h=bldg_href: self._building(h)) details: dict = {} mfloor = FLOOR_RE.match(floor_label) if mfloor: details["floor_label"] = f"Étage {floor_label}" if pdf: details["plan_pdf"] = pdf listings.append(Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}{bldg_href}" if bldg_href else LIST_URL, title=f"{unit_type} — {building}", address=d.get("address", ""), sector=sector, city="Longueuil", unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, description=d.get("description", ""), details=details, images=d.get("images") or [], )) except Exception: continue return listings def _building(self, href: str) -> dict: """Page immeuble : adresse civique (Longueuil), photo, description.""" out: dict = {} try: html = self.get(f"{BASE}{href}").text except Exception: return out soup = BeautifulSoup(html, "html.parser") text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) m = ADDR_RE.search(text) if m: out["address"] = m.group(0).strip().rstrip(",") # description : premiers paragraphes substantiels de la page paras = [p.get_text(" ", strip=True) for p in soup.find_all("p") if len(p.get_text(strip=True)) > 80] if paras: out["description"] = " ".join(paras)[:700] out["images"] = list(dict.fromkeys(IMG_RE.findall(html)))[:10] return out