# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immeubles_aeb.py : connecteur Immeubles AEB (immeublesaeb.com — # Saguenay : Chicoutimi, Jonquière, Arvida, La Baie, Laterrière + St-Honoré ; # filiale du Groupe Boudreault). Site custom statique (Bootstrap), tout rendu # serveur. Liste /logements : cartes `.housing-block` (nom, type « Appartement # 4 1/2 »/« Loft »/« Chambre », prix « 1380.00$ / mois », secteur, texte de # disponibilité, photo de couverture en background). Fiche /logements/ # (via cache BD) : adresse civique (« Coût et localisation »), blocs # Description structurés (étage, durée du bail, chambres, salles de bain), # caractéristiques/commodités, « À proximité de » et galerie complète. # robots.txt géré par Cloudflare : `User-agent: *` → Allow: / avec # Content-Signal search=yes (l'usage index/agrégateur est permis ; seuls des # bots d'entraînement IA nommés — ClaudeBot, GPTBot… — sont bloqués, LouKaBot # n'est pas visé). La page /logement-etudiant est une vitrine sans annonces. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://immeublesaeb.com" LIST_URL = f"{BASE}/logements" _BG_URL_RE = re.compile(r"background\s*:\s*url\(([^)]+)\)") # secteurs de la ville de Saguenay (le reste = municipalités distinctes) _SAGUENAY_SECTORS = {"chicoutimi", "jonquiere", "arvida", "la baie", "laterriere"} class ImmeublesAEBConnector(BaseConnector): source_id = "immeubles_aeb" request_delay = 0.6 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") self._fetched = 0 listings: dict[str, Listing] = {} for block in soup.select(".housing-block"): try: self._parse_card(block, listings) except Exception: continue return list(listings.values()) # -- carte (.housing-block) ------------------------------------------------------ def _parse_card(self, block, listings: dict[str, Listing]) -> None: link = block.select_one('a[href*="/logements/"]') if not link: return url = link["href"] m = re.search(r"/logements/([^/?#]+)", url) if not m: return ext_id = m.group(1) if not ext_id or ext_id in listings: return name_el = block.select_one("p.housingName") name = name_el.get_text(strip=True) if name_el else "" type_el = block.select_one(".content h4") unit_label = re.sub(r"\s+", " ", type_el.get_text(" ", strip=True)) if type_el else "" #

du bloc contenu : prix (« 980.00$ / mois »), secteur (épinglette), # disponibilité (icône calendrier) price_label = sector = availability = "" for p in block.select(".content p"): txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if not txt or p.get("class") == ["housingName"]: continue if p.select_one("i.fa-map-marker-alt"): sector = txt elif p.select_one("i.icon-calendar"): availability = txt elif "$" in txt and not price_label: price_label = txt # photo de couverture (background-image de la carte) images: list[str] = [] for el in block.select("[style]"): m_bg = _BG_URL_RE.search(el.get("style", "")) if m_bg: u = m_bg.group(1).strip("'\" ") if u.startswith("http") and u not in images: images.append(u) city = "Saguenay" sec_key = strip_accents(re.sub(r"[-_]", " ", sector.lower())).strip() if sec_key and sec_key not in _SAGUENAY_SECTORS: city = sector # St-Honoré et autres municipalités distinctes lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=name or unit_label, sector=sector, city=city, unit_type=normalize_unit_type(unit_label.replace("Appartement", "").strip()), price=parse_price(price_label), price_label=price_label, availability=availability, images=images[:10], ) key = hashlib.sha1( f"{name}|{unit_label}|{price_label}|{availability}|{sector}" .encode("utf-8")).hexdigest() try: payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u)) self._apply_detail(lst, payload) except Exception: pass listings[ext_id] = lst # -- fiche détail (/logements/) -------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Adresse, blocs Description, commodités, proximité et galerie.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # « Coût et localisation » : adresse civique sous l'épinglette addr_el = soup.select_one(".price-location .address p") if addr_el: out["address"] = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)).strip(" ,") # blocs Description structurés (étage, bail, chambres, salles de bain) traits: list[str] = [] for box in soup.select(".housing-description .description-box"): label_el = box.select_one("p.label") val_el = box.select_one("p.content") if not label_el or not val_el: continue label = label_el.get_text(strip=True) val = val_el.get_text(strip=True) if not val or re.match(r"type|disponibilit", label, re.I): continue # type et dispo déjà portés par la carte if re.match(r"chambres", label, re.I): traits.append(f"{val} chambre(s)") elif re.match(r"salles? de bain", label, re.I): traits.append(f"{val} salle(s) de bain") else: traits.append(f"{label} : {val}") # caractéristiques et commodités (chauffé/éclairé, meublé, internet…) for li in soup.select(".features .features-content li, " ".features .features-content span"): txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if txt: traits.append(txt) out["amenities"] = traits[:20] # « À proximité de » : texte libre -> description near_el = soup.select_one(".close-by .close-by-content") if near_el: txt = re.sub(r"\s+", " ", near_el.get_text(" ", strip=True)) if txt: out["description"] = txt[:1200] # galerie complète (diaporama en background-image) images: list[str] = [] for el in soup.select(".slider-container[style], .housing-slider [style]"): m_bg = _BG_URL_RE.search(el.get("style", "")) if m_bg: u = m_bg.group(1).strip("'\" ") if u.startswith("http") and u not in images: images.append(u) out["images"] = images[:25] return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return if d.get("address"): lst.address = d["address"] if d.get("description"): lst.description = d["description"] if d.get("amenities"): lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) if d.get("images") and len(d["images"]) > len(lst.images): lst.images = d["images"]