# -----------------------------------------------------------------------------
# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/agenceimage.py : Image, l'agence immobilière (agenceimage.ca) —
# Saguenay, Jonquière, Roberval, Dolbeau-Mistassini, Lac-Saint-Jean.
# Plateforme Yoamo (ID-3 Innovations) + photos Centris. La page /mes-proprietes/
# rend TOUT l'inventaire côté serveur : chaque carte porte n° Centris (rel/URL),
# type + ville (dans l'URL et l'alt), coordonnées (data-geo-lat/lng), prix et
# photo (yoamo.immo/ALSPicture.axd?propId={Centris}).
#
# Page détail (rel="/proprietes/a-vendre/{type}/{ville}/{centris}/") rendue
# serveur elle aussi : galerie complète (ALSPicture.axd, une URL par seq=N,
# pleine taille sans &w=), description JSON-LD (Product), sections
# libellé (#building/#land/#more-carac),
# pièces (#rooms), « En complément » (taxes, évaluation, année, terrain),
# inclusions/exclusions, courtier (#broker_list, schema.org/RealEstateAgent)
# et lien Google Maps (adresse + GPS). ⚠ la section #related (« Aussi
# disponibles ») liste d'AUTRES propriétés — tout est parsé AVANT elle.
#
# source_id « image_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —
# masque les fiches déjà portées par une bannière couverte au Saguenay–LSJ.
# -----------------------------------------------------------------------------
from __future__ import annotations
import html as _html
import os
import re
from .base import BaseConnector
from . import _detailutil as du
from ..normalize import parse_lot_sqft
from ..schema import PropertyListing
SITE = "https://agenceimage.ca"
LISTING = SITE + "/mes-proprietes/"
AGENCY = "Image, l'agence immobilière"
DETAIL_LIMIT = int(os.environ.get("IMMOKA_IMAGE_DETAIL_LIMIT",
os.environ.get("IMMOKA_DETAIL_LIMIT", "100")))
# chaque fiche = un dont les data-filter-* portent
# tout (prix, ville, genre, chambres, sdb, coords) — plus fiable que le HTML visible.
_ART_SPLIT = re.compile(r']+', re.I)
_SUP_LABEL_RE = re.compile(r'([^<]+)\s*', re.I)
_MAPS_RE = re.compile(r'google\.[^"\']*?[?&]q=([^@"\']+)@(-?\d{1,2}\.\d+),(-?\d{2,3}\.\d+)')
def _attr(blk: str, name: str) -> str:
m = re.search(name + r'="([^"]*)"', blk, re.I)
return _html.unescape(m.group(1)).strip() if m else ""
def _deslug(s: str) -> str:
return _html.unescape(s.replace("-", " ")).strip().title()
def _section(html: str, sid: str) -> str:
m = re.search(r'' % re.escape(sid), html, re.S | re.I)
return m.group(0) if m else ""
def _text(fragment: str) -> str:
return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
def parse_image_detail(html: str) -> dict:
"""Fiche Yoamo/agenceimage.ca : galerie complète (ordre seq=), description
JSON-LD, caractéristiques /\s*([^<]*)', chunk)
if not nm or not nm.group(1).strip():
continue
room = {"nom": _html.unescape(nm.group(1)).strip()[:40]}
dims = _html.unescape(nm.group(2)).strip()
if dims:
room["dimensions"] = dims
for lab, key in (("Étage", "niveau"), ("Plancher", "revetement")):
mv = re.search(r'%s\s*' % lab, chunk)
if mv and mv.group(1).strip():
room[key] = _html.unescape(mv.group(1)).strip()
rooms.append(room)
if rooms:
details["pieces"] = rooms[:25]
# « En complément » : taxes, évaluation municipale, année, terrain
text = _text(main)
for pat, key in ((r"Municipale:\s*([\d\s ]+\$)", "Taxes municipales"),
(r"Scolaire:\s*([\d\s ]+\$)", "Taxes scolaires"),
(r"Évaluation municipale\s*([\d\s ]+\$)", "Évaluation municipale")):
mm = re.search(pat, text)
if mm:
details[key] = re.sub(r"\s+", " ", mm.group(1)).strip()
my = re.search(r"Construit en ((?:1[6-9]|20)\d{2})\b", text)
if my:
out["year_built"] = int(my.group(1))
mt = re.search(r"Terrain de ([\d\s .,]+?)\s*m2", main)
if mt:
lot = parse_lot_sqft(mt.group(1).strip() + " m²")
if lot:
out["lot_sqft"] = lot
# inclusions -> features ; exclusions -> details
inc = _text(_section(main, "inclusive"))
inc = re.sub(r"^Inclusions\s*", "", inc, flags=re.I)
feats = [s.strip() for s in re.split(r"[,;]", inc) if 2 <= len(s.strip()) <= 90]
if feats:
out["features"] = feats[:20]
exc = re.sub(r"^Exclusions\s*", "", _text(_section(main, "exclusive")), flags=re.I)
if exc:
details["Exclusions"] = exc[:300]
# adresse + GPS depuis le lien « Ouvrir la carte »
mg = _MAPS_RE.search(main)
if mg:
addr = _html.unescape(mg.group(1)).split(",")[0].strip()
if addr:
out["address"] = addr
try:
lat, lng = float(mg.group(2)), float(mg.group(3))
if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
out["lat"], out["lng"] = lat, lng
except ValueError:
pass
# courtier inscripteur (« Présenté par », 1er de la liste) + téléphone
broker = _section(main, "broker_list")
mb = re.search(r'itemprop="name" content="([^"]+)"', broker)
if mb:
out["broker_name"] = _html.unescape(mb.group(1)).strip()
mp = re.search(r'href="tel:\+?\d+">\s*([\d\s().-]{10,20})\s*<', broker)
if mp:
out["broker_phone"] = mp.group(1).strip()
if details:
out["details"] = details
return out
class AgenceImageConnector(BaseConnector):
source_id = "image_ag_qc"
request_delay = 0.5
def fetch(self) -> list[PropertyListing]:
try:
html = self.get(LISTING).text
except Exception:
return []
by_id: dict[str, PropertyListing] = {}
for blk in _ART_SPLIT.split(html)[1:]:
blk = blk[:3000]
lst = self._card(blk)
if lst:
by_id.setdefault(lst.external_id, lst)
listings = list(by_id.values())
# fiche détail : galerie complète, description, caractéristiques,
# pièces, adresse, courtier — plafonné/cycle, cache BD (voir _detailutil)
du.enrich(self, [l for l in listings if l.external_id in l.url],
DETAIL_LIMIT, parse_image_detail, key="v1")
return listings
def _card(self, blk: str) -> PropertyListing | None:
lm = _LID_RE.search(blk)
if not lm:
return None
mls = lm.group(1)
# écarter locations / vendus / loués
if _attr(blk, "data-filter-rental") == "true" or _attr(blk, "data-filter-sold") == "true":
return None
rel = _REL_RE.search(blk)
url = f"{SITE}{rel.group(1)}" if rel else f"{SITE}/proprietes/"
genre = _attr(blk, "data-filter-genre")
city = _attr(blk, "data-filter-city")
price = _attr(blk, "data-filter-price")
beds = _attr(blk, "data-filter-bedrooms")
baths = _attr(blk, "data-filter-bathrooms")
lat = _attr(blk, "data-geo-lat")
lng = _attr(blk, "data-geo-lng")
im = _IMG_RE.search(blk)
return PropertyListing(
source=self.source_id,
external_id=mls,
url=url,
title=f"{genre} à vendre, {city}".strip(" ,") or "Propriété à vendre",
property_type=genre,
city=city,
price=float(price) if price.isdigit() else None,
price_label=(f"{int(price):,} $".replace(",", " ") if price.isdigit() else ""),
bedrooms=int(beds) if beds.isdigit() else None,
bathrooms=int(baths) if baths.isdigit() else None,
mls=mls,
images=[im.group(1).replace("&w=320", "")] if im else [],
lat=float(lat) if lat else None,
lng=float(lng) if lng else None,
agency=AGENCY,
broker_name=AGENCY,
)