# -----------------------------------------------------------------------------
# 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,
)