# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/imcha.py : Les Immeubles Charlevoix (imcha.com) — Baie-Saint-Paul, # La Malbaie, Les Éboulements, Petite-Rivière-Saint-François + Vieux-Québec / # Côte-de-Beaupré. Seule agence indépendante crédible de Charlevoix. Site # custom rendu SERVEUR, liste paginée /vente-de-proprietes/page-N. Chaque carte # (li.estate-single) porte : code interne, type, ville, prix, chambres et # photo. Le n° Centris est encodé dans le nom de fichier photo # (/uploads/photos/max/{Centris}-…) → clé de déduplication. # # Page détail : description dans

Description

# +

, galerie fancybox /uploads/photos/max/… (pleine résolution, ordre # d'origine — préfixe = n° Centris), specs à icônes (Chambres / Salles de # bain / Salle d'eau, ordre libellé/nombre variable), sections # .estate__info (Type + année, Évaluation municipale, Bâtiment, Terrain # [façade/profondeur/superficie en mètres], Inclusions, Exclusions en #

/
). AUCUNE coordonnée GPS ni adresse civique exposée (pas de # carte sur la fiche) — impossible à géocoder honnêtement. ⚠️ Les réponses # HTTP n'annoncent pas de charset alors que le HTML est en UTF-8 → forcer # resp.encoding partout. Enrichissement plafonné + cache (du.enrich). # # source_id « imcha_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup). # ----------------------------------------------------------------------------- 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 from ..schema import PropertyListing SITE = "https://www.imcha.com" DETAIL_LIMIT = int(os.environ.get("IMMOKA_IMCHA_DETAIL_LIMIT", "150")) AGENCY = "Les Immeubles Charlevoix" _ITEM_SPLIT = re.compile(r'
  • ([^<]+)<', re.I) _CITY_RE = re.compile(r'estate-single__city">([^<]+)<', re.I) _PRICE_RE = re.compile(r'estate-single__price">([^<]+)<', re.I) _BEDS_RE = re.compile(r'estate-spec__count">(\d+)<', re.I) # --- page détail --- _DESC_RE = re.compile(r'title--small">Description\s*(.*?)\s*
    ]*href="(/uploads/photos/max/[^"]+)"', re.I) _SPEC_RE = re.compile(r'
    \s*(.*?)
    \s*
    ', re.S | re.I) _SPEC_LABEL_RE = re.compile(r'estate-spec__label">\s*([^<]+)', re.I) _SPEC_COUNT_RE = re.compile(r'estate-spec__count">\s*(\d+)', re.I) _INFO_RE = re.compile(r'
    \s*

    ' r'([^<]+)

    \s*
    (.*?)
    ', re.S | re.I) _DTDD_RE = re.compile(r'
    ([^<]*)
    \s*(?:
    ([^<]*)
    )?', re.S | re.I) _TEL_RE = re.compile(r'Tel\.?\s*:\s*((?:1[\s-])?\d{3}[\s-]\d{3}-\d{4})') _AREA_M2_RE = re.compile(r'([\d\s .,]+?)\s*(m|mètre|pied)', re.I) _SQFT_PER_SQM = 10.7639 _SPEC_FIELDS = {"chambres": "bedrooms", "chambre": "bedrooms", "salles de bain": "bathrooms", "salle de bain": "bathrooms", "salle d'eau": "powder_rooms", "salles d'eau": "powder_rooms"} class ImchaConnector(BaseConnector): source_id = "imcha_ag_qc" request_delay = 0.4 max_pages = 30 def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} dry = 0 for page in range(1, self.max_pages + 1): url = f"{SITE}/vente-de-proprietes/page-{page}" try: resp = self.get(url) resp.encoding = "utf-8" html = resp.text except Exception: break before = len(by_id) for blk in _ITEM_SPLIT.split(html)[1:]: blk = blk[:4000] lst = self._card(blk) if lst: by_id.setdefault(lst.external_id, lst) dry = dry + 1 if len(by_id) == before else 0 if dry >= 2: break listings = list(by_id.values()) du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1", fetch_html=self._fetch_utf8) return listings def _fetch_utf8(self, url: str) -> str: # le serveur n'annonce pas de charset (requests retomberait sur # ISO-8859-1) alors que les fiches sont en UTF-8 resp = self.get(url) resp.encoding = "utf-8" return resp.text def _card(self, blk: str) -> PropertyListing | None: hm = _HREF_RE.search(blk) if not hm: return None cm = _CENTRIS_RE.search(blk) code = _CODE_RE.search(blk) # n° Centris (photo) = clé de dédup ; sinon code interne (pas de dédup) mls = cm.group(1) if cm else "" external_id = mls or (code.group(1) if code else None) if not external_id: return None tm = _TYPE_RE.search(blk) cy = _CITY_RE.search(blk) pm = _PRICE_RE.search(blk) bm = _BEDS_RE.search(blk) im = _IMG_RE.search(blk) img = im.group(1) if im else "" if img.startswith("/"): img = SITE + img price_label = _html.unescape(pm.group(1)).strip() if pm else "" city = _html.unescape(cy.group(1)).strip() if cy else "" return PropertyListing( source=self.source_id, external_id=external_id, url=SITE + hm.group(1), title=f"{_html.unescape(tm.group(1)).strip() if tm else 'Propriété'} à vendre — {city}".strip(" —"), city=city, property_type=_html.unescape(tm.group(1)).strip() if tm else "", price=parse_price(price_label), price_label=price_label, bedrooms=int(bm.group(1)) if bm else None, mls=mls, images=[img] if img else [], agency=AGENCY, broker_name=AGENCY, ) def _txt(fragment: str) -> str: t = _html.unescape(re.sub(r"", "\n", fragment, flags=re.I)) t = re.sub(r"<[^>]+>", " ", t).replace("\xa0", " ").replace("×", "x") t = re.sub(r"[ \t]+", " ", t) return re.sub(r"\n{3,}", "\n\n", t).strip() def _parse_detail(html: str) -> dict: """Fiche détail imcha : description, galerie max, specs, sections dt/dd.""" out: dict = {} dm = _DESC_RE.search(html) if dm: paras = [_txt(p) for p in _P_RE.findall(dm.group(1))] desc = "\n\n".join(p for p in paras if p) if desc: out["description"] = desc # galerie fancybox pleine résolution ; le préfixe du nom de fichier est le # n° Centris de LA fiche → on ne garde que le lot majoritaire (sécurité) anchors = _FANCY_RE.findall(html) if anchors: prefix = anchors[0].rsplit("/", 1)[-1].split("-", 1)[0] seen, uniq = set(), [] for a in anchors: if not a.rsplit("/", 1)[-1].startswith(prefix + "-"): continue u = SITE + a if u not in seen: seen.add(u) uniq.append(u) if uniq: out["images"] = uniq # specs à icônes (ordre libellé/nombre variable selon la spec) for spec in _SPEC_RE.findall(html): lm, cm = _SPEC_LABEL_RE.search(spec), _SPEC_COUNT_RE.search(spec) if not lm or not cm: continue field = _SPEC_FIELDS.get(_txt(lm.group(1)).lower()) m = re.search(r"\d+", cm.group(1)) if field and m and int(m.group()) > 0: out.setdefault(field, int(m.group())) # sections .estate__info (
    /
    ) details: dict = {} for title, body in _INFO_RE.findall(html): title = _txt(title) pairs = [(_txt(dt), _txt(dd or "")) for dt, dd in _DTDD_RE.findall(body)] if title.lower().startswith(("inclusions", "exclusions")): items = [dt for dt, _ in pairs if dt] if items: details[title.split(" ")[0]] = ", ".join(items) continue for dt, dd in pairs: dt = dt.rstrip(" :") if not dt or not dd or len(dd) > 120: continue if dt == "Année de construction": m = re.search(r"\b(1[6-9]\d{2}|20\d{2})\b", dd) if m: # « 0 » = inconnue chez imcha out["year_built"] = int(m.group(1)) details[dt] = dd elif dt == "Type": details["Type de propriété"] = dd elif title.startswith("Évaluation municipale"): details[f"Évaluation municipale ({dt.lower()})"] = dd elif title in ("Terrain", "Bâtiment"): if dt == "Superficie": details[f"Superficie du {title.lower()}"] = dd if title == "Terrain": sq = _area_to_sqft(dd) if sq: out["lot_sqft"] = sq else: details[f"{title} — {dt.lower()}"] = dd else: details[dt] = dd if details: out["details"] = details # téléphone de l'agence (meta description « Tel. : 1 418-435-6221 ») tm = _TEL_RE.search(html) if tm: out["broker_phone"] = tm.group(1).strip() return out def _area_to_sqft(val: str) -> float | None: """« 7657.10 Mètres carrés » / « 2 400 pieds carrés » -> pi².""" m = _AREA_M2_RE.match(val.strip()) if not m: return None try: n = float(re.sub(r"[^\d.]", "", m.group(1))) except ValueError: return None if m.group(2).lower().startswith(("m", "mètre")): n *= _SQFT_PER_SQM return round(n, 1) or None