# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/duquette.py : connecteur Entreprises Duquette Immobilier # (duquette.ca). Constructeur-gestionnaire de la Rive-Nord (base à Laval) : # condos locatifs neufs à Saint-Eustache (320 et 405 chemin de la # Grande-Côte) et logements à Deux-Montagnes. WordPress + thème WPEstate : # API REST publique wp-json/wp/v2/estate_property (une fiche par UNITÉ, # adresse civique en titre), taxonomies property_status (« À louer » / # « Déjà loué ») et property_city. Le prix ($/mois), la superficie (ft²), # les sdb et les chambres (libellé property_category « … 2 chambres », # dont l'endpoint REST renvoie 500) viennent de la page détail rendue # serveur (div.price_area, aperçu), via cache detail. # Granularité UNITÉ, external_id = slug WordPress. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://www.duquette.ca" API = f"{BASE}/wp-json/wp/v2" PRICE_RE = re.compile(r"([\d\s ,]{3,9})\$\s*/?\s*mois|" r"([\d\s ,]{3,9})\s*\$\s*/mois") SQFT_RE = re.compile(r"([\d,\s]{2,7})\s*ft\s*2?\b", re.I) BATH_RE = re.compile(r"(\d+(?:[.,]\d)?)\s*Salles? de bain", re.I) BED_CAT_RE = re.compile(r"(\d+)\s*chambres?", re.I) IMG_RE = re.compile( r"https://www\.duquette\.ca/wp-content/uploads/[^\"'\s\\)]+?" r"\.(?:jpg|jpeg|png|webp)", re.I) def _num(txt: str) -> float | None: digits = re.sub(r"[^\d]", "", txt or "") return float(digits) if digits else None class DuquetteConnector(BaseConnector): source_id = "duquette" request_delay = 0.7 def _terms(self, taxonomy: str) -> dict[int, str]: try: data = self.get(f"{API}/{taxonomy}?per_page=100").json() except Exception: return {} return {t["id"]: t["name"] for t in data if isinstance(t, dict)} def _detail_payload(self, url: str) -> dict: """Prix, superficie, sdb et photos de la fiche WPEstate.""" try: html = self.get(url).text except Exception: return {} soup = BeautifulSoup(html, "html.parser") payload: dict = {} area = soup.select_one("div.price_area") if area is not None: txt = area.get_text(" ", strip=True) m = PRICE_RE.search(txt) if m: price = _num(m.group(1) or m.group(2)) if price and 300 <= price <= 20000: payload["price"] = price payload["price_label"] = f"{price:.0f} $ /mois" body = soup.get_text(" ", strip=True) m = SQFT_RE.search(body) if m: sqft = _num(m.group(1)) if sqft and 150 <= sqft <= 5000: payload["area_sqft"] = sqft m = BATH_RE.search(body) if m: payload["bathrooms"] = float(m.group(1).replace(",", ".")) images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|favicon|icon|-\d+x\d+\.", u, re.I)] if images: payload["images"] = images[:20] # chambres via le libellé de catégorie (« 320 Grand-Côte 2 chambres ») # — l'endpoint REST property_category renvoie 500 chez cet hôte for a in soup.select("a[href*='/property_category/']"): m = BED_CAT_RE.search(a.get_text(" ", strip=True)) if m: payload["bedrooms"] = float(m.group(1)) break return payload def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: props = self.get(f"{API}/estate_property?per_page=100").json() except Exception: return listings if not isinstance(props, list): return listings status = self._terms("property_status") cities = self._terms("property_city") for p in props: if not isinstance(p, dict) or p.get("status") != "publish": continue st = [status.get(i, "") for i in p.get("property_status") or []] if not any(s.lower().startswith("à louer") for s in st): continue # « Déjà loué » slug = p.get("slug") or "" url = p.get("link") or "" if not slug or not url: continue title = BeautifulSoup(p.get("title", {}).get("rendered", ""), "html.parser").get_text(" ", strip=True) address, city = title, "" if "," in title: address, city = [x.strip() for x in title.rsplit(",", 1)] for i in p.get("property_city") or []: if cities.get(i): city = cities[i] break content = BeautifulSoup(p.get("content", {}).get("rendered", ""), "html.parser").get_text(" ", strip=True) payload = self.detail(slug, str(p.get("modified", "")), lambda u=url: self._detail_payload(u)) # chambres via catégorie (page détail) ; studio via le contenu bedrooms = payload.get("bedrooms") unit_type = "" if bedrooms is None and re.match(r"(?i)\W*\w*\s*studio", content[:80]): unit_type = "Studio" listings.append(Listing( source=self.source_id, external_id=slug, url=url, title=title, address=address, city=city, unit_type=unit_type, bedrooms=bedrooms, bathrooms=payload.get("bathrooms"), price=payload.get("price"), price_label=payload.get("price_label", ""), availability="À louer", area_sqft=payload.get("area_sqft"), description=content[:2000], images=payload.get("images", []), )) return listings