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/excell.py : Les immeubles EXCELL (immeublesexcell.com) — agence5# immobilière d'Abitibi-Ouest (La Sarre, Macamic, Taschereau…) avec une6# section « Logements » à louer.7# Techno : ColdFusion servi côté serveur (details.cfm?LogementID=N) — simple8# parse HTML des cartes `ficheMaison` + page détail pour les attributs9# (type, meublé, animaux, chauffage, électroménagers, étage).10# Granularité : LOGEMENT (une carte = un logement, LogementID stable).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import html as _html16import re1718import requests1920from .base import BaseConnector21from ..schema import Listing2223BASE = "https://www.immeublesexcell.com"24LIST_URL = f"{BASE}/fr/logements/"2526_CARD_SPLIT_RE = re.compile(r'<div class="ficheMaison">')27_ID_RE = re.compile(r'LogementID_(\d+)')28_PRICE_RE = re.compile(r'<h4>([^<]+)</h4>')29_ADDR_RE = re.compile(r'<p>\s*([^<]+?)<br')30_IMG_RE = re.compile(r'background:url\(([^)]+)\)')31# attributs « <span class="valeurAttribut">X</span>…<span class="valeurAttributDetails">Y</span> »32_ATTR_RE = re.compile(33 r'<span class="valeurAttribut">([^<]*)</span>\s*<br\s*/?>\s*'34 r'(?:<span class="valeurAttributDetails">)?([^<]*)', re.S)35_POSTAL_RE = re.compile(r'\b[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d\b')36_DETAIL_IMG_RE = re.compile(r'(?:src|href)="([^"]*images/logements[^"]*)"')3738# labels détail -> clés structurées39_DETAIL_KEYS = {"type", "mobilié", "mobilier", "chauffage", "animaux",40 "étage", "frigidaire", "laveuse", "sécheuse", "extérieur",41 "poêle", "lave-vaisselle"}424344def _abs(url: str) -> str:45 url = url.strip().strip("'\"")46 if url.startswith("http"):47 return url48 return BASE + "/" + url.lstrip("./").lstrip("/")495051class ExcellConnector(BaseConnector):52 """Les immeubles EXCELL — logements à louer en Abitibi-Ouest."""5354 source_id = "excell"55 request_delay = 0.85657 def _parse_detail(self, ext_id: str) -> dict:58 """Attributs de la page détail (dict JSON-sérialisable, pour cache)."""59 url = f"{LIST_URL}details.cfm?LogementID={ext_id}"60 h = re.sub(r"\s+", " ", self.get(url).text)61 attrs: dict[str, str] = {}62 for k, v in _ATTR_RE.findall(h):63 k, v = _html.unescape(k).strip(), _html.unescape(v).strip()64 if k and v and k.lower() in _DETAIL_KEYS | {"location"}:65 attrs[k] = v66 images = [_abs(u) for u in _DETAIL_IMG_RE.findall(h)]67 return {"attrs": attrs, "images": images}6869 def fetch(self) -> list[Listing]:70 page = self.get(LIST_URL).text71 out: list[Listing] = []72 seen: set[str] = set()73 for block in _CARD_SPLIT_RE.split(page)[1:]:74 b = re.sub(r"\s+", " ", block)75 m = _ID_RE.search(b)76 if not m:77 continue78 ext_id = m.group(1)79 if ext_id in seen:80 continue81 seen.add(ext_id)82 url = f"{LIST_URL}details.cfm?LogementID={ext_id}"8384 price_m = _PRICE_RE.search(b)85 price_label = _html.unescape(price_m.group(1)).strip() if price_m else ""86 addr_m = _ADDR_RE.search(b)87 raw_addr = _html.unescape(addr_m.group(1)).strip() if addr_m else ""88 raw_addr = _POSTAL_RE.sub("", raw_addr).strip(" ,")89 parts = [p.strip() for p in raw_addr.split(",") if p.strip()]90 city = parts[-1] if len(parts) > 1 else ""91 address = ", ".join(parts[:-1]) if len(parts) > 1 else raw_addr9293 # attributs de la carte : pièces (5 1/2), chambres, sdb, dispo94 unit_type, bedrooms, bathrooms, avail = "", None, None, ""95 for val, lbl in _ATTR_RE.findall(b):96 val = _html.unescape(val).strip()97 lbl = _html.unescape(lbl).strip()98 low = lbl.lower()99 if "pièce" in low and val:100 unit_type = val101 elif "chambre" in low and val:102 try:103 bedrooms = float(val)104 except ValueError:105 pass106 elif "bain" in low and val:107 try:108 bathrooms = float(val)109 except ValueError:110 pass111 elif not val and lbl: # icône calendrier : début location112 avail = lbl113114 imgs: list[str] = []115 img_m = _IMG_RE.search(b)116 if img_m and "no-image" not in img_m.group(1):117 imgs.append(_abs(img_m.group(1)))118119 # page détail (cache : clé = hash de la carte)120 key = hashlib.sha256(b.encode()).hexdigest()[:16]121 try:122 det = self.detail(ext_id, key,123 lambda e=ext_id: self._parse_detail(e))124 except requests.RequestException:125 det = {}126 attrs = det.get("attrs") or {}127 for u in det.get("images") or []:128 if u not in imgs:129 imgs.append(u)130131 # filtre résidentiel : le site ne liste que des logements ;132 # on écarte tout de même les locaux commerciaux éventuels133 typ = (attrs.get("Type") or "").lower()134 if typ and any(w in typ for w in ("commercial", "bureau", "local",135 "entrepôt", "garage")):136 continue137138 furnished = None139 mob = (attrs.get("Mobilié") or attrs.get("Mobilier") or "").lower()140 if mob:141 furnished = "meublé" in mob and "non" not in mob142 pets = None143 ani = (attrs.get("Animaux") or "").lower()144 if ani:145 pets = "oui" if ani.startswith("oui") else \146 "non" if ani.startswith("non") else "conditions"147148 if "chambre" in typ: # chambre en maison de chambres149 unit_type = "Chambre"150151 amenities = [f"{k} : {v}" for k, v in attrs.items()152 if k.lower() in _DETAIL_KEYS]153 avail = attrs.get("Location") or avail154155 out.append(Listing(156 source=self.source_id,157 external_id=ext_id, # LogementID ColdFusion (stable)158 url=url,159 title=f"{address}, {city}".strip(" ,"),160 address=address,161 city=city,162 unit_type=unit_type,163 bedrooms=bedrooms,164 bathrooms=bathrooms,165 price_label=price_label, # ex. « 975 $ / mois »166 availability=f"Location : {avail}" if avail else "",167 furnished=furnished,168 pets=pets,169 amenities=amenities,170 details={"region": "Abitibi-Témiscamingue"},171 images=imgs[:30],172 ))173 return out174