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/cite_immobilier.py : connecteur Cité Immobilier (citeimmobilier.com)5# 224 logements à Victoriaville. Site GoDaddy Website Builder 8 (même famille6# que capital_rdr) mais SANS annonces par unité : la page /immeubles liste les7# immeubles (cartes data-ux="ContentBasic", titres data-aid ABOUT_HEADLINE_*)8# avec adresse civique, description et un bouton d'état — « APPARTEMENT À9# LOUER » (vacance) ou « - COMPLET - ». On publie une annonce par immeuble10# résidentiel affichant une vacance ; prix/type ne sont pas publiés.11# robots.txt : « User-agent: * » sans Disallow (tout permis).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, strip_accents20from .base import BaseConnector2122BASE = "https://citeimmobilier.com"23LIST_URL = f"{BASE}/immeubles"242526def _slug(text: str) -> str:27 """« 540 rue Notre-Dame Est » -> « 540-rue-notre-dame-est » (id stable)."""28 s = strip_accents(text.lower())29 return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-")303132class CiteImmobilierConnector(BaseConnector):33 source_id = "cite_immobilier"34 request_delay = 0.73536 def fetch(self) -> list[Listing]:37 html = self.get(LIST_URL).text38 soup = BeautifulSoup(html, "html.parser")3940 listings: list[Listing] = []41 seen: set[str] = set()42 for section in soup.select('section[data-ux="Section"]'):43 sec_title = section.select_one('[data-aid="ABOUT_SECTION_TITLE_RENDERED"]')44 sec_name = sec_title.get_text(" ", strip=True) if sec_title else ""45 # seules les sections résidentielles nous intéressent46 # (« IMMEUBLES RÉSIDENTIELS », « IMMEUBLES COMMERCIAUX ET RÉSIDENTIELS »)47 if "RÉSIDENTIEL" not in sec_name.upper():48 continue4950 for card in section.select('[data-ux="ContentBasic"]'):51 head = card.select_one('[data-aid^="ABOUT_HEADLINE"]')52 if not head:53 continue54 address = head.get_text(" ", strip=True)55 ext_id = _slug(address)56 if not ext_id or ext_id in seen:57 continue5859 card_text = card.get_text(" ", strip=True)60 ctas = [a.get_text(" ", strip=True)61 for a in card.find_all("a") if a.get_text(strip=True)]62 # vacance affichée = bouton « APPARTEMENT À LOUER » ;63 # « - COMPLET - » ou absence du bouton → immeuble plein, ignoré64 avail = next((c for c in ctas65 if re.search(r"appartement.*louer", c, re.I)), "")66 if not avail or re.search(r"-\s*COMPLET\s*-", card_text):67 continue68 seen.add(ext_id)6970 # description : paragraphes de la carte (texte source),71 # sans le libellé du bouton72 lines = []73 for p in card.find_all(["p", "h5"]):74 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))75 if t and t not in lines and t.upper() != avail.upper():76 lines.append(t)7778 images: list[str] = []79 img = card.select_one("img[src], img[srcset]")80 if img:81 src = img.get("src") or (img.get("srcset") or "").split(" ")[0]82 if src.startswith("//"):83 src = "https:" + src84 if src.startswith("http"):85 images.append(src)8687 listings.append(Listing(88 source=self.source_id,89 external_id=ext_id, # slug de l'adresse de l'immeuble90 url=f"{LIST_URL}#{ext_id}", # pas de fiche individuelle91 title=f"Appartement à louer — {address}",92 address=address,93 sector="",94 # tout le parc Cité Immobilier (224 logements + Le Quartz)95 # est à Victoriaville (centre-ville, cf. page Immeubles)96 city="Victoriaville",97 unit_type="", # jamais publié par la source98 price_label="", # jamais publié par la source99 availability=avail, # texte du bouton (source)100 description="\n".join(lines)[:900],101 images=images,102 ))103 return listings104