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.0 KB · 237 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/agenceimage.py : Image, l'agence immobilière (agenceimage.ca) —5#   Saguenay, Jonquière, Roberval, Dolbeau-Mistassini, Lac-Saint-Jean.6#   Plateforme Yoamo (ID-3 Innovations) + photos Centris. La page /mes-proprietes/7#   rend TOUT l'inventaire côté serveur : chaque carte porte n° Centris (rel/URL),8#   type + ville (dans l'URL et l'alt), coordonnées (data-geo-lat/lng), prix et9#   photo (yoamo.immo/ALSPicture.axd?propId={Centris}).10#11#   Page détail (rel="/proprietes/a-vendre/{type}/{ville}/{centris}/") rendue12#   serveur elle aussi : galerie complète (ALSPicture.axd, une URL par seq=N,13#   pleine taille sans &w=), description JSON-LD (Product), sections14#   <sup>libellé</sup><label>valeur</label> (#building/#land/#more-carac),15#   pièces (#rooms), « En complément » (taxes, évaluation, année, terrain),16#   inclusions/exclusions, courtier (#broker_list, schema.org/RealEstateAgent)17#   et lien Google Maps (adresse + GPS). ⚠ la section #related (« Aussi18#   disponibles ») liste d'AUTRES propriétés — tout est parsé AVANT elle.19#20#   source_id « image_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —21#   masque les fiches déjà portées par une bannière couverte au Saguenay–LSJ.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import html as _html26import os27import re2829from .base import BaseConnector30from . import _detailutil as du31from ..normalize import parse_lot_sqft32from ..schema import PropertyListing3334SITE = "https://agenceimage.ca"35LISTING = SITE + "/mes-proprietes/"36AGENCY = "Image, l'agence immobilière"37DETAIL_LIMIT = int(os.environ.get("IMMOKA_IMAGE_DETAIL_LIMIT",38                                  os.environ.get("IMMOKA_DETAIL_LIMIT", "100")))3940# chaque fiche = un <article ... lid="{Centris}"> dont les data-filter-* portent41# tout (prix, ville, genre, chambres, sdb, coords) — plus fiable que le HTML visible.42_ART_SPLIT = re.compile(r'<article\b', re.I)43_LID_RE = re.compile(r'l(?:id|s)="(\d{6,9})"', re.I)44_REL_RE = re.compile(r'rel="(/proprietes/a-vendre/[a-z0-9-]+/[a-z0-9-]+/\d{6,9}/)"', re.I)45_IMG_RE = re.compile(r'(https://yoamo\.immo/ALSPicture\.axd\?propId=\d+[^"\']*)', re.I)4647# --- page détail -------------------------------------------------------------48_DET_URL_RE = re.compile(r'https://yoamo\.immo/ALSPicture\.axd\?[^"\'\s<>]+', re.I)49_SUP_LABEL_RE = re.compile(r'<sup>([^<]+)</sup>\s*<label>\s*([^<]*?)\s*</label>', re.I)50_MAPS_RE = re.compile(r'google\.[^"\']*?[?&]q=([^@"\']+)@(-?\d{1,2}\.\d+),(-?\d{2,3}\.\d+)')515253def _attr(blk: str, name: str) -> str:54    m = re.search(name + r'="([^"]*)"', blk, re.I)55    return _html.unescape(m.group(1)).strip() if m else ""565758def _deslug(s: str) -> str:59    return _html.unescape(s.replace("-", " ")).strip().title()606162def _section(html: str, sid: str) -> str:63    m = re.search(r'<section id="%s".*?</section>' % re.escape(sid), html, re.S | re.I)64    return m.group(0) if m else ""656667def _text(fragment: str) -> str:68    return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()697071def parse_image_detail(html: str) -> dict:72    """Fiche Yoamo/agenceimage.ca : galerie complète (ordre seq=), description73    JSON-LD, caractéristiques <sup>/<label>, pièces, taxes/évaluation/année,74    inclusions, GPS + adresse (lien Google Maps), courtier + téléphone."""75    out: dict = {}76    # tout ce qui suit #related appartient à D'AUTRES propriétés77    main = re.split(r'<section id="related"', html, 1)[0]7879    # galerie : une URL ALSPicture par seq=N ; on regroupe par propId et on80    # garde le groupe majoritaire (la fiche elle-même), trié par seq d'origine81    groups: dict[str, dict[int, str]] = {}82    for m in _DET_URL_RE.finditer(main):83        u = _html.unescape(m.group(0))84        pid = re.search(r'propId=(\d+)', u)85        seq = re.search(r'[?&]seq=(\d+)', u)86        if pid and seq:87            groups.setdefault(pid.group(1), {}).setdefault(int(seq.group(1)), u)88    if groups:89        best = max(groups.values(), key=len)90        out["images"] = [best[s] for s in sorted(best)]9192    desc = du.ld_description(html)93    if desc:94        out["description"] = desc[:4000]9596    details: dict = {}97    # sections « libellé / valeur » (Bâtiment et intérieur, Terrain et98    # extérieur, Autres caractéristiques)99    for sid in ("building", "land", "more-carac"):100        for lab, val in _SUP_LABEL_RE.findall(_section(main, sid)):101            lab, val = _html.unescape(lab).strip(), _html.unescape(val).strip()102            if lab and val:103                details[lab] = val104105    # pièces (#rooms) : nom + dimensions + étage + plancher106    rooms = []107    for chunk in re.split(r'class="row-item"', _section(main, "rooms"))[1:]:108        nm = re.search(r'<label>([^<]+)</label>\s*<sub>([^<]*)</sub>', chunk)109        if not nm or not nm.group(1).strip():110            continue111        room = {"nom": _html.unescape(nm.group(1)).strip()[:40]}112        dims = _html.unescape(nm.group(2)).strip()113        if dims:114            room["dimensions"] = dims115        for lab, key in (("Étage", "niveau"), ("Plancher", "revetement")):116            mv = re.search(r'<sup>%s</sup>\s*<label>([^<]*)</label>' % lab, chunk)117            if mv and mv.group(1).strip():118                room[key] = _html.unescape(mv.group(1)).strip()119        rooms.append(room)120    if rooms:121        details["pieces"] = rooms[:25]122123    # « En complément » : taxes, évaluation municipale, année, terrain124    text = _text(main)125    for pat, key in ((r"Municipale:\s*([\d\s  ]+\$)", "Taxes municipales"),126                     (r"Scolaire:\s*([\d\s  ]+\$)", "Taxes scolaires"),127                     (r"Évaluation municipale\s*([\d\s  ]+\$)", "Évaluation municipale")):128        mm = re.search(pat, text)129        if mm:130            details[key] = re.sub(r"\s+", " ", mm.group(1)).strip()131    my = re.search(r"Construit en ((?:1[6-9]|20)\d{2})\b", text)132    if my:133        out["year_built"] = int(my.group(1))134    mt = re.search(r"Terrain de ([\d\s  .,]+?)\s*m<sup>2</sup>", main)135    if mt:136        lot = parse_lot_sqft(mt.group(1).strip() + " m²")137        if lot:138            out["lot_sqft"] = lot139140    # inclusions -> features ; exclusions -> details141    inc = _text(_section(main, "inclusive"))142    inc = re.sub(r"^Inclusions\s*", "", inc, flags=re.I)143    feats = [s.strip() for s in re.split(r"[,;]", inc) if 2 <= len(s.strip()) <= 90]144    if feats:145        out["features"] = feats[:20]146    exc = re.sub(r"^Exclusions\s*", "", _text(_section(main, "exclusive")), flags=re.I)147    if exc:148        details["Exclusions"] = exc[:300]149150    # adresse + GPS depuis le lien « Ouvrir la carte »151    mg = _MAPS_RE.search(main)152    if mg:153        addr = _html.unescape(mg.group(1)).split(",")[0].strip()154        if addr:155            out["address"] = addr156        try:157            lat, lng = float(mg.group(2)), float(mg.group(3))158            if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:159                out["lat"], out["lng"] = lat, lng160        except ValueError:161            pass162163    # courtier inscripteur (« Présenté par », 1er de la liste) + téléphone164    broker = _section(main, "broker_list")165    mb = re.search(r'itemprop="name" content="([^"]+)"', broker)166    if mb:167        out["broker_name"] = _html.unescape(mb.group(1)).strip()168    mp = re.search(r'href="tel:\+?\d+">\s*([\d\s().-]{10,20})\s*<', broker)169    if mp:170        out["broker_phone"] = mp.group(1).strip()171172    if details:173        out["details"] = details174    return out175176177class AgenceImageConnector(BaseConnector):178    source_id = "image_ag_qc"179    request_delay = 0.5180181    def fetch(self) -> list[PropertyListing]:182        try:183            html = self.get(LISTING).text184        except Exception:185            return []186        by_id: dict[str, PropertyListing] = {}187        for blk in _ART_SPLIT.split(html)[1:]:188            blk = blk[:3000]189            lst = self._card(blk)190            if lst:191                by_id.setdefault(lst.external_id, lst)192        listings = list(by_id.values())193        # fiche détail : galerie complète, description, caractéristiques,194        # pièces, adresse, courtier — plafonné/cycle, cache BD (voir _detailutil)195        du.enrich(self, [l for l in listings if l.external_id in l.url],196                  DETAIL_LIMIT, parse_image_detail, key="v1")197        return listings198199    def _card(self, blk: str) -> PropertyListing | None:200        lm = _LID_RE.search(blk)201        if not lm:202            return None203        mls = lm.group(1)204        # écarter locations / vendus / loués205        if _attr(blk, "data-filter-rental") == "true" or _attr(blk, "data-filter-sold") == "true":206            return None207208        rel = _REL_RE.search(blk)209        url = f"{SITE}{rel.group(1)}" if rel else f"{SITE}/proprietes/"210        genre = _attr(blk, "data-filter-genre")211        city = _attr(blk, "data-filter-city")212        price = _attr(blk, "data-filter-price")213        beds = _attr(blk, "data-filter-bedrooms")214        baths = _attr(blk, "data-filter-bathrooms")215        lat = _attr(blk, "data-geo-lat")216        lng = _attr(blk, "data-geo-lng")217        im = _IMG_RE.search(blk)218219        return PropertyListing(220            source=self.source_id,221            external_id=mls,222            url=url,223            title=f"{genre} à vendre, {city}".strip(" ,") or "Propriété à vendre",224            property_type=genre,225            city=city,226            price=float(price) if price.isdigit() else None,227            price_label=(f"{int(price):,} $".replace(",", " ") if price.isdigit() else ""),228            bedrooms=int(beds) if beds.isdigit() else None,229            bathrooms=int(baths) if baths.isdigit() else None,230            mls=mls,231            images=[im.group(1).replace("&w=320", "")] if im else [],232            lat=float(lat) if lat else None,233            lng=float(lng) if lng else None,234            agency=AGENCY,235            broker_name=AGENCY,236        )237