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%
6.7 KB · 166 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/location_sb.py : connecteur Location Résidentielle SB5#   (locationresidentielle.com — jumelés, appartements et maisons à louer à6#    Saint-Georges, Beauce). WordPress thème RealHomes rendu serveur : la page7#    /a-louer/ liste des cartes <article class="property-item"> (titre,8#    extrait, vignette, lien /property/<slug>/). Petit inventaire (2-3 fiches9#    actives) ; on écarte les cartes « liste d'attente » et « NON10#    DISPONIBLE ». La page détail (via cache BD) fournit adresse, chambres,11#    salles de bain, prix ($/mois — souvent « communiquez avec nous »),12#    disponibilité, caractéristiques (<li>) et photos.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price22from .base import BaseConnector2324BASE = "https://locationresidentielle.com"25LIST_URL = f"{BASE}/a-louer/"2627CITY = "Saint-Georges"2829SLUG_RE = re.compile(r"/property/([^/?#]+)")30# cartes qui ne sont pas de vraies annonces31SKIP_RE = re.compile(r"liste\s+d.attente|inscription|non\s+disponible", re.I)32ADDR_RE = re.compile(r"(\d{3,6},?\s+\d{1,3}e\s+(?:Rue|Avenue),?\s+"33                     r"(?:Saint|St)-Georges(?:,?\s+G\d[A-Z]\s*\d[A-Z]\d)?)",34                     re.I)35BED_RE = re.compile(r"(\d+)\s*(?:grandes?\s*)?chambres", re.I)36BATH_RE = re.compile(r"(\d+)\s*salles? de bain", re.I)37PRICE_RE = re.compile(r"(\d[\d\s]{2,6})\s*\$\s*/?\s*mois", re.I)38DISPO_RE = re.compile(r"(?:Disponibilit[ée]\s*:\s*([^\n]{2,60})|"39                      r"((?:CESSION DE BAIL\s+)?DISPONIBLE EN [A-ZÉÛ]+))")40TYPE_RE = re.compile(r"\d\s*1/2")41UNIT_TEL_RE = re.compile(r"\b(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})\b")42IMG_RE = re.compile(r'(?:src|href)="(https://locationresidentielle\.com/'43                    r'wp-content/uploads/[^"]+\.(?:jpe?g|png|webp))"', re.I)44IMG_SKIP_RE = re.compile(r"logo|signature|carte|a_louer|"45                         r"jumeles-appartements|-\d{2,3}x\d{2,3}\.", re.I)464748class LocationSbConnector(BaseConnector):49    source_id = "location_sb"50    request_delay = 0.65152    def fetch(self) -> list[Listing]:53        listings: list[Listing] = []54        html = self.get(LIST_URL).text55        soup = BeautifulSoup(html, "html.parser")5657        for item in soup.select("article.property-item"):58            try:59                a = item.select_one("h4 a[href]")60                if not a:61                    continue62                title = re.sub(r"[•\s]+", " ", a.get_text(strip=True)).strip()63                if not title or SKIP_RE.search(title):64                    continue65                m = SLUG_RE.search(a["href"])66                if not m:67                    continue68                slug = m.group(1)69                url = f"{BASE}/property/{slug}/"7071                excerpt = ""72                p = item.find("p")73                if p:74                    excerpt = re.sub(75                        r"\s+", " ",76                        p.get_text(" ", strip=True)).replace(77                        "Plus d'informations", "").strip(" …-")7879                key = hashlib.sha1(80                    f"{url}|{title}|{excerpt}".encode()).hexdigest()81                try:82                    d = self.detail(slug, key, lambda u=url: self._detail(u))83                except Exception:84                    d = {}8586                tm = TYPE_RE.search(title)87                unit_type = normalize_unit_type(tm.group(0)) if tm else ""8889                listings.append(Listing(90                    source=self.source_id,91                    external_id=slug,92                    url=url,93                    title=title,94                    address=d.get("address")95                    or f"{title}, Saint-Georges",96                    city=CITY,97                    unit_type=unit_type,98                    bedrooms=d.get("bedrooms"),99                    bathrooms=d.get("bathrooms"),100                    price=d.get("price"),101                    price_label=d.get("price_label", ""),102                    availability=d.get("availability", ""),103                    description=d.get("description") or excerpt,104                    amenities=d.get("amenities") or [],105                    details=d.get("details") or {},106                    images=d.get("images") or [],107                ))108            except Exception:109                continue110        return listings111112    # -- page détail /property/<slug>/ (thème RealHomes) ------------------------113    def _detail(self, url: str) -> dict:114        html = self.get(url).text115        soup = BeautifulSoup(html, "html.parser")116        art = soup.find("article") or soup117        txt = re.sub(r"[ \t]+", " ", art.get_text("\n", strip=True))118        flat = re.sub(r"\s+", " ", txt)119        out: dict = {}120121        m = ADDR_RE.search(flat)122        if m:123            addr = re.sub(r"\s+", " ", m.group(1)).strip()124            if not re.search(r"saint-georges", addr, re.I):125                addr = re.sub(r"St-Georges", "Saint-Georges", addr, flags=re.I)126            out["address"] = addr127        m = BED_RE.search(flat)128        if m:129            out["bedrooms"] = float(m.group(1))130        m = BATH_RE.search(flat)131        if m:132            out["bathrooms"] = float(m.group(1))133        m = PRICE_RE.search(flat)134        if m:135            out["price"] = parse_price(m.group(0))136            out["price_label"] = f"{m.group(1).strip()} $/mois"137        elif re.search(r"Prix\s*:\s*communiquez", flat, re.I):138            out["price_label"] = "Prix : communiquez avec nous"139        m = DISPO_RE.search(txt)140        if m:141            out["availability"] = (m.group(1) or m.group(2) or "") \142                .strip().capitalize()143144        # caractéristiques / inclusions : listes <li> du contenu145        amenities: list[str] = []146        for li in art.find_all("li"):147            t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))148            t = re.sub(r"^[^\w]+|►.*$", "", t).strip()149            if (t and 3 <= len(t) < 80 and "ICI" not in t150                    and not re.search(r"estimez|inscrivez", t, re.I)151                    and t not in amenities):152                amenities.append(t)153        out["amenities"] = amenities[:20]154155        details: dict = {}156        pm = UNIT_TEL_RE.search(flat)157        if pm:158            details["contact"] = {159                "phone": f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}"}160        if details:161            out["details"] = details162163        out["images"] = [u for u in dict.fromkeys(IMG_RE.findall(html))164                         if not IMG_SKIP_RE.search(u)][:15]165        return out166