SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
6.3 KB · 137 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/pmml.py : PMML (pmml.ca) — LA grande agence commerciale/5#   multilogements indépendante du Québec (plex, multis, terrains, industriel,6#   bureaux — ~550 inscriptions actives, toutes régions).7#8#   Le site est une SPA (Svelte + Mapbox) derrière Cloudflare, mais son API9#   JSON interne est ouverte : GET /api/proprietes/avendre?limit=1000 retourne10#   TOUT l'inventaire à vendre en un appel (prix demandé, GPS, adresse civique,11#   ville/région, unités rés./comm., superficies, année, courtier inscripteur,12#   slug de fiche, photo principale). La chaîne anti-bot de BaseConnector13#   (Oxylabs → Scrapfly → Bright Data) encaisse le 403 Cloudflare du direct.14#   Pas de description dans le flux → on synthétise ≥3 caractéristiques15#   structurées (unités, superficie, prix/logement…) pour la publication.16#17#   IDPropriete est interne (pas de n° Centris exposé) → pas d'infixe _ag_ ;18#   la dédup par adresse (inter-sources) couvre les recoupements éventuels.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import json23import re2425from .base import BaseConnector26from ..schema import PropertyListing2728API = "https://pmml.ca/api/proprietes/avendre?limit=1000"29SITE = "https://pmml.ca"30AGENCY = "PMML"3132_TYPES = {33    "multilogement": "Multilogement", "terrain": "Terrain",34    "commerceDetail": "Commerce de détail", "industriel": "Industriel",35    "bureaux": "Bureaux", "rpa": "Résidence pour aînés (RPA)",36    "hotel": "Hôtel", "commercialGeneral": "Commercial",37    "fondCommerce": "Fonds de commerce",38    "portfolioRes": "Portefeuille résidentiel",39    "portfolioComm": "Portefeuille commercial",40}41_YEAR_RE = re.compile(r"\b(1[6-9]\d{2}|20\d{2})\b")424344def _fmt_money(v) -> str:45    return f"{int(round(float(v))):,} $".replace(",", " ")464748class PmmlConnector(BaseConnector):49    source_id = "pmml"5051    def fetch(self) -> list[PropertyListing]:52        rows = self.get(API).json()53        out: list[PropertyListing] = []54        for r in rows:55            if r.get("iLocation") or (r.get("sType") or "vente") != "vente":56                continue57            lid = r.get("IDPropriete")58            slug = r.get("slugFr") or ""59            if not lid or not slug:60                continue61            ptype = _TYPES.get(r.get("IDType") or "", r.get("IDType") or "")62            civic, street = (r.get("sNumeroCivique") or "").strip(), (r.get("sRue") or "").strip()63            address = f"{civic}, {street}".strip(", ") if street else ""64            city = (r.get("sNomVille") or "").strip()6566            price = r.get("fPrixDemande") if not r.get("iMasquerPrix") else None67            price_label = (_fmt_money(price) if price68                           else (r.get("TextePourPrixFr") or "Prix sur demande"))6970            year = None71            ym = _YEAR_RE.search(str(r.get("iAnneeConstruction") or ""))72            if ym:73                year = int(ym.group(1))7475            # caractéristiques synthétiques (le flux n'a pas de description) :76            # ≥3 items = seuil de publication du module qualité77            features: list[str] = [ptype] if ptype else []78            details: dict = {"Type de propriété": ptype} if ptype else {}79            if r.get("iUnitesTotal"):80                features.append(f"{r['iUnitesTotal']} unités")81                details["Nombre d'unités"] = str(r["iUnitesTotal"])82                if r.get("iUnitesResidentiel"):83                    details["Unités résidentielles"] = str(r["iUnitesResidentiel"])84                if r.get("iUnitesCommercial"):85                    details["Unités commerciales"] = str(r["iUnitesCommercial"])86            if r.get("iAnneeConstruction"):87                features.append(f"Construction {r['iAnneeConstruction']}")88                details["Année de construction"] = str(r["iAnneeConstruction"])89            if r.get("iPiedsCarreTotal"):90                features.append(f"{r['iPiedsCarreTotal']:,} pi² (bâtiment)".replace(",", " "))91                details["Superficie du bâtiment"] = f"{r['iPiedsCarreTotal']:,} pi²".replace(",", " ")92            if r.get("iPiedsCarreTerrain"):93                features.append(f"{r['iPiedsCarreTerrain']:,} pi² (terrain)".replace(",", " "))94                details["Superficie du terrain"] = f"{r['iPiedsCarreTerrain']:,} pi²".replace(",", " ")95            if r.get("fCPL") and price:96                try:97                    details["Prix par logement"] = _fmt_money(r["fCPL"])98                    features.append(f"{_fmt_money(r['fCPL'])} / logement")99                except (TypeError, ValueError):100                    pass101            if r.get("sNomSecteur"):102                details["Secteur PMML"] = r["sNomSecteur"]103            if r.get("plusTaxes"):104                details["Prix"] = "Plus taxes"105            if r.get("NoteApresPrixFr"):106                details["Note sur le prix"] = r["NoteApresPrixFr"]107108            broker = (r.get("sNomInscripteur") or "").strip()109            if r.get("sNomCoInscripteur"):110                broker = f"{broker} et {r['sNomCoInscripteur']}".strip(" et")111112            images = []113            try:114                images = [u for u in json.loads(r.get("Photos") or "[]") if u]115            except ValueError:116                pass117            if not images and r.get("sImagePrincipale"):118                images = [r["sImagePrincipale"]]119120            title = (r.get("TitreAltFr") or "").strip() or \121                f"{ptype or 'Propriété'} à vendre — {address or city}".strip(" —")122123            out.append(PropertyListing(124                source=self.source_id, external_id=str(lid),125                url=SITE + slug, title=title,126                address=address, city=city,127                region=(r.get("sNomRegion") or "").strip(),128                property_type=ptype, price=price, price_label=price_label,129                year_built=year,130                area_sqft=r.get("iPiedsCarreTotal"),131                lot_sqft=r.get("iPiedsCarreTerrain"),132                features=features, details=details, images=images,133                lat=r.get("dPosLat"), lng=r.get("dPosLong"),134                agency=AGENCY, broker_name=broker or AGENCY,135            ))136        return out137