# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/excell.py : Les immeubles EXCELL (immeublesexcell.com) — agence # immobilière d'Abitibi-Ouest (La Sarre, Macamic, Taschereau…) avec une # section « Logements » à louer. # Techno : ColdFusion servi côté serveur (details.cfm?LogementID=N) — simple # parse HTML des cartes `ficheMaison` + page détail pour les attributs # (type, meublé, animaux, chauffage, électroménagers, étage). # Granularité : LOGEMENT (une carte = un logement, LogementID stable). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as _html import re import requests from .base import BaseConnector from ..schema import Listing BASE = "https://www.immeublesexcell.com" LIST_URL = f"{BASE}/fr/logements/" _CARD_SPLIT_RE = re.compile(r'
') _ID_RE = re.compile(r'LogementID_(\d+)') _PRICE_RE = re.compile(r'

([^<]+)

') _ADDR_RE = re.compile(r'

\s*([^<]+?)X…Y » _ATTR_RE = re.compile( r'([^<]*)\s*\s*' r'(?:)?([^<]*)', re.S) _POSTAL_RE = re.compile(r'\b[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d\b') _DETAIL_IMG_RE = re.compile(r'(?:src|href)="([^"]*images/logements[^"]*)"') # labels détail -> clés structurées _DETAIL_KEYS = {"type", "mobilié", "mobilier", "chauffage", "animaux", "étage", "frigidaire", "laveuse", "sécheuse", "extérieur", "poêle", "lave-vaisselle"} def _abs(url: str) -> str: url = url.strip().strip("'\"") if url.startswith("http"): return url return BASE + "/" + url.lstrip("./").lstrip("/") class ExcellConnector(BaseConnector): """Les immeubles EXCELL — logements à louer en Abitibi-Ouest.""" source_id = "excell" request_delay = 0.8 def _parse_detail(self, ext_id: str) -> dict: """Attributs de la page détail (dict JSON-sérialisable, pour cache).""" url = f"{LIST_URL}details.cfm?LogementID={ext_id}" h = re.sub(r"\s+", " ", self.get(url).text) attrs: dict[str, str] = {} for k, v in _ATTR_RE.findall(h): k, v = _html.unescape(k).strip(), _html.unescape(v).strip() if k and v and k.lower() in _DETAIL_KEYS | {"location"}: attrs[k] = v images = [_abs(u) for u in _DETAIL_IMG_RE.findall(h)] return {"attrs": attrs, "images": images} def fetch(self) -> list[Listing]: page = self.get(LIST_URL).text out: list[Listing] = [] seen: set[str] = set() for block in _CARD_SPLIT_RE.split(page)[1:]: b = re.sub(r"\s+", " ", block) m = _ID_RE.search(b) if not m: continue ext_id = m.group(1) if ext_id in seen: continue seen.add(ext_id) url = f"{LIST_URL}details.cfm?LogementID={ext_id}" price_m = _PRICE_RE.search(b) price_label = _html.unescape(price_m.group(1)).strip() if price_m else "" addr_m = _ADDR_RE.search(b) raw_addr = _html.unescape(addr_m.group(1)).strip() if addr_m else "" raw_addr = _POSTAL_RE.sub("", raw_addr).strip(" ,") parts = [p.strip() for p in raw_addr.split(",") if p.strip()] city = parts[-1] if len(parts) > 1 else "" address = ", ".join(parts[:-1]) if len(parts) > 1 else raw_addr # attributs de la carte : pièces (5 1/2), chambres, sdb, dispo unit_type, bedrooms, bathrooms, avail = "", None, None, "" for val, lbl in _ATTR_RE.findall(b): val = _html.unescape(val).strip() lbl = _html.unescape(lbl).strip() low = lbl.lower() if "pièce" in low and val: unit_type = val elif "chambre" in low and val: try: bedrooms = float(val) except ValueError: pass elif "bain" in low and val: try: bathrooms = float(val) except ValueError: pass elif not val and lbl: # icône calendrier : début location avail = lbl imgs: list[str] = [] img_m = _IMG_RE.search(b) if img_m and "no-image" not in img_m.group(1): imgs.append(_abs(img_m.group(1))) # page détail (cache : clé = hash de la carte) key = hashlib.sha256(b.encode()).hexdigest()[:16] try: det = self.detail(ext_id, key, lambda e=ext_id: self._parse_detail(e)) except requests.RequestException: det = {} attrs = det.get("attrs") or {} for u in det.get("images") or []: if u not in imgs: imgs.append(u) # filtre résidentiel : le site ne liste que des logements ; # on écarte tout de même les locaux commerciaux éventuels typ = (attrs.get("Type") or "").lower() if typ and any(w in typ for w in ("commercial", "bureau", "local", "entrepôt", "garage")): continue furnished = None mob = (attrs.get("Mobilié") or attrs.get("Mobilier") or "").lower() if mob: furnished = "meublé" in mob and "non" not in mob pets = None ani = (attrs.get("Animaux") or "").lower() if ani: pets = "oui" if ani.startswith("oui") else \ "non" if ani.startswith("non") else "conditions" if "chambre" in typ: # chambre en maison de chambres unit_type = "Chambre" amenities = [f"{k} : {v}" for k, v in attrs.items() if k.lower() in _DETAIL_KEYS] avail = attrs.get("Location") or avail out.append(Listing( source=self.source_id, external_id=ext_id, # LogementID ColdFusion (stable) url=url, title=f"{address}, {city}".strip(" ,"), address=address, city=city, unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price_label=price_label, # ex. « 975 $ / mois » availability=f"Location : {avail}" if avail else "", furnished=furnished, pets=pets, amenities=amenities, details={"region": "Abitibi-Témiscamingue"}, images=imgs[:30], )) return out