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%
21.8 KB · 462 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/ubee.py : Ubee (ubee.com) — plateforme immobilière québécoise5#   API publique anonyme : POST api.ubee.ca/api/anonymous/Search/SearchProperties6#   (pageIndex=N, 24 résultats/page, JSON riche : adresse, GPS, prix, pièces,7#   superficies m², année, galerie Cloudinary complète).8#   Deux volets À VENDRE (listingType=Seller) : résidentiel + commercial.9#10#   ENRICHISSEMENT DÉTAIL (fiche complète) :11#     GET api.ubee.ca/api/anonymous/Listing/{id}12#   retourne TOUT : description longue (FR/EN), n° Centris, ~30 listes de13#   caractéristiques énumérées (chauffage, sous-sol, garage, piscine, vue,14#   proximité…), taxes/frais (expenses), évaluation municipale, garantie15#   légale, délai d'occupation, stationnements, courtier inscripteur16#   (nom/agence/téléphone/courriel), visite virtuelle, lien Centris original.17#   Mise en cache BD (detail_cache) — clé = prix + photo de couverture, seules18#   les fiches nouvelles/modifiées sont re-lues (plafond IMMOKA_DETAIL_LIMIT).19#   NB : l'endpoint détail refuse les User-Agent non navigateur (403) — le20#   User-Agent de BaseConnector passe. Images Cloudinary : transformations21#   nommées strictes, t_default_size (1260 px) est le maximum public.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import os26import re2728from ..schema import PropertyListing29from ._detailutil import apply_detail30from .base import BaseConnector3132API = "https://api.ubee.ca/api/anonymous/Search/SearchProperties"33DETAIL_API = "https://api.ubee.ca/api/anonymous/Listing"34SITE = "https://ubee.com"35M2_TO_SQFT = 10.76393637_TYPES = {38    "Unifamiliale": "Maison", "Condo": "Condo", "Terrain": "Terrain",39    "Plex": "Immeuble à revenus", "Commercial": "Commercial",40    "Fermette": "Fermette", "Chalet": "Chalet",41}4243# --- vocabulaire énuméré de l'API détail -> libellés français ----------------44_ENUM_FR = {45    # sous-sol / fondation / structure46    "Aucun": "Aucun", "Autre": "Autre", "EntreeExterieure": "Entrée extérieure",47    "MoinsDeSixPieds": "Moins de 6 pieds", "SixPiedsOuPlus": "6 pieds ou plus",48    "NonAmenage": "Non aménagé", "PartiellementAmenage": "Partiellement aménagé",49    "TotalementAmenage": "Totalement aménagé", "VideSanitaire": "Vide sanitaire",50    "BetonCoule": "Béton coulé", "BlocDeBeton": "Bloc de béton",51    "DalleDeBetonAuSol": "Dalle de béton au sol",52    # chauffage / énergie53    "AirSouffle": "Air soufflé", "PlinthesAConvection": "Plinthes à convection",54    "PlinthesElectriques": "Plinthes électriques", "Radiant": "Radiant",55    "Electricite": "Électricité", "GazNaturel": "Gaz naturel",56    "Mazout": "Mazout", "Propane": "Propane", "Bois": "Bois",57    "Geothermie": "Géothermie", "Solaire": "Solaire",58    # foyer / poêle59    "FoyerAuBois": "Foyer au bois", "FoyerAuGaz": "Foyer au gaz",60    "FoyerAuMazout": "Foyer au mazout", "PoeleAuBois": "Poêle au bois",61    "PoeleAuxGranules": "Poêle aux granules",62    # garage / abri / allée / stationnement63    "Attache": "Attaché", "Detache": "Détaché", "Integre": "Intégré",64    "Chauffe": "Chauffé", "DoubleOuPlus": "Double ou plus",65    "SimpleLargeur": "Simple largeur", "DoubleLargeurOuPlus": "Double largeur ou plus",66    "Asphalte": "Asphalte", "NonPave": "Non pavé", "PaveUni": "Pavé uni",67    "AvecPriseExterieure": "Avec prise extérieure",68    # revêtements / toiture / fenêtres69    "Acier": "Acier", "Aluminium": "Aluminium", "Brique": "Brique",70    "Pierre": "Pierre", "Stucco": "Stucco", "Vinyle": "Vinyle", "PVC": "PVC",71    "BardeauxDAsphalte": "Bardeaux d'asphalte", "BardeauxDeCedre": "Bardeaux de cèdre",72    "BitumeEtGravier": "Bitume et gravier", "MembraneElastomere": "Membrane élastomère",73    "Tole": "Tôle", "Crank": "À manivelle", "PorteFenetre": "Porte-fenêtre",74    "Sliding": "Coulissante", "Melamine": "Mélamine", "Polyester": "Polyester",75    # commodités76    "AdoucisseurDEau": "Adoucisseur d'eau", "Ascenseur": "Ascenseur",77    "AspirateurCentral": "Aspirateur central", "Balcon": "Balcon",78    "BorneDeRecharge": "Borne de recharge", "ClimatiseurCentral": "Climatiseur central",79    "ClimatiseurMural": "Climatiseur mural", "EchangeurDAir": "Échangeur d'air",80    "EspaceDeRangement": "Espace de rangement", "Interphone": "Interphone",81    "Meuble": "Meublé", "SemiMeuble": "Semi-meublé", "Sauna": "Sauna",82    "PorteDeGarageElectrique": "Porte de garage électrique",83    "SystemeDAlarme": "Système d'alarme", "ThermopompeCentrale": "Thermopompe centrale",84    "ThermopompeMurale": "Thermopompe murale", "WaterHeater": "Chauffe-eau",85    # salle de bains86    "AttachedToMasterBedroom": "Attenante à la chambre principale",87    "BaignoireARemous": "Baignoire à remous", "SeparateShower": "Douche séparée",88    # piscine89    "Chauffee": "Chauffée", "Creusee": "Creusée", "HorsTerre": "Hors terre",90    "Interieure": "Intérieure",91    # eau / égouts92    "ChampDEpuration": "Champ d'épuration", "FosseSeptique": "Fosse septique",93    "Municipalite": "Municipalité", "SystemeBIONEST": "Système BIONEST",94    "EauDuLac": "Eau du lac", "PuitsArtesien": "Puits artésien",95    "PuitsDeSurface": "Puits de surface", "AccesALEau": "Accès à l'eau",96    "BordeParLEau": "Bordé par l'eau", "Navigable": "Navigable",97    # vue / terrain / zonage / particularités98    "Panoramique": "Panoramique", "SurLEau": "Sur l'eau",99    "SurLaMontagne": "Sur la montagne", "SurLaVille": "Sur la ville",100    "Other": "Autre", "Boise": "Boisé", "Cloture": "Clôturé",101    "Paysage": "Paysagé", "Agricultural": "Agricole", "Commercial": "Commercial",102    "Holiday": "Villégiature", "Multifamily": "Multifamilial",103    "Recreational": "Récréatif", "Residential": "Résidentiel",104    "CoinDeRue": "Coin de rue", "CornerUnit": "Unité de coin",105    "CulDeSac": "Cul-de-sac", "ForestMixed": "Forêt mixte",106    "MotorizedBoatPermitted": "Bateau à moteur permis",107    "MotorlessBoatOnly": "Bateau sans moteur seulement",108    "PrivateRoad": "Chemin privé", "SansVoisinArriere": "Sans voisin à l'arrière",109    # proximité110    "Autoroute": "Autoroute", "Cegep": "Cégep", "EcolePrimaire": "École primaire",111    "EcoleSecondaire": "École secondaire", "Garderie": "Garderie", "Golf": "Golf",112    "Hopital": "Hôpital", "MetropolitanExpressNetwork": "REM", "Parc": "Parc",113    "PisteCyclable": "Piste cyclable", "SentierDeMotoneige": "Sentier de motoneige",114    "SentierDeVTT": "Sentier de VTT", "SkiAlpin": "Ski alpin",115    "SkiDeFond": "Ski de fond", "TransportEnCommun": "Transport en commun",116    "Universite": "Université",117    # divers118    "AnimauxPermis": "Animaux permis", "RezDeChaussee": "Rez-de-chaussée",119    "SousSol": "Sous-sol", "Flat": "Plat", "Slope": "En pente",120}121122# champs-listes de la fiche détail -> groupe de caractéristiques (features)123_FEATURE_GROUPS = [124    ("heatingType", "Chauffage"),125    ("heatingEnergyType", "Énergie pour le chauffage"),126    ("basementType", "Sous-sol"),127    ("garageType", "Garage"),128    ("carportType", "Abri d'auto"),129    ("drivewayType", "Allée d'accès"),130    ("poolType", "Piscine"),131    ("fireplaceType", "Foyer/Poêle"),132    ("convenienceType", "Commodités"),133    ("cabinetType", "Armoires"),134    ("bathroomFeatureType", "Salle de bains"),135    ("exteriorCoveringType", "Revêtement extérieur"),136    ("roofingType", "Toiture"),137    ("foundationType", "Fondation"),138    ("fenestrationType", "Fenestration"),139    ("windowMechanismType", "Type de fenêtres"),140    ("sewerSystemType", "Système d'égouts"),141    ("waterSupplyType", "Approvisionnement en eau"),142    ("waterAccessType", "Accès à l'eau"),143    ("viewType", "Vue"),144    ("proximityType", "À proximité"),145    ("landType", "Terrain"),146    ("landZoningType", "Zonage"),147    ("particularityType", "Particularités"),148    ("reducedMobilityType", "Mobilité réduite"),149    ("animalPermissionType", "Animaux"),150    ("energyEfficiencyType", "Efficacité énergétique"),151    ("rentedApplianceType", "Équipement loué"),152    ("washerDryerLocationType", "Laveuse/sécheuse"),153    ("commercialListingUsage", "Usage commercial"),154    ("farmAnimalType", "Animaux de ferme"),155    ("farmBuildingType", "Bâtiments agricoles"),156]157158_EXPENSE_FR = {159    "Taxmun": "Taxes municipales", "Taxsco": "Taxes scolaires",160    "Fcop": "Frais de copropriété", "Taxeau": "Taxe d'eau",161    "Elec": "Électricité", "Chauffage": "Chauffage",162    "CommonExpenses": "Frais communs", "Assurance": "Assurances",163}164_FREQ_FR = {"Annual": "an", "Monthly": "mois", "Weekly": "sem.", "Daily": "jour"}165_WARRANTY_FR = {166    "WithWarranty": "Avec garantie légale", "WithExclusions": "Avec exclusions",167    "WithoutWarranty": "Sans garantie légale (aux risques et périls de l'acheteur)",168    "NoWarranty": "Sans garantie légale (aux risques et périls de l'acheteur)",169}170_PARKING_FR = {"Garage": "garage", "Driveway": "allée", "Carport": "abri d'auto",171               "Street": "rue", "Interior": "intérieur", "Exterior": "extérieur"}172_HALF_UNITS = [("oneAndHalfCount", "1½"), ("twoAndHalfCount", "2½"),173               ("threeAndHalfCount", "3½"), ("fourAndHalfCount", "4½"),174               ("fiveAndHalfCount", "5½"), ("sixAndHalfCount", "6½"),175               ("sevenAndHalfCount", "7½+")]176177_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")178179180def _fr(code) -> str:181    """Libellé français d'un code énuméré de l'API (repli : découpe CamelCase)."""182    code = str(code or "").strip()183    if not code:184        return ""185    return _ENUM_FR.get(code) or _CAMEL_RE.sub(" ", code)186187188def _money(amount) -> str:189    try:190        return f"{float(amount):,.0f} $".replace(",", " ")191    except (TypeError, ValueError):192        return ""193194195class UbeeConnector(BaseConnector):196    source_id = "ubee"197    request_delay = 0.5198    # fiches détail (re)lues au plus par synchronisation — le cache BD rend les199    # cycles suivants quasi gratuits. IMMOKA_DETAIL_LIMIT pour un rattrapage.200    detail_limit = 300201202    def _search(self, body: dict) -> list[dict]:203        out, page = [], 0204        while True:205            r = self.post(f"{API}?pageIndex={page}", json=body).json()206            results = r.get("results") or []207            out.extend(results)208            if len(out) >= (r.get("totalCount") or 0) or not results:209                break210            page += 1211            if page > 200:   # garde-fou212                break213        return out214215    def _to_listing(self, it: dict) -> PropertyListing | None:216        lid = str(it.get("id") or "")217        slug = it.get("slugFr") or it.get("slugEn") or ""218        if not lid or not slug:219            return None220        city = (it.get("city") or "").strip()221        url = f"{SITE}/a-vendre/{it.get('citySlug') or ''}/{slug}"222        images = [im["publicUrls"]["default_size"]223                  for im in it.get("images") or []224                  if (im.get("publicUrls") or {}).get("default_size")]225        living = it.get("livingSurfaceInMeters")226        land = it.get("landSurfaceInMeters")227        ptype = _TYPES.get(it.get("inscriptionType") or "",228                           it.get("inscriptionType") or "")229        return PropertyListing(230            source=self.source_id,231            external_id=lid,232            url=url,233            title=f"{ptype} à vendre — {city}" if city else f"{ptype} à vendre",234            address=it.get("address") or "",235            city=city,236            property_type=ptype,237            price=it.get("askPrice"),238            price_label=(f"{it['askPrice']:,.0f} $".replace(",", " ")239                         if it.get("askPrice") else ""),240            bedrooms=it.get("nbBedrooms"),241            bathrooms=it.get("nbBathrooms"),242            powder_rooms=it.get("nbHalfBaths"),243            area_sqft=round(living * M2_TO_SQFT) if living else None,244            lot_sqft=round(land * M2_TO_SQFT) if land else None,245            year_built=it.get("yearBuilt"),246            details={k: it.get(k) for k in247                     ("propertyType", "buildingType", "toBuild", "taxable",248                      "openHouseDetail", "isOnlineSince") if it.get(k)},249            features=[f for f in (250                f"Type de bâtiment : {it['buildingType']}" if it.get("buildingType") else "",251                f"Sous-type : {it['propertyType']}" if it.get("propertyType") else "",252                "Neuf / à construire" if it.get("toBuild") else "",253                "Prix taxable (+tx)" if it.get("taxable") else "",254            ) if f],255            images=images,256            lat=it.get("latitude"),257            lng=it.get("longitude"),258            broker_name="Ubee",259            agency="Ubee Québec",260        )261262    def fetch(self) -> list[PropertyListing]:263        out: dict[str, PropertyListing] = {}264        for flags in ({"isResidential": True}, {"isCommercial": True}):265            body = {"sortBy": "DateDescending", "listingType": "Seller", **flags}266            for it in self._search(body):267                # Québec seulement (l'API est QC par nature, on double-vérifie)268                if (it.get("province") or "QC") != "QC":269                    continue270                lst = self._to_listing(it)271                if lst is not None:272                    out.setdefault(lst.uid, lst)273        listings = list(out.values())274        self._enrich_details(listings)275        return listings276277    # -- fiche détail (description, caractéristiques, taxes, courtier…) --------278    def _enrich_details(self, listings: list[PropertyListing]) -> None:279        budget = int(os.environ.get("IMMOKA_DETAIL_LIMIT", self.detail_limit))280        if budget <= 0:281            return282        from .. import db283        con = db.connect()284        try:285            for lst in listings:286                # clé de changement : prix + photo de couverture (l'URL Cloudinary287                # porte la version) — les fiches modifiées sont re-lues288                cover = lst.images[0] if lst.images else ""289                key = f"v1:{lst.price or ''}:{cover}"290                cached = db.get_cached_detail(con, self.source_id,291                                              lst.external_id, key)292                if cached is None:293                    stale = db.get_stale_detail(con, self.source_id, lst.external_id)294                    if budget <= 0:295                        if stale:296                            self._apply(lst, stale)297                        continue298                    try:299                        raw = self.get(f"{DETAIL_API}/{lst.external_id}").json()300                        cached = self._detail_payload(raw)301                    except Exception:302                        cached = {}303                    if not cached and stale:304                        # échec transitoire : on garde l'ancien payload305                        self._apply(lst, stale)306                        budget -= 1307                        continue308                    db.put_cached_detail(con, self.source_id, lst.external_id,309                                         key, cached)310                    budget -= 1311                self._apply(lst, cached)312        finally:313            con.close()314315    def _apply(self, lst: PropertyListing, payload: dict) -> None:316        if not payload:317            return318        if payload.get("mls") and not lst.mls:319            lst.mls = payload["mls"]320        # agence réelle du courtier inscripteur (remplace « Ubee Québec »)321        if payload.get("agency"):322            lst.agency = payload["agency"]323        apply_detail(lst, payload)324325    def _detail_payload(self, d: dict) -> dict:326        """Payload compact (JSON-sérialisable, mis en cache) depuis la fiche API."""327        if not isinstance(d, dict) or not d.get("id"):328            return {}329        out: dict = {}330        details: dict = {}331        features: list[str] = []332333        # -- description longue (préférence au texte FR) -------------------------334        texts = d.get("descriptionTexts") or []335        desc = next((t.get("plainText") or t.get("text") or ""336                     for t in texts if (t.get("language") or "").lower() == "fr"), "")337        desc = (desc or d.get("plainTextDescription") or d.get("description") or "").strip()338        if desc:339            out["description"] = desc340341        # -- galerie (même famille Cloudinary ; on garde la plus fournie) --------342        images = [im["publicUrls"]["default_size"]343                  for im in d.get("images") or []344                  if (im.get("publicUrls") or {}).get("default_size")]345        if images:346            out["images"] = images347348        # -- identifiants / champs simples ---------------------------------------349        if d.get("mlsNumber"):350            out["mls"] = str(d["mlsNumber"])351            details["Numéro Centris"] = str(d["mlsNumber"])352        if d.get("nbRooms"):353            details["Nombre de pièces"] = d["nbRooms"]354        if d.get("numberOfFloors"):355            details["Nombre d'étages"] = d["numberOfFloors"]356        if d.get("numberOfParkings"):357            details["Stationnement (total)"] = d["numberOfParkings"]358        parks = [f"{_PARKING_FR.get(p.get('parkingKind'), _fr(p.get('parkingKind')).lower())}"359                 f" ({p['count']})"360                 for p in d.get("parkings") or [] if p.get("count")]361        if parks:362            features.append("Stationnement : " + ", ".join(parks))363        land = d.get("landSurfaceInMeters")364        if land:365            out["lot_sqft"] = round(land * M2_TO_SQFT)366        if d.get("yearBuilt"):367            out["year_built"] = d["yearBuilt"]368369        # -- taxes / frais / évaluation municipale --------------------------------370        for e in d.get("expenses") or []:371            label = _EXPENSE_FR.get(e.get("expenseType") or "") or \372                _fr(e.get("expenseType"))373            money = _money(e.get("amount"))374            if not (label and money):375                continue376            per = _FREQ_FR.get(e.get("paymentFrequency") or "", "")377            details[label] = f"{money}/{per}" if per else money378        lot_a = d.get("lotMunicipalAssessment") or 0379        bld_a = d.get("buildingMunicipalAssessment") or 0380        if lot_a or bld_a:381            year = d.get("municipalAssessmentYear")382            details["Évaluation municipale"] = _money(lot_a + bld_a) + \383                (f" ({year})" if year else "")384            if lot_a:385                details["Évaluation municipale (terrain)"] = _money(lot_a)386            if bld_a:387                details["Évaluation municipale (bâtiment)"] = _money(bld_a)388389        # -- conditions de vente / occupation -------------------------------------390        if d.get("legalWarrantyType") in _WARRANTY_FR:391            details["Garantie légale"] = _WARRANTY_FR[d["legalWarrantyType"]]392        if d.get("coOwnershipKind") in ("Divise", "Indivise"):393            details["Copropriété"] = d["coOwnershipKind"]394        if d.get("coOwnershipPercentage"):395            details["Quote-part"] = f"{d['coOwnershipPercentage']} %"396        if d.get("moveInDelay"):397            details["Délai d'occupation"] = str(d["moveInDelay"]).strip()398        if d.get("moveInDate"):399            details["Date d'occupation"] = str(d["moveInDate"])[:10]400        if d.get("topographyType"):401            details["Topographie"] = _fr(d["topographyType"])402        if d.get("waterBodyName"):403            details["Plan d'eau"] = str(d["waterBodyName"])404        if d.get("constructionCharacteristic") == "New":405            features.append("Construction neuve")406        if d.get("shortTermRentalPolicy"):407            details["Location court terme"] = (408                "Non permise" if d["shortTermRentalPolicy"] == "NotAllowed"409                else "Permise")410411        # -- plex / commercial -----------------------------------------------------412        units = [f"{lab} × {d[k]}" for k, lab in _HALF_UNITS if d.get(k)]413        for k, lab in (("commercialCount", "commercial"),414                       ("officeCount", "bureau"), ("industrialCount", "industriel")):415            if d.get(k):416                units.append(f"{lab} × {d[k]}")417        if units:418            details["Unités"] = ", ".join(units)419        if d.get("potentialCommercialRevenue"):420            details["Revenus potentiels"] = _money(d["potentialCommercialRevenue"])421422        # -- caractéristiques énumérées (~30 listes) ------------------------------423        for field, group in _FEATURE_GROUPS:424            vals = [_fr(v) for v in d.get(field) or []]425            vals = [v for v in vals if v]426            if vals:427                features.append(f"{group} : {', '.join(vals)}")428429        # -- liens : fiche Centris originale, visite virtuelle ---------------------430        links = d.get("additionalLinks") or {}431        if links.get("listingLink"):432            details["listing_origin_url"] = links["listingLink"]433        tour = links.get("virtualTourFrLink") or links.get("virtualTourEnLink")434        if tour:435            details["Visite virtuelle"] = tour436437        # -- courtier inscripteur (sinon la fiche reste « Ubee » = proprio direct) --438        pro = d.get("professional") or {}439        name = (pro.get("fullname") or "").strip()440        if name:441            out["broker_name"] = name442            phone = (pro.get("displayPhoneNumber") or "").strip()443            if phone:444                out["broker_phone"] = phone445            agence = (pro.get("agenceName") or pro.get("corporationName") or "").strip()446            if agence:447                out["agency"] = agence448            details["Courtier inscripteur"] = name + \449                (f" ({agence})" if agence else "") + (f" — {phone}" if phone else "")450            if pro.get("displayEmail"):451                details["Courriel du courtier"] = pro["displayEmail"]452        cols = [c.get("fullname") for c in d.get("coListingProfessionals") or []453                if c.get("fullname")]454        if cols:455            details["Courtier collaborateur"] = ", ".join(cols)456457        if features:458            out["features"] = features459        if details:460            out["details"] = details461        return out462