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%
10.1 KB · 230 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/duproprio.py : DuProprio (duproprio.com) — section LOCATION5#   ~4 200 annonces de particuliers (sans courtier : zéro chevauchement MLS).6#   Découverte par les 21 sitemaps régionaux `sitemaps/fr/<region>-listings.xml.gz`7#   filtrés sur `/fr/location/`. Fiches SSR (Laravel/CloudFront, aucun anti-bot) :8#   JSON-LD `Apartment` (adresse + code postal + chambres/SDB + amenityFeature)9#   et `RealEstateListing` (offers.price + availabilityStarts), lat/lng inline,10#   superficie « 970 pi² », galerie dans un JSON échappé ("photos":[…]).11#   Fiche retirée -> 302 vers la page parente (détectée par l'URL finale sans12#   `/hab-`). Cache détail avec TTL (_detailutil.TtlDetailCache).13#   Respect robots.txt : ne jamais toucher /webservice/, /fr-ca/api, /api-proxy.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import gzip18import html as H19import os20import re2122from ..schema import Listing23from .base import BaseConnector24from . import _detailutil as du2526BASE = "https://duproprio.com"27SITEMAP_INDEX = f"{BASE}/sitemaps/fr/index.xml.gz"2829_LOC_RE = re.compile(r"<loc>(.*?)</loc>")30# .../fr/location/<region>/<ville>/<type>-a-louer/hab-<slug>-<ID>31_FICHE_RE = re.compile(32    r"^https://duproprio\.com/fr/location/([a-z0-9-]+)/([a-z0-9-]+)/"33    r"([a-z0-9-]+)-a-louer/hab-[a-z0-9-]+-(\d+)$")34_GEO_RE = re.compile(r'"latitude":([\d.-]+),"longitude":([\d.-]+)')35_AREA_RE = re.compile(r"([\d\s,.]{1,10})\s*pi²")36_PHOTO_RE = re.compile(37    r"photos\\?/public\\?/for_rent\\?/[0-9]+\\?/[0-9]+\\?/[a-z0-9-]+-(\d+)\.jpg")38_PHOTO_FULL_RE = re.compile(39    r"photos[\\/]+public[\\/]+for_rent[\\/]+(\d+)[\\/]+(\d+)[\\/]+([a-z0-9-]+)-(\d+)\.jpg")40_TYPE_HALF_RE = re.compile(r"^(\d+)-1-2$")4142# types non résidentiels (premier mot du slug type) exclus du parc43_EXCLUDED_TYPES = {"commerce", "bureau", "local", "entrepot", "espace",44                   "terrain", "garage", "stationnement", "industriel"}4546DETAIL_LIMIT = int(os.environ.get("LOUKA_DUPROPRIO_DETAIL_LIMIT", "800"))47TTL_DAYS = float(os.environ.get("LOUKA_DUPROPRIO_TTL_DAYS", "7"))48MAX_FICHES = int(os.environ.get("LOUKA_DUPROPRIO_MAX", "0"))     # 0 = tout495051def _parse_fiche(html: str) -> dict:52    out: dict = {}53    for node in du.ld_nodes(html):54        t = node.get("@type") or ""55        if t in ("Apartment", "House", "SingleFamilyResidence", "Accommodation"):56            out["title"] = (node.get("name") or "").strip()57            addr = node.get("address") or {}58            out["address"] = (addr.get("streetAddress") or "").strip()59            out["city"] = (addr.get("addressLocality") or "").strip()60            out["postal_code"] = (addr.get("postalCode") or "").strip()61            for src, lab in (("numberOfBedrooms", "Chambres"),62                             ("numberOfFullBathrooms", "Salles de bain"),63                             ("floorLevel", "Étage")):64                if node.get(src):65                    out.setdefault("details", {})[lab] = str(node[src])66            if node.get("numberOfBedrooms"):67                out["bedrooms"] = str(node["numberOfBedrooms"])68            feats = node.get("amenityFeature") or []69            names = [f.get("name", "").strip() for f in feats70                     if isinstance(f, dict) and f.get("name")]71            if names:72                out["amenities"] = names73            if node.get("description"):74                out["description"] = H.unescape(str(node["description"])).strip()[:6000]75        elif t == "RealEstateListing":76            offers = node.get("offers") or {}77            try:78                out["price"] = float(str(offers.get("price")).replace(",", "."))79            except (TypeError, ValueError):80                pass81            if offers.get("availabilityStarts"):82                out["availability_date"] = str(offers["availabilityStarts"])[:10]8384    m = _GEO_RE.search(html)85    if m:86        out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))87    m = _AREA_RE.search(html)88    if m:89        try:90            area = float(m.group(1).replace(" ", "").replace(",", ""))91            if 80 <= area <= 20000:92                out["area_sqft"] = area93        except ValueError:94            pass9596    # galerie : URLs (souvent échappées \/) dédupliquées par id photo, en 1600 px97    photos, seen = [], set()98    for m in _PHOTO_FULL_RE.finditer(html):99        ym, lid, slug, pid = m.groups()100        if pid in seen:101            continue102        seen.add(pid)103        # le slug embarque la taille en avant-dernier segment : la re-forcer à 1600104        slug1600 = re.sub(r"-\d+$", "-1600", slug)105        photos.append(f"https://photos.duproprio.com/photos/public/for_rent/"106                      f"{ym}/{lid}/{slug1600}-{pid}.jpg")107    if photos:108        out["images"] = photos109110    vt = du.virtual_tour(html)      # visite virtuelle (Matterport/iGUIDE…)111    if vt:112        out.setdefault("details", {})["virtual_tour"] = vt113    return out114115116class DuProprioConnector(BaseConnector):117    source_id = "duproprio"118    request_delay = 0.8119120    def _gz(self, url: str) -> str:121        resp = self.get(url)122        try:123            return gzip.decompress(resp.content).decode("utf-8", "replace")124        except (OSError, EOFError):125            return resp.text126127    def _fiche_urls(self) -> list[tuple[str, str, str, str]]:128        """(url, ville, type_slug, id) des fiches location, tous sitemaps."""129        index = self._gz(SITEMAP_INDEX)130        subs = [u for u in _LOC_RE.findall(index) if u.endswith("-listings.xml.gz")]131        out, seen = [], set()132        for sub in subs:133            try:134                xml = self._gz(sub)135            except Exception:136                continue137            for u in _LOC_RE.findall(xml):138                m = _FICHE_RE.match(u)139                if m and m.group(3).split("-")[0] in _EXCLUDED_TYPES:140                    continue141                if m and m.group(4) not in seen:142                    seen.add(m.group(4))143                    out.append((u, m.group(2), m.group(3), m.group(4)))144        return out145146    def _fetch_fiche(self, url: str) -> str:147        """GET d'une fiche ; une redirection vers la page parente = retirée."""148        resp = self.get(url, allow_redirects=True)149        if "/hab-" not in resp.url:150            return ""                     # 302 « annonce disparue »151        return resp.text152153    @staticmethod154    def _unit_type(type_slug: str, bedrooms: str | None) -> str:155        m = _TYPE_HALF_RE.match(type_slug)156        if m:157            n = int(m.group(1))158            return "6½+" if n >= 6 else f"{n}½"159        base = {"studio": "Studio", "loft": "Loft", "chambre": "Chambre",160                "maison": "Maison", "condo": "", "appartement": ""}161        label = base.get(type_slug.split("-")[0], "")162        if not label and bedrooms and bedrooms.isdigit():163            n = int(bedrooms) + 2164            return "6½+" if n >= 6 else f"{n}½"165        return label or type_slug.replace("-", " ").capitalize()166167    def fetch(self) -> list[Listing]:168        fiches = self._fiche_urls()169        if MAX_FICHES:170            fiches = fiches[:MAX_FICHES]171        cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,172                                  fetch_html=self._fetch_fiche)173        out: list[Listing] = []174        try:175            for url, _ville, type_slug, eid in fiches:176                payload = cache.get(eid, url, _parse_fiche)177                if payload is None:       # jamais visitée + budget épuisé178                    continue179                if not payload or payload.get("gone"):180                    continue              # fiche retirée (302) ou page vide181                price = payload.get("price")182                # availabilityStarts dans le passé = déjà libre aujourd'hui183                avail = payload.get("availability_date")184                if avail:185                    import datetime as _dt186                    if avail <= _dt.date.today().isoformat():187                        avail = "now"188                # « Ahuntsic (Montréal) » -> secteur + ville ; « X (X) » -> X189                locality = payload.get("city") or ""190                sector = ""191                m = re.match(r"^(.*?)\s*\((.+)\)\s*$", locality)192                if m:193                    inner, outer = m.group(1).strip(), m.group(2).strip()194                    if inner.lower() == outer.lower():195                        locality, sector = inner, ""196                    else:197                        locality, sector = outer, inner198                lst = Listing(199                    source=self.source_id,200                    external_id=str(eid),201                    url=url,202                    title=payload.get("title") or "",203                    address=payload.get("address") or "",204                    sector=sector,205                    city=locality,206                    unit_type=self._unit_type(type_slug, payload.get("bedrooms")),207                    price=price,208                    price_label=(f"{price:,.0f} $/mois".replace(",", " ")209                                 if price else ""),210                    availability_date=avail,211                    availability=("Libre immédiatement" if avail == "now"212                                  else f"Libre le {avail}" if avail else ""),213                    area_sqft=payload.get("area_sqft"),214                    description=payload.get("description") or "",215                    amenities=payload.get("amenities") or [],216                    details=payload.get("details") or {},217                    images=payload.get("images") or [],218                    lat=payload.get("lat"),219                    lng=payload.get("lng"),220                )221                low = [a.lower() for a in lst.amenities]222                if any("animaux" in a and "permis" in a for a in low):223                    lst.pets = "oui"224                if any(a.startswith("meublé") for a in low):225                    lst.furnished = True226                out.append(lst)227        finally:228            cache.close()229        return out230