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%
12.7 KB · 297 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/avecuncourtier.py : portail « avec un courtier » de la Chambre5#   immobilière de l'Outaouais (avecuncourtier.com). Couvre TOUT l'Outaouais6#   (Gatineau → Maniwaki/Pontiac) d'un coup — c'est le portail MLS régional de7#   la chambre, pas une agence unique. Recherche rendue SERVEUR (plateforme8#   Concrete5, Tonik Web Studio), paginée par ?page=N (12 cartes/page, ~3419#   pages). Chaque carte porte déjà : n° MLS (Centris), adresse, ville/secteur,10#   type, aire habitable, prix et photo.11#12#   ENRICHISSEMENT DÉTAIL (v2) : /fr/proprietes/details/{mls}-{slug} est rendue13#   serveur : galerie complète (/centris/photos/{mls}/{n}.jpg, ordre d'origine),14#   description + Addendas, GPS (data-lat/data-lon), accordéons « Proximité »,15#   « Dimension des pièces » (4 col.), « Taxes et dépenses » + « Bâtiment et16#   intérieur » (paires td/td : chambres, sdb, aire habitable en m², terrain,17#   année, évaluations…), visites libres, courtier(s) inscripteur(s) + agence18#   réelle (RE/MAX Vision, Exit…). Cache BD via _detailutil.enrich.19#20#   source_id « cio_ag_outaouais » : l'infixe _ag_ active la dédup Centris21#   (db.refresh_dedup, sources %_ag_%). Comme le portail agrège les inscriptions22#   des bannières déjà couvertes (RE/MAX, Royal LePage, Via Capitale, Proprio23#   Direct…), la dédup masque les fiches dont le n° Centris est déjà porté par24#   une source canonique ; seules les inscriptions d'agences NON couvertes de25#   l'Outaouais restent visibles. Aucun double-comptage.26# -----------------------------------------------------------------------------27from __future__ import annotations2829import html as _html30import os31import re3233from .base import BaseConnector34from . import _detailutil as du35from ..normalize import parse_price36from ..schema import PropertyListing3738SITE = "https://avecuncourtier.com"39LISTING = SITE + "/fr/proprietes"40MAX_PAGES = int(os.environ.get("IMMOKA_AUC_MAX_PAGES", "400"))41AGENCY = "Chambre immobilière de l'Outaouais"42# fiches détail (re)lues au plus par cycle — cache BD (key v2). ~1 400 fiches43# publiées → ~5 cycles de rattrapage, puis seules les nouvelles sont lues.44DETAIL_LIMIT = int(os.environ.get("IMMOKA_AUC_DETAIL_LIMIT",45                                  os.environ.get("IMMOKA_DETAIL_LIMIT", "300")))4647# une carte commence par <div class="photo "> et court jusqu'à la fermeture du48# bloc listing suivant ; on la borne au <div class="photo "> suivant.49_CARD_SPLIT = re.compile(r'<div class="photo\s*">')50_HREF_RE = re.compile(r'/fr/proprietes/details/(\d{6,9})-([a-z0-9-]+)', re.I)51_ALT_RE = re.compile(r'<img[^>]+alt="([^"]*)"', re.I)52_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"', re.I)53_MLS_RE = re.compile(r'MLS[^0-9]{0,3}(\d{6,9})', re.I)54_PRICE_RE = re.compile(r'class="price">\s*([\d\s ,.]+\$[^<]*)<', re.I)55_ADDR_RE = re.compile(r'<address>\s*<span>([^<]*)</span>\s*(?:<br\s*/?>)?\s*([^<]*)</address>', re.S | re.I)56_TYPE_RE = re.compile(r'header">\s*Type\s*</div>\s*<div class="column">\s*([^<]+?)\s*</div>', re.S | re.I)575859class AvecUnCourtierConnector(BaseConnector):60    source_id = "cio_ag_outaouais"61    request_delay = 0.462    max_pages = MAX_PAGES6364    def fetch(self) -> list[PropertyListing]:65        by_id: dict[str, PropertyListing] = {}66        dry = 067        for page in range(1, self.max_pages + 1):68            url = f"{LISTING}?page={page}"69            try:70                html = self.get(url).text71            except Exception:72                break73            before = len(by_id)74            for blk in _CARD_SPLIT.split(html)[1:]:75                blk = blk[:3000]76                lst = self._card(blk)77                if lst:78                    by_id.setdefault(lst.external_id, lst)79            # fin de pagination : plus aucune carte neuve sur 2 pages de suite80            dry = dry + 1 if len(by_id) == before else 081            if dry >= 2:82                break83        listings = list(by_id.values())84        # fiche détail (server-rendered) : galerie complète, description +85        # addendas, GPS, pièces, taxes/évaluations, courtier + agence réelle.86        du.enrich(self, listings, DETAIL_LIMIT, _parse_auc_detail, key="v2")87        for lst in listings:88            office = lst.details.pop("__agency", "")89            if office:90                lst.agency = office91        return listings9293    def _card(self, blk: str) -> PropertyListing | None:94        hm = _HREF_RE.search(blk)95        if not hm:96            return None97        mls = hm.group(1)9899        addr, city = "", ""100        am = _ADDR_RE.search(blk)101        if am:102            addr = _html.unescape(am.group(1)).strip()103            city = _html.unescape(re.sub(r'\s+', ' ', am.group(2))).strip()104        if not addr:105            alt = _ALT_RE.search(blk)106            addr = _html.unescape(alt.group(1)).strip() if alt else ""107108        pm = _PRICE_RE.search(blk)109        price_label = _html.unescape(pm.group(1)).replace(" ", " ").strip() if pm else ""110        tm = _TYPE_RE.search(blk)111        prop_type = _html.unescape(tm.group(1)).strip() if tm else ""112        im = _IMG_RE.search(blk)113        img = im.group(1) if im else ""114        if img and img.startswith("/"):115            img = SITE + img116117        return PropertyListing(118            source=self.source_id,119            external_id=mls,120            url=f"{SITE}/fr/proprietes/details/{mls}-{hm.group(2)}",121            title=addr or f"{prop_type} à vendre",122            address=addr,123            city=city,124            property_type=prop_type,125            price=parse_price(price_label),126            price_label=price_label,127            mls=mls,128            images=[img] if img else [],129            agency=AGENCY,130        )131132133# --- fiche détail (Concrete5 / Tonik, server-rendered) ------------------------134_GAL_RE = re.compile(r'data-lazy="(/centris/photos/\d+/[^"]+)"')135_COORD_RE = re.compile(r'data-lat="(-?\d{1,2}\.\d+)"\s+data-lon="(-?\d{2,3}\.\d+)"')136_DESC_RE = re.compile(r'class="property-description">\s*<h2>[^<]*</h2>(.*?)</div>', re.S)137_OPEN_RE = re.compile(r'class="open-houses">\s*<h2>[^<]*</h2>(.*?)</div>', re.S)138_ITEM_TITLE_RE = re.compile(r'>\s*([^<]{3,60}?)\s*<i class="fa fa-angle-down')139_PAIR_RE = re.compile(r'<tr>\s*<td>([^<]{1,70}?)</td>\s*<td>([^<]*?)</td>\s*</tr>', re.S)140_ROOM_RE = re.compile(r'<tr>\s*<td>([^<]*)</td>\s*<td>([^<]*)</td>\s*'141                      r'<td>([^<]*)</td>\s*<td>([^<]*)</td>\s*</tr>', re.S)142_ADD_RE = re.compile(r'<td>([^<]{40,})</td>', re.S)143_PROX_RE = re.compile(r'<p>(.*?)</p>', re.S)144_AGENT_NAME_RE = re.compile(r'class="name">([^<]+)<')145_AGENT_TEL_RE = re.compile(r'href="tel:[^"]*"[^>]*>([^<]+)<')146# <div class="agency">NOM INC.<div>RE/MAX VISION</div><div>Agence Immobilière</div>147_AGENT_BANNER_RE = re.compile(r'class="agency">[^<]*<div>([^<]+)</div>', re.S)148_TOUR_RE = re.compile(r'https?://(?:my\.matterport\.com/show/[^"\'\s<>]+'149                      r'|(?:www\.)?youtube\.com/(?:embed/|watch\?v=)[^"\'\s<>]+'150                      r'|youtu\.be/[^"\'\s<>]+'151                      r'|(?:player\.)?vimeo\.com/(?:video/)?\d+[^"\'\s<>]*)', re.I)152153# libellés « Bâtiment et intérieur » promus en colonnes du schéma154_INT_FIELDS = {"Chambres": "bedrooms", "Salles de bain": "bathrooms",155               "Salles d'eau": "powder_rooms"}156157158def _clean(s: str) -> str:159    return re.sub(r'\s+', ' ', _html.unescape(s)).strip()160161162def _to_sqft(val: str) -> float | None:163    """« 128.8 Mètres carrés » / « 1 200 Pieds carrés » / « 305.4 » (m² implicite,164    unités Centris métriques sur ce portail) -> pi²."""165    m = re.search(r'([\d][\d\s  ,.]*)', val or "")166    if not m:167        return None168    raw = m.group(1).replace(" ", "").replace(" ", "").replace(" ", "")169    if raw.count(",") == 1 and "." not in raw:170        raw = raw.replace(",", ".")171    else:172        raw = raw.replace(",", "")173    try:174        v = float(raw)175    except ValueError:176        return None177    if v <= 0:178        return None179    if re.search(r'pied|pi2|pi²|pc\b', val, re.I):180        return round(v)181    return round(v * 10.7639)                # métrique (défaut du portail)182183184def _parse_auc_detail(html: str) -> dict:185    """Fiche avecuncourtier.com : galerie, description+addendas, GPS,186    accordéons (pièces, taxes, bâtiment), courtiers + agence réelle."""187    out: dict = {}188    details: dict = {}189    feats: list[str] = []190191    # galerie : slider principal seulement (avant les vignettes dupliquées)192    main = html.split('gallery-slider__thumbnails')[0]193    imgs = list(dict.fromkeys(SITE + u for u in _GAL_RE.findall(main)))194    if len(imgs) > 1:195        out["images"] = imgs[:80]196197    mc = _COORD_RE.search(html)198    if mc:199        lat, lng = float(mc.group(1)), float(mc.group(2))200        if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:201            out["lat"], out["lng"] = lat, lng202203    desc, addendum = "", ""204    md = _DESC_RE.search(html)205    if md:206        desc = _clean(re.sub(r'<[^>]+>', ' ', md.group(1)))207    mo = _OPEN_RE.search(html)208    if mo:209        oh = _clean(re.sub(r'<[^>]+>', ' ', mo.group(1)))210        if oh:211            details["Visites libres"] = oh[:200]212213    # accordéons « Détails »214    for item in re.split(r'<div class="accordion-item">', html)[1:]:215        tm = _ITEM_TITLE_RE.search(item)216        title = _clean(tm.group(1)) if tm else ""217        if title == "Proximité":218            mp = _PROX_RE.search(item)219            if mp:220                prox = _clean(re.sub(r'<[^>]+>', ' ', mp.group(1)))221                if prox:222                    details["Proximité"] = prox[:300]223                    feats.append(f"Proximité : {prox}"[:300])224        elif title == "Dimension des pièces":225            rooms = _ROOM_RE.findall(item)226            for name, lvl, dim, floor in rooms:227                name, lvl = _clean(name), _clean(lvl)228                dim, floor = _clean(dim), _clean(floor)229                line = name + (f" — {dim}" if dim else "") + (f", {lvl}" if lvl else "")230                if floor:231                    line += f" ({floor})"232                feats.append(line)233        elif title == "Addendas":234            ma = _ADD_RE.search(item)235            if ma:236                # les sauts de ligne du texte Centris sont préservés dans le <td>237                addendum = _html.unescape(ma.group(1)).strip()238        else:                                  # Taxes et dépenses / Bâtiment…239            for label, val in _PAIR_RE.findall(item):240                label, val = _clean(label).rstrip(":"), _clean(val)241                if not label or not val or val in ("0", "-"):242                    continue243                fld = _INT_FIELDS.get(label)244                if fld:245                    mi = re.search(r'\d+', val)246                    if mi:247                        out[fld] = int(mi.group())248                elif label == "Aire habitable":249                    a = _to_sqft(val)250                    if a:251                        out["area_sqft"] = a252                elif label == "Superficie du terrain":253                    t = _to_sqft(val)254                    if t:255                        out["lot_sqft"] = t256                        val = f"{val} m²" if re.fullmatch(r'[\d\s,.]+', val) else val257                elif label == "Année de construction":258                    my = re.search(r'\b((?:1[6-9]|20)\d{2})\b', val)259                    if my:260                        out["year_built"] = int(my.group(1))261                if label in details and val not in details[label]:262                    details[label] = f"{details[label]}, {val}"[:300]263                else:264                    details[label] = val[:300]265266    if desc or addendum:267        out["description"] = "\n\n".join(filter(None, (desc, addendum)))[:6000]268    if feats:269        out["features"] = feats[:40]270271    # courtier(s) inscripteur(s) + bannière réelle (encadré agents-wrapper) :272    # premier agent = inscripteur principal273    i = html.find('class="agents-wrapper"')274    if i != -1:275        j = html.find('calculator-wrapper', i)276        seg = html[i:j if j != -1 else i + 5000]277        mn = _AGENT_NAME_RE.search(seg)278        if mn:279            out["broker_name"] = _clean(mn.group(1))280        mt = _AGENT_TEL_RE.search(seg)281        if mt:282            out["broker_phone"] = _clean(mt.group(1))283        mb = _AGENT_BANNER_RE.search(seg)284        if mb:285            banner = _clean(mb.group(1))286            if banner and banner.lower() != "agence immobilière":287                details["__agency"] = banner.title() if banner.isupper() else banner288289    # visite virtuelle / vidéo (hors liens de pied de page /channel/)290    mtou = _TOUR_RE.search(html)291    if mtou:292        details.setdefault("Visite virtuelle / vidéo", mtou.group(0))293294    if details:295        out["details"] = details296    return out297