SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.4 KB · 113 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/cosoltec.py : connecteur Cosoltec (cosoltec.com — projets locatifs5#   Evado à Sainte-Thérèse, Le Monroe à Blainville, Natür à Saint-Jérôme).6#   Les sites vitrines (evado.ca, lemonroe.ca, naturcondos.ca) affichent leurs7#   unités via le widget Planpoint : on interroge la même API JSON publique8#   (app.planpoint.io/api/{groups,projects}/find, POST namespace+hostName,9#   3 requêtes/sync) — prix mensuel, pi², chambres, statut, plans et photos10#   par unité ; seules les unités « Available » sont retenues.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import time1516from ..schema import Listing, normalize_unit_type17from .base import BaseConnector1819PLANPOINT_API = "https://app.planpoint.io/api"2021# les trois vitrines locatives de Cosoltec branchées sur Planpoint22_SITES = [23    {"kind": "groups", "namespace": "evado", "hostName": "evado",24     "page": "https://www.evado.ca/plans", "city": "Sainte-Thérèse"},25    {"kind": "groups", "namespace": "monroe", "hostName": "monroe",26     "page": "https://www.lemonroe.ca/plans", "city": "Blainville"},27    {"kind": "projects", "namespace": "cosolte", "hostName": "Natur",28     "page": "https://www.naturcondos.ca/", "city": "Saint-Jérôme"},29]303132class CosoltecConnector(BaseConnector):33    source_id = "cosoltec"34    request_delay = 0.835    max_pages = 3        # 1 appel API par vitrine (evado, monroe, natur)3637    # -- POST poli (même throttling/session que get(), donc record/replay ok) --38    def _post_json(self, url: str, payload: dict) -> dict:39        wait = self.request_delay - (time.time() - self._last_request)40        if wait > 0:41            time.sleep(wait)42        resp = self.session.post(url, json=payload, timeout=self.timeout)43        self._last_request = time.time()44        resp.raise_for_status()45        return resp.json()4647    def fetch(self) -> list[Listing]:48        listings: list[Listing] = []49        for site in _SITES:50            try:51                data = self._post_json(52                    f"{PLANPOINT_API}/{site['kind']}/find",53                    {"namespace": site["namespace"], "hostName": site["hostName"]})54            except Exception:55                continue56            # groups/find -> {projects: [...]} ; projects/find -> un seul projet57            projects = data.get("projects") if site["kind"] == "groups" else [data]58            for project in projects or []:59                listings.extend(self._parse_project(project, site))60        return listings6162    # -- un projet Planpoint = un immeuble ---------------------------------------63    def _parse_project(self, project: dict, site: dict) -> list[Listing]:64        name = (project.get("name") or "").strip()65        # adresse « 350 Place Fabien-Drapeau, Sainte-Thérèse, Quebec J7E 0C4 »66        addr_parts = [p.strip() for p in (project.get("address") or "").split(",")67                      if p.strip()]68        street = addr_parts[0] if addr_parts else ""69        city = addr_parts[1] if len(addr_parts) > 1 else site["city"]70        lat = project.get("lat")71        lng = project.get("lon")7273        res: list[Listing] = []74        for floor in project.get("floors") or []:75            for u in floor.get("units") or []:76                # seules les unités affichées « Disponible » chez la source77                if (u.get("availability") or "").lower() != "available":78                    continue79                uid = u.get("_id") or ""80                if not uid:81                    continue82                price = u.get("price")83                price = float(price) if isinstance(price, (int, float)) and price > 0 else None84                area = u.get("squareFeet")85                area = float(area) if isinstance(area, (int, float)) and area > 0 else None86                images = [i for i in (u.get("images") or []) if isinstance(i, str)]87                images += [i for i in (u.get("layoutGallery") or [])88                           if isinstance(i, str) and i not in images]89                details: dict = {}90                if isinstance(u.get("bathrooms"), (int, float)):91                    details["bathrooms"] = u["bathrooms"]92                if floor.get("name"):93                    details["floor"] = floor["name"]94                res.append(Listing(95                    source=self.source_id,96                    external_id=uid,             # ObjectId Planpoint de l'unité97                    url=site["page"],            # pas de page par unité publiée98                    title=f"{name} — Unité {u.get('name', '')}".strip(" —"),99                    address=street,100                    city=city,101                    unit_type=normalize_unit_type(u.get("bedrooms") or ""),102                    price=price,103                    price_label=f"{price:.0f} $ /mois" if price else "",104                    availability=u.get("availability") or "",105                    area_sqft=area,106                    furnished=u["furnished"] if isinstance(u.get("furnished"), bool) else None,107                    details=details,108                    images=images[:30],109                    lat=lat if (lat is not None and lng is not None) else None,110                    lng=lng if (lat is not None and lng is not None) else None,111                ))112        return res113