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