Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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