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%
11.2 KB · 259 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/immeuble_contact.py : Les Immeubles Contact (Alma / Lac-Saint-Jean-Est5#   + Chibougamau-Chapais). Agence indépendante, site PHP maison (Solution Globale6#   Informatique). La liste est rendue SERVEUR sur immeubles_centris.php?page=…7#   (résidentiel + revenu), encodage ISO-8859-1. Chaque carte porte déjà tout :8#   n° MLS (Centris), type, adresse, ville, prix (data-cost) et photo Centris.9#10#   Page détail immeubles_centris_fiche.php?no_mls={mls} (ISO-8859-1 aussi) :11#   galerie complète mesimages/photos_centris/_{mls}_{n}.jpg (spans .item_slide,12#   dans l'ordre Centris), prix demandé, région + adresse complète (code postal),13#   courtiers (.texte_rouge + « Cell.: »), description (#description),14#   évaluation municipale, taxes, dimensions bâtiment/terrain (superficies en15#   m² étiquetées « (Mètres) »), compte de pièces (Total/Chambre/S. de bain/16#   S. d'eau), caractéristiques principales (#caracteristique_principales),17#   inclus/exclus. Pas de GPS sur la fiche.18#19#   source_id « contact_ag_qc » : l'infixe _ag_ active la dédup Centris (db.refresh_dedup,20#   sources %_ag_%) — si le n° Centris est déjà porté par une bannière couverte21#   (RE/MAX, Via Capitale…), la fiche est masquée ; les inscriptions propres de22#   l'agence restent visibles. Aucun double-comptage.23# -----------------------------------------------------------------------------24from __future__ import annotations2526import html as _html27import os28import re2930from .base import BaseConnector31from . import _detailutil as du32from ..normalize import parse_price, parse_area_sqft, parse_lot_sqft33from ..schema import PropertyListing3435SITE = "https://www.lesimmeublescontact.com"36PAGES = ["residentiel", "revenu", "commercial"]37AGENCY = "Les Immeubles Contact"38DETAIL_LIMIT = int(os.environ.get("IMMOKA_CONTACT_DETAIL_LIMIT",39                                  os.environ.get("IMMOKA_DETAIL_LIMIT", "100")))4041# une carte = <article class="immeuble_row" data-cost="NNN"> … </article>42_ROW_RE = re.compile(r'<article class="immeuble_row"\s+data-cost="(\d+)"(.*?)</article>', re.S | re.I)43_TYPE_RE = re.compile(r'class="type"[^>]*>.*?<span>([^<]+)</span>', re.S | re.I)44_ADDR_RE = re.compile(r'class="address">(.*?)</p>', re.S | re.I)45_MLS_RE = re.compile(r'no_mls=(\d{6,9})', re.I)46_PRICE_RE = re.compile(r'class="price"><p>([^<]+)</p>', re.S | re.I)4748# --- page détail -------------------------------------------------------------49_DET_IMG_RE = re.compile(r'href="(mesimages/photos_centris/_\d+_(\d+)\.jpg)"', re.I)50_BROKER_RE = re.compile(r'class="texte_rouge">([^<]+)</span>', re.I)51_CELL_RE = re.compile(r'Cell\.?\s*:?\s*([\d][\d.\- ]{8,14}\d)', re.I)52# « Prix vendu / loué : S/O<br /><br /> RÉGION<br />ADRESSE, VILLE CODE </td> »53_REGION_ADDR_RE = re.compile(54    r'Prix vendu / loué\s*:</span>[^<]*<br\s*/?>\s*<br\s*/?>\s*'55    r'([^<>]{3,60}?)\s*<br\s*/?>\s*([^<>]{5,120}?)\s*</td>', re.S)565758def _div(html: str, div_id: str) -> str:59    m = re.search(r'<div id="%s">(.*?)</div>' % re.escape(div_id), html, re.S | re.I)60    return m.group(1) if m else ""616263def _text(fragment: str) -> str:64    return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()656667def _sqm(val: str):68    """« 342 (Mètres) » -> pi² (les colonnes Superficie de la fiche sont en m²)."""69    v = val.replace("(Mètres)", "m²").replace("(Pieds)", "pi²").strip()70    return parse_area_sqft(v)717273def parse_contact_detail(html: str) -> dict:74    """Fiche lesimmeublescontact.com : galerie complète Centris (ordre d'origine),75    description, évaluation/taxes, superficies, pièces, caractéristiques,76    inclus/exclus, courtier(s) + cellulaire. Pas de GPS sur ces pages."""77    out: dict = {}78    # galerie : href des .item_slide, une par photo, déjà dans l'ordre Centris79    imgs, seen = [], set()80    for m in _DET_IMG_RE.finditer(html):81        u = f"{SITE}/{m.group(1)}"82        if u not in seen:83            seen.add(u)84            imgs.append(u)85    if imgs:86        out["images"] = imgs8788    details: dict = {}89    unesc = _html.unescape(html)90    # prix demandé + adresse complète (région | adresse, ville + code postal)91    mp = re.search(r'Prix demandé\s*:</span>\s*([^<]+)<', unesc)92    if mp:93        label = mp.group(1).strip()94        if parse_price(label):95            out["price_label"] = label96            out["price"] = parse_price(label)97    ma = _REGION_ADDR_RE.search(unesc)98    if ma:99        details["Région"] = _text(ma.group(1))       # « Saguenay/Lac-Saint-Jean »100        full = _text(ma.group(2))                    # « 216 Rue de la Pointe, Dolbeau-Mistassini G8L5M2 »101        out["address"] = full.split(",")[0].strip()102103    # description104    desc = _text(re.sub(r'<span class="moyen_titre">Description</span>', ' ',105                        _div(html, "description")))106    if len(desc) > 20:107        out["description"] = desc[:4000]108109    # courtiers (souvent 2) + cellulaire du premier110    names = [_html.unescape(n).strip() for n in _BROKER_RE.findall(html)]111    if names:112        out["broker_name"] = " et ".join(dict.fromkeys(names[:2]))113    mc = _CELL_RE.search(html)114    if mc:115        out["broker_phone"] = mc.group(1).replace(".", "-").replace(" ", "-")116117    # évaluation municipale (année, bâtiment, terrain, total)118    ev = _html.unescape(_div(html, "evaluation"))119    my = re.search(r'Année</span>\s*:\s*(\d{4})', ev)120    year = f" ({my.group(1)})" if my else ""121    for lab, key in (("Bâtiment", "Évaluation municipale (bâtiment)"),122                     ("Terrain", "Évaluation municipale (terrain)"),123                     ("Total", "Évaluation municipale")):124        mv = re.search(lab + r'</span>\s*:\s*([\d\s  ]+\$)', ev)125        if mv:126            details[key] = re.sub(r"\s+", " ", mv.group(1)).strip() + year127128    # taxes (municipales / scolaires / total, avec année)129    tx = _html.unescape(_div(html, "taxes"))130    for lab, key in (("Municipales", "Taxes municipales"),131                     ("Scolaires", "Taxes scolaires")):132        mv = re.search(lab + r'\s*:</span></td>\s*<td[^>]*>([\d\s  ]+\$)</td>\s*'133                       r'<td[^>]*>(\d{4})?', tx)134        if mv:135            details[key] = (re.sub(r"\s+", " ", mv.group(1)).strip()136                            + (f" ({mv.group(2)})" if mv.group(2) else ""))137138    # dimensions : lignes Bâtiment / Terrain — Superficie(s) en m² « (Mètres) »139    dims = _html.unescape(_div(html, "dimensions"))140    _row = (r'</span></td>\s*<td>([^<]*)</td>\s*<td>([^<]*)</td>\s*'141            r'<td>([^<]*)</td>\s*<td>([^<]*)</td>')142    mb = re.search(r'Bâtiment' + _row + r'\s*<td>([^<]*)</td>', dims)143    if mb:144        w, d = mb.group(2).strip(), mb.group(3).strip()145        if w and d:146            details["Dimensions du bâtiment"] = f"{w} x {d}"147        hab = _sqm(mb.group(5))148        if hab and hab > 200:149            out["area_sqft"] = hab150    mt = re.search(r'Terrain' + _row, dims)151    if mt:152        lot = _sqm(mt.group(4))153        if lot:154            out["lot_sqft"] = lot155156    # pièces : Total | Chambre | S. de bain | S. d'eau157    mr = re.search(r"S\.\s*d'eau.*?<tr>\s*<td[^>]*>(\d+)</td>\s*<td[^>]*>(\d+)</td>"158                   r"\s*<td[^>]*>(\d+)</td>\s*<td[^>]*>(\d+)</td>", unesc, re.S)159    if mr:160        details["Nombre de pièces"] = mr.group(1)161        out["bedrooms"] = int(mr.group(2)) or None162        out["bathrooms"] = int(mr.group(3)) or None163        out["powder_rooms"] = int(mr.group(4)) or None164165    # caractéristiques principales : « Libellé : | Valeur » (2 colonnes) —166    # le div contient un div imbriqué (tableautop), on tranche jusqu'à #inclus167    i = unesc.find('id="caracteristique_principales"')168    j = unesc.find('id="inclus"', max(i, 0))169    car = unesc[i:j] if 0 <= i < j else (unesc[i:] if i >= 0 else "")170    for lab, val in re.findall(r'sous_titre">([^<:]{2,45}?)\s*:</span></td>\s*'171                               r'<td[^>]*>([^<]+)</td>', car):172        val = re.sub(r"\s+", " ", val).strip()173        if val and val.lower() != "s/o":174            details[lab.strip()] = val[:200]175176    # inclus -> features ; exclus -> details177    inc = _text(re.sub(r'<span class="moyen_titre">Inclus</span>', ' ',178                       _div(html, "inclus")))179    feats = [s.strip() for s in re.split(r"[,;]| et ", inc) if 2 <= len(s.strip()) <= 90]180    if feats:181        out["features"] = feats[:20]182    exc = _text(re.sub(r'<span class="moyen_titre">Exclus</span>', ' ',183                       _div(html, "exclus")))184    if exc:185        details["Exclusions"] = exc[:300]186187    if details:188        out["details"] = details189    return out190191192class ImmeubleContactConnector(BaseConnector):193    source_id = "contact_ag_qc"194    request_delay = 0.4195196    def fetch(self) -> list[PropertyListing]:197        by_id: dict[str, PropertyListing] = {}198        for page in PAGES:199            url = f"{SITE}/immeubles_centris.php?page={page}"200            try:201                resp = self.get(url)202                resp.encoding = "iso-8859-1"203                html = resp.text204            except Exception:205                continue206            for cost, blk in _ROW_RE.findall(html):207                lst = self._card(cost, blk, page)208                if lst:209                    by_id.setdefault(lst.external_id, lst)210        listings = list(by_id.values())211        # fiche détail : galerie complète, description, caractéristiques,212        # courtier — plafonné/cycle, cache BD (voir _detailutil)213        du.enrich(self, listings, DETAIL_LIMIT, parse_contact_detail,214                  key="v1", fetch_html=self._fetch_html)215        return listings216217    def _fetch_html(self, url: str) -> str:218        """GET avec décodage ISO-8859-1 (le site n'envoie pas de charset HTTP)."""219        resp = self.get(url)220        resp.encoding = "iso-8859-1"221        return resp.text222223    def _card(self, cost: str, blk: str, page: str) -> PropertyListing | None:224        mm = _MLS_RE.search(blk)225        if not mm:226            return None227        mls = mm.group(1)228229        addr_raw = _ADDR_RE.search(blk)230        addr_txt = ""231        city = ""232        if addr_raw:233            parts = re.split(r'<br\s*/?>', addr_raw.group(1))234            addr_txt = _html.unescape(re.sub(r'<[^>]+>', ' ', parts[0])).strip().rstrip(",").strip()235            if len(parts) > 1:236                city = _html.unescape(re.sub(r'<[^>]+>', ' ', parts[1])).strip()237238        tm = _TYPE_RE.search(blk)239        prop_type = _html.unescape(tm.group(1)).strip() if tm else ""240        pm = _PRICE_RE.search(blk)241        price_label = _html.unescape(pm.group(1)).strip() if pm else ""242        price = parse_price(price_label) or (float(cost) if cost and cost != "0" else None)243244        return PropertyListing(245            source=self.source_id,246            external_id=mls,247            url=f"{SITE}/immeubles_centris_fiche.php?no_mls={mls}",248            title=addr_txt or f"{prop_type} à vendre",249            address=addr_txt,250            city=city,251            property_type=prop_type,252            price=price,253            price_label=price_label,254            mls=mls,255            images=[f"{SITE}/mesimages/photos_centris/_{mls}_1.jpg"],256            agency=AGENCY,257            broker_name=AGENCY,258        )259