# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immeuble_contact.py : Les Immeubles Contact (Alma / Lac-Saint-Jean-Est # + Chibougamau-Chapais). Agence indépendante, site PHP maison (Solution Globale # Informatique). La liste est rendue SERVEUR sur immeubles_centris.php?page=… # (résidentiel + revenu), encodage ISO-8859-1. Chaque carte porte déjà tout : # n° MLS (Centris), type, adresse, ville, prix (data-cost) et photo Centris. # # Page détail immeubles_centris_fiche.php?no_mls={mls} (ISO-8859-1 aussi) : # galerie complète mesimages/photos_centris/_{mls}_{n}.jpg (spans .item_slide, # dans l'ordre Centris), prix demandé, région + adresse complète (code postal), # courtiers (.texte_rouge + « Cell.: »), description (#description), # évaluation municipale, taxes, dimensions bâtiment/terrain (superficies en # m² étiquetées « (Mètres) »), compte de pièces (Total/Chambre/S. de bain/ # S. d'eau), caractéristiques principales (#caracteristique_principales), # inclus/exclus. Pas de GPS sur la fiche. # # source_id « contact_ag_qc » : l'infixe _ag_ active la dédup Centris (db.refresh_dedup, # sources %_ag_%) — si le n° Centris est déjà porté par une bannière couverte # (RE/MAX, Via Capitale…), la fiche est masquée ; les inscriptions propres de # l'agence restent visibles. Aucun double-comptage. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from . import _detailutil as du from ..normalize import parse_price, parse_area_sqft, parse_lot_sqft from ..schema import PropertyListing SITE = "https://www.lesimmeublescontact.com" PAGES = ["residentiel", "revenu", "commercial"] AGENCY = "Les Immeubles Contact" DETAIL_LIMIT = int(os.environ.get("IMMOKA_CONTACT_DETAIL_LIMIT", os.environ.get("IMMOKA_DETAIL_LIMIT", "100"))) # une carte =
…
_ROW_RE = re.compile(r'
', re.S | re.I) _TYPE_RE = re.compile(r'class="type"[^>]*>.*?([^<]+)', re.S | re.I) _ADDR_RE = re.compile(r'class="address">(.*?)

', re.S | re.I) _MLS_RE = re.compile(r'no_mls=(\d{6,9})', re.I) _PRICE_RE = re.compile(r'class="price">

([^<]+)

', re.S | re.I) # --- page détail ------------------------------------------------------------- _DET_IMG_RE = re.compile(r'href="(mesimages/photos_centris/_\d+_(\d+)\.jpg)"', re.I) _BROKER_RE = re.compile(r'class="texte_rouge">([^<]+)', re.I) _CELL_RE = re.compile(r'Cell\.?\s*:?\s*([\d][\d.\- ]{8,14}\d)', re.I) # « Prix vendu / loué : S/O

RÉGION
ADRESSE, VILLE CODE » _REGION_ADDR_RE = re.compile( r'Prix vendu / loué\s*:[^<]*\s*\s*' r'([^<>]{3,60}?)\s*\s*([^<>]{5,120}?)\s*', re.S) def _div(html: str, div_id: str) -> str: m = re.search(r'
(.*?)
' % re.escape(div_id), html, re.S | re.I) return m.group(1) if m else "" def _text(fragment: str) -> str: return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip() def _sqm(val: str): """« 342 (Mètres) » -> pi² (les colonnes Superficie de la fiche sont en m²).""" v = val.replace("(Mètres)", "m²").replace("(Pieds)", "pi²").strip() return parse_area_sqft(v) def parse_contact_detail(html: str) -> dict: """Fiche lesimmeublescontact.com : galerie complète Centris (ordre d'origine), description, évaluation/taxes, superficies, pièces, caractéristiques, inclus/exclus, courtier(s) + cellulaire. Pas de GPS sur ces pages.""" out: dict = {} # galerie : href des .item_slide, une par photo, déjà dans l'ordre Centris imgs, seen = [], set() for m in _DET_IMG_RE.finditer(html): u = f"{SITE}/{m.group(1)}" if u not in seen: seen.add(u) imgs.append(u) if imgs: out["images"] = imgs details: dict = {} unesc = _html.unescape(html) # prix demandé + adresse complète (région | adresse, ville + code postal) mp = re.search(r'Prix demandé\s*:\s*([^<]+)<', unesc) if mp: label = mp.group(1).strip() if parse_price(label): out["price_label"] = label out["price"] = parse_price(label) ma = _REGION_ADDR_RE.search(unesc) if ma: details["Région"] = _text(ma.group(1)) # « Saguenay/Lac-Saint-Jean » full = _text(ma.group(2)) # « 216 Rue de la Pointe, Dolbeau-Mistassini G8L5M2 » out["address"] = full.split(",")[0].strip() # description desc = _text(re.sub(r'Description', ' ', _div(html, "description"))) if len(desc) > 20: out["description"] = desc[:4000] # courtiers (souvent 2) + cellulaire du premier names = [_html.unescape(n).strip() for n in _BROKER_RE.findall(html)] if names: out["broker_name"] = " et ".join(dict.fromkeys(names[:2])) mc = _CELL_RE.search(html) if mc: out["broker_phone"] = mc.group(1).replace(".", "-").replace(" ", "-") # évaluation municipale (année, bâtiment, terrain, total) ev = _html.unescape(_div(html, "evaluation")) my = re.search(r'Année\s*:\s*(\d{4})', ev) year = f" ({my.group(1)})" if my else "" for lab, key in (("Bâtiment", "Évaluation municipale (bâtiment)"), ("Terrain", "Évaluation municipale (terrain)"), ("Total", "Évaluation municipale")): mv = re.search(lab + r'\s*:\s*([\d\s ]+\$)', ev) if mv: details[key] = re.sub(r"\s+", " ", mv.group(1)).strip() + year # taxes (municipales / scolaires / total, avec année) tx = _html.unescape(_div(html, "taxes")) for lab, key in (("Municipales", "Taxes municipales"), ("Scolaires", "Taxes scolaires")): mv = re.search(lab + r'\s*:\s*]*>([\d\s ]+\$)\s*' r']*>(\d{4})?', tx) if mv: details[key] = (re.sub(r"\s+", " ", mv.group(1)).strip() + (f" ({mv.group(2)})" if mv.group(2) else "")) # dimensions : lignes Bâtiment / Terrain — Superficie(s) en m² « (Mètres) » dims = _html.unescape(_div(html, "dimensions")) _row = (r'\s*([^<]*)\s*([^<]*)\s*' r'([^<]*)\s*([^<]*)') mb = re.search(r'Bâtiment' + _row + r'\s*([^<]*)', dims) if mb: w, d = mb.group(2).strip(), mb.group(3).strip() if w and d: details["Dimensions du bâtiment"] = f"{w} x {d}" hab = _sqm(mb.group(5)) if hab and hab > 200: out["area_sqft"] = hab mt = re.search(r'Terrain' + _row, dims) if mt: lot = _sqm(mt.group(4)) if lot: out["lot_sqft"] = lot # pièces : Total | Chambre | S. de bain | S. d'eau mr = re.search(r"S\.\s*d'eau.*?\s*]*>(\d+)\s*]*>(\d+)" r"\s*]*>(\d+)\s*]*>(\d+)", unesc, re.S) if mr: details["Nombre de pièces"] = mr.group(1) out["bedrooms"] = int(mr.group(2)) or None out["bathrooms"] = int(mr.group(3)) or None out["powder_rooms"] = int(mr.group(4)) or None # caractéristiques principales : « Libellé : | Valeur » (2 colonnes) — # le div contient un div imbriqué (tableautop), on tranche jusqu'à #inclus i = unesc.find('id="caracteristique_principales"') j = unesc.find('id="inclus"', max(i, 0)) car = unesc[i:j] if 0 <= i < j else (unesc[i:] if i >= 0 else "") for lab, val in re.findall(r'sous_titre">([^<:]{2,45}?)\s*:\s*' r']*>([^<]+)', car): val = re.sub(r"\s+", " ", val).strip() if val and val.lower() != "s/o": details[lab.strip()] = val[:200] # inclus -> features ; exclus -> details inc = _text(re.sub(r'Inclus', ' ', _div(html, "inclus"))) feats = [s.strip() for s in re.split(r"[,;]| et ", inc) if 2 <= len(s.strip()) <= 90] if feats: out["features"] = feats[:20] exc = _text(re.sub(r'Exclus', ' ', _div(html, "exclus"))) if exc: details["Exclusions"] = exc[:300] if details: out["details"] = details return out class ImmeubleContactConnector(BaseConnector): source_id = "contact_ag_qc" request_delay = 0.4 def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} for page in PAGES: url = f"{SITE}/immeubles_centris.php?page={page}" try: resp = self.get(url) resp.encoding = "iso-8859-1" html = resp.text except Exception: continue for cost, blk in _ROW_RE.findall(html): lst = self._card(cost, blk, page) if lst: by_id.setdefault(lst.external_id, lst) listings = list(by_id.values()) # fiche détail : galerie complète, description, caractéristiques, # courtier — plafonné/cycle, cache BD (voir _detailutil) du.enrich(self, listings, DETAIL_LIMIT, parse_contact_detail, key="v1", fetch_html=self._fetch_html) return listings def _fetch_html(self, url: str) -> str: """GET avec décodage ISO-8859-1 (le site n'envoie pas de charset HTTP).""" resp = self.get(url) resp.encoding = "iso-8859-1" return resp.text def _card(self, cost: str, blk: str, page: str) -> PropertyListing | None: mm = _MLS_RE.search(blk) if not mm: return None mls = mm.group(1) addr_raw = _ADDR_RE.search(blk) addr_txt = "" city = "" if addr_raw: parts = re.split(r'', addr_raw.group(1)) addr_txt = _html.unescape(re.sub(r'<[^>]+>', ' ', parts[0])).strip().rstrip(",").strip() if len(parts) > 1: city = _html.unescape(re.sub(r'<[^>]+>', ' ', parts[1])).strip() tm = _TYPE_RE.search(blk) prop_type = _html.unescape(tm.group(1)).strip() if tm else "" pm = _PRICE_RE.search(blk) price_label = _html.unescape(pm.group(1)).strip() if pm else "" price = parse_price(price_label) or (float(cost) if cost and cost != "0" else None) return PropertyListing( source=self.source_id, external_id=mls, url=f"{SITE}/immeubles_centris_fiche.php?no_mls={mls}", title=addr_txt or f"{prop_type} à vendre", address=addr_txt, city=city, property_type=prop_type, price=price, price_label=price_label, mls=mls, images=[f"{SITE}/mesimages/photos_centris/_{mls}_1.jpg"], agency=AGENCY, broker_name=AGENCY, )