Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
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