# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/pmml.py : PMML (pmml.ca) — LA grande agence commerciale/ # multilogements indépendante du Québec (plex, multis, terrains, industriel, # bureaux — ~550 inscriptions actives, toutes régions). # # Le site est une SPA (Svelte + Mapbox) derrière Cloudflare, mais son API # JSON interne est ouverte : GET /api/proprietes/avendre?limit=1000 retourne # TOUT l'inventaire à vendre en un appel (prix demandé, GPS, adresse civique, # ville/région, unités rés./comm., superficies, année, courtier inscripteur, # slug de fiche, photo principale). La chaîne anti-bot de BaseConnector # (Oxylabs → Scrapfly → Bright Data) encaisse le 403 Cloudflare du direct. # Pas de description dans le flux → on synthétise ≥3 caractéristiques # structurées (unités, superficie, prix/logement…) pour la publication. # # IDPropriete est interne (pas de n° Centris exposé) → pas d'infixe _ag_ ; # la dédup par adresse (inter-sources) couvre les recoupements éventuels. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from .base import BaseConnector from ..schema import PropertyListing API = "https://pmml.ca/api/proprietes/avendre?limit=1000" SITE = "https://pmml.ca" AGENCY = "PMML" _TYPES = { "multilogement": "Multilogement", "terrain": "Terrain", "commerceDetail": "Commerce de détail", "industriel": "Industriel", "bureaux": "Bureaux", "rpa": "Résidence pour aînés (RPA)", "hotel": "Hôtel", "commercialGeneral": "Commercial", "fondCommerce": "Fonds de commerce", "portfolioRes": "Portefeuille résidentiel", "portfolioComm": "Portefeuille commercial", } _YEAR_RE = re.compile(r"\b(1[6-9]\d{2}|20\d{2})\b") def _fmt_money(v) -> str: return f"{int(round(float(v))):,} $".replace(",", " ") class PmmlConnector(BaseConnector): source_id = "pmml" def fetch(self) -> list[PropertyListing]: rows = self.get(API).json() out: list[PropertyListing] = [] for r in rows: if r.get("iLocation") or (r.get("sType") or "vente") != "vente": continue lid = r.get("IDPropriete") slug = r.get("slugFr") or "" if not lid or not slug: continue ptype = _TYPES.get(r.get("IDType") or "", r.get("IDType") or "") civic, street = (r.get("sNumeroCivique") or "").strip(), (r.get("sRue") or "").strip() address = f"{civic}, {street}".strip(", ") if street else "" city = (r.get("sNomVille") or "").strip() price = r.get("fPrixDemande") if not r.get("iMasquerPrix") else None price_label = (_fmt_money(price) if price else (r.get("TextePourPrixFr") or "Prix sur demande")) year = None ym = _YEAR_RE.search(str(r.get("iAnneeConstruction") or "")) if ym: year = int(ym.group(1)) # caractéristiques synthétiques (le flux n'a pas de description) : # ≥3 items = seuil de publication du module qualité features: list[str] = [ptype] if ptype else [] details: dict = {"Type de propriété": ptype} if ptype else {} if r.get("iUnitesTotal"): features.append(f"{r['iUnitesTotal']} unités") details["Nombre d'unités"] = str(r["iUnitesTotal"]) if r.get("iUnitesResidentiel"): details["Unités résidentielles"] = str(r["iUnitesResidentiel"]) if r.get("iUnitesCommercial"): details["Unités commerciales"] = str(r["iUnitesCommercial"]) if r.get("iAnneeConstruction"): features.append(f"Construction {r['iAnneeConstruction']}") details["Année de construction"] = str(r["iAnneeConstruction"]) if r.get("iPiedsCarreTotal"): features.append(f"{r['iPiedsCarreTotal']:,} pi² (bâtiment)".replace(",", " ")) details["Superficie du bâtiment"] = f"{r['iPiedsCarreTotal']:,} pi²".replace(",", " ") if r.get("iPiedsCarreTerrain"): features.append(f"{r['iPiedsCarreTerrain']:,} pi² (terrain)".replace(",", " ")) details["Superficie du terrain"] = f"{r['iPiedsCarreTerrain']:,} pi²".replace(",", " ") if r.get("fCPL") and price: try: details["Prix par logement"] = _fmt_money(r["fCPL"]) features.append(f"{_fmt_money(r['fCPL'])} / logement") except (TypeError, ValueError): pass if r.get("sNomSecteur"): details["Secteur PMML"] = r["sNomSecteur"] if r.get("plusTaxes"): details["Prix"] = "Plus taxes" if r.get("NoteApresPrixFr"): details["Note sur le prix"] = r["NoteApresPrixFr"] broker = (r.get("sNomInscripteur") or "").strip() if r.get("sNomCoInscripteur"): broker = f"{broker} et {r['sNomCoInscripteur']}".strip(" et") images = [] try: images = [u for u in json.loads(r.get("Photos") or "[]") if u] except ValueError: pass if not images and r.get("sImagePrincipale"): images = [r["sImagePrincipale"]] title = (r.get("TitreAltFr") or "").strip() or \ f"{ptype or 'Propriété'} à vendre — {address or city}".strip(" —") out.append(PropertyListing( source=self.source_id, external_id=str(lid), url=SITE + slug, title=title, address=address, city=city, region=(r.get("sNomRegion") or "").strip(), property_type=ptype, price=price, price_label=price_label, year_built=year, area_sqft=r.get("iPiedsCarreTotal"), lot_sqft=r.get("iPiedsCarreTerrain"), features=features, details=details, images=images, lat=r.get("dPosLat"), lng=r.get("dPosLong"), agency=AGENCY, broker_name=broker or AGENCY, )) return out