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/double_v.py : connecteur Le Double V (ledoublev.ca)5# Collection de projets locatifs en Montérégie : Sainte-Julie (500, boul.6# Saint-Joseph), McMasterville (235, chemin du Richelieu — livraison Hiver7# 2026-2027) et Saint-Hyacinthe (845, avenue Crémazie, secteur Douville).8# WordPress + Bricks rendu serveur : chaque page projet expose la grille9# des unités en div[data-unit-post-title] avec data-unit-rooms (« 3 ½ »),10# data-unit-surface, data-unit-floor et plans (data-unit-plan-image/floor).11# Le statut (« Disponible »/« Loué ») est dans le texte de la ligne — seules12# les unités « Disponible » sont ingérées. Les prix sont par typologie13# (« 3 ½ À partir de 1900$ par mois » dans le texte de la page) ; une14# mention de saison en tête de page (« Hiver 2026-2027 ») sert de15# disponibilité (projet en prélocation).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type24from .base import BaseConnector2526BASE = "https://www.ledoublev.ca"2728# clé de page -> (chemin, ville, adresse civique)29PAGES = {30 "sainte-julie": ("/sainte-julie/", "Sainte-Julie",31 "500, boulevard Saint-Joseph, Sainte-Julie"),32 "mcmasterville": ("/mcmasterville/", "McMasterville",33 "235, chemin du Richelieu, McMasterville"),34 "saint-hyacinthe": ("/saint-hyacinthe/", "Saint-Hyacinthe",35 "845, avenue Crémazie, Saint-Hyacinthe"),36}3738PRICE_TYPE_RE = re.compile(39 r"(\d)\s*½\s*À partir de\s*(\d[\d\s ]{2,6})\s*\$\s*par mois", re.I)40DELIVERY_RE = re.compile(41 r"\b((?:Hiver|Printemps|Été|Automne)\s+\d{4}(?:-\d{4})?)")42BEDS_RE = re.compile(r"(\d)\s*chambres?", re.I)43IMG_RE = re.compile(44 r"https://www\.ledoublev\.ca/wp-content/uploads/[^\"\s\\']+"45 r"\.(?:jpe?g|png|webp)", re.I)46SKIP_IMG_RE = re.compile(47 r"logo|icon|favicon|cropped|plan|-\d{2,4}x\d{2,4}\.", re.I)484950class DoubleVConnector(BaseConnector):51 source_id = "double_v"52 request_delay = 0.65354 def fetch(self) -> list[Listing]:55 listings: list[Listing] = []56 for page_key, (path, city, address) in PAGES.items():57 try:58 html = self.get(f"{BASE}{path}").text59 except Exception:60 continue61 self._parse_page(html, page_key, f"{BASE}{path}",62 city, address, listings)63 return listings6465 def _parse_page(self, html: str, page_key: str, url: str, city: str,66 address: str, listings: list[Listing]) -> None:67 soup = BeautifulSoup(html, "html.parser")68 text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))6970 # prix plancher par typologie : « 3 ½ À partir de 1900$ par mois »71 prices: dict[str, tuple[float, str]] = {}72 for m in PRICE_TYPE_RE.finditer(text):73 t = f"{m.group(1)}½"74 try:75 prices[t] = (float(re.sub(r"[^\d]", "", m.group(2))),76 f"À partir de {re.sub(r'[ ]', ' ', m.group(2)).strip()}$ par mois")77 except ValueError:78 continue7980 # projet en prélocation : saison de livraison en tête de page81 m = DELIVERY_RE.search(text[:2000])82 delivery = m.group(1) if m else ""8384 images = [u for u in dict.fromkeys(IMG_RE.findall(html))85 if not SKIP_IMG_RE.search(u)][:8]8687 seen: set[str] = set()88 for row in soup.select("div[data-unit-post-title]"):89 try:90 unit_no = row.get("data-unit-post-title", "").strip()91 if not re.fullmatch(r"[\w-]{1,10}", unit_no):92 continue93 ext_id = f"{page_key}-{unit_no}"94 if ext_id in seen:95 continue9697 rtext = row.get_text(" ", strip=True)98 if not re.search(r"\bDisponible\b", rtext, re.I):99 continue # Loué / Réservé : exclu100 seen.add(ext_id)101102 rooms = row.get("data-unit-rooms", "")103 unit_type = normalize_unit_type(re.sub(r"\s+", "", rooms))104 if not re.match(r"^\d½$", unit_type):105 continue106107 area = None108 surf = row.get("data-unit-surface", "")109 digits = re.sub(r"[^\d]", "", surf)110 if digits:111 area = float(digits)112113 bedrooms = None114 m = BEDS_RE.search(rtext)115 if m:116 bedrooms = float(m.group(1))117118 price, price_label = prices.get(unit_type, (None, ""))119120 details: dict = {}121 floor = row.get("data-unit-floor", "")122 if floor:123 details["floor_label"] = floor124 if row.get("data-unit-plan-image"):125 details["plan_image"] = row["data-unit-plan-image"]126127 listings.append(Listing(128 source=self.source_id,129 external_id=ext_id,130 url=url,131 title=f"{unit_type} — Unité {unit_no}, "132 f"Le Double V {city}",133 address=address,134 city=city,135 unit_type=unit_type,136 bedrooms=bedrooms,137 price=price,138 price_label=price_label,139 availability=delivery or "Disponible",140 area_sqft=area,141 details=details,142 images=images,143 ))144 except Exception:145 continue146