SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.6 KB · 247 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/multi_logis.py : connecteur Multi-Logis (multi-logis.com — 500+5#   logements : Sept-Îles, Port-Cartier — plus gros parc de la Côte-Nord —6#   et aussi Lévis, toutes les villes conservées). WordPress + Elementor +7#   JetEngine + plugin maison « multilogis-listing », tout rendu serveur.8#   Liste /trouver-mon-appartement/ : une carte par bâtiment (nom, adresse,9#   typologies offertes, « À partir de »). Page /batiment/<slug>/ (via cache10#   BD) : unités disponibles individuelles (numéro, type, description, loyer,11#   superficie, disponibilité, galerie, plan), adresse structurée du champ ACF12#   (ville, code postal, lat/lng) et listes « Inclus » / « Caractéristiques »13#   de l'immeuble. Une annonce Lou-Ka = une unité disponible. Les champs des14#   unités sont identifiés par leur CONTENU (motifs « $/mois », « Studio/n½ »,15#   « Superficie: », « Disponibilité: ») — robustes aux ids Elementor.16#   Immeubles commerciaux (« Le St-Georges ») sans unités : ignorés d'eux-mêmes.17#   robots.txt ouvert, sitemap Yoast.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import hashlib22import re2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://multi-logis.com"30LIST_URL = f"{BASE}/trouver-mon-appartement/"3132# suffixe de redimensionnement WordPress (« -1024x1024.png » -> pleine taille)33_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)3435_TYPE_RE = re.compile(r"^(studio|\d\s*½|\d\s*1/2)$", re.I)36_ACF_RE = re.compile(r"'(address|city|post_code|lat|lng)'\s*=>\s*'?([^',\n]+)'?")373839def _clean_img(url: str) -> str:40    return _SIZE_SUFFIX.sub("", url.strip())414243class MultiLogisConnector(BaseConnector):44    source_id = "multi_logis"45    request_delay = 0.646    max_details = 25     # garde-fou pages bâtiment (vraies requêtes par sync)4748    def fetch(self) -> list[Listing]:49        html = self.get(LIST_URL).text50        soup = BeautifulSoup(html, "html.parser")5152        # cartes bâtiment (le sélecteur de carte duplique la grille : dédupliquer)53        buildings: dict[str, dict] = {}54        for item in soup.select(".jet-listing-grid__item"):55            link = item.select_one('a[href*="/batiment/"]')56            if not link:57                continue58            m = re.search(r"/batiment/([^/?#]+)", link["href"])59            if not m:60                continue61            slug = m.group(1).strip("/")62            fields = [re.sub(r"\s+", " ", f.get_text(" ", strip=True))63                      for f in item.select(".jet-listing-dynamic-field__content")]64            fields = [f for f in fields if f]65            if slug in buildings and len(fields) <= len(buildings[slug]["fields"]):66                continue67            buildings[slug] = {"fields": fields}6869        self._fetched = 070        listings: list[Listing] = []71        for slug, info in buildings.items():72            try:73                listings.extend(self._parse_building(slug, info["fields"]))74            except Exception:75                continue76        return listings7778    # -- un bâtiment ------------------------------------------------------------------79    def _parse_building(self, slug: str, fields: list[str]) -> list[Listing]:80        url = f"{BASE}/batiment/{slug}/"81        name = fields[0] if fields else slug82        card_addr = next((f for f in fields if re.search(r",\s*QC", f, re.I)), "")8384        # page bâtiment (cache BD, invalidée quand la carte liste change)85        key = hashlib.sha1("|".join(fields).encode("utf-8")).hexdigest()8687        def fetch_fn():88            if self._fetched >= self.max_details:89                raise RuntimeError("budget de pages bâtiment atteint")90            self._fetched += 191            return self._fetch_building(url)9293        payload = self.detail(slug, key, fetch_fn)94        if not payload:95            return []9697        address = payload.get("address") or card_addr98        city = payload.get("city") or ""99        if payload.get("post_code") and payload["post_code"] not in address:100            address = f"{address}, {payload['post_code']}"101        lat, lng = payload.get("lat"), payload.get("lng")102        amenities_building = payload.get("amenities", [])103104        out: list[Listing] = []105        for u in payload.get("units", []):106            numero = u.get("numero") or ""107            m = re.search(r"#\s*([\w-]+)", numero)108            unit_no = (m.group(1) if m else "").strip(" ,#").lower()109            if not unit_no:110                # unité affichée sans numéro (« Appartement # ») : identifiant111                # stable dérivé du contenu invariant (type, superficie, plan,112                # début de description) — pas du prix ni de la disponibilité113                sig = (f"{u.get('type', '')}|{u.get('superficie', '')}|"114                       f"{u.get('plan', '')}|{u.get('description', '')[:40]}")115                unit_no = "sn-" + hashlib.sha1(sig.encode("utf-8")).hexdigest()[:8]116117            amenities = list(amenities_building)118            details: dict = {"building": name}119            if u.get("plan"):120                details["floorplan"] = u["plan"]121122            area = None123            if u.get("superficie"):124                m_a = re.search(r"([\d\s]{2,6})\s*pi", u["superficie"])125                if m_a:126                    val = float(re.sub(r"\s", "", m_a.group(1)))127                    if 80 <= val <= 20000:128                        area = val129130            availability = ""131            if u.get("disponibilite"):132                availability = re.sub(r"^Disponibilit[ée]\s*:\s*", "",133                                      u["disponibilite"], flags=re.I).strip()134135            out.append(Listing(136                source=self.source_id,137                external_id=f"{slug}--{unit_no}",138                url=url,139                title=f"{name}{numero}" if numero else name,140                address=address,141                city=city,142                unit_type=normalize_unit_type(u.get("type", "")),143                price=parse_price(u.get("loyer", "")),144                price_label=u.get("loyer", ""),145                availability=availability,146                area_sqft=area,147                description=u.get("description", ""),148                amenities=amenities,149                details=details,150                images=(u.get("images") or [])[:20],151                lat=lat,152                lng=lng,153            ))154        return out155156    # -- page bâtiment ------------------------------------------------------------------157    def _fetch_building(self, url: str) -> dict:158        """Payload JSON-sérialisable : unités disponibles + infos du bâtiment."""159        resp = self.get(url)160        html = resp.text161        soup = BeautifulSoup(html, "html.parser")162        payload: dict = {}163164        # adresse structurée : champ ACF Google Maps rendu en clair dans la page165        # ('address' => '40, rue Saint-Étienne, à Lévis', 'city' => 'Lévis'…)166        acf = dict()167        for k, v in _ACF_RE.findall(html):168            acf.setdefault(k, v.strip())169        if acf.get("address"):170            payload["address"] = re.sub(r",?\s*à\s+", ", ", acf["address"])171        for k in ("city", "post_code"):172            if acf.get(k):173                payload[k] = acf[k]174        try:175            payload["lat"] = float(acf["lat"])176            payload["lng"] = float(acf["lng"])177        except (KeyError, ValueError):178            pass179180        # listes « Inclus » et « Caractéristiques » de l'immeuble181        amenities: list[str] = []182        for h in soup.find_all(["h2", "h3", "h4"]):183            titre = h.get_text(strip=True).lower()184            if titre not in ("inclus", "caractéristiques", "caracteristiques"):185                continue186            cont = h.find_parent(class_="elementor-widget")187            for sib in (cont.find_next_siblings() if cont else []):188                lis = sib.select("li")189                if lis:190                    for li in lis:191                        t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))192                        if t and t not in amenities:193                            amenities.append(t)194                    break195        payload["amenities"] = amenities[:25]196197        # unités disponibles : items JetEngine contenant un champ « numéro »198        units: list[dict] = []199        for item in soup.select(".jet-listing-grid__item"):200            unit = self._parse_unit(item)201            if unit:202                units.append(unit)203        payload["units"] = units204        return payload205206    # -- une unité (classification des champs par contenu) -------------------------------207    def _parse_unit(self, item) -> dict | None:208        texts: list[str] = []209        for f in item.select(".jet-listing-dynamic-field__content"):210            t = re.sub(r"\s+", " ", f.get_text(" ", strip=True))211            if t:212                texts.append(t)213        unit: dict = {}214        for t in texts:215            if re.search(r"^Appartement\s*#", t, re.I) and "numero" not in unit:216                unit["numero"] = t.rstrip(" ,")217            elif _TYPE_RE.match(t) and "type" not in unit:218                unit["type"] = t219            elif re.search(r"\$\s*/\s*mois|\d\$/mois", t) and "loyer" not in unit:220                unit["loyer"] = t221            elif re.search(r"^Superficie\s*:", t, re.I):222                unit["superficie"] = t223            elif re.search(r"^Disponibilit[ée]\s*:", t, re.I):224                unit["disponibilite"] = t225            elif len(t) > 60 and "description" not in unit:226                unit["description"] = t[:1200]227        if "numero" not in unit:228            return None                       # carte bâtiment / bloc décoratif229230        # galerie de l'unité (liens lightbox pleine taille)231        images: list[str] = []232        gal = item.select_one(".single-unit-gallery") or item233        for a in gal.select('a[href*="/wp-content/uploads/"]'):234            u = _clean_img(a["href"])235            if u.startswith("http") and u not in images:236                images.append(u)237        unit["images"] = images238239        # plan d'étage (« Voir le plan », seulement si un lien réel est fourni)240        for a in item.select("a[href]"):241            if "plan" in a.get_text(strip=True).lower():242                href = a["href"].strip()243                if href.startswith("http") and "/wp-content/" in href:244                    unit["plan"] = href245                break246        return unit247