# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/fournelle.py : connecteur Appartements Fournelle (Groupe Fournelle) # (groupefournelle.com/appartements-fournelle/ — Bécancour, Centre-du-Québec). # Page-brochure WordPress : des blocs .item par projet (h2 + paragraphes + # galerie swiper). Seuls les blocs affichant des lignes de prix par unité # (« 2 × 5½ au sous-sol – 1300 $ | 2 × 5½ au RDC – 1525 $ … ») produisent des # annonces : une annonce par ligne typologie/étage, prix et disponibilité # (« Immeuble neuf disponible à partir du 1er Mai 2026 ») fidèles au texte. # external_id = slug du projet + typologie + étage. 1 requête par sync. # ----------------------------------------------------------------------------- from __future__ import annotations import re import unicodedata from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.groupefournelle.com" LIST_URL = f"{BASE}/appartements-fournelle/" # « 2 × 5½ au sous-sol – 1300 $ » (séparateur | ; tirets – ou - ; l'étage peut # contenir un trait d'union — « sous-sol » — d'où le prix ancré sur un chiffre) _LINE_RE = re.compile( r"(\d+)\s*[×x]\s*(\d\s*(?:½|1/2))\s*(?:aux?|en)?\s*([^–|]*?)\s*[–\-]\s*(\d[\d\s]*\$)") _AVAIL_RE = re.compile(r"disponible\s+à\s+partir\s+d[ue][^.|]*", re.I) def _strip_accents(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") def _slug(s: str) -> str: s = _strip_accents(s.lower()).replace("½", "12") return re.sub(r"[^a-z0-9]+", "-", s).strip("-") class FournelleConnector(BaseConnector): source_id = "fournelle" request_delay = 0.7 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for item in soup.select("div.item"): content = item.select_one("div.content") if content is None: continue try: self._parse_block(item, content, listings) except Exception: continue return list(listings.values()) def _parse_block(self, item, content, listings: dict[str, Listing]) -> None: h2 = content.find("h2") block_title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if h2 else "" link = content.select_one("a[href]") project = _slug((link["href"] if link else "").strip("/")) or _slug(block_title) text = re.sub(r"\s+", " ", content.get_text(" ", strip=True)) lines = _LINE_RE.findall(text) if not lines: return # bloc informatif sans prix : pas d'annonce m_av = _AVAIL_RE.search(text) availability = re.sub(r"\s+", " ", m_av.group(0)).strip() if m_av else "" # caractéristiques (liste