# -----------------------------------------------------------------------------
# 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'