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/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