# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/charisma.py : Les Immeubles Charisma (charisma.ca) — Laval, # Montréal, Montérégie. 3e bannière indépendante du Québec (200+ courtiers). # Site WordPress (plugin « MW Properties » / realestate.marketingwebsites.ca), # liste rendue SERVEUR sur /fr/proprietes/, paginée par ?pages=N (~10/page, # ~84 pages). Chaque carte (box-house) porte : n° Centris (URL + image), prix, # ville, chambres/sdb et photo (property-images/{Centris}/). # # ENRICHISSEMENT DÉTAIL (v2) : la page /fr/properties/{adresse}/{centris}/ est # rendue serveur elle aussi et porte TOUT : galerie fancybox complète (toutes # les photos property-images pleine taille, ordre d'origine), blocs # Description / Inclusions / Exclusions / Plus d'information, box-icons # (sdb, salles d'eau, année, chambres, superficie « 58.9 MC »), tableaux # th.prop-table/td (Bâtiment + Caractéristiques), Détails de pièce, courtier # inscripteur (seller-info : nom + tel), coordonnées GPS (google.maps.LatLng). # Cache BD via _detailutil.enrich (seules les fiches nouvelles sont relues). # # ⚠ DÉDUP : Charisma a fusionné avec L'Expert Immobilier P.M. (déjà couvert) # en 2021 → beaucoup de co-inscriptions. source_id « charisma_ag_qc » : l'infixe # _ag_ (db.refresh_dedup) masque les fiches dont le n° Centris est déjà porté # par une source couverte ; les inscriptions uniques restent visibles. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re import urllib.parse from .base import BaseConnector from . import _detailutil as du from ..normalize import parse_price from ..schema import PropertyListing SITE = "https://charisma.ca" LISTING = SITE + "/fr/proprietes/" AGENCY = "Les Immeubles Charisma" IMG_ROOT = "https://realestate.marketingwebsites.ca/property-images" # fiches détail (re)lues au plus par cycle — le cache BD (key v2) rend les # cycles suivants quasi gratuits (~368 fiches publiées → 2 cycles de rattrapage) DETAIL_LIMIT = int(os.environ.get("IMMOKA_CHARISMA_DETAIL_LIMIT", os.environ.get("IMMOKA_DETAIL_LIMIT", "200"))) _CARD_SPLIT = re.compile(r'
') _HREF_RE = re.compile(r'href="(https://charisma\.ca/fr/properties/[^"]*?/(\d{6,9}))"', re.I) _IMG_RE = re.compile(r'(https://realestate\.marketingwebsites\.ca/property-images/\d+/[^"\']+\.(?:jpg|jpeg|png|webp))', re.I) # prix format nord-américain « $545,000 » (parfois « 545 000 $ ») _PRICE_RE = re.compile(r'class="price">\s*(\$[\d,]+|[\d\s ]+\$)', re.I) _BEDS_RE = re.compile(r'(\d+)\s*Chambres', re.I) _BATHS_RE = re.compile(r'(\d+)\s*Bains', re.I) # « #1605 - 200 Rue André-Prévost
Montréal (Verdun/Île-des-Soeurs) » _LOC_RE = re.compile(r'class="location[^"]*"[^>]*>(.*?)

', re.S | re.I) _TYPE_RE = re.compile(r'class="title">\s*]*>([^<]+)', re.S | re.I) class CharismaConnector(BaseConnector): source_id = "charisma_ag_qc" request_delay = 0.4 max_pages = 120 # garde-fou (~10/page) def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} dry = 0 for page in range(1, self.max_pages + 1): url = LISTING if page == 1 else f"{LISTING}?pages={page}" try: html = self.get(url).text except Exception: break before = len(by_id) for blk in _CARD_SPLIT.split(html)[1:]: blk = blk[:2500] lst = self._card(blk) if lst: by_id.setdefault(lst.external_id, lst) dry = dry + 1 if len(by_id) == before else 0 if dry >= 2: break listings = list(by_id.values()) # fiche détail (server-rendered) : galerie complète, description + # addendum, caractéristiques, pièces, courtier, GPS — cache BD. du.enrich(self, listings, DETAIL_LIMIT, _parse_charisma_detail, key="v2") return listings def _card(self, blk: str) -> PropertyListing | None: hm = _HREF_RE.search(blk) im = _IMG_RE.search(blk) mls = hm.group(2) if hm else (re.search(r'property-images/(\d{6,9})/', blk) or [None, None])[1] if not mls: return None url = hm.group(1) if hm else f"{SITE}/fr/properties/{mls}" # localisation : « {adresse}
{ville} » ; adresse aussi dans l'URL addr, city = "", "" lm = _LOC_RE.search(blk) if lm: loc = re.split(r'', lm.group(1)) addr = _html.unescape(re.sub(r'<[^>]+>', ' ', loc[0])).strip() if len(loc) > 1: city = _html.unescape(re.sub(r'<[^>]+>', ' ', loc[1])).strip() if not addr: am = re.search(r'/fr/properties/([^/]+)/\d{6,9}', url) if am: addr = _html.unescape(urllib.parse.unquote_plus(am.group(1))).strip() pm = _PRICE_RE.search(blk) price_label = pm.group(1).strip() if pm else "" price = None if price_label: price = parse_price(price_label.replace("$", "").replace(",", "")) \ if price_label.startswith("$") else parse_price(price_label) bm = _BEDS_RE.search(blk) sm = _BATHS_RE.search(blk) tm = _TYPE_RE.search(blk) return PropertyListing( source=self.source_id, external_id=mls, url=url, title=addr or "Propriété à vendre", address=addr, city=city, property_type=_html.unescape(tm.group(1)).strip() if tm else "", price=price, price_label=price_label, bedrooms=int(bm.group(1)) if bm else None, bathrooms=int(sm.group(1)) if sm else None, mls=mls, images=[im.group(1)] if im else [f"{IMG_ROOT}/{mls}/{mls}-01.jpg"], agency=AGENCY, broker_name=AGENCY, ) # --- fiche détail (MW Properties, server-rendered) --------------------------- _GAL_RE = re.compile(r'data-fancybox="gallery"\s+href="(https://realestate\.' r'marketingwebsites\.ca/property-images/[^"]+)"', re.I) # box-icons du sommaire : «
Libellé:
Valeur
» _FACT_RE = re.compile(r'text-4 text-color-default">\s*([^<]+?)\s*
\s*' r'
\s*([^<]+?)\s*
', re.S) # sections texte : «
Description

…

» _SEC_RE = re.compile(r'wg-title[^"]*">\s*(Description|Inclusions|Exclusions|' r'Plus d.information)\s*\s*

(.*?)

', re.S | re.I) # tableaux Bâtiment / Caractéristiques : « LV » # (le tableau des pièces a 4 consécutifs → jamais capté par cette forme) _ROW_RE = re.compile(r'\s*]*>\s*([^<]+?)\s*\s*' r']*>\s*(.*?)\s*\s*', re.S) _ROOM_RE = re.compile(r'([^<]*)\s*' r'([^<]*)\s*' r'([^<]*)\s*' r'([^<]*)', re.S) _BROKER_RE = re.compile(r'class="seller-info"(.*?)', re.S) _NAME_RE = re.compile(r'
([^<]+)
') _DESIG_RE = re.compile(r'class="designation[^"]*">([^<]+)<') _TEL_RE = re.compile(r'href="tel:[^"]*"[^>]*>([^<]+)<') _COORD_RE = re.compile(r'LatLng\((-?\d{1,2}\.\d+),\s*(-?\d{2,3}\.\d+)\)') _TOUR_RE = re.compile(r'https?://(?:my\.matterport\.com/show/[^"\'\s<>]+' r'|(?:www\.)?youtube\.com/(?:embed/|watch\?v=)[^"\'\s<>]+' r'|youtu\.be/[^"\'\s<>]+' r'|(?:player\.)?vimeo\.com/(?:video/)?\d+[^"\'\s<>]*)', re.I) _ZEROS = {"", "0", "0x0", "0 x 0", "n/a", "-"} def _text(fragment: str) -> str: """Fragment HTML -> texte propre (les
deviennent des sauts de ligne).""" t = re.sub(r'', '\n', fragment) t = re.sub(r'<[^>]+>', ' ', t) t = _html.unescape(t) t = re.sub(r'[ \t]+', ' ', t) return "\n".join(line.strip() for line in t.splitlines()).strip() def _num(raw: str) -> float | None: v = raw.replace(" ", "").replace(" ", "").replace(" ", "") if v.count(",") == 1 and "." not in v: v = v.replace(",", ".") # décimale française « 58,9 » else: v = v.replace(",", "") # milliers « 1,200 » try: return float(v) or None except ValueError: return None def _sqft(val: str) -> float | None: """« 58.9 MC » / « 1200 PC » -> pi² (unité obligatoire, 0 rejeté).""" m = re.search(r'([\d][\d\s ,.]*)\s*(MC|M2|M²|PC|PI2|PI²)\b', val or "", re.I) if not m: return None v = _num(m.group(1)) if not v: return None return round(v * 10.7639) if m.group(2).upper() in ("MC", "M2", "M²") else round(v) def _parse_charisma_detail(html: str) -> dict: """Fiche MW Properties : galerie, description+addendum, box-icons, tableaux th/td, pièces, courtier, GPS, visite virtuelle.""" out: dict = {} details: dict = {} # galerie fancybox pleine taille, ordre du document imgs = list(dict.fromkeys(_GAL_RE.findall(html))) if len(imgs) > 1: out["images"] = imgs[:80] # sommaire (box-icons) for label, val in _FACT_RE.findall(html): label = _html.unescape(label).strip().rstrip(":").strip() val = _html.unescape(val).strip() if val.lower() in _ZEROS: continue if label == "Salles de bain" and val.isdigit(): out["bathrooms"] = int(val) elif label == "Salles d'eau" and val.isdigit(): out["powder_rooms"] = int(val) elif label == "Chambres à coucher" and val.isdigit(): out["bedrooms"] = int(val) elif label == "Année de construction" and re.fullmatch(r"(1[6-9]|20)\d{2}", val): out["year_built"] = int(val) elif label == "Superficie habitable": a = _sqft(val) if a: out["area_sqft"] = a details["Superficie habitable"] = val elif label == "Taille du lot": t = _sqft(val) if t: out["lot_sqft"] = t details["Superficie du terrain"] = val # Description / Inclusions / Exclusions / Plus d'information (addendum) desc, addendum = "", "" for name, body in _SEC_RE.findall(html): txt = _text(body) if not txt: continue low = name.lower() if low.startswith("description"): desc = txt elif low.startswith("inclusion"): details["Inclusions"] = txt[:600] elif low.startswith("exclusion"): details["Exclusions"] = txt[:600] else: # « Plus d'information » addendum = txt if desc or addendum: out["description"] = "\n\n".join(filter(None, (desc, addendum)))[:6000] # tableaux Bâtiment + Caractéristiques (paires th.prop-table / td) for label, val in _ROW_RE.findall(html): label = _html.unescape(label).strip().rstrip(":").strip() val = _text(val) if not label or val.lower() in _ZEROS: continue if label == "La Taille Du Lot": label = "Superficie du terrain" if label == "Type": label = "Type de bâtiment" details.setdefault(label, val[:300]) # détails de pièce -> features + nombre de pièces rooms = _ROOM_RE.findall(html) if rooms: feats = [] for name, dim, lvl, floor in rooms: name, dim = _html.unescape(name).strip(), _html.unescape(dim).strip() lvl, floor = _html.unescape(lvl).strip(), _html.unescape(floor).strip() line = name + (f" — {dim}" if dim else "") + (f", {lvl}" if lvl else "") if floor: line += f" ({floor})" feats.append(line) out["features"] = feats[:40] details.setdefault("Nombre de pièces", str(len(rooms))) # courtier inscripteur (encadré « Informations ») mb = _BROKER_RE.search(html) if mb: seg = mb.group(1) mn = _NAME_RE.search(seg) if mn: out["broker_name"] = _html.unescape(mn.group(1)).strip() md = _DESIG_RE.search(seg) if md: details.setdefault("Titre du courtier", _html.unescape(md.group(1)).strip()) mt = _TEL_RE.search(seg) if mt: out["broker_phone"] = _html.unescape(mt.group(1)).strip() # coordonnées GPS (carte Google inline) mc = _COORD_RE.search(html) if mc: lat, lng = float(mc.group(1)), float(mc.group(2)) if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0: out["lat"], out["lng"] = lat, lng # visite virtuelle / vidéo (modal #video, matterport, youtube, vimeo) mt = _TOUR_RE.search(html) if mt: details.setdefault("Visite virtuelle / vidéo", mt.group(0)) if details: out["details"] = details return out