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%
6.3 KB · 155 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/duquette.py : connecteur Entreprises Duquette Immobilier5#   (duquette.ca). Constructeur-gestionnaire de la Rive-Nord (base à Laval) :6#   condos locatifs neufs à Saint-Eustache (320 et 405 chemin de la7#   Grande-Côte) et logements à Deux-Montagnes. WordPress + thème WPEstate :8#   API REST publique wp-json/wp/v2/estate_property (une fiche par UNITÉ,9#   adresse civique en titre), taxonomies property_status (« À louer » /10#   « Déjà loué ») et property_city. Le prix ($/mois), la superficie (ft²),11#   les sdb et les chambres (libellé property_category « … 2 chambres »,12#   dont l'endpoint REST renvoie 500) viennent de la page détail rendue13#   serveur (div.price_area, aperçu), via cache detail.14#   Granularité UNITÉ, external_id = slug WordPress.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing23from .base import BaseConnector2425BASE = "https://www.duquette.ca"26API = f"{BASE}/wp-json/wp/v2"2728PRICE_RE = re.compile(r"([\d\s  ,]{3,9})\$\s*/?\s*mois|"29                      r"([\d\s  ,]{3,9})\s*\$\s*/mois")30SQFT_RE = re.compile(r"([\d,\s]{2,7})\s*ft\s*2?\b", re.I)31BATH_RE = re.compile(r"(\d+(?:[.,]\d)?)\s*Salles? de bain", re.I)32BED_CAT_RE = re.compile(r"(\d+)\s*chambres?", re.I)33IMG_RE = re.compile(34    r"https://www\.duquette\.ca/wp-content/uploads/[^\"'\s\\)]+?"35    r"\.(?:jpg|jpeg|png|webp)", re.I)363738def _num(txt: str) -> float | None:39    digits = re.sub(r"[^\d]", "", txt or "")40    return float(digits) if digits else None414243class DuquetteConnector(BaseConnector):44    source_id = "duquette"45    request_delay = 0.74647    def _terms(self, taxonomy: str) -> dict[int, str]:48        try:49            data = self.get(f"{API}/{taxonomy}?per_page=100").json()50        except Exception:51            return {}52        return {t["id"]: t["name"] for t in data if isinstance(t, dict)}5354    def _detail_payload(self, url: str) -> dict:55        """Prix, superficie, sdb et photos de la fiche WPEstate."""56        try:57            html = self.get(url).text58        except Exception:59            return {}60        soup = BeautifulSoup(html, "html.parser")61        payload: dict = {}62        area = soup.select_one("div.price_area")63        if area is not None:64            txt = area.get_text(" ", strip=True)65            m = PRICE_RE.search(txt)66            if m:67                price = _num(m.group(1) or m.group(2))68                if price and 300 <= price <= 20000:69                    payload["price"] = price70                    payload["price_label"] = f"{price:.0f} $ /mois"71        body = soup.get_text(" ", strip=True)72        m = SQFT_RE.search(body)73        if m:74            sqft = _num(m.group(1))75            if sqft and 150 <= sqft <= 5000:76                payload["area_sqft"] = sqft77        m = BATH_RE.search(body)78        if m:79            payload["bathrooms"] = float(m.group(1).replace(",", "."))80        images = [u for u in dict.fromkeys(IMG_RE.findall(html))81                  if not re.search(r"logo|favicon|icon|-\d+x\d+\.", u, re.I)]82        if images:83            payload["images"] = images[:20]84        # chambres via le libellé de catégorie (« 320 Grand-Côte 2 chambres »)85        # — l'endpoint REST property_category renvoie 500 chez cet hôte86        for a in soup.select("a[href*='/property_category/']"):87            m = BED_CAT_RE.search(a.get_text(" ", strip=True))88            if m:89                payload["bedrooms"] = float(m.group(1))90                break91        return payload9293    def fetch(self) -> list[Listing]:94        listings: list[Listing] = []95        try:96            props = self.get(f"{API}/estate_property?per_page=100").json()97        except Exception:98            return listings99        if not isinstance(props, list):100            return listings101        status = self._terms("property_status")102        cities = self._terms("property_city")103104        for p in props:105            if not isinstance(p, dict) or p.get("status") != "publish":106                continue107            st = [status.get(i, "") for i in p.get("property_status") or []]108            if not any(s.lower().startswith("à louer") for s in st):109                continue                      # « Déjà loué »110            slug = p.get("slug") or ""111            url = p.get("link") or ""112            if not slug or not url:113                continue114            title = BeautifulSoup(p.get("title", {}).get("rendered", ""),115                                  "html.parser").get_text(" ", strip=True)116            address, city = title, ""117            if "," in title:118                address, city = [x.strip() for x in title.rsplit(",", 1)]119            for i in p.get("property_city") or []:120                if cities.get(i):121                    city = cities[i]122                    break123124            content = BeautifulSoup(p.get("content", {}).get("rendered", ""),125                                    "html.parser").get_text(" ", strip=True)126127            payload = self.detail(slug, str(p.get("modified", "")),128                                  lambda u=url: self._detail_payload(u))129130            # chambres via catégorie (page détail) ; studio via le contenu131            bedrooms = payload.get("bedrooms")132            unit_type = ""133            if bedrooms is None and re.match(r"(?i)\W*\w*\s*studio",134                                             content[:80]):135                unit_type = "Studio"136137            listings.append(Listing(138                source=self.source_id,139                external_id=slug,140                url=url,141                title=title,142                address=address,143                city=city,144                unit_type=unit_type,145                bedrooms=bedrooms,146                bathrooms=payload.get("bathrooms"),147                price=payload.get("price"),148                price_label=payload.get("price_label", ""),149                availability="À louer",150                area_sqft=payload.get("area_sqft"),151                description=content[:2000],152                images=payload.get("images", []),153            ))154        return listings155