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.8 KB · 164 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/grilli_citea.py : connecteur Groupe Grilli Samuel / Citéa5#   (grillisamuel.com + projetcitea.com) — constructeur-gestionnaire ; son6#   inventaire locatif = le projet Citéa (950-970, avenue Pierre-Dansereau,7#   Terrebonne/Urbanova, 4 phases, condos locatifs tout inclus).8#   Backend : WordPress — l'API REST publique /wp-json/wp/v2/inventaire de9#   grillisamuel.com expose les fiches (content.rendered structuré :10#   caractéristiques, superficie, animaux, semi-meublé, photos). On ne garde11#   que les fiches LOCATIVES (« à louer » dans le texte) — les maisons de12#   Sainte-Julie/Pointe-Claire sont à VENDRE (promesse d'achat), exclues.13#   Les prix « à partir de » par typologie (3½/4½/4½+DEN) sont relevés sur14#   projetcitea.com/condos-locatifs/ et appariés à chaque fiche.15#   Granularité TYPOLOGIE (une annonce par type d'unité du projet).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from bs4 import BeautifulSoup2223from ..schema import Listing24from .base import BaseConnector2526API_URL = "https://grillisamuel.com/wp-json/wp/v2/inventaire?per_page=100&_embed=wp:featuredmedia"27CITEA_URL = "https://projetcitea.com/condos-locatifs/"2829# « 3½ à partir de 1 605$ /mois » (le + DEN distingue la grande variante)30CITEA_PRICE_RE = re.compile(31    r"([2-6])\s*½\s*(\+\s*DEN\s*)?à partir de\s*([\d  ]{4,9})\s*\$\s*/\s*mois",32    re.I)33# typologie de la fiche : « Projet Citéa – 4½ + DEN à louer … »34TYPE_RE = re.compile(r"([2-6])\s*(?:½|1/2)\s*(\+\s*DEN)?", re.I)35RENT_RE = re.compile(r"à louer|locatif", re.I)36SALE_RE = re.compile(r"promesse d[’']achat(?! ou de location)", re.I)37SQFT_RE = re.compile(r"Superficie habitable\s*\|?\s*([\d  ]+)(?:\s*à\s*([\d  ]+))?\s*pi", re.I)38IMG_RE = re.compile(r'(?:src|data-src|href)="(https://grillisamuel\.com/'39                    r'wp-content/uploads/[^"]+\.(?:jpe?g|png|webp))"', re.I)404142class GrilliCiteaConnector(BaseConnector):43    source_id = "grilli_citea"44    request_delay = 0.84546    def fetch(self) -> list[Listing]:47        listings: list[Listing] = []48        try:49            posts = self.get(API_URL).json()50        except Exception:51            return listings52        if not isinstance(posts, list):53            return listings54        prices = self._citea_prices()5556        for post in posts:57            try:58                lst = self._from_post(post, prices)59            except Exception:60                continue61            if lst is not None:62                listings.append(lst)63        return listings6465    # -- prix « à partir de » par typologie sur projetcitea.com ----------------66    def _citea_prices(self) -> dict[str, float]:67        prices: dict[str, float] = {}68        try:69            txt = BeautifulSoup(self.get(CITEA_URL).text,70                                "html.parser").get_text(" ", strip=True)71        except Exception:72            return prices73        for n, den, amount in CITEA_PRICE_RE.findall(txt):74            key = f"{n}½" + ("+DEN" if den else "")75            try:76                prices[key] = float(re.sub(r"[  ]", "", amount))77            except ValueError:78                continue79        return prices8081    def _from_post(self, post: dict, prices: dict[str, float]) -> Listing | None:82        title = BeautifulSoup(post.get("title", {}).get("rendered", ""),83                              "html.parser").get_text(" ", strip=True)84        content_html = post.get("content", {}).get("rendered", "")85        text = BeautifulSoup(content_html, "html.parser").get_text("\n", strip=True)86        blob = f"{title}\n{text}"8788        # logements à louer seulement (les maisons à vendre sont exclues)89        if not RENT_RE.search(blob) or SALE_RE.search(blob):90            return None9192        # typologie : mention explicite « 3½ / 4½ (+ DEN) à louer » du descriptif93        tm = TYPE_RE.search(text) or TYPE_RE.search(title)94        unit_type = f"{tm.group(1)}½" if tm else ""95        den = bool(tm and tm.group(2))96        price = prices.get(unit_type + ("+DEN" if den else ""))9798        # adresse dans le titre : « Condo 1 chambre, 970, Avenue …, Terrebonne »99        addr = ""100        city = ""101        am = re.search(r"(\d{2,5}(?:-\d{2,5})?,?\s+(?:rue|avenue|boulevard|chemin|montée)"102                       r"[^,]*),?\s*([A-ZÉÈÀ][\w-]+)?\s*$", title, re.I)103        if am:104            addr = am.group(1).strip()105            city = (am.group(2) or "").strip()106        if not city:107            cm = re.search(r"\b(Terrebonne|Sainte-Julie|Pointe-Claire|Mascouche"108                           r"|Laval)\b", blob)109            city = cm.group(1) if cm else ""110111        area = None112        sm = SQFT_RE.search(text)113        if sm:114            try:115                area = float(re.sub(r"[  ]", "", sm.group(1)))116            except ValueError:117                pass118119        amenities: list[str] = []120        for pat, lbl in ((r"Semi-meublé", "Semi-meublé"),121                         (r"Stationnement intérieur", "Stationnement intérieur"),122                         (r"[Tt]out inclus", "Tout inclus")):123            if re.search(pat, blob):124                amenities.append(lbl)125        pets = None126        pm = re.search(r"(Chiens[^|\n]{0,80}|[Aa]nimaux[^|\n]{0,80})", text)127        if pm:128            pets = "conditions"129            amenities.append(pm.group(1).strip())130131        images = list(dict.fromkeys(IMG_RE.findall(content_html)))[:20]132        if not images:133            emb = (post.get("_embedded", {}).get("wp:featuredmedia") or [{}])[0]134            src = emb.get("source_url", "")135            if src.startswith("http"):136                images = [src]137138        # descriptif : à partir de la section DESCRIPTIF ou du projet139        desc = ""140        dm = re.search(r"(?:DESCRIPTIF|Projet Citéa)[\s:–-]*\n?(.{40,1800})",141                       text, re.S)142        if dm:143            desc = re.sub(r"\s+", " ", dm.group(1)).strip()144145        return Listing(146            source=self.source_id,147            external_id=str(post.get("id")),      # ID WordPress : stable148            url=post.get("link", CITEA_URL),149            title=title,150            address=addr,151            sector="Urbanova" if city == "Terrebonne" else "",152            city=city,153            unit_type=unit_type + (" + DEN" if den else ""),154            price=price,155            price_label=(f"à partir de {price:.0f} $ /mois" if price else ""),156            availability="",157            area_sqft=area,158            pets=pets,159            description=desc[:2000],160            amenities=amenities,161            details={"price_from": True} if price else {},162            images=images,163        )164