# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_valco.py : connecteur Gestion Valco (gestionvalco.ca) # WordPress Divi/Elementor, page « Logements à louer » rédigée à la main : # chaque logement = une section Elementor « titre » (« 402 Laviolette # Trois-Rivières 4 1/2 »), suivie d'un bloc texte (inclusions, dispo…) # et de galeries d'images. 1 seule requête par sync ; external_id = # data-id Elementor de la section titre (stable tant que la section vit). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://gestionvalco.ca" LIST_URL = f"{BASE}/logements-a-louer/" # redirige vers /elementor-344/logements-a-louer/ # titre d'annonce : contient un type d'unité (« 4 1/2 », « 3½ », studio…) _TYPE_RE = re.compile(r"\d\s*(?:½|1/2)|studio|loft", re.I) # villes desservies par Gestion Valco (Mauricie / Centre-du-Québec) _CITIES = [ ("trois-rivieres", "Trois-Rivières"), ("trois- rivieres", "Trois-Rivières"), ("cap-de-la-madeleine", "Trois-Rivières"), ("shawinigan", "Shawinigan"), ("nicolet", "Nicolet"), ("louiseville", "Louiseville"), ("st-narcisse", "Saint-Narcisse"), ("saint-narcisse", "Saint-Narcisse"), ] def _norm(txt: str) -> str: """Aplatis les de Google Translate : espaces multiples, « Trois- Rivières ».""" txt = re.sub(r"\s+", " ", txt).strip() return re.sub(r"(\w)-\s+(\w)", r"\1-\2", txt) def _strip_accents(s: str) -> str: import unicodedata return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") class GestionValcoConnector(BaseConnector): source_id = "gestion_valco" request_delay = 0.7 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text # requests suit la redirection soup = BeautifulSoup(html, "html.parser") main = soup.find("main") or soup listings: list[Listing] = [] current: Listing | None = None desc_lines: list[str] = [] def _flush() -> None: nonlocal current, desc_lines if current is not None: current.description = "\n".join(desc_lines)[:900] listings.append(current) current, desc_lines = None, [] for sec in main.select("section.elementor-top-section"): head = sec.select_one(".elementor-widget-heading .elementor-heading-title") title = _norm(head.get_text(" ", strip=True)) if head else "" if title and _TYPE_RE.search(title) and not re.search( r"commercial|stationnement|rangement|local\b", title, re.I): # nouvelle annonce : section titre _flush() low = _strip_accents(title.lower()) city, city_pos = "", -1 for key, name in _CITIES: p = low.find(key) if p >= 0: city, city_pos = name, p break # adresse civique : début du titre jusqu'au nom de ville address = "" if city_pos > 0 and re.match(r"\d", title): address = title[:city_pos].strip(" ,-") # disponibilité : mention « Libre … » du titre (texte source) m = re.search(r"\b(Libre\b.*)$", title, re.I) availability = m.group(1).strip() if m else "" ext_id = sec.get("data-id") or "" if not ext_id: continue current = Listing( source=self.source_id, external_id=str(ext_id), url=f"{BASE}/elementor-344/logements-a-louer/#{ext_id}", title=title, address=address, city=city, unit_type=normalize_unit_type(title), availability=availability, ) # la section titre peut aussi porter le bloc texte : on continue if current is None: continue # même section ou sections suivantes : description (texte), galeries for txt in sec.select(".elementor-widget-text-editor"): for p in txt.find_all(["p", "li"]) or [txt]: t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if t and t not in desc_lines: desc_lines.append(t) if not current.availability: for t in desc_lines: if re.search(r"\b(libre|disponible)\b", t, re.I): current.availability = t break for a in sec.select("a.e-gallery-item[href]"): u = a["href"] if u.startswith("http") and u not in current.images: current.images.append(u) current.images = current.images[:30] _flush() return listings