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