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/immeubles_aeb.py : connecteur Immeubles AEB (immeublesaeb.com —5# Saguenay : Chicoutimi, Jonquière, Arvida, La Baie, Laterrière + St-Honoré ;6# filiale du Groupe Boudreault). Site custom statique (Bootstrap), tout rendu7# serveur. Liste /logements : cartes `.housing-block` (nom, type « Appartement8# 4 1/2 »/« Loft »/« Chambre », prix « 1380.00$ / mois », secteur, texte de9# disponibilité, photo de couverture en background). Fiche /logements/<slug>10# (via cache BD) : adresse civique (« Coût et localisation »), blocs11# Description structurés (étage, durée du bail, chambres, salles de bain),12# caractéristiques/commodités, « À proximité de » et galerie complète.13# robots.txt géré par Cloudflare : `User-agent: *` → Allow: / avec14# Content-Signal search=yes (l'usage index/agrégateur est permis ; seuls des15# bots d'entraînement IA nommés — ClaudeBot, GPTBot… — sont bloqués, LouKaBot16# n'est pas visé). La page /logement-etudiant est une vitrine sans annonces.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type, parse_price, strip_accents26from .base import BaseConnector2728BASE = "https://immeublesaeb.com"29LIST_URL = f"{BASE}/logements"3031_BG_URL_RE = re.compile(r"background\s*:\s*url\(([^)]+)\)")3233# secteurs de la ville de Saguenay (le reste = municipalités distinctes)34_SAGUENAY_SECTORS = {"chicoutimi", "jonquiere", "arvida", "la baie", "laterriere"}353637class ImmeublesAEBConnector(BaseConnector):38 source_id = "immeubles_aeb"39 request_delay = 0.640 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync)4142 def fetch(self) -> list[Listing]:43 html = self.get(LIST_URL).text44 soup = BeautifulSoup(html, "html.parser")4546 self._fetched = 047 listings: dict[str, Listing] = {}48 for block in soup.select(".housing-block"):49 try:50 self._parse_card(block, listings)51 except Exception:52 continue53 return list(listings.values())5455 # -- carte (.housing-block) ------------------------------------------------------56 def _parse_card(self, block, listings: dict[str, Listing]) -> None:57 link = block.select_one('a[href*="/logements/"]')58 if not link:59 return60 url = link["href"]61 m = re.search(r"/logements/([^/?#]+)", url)62 if not m:63 return64 ext_id = m.group(1)65 if not ext_id or ext_id in listings:66 return6768 name_el = block.select_one("p.housingName")69 name = name_el.get_text(strip=True) if name_el else ""70 type_el = block.select_one(".content h4")71 unit_label = re.sub(r"\s+", " ",72 type_el.get_text(" ", strip=True)) if type_el else ""7374 # <p> du bloc contenu : prix (« 980.00$ / mois »), secteur (épinglette),75 # disponibilité (icône calendrier)76 price_label = sector = availability = ""77 for p in block.select(".content p"):78 txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))79 if not txt or p.get("class") == ["housingName"]:80 continue81 if p.select_one("i.fa-map-marker-alt"):82 sector = txt83 elif p.select_one("i.icon-calendar"):84 availability = txt85 elif "$" in txt and not price_label:86 price_label = txt8788 # photo de couverture (background-image de la carte)89 images: list[str] = []90 for el in block.select("[style]"):91 m_bg = _BG_URL_RE.search(el.get("style", ""))92 if m_bg:93 u = m_bg.group(1).strip("'\" ")94 if u.startswith("http") and u not in images:95 images.append(u)9697 city = "Saguenay"98 sec_key = strip_accents(re.sub(r"[-_]", " ", sector.lower())).strip()99 if sec_key and sec_key not in _SAGUENAY_SECTORS:100 city = sector # St-Honoré et autres municipalités distinctes101102 lst = Listing(103 source=self.source_id,104 external_id=ext_id,105 url=url,106 title=name or unit_label,107 sector=sector,108 city=city,109 unit_type=normalize_unit_type(unit_label.replace("Appartement", "").strip()),110 price=parse_price(price_label),111 price_label=price_label,112 availability=availability,113 images=images[:10],114 )115116 key = hashlib.sha1(117 f"{name}|{unit_label}|{price_label}|{availability}|{sector}"118 .encode("utf-8")).hexdigest()119 try:120 payload = self.detail(ext_id, key,121 lambda u=url: self._fetch_detail(u))122 self._apply_detail(lst, payload)123 except Exception:124 pass125 listings[ext_id] = lst126127 # -- fiche détail (/logements/<slug>) --------------------------------------------128 def _fetch_detail(self, url: str) -> dict:129 """Adresse, blocs Description, commodités, proximité et galerie."""130 if self._fetched >= self.max_details:131 raise RuntimeError("budget de fiches détail atteint")132 self._fetched += 1133 html = self.get(url).text134 soup = BeautifulSoup(html, "html.parser")135 out: dict = {}136137 # « Coût et localisation » : adresse civique sous l'épinglette138 addr_el = soup.select_one(".price-location .address p")139 if addr_el:140 out["address"] = re.sub(r"\s+", " ",141 addr_el.get_text(" ", strip=True)).strip(" ,")142143 # blocs Description structurés (étage, bail, chambres, salles de bain)144 traits: list[str] = []145 for box in soup.select(".housing-description .description-box"):146 label_el = box.select_one("p.label")147 val_el = box.select_one("p.content")148 if not label_el or not val_el:149 continue150 label = label_el.get_text(strip=True)151 val = val_el.get_text(strip=True)152 if not val or re.match(r"type|disponibilit", label, re.I):153 continue # type et dispo déjà portés par la carte154 if re.match(r"chambres", label, re.I):155 traits.append(f"{val} chambre(s)")156 elif re.match(r"salles? de bain", label, re.I):157 traits.append(f"{val} salle(s) de bain")158 else:159 traits.append(f"{label} : {val}")160161 # caractéristiques et commodités (chauffé/éclairé, meublé, internet…)162 for li in soup.select(".features .features-content li, "163 ".features .features-content span"):164 txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True))165 if txt:166 traits.append(txt)167 out["amenities"] = traits[:20]168169 # « À proximité de » : texte libre -> description170 near_el = soup.select_one(".close-by .close-by-content")171 if near_el:172 txt = re.sub(r"\s+", " ", near_el.get_text(" ", strip=True))173 if txt:174 out["description"] = txt[:1200]175176 # galerie complète (diaporama en background-image)177 images: list[str] = []178 for el in soup.select(".slider-container[style], .housing-slider [style]"):179 m_bg = _BG_URL_RE.search(el.get("style", ""))180 if m_bg:181 u = m_bg.group(1).strip("'\" ")182 if u.startswith("http") and u not in images:183 images.append(u)184 out["images"] = images[:25]185 return out186187 def _apply_detail(self, lst: Listing, d: dict) -> None:188 """Reporte le payload (frais ou en cache) sur l'annonce."""189 if not d:190 return191 if d.get("address"):192 lst.address = d["address"]193 if d.get("description"):194 lst.description = d["description"]195 if d.get("amenities"):196 lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"]))197 if d.get("images") and len(d["images"]) > len(lst.images):198 lst.images = d["images"]199