# ----------------------------------------------------------------------------- # 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'
\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