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