# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_legrand.py : connecteur Gestion Le Grand (gestionlegrand.ca) # WordPress + Elementor. Les logements sont des articles de la catégorie # « Location » (id 28) : l'API wp-json livre id, lien, titre et contenu # complet en 1 requête ; la page /a-louer/ (2e requête) fournit les # vignettes (certains médias sont bloqués côté REST). Prix (« Prix : # 1 200 $ / mois »), dispo et adresse lus dans le contenu de l'article. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://gestionlegrand.ca" API_URL = (f"{BASE}/wp-json/wp/v2/posts?categories=28&per_page=100" "&_fields=id,link,title,content") LIST_URL = f"{BASE}/a-louer/" _PRIX_RE = re.compile(r"Prix\s*:\s*([\d\s.,]*\d\s*\$(?:\s*/?\s*mois)?)", re.I) _ADDR_RE = re.compile(r"situ[ée]e?\s+au\s+(\d+[^,.]*,\s*(?:rue|boul(?:evard|\.)?|" r"avenue|av\.|place|chemin)[^,.]+)", re.I) _SECTEUR_RE = re.compile(r"secteur\s+(?:[\w\s]{0,40}?de\s+)?" r"([A-ZÉ][\wé]+(?:-[A-ZÉa-z][\wéè]+)+)") class GestionLegrandConnector(BaseConnector): source_id = "gestion_legrand" request_delay = 0.6 def fetch(self) -> list[Listing]: posts = self.get(API_URL).json() # vignettes : la grille Elementor de /a-louer/ (post- -> data-src) thumbs: dict[str, str] = {} try: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") for art in soup.select("article.elementor-post"): pid = next((c.split("-", 1)[1] for c in art.get("class", []) if re.fullmatch(r"post-\d+", c)), "") img = art.select_one("img[data-src], img[src]") if pid and img: src = img.get("data-src") or img.get("src") or "" if src.startswith("http"): thumbs[pid] = src except Exception: pass listings: list[Listing] = [] for post in posts: try: lst = self._parse_post(post, thumbs) except Exception: continue if lst: listings.append(lst) return listings def _parse_post(self, post: dict, thumbs: dict[str, str]) -> Listing | None: ext_id = str(post.get("id", "")) url = post.get("link", "") title = BeautifulSoup(post.get("title", {}).get("rendered", ""), "html.parser").get_text(" ", strip=True) if not ext_id or not url or not title: return None # exclusions : locaux commerciaux, stationnements, rangements if re.search(r"commercial|bureau|stationnement|rangement|entrep[ôo]t", title, re.I): return None body = BeautifulSoup(post.get("content", {}).get("rendered", ""), "html.parser") paras = [re.sub(r"\s+", " ", el.get_text(" ", strip=True)) for el in body.find_all(["p", "h2", "h3", "li"])] paras = [p for p in paras if p] text = "\n".join(dict.fromkeys(paras)) # « Disponible maintenant », « Disponible dès juin 2026 !! », … availability = next((p for p in paras if re.match(r"disponible\b", p, re.I)), "") m = _PRIX_RE.search(text) price_label = m.group(1).strip() if m else "" m = _ADDR_RE.search(text) address = m.group(1).strip() if m else "" m = _SECTEUR_RE.search(text) sector = m.group(1) if m else "" # ville réelle : le titre se termine par « …, Drummondville » ; # tout le parc Le Grand y est (repli documenté dans le rapport) city = title.rsplit(",", 1)[1].strip() if "," in title else "Drummondville" unit_type = normalize_unit_type(title) if not re.fullmatch(r"\d½\+?|Studio|Loft|Maison", unit_type or ""): unit_type = "" images = [thumbs[ext_id]] if ext_id in thumbs else [] return Listing( source=self.source_id, external_id=ext_id, url=url, title=title, address=address, sector=sector, city=city, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, description=text[:2000], images=images, )