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/stb_immobilier.py : connecteur STB Immobilier (stbimmobilier.com)5# Gestionnaire de Matane (Bas-Saint-Laurent) : ~11 immeubles/projets à Matane6# et Saint-Ulric (rue Fournier, Boisé St-Rédempteur, rue Marquis, maisons de7# ville…). WordPress + Elementor, tout rendu serveur. Liste8# /locations-residentielles/ : cartes `e-loop-item` par IMMEUBLE (granularité9# immeuble — le site ne publie pas d'unités individuelles) : nombre de10# logements/maisons, adresse civique avec ville, nom, « Unités en location »11# (typologies réelles « 4 X 4 ½, 6 X 5 ½ »), « À partir de X $/mois » et12# parfois « Date de disponibilité ». Fiche /location/<slug>/ (via cache BD) :13# type de location, services associés/à proximité, photos.14# robots.txt ouvert, sitemap Yoast.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://stbimmobilier.com"27LIST_URL = f"{BASE}/locations-residentielles/"2829_PRICE_RE = re.compile(r"À partir de[\s ]*([\d\s .,]+\$\s*/\s*mois)", re.I)30_DISPO_RE = re.compile(31 r"Date de disponibilité\s*:\s*(.+?)(?=Unités en location|Type de location|À partir de|$)",32 re.I | re.S)33_UNITES_RE = re.compile(34 r"(?:Unités en location|Type de location)\s*:\s*(.+?)(?=À partir de|$)",35 re.I | re.S)36_TYPE_RE = re.compile(r"(\d\s*½|Studio|Loft|maisons? de ville)", re.I)373839class STBImmobilierConnector(BaseConnector):40 source_id = "stb_immobilier"41 request_delay = 0.642 max_details = 15 # garde-fou fiches détail (vraies requêtes par sync)4344 def fetch(self) -> list[Listing]:45 html = self.get(LIST_URL).text46 soup = BeautifulSoup(html, "html.parser")4748 self._fetched = 049 listings: dict[str, Listing] = {}50 for card in soup.select("div.e-loop-item"):51 try:52 self._parse_card(card, listings)53 except Exception:54 continue55 return list(listings.values())5657 # -- carte immeuble (boucle Elementor) ------------------------------------------58 def _parse_card(self, card, listings: dict[str, Listing]) -> None:59 link = card.select_one('a[href*="/location/"]')60 if not link:61 return62 url = link["href"]63 m = re.search(r"/location/([^/?#]+)", url)64 if not m:65 return66 ext_id = m.group(1)67 if ext_id in listings:68 return6970 # entêtes : [nombre, « logements|maisons », adresse, nom de l'immeuble]71 # (widgets « heading » Elementor — balises variables, pas toujours h1-h6)72 heads = [re.sub(r"\s+", " ", h.get_text(" ", strip=True))73 for h in card.select(".elementor-widget-heading")]74 count = kind = address = name = ""75 for h in heads:76 if re.search(r"(?i)unités en location|type de location"77 r"|date de disponibilité|à partir de", h):78 continue # lignes d'infos, traitées plus bas79 if re.fullmatch(r"\d+", h):80 count = h81 elif re.fullmatch(r"(logements?|maisons?)", h, re.I):82 kind = h.lower()83 elif "," in h:84 address = h85 elif h:86 name = h8788 city = ""89 parts = [p.strip() for p in address.split(",") if p.strip()]90 if parts:91 city = re.sub(r"\s+", " ", parts[-1]).strip()9293 # texte de la carte : disponibilité, typologies, prix « À partir de »94 text = re.sub(r"\s+", " ", card.get_text(" ", strip=True))95 m_d = _DISPO_RE.search(text)96 availability = m_d.group(1).strip(" .|") if m_d else ""97 m_u = _UNITES_RE.search(text)98 unites = m_u.group(1).strip(" .|") if m_u else ""99 m_p = _PRICE_RE.search(text)100 price_label = f"À partir de {m_p.group(1).strip()}" if m_p else ""101102 # type d'unité : seulement si l'immeuble n'offre qu'une typologie103 unit_type = ""104 types = {normalize_unit_type(re.sub(r"(?i)^maisons\b", "maison", t))105 for t in _TYPE_RE.findall(unites)}106 types.discard("")107 if len(types) == 1:108 unit_type = types.pop()109110 amenities: list[str] = []111 if count and kind:112 amenities.append(f"Immeuble de {count} {kind}")113 if unites:114 amenities.append(f"Unités en location : {unites}")115116 # vignette de la carte (ignorer le bandeau décoratif « À venir »)117 images: list[str] = []118 for img in card.select("img[src]"):119 src = img["src"]120 if src.startswith("http") and "Bandeau" not in src:121 images.append(src)122 break123124 lst = Listing(125 source=self.source_id,126 external_id=ext_id,127 url=url,128 title=f"{name} — {address}".strip(" —") or ext_id,129 address=address,130 city=city,131 unit_type=unit_type,132 price=parse_price(price_label),133 price_label=price_label,134 availability=availability,135 amenities=amenities,136 images=images,137 )138139 key = hashlib.sha1(140 f"{name}|{price_label}|{availability}|{unites}"141 .encode("utf-8")).hexdigest()142 try:143 payload = self.detail(ext_id, key,144 lambda u=url: self._fetch_detail(u))145 self._apply_detail(lst, payload)146 except Exception:147 pass148 listings[ext_id] = lst149150 # -- fiche immeuble (/location/<slug>/) ------------------------------------------151 def _fetch_detail(self, url: str) -> dict:152 """Services associés / à proximité et photos de la fiche."""153 if self._fetched >= self.max_details:154 raise RuntimeError("budget de fiches détail atteint")155 self._fetched += 1156 html = self.get(url).text157 soup = BeautifulSoup(html, "html.parser")158 out: dict = {}159160 text = re.sub(r"\s+", " ", soup.get_text(" | ", strip=True))161 m = re.search(r"Services \|? ?associés \|? ?: \|(.+?)(?=Services \|? ?à \|"162 r"|Formulaire|$)", text)163 if m:164 out["services"] = [s.strip() for s in m.group(1).split("|")165 if s.strip()][:12]166 m = re.search(r"Services \|? ?à \|? ?proximité \|? ?: \|(.+?)"167 r"(?=Formulaire|$)", text)168 if m:169 out["nearby"] = [s.strip() for s in m.group(1).split("|")170 if s.strip()][:12]171172 images = []173 for img in soup.select("main img[src], div[data-elementor-type] img[src]"):174 src = img["src"]175 if (src.startswith("http") and "/uploads/" in src176 and "Bandeau" not in src and "logo" not in src.lower()177 and src not in images):178 images.append(src)179 out["images"] = images[:15]180 return out181182 def _apply_detail(self, lst: Listing, d: dict) -> None:183 """Reporte le payload (frais ou en cache) sur l'annonce."""184 if not d:185 return186 if d.get("services"):187 lst.amenities = list(dict.fromkeys(lst.amenities + d["services"]))188 if d.get("nearby"):189 lst.description = (lst.description + "\nÀ proximité : "190 + ", ".join(d["nearby"])).strip()191 if d.get("images") and len(d["images"]) > len(lst.images):192 lst.images = d["images"]193