SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.5 KB · 247 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/simard.py : connecteur Immeubles Simard (immeublessimard.com)5#   Section « À louer » — catégorie appartement uniquement (les bureaux,6#   commerces et laboratoires sont exclus d'office). Pages détail (cache BD7#   self.detail) : bloc specs étiqueté (#infos : pièces, prix, superficie,8#   disponibilité), « Caractéristiques et inclusions », description, images.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import hashlib13import re1415from bs4 import BeautifulSoup1617from ..schema import (Listing, infer_city, merge_details, normalize_unit_type,18                      strip_accents)19from .base import BaseConnector2021BASE = "https://immeublessimard.com"22LIST_URL = f"{BASE}/a-louer/categorie/appartement/"2324DETAIL_RE = re.compile(r"/a-louer/appartement/([a-z0-9\-]+)/([a-z0-9\-]+)/?$")25IMG_RE = re.compile(26    r"https://immeublessimard\.com/wp-content/uploads/[^\"'\\\s\)]+"27    r"\.(?:jpg|jpeg|png|webp)", re.I)28IMG_NOISE_RE = re.compile(r"logo|favicon|icon", re.I)29# Prix affiché « $1975.00 » ou « 1 975$ »30PRICE_RE = re.compile(r"(?:\$\s*([\d\s,]+(?:\.\d{2})?)|([\d][\d\s]*(?:,\d{2})?)\s*\$)")313233def _parse_price(text: str) -> float | None:34    """Gère les deux formats : « $1975.00 » et « 1 975$ »."""35    if not text:36        return None37    s = text.replace(" ", " ").replace(" ", " ")38    s = re.sub(r"(\d),(\d{3})", r"\1\2", s)      # 1,975 -> 197539    m = PRICE_RE.search(s)40    if not m:41        return None42    num = (m.group(1) or m.group(2)).replace(" ", "").replace(",", ".")43    try:44        val = float(num)45    except ValueError:46        return None47    return val if 100 <= val <= 20000 else None484950class SimardConnector(BaseConnector):51    source_id = "simard"52    request_delay = 0.653    max_details = 60             # garde-fou5455    def fetch(self) -> list[Listing]:56        html = self.get(LIST_URL).text57        soup = BeautifulSoup(html, "html.parser")5859        listings: dict[str, Listing] = {}60        for card in soup.select("div.item a[href*='/a-louer/appartement/']"):61            try:62                lst = self._parse_card(card)63            except Exception:64                continue65            if lst and lst.external_id not in listings:66                listings[lst.external_id] = lst6768        # Pages détail (cache BD) : specs, prix, dispo, commodités, images69        fetched = 070        for i, lst in enumerate(listings.values()):71            if i >= self.max_details:72                break73            # « v3 » : version du format de payload (invalide les caches anciens)74            key = hashlib.sha1(75                f"v3|{lst.title}|{lst.description}".encode("utf-8")).hexdigest()7677            def _fetch(url=lst.url):78                nonlocal fetched79                if fetched >= self.max_details:80                    raise RuntimeError("plafond de requêtes détail atteint")81                fetched += 182                return self._fetch_detail(url)8384            try:85                payload = self.detail(lst.external_id, key, _fetch)86            except Exception:87                payload = {}88            if payload:89                self._apply_detail(lst, payload)9091        return list(listings.values())9293    def _parse_card(self, card) -> Listing | None:94        href = card.get("href", "").split("?")[0]95        m = DETAIL_RE.search(href)96        if not m:97            return None98        sector_slug, slug = m.groups()99        title_el = card.select_one("h2")100        sector_el = card.select_one(".secteur")101        bullets = [li.get_text(" ", strip=True) for li in card.select("li")]102        title = title_el.get_text(" ", strip=True) if title_el else slug103        sector = sector_el.get_text(strip=True) if sector_el else \104            sector_slug.replace("-", " ").title()105        unit_type = ""106        for b in bullets + [title]:107            m2 = re.search(r"\d\s*(?:½|1/2)|studio|loft", b, re.I)108            if m2:109                unit_type = normalize_unit_type(m2.group(0))110                break111        # certains titres SONT l'adresse civique (« 1276-24 Chanoine-Morel »)112        address = title if re.match(r"^\d{2,5}(?:-\d+[A-Za-z]?)?\s+\D", title) else ""113        return Listing(114            source=self.source_id,115            external_id=slug,116            url=href if href.startswith("http") else BASE + href,117            title=title,118            address=address,119            sector=sector,120            city=infer_city(sector),121            unit_type=unit_type,122            description=" • ".join(b for b in bullets if b)[:400],123        )124125    def _fetch_detail(self, url: str) -> dict:126        """Télécharge une fiche et en extrait le payload brut (cacheable)."""127        html = self.get(url).text128        soup = BeautifulSoup(html, "html.parser")129130        # Bloc specs étiqueté (#infos) : « Nombre de pièces : 4½ »,131        # « Prix : $1975 », « Superficie : 902 pi2 », « Disponibilité : … »132        specs: dict[str, str] = {}133        for li in soup.select("#infos ul li"):134            t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))135            m = re.match(r"(Nombre de pièces|Prix|Superficie|Disponibilité)"136                         r"\s*:\s*(.+)", t, re.I)137            if m:138                specs[strip_accents(m.group(1).lower())] = m.group(2).strip()139140        # statut affiché dans l'entête (« Disponible », « Loué »)141        status_el = soup.select_one("#infos p.details .text-right")142        status = status_el.get_text(" ", strip=True) if status_el else ""143144        # bloc « Location : » — contact structuré (tel:/mailto:)145        contact: dict = {}146        tel = soup.select_one("#infos a[href^='tel:']")147        if tel:148            digits = re.sub(r"\D", "", tel.get("href", ""))149            if len(digits) == 10:150                contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"151        mail = soup.select_one("#infos a[href^='mailto:']")152        if mail:153            m = re.match(r"mailto:([^?]+)", mail.get("href", ""))154            if m:155                contact["email"] = m.group(1).strip()156157        # « Caractéristiques et inclusions » + « Description »158        amenities: list[str] = []159        desc_parts: list[str] = []160        contenu = soup.select_one("section .contenu")161        if contenu:162            for h2 in contenu.select("h2"):163                title = h2.get_text(" ", strip=True)164                if re.search(r"Caractéristiques", title, re.I):165                    ul = h2.find_next_sibling("ul")166                    for li in (ul.select("li") if ul else []):167                        t = li.get_text(" ", strip=True)168                        if t and len(t) < 80 and t not in amenities:169                            amenities.append(t)170                elif re.search(r"Description", title, re.I):171                    for p in h2.find_next_siblings("p"):172                        t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))173                        if t:174                            desc_parts.append(t)175        desc = " ".join(desc_parts)176        if not desc:  # repli : meta description177            og = soup.find("meta", attrs={"property": "og:description"}) or \178                soup.find("meta", attrs={"name": "description"})179            desc = (og.get("content") or "").strip() if og else ""180181        imgs = [u for u in dict.fromkeys(IMG_RE.findall(html))182                if not IMG_NOISE_RE.search(u)]183        # éliminer les variantes redimensionnées quand l'originale est là184        originals = {re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) for u in imgs}185        images = [u for u in imgs186                  if re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) == u187                  or re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) not in originals][:25]188189        return {"specs": specs, "status": status, "amenities": amenities,190                "contact": contact, "description": desc[:1200],191                "images": images}192193    def _apply_detail(self, lst: Listing, payload: dict) -> None:194        """Applique le payload d'une fiche (frais ou depuis le cache BD)."""195        specs = payload.get("specs") or {}196197        if specs.get("prix"):198            price = _parse_price(specs["prix"])199            if price:200                lst.price = price201                lst.price_label = specs["prix"]202        if lst.price is None:203            # repli : prix dans la description (« Prix: $1975.00 … ») en204            # ignorant les mentions de stationnement (« Stationnement; $90.00 »)205            cleaned = re.sub(r"[Ss]tationnement[^.]{0,40}\$\s*[\d.,]+", " ",206                             payload.get("description") or "")207            price = _parse_price(cleaned)208            if price:209                lst.price = price210                m = PRICE_RE.search(re.sub(r"(\d),(\d{3})", r"\1\2", cleaned))211                lst.price_label = m.group(0).strip() if m else ""212213        if specs.get("disponibilite"):214            lst.availability = f"Disponibilité : {specs['disponibilite']}"215        elif payload.get("status") and re.search(r"disponible",216                                                 payload["status"], re.I):217            lst.availability = payload["status"]218219        if specs.get("nombre de pieces"):220            lst.unit_type = normalize_unit_type(specs["nombre de pieces"])221        if not lst.unit_type:222            m = re.search(r"\d\s*(?:½|1/2)|studio|loft",223                          payload.get("description") or "", re.I)224            if m:225                lst.unit_type = normalize_unit_type(m.group(0))226227        amenities = list(payload.get("amenities") or [])228        # superficie : texte source (« 902 pi2 ») ajouté aux commodités pour229        # affichage + parsing par finalize(). Quirk du site : le champ230        # contient parfois un type d'unité (« Superficie : 4 ½ »).231        surf = specs.get("superficie") or ""232        if re.search(r"\d\s*(?:pi|m)\b|pi[²2]|m[²2]", surf):233            amenities.append(f"Superficie : {surf}")234        elif surf and not lst.unit_type:235            lst.unit_type = normalize_unit_type(surf)236        if amenities:237            lst.amenities = amenities238239        if payload.get("contact"):240            lst.details = merge_details(lst.details,241                                        {"contact": payload["contact"]})242243        if payload.get("description"):244            lst.description = payload["description"]245        if payload.get("images"):246            lst.images = payload["images"]247