spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/gestion_legrand.py : connecteur Gestion Le Grand (gestionlegrand.ca)5# WordPress + Elementor. Les logements sont des articles de la catégorie6# « Location » (id 28) : l'API wp-json livre id, lien, titre et contenu7# complet en 1 requête ; la page /a-louer/ (2e requête) fournit les8# vignettes (certains médias sont bloqués côté REST). Prix (« Prix :9# 1 200 $ / mois »), dispo et adresse lus dans le contenu de l'article.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, normalize_unit_type, parse_price18from .base import BaseConnector1920BASE = "https://gestionlegrand.ca"21API_URL = (f"{BASE}/wp-json/wp/v2/posts?categories=28&per_page=100"22 "&_fields=id,link,title,content")23LIST_URL = f"{BASE}/a-louer/"2425_PRIX_RE = re.compile(r"Prix\s*:\s*([\d\s.,]*\d\s*\$(?:\s*/?\s*mois)?)", re.I)26_ADDR_RE = re.compile(r"situ[ée]e?\s+au\s+(\d+[^,.]*,\s*(?:rue|boul(?:evard|\.)?|"27 r"avenue|av\.|place|chemin)[^,.]+)", re.I)28_SECTEUR_RE = re.compile(r"secteur\s+(?:[\w\s]{0,40}?de\s+)?"29 r"([A-ZÉ][\wé]+(?:-[A-ZÉa-z][\wéè]+)+)")303132class GestionLegrandConnector(BaseConnector):33 source_id = "gestion_legrand"34 request_delay = 0.63536 def fetch(self) -> list[Listing]:37 posts = self.get(API_URL).json()3839 # vignettes : la grille Elementor de /a-louer/ (post-<id> -> data-src)40 thumbs: dict[str, str] = {}41 try:42 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")43 for art in soup.select("article.elementor-post"):44 pid = next((c.split("-", 1)[1] for c in art.get("class", [])45 if re.fullmatch(r"post-\d+", c)), "")46 img = art.select_one("img[data-src], img[src]")47 if pid and img:48 src = img.get("data-src") or img.get("src") or ""49 if src.startswith("http"):50 thumbs[pid] = src51 except Exception:52 pass5354 listings: list[Listing] = []55 for post in posts:56 try:57 lst = self._parse_post(post, thumbs)58 except Exception:59 continue60 if lst:61 listings.append(lst)62 return listings6364 def _parse_post(self, post: dict, thumbs: dict[str, str]) -> Listing | None:65 ext_id = str(post.get("id", ""))66 url = post.get("link", "")67 title = BeautifulSoup(post.get("title", {}).get("rendered", ""),68 "html.parser").get_text(" ", strip=True)69 if not ext_id or not url or not title:70 return None71 # exclusions : locaux commerciaux, stationnements, rangements72 if re.search(r"commercial|bureau|stationnement|rangement|entrep[ôo]t",73 title, re.I):74 return None7576 body = BeautifulSoup(post.get("content", {}).get("rendered", ""),77 "html.parser")78 paras = [re.sub(r"\s+", " ", el.get_text(" ", strip=True))79 for el in body.find_all(["p", "h2", "h3", "li"])]80 paras = [p for p in paras if p]81 text = "\n".join(dict.fromkeys(paras))8283 # « Disponible maintenant », « Disponible dès juin 2026 !! », …84 availability = next((p for p in paras85 if re.match(r"disponible\b", p, re.I)), "")8687 m = _PRIX_RE.search(text)88 price_label = m.group(1).strip() if m else ""8990 m = _ADDR_RE.search(text)91 address = m.group(1).strip() if m else ""9293 m = _SECTEUR_RE.search(text)94 sector = m.group(1) if m else ""9596 # ville réelle : le titre se termine par « …, Drummondville » ;97 # tout le parc Le Grand y est (repli documenté dans le rapport)98 city = title.rsplit(",", 1)[1].strip() if "," in title else "Drummondville"99100 unit_type = normalize_unit_type(title)101 if not re.fullmatch(r"\d½\+?|Studio|Loft|Maison", unit_type or ""):102 unit_type = ""103104 images = [thumbs[ext_id]] if ext_id in thumbs else []105106 return Listing(107 source=self.source_id,108 external_id=ext_id,109 url=url,110 title=title,111 address=address,112 sector=sector,113 city=city,114 unit_type=unit_type,115 price=parse_price(price_label),116 price_label=price_label,117 availability=availability,118 description=text[:2000],119 images=images,120 )121