SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.8 KB · 167 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/imvest.py : connecteur Société Imvest (societeimvest.ca)5#   Le Méribel, 195 logements en 2 phases au 1191, rue de Courchevel à Lévis6#   (secteur Saint-Romuald). WordPress (thème TeamPress) avec plans d'étage7#   interactifs : endpoints AJAX POST maison —8#     /ajax/recherche-etage/ (id=immeuble) -> liste des étages (li.Etage)9#     /ajax/load-etage/ (id=étage)         -> <area> par unité (no, type,10#         superficie dans data-tooltip) + JSON `areas` dont fillColor code le11#         statut : cbcbcb = loué, 940723 = réservé, autre = disponible12#     /ajax/recherche-unite/ (id=unité)    -> détail (disponibilité, plan,13#         caractéristiques)14#   Granularité : unité. Pas de prix publié (Tier B).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import json20import re2122from ..schema import Listing, normalize_unit_type23from .base import BaseConnector2425BASE = "https://www.societeimvest.ca"26AJAX_ETAGES = f"{BASE}/ajax/recherche-etage/"27AJAX_PLAN = f"{BASE}/ajax/load-etage/"28AJAX_UNITE = f"{BASE}/ajax/recherche-unite/"2930# phases (slug de page « immeubles ») — l'id AJAX est lu sur la page31PHASES = [("le-meribel", "Le Méribel — Phase 1"),32          ("le-meribel-phase-2", "Le Méribel — Phase 2")]3334ADDRESS = "1191, rue de Courchevel, Lévis"35LAT, LNG = 46.732973, -71.2629413637IMMEUBLE_ID_RE = re.compile(r"immeuble_id = '(\d+)'")38FLOOR_RE = re.compile(r'<li id="(\d+)" class="Etage"><a class="BoutonEtage"'39                      r' href="#" title="([^"]*)"')40AREA_RE = re.compile(r'<area data-tooltip="([^"]*)" id="(\d+)"[^>]*'41                     r'data-key="(\d+)"')42AREAS_JSON_RE = re.compile(r"areas:\s*(\[.*?\])", re.S)43TYPE_RE = re.compile(r"<strong>([^<]*)</strong>")44SQFT_RE = re.compile(r"Superficie\s*:\s*([\d\s]+)\s*p\.?c\.?", re.I)45DISPO_RE = re.compile(r"Disponibilit[^:]*:\s*<span class=\"Spacing\">"46                      r"([^<]*)</span>", re.I)47PLAN_RE = re.compile(r'href="(https://www\.societeimvest\.ca/wp-content/'48                     r'uploads/[^"]+\.pdf)"')49IMG_RE = re.compile(r'<img[^>]+src="([^"]+phpThumb[^"]+)"')50CARAC_RE = re.compile(r"Caractéristiques du logement</h3>\s*<ul>(.*?)</ul>",51                      re.S)52LI_RE = re.compile(r"<li>(.*?)</li>", re.S)5354# statuts non offerts en location (gris = loué, rouge foncé = réservé)55TAKEN_COLORS = {"cbcbcb", "940723"}565758class ImvestConnector(BaseConnector):59    source_id = "imvest"60    request_delay = 0.66162    def fetch(self) -> list[Listing]:63        listings: list[Listing] = []64        for slug, phase_name in PHASES:65            page_url = f"{BASE}/immeubles/{slug}/"66            try:67                page = self.get(page_url).text68            except Exception:69                continue70            m = IMMEUBLE_ID_RE.search(page)71            if not m:72                continue73            try:74                floors_html = self.post(AJAX_ETAGES,75                                        data={"id": m.group(1)}).text76            except Exception:77                continue78            for floor_id, floor_name in FLOOR_RE.findall(floors_html):79                try:80                    listings.extend(self._parse_floor(81                        slug, phase_name, page_url, floor_id, floor_name))82                except Exception:83                    continue84        return listings8586    def _parse_floor(self, slug: str, phase_name: str, page_url: str,87                     floor_id: str, floor_name: str) -> list[Listing]:88        out: list[Listing] = []89        plan_html = self.post(AJAX_PLAN, data={"id": floor_id}).text9091        # statut de chaque unité (JSON mapster : key = no d'unité)92        status: dict[str, str] = {}93        m = AREAS_JSON_RE.search(plan_html)94        if m:95            try:96                for a in json.loads(m.group(1)):97                    status[str(a.get("key"))] = a.get("fillColor", "")98            except ValueError:99                pass100101        for tooltip, unit_id, unit_no in AREA_RE.findall(plan_html):102            color = status.get(unit_no, "")103            if not color or color.lower() in TAKEN_COLORS:104                continue           # loué / réservé / inconnu105106            tm = TYPE_RE.search(tooltip)107            unit_type = normalize_unit_type(tm.group(1)) if tm else ""108            sm = SQFT_RE.search(tooltip)109            area = float(sm.group(1).replace(" ", "").replace(" ", "")) \110                if sm else None111112            key = hashlib.sha1(f"{color}|{tooltip}".encode()).hexdigest()113            d = self.detail(f"{slug}-{unit_no}", key,114                            lambda uid=unit_id: self._fetch_unit(uid))115116            out.append(Listing(117                source=self.source_id,118                external_id=f"{slug}-{unit_no}",119                url=page_url,120                title=f"Unité {unit_no} — {phase_name}",121                address=ADDRESS,122                sector="Saint-Romuald",123                city="Lévis",124                unit_type=unit_type,125                area_sqft=area,126                price_label="Prix sur demande",127                availability=d.get("availability", ""),128                description=f"Unité {unit_no} ({unit_type}), {floor_name} — "129                            f"{phase_name}, Société Imvest, Lévis.",130                amenities=d.get("amenities") or [],131                details={"floor": floor_name,132                         **({"floor_plan": d["plan"]} if d.get("plan")133                            else {})},134                images=d.get("images") or [],135                lat=LAT,136                lng=LNG,137            ))138        return out139140    def _fetch_unit(self, unit_id: str) -> dict:141        """Détail d'une unité via /ajax/recherche-unite/ (POST id=…)."""142        out: dict = {}143        try:144            html = self.post(AJAX_UNITE, data={"id": unit_id}).text145        except Exception:146            return out147        m = DISPO_RE.search(html)148        if m:149            dispo = m.group(1).strip()150            out["availability"] = dispo if re.match(r"^\d|^libre|^immédiat",151                                                    dispo, re.I) \152                else f"Disponibilité : {dispo}"153        m = PLAN_RE.search(html)154        if m:155            out["plan"] = m.group(1)156        m = CARAC_RE.search(html)157        if m:158            out["amenities"] = [159                re.sub(r"<[^>]+>", "", li).strip()160                for li in LI_RE.findall(m.group(1))161                if re.sub(r"<[^>]+>", "", li).strip()][:20]162        imgs = [u.replace("&amp;", "&") for u in IMG_RE.findall(html)163                if "ajax_loading" not in u]164        if imgs:165            out["images"] = imgs[:5]166        return out167