# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/duquesimms.py : Duque Simms (duquesimms.com) — agence indépendante # de Montréal (NDG, Côte-des-Neiges, Ouest-de-l'Île), ~25 inscriptions. # # Site WordPress/Elementor (plateforme proprius/e-mmobilier, photos sur # cdn.proprius.e-mmobilier.ca). Découverte par property-sitemap.xml : URLs # fr /property/{n° Centris}/ (les /en/ sont des doublons de langue). # # TOUT vient de la fiche détail (du.enrich) : JSON-LD RealEstateListing # (description, contentLocation adresse complète + GPS, offers[].price avec # businessFunction Sell, keywords = type, seller = courtier nom/téléphone) # + blocs HTML property-characteristic (libellé/valeurs), property-expense # (taxes, énergie), property-evaluation, compteurs chambres/salles de bain # (icônes bedroom.svg/bathroom.svg suivies du nombre), galerie swiper # cdn …-pi.jpg (les -c.jpg sont des portraits). Les locations (prix de # loyer) sont écartées par le seuil de prix vente. # # Agence première main (pas d'infixe _ag_) : ses fiches battent les copies # portails au même n° Centris dans la dédup. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from . import _detailutil as du from ..schema import PropertyListing SITE = "https://duquesimms.com" SITEMAP = SITE + "/property-sitemap.xml" AGENCY = "Duque Simms" DETAIL_LIMIT = int(os.environ.get("IMMOKA_DUQUESIMMS_DETAIL_LIMIT", "80")) MIN_SALE_PRICE = 20000 # sous ce prix : loyer mensuel (location) → écarté _LOC_RE = re.compile(r"\s*(https://duquesimms\.com/property/(\d{6,10})/?)\s*") _ADDR_RE = re.compile(r"^(.*?),\s*(.+?)\s*([A-Z]\d[A-Z]\s?\d[A-Z]\d)?$") _CHAR_RE = re.compile(r'property-characteristic-type">(.*?)(.*?)', re.S) _SUBVAL_RE = re.compile(r'property-subcharacteristic-value">(.*?)', re.S) _EXP_RE = re.compile(r'property-expense-label">(.*?).*?property-money-amount">' r'([\d\s ,]+)', re.S) _EVAL_RE = re.compile(r'property-evaluation-label">(.*?).*?property-money-amount">' r'([\d\s ,]+)', re.S) _BEDS_RE = re.compile(r'bedroom\.svg.{0,900}?elementor-widget-container">\s*(\d+)\s*<', re.S) _BATHS_RE = re.compile(r'bathroom\.svg.{0,900}?elementor-widget-container">\s*(\d+)\s*<', re.S) _IMG_RE = re.compile(r'https://cdn\.proprius\.e-mmobilier\.ca/([A-Z0-9]+-pi)\.jpe?g', re.I) class DuqueSimmsConnector(BaseConnector): source_id = "duquesimms" request_delay = 0.4 def fetch(self) -> list[PropertyListing]: # SiteGround sert un challenge sgcaptcha en HTTP 202 (sans exception) : # get_resilient escalade (Oxylabs → Scrapfly ASP → Bright Data) xml = self.get_resilient(SITEMAP).text by_id: dict[str, PropertyListing] = {} for url, mls in _LOC_RE.findall(xml): by_id.setdefault(mls, PropertyListing( source=self.source_id, external_id=mls, url=url if url.endswith("/") else url + "/", title="", mls=mls, agency=AGENCY, broker_name=AGENCY, )) listings = list(by_id.values()) du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1", fetch_html=lambda u: self.get_resilient(u).text) out = [] for lst in listings: if not lst.price or lst.price < MIN_SALE_PRICE: continue # location (loyer) ou fiche vide if not lst.title: lst.title = f"{lst.property_type or 'Propriété'} à vendre — " \ f"{lst.address or lst.city}".strip(" —") if lst.price and not lst.price_label: lst.price_label = f"{int(lst.price):,} $".replace(",", " ") out.append(lst) return out def _txt(s: str) -> str: return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip() def _parse_detail(html: str) -> dict: node = None for n in du.ld_nodes(html): if n.get("@type") == "RealEstateListing": node = n break if node is None: return {} out: dict = {} details: dict = {} features: list[str] = [] desc = _html.unescape(str(node.get("description") or "")).strip() if desc: out["description"] = desc kw = node.get("keywords") or [] if isinstance(kw, str): kw = [kw] if kw: out["property_type"] = str(kw[0]).strip() details["Type de propriété"] = out["property_type"] if len(kw) > 1: details["Catégorie"] = str(kw[1]).strip() loc = node.get("contentLocation") or {} if isinstance(loc, dict): full = _html.unescape(str(loc.get("address") or "")).strip() m = _ADDR_RE.match(full) if m: out["address"] = m.group(1).strip() out["city"] = m.group(2).strip() if m.group(3): details["Code postal"] = m.group(3) elif full: out["address"] = full try: out["lat"], out["lng"] = float(loc["latitude"]), float(loc["longitude"]) except (KeyError, TypeError, ValueError): pass offers = node.get("offers") or [] if isinstance(offers, dict): offers = [offers] sellers: list[str] = [] for off in offers: if not isinstance(off, dict): continue if "Sell" not in str(off.get("businessFunction") or "Sell"): continue if out.get("price") is None and off.get("price"): try: out["price"] = float(str(off["price"]).replace(" ", "")) except ValueError: pass seller = off.get("seller") or {} if isinstance(seller, dict) and seller.get("name"): name = str(seller["name"]).strip() if name not in sellers: sellers.append(name) if "broker_phone" not in out: tel = str(seller.get("telephone") or "").strip() if re.fullmatch(r"\d{10}", tel): out["broker_phone"] = f"{tel[:3]} {tel[3:6]}-{tel[6:]}" if sellers: out["broker_name"] = " et ".join(sellers[:2]) bm, tm = _BEDS_RE.search(html), _BATHS_RE.search(html) if bm: out["bedrooms"] = int(bm.group(1)) if tm: out["bathrooms"] = int(tm.group(1)) for label, blob in _CHAR_RE.findall(html): label = _txt(label) vals = [_txt(v) for v in _SUBVAL_RE.findall(blob)] vals = [v for v in vals if v] if label and vals: details[label] = ", ".join(vals) features.append(f"{label} : {details[label]}" if len(details[label]) < 40 else label) for rx in (_EXP_RE, _EVAL_RE): for label, amount in rx.findall(html): label, amount = _txt(label), _txt(amount).replace(" ", " ") if label and amount: details[label] = f"{amount} $" imgs, seen = [], set() for base in _IMG_RE.findall(html): if base not in seen: seen.add(base) imgs.append(f"https://cdn.proprius.e-mmobilier.ca/{base}.jpg") if imgs: out["images"] = imgs if details: out["details"] = details if features: out["features"] = features return out