# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/dupin_despres.py : connecteur Dupin Després (dupindespres.com)
# Gestionnaire de Repentigny / Rive-Nord (Les Cimes Repentigny, Quartier du
# Moulin, immeubles rue Notre-Dame…). WordPress (thème d'agence « Voyou »,
# classes vy_*) rendu serveur : /appartements-a-louer + /page/N liste des
# cartes par UNITÉ (titre, typologie, ville, prix, bandeau de disponibilité,
# badge 55 ans+). La page détail fournit description, photos et l'adresse
# civique (fil d'Ariane JSON-LD, ex. « #118-325 rue Notre-Dame Repentigny »).
# Granularité : unité.
# -----------------------------------------------------------------------------
from __future__ import annotations
import json
import re
from bs4 import BeautifulSoup
from ..schema import Listing, normalize_unit_type
from .base import BaseConnector
BASE = "https://dupindespres.com"
LIST_URL = f"{BASE}/appartements-a-louer"
MAX_PAGES = 10
IMG_RE = re.compile(
r"https://dupindespres\.com/app/uploads/[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I)
SKIP_IMG_RE = re.compile(r"logo|favicon|icon|-\d{2,4}x\d{2,4}\.", re.I)
PHONE_RE = re.compile(r"(\d{3})\s*(\d{3})[\s-]*(\d{4})")
# dernier maillon du fil d'Ariane JSON-LD : « #118-325 rue Notre-Dame Repentigny »
CRUMB_RE = re.compile(r'"name"\s*:\s*"(#[^"]+)"')
class DupinDespresConnector(BaseConnector):
source_id = "dupin_despres"
request_delay = 0.8
max_images = 12
# -- page détail (cachée en BD via self.detail) ---------------------------
def _detail(self, url: str) -> dict:
try:
html = self.get(url).text
except Exception:
return {}
soup = BeautifulSoup(html, "html.parser")
payload: dict = {}
# adresse : dernier maillon du fil d'Ariane JSON-LD (« #118-325 rue … »)
for script in soup.find_all("script", type="application/ld+json"):
m = CRUMB_RE.findall(script.string or "")
if m:
addr = json.loads(f'"{m[-1]}"') if "\\" in m[-1] else m[-1]
# « #118-325 rue Notre-Dame Repentigny » -> « 325 rue Notre-Dame »
addr = re.sub(r"^#?\d+\s*-\s*", "", addr).strip()
payload["address"] = addr
break
# description : paragraphes du corps de la fiche
body = soup.select_one(".vy_main_body")
if body is not None:
paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))
for p in body.find_all("p")]
desc = " ".join(t for t in paras if len(t) > 40)
if desc:
payload["description"] = desc[:1500]
# contact (téléphone affiché sur la fiche)
m = re.search(r'href="tel:\+?1?(\d{10})"', html)
if m:
d = m.group(1)
payload["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}"
# photos de la fiche
imgs = [u for u in dict.fromkeys(IMG_RE.findall(html))
if not SKIP_IMG_RE.search(u)]
payload["images"] = imgs[: self.max_images]
return payload
# -- fetch -----------------------------------------------------------------
def fetch(self) -> list[Listing]:
listings: list[Listing] = []
seen: set[str] = set()
for page in range(1, MAX_PAGES + 1):
url = LIST_URL if page == 1 else f"{LIST_URL}/page/{page}"
try:
html = self.get(url).text
except Exception:
break
soup = BeautifulSoup(html, "html.parser")
items = soup.select(".vy_rentals_listing_item")
if not items:
break
new_on_page = 0
for it in items:
link = it.select_one("a.vy_link_cover[href]")
if link is None:
continue
href = link["href"].split("?")[0].rstrip("/")
slug = href.rsplit("/", 1)[-1]
if not slug or "/page/" in href or slug in seen:
continue
seen.add(slug)
new_on_page += 1
title = ""
el = it.select_one(".vy_rentals_listing_item_title")
if el is not None:
title = re.sub(r"\s+", " ", el.get_text(" ", strip=True))
# 4 ½ | Repentigny — certaines cartes
# n'ont pas de span typologie (ex. Les Cimes Saint-Sulpice)
unit_type = city = ""
info = it.select_one(".vy_rentals_listing_item_info")
if info is not None:
spans = [s.get_text(" ", strip=True)
for s in info.find_all("span")]
for s in (s for s in spans if s):
if re.search(r"½|1/2|studio|loft|chambre", s, re.I):
unit_type = normalize_unit_type(s)
else:
city = s
price = None
price_label = ""
el = it.select_one(".vy_rentals_listing_item_price")
if el is not None:
price_label = el.get_text(" ", strip=True)
m = re.search(r"(\d[\d\s,]*)\s*\$", price_label)
if m:
try:
val = float(m.group(1).replace(" ", "")
.replace(" ", "").replace(",", ""))
if 300 <= val <= 20000:
price = val
except ValueError:
pass
availability = ""
el = it.select_one(".vy_bannerinfo_text")
if el is not None:
availability = el.get_text(" ", strip=True)
amenities: list[str] = []
if it.select_one(".vy_badge.--fiftyfive") is not None:
amenities.append("55 ans et plus")
card_img = ""
img = it.select_one("img[data-src]")
if img is not None:
card_img = img.get("data-src") or ""
key = f"{unit_type}|{city}|{price_label}|{availability}"
det = self.detail(slug, key, lambda u=href: self._detail(u))
images = list(det.get("images") or [])
if card_img and card_img not in images:
images.insert(0, card_img)
details: dict = {}
if det.get("phone"):
details["contact"] = {"phone": det["phone"]}
# adresse : fiche détail, sinon dérivée du slug de l'URL
address = det.get("address") or ""
if not address:
m = re.match(r"^\d+-(.+?)(?:-repentigny|-terrebonne)?$", slug)
if m and re.search(r"[a-z]{3}", m.group(1)):
address = m.group(1).replace("-", " ")
listings.append(Listing(
source=self.source_id,
external_id=slug,
url=href,
title=title or f"Appartement {unit_type}",
address=address,
city=city,
unit_type=unit_type,
price=price,
price_label=price_label,
availability=availability,
description=det.get("description", ""),
amenities=amenities,
details=details,
images=images[: self.max_images],
))
if new_on_page == 0:
break
return listings