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/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