SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
5.9 KB · 146 lines python
Raw Blame History
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