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.1 KB · 136 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/signature_boisbriand.py : connecteur Signature Boisbriand5#   (signatureboisbriand.com) — projet locatif au 1040-1050, rue des6#   Francs-Bourgeois, Boisbriand (Faubourg Boisbriand, ~266 unités).7#   WordPress + FacetWP rendu serveur : /unites-disponibles/ liste des lignes8#   .unit-row (numéro #1040-101, pièces, étage, superficie, prix « à partir9#   de », disponibilité, plan PDF) paginées via le paramètre ?_paged=N10#   (15 lignes/page). On ne garde que les unités NON « Unité louée ».11#   Granularité : unité.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type20from .base import BaseConnector2122BASE = "https://signatureboisbriand.com"23UNITS_URL = f"{BASE}/unites-disponibles/"24MAX_PAGES = 302526ADDRESS = "1040, rue des Francs-Bourgeois, Boisbriand"27CITY = "Boisbriand"2829PRICE_RE = re.compile(r"(\d[\d\s,]*)\s*\$")30IMG_RE = re.compile(31    r"https://signatureboisbriand\.com/wp-content/uploads/"32    r"[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I)33SKIP_IMG_RE = re.compile(r"logo|favicon|icon|-\d{2,4}x\d{2,4}\.", re.I)343536class SignatureBoisbriandConnector(BaseConnector):37    source_id = "signature_boisbriand"38    request_delay = 0.639    max_images = 104041    def _site_images(self) -> list[str]:42        """Photos du projet (page d'accueil) — les lignes n'ont qu'un plan PDF."""43        try:44            html = self.get(f"{BASE}/").text45        except Exception:46            return []47        return [u for u in dict.fromkeys(IMG_RE.findall(html))48                if not SKIP_IMG_RE.search(u)][: self.max_images]4950    @staticmethod51    def _txt(row, sel: str) -> str:52        el = row.select_one(sel)53        return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else ""5455    def fetch(self) -> list[Listing]:56        images = self._site_images()57        listings: list[Listing] = []58        seen: set[str] = set()5960        for page in range(1, MAX_PAGES + 1):61            url = UNITS_URL if page == 1 else f"{UNITS_URL}?_paged={page}"62            try:63                html = self.get(url).text64            except Exception:65                break66            soup = BeautifulSoup(html, "html.parser")67            rows = soup.select(".unit-row")68            if not rows:69                break7071            new_on_page = 072            for row in rows:73                num = self._txt(row, ".unit-number__text").lstrip("#").strip()74                if not num or num in seen:75                    continue76                seen.add(num)77                new_on_page += 17879                availability = self._txt(row, ".unit-availability__text")80                if re.search(r"lou[ée]", availability, re.I):81                    continue                       # « Unité louée »8283                unit_type = normalize_unit_type(84                    self._txt(row, ".unit-room__text"))85                floor = self._txt(row, ".unit-floor__text")8687                area = None88                m = re.search(r"(\d[\d\s]*)\s*pi",89                              self._txt(row, ".unit-area__text"))90                if m:91                    try:92                        v = float(m.group(1).replace(" ", "").replace(" ", ""))93                        if 100 <= v <= 10000:94                            area = v95                    except ValueError:96                        pass9798                price = None99                price_label = ""100                m = PRICE_RE.search(self._txt(row, ".unit-price__text"))101                if m:102                    try:103                        val = float(m.group(1).replace(" ", "")104                                    .replace(" ", "").replace(",", ""))105                        if 300 <= val <= 20000:106                            price = val107                            price_label = f"à partir de {int(val)}$"108                    except ValueError:109                        pass110111                imgs = list(images)112                plan = row.select_one('.unit-plan a[href$=".pdf"]')113                details: dict = {}114                if plan is not None:115                    details["floor_plan"] = plan["href"]116117                listings.append(Listing(118                    source=self.source_id,119                    external_id=num,120                    url=UNITS_URL,121                    title=f"Signature Boisbriand — Unité #{num}",122                    address=ADDRESS,123                    city=CITY,124                    unit_type=unit_type,125                    price=price,126                    price_label=price_label,127                    availability=availability or "Disponible",128                    area_sqft=area,129                    description=f"Unité #{num}, {floor}" if floor else "",130                    details=details,131                    images=imgs,132                ))133            if new_on_page == 0:134                break135        return listings136