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%
7.8 KB · 195 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/iparc.py : connecteur iParc (iparc.ca) — logements à louer à5#   Montréal. WordPress + plugin Estatik (type « property » non exposé au6#   REST) : on parcourt les pages de la catégorie /property-category/a-louer/7#   (+ /page/N/) dont les cartes .es-listing portent data-post-id (external_id8#   stable) et le lien /property/<slug>/. La fiche fournit les champs Estatik9#   (« Taille : 4 1/2 », Chambres, Salle de bain), le prix (.es-price), les10#   équipements/caractéristiques, les coordonnées GPS (data-latitude/11#   longitude), la galerie (wp-content/uploads, miniatures -WxH retirées) et12#   une description bilingue avec l'adresse (📍 …) et la disponibilité.13#   Fiches via le cache détail (clé = hash du texte de la carte).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://iparc.ca"26CAT_URL = f"{BASE}/property-category/a-louer/"27_MAX_PAGES = 10                          # garde-fou pagination2829FIELD_RE = re.compile(r"^([^:]{2,30})\s*:\s*(.+)$", re.S)30# adresse civique après le 📍 (doit commencer par un numéro)31ADDR_RE = re.compile(r"📍\s*(\d[\w\s.,'°é½-]{5,80}?)\s*[–—(]")32DISPO_RE = re.compile(33    r"Disponible\s*(?:le\s*)?(?:immédiatement|d[èe]s maintenant"34    r"|\d{1,2}\s*(?:er)?\s*(?:janvier|février|mars|avril|mai|juin|juillet"35    r"|août|septembre|octobre|novembre|décembre)(?:\s*20\d\d)?)", re.I)36# repli adresse : « 5187 rue Berri », « 6050, 30e Avenue »…37ADDR_FALLBACK_RE = re.compile(38    r"(\d{2,5}(?:\s*,)?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|"39    r"place|\d{1,2}e\s+Avenue)\s*[A-ZÀ-Ü0-9][\w'’.é½-]*(?:\s+[A-ZÀ-Ü][\w'’é-]+)?)")40THUMB_RE = re.compile(r"-\d+x\d+(\.\w+)$")41IMG_BAD_RE = re.compile(r"logo|icon|favicon|iparc20\d\d", re.I)424344class IparcConnector(BaseConnector):45    source_id = "iparc"46    request_delay = 0.74748    def fetch(self) -> list[Listing]:49        listings: list[Listing] = []50        seen: set[str] = set()51        for page in range(1, _MAX_PAGES + 1):52            url = CAT_URL if page == 1 else f"{CAT_URL}page/{page}/"53            try:54                html = self.get(url).text55            except Exception:56                break                    # 404 = fin de la pagination57            soup = BeautifulSoup(html, "html.parser")58            cards = soup.select(".es-listing[data-post-id]")59            if not cards:60                break61            for card in cards:62                try:63                    pid = str(card["data-post-id"])64                    if pid in seen:65                        continue66                    seen.add(pid)67                    a = card.find("a", href=re.compile(r"/property/"))68                    if not a:69                        continue70                    href = a["href"]71                    card_text = card.get_text(" ", strip=True)72                    key = hashlib.sha1(73                        card_text.encode("utf-8")).hexdigest()74                    d = self.detail(pid, key,75                                    lambda h=href: self._property(h))76                    lst = self._listing(pid, href, d)77                    if lst:78                        listings.append(lst)79                except Exception:80                    continue81        return listings8283    def _property(self, url: str) -> dict:84        """Scrape la fiche Estatik : champs, prix, GPS, galerie, description."""85        out: dict = {"title": "", "price": "", "fields": {}, "desc": "",86                     "amenities": [], "lat": None, "lng": None, "images": []}87        page = self.get(url).text88        soup = BeautifulSoup(page, "html.parser")89        if soup.h1:90            out["title"] = soup.h1.get_text(" ", strip=True)91        pr = soup.select_one(".es-price")92        if pr:93            out["price"] = pr.get_text(" ", strip=True)94        for f in soup.select(".es-property-field"):95            t = re.sub(r"\s+", " ", f.get_text(" ", strip=True))96            m = FIELD_RE.match(t)97            if not m:98                continue99            label, value = m.group(1).strip(), m.group(2).strip()100            if label.lower().startswith("la description"):101                out["desc"] = value[:3000]102            elif label.lower().startswith(("équipements",103                                           "caractéristiques")):104                out["amenities"].extend(105                    x.strip() for x in value.split("  ") if x.strip())106            else:107                out["fields"][label] = value108        geo = soup.select_one("[data-latitude][data-longitude]")109        if geo:110            try:111                out["lat"] = float(geo["data-latitude"])112                out["lng"] = float(geo["data-longitude"])113            except (TypeError, ValueError):114                pass115        images = []116        for img in soup.select(".es-single img, .es-mobile-gallery img"):117            u = img.get("src") or img.get("data-lazy") or ""118            if not u.startswith("http") or "/uploads/" not in u \119                    or IMG_BAD_RE.search(u):120                continue121            u = THUMB_RE.sub(r"\1", u)   # miniature -> pleine taille122            if u not in images:123                images.append(u)124        out["images"] = images[:15]125        return out126127    def _listing(self, pid: str, url: str, d: dict) -> Listing | None:128        fields = d.get("fields", {})129        desc = d.get("desc", "")130        title = d.get("title") or ""131132        taille = fields.get("Taille", "")133        unit_type = normalize_unit_type(taille) if taille else ""134        bedrooms = bathrooms = None135        try:136            if fields.get("Chambres"):137                bedrooms = float(fields["Chambres"])138        except ValueError:139            pass140        try:141            if fields.get("Salle de bain"):142                bathrooms = float(fields["Salle de bain"])143        except ValueError:144            pass145146        address = ""147        am = ADDR_RE.search(desc) or ADDR_FALLBACK_RE.search(desc)148        if am:149            address = re.sub(r"\s+,", ",", am.group(1)).strip()150        availability = ""151        dm = DISPO_RE.search(desc)152        if dm:153            availability = re.sub(r"\s+", " ", dm.group(0)).strip()154155        sector = ""156        poi = fields.get("centre d'intéret") or fields.get("centre d'intérêt")157        if poi:158            premier = poi.split(",")[0].strip()159            # noms propres seulement (écarte « commerces », « métro »…)160            if premier[:1].isupper():161                sector = premier162163        amenities = list(dict.fromkeys(d.get("amenities", [])))164        pieces = fields.get("Nombre de pièces")165        if pieces:166            amenities.append(f"{pieces} pièces")167        furnished = None168        blob = " ".join(amenities).lower()169        if "non meublé" in blob:170            furnished = False171        elif "meublé" in blob or "meublé" in title.lower():172            furnished = True173174        return Listing(175            source=self.source_id,176            external_id=pid,177            url=url,178            title=title,179            address=address,180            sector=sector,181            city="Montréal",182            unit_type=unit_type,183            bedrooms=bedrooms,184            bathrooms=bathrooms,185            price=parse_price(d.get("price", "")),186            price_label=d.get("price", ""),187            availability=availability,188            furnished=furnished,189            description=desc,190            amenities=amenities,191            images=d.get("images", []),192            lat=d.get("lat"),193            lng=d.get("lng"),194        )195