# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/cite_immobilier.py : connecteur Cité Immobilier (citeimmobilier.com) # 224 logements à Victoriaville. Site GoDaddy Website Builder 8 (même famille # que capital_rdr) mais SANS annonces par unité : la page /immeubles liste les # immeubles (cartes data-ux="ContentBasic", titres data-aid ABOUT_HEADLINE_*) # avec adresse civique, description et un bouton d'état — « APPARTEMENT À # LOUER » (vacance) ou « - COMPLET - ». On publie une annonce par immeuble # résidentiel affichant une vacance ; prix/type ne sont pas publiés. # robots.txt : « User-agent: * » sans Disallow (tout permis). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, strip_accents from .base import BaseConnector BASE = "https://citeimmobilier.com" LIST_URL = f"{BASE}/immeubles" def _slug(text: str) -> str: """« 540 rue Notre-Dame Est » -> « 540-rue-notre-dame-est » (id stable).""" s = strip_accents(text.lower()) return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-") class CiteImmobilierConnector(BaseConnector): source_id = "cite_immobilier" request_delay = 0.7 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: list[Listing] = [] seen: set[str] = set() for section in soup.select('section[data-ux="Section"]'): sec_title = section.select_one('[data-aid="ABOUT_SECTION_TITLE_RENDERED"]') sec_name = sec_title.get_text(" ", strip=True) if sec_title else "" # seules les sections résidentielles nous intéressent # (« IMMEUBLES RÉSIDENTIELS », « IMMEUBLES COMMERCIAUX ET RÉSIDENTIELS ») if "RÉSIDENTIEL" not in sec_name.upper(): continue for card in section.select('[data-ux="ContentBasic"]'): head = card.select_one('[data-aid^="ABOUT_HEADLINE"]') if not head: continue address = head.get_text(" ", strip=True) ext_id = _slug(address) if not ext_id or ext_id in seen: continue card_text = card.get_text(" ", strip=True) ctas = [a.get_text(" ", strip=True) for a in card.find_all("a") if a.get_text(strip=True)] # vacance affichée = bouton « APPARTEMENT À LOUER » ; # « - COMPLET - » ou absence du bouton → immeuble plein, ignoré avail = next((c for c in ctas if re.search(r"appartement.*louer", c, re.I)), "") if not avail or re.search(r"-\s*COMPLET\s*-", card_text): continue seen.add(ext_id) # description : paragraphes de la carte (texte source), # sans le libellé du bouton lines = [] for p in card.find_all(["p", "h5"]): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if t and t not in lines and t.upper() != avail.upper(): lines.append(t) images: list[str] = [] img = card.select_one("img[src], img[srcset]") if img: src = img.get("src") or (img.get("srcset") or "").split(" ")[0] if src.startswith("//"): src = "https:" + src if src.startswith("http"): images.append(src) listings.append(Listing( source=self.source_id, external_id=ext_id, # slug de l'adresse de l'immeuble url=f"{LIST_URL}#{ext_id}", # pas de fiche individuelle title=f"Appartement à louer — {address}", address=address, sector="", # tout le parc Cité Immobilier (224 logements + Le Quartz) # est à Victoriaville (centre-ville, cf. page Immeubles) city="Victoriaville", unit_type="", # jamais publié par la source price_label="", # jamais publié par la source availability=avail, # texte du bouton (source) description="\n".join(lines)[:900], images=images, )) return listings