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/fournelle.py : connecteur Appartements Fournelle (Groupe Fournelle)5# (groupefournelle.com/appartements-fournelle/ — Bécancour, Centre-du-Québec).6# Page-brochure WordPress : des blocs .item par projet (h2 + paragraphes +7# galerie swiper). Seuls les blocs affichant des lignes de prix par unité8# (« 2 × 5½ au sous-sol – 1300 $ | 2 × 5½ au RDC – 1525 $ … ») produisent des9# annonces : une annonce par ligne typologie/étage, prix et disponibilité10# (« Immeuble neuf disponible à partir du 1er Mai 2026 ») fidèles au texte.11# external_id = slug du projet + typologie + étage. 1 requête par sync.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re16import unicodedata1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type, parse_price21from .base import BaseConnector2223BASE = "https://www.groupefournelle.com"24LIST_URL = f"{BASE}/appartements-fournelle/"2526# « 2 × 5½ au sous-sol – 1300 $ » (séparateur | ; tirets – ou - ; l'étage peut27# contenir un trait d'union — « sous-sol » — d'où le prix ancré sur un chiffre)28_LINE_RE = re.compile(29 r"(\d+)\s*[×x]\s*(\d\s*(?:½|1/2))\s*(?:aux?|en)?\s*([^–|]*?)\s*[–\-]\s*(\d[\d\s]*\$)")30_AVAIL_RE = re.compile(r"disponible\s+à\s+partir\s+d[ue][^.|]*", re.I)313233def _strip_accents(s: str) -> str:34 return "".join(c for c in unicodedata.normalize("NFD", s)35 if unicodedata.category(c) != "Mn")363738def _slug(s: str) -> str:39 s = _strip_accents(s.lower()).replace("½", "12")40 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")414243class FournelleConnector(BaseConnector):44 source_id = "fournelle"45 request_delay = 0.74647 def fetch(self) -> list[Listing]:48 html = self.get(LIST_URL).text49 soup = BeautifulSoup(html, "html.parser")50 listings: dict[str, Listing] = {}51 for item in soup.select("div.item"):52 content = item.select_one("div.content")53 if content is None:54 continue55 try:56 self._parse_block(item, content, listings)57 except Exception:58 continue59 return list(listings.values())6061 def _parse_block(self, item, content, listings: dict[str, Listing]) -> None:62 h2 = content.find("h2")63 block_title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if h2 else ""64 link = content.select_one("a[href]")65 project = _slug((link["href"] if link else "").strip("/")) or _slug(block_title)6667 text = re.sub(r"\s+", " ", content.get_text(" ", strip=True))68 lines = _LINE_RE.findall(text)69 if not lines:70 return # bloc informatif sans prix : pas d'annonce7172 m_av = _AVAIL_RE.search(text)73 availability = re.sub(r"\s+", " ", m_av.group(0)).strip() if m_av else ""7475 # caractéristiques (liste <ul>) + phrases de conditions (sans fumée…)76 feats = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))77 for li in content.find_all("li")]78 conditions = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))79 for p in content.find_all("p")80 if re.search(r"sans fumée|sans animaux|enquête de crédit|"81 r"stationnement inclus|chauffé", p.get_text(), re.I)]82 description = "\n".join([block_title] + conditions)[:900]8384 # galerie swiper du bloc (photos + plans, pleine taille)85 images: list[str] = []86 for a in item.select(".swiper-slide a[href]"):87 u = a["href"]88 if u.startswith("http") and re.search(r"\.(jpe?g|png|webp)$", u, re.I) \89 and u not in images:90 images.append(u)9192 for qty, utype, floor, price in lines:93 floor = floor.strip(" .,")94 ext = _slug(f"{project}-{utype}-{floor}")95 if ext in listings:96 continue97 label = f"{utype} {('au ' + floor) if floor else ''}".strip()98 listings[ext] = Listing(99 source=self.source_id,100 external_id=ext,101 url=LIST_URL,102 title=f"{label} — {block_title}",103 city="Bécancour", # page « Appartements à louer à Bécancour »104 unit_type=normalize_unit_type(utype),105 price=parse_price(price),106 price_label=price.strip(),107 availability=availability,108 description=description,109 amenities=feats[:20],110 details={"project": project, "floor": floor,111 "units_on_line": qty},112 images=images[:15],113 )114