SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
7.1 KB · 174 lines python
Raw Blame History
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