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