Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/charisma.py : Les Immeubles Charisma (charisma.ca) — Laval,5# Montréal, Montérégie. 3e bannière indépendante du Québec (200+ courtiers).6# Site WordPress (plugin « MW Properties » / realestate.marketingwebsites.ca),7# liste rendue SERVEUR sur /fr/proprietes/, paginée par ?pages=N (~10/page,8# ~84 pages). Chaque carte (box-house) porte : n° Centris (URL + image), prix,9# ville, chambres/sdb et photo (property-images/{Centris}/).10#11# ENRICHISSEMENT DÉTAIL (v2) : la page /fr/properties/{adresse}/{centris}/ est12# rendue serveur elle aussi et porte TOUT : galerie fancybox complète (toutes13# les photos property-images pleine taille, ordre d'origine), blocs14# Description / Inclusions / Exclusions / Plus d'information, box-icons15# (sdb, salles d'eau, année, chambres, superficie « 58.9 MC »), tableaux16# th.prop-table/td (Bâtiment + Caractéristiques), Détails de pièce, courtier17# inscripteur (seller-info : nom + tel), coordonnées GPS (google.maps.LatLng).18# Cache BD via _detailutil.enrich (seules les fiches nouvelles sont relues).19#20# ⚠ DÉDUP : Charisma a fusionné avec L'Expert Immobilier P.M. (déjà couvert)21# en 2021 → beaucoup de co-inscriptions. source_id « charisma_ag_qc » : l'infixe22# _ag_ (db.refresh_dedup) masque les fiches dont le n° Centris est déjà porté23# par une source couverte ; les inscriptions uniques restent visibles.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import html as _html28import os29import re30import urllib.parse3132from .base import BaseConnector33from . import _detailutil as du34from ..normalize import parse_price35from ..schema import PropertyListing3637SITE = "https://charisma.ca"38LISTING = SITE + "/fr/proprietes/"39AGENCY = "Les Immeubles Charisma"40IMG_ROOT = "https://realestate.marketingwebsites.ca/property-images"41# fiches détail (re)lues au plus par cycle — le cache BD (key v2) rend les42# cycles suivants quasi gratuits (~368 fiches publiées → 2 cycles de rattrapage)43DETAIL_LIMIT = int(os.environ.get("IMMOKA_CHARISMA_DETAIL_LIMIT",44 os.environ.get("IMMOKA_DETAIL_LIMIT", "200")))4546_CARD_SPLIT = re.compile(r'<div class="box-house[^"]*">')47_HREF_RE = re.compile(r'href="(https://charisma\.ca/fr/properties/[^"]*?/(\d{6,9}))"', re.I)48_IMG_RE = re.compile(r'(https://realestate\.marketingwebsites\.ca/property-images/\d+/[^"\']+\.(?:jpg|jpeg|png|webp))', re.I)49# prix format nord-américain « $545,000 » (parfois « 545 000 $ »)50_PRICE_RE = re.compile(r'class="price">\s*(\$[\d,]+|[\d\s ]+\$)', re.I)51_BEDS_RE = re.compile(r'<span>(\d+)</span>\s*Chambres', re.I)52_BATHS_RE = re.compile(r'<span>(\d+)</span>\s*Bains', re.I)53# « #1605 - 200 Rue André-Prévost<br/> Montréal (Verdun/Île-des-Soeurs) »54_LOC_RE = re.compile(r'class="location[^"]*"[^>]*>(.*?)</p>', re.S | re.I)55_TYPE_RE = re.compile(r'class="title">\s*<a[^>]*>([^<]+)</a>', re.S | re.I)565758class CharismaConnector(BaseConnector):59 source_id = "charisma_ag_qc"60 request_delay = 0.461 max_pages = 120 # garde-fou (~10/page)6263 def fetch(self) -> list[PropertyListing]:64 by_id: dict[str, PropertyListing] = {}65 dry = 066 for page in range(1, self.max_pages + 1):67 url = LISTING if page == 1 else f"{LISTING}?pages={page}"68 try:69 html = self.get(url).text70 except Exception:71 break72 before = len(by_id)73 for blk in _CARD_SPLIT.split(html)[1:]:74 blk = blk[:2500]75 lst = self._card(blk)76 if lst:77 by_id.setdefault(lst.external_id, lst)78 dry = dry + 1 if len(by_id) == before else 079 if dry >= 2:80 break81 listings = list(by_id.values())82 # fiche détail (server-rendered) : galerie complète, description +83 # addendum, caractéristiques, pièces, courtier, GPS — cache BD.84 du.enrich(self, listings, DETAIL_LIMIT, _parse_charisma_detail, key="v2")85 return listings8687 def _card(self, blk: str) -> PropertyListing | None:88 hm = _HREF_RE.search(blk)89 im = _IMG_RE.search(blk)90 mls = hm.group(2) if hm else (re.search(r'property-images/(\d{6,9})/', blk) or [None, None])[1]91 if not mls:92 return None93 url = hm.group(1) if hm else f"{SITE}/fr/properties/{mls}"9495 # localisation : « {adresse}<br/> {ville} » ; adresse aussi dans l'URL96 addr, city = "", ""97 lm = _LOC_RE.search(blk)98 if lm:99 loc = re.split(r'<br\s*/?>', lm.group(1))100 addr = _html.unescape(re.sub(r'<[^>]+>', ' ', loc[0])).strip()101 if len(loc) > 1:102 city = _html.unescape(re.sub(r'<[^>]+>', ' ', loc[1])).strip()103 if not addr:104 am = re.search(r'/fr/properties/([^/]+)/\d{6,9}', url)105 if am:106 addr = _html.unescape(urllib.parse.unquote_plus(am.group(1))).strip()107108 pm = _PRICE_RE.search(blk)109 price_label = pm.group(1).strip() if pm else ""110 price = None111 if price_label:112 price = parse_price(price_label.replace("$", "").replace(",", "")) \113 if price_label.startswith("$") else parse_price(price_label)114 bm = _BEDS_RE.search(blk)115 sm = _BATHS_RE.search(blk)116 tm = _TYPE_RE.search(blk)117118 return PropertyListing(119 source=self.source_id,120 external_id=mls,121 url=url,122 title=addr or "Propriété à vendre",123 address=addr,124 city=city,125 property_type=_html.unescape(tm.group(1)).strip() if tm else "",126 price=price,127 price_label=price_label,128 bedrooms=int(bm.group(1)) if bm else None,129 bathrooms=int(sm.group(1)) if sm else None,130 mls=mls,131 images=[im.group(1)] if im else [f"{IMG_ROOT}/{mls}/{mls}-01.jpg"],132 agency=AGENCY,133 broker_name=AGENCY,134 )135136137# --- fiche détail (MW Properties, server-rendered) ---------------------------138_GAL_RE = re.compile(r'data-fancybox="gallery"\s+href="(https://realestate\.'139 r'marketingwebsites\.ca/property-images/[^"]+)"', re.I)140# box-icons du sommaire : « <div class=text-4…>Libellé:</div><div class=text-1…>Valeur</div> »141_FACT_RE = re.compile(r'text-4 text-color-default">\s*([^<]+?)\s*</div>\s*'142 r'<div class="text-1 text-color-heading">\s*([^<]+?)\s*</div>', re.S)143# sections texte : « <div class="wg-title …">Description</div> <p>…</p> »144_SEC_RE = re.compile(r'wg-title[^"]*">\s*(Description|Inclusions|Exclusions|'145 r'Plus d.information)\s*</div>\s*<p>(.*?)</p>', re.S | re.I)146# tableaux Bâtiment / Caractéristiques : « <tr><th class=prop-table>L</th><td>V</td></tr> »147# (le tableau des pièces a 4 <th> consécutifs → jamais capté par cette forme)148_ROW_RE = re.compile(r'<tr>\s*<th class="prop-table"[^>]*>\s*([^<]+?)\s*</th>\s*'149 r'<td[^>]*>\s*(.*?)\s*</td>\s*</tr>', re.S)150_ROOM_RE = re.compile(r'<td data-title="Room">([^<]*)</td>\s*'151 r'<td data-title="Dimensions">([^<]*)</td>\s*'152 r'<td data-title="Level">([^<]*)</td>\s*'153 r'<td data-title="Flooring">([^<]*)</td>', re.S)154_BROKER_RE = re.compile(r'class="seller-info"(.*?)</ul>', re.S)155_NAME_RE = re.compile(r'<h6 class="name">([^<]+)</h6>')156_DESIG_RE = re.compile(r'class="designation[^"]*">([^<]+)<')157_TEL_RE = re.compile(r'href="tel:[^"]*"[^>]*>([^<]+)<')158_COORD_RE = re.compile(r'LatLng\((-?\d{1,2}\.\d+),\s*(-?\d{2,3}\.\d+)\)')159_TOUR_RE = re.compile(r'https?://(?:my\.matterport\.com/show/[^"\'\s<>]+'160 r'|(?:www\.)?youtube\.com/(?:embed/|watch\?v=)[^"\'\s<>]+'161 r'|youtu\.be/[^"\'\s<>]+'162 r'|(?:player\.)?vimeo\.com/(?:video/)?\d+[^"\'\s<>]*)', re.I)163_ZEROS = {"", "0", "0x0", "0 x 0", "n/a", "-"}164165166def _text(fragment: str) -> str:167 """Fragment HTML -> texte propre (les <br> deviennent des sauts de ligne)."""168 t = re.sub(r'<br\s*/?>', '\n', fragment)169 t = re.sub(r'<[^>]+>', ' ', t)170 t = _html.unescape(t)171 t = re.sub(r'[ \t]+', ' ', t)172 return "\n".join(line.strip() for line in t.splitlines()).strip()173174175def _num(raw: str) -> float | None:176 v = raw.replace(" ", "").replace(" ", "").replace(" ", "")177 if v.count(",") == 1 and "." not in v:178 v = v.replace(",", ".") # décimale française « 58,9 »179 else:180 v = v.replace(",", "") # milliers « 1,200 »181 try:182 return float(v) or None183 except ValueError:184 return None185186187def _sqft(val: str) -> float | None:188 """« 58.9 MC » / « 1200 PC » -> pi² (unité obligatoire, 0 rejeté)."""189 m = re.search(r'([\d][\d\s ,.]*)\s*(MC|M2|M²|PC|PI2|PI²)\b', val or "", re.I)190 if not m:191 return None192 v = _num(m.group(1))193 if not v:194 return None195 return round(v * 10.7639) if m.group(2).upper() in ("MC", "M2", "M²") else round(v)196197198def _parse_charisma_detail(html: str) -> dict:199 """Fiche MW Properties : galerie, description+addendum, box-icons,200 tableaux th/td, pièces, courtier, GPS, visite virtuelle."""201 out: dict = {}202 details: dict = {}203204 # galerie fancybox pleine taille, ordre du document205 imgs = list(dict.fromkeys(_GAL_RE.findall(html)))206 if len(imgs) > 1:207 out["images"] = imgs[:80]208209 # sommaire (box-icons)210 for label, val in _FACT_RE.findall(html):211 label = _html.unescape(label).strip().rstrip(":").strip()212 val = _html.unescape(val).strip()213 if val.lower() in _ZEROS:214 continue215 if label == "Salles de bain" and val.isdigit():216 out["bathrooms"] = int(val)217 elif label == "Salles d'eau" and val.isdigit():218 out["powder_rooms"] = int(val)219 elif label == "Chambres à coucher" and val.isdigit():220 out["bedrooms"] = int(val)221 elif label == "Année de construction" and re.fullmatch(r"(1[6-9]|20)\d{2}", val):222 out["year_built"] = int(val)223 elif label == "Superficie habitable":224 a = _sqft(val)225 if a:226 out["area_sqft"] = a227 details["Superficie habitable"] = val228 elif label == "Taille du lot":229 t = _sqft(val)230 if t:231 out["lot_sqft"] = t232 details["Superficie du terrain"] = val233234 # Description / Inclusions / Exclusions / Plus d'information (addendum)235 desc, addendum = "", ""236 for name, body in _SEC_RE.findall(html):237 txt = _text(body)238 if not txt:239 continue240 low = name.lower()241 if low.startswith("description"):242 desc = txt243 elif low.startswith("inclusion"):244 details["Inclusions"] = txt[:600]245 elif low.startswith("exclusion"):246 details["Exclusions"] = txt[:600]247 else: # « Plus d'information »248 addendum = txt249 if desc or addendum:250 out["description"] = "\n\n".join(filter(None, (desc, addendum)))[:6000]251252 # tableaux Bâtiment + Caractéristiques (paires th.prop-table / td)253 for label, val in _ROW_RE.findall(html):254 label = _html.unescape(label).strip().rstrip(":").strip()255 val = _text(val)256 if not label or val.lower() in _ZEROS:257 continue258 if label == "La Taille Du Lot":259 label = "Superficie du terrain"260 if label == "Type":261 label = "Type de bâtiment"262 details.setdefault(label, val[:300])263264 # détails de pièce -> features + nombre de pièces265 rooms = _ROOM_RE.findall(html)266 if rooms:267 feats = []268 for name, dim, lvl, floor in rooms:269 name, dim = _html.unescape(name).strip(), _html.unescape(dim).strip()270 lvl, floor = _html.unescape(lvl).strip(), _html.unescape(floor).strip()271 line = name + (f" — {dim}" if dim else "") + (f", {lvl}" if lvl else "")272 if floor:273 line += f" ({floor})"274 feats.append(line)275 out["features"] = feats[:40]276 details.setdefault("Nombre de pièces", str(len(rooms)))277278 # courtier inscripteur (encadré « Informations »)279 mb = _BROKER_RE.search(html)280 if mb:281 seg = mb.group(1)282 mn = _NAME_RE.search(seg)283 if mn:284 out["broker_name"] = _html.unescape(mn.group(1)).strip()285 md = _DESIG_RE.search(seg)286 if md:287 details.setdefault("Titre du courtier", _html.unescape(md.group(1)).strip())288 mt = _TEL_RE.search(seg)289 if mt:290 out["broker_phone"] = _html.unescape(mt.group(1)).strip()291292 # coordonnées GPS (carte Google inline)293 mc = _COORD_RE.search(html)294 if mc:295 lat, lng = float(mc.group(1)), float(mc.group(2))296 if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:297 out["lat"], out["lng"] = lat, lng298299 # visite virtuelle / vidéo (modal #video, matterport, youtube, vimeo)300 mt = _TOUR_RE.search(html)301 if mt:302 details.setdefault("Visite virtuelle / vidéo", mt.group(0))303304 if details:305 out["details"] = details306 return out307