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/imcha.py : Les Immeubles Charlevoix (imcha.com) — Baie-Saint-Paul,5# La Malbaie, Les Éboulements, Petite-Rivière-Saint-François + Vieux-Québec /6# Côte-de-Beaupré. Seule agence indépendante crédible de Charlevoix. Site7# custom rendu SERVEUR, liste paginée /vente-de-proprietes/page-N. Chaque carte8# (li.estate-single) porte : code interne, type, ville, prix, chambres et9# photo. Le n° Centris est encodé dans le nom de fichier photo10# (/uploads/photos/max/{Centris}-…) → clé de déduplication.11#12# Page détail : description dans <h3 class="title--small">Description</h3>13# + <p>, galerie fancybox /uploads/photos/max/… (pleine résolution, ordre14# d'origine — préfixe = n° Centris), specs à icônes (Chambres / Salles de15# bain / Salle d'eau, ordre libellé/nombre variable), sections16# .estate__info (Type + année, Évaluation municipale, Bâtiment, Terrain17# [façade/profondeur/superficie en mètres], Inclusions, Exclusions en18# <dt>/<dd>). AUCUNE coordonnée GPS ni adresse civique exposée (pas de19# carte sur la fiche) — impossible à géocoder honnêtement. ⚠️ Les réponses20# HTTP n'annoncent pas de charset alors que le HTML est en UTF-8 → forcer21# resp.encoding partout. Enrichissement plafonné + cache (du.enrich).22#23# source_id « imcha_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import html as _html28import os29import re3031from .base import BaseConnector32from . import _detailutil as du33from ..normalize import parse_price34from ..schema import PropertyListing3536SITE = "https://www.imcha.com"37DETAIL_LIMIT = int(os.environ.get("IMMOKA_IMCHA_DETAIL_LIMIT", "150"))38AGENCY = "Les Immeubles Charlevoix"3940_ITEM_SPLIT = re.compile(r'<li class="estate-single"')41_HREF_RE = re.compile(r'href="(/(?:maisons-a-vendre|terrains-et-terres-a-vendre|[a-z-]+-a-vendre)/[a-z0-9_-]+)"', re.I)42_CODE_RE = re.compile(r'data-code="([A-Z0-9]+)"', re.I)43_CENTRIS_RE = re.compile(r'/uploads/photos/[a-z]+/(\d{6,9})-', re.I)44_IMG_RE = re.compile(r'src="(/resize\?src=[^"]+|/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"', re.I)45_TYPE_RE = re.compile(r'estate-single__status">([^<]+)<', re.I)46_CITY_RE = re.compile(r'estate-single__city">([^<]+)<', re.I)47_PRICE_RE = re.compile(r'estate-single__price">([^<]+)<', re.I)48_BEDS_RE = re.compile(r'estate-spec__count">(\d+)<', re.I)4950# --- page détail ---51_DESC_RE = re.compile(r'title--small">Description</h3>\s*(.*?)\s*<div class="estate__info', re.S | re.I)52_P_RE = re.compile(r'<p[^>]*>(.*?)</p>', re.S | re.I)53_FANCY_RE = re.compile(r'class="fancybox"[^>]*href="(/uploads/photos/max/[^"]+)"', re.I)54_SPEC_RE = re.compile(r'<div class="estate__spec">\s*(.*?)</div>\s*</div>', re.S | re.I)55_SPEC_LABEL_RE = re.compile(r'estate-spec__label">\s*([^<]+)', re.I)56_SPEC_COUNT_RE = re.compile(r'estate-spec__count">\s*(\d+)', re.I)57_INFO_RE = re.compile(r'<div class="estate__info">\s*<h3 class="estate-info__title">'58 r'([^<]+)</h3>\s*<dl class="estate-info__list">(.*?)</dl>', re.S | re.I)59_DTDD_RE = re.compile(r'<dt>([^<]*)</dt>\s*(?:<dd>([^<]*)</dd>)?', re.S | re.I)60_TEL_RE = re.compile(r'Tel\.?\s*:\s*((?:1[\s-])?\d{3}[\s-]\d{3}-\d{4})')61_AREA_M2_RE = re.compile(r'([\d\s .,]+?)\s*(m|mètre|pied)', re.I)6263_SQFT_PER_SQM = 10.763964_SPEC_FIELDS = {"chambres": "bedrooms", "chambre": "bedrooms",65 "salles de bain": "bathrooms", "salle de bain": "bathrooms",66 "salle d'eau": "powder_rooms", "salles d'eau": "powder_rooms"}676869class ImchaConnector(BaseConnector):70 source_id = "imcha_ag_qc"71 request_delay = 0.472 max_pages = 307374 def fetch(self) -> list[PropertyListing]:75 by_id: dict[str, PropertyListing] = {}76 dry = 077 for page in range(1, self.max_pages + 1):78 url = f"{SITE}/vente-de-proprietes/page-{page}"79 try:80 resp = self.get(url)81 resp.encoding = "utf-8"82 html = resp.text83 except Exception:84 break85 before = len(by_id)86 for blk in _ITEM_SPLIT.split(html)[1:]:87 blk = blk[:4000]88 lst = self._card(blk)89 if lst:90 by_id.setdefault(lst.external_id, lst)91 dry = dry + 1 if len(by_id) == before else 092 if dry >= 2:93 break94 listings = list(by_id.values())95 du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1",96 fetch_html=self._fetch_utf8)97 return listings9899 def _fetch_utf8(self, url: str) -> str:100 # le serveur n'annonce pas de charset (requests retomberait sur101 # ISO-8859-1) alors que les fiches sont en UTF-8102 resp = self.get(url)103 resp.encoding = "utf-8"104 return resp.text105106 def _card(self, blk: str) -> PropertyListing | None:107 hm = _HREF_RE.search(blk)108 if not hm:109 return None110 cm = _CENTRIS_RE.search(blk)111 code = _CODE_RE.search(blk)112 # n° Centris (photo) = clé de dédup ; sinon code interne (pas de dédup)113 mls = cm.group(1) if cm else ""114 external_id = mls or (code.group(1) if code else None)115 if not external_id:116 return None117118 tm = _TYPE_RE.search(blk)119 cy = _CITY_RE.search(blk)120 pm = _PRICE_RE.search(blk)121 bm = _BEDS_RE.search(blk)122 im = _IMG_RE.search(blk)123 img = im.group(1) if im else ""124 if img.startswith("/"):125 img = SITE + img126 price_label = _html.unescape(pm.group(1)).strip() if pm else ""127 city = _html.unescape(cy.group(1)).strip() if cy else ""128129 return PropertyListing(130 source=self.source_id,131 external_id=external_id,132 url=SITE + hm.group(1),133 title=f"{_html.unescape(tm.group(1)).strip() if tm else 'Propriété'} à vendre — {city}".strip(" —"),134 city=city,135 property_type=_html.unescape(tm.group(1)).strip() if tm else "",136 price=parse_price(price_label),137 price_label=price_label,138 bedrooms=int(bm.group(1)) if bm else None,139 mls=mls,140 images=[img] if img else [],141 agency=AGENCY,142 broker_name=AGENCY,143 )144145146def _txt(fragment: str) -> str:147 t = _html.unescape(re.sub(r"<br\s*/?>", "\n", fragment, flags=re.I))148 t = re.sub(r"<[^>]+>", " ", t).replace("\xa0", " ").replace("×", "x")149 t = re.sub(r"[ \t]+", " ", t)150 return re.sub(r"\n{3,}", "\n\n", t).strip()151152153def _parse_detail(html: str) -> dict:154 """Fiche détail imcha : description, galerie max, specs, sections dt/dd."""155 out: dict = {}156157 dm = _DESC_RE.search(html)158 if dm:159 paras = [_txt(p) for p in _P_RE.findall(dm.group(1))]160 desc = "\n\n".join(p for p in paras if p)161 if desc:162 out["description"] = desc163164 # galerie fancybox pleine résolution ; le préfixe du nom de fichier est le165 # n° Centris de LA fiche → on ne garde que le lot majoritaire (sécurité)166 anchors = _FANCY_RE.findall(html)167 if anchors:168 prefix = anchors[0].rsplit("/", 1)[-1].split("-", 1)[0]169 seen, uniq = set(), []170 for a in anchors:171 if not a.rsplit("/", 1)[-1].startswith(prefix + "-"):172 continue173 u = SITE + a174 if u not in seen:175 seen.add(u)176 uniq.append(u)177 if uniq:178 out["images"] = uniq179180 # specs à icônes (ordre libellé/nombre variable selon la spec)181 for spec in _SPEC_RE.findall(html):182 lm, cm = _SPEC_LABEL_RE.search(spec), _SPEC_COUNT_RE.search(spec)183 if not lm or not cm:184 continue185 field = _SPEC_FIELDS.get(_txt(lm.group(1)).lower())186 m = re.search(r"\d+", cm.group(1))187 if field and m and int(m.group()) > 0:188 out.setdefault(field, int(m.group()))189190 # sections .estate__info (<dt>/<dd>)191 details: dict = {}192 for title, body in _INFO_RE.findall(html):193 title = _txt(title)194 pairs = [(_txt(dt), _txt(dd or "")) for dt, dd in _DTDD_RE.findall(body)]195 if title.lower().startswith(("inclusions", "exclusions")):196 items = [dt for dt, _ in pairs if dt]197 if items:198 details[title.split(" ")[0]] = ", ".join(items)199 continue200 for dt, dd in pairs:201 dt = dt.rstrip(" :")202 if not dt or not dd or len(dd) > 120:203 continue204 if dt == "Année de construction":205 m = re.search(r"\b(1[6-9]\d{2}|20\d{2})\b", dd)206 if m: # « 0 » = inconnue chez imcha207 out["year_built"] = int(m.group(1))208 details[dt] = dd209 elif dt == "Type":210 details["Type de propriété"] = dd211 elif title.startswith("Évaluation municipale"):212 details[f"Évaluation municipale ({dt.lower()})"] = dd213 elif title in ("Terrain", "Bâtiment"):214 if dt == "Superficie":215 details[f"Superficie du {title.lower()}"] = dd216 if title == "Terrain":217 sq = _area_to_sqft(dd)218 if sq:219 out["lot_sqft"] = sq220 else:221 details[f"{title} — {dt.lower()}"] = dd222 else:223 details[dt] = dd224 if details:225 out["details"] = details226227 # téléphone de l'agence (meta description « Tel. : 1 418-435-6221 »)228 tm = _TEL_RE.search(html)229 if tm:230 out["broker_phone"] = tm.group(1).strip()231 return out232233234def _area_to_sqft(val: str) -> float | None:235 """« 7657.10 Mètres carrés » / « 2 400 pieds carrés » -> pi²."""236 m = _AREA_M2_RE.match(val.strip())237 if not m:238 return None239 try:240 n = float(re.sub(r"[^\d.]", "", m.group(1)))241 except ValueError:242 return None243 if m.group(2).lower().startswith(("m", "mètre")):244 n *= _SQFT_PER_SQM245 return round(n, 1) or None246