# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/grilli_citea.py : connecteur Groupe Grilli Samuel / Citéa # (grillisamuel.com + projetcitea.com) — constructeur-gestionnaire ; son # inventaire locatif = le projet Citéa (950-970, avenue Pierre-Dansereau, # Terrebonne/Urbanova, 4 phases, condos locatifs tout inclus). # Backend : WordPress — l'API REST publique /wp-json/wp/v2/inventaire de # grillisamuel.com expose les fiches (content.rendered structuré : # caractéristiques, superficie, animaux, semi-meublé, photos). On ne garde # que les fiches LOCATIVES (« à louer » dans le texte) — les maisons de # Sainte-Julie/Pointe-Claire sont à VENDRE (promesse d'achat), exclues. # Les prix « à partir de » par typologie (3½/4½/4½+DEN) sont relevés sur # projetcitea.com/condos-locatifs/ et appariés à chaque fiche. # Granularité TYPOLOGIE (une annonce par type d'unité du projet). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector API_URL = "https://grillisamuel.com/wp-json/wp/v2/inventaire?per_page=100&_embed=wp:featuredmedia" CITEA_URL = "https://projetcitea.com/condos-locatifs/" # « 3½ à partir de 1 605$ /mois » (le + DEN distingue la grande variante) CITEA_PRICE_RE = re.compile( r"([2-6])\s*½\s*(\+\s*DEN\s*)?à partir de\s*([\d ]{4,9})\s*\$\s*/\s*mois", re.I) # typologie de la fiche : « Projet Citéa – 4½ + DEN à louer … » TYPE_RE = re.compile(r"([2-6])\s*(?:½|1/2)\s*(\+\s*DEN)?", re.I) RENT_RE = re.compile(r"à louer|locatif", re.I) SALE_RE = re.compile(r"promesse d[’']achat(?! ou de location)", re.I) SQFT_RE = re.compile(r"Superficie habitable\s*\|?\s*([\d ]+)(?:\s*à\s*([\d ]+))?\s*pi", re.I) IMG_RE = re.compile(r'(?:src|data-src|href)="(https://grillisamuel\.com/' r'wp-content/uploads/[^"]+\.(?:jpe?g|png|webp))"', re.I) class GrilliCiteaConnector(BaseConnector): source_id = "grilli_citea" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: posts = self.get(API_URL).json() except Exception: return listings if not isinstance(posts, list): return listings prices = self._citea_prices() for post in posts: try: lst = self._from_post(post, prices) except Exception: continue if lst is not None: listings.append(lst) return listings # -- prix « à partir de » par typologie sur projetcitea.com ---------------- def _citea_prices(self) -> dict[str, float]: prices: dict[str, float] = {} try: txt = BeautifulSoup(self.get(CITEA_URL).text, "html.parser").get_text(" ", strip=True) except Exception: return prices for n, den, amount in CITEA_PRICE_RE.findall(txt): key = f"{n}½" + ("+DEN" if den else "") try: prices[key] = float(re.sub(r"[ ]", "", amount)) except ValueError: continue return prices def _from_post(self, post: dict, prices: dict[str, float]) -> Listing | None: title = BeautifulSoup(post.get("title", {}).get("rendered", ""), "html.parser").get_text(" ", strip=True) content_html = post.get("content", {}).get("rendered", "") text = BeautifulSoup(content_html, "html.parser").get_text("\n", strip=True) blob = f"{title}\n{text}" # logements à louer seulement (les maisons à vendre sont exclues) if not RENT_RE.search(blob) or SALE_RE.search(blob): return None # typologie : mention explicite « 3½ / 4½ (+ DEN) à louer » du descriptif tm = TYPE_RE.search(text) or TYPE_RE.search(title) unit_type = f"{tm.group(1)}½" if tm else "" den = bool(tm and tm.group(2)) price = prices.get(unit_type + ("+DEN" if den else "")) # adresse dans le titre : « Condo 1 chambre, 970, Avenue …, Terrebonne » addr = "" city = "" am = re.search(r"(\d{2,5}(?:-\d{2,5})?,?\s+(?:rue|avenue|boulevard|chemin|montée)" r"[^,]*),?\s*([A-ZÉÈÀ][\w-]+)?\s*$", title, re.I) if am: addr = am.group(1).strip() city = (am.group(2) or "").strip() if not city: cm = re.search(r"\b(Terrebonne|Sainte-Julie|Pointe-Claire|Mascouche" r"|Laval)\b", blob) city = cm.group(1) if cm else "" area = None sm = SQFT_RE.search(text) if sm: try: area = float(re.sub(r"[ ]", "", sm.group(1))) except ValueError: pass amenities: list[str] = [] for pat, lbl in ((r"Semi-meublé", "Semi-meublé"), (r"Stationnement intérieur", "Stationnement intérieur"), (r"[Tt]out inclus", "Tout inclus")): if re.search(pat, blob): amenities.append(lbl) pets = None pm = re.search(r"(Chiens[^|\n]{0,80}|[Aa]nimaux[^|\n]{0,80})", text) if pm: pets = "conditions" amenities.append(pm.group(1).strip()) images = list(dict.fromkeys(IMG_RE.findall(content_html)))[:20] if not images: emb = (post.get("_embedded", {}).get("wp:featuredmedia") or [{}])[0] src = emb.get("source_url", "") if src.startswith("http"): images = [src] # descriptif : à partir de la section DESCRIPTIF ou du projet desc = "" dm = re.search(r"(?:DESCRIPTIF|Projet Citéa)[\s:–-]*\n?(.{40,1800})", text, re.S) if dm: desc = re.sub(r"\s+", " ", dm.group(1)).strip() return Listing( source=self.source_id, external_id=str(post.get("id")), # ID WordPress : stable url=post.get("link", CITEA_URL), title=title, address=addr, sector="Urbanova" if city == "Terrebonne" else "", city=city, unit_type=unit_type + (" + DEN" if den else ""), price=price, price_label=(f"à partir de {price:.0f} $ /mois" if price else ""), availability="", area_sqft=area, pets=pets, description=desc[:2000], amenities=amenities, details={"price_from": True} if price else {}, images=images, )