SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.4 KB · 202 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/brochu.py : connecteur Groupe Immobilier Brochu5#   (groupeimmobilierbrochu.com — Lévis : St-Romuald, Charny, St-Nicolas,6#    centre-ville + Québec : Les Saules). Une annonce par projet/immeuble7#   (pas d'unités individuelles listées sur le site).8# -----------------------------------------------------------------------------9from __future__ import annotations1011import hashlib12import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, infer_city, normalize_unit_type, parse_price17from .base import BaseConnector1819BASE = "https://groupeimmobilierbrochu.com"20LIST_URL = f"{BASE}/projets/"2122# Hors agglomération Québec/Lévis23EXCLUDED_SECTORS = {"saint-lambert-de-lauzon", "st-lambert-de-lauzon"}2425ADDR_RE = re.compile(26    r"\d{1,5},?\s+(?:rue|avenue|av\.|boulevard|boul\.?|chemin|route|rang|"27    r"place|mont[ée]e)\s+[^,<>]{2,50},?\s*(?:L[ée]vis|Qu[ée]bec)"28    r"(?:\s*\(Qu[ée]bec\))?(?:\s*,?\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d)?", re.I)29IMG_RE = re.compile(r"https://groupeimmobilierbrochu\.com/wp-content/uploads/"30                    r'[^"\s\\)]+\.(?:jpe?g|png|webp|avif)', re.I)31PHONE_RE = re.compile(r"\b(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})\b")323334def _decode_cfemail(hexstr: str) -> str:35    """Décode un courriel protégé Cloudflare (attribut data-cfemail)."""36    try:37        raw = bytes.fromhex(hexstr)38        key = raw[0]39        return bytes(b ^ key for b in raw[1:]).decode("utf-8")40    except Exception:41        return ""424344class BrochuConnector(BaseConnector):45    source_id = "brochu"46    request_delay = 0.647    max_projects = 20        # garde-fou de crawl4849    def fetch(self) -> list[Listing]:50        listings: list[Listing] = []51        try:52            html = self.get(LIST_URL).text53        except Exception:54            return listings55        soup = BeautifulSoup(html, "html.parser")5657        seen: set[str] = set()58        for card in soup.select("div.project"):59            if len(seen) >= self.max_projects:60                break61            try:62                a = card.select_one('a[href*="/projets/"]')63                if not a:64                    continue65                url = a.get("href", "").split("?")[0]66                slug = url.rstrip("/").split("/")[-1]67                if not slug or slug in seen:68                    continue69                seen.add(slug)7071                label_el = card.select_one(".uk-label")72                label = label_el.get_text(" ", strip=True) if label_el else ""73                meta_el = card.select_one(".el-meta")74                sector = meta_el.get_text(" ", strip=True) if meta_el else ""75                title_el = card.select_one(".el-title")76                name = title_el.get_text(" ", strip=True) if title_el else slug77                bold_el = card.select_one(".uk-text-bold")78                bold = (re.sub(r"\s+", " ", bold_el.get_text(" ", strip=True))79                        if bold_el else "")80                ps = [p.get_text(" ", strip=True) for p in card.select("p")81                      if p.get_text(strip=True)]8283                # Projets complets ou hors Québec/Lévis : exclure84                full_text = f"{label} {' '.join(ps)}"85                if re.search(r"\bComplet\b", full_text, re.I) and \86                        not re.search(r"disponible|libre", full_text, re.I):87                    continue88                key = sector.lower().replace(" ", "-").replace("--", "-")89                if key in EXCLUDED_SECTORS or "lauzon" in key:90                    continue9192                availability = label or (ps[0] if ps else "")93                price_label = bold if "$" in bold else ""94                unit_type = normalize_unit_type(bold)95                # ne garder qu'un vrai type d'unité (ex. "4½"), pas le texte brut96                if not re.match(r"^\d½$|^Studio$|^Loft$|^Maison$", unit_type):97                    unit_type = ""98                city = infer_city(sector,99                                  default="Québec" if "saules" in key else "Québec")100101                # Page projet : adresse, dispo, description, commodités,102                # contact, photos — via le cache BD des pages détail103                # (revisitée seulement si la carte liste a changé)104                card_key = hashlib.sha1(105                    f"{label}|{sector}|{name}|{bold}|{'|'.join(ps)}"106                    .encode("utf-8")).hexdigest()107                d = self.detail(slug, card_key,108                                lambda url=url: self._fetch_detail(url))109110                if d.get("availability"):111                    availability = d["availability"]112113                details: dict = {}114                contact = {k: d[k] for k in ("phone", "email") if d.get(k)}115                if contact:116                    details["contact"] = contact117118                listings.append(Listing(119                    source=self.source_id,120                    external_id=slug,121                    url=url,122                    title=name,123                    address=d.get("address", ""),124                    sector=sector,125                    city=city,126                    unit_type=unit_type,127                    price=parse_price(price_label),128                    price_label=price_label,129                    availability=availability,130                    description=d.get("description") or bold,131                    amenities=d.get("amenities") or [],132                    details=details,133                    images=d.get("images") or [],134                ))135            except Exception:136                continue137138        return listings139140    def _fetch_detail(self, url: str) -> dict:141        """Extrait les champs riches d'une page projet (thème UIkit).142143        Adresse (lien Google Maps), disponibilité (encadré Statut),144        description + commodités (colonne principale), contact (téléphone145        affiché + courriel protégé Cloudflare décodé), photos.146        """147        out: dict = {}148        try:149            detail = self.get(url).text150        except Exception:151            return out152        dsoup = BeautifulSoup(detail, "html.parser")153154        # adresse : lien Google Maps du projet (hors pied de page, qui porte155        # l'adresse du bureau du Groupe Brochu), sinon regex globale156        for maps in dsoup.select('a[href*="maps"]'):157            if maps.find_parent("footer"):158                continue159            addr = re.sub(r"\s+", " ", maps.get_text(" ", strip=True)).strip()160            if re.search(r"\d", addr):161                out["address"] = addr162                break163        if not out.get("address"):164            dtext = re.sub(r"\s+", " ", dsoup.get_text(" ", strip=True))165            m = ADDR_RE.search(dtext)166            if m:167                out["address"] = m.group(0).strip().rstrip(",")168169        # disponibilité : encadré « Statut » (uk-alert-primary)170        alert = dsoup.select_one("div.uk-alert-primary p")171        if alert:172            out["availability"] = alert.get_text(" ", strip=True)173174        # colonne principale : description (h3 + p) et commodités (li)175        main = dsoup.select_one('div[class*="uk-width-3-5"]')176        if main:177            paras = [p.get_text(" ", strip=True) for p in main.find_all("p")178                     if p.get_text(strip=True)]179            desc = " ".join(paras)180            out["description"] = re.sub(r"\s+", " ", desc).strip()[:900]181            out["amenities"] = [li.get_text(" ", strip=True)182                                for li in main.select("li")183                                if li.get_text(strip=True)][:25]184185        # contact : téléphone affiché dans l'encadré Statut + courriel186        # protégé Cloudflare (data-cfemail, décodable)187        panel = dsoup.select_one("div.uk-panel.uk-background-muted")188        m = PHONE_RE.search(panel.get_text(" ", strip=True) if panel else "")189        if m:190            out["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"191        cf = (panel.select_one("[data-cfemail]") if panel else None) or \192            dsoup.select_one("[data-cfemail]")193        if cf:194            email = _decode_cfemail(cf.get("data-cfemail", ""))195            if email:196                out["email"] = email197198        out["images"] = [u for u in dict.fromkeys(IMG_RE.findall(detail))199                         if not re.search(r"logo|icon|favicon|cropped-|"200                                          r"-\d{2,3}x\d{2,3}\.", u, re.I)][:25]201        return out202