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/glmc.py : Les Immeubles GLMC (glmc.ca) — Rivière-du-Loup + KRTB /5# Témiscouata / Les Basques (Bas-Saint-Laurent). Plus grosse agence6# indépendante de l'Est du Québec. Site WordPress (plugin mw-centris, thème X),7# liste rendue SERVEUR sur /proprietes/, paginée par ?pg=N (60/page, ~1768# fiches). Chaque carte : lien /proprietes/{adresse-slug}-{n°Centris}/ (le n°9# Centris termine le slug), adresse+ville, photo. Prix/chambres via la page10# détail (enrichissement plafonné + cache). La section « Vendus » (fiches11# vendues, après la pagination) est exclue du parsing.12#13# Page détail (thème X / Cornerstone) : PAS de JSON-LD propriété — tout est14# dans le HTML : stats à icônes « N pièces / N chambre(s) / N salle(s) de15# bain / N salle d'eau », description dans <ul class="remarques-list">,16# caractéristiques dans des grilles x-cell alternant libellé/valeur17# (Genre, Année de construction, Terrain [dimensions puis superficie],18# évaluations, taxes…), Inclusions/Exclusions en x-text, courtier(s) dans19# .mw-courtier-card (nom + tel:), galerie = photos Centris re-hébergées20# /wp-content/uploads/…/{md5}.jpg (nom 32 hex, pleine résolution, ordre21# d'origine — les logos/portraits ont un nom lisible et sont donc exclus).22# Coordonnées GPS : chargées en AJAX par mw-centris-map → non exposées.23#24# source_id « glmc_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —25# si le n° Centris est déjà porté par une bannière couverte, la fiche est26# masquée ; les inscriptions propres de GLMC restent visibles.27# -----------------------------------------------------------------------------28from __future__ import annotations2930import html as _html31import os32import re3334from .base import BaseConnector35from . import _detailutil as du36from ..normalize import parse_price37from ..schema import PropertyListing3839SITE = "https://www.glmc.ca"40LISTING = SITE + "/proprietes/"41DETAIL_LIMIT = int(os.environ.get("IMMOKA_GLMC_DETAIL_LIMIT", "250"))42AGENCY = "Les Immeubles GLMC"4344_CARD_SPLIT = re.compile(r'<a class="x-col[^"]*"\s+href="')45_HREF_RE = re.compile(r'\s*(https://www\.glmc\.ca/proprietes/([a-z0-9-]+?)-(\d{6,9})/)"', re.I)46_IMG_RE = re.compile(r'<img[^>]+src="(https://www\.glmc\.ca/wp-content/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"', re.I)47_TITLE_RE = re.compile(r'x-text-content-text-primary">([^<]+)</p>', re.I)48_PRICE_RE = re.compile(r'([\d][\d\s ]{2,}\$)', re.I)4950# --- page détail ---51_GALLERY_RE = re.compile(52 r'https://www\.glmc\.ca/wp-content/uploads/sites/\d+/\d{4}/\d{2}/'53 r'[0-9a-f]{32}\.(?:jpg|jpeg|png|webp)', re.I)54_DESC_RE = re.compile(r'<ul class="remarques-list">(.*?)</ul>', re.S | re.I)55_LI_RE = re.compile(r'<li[^>]*>(.*?)</li>', re.S | re.I)56_CELL_RE = re.compile(r'class="x-cell[^>]*>(.*?)</div>\s*</div>', re.S)57_STAT_ROOMS_RE = re.compile(r'x-text-content-text-primary">\s*(\d+)\s*pi[èe]ces?', re.I)58_STAT_BEDS_RE = re.compile(r'x-text-content-text-primary">\s*(\d+)\s*chambre', re.I)59_STAT_BATHS_RE = re.compile(r'x-text-content-text-primary">\s*(\d+)\s*salle\(s\) de bain', re.I)60_STAT_POWDER_RE = re.compile(r"x-text-content-text-primary\">\s*(\d+)\s*salle d['’]eau", re.I)61_BROKER_RE = re.compile(r'mw-courtier-name">\s*<h3>([^<]+)</h3>', re.I)62_BROKER_TEL_RE = re.compile(r'mw-courtier-phone">\s*<a href="tel:(\d{7,11})"', re.I)63_INCL_RE = re.compile(r'>Inclusions</p></div>\s*<div class="x-text x-content[^>]*>([^<]{2,400})<', re.I)64_EXCL_RE = re.compile(r'>Exclusions</p></div>\s*<div class="x-text x-content[^>]*>([^<]{2,400})<', re.I)65_AREA_VAL_RE = re.compile(r'([\d\s .,]+?)\s*(MC|M2|M²|PC|PI2|PI²)\s*$', re.I)6667_SQFT_PER_SQM = 10.7639686970class GlmcConnector(BaseConnector):71 source_id = "glmc_ag_qc"72 request_delay = 0.473 max_pages = 40 # garde-fou (60/page)7475 def fetch(self) -> list[PropertyListing]:76 by_id: dict[str, PropertyListing] = {}77 dry = 078 for page in range(1, self.max_pages + 1):79 url = f"{LISTING}?pg={page}"80 try:81 # SiteGround sert un challenge sgcaptcha en HTTP 202 (sans82 # exception) : get_resilient escalade (Oxylabs → Scrapfly ASP83 # → Bright Data)84 html = self.get_resilient(url).text85 except Exception:86 break87 # exclure la section « Vendus » (fiches vendues, après la pagination)88 html = html.split(">Vendus<")[0]89 before = len(by_id)90 for blk in _CARD_SPLIT.split(html)[1:]:91 blk = blk[:2500]92 lst = self._card(blk)93 if lst:94 by_id.setdefault(lst.external_id, lst)95 dry = dry + 1 if len(by_id) == before else 096 if dry >= 2:97 break98 listings = list(by_id.values())99 du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v2",100 fetch_html=lambda u: self.get_resilient(u).text)101 return listings102103 def _card(self, blk: str) -> PropertyListing | None:104 hm = _HREF_RE.match(blk) or _HREF_RE.search(blk[:400])105 if not hm:106 return None107 url, _slug, mls = hm.group(1), hm.group(2), hm.group(3)108109 title = ""110 tm = _TITLE_RE.search(blk)111 if tm:112 title = _html.unescape(tm.group(1)).strip()113 # dernière virgule = ville (« 37, Rue Lionel-Chalifour, Rivière-du-Loup »)114 city = ""115 if title.count(",") >= 1:116 city = title.rsplit(",", 1)[1].strip()117118 im = _IMG_RE.search(blk)119 pm = _PRICE_RE.search(blk)120 price_label = _html.unescape(pm.group(1)).replace(" ", " ").strip() if pm else ""121122 return PropertyListing(123 source=self.source_id,124 external_id=mls,125 url=url,126 title=title or "Propriété à vendre",127 address=title,128 city=city,129 price=parse_price(price_label),130 price_label=price_label,131 mls=mls,132 images=[im.group(1)] if im else [],133 agency=AGENCY,134 broker_name=AGENCY,135 )136137138def _txt(fragment: str) -> str:139 """HTML -> texte plat propre (une ligne)."""140 t = _html.unescape(re.sub(r"<[^>]+>", " ", fragment))141 return re.sub(r"\s+", " ", t.replace("\xa0", " ")).strip()142143144def _to_sqft(val: str) -> float | None:145 """« 1729.60 MC » / « 2 400 PC » -> pieds carrés."""146 m = _AREA_VAL_RE.search(val.strip())147 if not m:148 return None149 try:150 n = float(re.sub(r"[^\d.]", "", m.group(1)))151 except ValueError:152 return None153 if m.group(2).upper() in ("MC", "M2", "M²"):154 n *= _SQFT_PER_SQM155 return round(n, 1) or None156157158def _parse_detail(html: str) -> dict:159 """Fiche détail GLMC (thème X) : stats à icônes, remarques, grilles x-cell,160 inclusions/exclusions, courtier(s), galerie pleine résolution."""161 out: dict = {}162163 # description = remarques Centris (<ul class="remarques-list">)164 dm = _DESC_RE.search(html)165 if dm:166 paras = [_txt(li) for li in _LI_RE.findall(dm.group(1))]167 desc = "\n\n".join(p for p in paras if p)168 if desc:169 out["description"] = desc170171 # stats à icônes (pièces / chambres / sdb / salles d'eau)172 details: dict = {}173 sm = _STAT_ROOMS_RE.search(html)174 if sm:175 details["Nombre de pièces"] = sm.group(1)176 for rx, field in ((_STAT_BEDS_RE, "bedrooms"),177 (_STAT_BATHS_RE, "bathrooms"),178 (_STAT_POWDER_RE, "powder_rooms")):179 m = rx.search(html)180 if m and int(m.group(1)) > 0:181 out[field] = int(m.group(1))182183 # grilles x-cell : cellules alternées libellé / valeur (avant les courtiers)184 seg = html.split("Courtier(s)")[0]185 cells = [_txt(c) for c in _CELL_RE.findall(seg)]186 for i in range(0, len(cells) - 1, 2):187 lab, val = cells[i], cells[i + 1]188 if not lab or not val or len(lab) > 45 or len(val) > 120 or lab == val:189 continue190 if lab in ("Terrain", "Bâtiment"): # sections Dimensions vs Superficie191 if re.search(r"\d\s*[xX]\s*\d", val):192 lab = f"Dimensions du {lab.lower()}"193 else:194 lab = f"Superficie du {lab.lower()}"195 elif lab == "Habitable":196 lab = "Superficie habitable"197 details[lab] = val198 if details.get("Superficie du terrain"):199 sq = _to_sqft(details["Superficie du terrain"])200 if sq:201 out["lot_sqft"] = sq202 if details.get("Superficie habitable"):203 sq = _to_sqft(details["Superficie habitable"])204 if sq:205 out["area_sqft"] = sq206 my = re.search(r"\b(1[6-9]\d{2}|20\d{2})\b", details.get("Année de construction", ""))207 if my:208 out["year_built"] = int(my.group(1))209210 # inclusions / exclusions (x-text hors grille)211 for rx, lab in ((_INCL_RE, "Inclusions"), (_EXCL_RE, "Exclusions")):212 m = rx.search(seg)213 if m:214 v = _txt(m.group(1))215 if v:216 details[lab] = v217 if details:218 out["details"] = details219220 # courtier inscripteur (première carte .mw-courtier-card)221 bm = _BROKER_RE.search(html)222 if bm:223 out["broker_name"] = _txt(bm.group(1))224 tm = _BROKER_TEL_RE.search(html)225 if tm:226 t = tm.group(1)227 if len(t) == 10:228 t = f"{t[:3]} {t[3:6]}-{t[6:]}"229 out["broker_phone"] = t230231 # prix « 349 900 $ »232 mp = re.search(r'([\d][\d\s ]{2,}\$)', html)233 if mp:234 p = parse_price(mp.group(1))235 if p:236 out["price"] = p237 out["price_label"] = mp.group(1).replace(" ", " ").strip()238239 co = du.gmaps_coords(html)240 if co:241 out["lat"], out["lng"] = co242243 # galerie : photos Centris re-hébergées (nom de fichier = 32 hex) —244 # pleine résolution, ordre d'origine ; exclut logos/portraits (noms lisibles)245 seen, uniq = set(), []246 for u in _GALLERY_RE.findall(html):247 if u not in seen:248 seen.add(u)249 uniq.append(u)250 if uniq:251 out["images"] = uniq252 return out253