SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
10.2 KB · 246 lines python
Raw Blame History
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