SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.9 KB · 260 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/mercini.py : connecteur Groupe Mercini (Gestion Faleschini Mercier)5#   (groupemercini.com — Limoilou, Duberger, Sainte-Foy, Beauport, Charlesbourg,6#   Saint-Sauveur, Sillery… + Lévis). WordPress + thème WpResidence, tout est7#   rendu serveur. Liste /appartements/ paginée (12 cartes/page) : titre complet8#   (alt de l'image), catégorie (« 4½ », « Studio ou 2½ », « Chambre »…), statut9#   (« Disponible pour visite », « Bientôt disponible »), prix, chambres/sdb.10#   Fiche détail (via cache BD) : adresse complète avec code postal11#   (.property_categs), secteur (taxonomie property_city), description riche12#   (n° de référence REF…, inclusions, animaux…), caractéristiques, galerie13#   complète (#owl-demo). Les lat/lng du thème sont erronées (latitude négative14#   identique partout) : ignorées, le géocodeur Lou-Ka prend le relais.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re20import unicodedata2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price25from .base import BaseConnector2627BASE = "https://groupemercini.com"28LIST_URL = f"{BASE}/appartements/"2930# suffixe de redimensionnement WordPress (« -525x328.jpg » -> pleine taille)31_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)32_BG_URL_RE = re.compile(r"background-image\s*:\s*url\(([^)]+)\)")33_REF_RE = re.compile(r"\bREF\w+")343536def _plain(txt: str) -> str:37    """Normalise l'Unicode décoratif du site (𝐏𝐫𝐢𝐱 -> Prix, 𝘙𝘦́𝘧 -> Réf)."""38    return unicodedata.normalize("NFKC", txt or "")394041def _clean_img(url: str) -> str:42    return _SIZE_SUFFIX.sub("", url.strip())434445class MerciniConnector(BaseConnector):46    source_id = "mercini"47    request_delay = 0.648    max_pages = 10       # garde-fou de pagination (3 pages actuellement)49    max_details = 60     # garde-fou fiches détail (vraies requêtes par sync)5051    def fetch(self) -> list[Listing]:52        listings: dict[str, Listing] = {}53        for page in range(1, self.max_pages + 1):54            url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/"55            try:56                html = self.get(url).text57            except Exception:58                break59            soup = BeautifulSoup(html, "html.parser")60            cards = soup.select("div.property_listing")61            if not cards:62                break63            for card in cards:64                try:65                    self._parse_card(card, listings)66                except Exception:67                    continue6869        # fiches détail (cache BD) : adresse complète, secteur, description,70        # caractéristiques, galerie — max self.max_details vraies requêtes71        self._fetched = 072        for lst in listings.values():73            key = hashlib.sha1(74                f"{lst.title}|{lst.price_label}|{lst.availability}"75                .encode("utf-8")).hexdigest()76            try:77                payload = self.detail(lst.external_id, key,78                                      lambda u=lst.url: self._fetch_detail(u))79            except Exception:80                continue81            self._apply_detail(lst, payload)82        return list(listings.values())8384    # -- carte liste (WpResidence) ----------------------------------------------85    def _parse_card(self, card, listings: dict[str, Listing]) -> None:86        link = card.select_one('h4 a[href*="/estate_property/"]')87        if not link:88            return89        url = link["href"]90        m = re.search(r"/estate_property/([^/]+)/?", url)91        if not m:92            return93        ext_id = m.group(1)                  # slug WordPress stable94        if ext_id in listings:95            return9697        # titre complet : alt de l'image (le h4 est tronqué par le thème)98        img = card.select_one("img[alt]")99        title = _plain(img["alt"].strip()) if img and img.get("alt") else ""100        if not title:101            title = link.get_text(strip=True)102103        # statut (« Disponible pour visite », « Bientôt disponible », « Loué »)104        ribbon = card.select_one(".ribbon-inside")105        status = ribbon.get_text(strip=True) if ribbon else ""106        if re.search(r"lou[ée]", status, re.I):107            return                            # déjà loué108109        # disponibilité : « Disponible : … » ou « Livraison … » du titre ;110        # sinon le statut, seulement s'il parle de disponibilité (pas « Nouveau »)111        availability = ""112        m_av = re.search(r"(?:Disponible\s*:|Livraison\s*:?)\s*([^|]+)", title, re.I)113        if m_av:114            availability = m_av.group(1).strip()115        elif re.search(r"disponible", status, re.I):116            availability = status117118        # secteur affiché dans le titre (« … | Secteur : Duberger | … »)119        sector = ""120        m_sec = re.search(r"Secteur\s*:\s*([^|]+)", title, re.I)121        if m_sec:122            sector = m_sec.group(1).strip()123124        # type d'unité : « 4-1/2 » du titre, sinon la catégorie de la carte125        tag = card.select_one(".action_tag_wrapper")126        category = tag.get_text(strip=True) if tag else ""127        unit_type = normalize_unit_type(title.replace("-1/2", " 1/2"))128        if not re.fullmatch(r"\d½|\+|Studio|Loft|Chambre|Maison|Condo",129                            unit_type or ""):130            unit_type = normalize_unit_type(category)131132        price_el = card.select_one('[class*="propery_price"]')133        price_label = price_el.get_text(" ", strip=True) if price_el else ""134135        # chambres / salles de bain structurées sur la carte136        amenities: list[str] = []137        rooms = card.select_one('[class^="inforoom"]')138        if rooms:139            amenities.append(rooms.get_text(" ", strip=True))       # « 2 Chambres »140        baths = card.select_one('[class^="infobath"]')141        if baths:142            txt = baths.get_text(" ", strip=True)                   # « 1.5 Baths »143            m_b = re.match(r"([\d.]+)", txt)144            if m_b:145                amenities.append(f"{m_b.group(1)} salle(s) de bain")146147        images = []148        for im in card.select(".carousel-item img[src]"):149            u = _clean_img(im["src"])150            if u.startswith("http") and u not in images:151                images.append(u)152153        details: dict = {}154        if status:155            details["status"] = status156        if category:157            details["category"] = category158159        listings[ext_id] = Listing(160            source=self.source_id,161            external_id=ext_id,162            url=url,163            title=title,164            sector=sector,165            city="Québec",                    # affiné par la fiche détail166            unit_type=unit_type,167            price=parse_price(price_label),168            price_label=price_label,169            availability=availability,170            amenities=amenities,171            details=details,172            images=images,173        )174175    # -- fiche détail (WpResidence) ----------------------------------------------176    def _fetch_detail(self, url: str) -> dict:177        """Adresse complète + taxonomies, description, caractéristiques,178        chambres/sdb (overview), galerie complète (#owl-demo)."""179        if self._fetched >= self.max_details:180            raise RuntimeError("budget de fiches détail atteint")181        self._fetched += 1182        html = self.get(url).text183        soup = BeautifulSoup(html, "html.parser")184        out: dict = {}185186        # « 3135 Rue Dubé, Québec, QC G1M 2N2, Duberger, Québec, Québec » :187        # le texte hors liens = adresse civique ; les liens = taxonomies188        # property_city (secteur) et property_area (ville)189        categs = soup.select_one(".property_categs")190        if categs:191            labels: list[str] = []192            for a in categs.select("a"):193                t = a.get_text(strip=True)194                if t and t not in labels:195                    labels.append(t)196                a.extract()                   # ne garder que l'adresse en texte197            address = re.sub(r"\s+", " ", categs.get_text(" ", strip=True))198            out["address"] = address.strip(" ,")199            out["taxonomies"] = labels200201        # description riche (bullets ▪️ : inclusions, animaux, référence…)202        desc_el = soup.select_one(".wpestate_property_description")203        if desc_el:204            txt = _plain(desc_el.get_text("\n", strip=True))205            txt = re.sub(r"^Description\n", "", txt)206            txt = re.sub(r"[ \t]+", " ", txt)207            out["description"] = txt.strip()[:1500]208            m_ref = _REF_RE.search(out["description"])209            if m_ref:210                out["reference"] = m_ref.group(0)211212        # overview : « 2 Chambres », « 1.5 Salles de bain »213        out["overview"] = [214            re.sub(r"\s+", " ", el.get_text(" ", strip=True))215            for el in soup.select(".overview_element")216            if re.search(r"chambre|salle", el.get_text(), re.I)]217218        # caractéristiques (« Entrée indépendante », « Stationnement extérieur »)219        feats: list[str] = []220        for el in soup.select(".listing_detail"):221            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))222            if t and not t.lower().startswith("other features") and t not in feats:223                feats.append(t)224        out["features"] = feats[:20]225226        # galerie complète : carrousel #owl-demo (background-image 1170x660)227        images: list[str] = []228        owl = soup.select_one("#owl-demo")229        for div in owl.select("div.item[style]") if owl else []:230            m = _BG_URL_RE.search(div["style"])231            if m:232                u = _clean_img(m.group(1).strip("'\" "))233                if u.startswith("http") and u not in images:234                    images.append(u)235        out["images"] = images[:30]236        return out237238    def _apply_detail(self, lst: Listing, d: dict) -> None:239        """Reporte le payload (frais ou en cache) sur l'annonce."""240        if not d:241            return242        if d.get("address"):243            lst.address = d["address"]244        # taxonomies : secteur (Duberger, Limoilou…) + ville (Québec/Lévis)245        labels = d.get("taxonomies") or []246        if any("lévis" in t.lower() or "levis" in t.lower() for t in labels):247            lst.city = "Lévis"248        sectors = [t for t in labels if t not in ("Québec", "Quebec", "Lévis", "Levis")]249        if sectors and not lst.sector:250            lst.sector = sectors[0]251        if d.get("description"):252            lst.description = d["description"]253        if d.get("reference"):254            lst.details["reference"] = d["reference"]255        extra = (d.get("overview") or []) + (d.get("features") or [])256        if extra:257            lst.amenities = list(dict.fromkeys(lst.amenities + extra))258        if d.get("images"):259            lst.images = d["images"]260