SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
4.9 KB · 114 lines python
Raw Blame History
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