SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.5 KB · 196 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/evoludev.py : connecteur Groupe Evoludev (location.groupeevoludev.com)5#   Portail locatif maison (Laravel). La page d'accueil liste ~60 immeubles6#   (cartes : nom, adresse+ville, étiquette Disponible/Complet, prix «à partir7#   de»). Les pages projet (cache BD, seulement si l'immeuble est «Disponible»)8#   exposent un tableau d'unités (no, type, prix, statut, date, cac/sdb, pi²,9#   plan avec id d'appartement stable) + JSON-LD ApartmentComplex (description,10#   GPS, animaux, commodités).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import json16import re1718from bs4 import BeautifulSoup1920from ..schema import (Listing, normalize_unit_type, parse_area_sqft,21                      parse_price, strip_accents)22from .base import BaseConnector2324BASE = "https://location.groupeevoludev.com"2526_MODAL_ID = re.compile(r"apartmentPlanModal_(\d+)")272829def _pets_value(raw: str) -> str | None:30    """petsAllowed du JSON-LD -> oui/non/conditions (jamais deviné)."""31    k = strip_accents((raw or "").strip().lower())32    if not k:33        return None34    if k.startswith("non") or "refus" in k or "aucun" in k:35        return "non"36    if "condition" in k or "approbation" in k:37        return "conditions"38    if k.startswith("oui") or "accepte" in k:39        return "oui"40    return None414243class EvoludevConnector(BaseConnector):44    source_id = "evoludev"45    request_delay = 0.846    max_pages = 1        # tout le parc est sur la page d'accueil du portail47    max_details = 45     # garde-fou pages projet (vraies requêtes)4849    def fetch(self) -> list[Listing]:50        html = self.get(BASE + "/").text51        soup = BeautifulSoup(html, "html.parser")5253        # cartes projet (dédupliquées par slug ; versions mobile/desktop en double)54        projects: dict[str, dict] = {}55        for hi in soup.select("div.headerImage[onclick]"):56            m = re.search(r"/projet/([\w-]+)", hi.get("onclick", ""))57            if not m:58                continue59            slug = m.group(1)60            if slug in projects:61                continue62            sect = hi.find_parent("section")63            tag = hi.select_one(".availabilityTag")64            title = sect.select_one(".ProjectItem__title") if sect else None65            addr = sect.select_one(".BuildingItem__address") if sect else None66            price = sect.select_one(".ProjectItem__price p") if sect else None67            addr_lines = ([l.strip() for l in addr.get_text("\n", strip=True).split("\n")]68                          if addr else [])69            projects[slug] = {70                "slug": slug,71                "name": title.get_text(strip=True) if title else slug,72                "tag": tag.get_text(" ", strip=True) if tag else "",73                "street": addr_lines[0] if addr_lines else "",74                "city": re.sub(r",?\s*QC.*$", "", addr_lines[1]).strip()75                        if len(addr_lines) > 1 else "",76                "price": re.sub(r"\s+", " ", price.get_text(" ", strip=True))77                         if price else "",78            }7980        # pages projet : uniquement les immeubles étiquetés « Disponible »81        # (Complet / livraison à venir = aucune unité offerte actuellement)82        listings: list[Listing] = []83        self._fetched = 084        for p in projects.values():85            if not p["tag"].lower().startswith("disponible"):86                continue87            card_key = hashlib.sha1(88                f"{p['tag']}|{p['price']}|{p['name']}".encode("utf-8")).hexdigest()89            try:90                payload = self.detail(p["slug"], card_key,91                                      lambda s=p["slug"]: self._fetch_project(s))92            except Exception:93                continue94            listings.extend(self._units_to_listings(p, payload))95        return listings9697    # -- page projet ------------------------------------------------------------98    def _fetch_project(self, slug: str) -> dict:99        """Tableau des unités + JSON-LD ApartmentComplex (desc, GPS, animaux)."""100        if self._fetched >= self.max_details:101            raise RuntimeError("budget de pages projet atteint")102        self._fetched += 1103        html = self.get(f"{BASE}/projet/{slug}").text104        soup = BeautifulSoup(html, "html.parser")105        out: dict = {"units": []}106107        # JSON-LD ApartmentComplex : description, GPS, animaux, commodités108        for sc in soup.select('script[type="application/ld+json"]'):109            try:110                data = json.loads(sc.string or "")111            except Exception:112                continue113            if data.get("@type") == "ApartmentComplex":114                out["description"] = (data.get("description") or "").strip()115                out["lat"] = data.get("latitude")116                out["lng"] = data.get("longitude")117                out["pets"] = data.get("petsAllowed") or ""118                plan = data.get("accommodationFloorPlan") or {}119                out["amenities"] = plan.get("amenityFeature") or []120                imgs = data.get("image") or []121                out["building_image"] = imgs[0] if imgs else ""122                break123124        # tableau desktop des unités : no | type, prix, statut, date, cac, sdb, pi²125        seen: set[str] = set()126        for tr in soup.select(".ApartmentTable tr"):127            th = tr.find("th")128            tds = tr.find_all("td")129            if not th or len(tds) < 6:130                continue131            name = th.get_text(" ", strip=True)          # « 101 | 4 1/2 »132            if name in seen:133                continue134            seen.add(name)135            btn = tr.select_one("button[data-target]")136            m = _MODAL_ID.search(btn["data-target"]) if btn else None137            plan_img = ""138            if m:139                img = soup.select_one(f"#apartmentPlanModal_{m.group(1)} img[src]")140                plan_img = img["src"] if img else ""141            out["units"].append({142                "name": name,143                "apt_id": m.group(1) if m else "",144                "price": tds[0].get_text(" ", strip=True),145                "status": tds[1].get_text(" ", strip=True),146                "date": tds[2].get_text(" ", strip=True),147                "bedrooms": tds[3].get_text(strip=True),148                "bathrooms": tds[4].get_text(strip=True),149                "area": tds[5].get_text(" ", strip=True),150                "plan_image": plan_img,151            })152        return out153154    # -- unités -> Listings -------------------------------------------------------155    def _units_to_listings(self, p: dict, d: dict) -> list[Listing]:156        res: list[Listing] = []157        for u in d.get("units", []):158            # seules les unités affichées « Disponible » (on saute Louée/Réservée)159            if not u["status"].lower().startswith("disponible"):160                continue161            num = u["name"].split("|")[0].strip()162            ext_id = u["apt_id"] or f"{p['slug']}-{num}"163            # prix placebo de la plateforme (« 0 $ / m », « N.D. $ / m ») :164            # aucun prix publié pour cette unité — on n'affiche rien165            price_label = u["price"]166            if re.match(r"^\s*(0|N\.?D\.?)\s*\$", price_label):167                price_label = ""168            images = [i for i in (u["plan_image"], d.get("building_image", ""))169                      if i]170            details: dict = {}171            if u["bedrooms"].isdigit():172                details["bedrooms"] = int(u["bedrooms"])173            if u["bathrooms"].isdigit():174                details["bathrooms"] = int(u["bathrooms"])175            res.append(Listing(176                source=self.source_id,177                external_id=str(ext_id),178                url=f"{BASE}/projet/{p['slug']}",179                title=f"{p['name']} — Unité {num}",180                address=p["street"],181                city=p["city"],182                unit_type=normalize_unit_type(u["name"]),183                price=parse_price(price_label),184                price_label=price_label,185                availability=u["date"],186                area_sqft=parse_area_sqft(u["area"]),187                pets=_pets_value(d.get("pets", "")),188                description=d.get("description", ""),189                amenities=list(d.get("amenities", []))[:20],190                details=details,191                images=images,192                lat=d.get("lat"),193                lng=d.get("lng"),194            ))195        return res196