SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
8.3 KB · 199 lines python
Raw Blame History
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