# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/double_v.py : connecteur Le Double V (ledoublev.ca) # Collection de projets locatifs en Montérégie : Sainte-Julie (500, boul. # Saint-Joseph), McMasterville (235, chemin du Richelieu — livraison Hiver # 2026-2027) et Saint-Hyacinthe (845, avenue Crémazie, secteur Douville). # WordPress + Bricks rendu serveur : chaque page projet expose la grille # des unités en div[data-unit-post-title] avec data-unit-rooms (« 3 ½ »), # data-unit-surface, data-unit-floor et plans (data-unit-plan-image/floor). # Le statut (« Disponible »/« Loué ») est dans le texte de la ligne — seules # les unités « Disponible » sont ingérées. Les prix sont par typologie # (« 3 ½ À partir de 1900$ par mois » dans le texte de la page) ; une # mention de saison en tête de page (« Hiver 2026-2027 ») sert de # disponibilité (projet en prélocation). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.ledoublev.ca" # clé de page -> (chemin, ville, adresse civique) PAGES = { "sainte-julie": ("/sainte-julie/", "Sainte-Julie", "500, boulevard Saint-Joseph, Sainte-Julie"), "mcmasterville": ("/mcmasterville/", "McMasterville", "235, chemin du Richelieu, McMasterville"), "saint-hyacinthe": ("/saint-hyacinthe/", "Saint-Hyacinthe", "845, avenue Crémazie, Saint-Hyacinthe"), } PRICE_TYPE_RE = re.compile( r"(\d)\s*½\s*À partir de\s*(\d[\d\s ]{2,6})\s*\$\s*par mois", re.I) DELIVERY_RE = re.compile( r"\b((?:Hiver|Printemps|Été|Automne)\s+\d{4}(?:-\d{4})?)") BEDS_RE = re.compile(r"(\d)\s*chambres?", re.I) IMG_RE = re.compile( r"https://www\.ledoublev\.ca/wp-content/uploads/[^\"\s\\']+" r"\.(?:jpe?g|png|webp)", re.I) SKIP_IMG_RE = re.compile( r"logo|icon|favicon|cropped|plan|-\d{2,4}x\d{2,4}\.", re.I) class DoubleVConnector(BaseConnector): source_id = "double_v" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] for page_key, (path, city, address) in PAGES.items(): try: html = self.get(f"{BASE}{path}").text except Exception: continue self._parse_page(html, page_key, f"{BASE}{path}", city, address, listings) return listings def _parse_page(self, html: str, page_key: str, url: str, city: str, address: str, listings: list[Listing]) -> None: soup = BeautifulSoup(html, "html.parser") text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) # prix plancher par typologie : « 3 ½ À partir de 1900$ par mois » prices: dict[str, tuple[float, str]] = {} for m in PRICE_TYPE_RE.finditer(text): t = f"{m.group(1)}½" try: prices[t] = (float(re.sub(r"[^\d]", "", m.group(2))), f"À partir de {re.sub(r'[ ]', ' ', m.group(2)).strip()}$ par mois") except ValueError: continue # projet en prélocation : saison de livraison en tête de page m = DELIVERY_RE.search(text[:2000]) delivery = m.group(1) if m else "" images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not SKIP_IMG_RE.search(u)][:8] seen: set[str] = set() for row in soup.select("div[data-unit-post-title]"): try: unit_no = row.get("data-unit-post-title", "").strip() if not re.fullmatch(r"[\w-]{1,10}", unit_no): continue ext_id = f"{page_key}-{unit_no}" if ext_id in seen: continue rtext = row.get_text(" ", strip=True) if not re.search(r"\bDisponible\b", rtext, re.I): continue # Loué / Réservé : exclu seen.add(ext_id) rooms = row.get("data-unit-rooms", "") unit_type = normalize_unit_type(re.sub(r"\s+", "", rooms)) if not re.match(r"^\d½$", unit_type): continue area = None surf = row.get("data-unit-surface", "") digits = re.sub(r"[^\d]", "", surf) if digits: area = float(digits) bedrooms = None m = BEDS_RE.search(rtext) if m: bedrooms = float(m.group(1)) price, price_label = prices.get(unit_type, (None, "")) details: dict = {} floor = row.get("data-unit-floor", "") if floor: details["floor_label"] = floor if row.get("data-unit-plan-image"): details["plan_image"] = row["data-unit-plan-image"] listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{unit_type} — Unité {unit_no}, " f"Le Double V {city}", address=address, city=city, unit_type=unit_type, bedrooms=bedrooms, price=price, price_label=price_label, availability=delivery or "Disponible", area_sqft=area, details=details, images=images, )) except Exception: continue