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%
13.3 KB · 307 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/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