# -----------------------------------------------------------------------------
# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/publimaison.py : Publimaison.ca (FSBO + transferts Centris)
#
# 2e plateforme « vente par le propriétaire » du Québec (~5 400 annonces),
# qui accepte aussi les transferts Centris de courtiers (« No. MLS » affiché).
# Recherche rendue SERVEUR pilotée par le pseudo-hash d'URL
# (/fr/recherche/?hash=/…/nbr_item=8/page=N, page 0-indexée, 8 cartes/page,
# total « Résultats de 1 à 8 sur NNNN »). Chaque carte porte déjà : réf MLS,
# type, ville, prix, chambres, adresse, description et photo extralarge.
# La fiche détail (HTML, sans JSON-LD) ajoute galerie complète, code postal,
# pièces et dimensions, taxes et évaluation — enrichissement cache + plafond.
#
# source_id « publimaison_ag_qc » : l'infixe _ag_ active la dédup Centris —
# quand la réf de la carte est un n° MLS déjà porté par la bannière du
# courtier, la fiche est masquée ; les annonces de particuliers (réf interne)
# restent toujours visibles. Aucun double-comptage.
# -----------------------------------------------------------------------------
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_price
from ..schema import PropertyListing
SITE = "https://www.publimaison.ca"
HASH = ("/show=liste/regions=/villes=/type_propriete=/categories=/prix_min=0"
"/prix_max=0/caracteristiques=/chambres=0/salles_bain=0/etat=1"
"/parution=4/construction=5/trier_par=3/existante=true/nbr_item=8")
DETAIL_LIMIT = int(os.environ.get("IMMOKA_PUBLIMAISON_DETAIL_LIMIT",
os.environ.get("IMMOKA_DETAIL_LIMIT", "300")))
AGENCY = "Publimaison"
_CARD_RE = re.compile(
r'href="/fr/annonce/(\d+)/([a-z0-9-]+?)-a-vendre-([a-z0-9-]+)"'
r'\s+title="(\d{6,9}) - ([^"]+?) à vendre"', re.I)
_TOTAL_RE = re.compile(r"sur\s+(\d{2,6})\s*
")
# fiche détail : « original » = pleine résolution (extrabig/extrathumbnail =
# variantes). La galerie fotorama porte data-full="…fr__original_…" où le
# slug de type suit celui de l'annonce (maison, condo, terrain, duplex, chalet…)
# — NE PAS figer « maison » : 44 % de l'inventaire n'en est pas. L'avatar du
# courtier (fr_original_…, sans slug de type) ne matche aucun des deux motifs.
_GALLERY_RE = re.compile(r'data-full="(https://image\.publimaison\.ca/[^"]+)"', re.I)
_PHOTO_RE = re.compile(
r"https://image\.publimaison\.ca/fr_[a-z0-9_]+?_original_[^\"'\\ ]+\.jpg", re.I)
# page /carte : seul le marqueur de la propriété a des littéraux dans LatLng(…)
# (le centre par défaut 46.8032826,-71.242796 passe par des variables)
_LATLNG_RE = re.compile(
r"google\.maps\.LatLng\(\s*(-?\d{1,2}\.\d+)\s*,\s*(-?\d{2,3}\.\d+)\s*\)")
class PublimaisonConnector(BaseConnector):
source_id = "publimaison_ag_qc"
request_delay = 0.25
max_pages = 900 # garde-fou (8 annonces/page)
def fetch(self) -> list[PropertyListing]:
by_id: dict[str, PropertyListing] = {}
total, dry = None, 0
for page in range(self.max_pages):
url = f"{SITE}/fr/recherche/?hash={HASH}/page={page}"
try:
html = self.get(url).text
except Exception:
break
if total is None:
m = _TOTAL_RE.search(html)
total = int(m.group(1)) if m else 0
before = len(by_id)
for lst in self._cards(html):
by_id.setdefault(lst.external_id, lst)
# les premières pages contiennent des items promus qui se
# chevauchent : on ne s'arrête qu'après 3 pages sans rien de neuf.
dry = dry + 1 if len(by_id) == before else 0
if dry >= 3:
break
if total and len(by_id) >= total:
break
listings = list(by_id.values())
# v2 : galerie toutes catégories (v1 ne captait que fr_maison_*),
# description fiche-description, prix d'en-tête, GPS via /carte
du.enrich(self, listings, DETAIL_LIMIT, parse_publimaison_detail,
key="v2", fetch_html=self._detail_html)
return listings
def _detail_html(self, url: str) -> str:
"""Fiche détail + onglet /carte (marqueur Google Maps -> lat/lng) ;
la carte est best-effort : son échec ne coûte pas la fiche."""
html = self.get(url).text
try:
html += "\n" + self.get(url.rstrip("/") + "/carte").text
except Exception:
pass
return html
# -- cartes de résultats ----------------------------------------------------
def _cards(self, html: str):
for blk in html.split('maison_resultat_recherche')[1:]:
blk = blk[:4000]
m = _CARD_RE.search(blk)
if not m:
continue
aid, type_s, region_s, ref, type_txt = m.groups()
def field(cls: str) -> str:
fm = re.search(r'class="' + cls + r'">.*?>([^<]+)', blk, re.S)
return _html.unescape(fm.group(1)).strip() if fm else ""
ville = re.search(r'class="ville">([^<]+)<', blk)
desc = re.search(r'class="description">([^<]+)<', blk)
img = re.search(r'src="(https://image\.publimaison\.ca/[^"]+)"', blk)
prix_txt = field("prix") # « 500 000 $ -4 Ch. »
mb = re.search(r"-\s*(\d{1,2})\s*Ch", prix_txt)
# réf 7-8 chiffres = n° MLS (transfert Centris) -> clé de dédup ;
# sinon annonce de particulier -> id d'annonce Publimaison.
is_mls = len(ref) in (7, 8)
yield PropertyListing(
source=self.source_id,
external_id=ref if is_mls else aid,
url=f"{SITE}/fr/annonce/{aid}/{type_s}-a-vendre-{region_s}",
title=field("adresse") or type_txt,
address=field("adresse"),
city=_html.unescape(ville.group(1)).strip() if ville else "",
region=_deslug(region_s),
property_type=type_txt,
price=parse_price(prix_txt),
price_label=prix_txt.split("-")[0].strip(),
bedrooms=int(mb.group(1)) if mb else None,
mls=ref if is_mls else "",
description=_html.unescape(desc.group(1)).strip() if desc else "",
images=[img.group(1).replace("_large_", "_extralarge_")] if img else [],
agency=AGENCY,
)
# ---------------------------------------------------------------------------
def parse_publimaison_detail(html: str) -> dict:
"""Fiche détail (HTML pur, + page /carte concaténée) : galerie originale
complète (fotorama data-full, ordre de la fiche), code postal, année, prix,
pièces et dimensions, taxes/évaluation, superficies, GPS du marqueur."""
out: dict = {}
details: dict = {}
seen, imgs = set(), []
for u in (_GALLERY_RE.findall(html) or _PHOTO_RE.findall(html)):
if u not in seen:
seen.add(u)
imgs.append(u)
if imgs:
out["images"] = imgs
# GPS : marqueur de la page /carte (littéraux), borné au Québec ;
# on saute le centre par défaut de la carte au cas où.
for mlat, mlng in _LATLNG_RE.findall(html):
try:
lat, lng = float(mlat), float(mlng)
except ValueError:
continue
if (44.0 <= lat <= 63.0 and -80.5 <= lng <= -56.0
and (round(lat, 4), round(lng, 4)) != (46.8033, -71.2428)):
out["lat"], out["lng"] = lat, lng
break
text = du.flatten(html)
# prix d'en-tête « … J7Y0A5 (No. MLS …) | 324 900 $ | Détails | … »
# (les cartes « Prix sur demande » ont parfois un prix sur la fiche)
mp = re.search(r"\|\s*([\d][\d\s]{1,11}\$)\s*\|\s*Détails\b", text)
if mp:
p = parse_price(mp.group(1))
if p:
out["price"] = p
out["price_label"] = re.sub(r"\s+", " ", mp.group(1)).strip()
# « 128 Rue Léonard, Châteauguay (Montérégie) J6K1N8 (No. MLS 14082277) »
mh = re.search(r"([A-Z]\d[A-Z]\s?\d[A-Z]\d)\s*(?:\(No\. MLS|\|)", text)
if mh:
details["postal_code"] = mh.group(1).strip()
# bloc « Données de base » (Libellé | Valeur, N/D = absent) :
# Construction | 2007 | … | Pièces | 8 | Chambre(s) | 2 | Salle(s) de bain | 1
my = re.search(r"(?:Année de construction|Construction)\s*\|\s*((?:1[6-9]|20)\d{2})\b", text)
if my:
out["year_built"] = int(my.group(1))
mb2 = re.search(r"Chambre\(s\)\s*\|\s*(\d{1,2})\b", text)
if mb2 and int(mb2.group(1)) > 0:
out["bedrooms"] = int(mb2.group(1))
ms2 = re.search(r"Salle\(s\) de bain\s*\|\s*(\d{1,2})\b", text)
if ms2 and int(ms2.group(1)) > 0:
out["bathrooms"] = int(ms2.group(1))
mse = re.search(r"Salle\(s\) d[’']eau\s*\|\s*(\d{1,2})\b", text)
if mse and int(mse.group(1)) > 0:
out["powder_rooms"] = int(mse.group(1))
mpc = re.search(r"Pièces\s*\|\s*(\d{1,2})\b", text)
if mpc and int(mpc.group(1)) > 0:
details["Nombre de pièces"] = int(mpc.group(1))
met = re.search(r"Nombre d[’']étage\(s\)\s*\|\s*(\d{1,2})\b", text)
if met and int(met.group(1)) > 0:
details["Étages"] = int(met.group(1))
for lab in ("Dimensions du bâtiment", "Dimensions du terrain"):
mdim = re.search(re.escape(lab) + r"\s*\|\s*([\d][^|]{0,24}?)\s*\|", text)
if mdim:
val = mdim.group(1).strip()
details[lab] = val
if lab == "Dimensions du terrain" and "lot_sqft" not in out:
mlx = re.match(r"([\d.,]+)\s*[xX]\s*([\d.,]+)\s*(pi|m)\b", val)
if mlx:
try:
a = float(mlx.group(1).replace(",", "."))
b = float(mlx.group(2).replace(",", "."))
except ValueError:
a = b = 0.0
if a and b:
f = 10.7639 if mlx.group(3) == "m" else 1
out["lot_sqft"] = round(a * b * f, 0)
for label, key in [("Évaluation totale", "Évaluation municipale"),
("Taxes municipales", "Taxes municipales"),
("Taxes scolaires", "Taxes scolaires")]:
mv = re.search(re.escape(label) + r"\s*\|\s*([\d\s,]+\$)", text)
if mv:
details[key] = mv.group(1).strip()
# superficies (habitable / terrain), formats « NNNN pi² » ou « N X M pi »
ma = re.search(r"(?:Superficie habitable|Sup\. habitable)\s*\|\s*([\d\s,.]+)\s*pi", text)
if ma:
try:
out["area_sqft"] = float(ma.group(1).replace(" ", "").replace(",", "."))
except ValueError:
pass
ml = re.search(r"Superficie du terrain\s*\|\s*([\d\s,.]+)\s*pi", text)
if ml:
try:
out["lot_sqft"] = float(ml.group(1).replace(" ", "").replace(",", "."))
except ValueError:
pass
# tableau des pièces : « Nom | Niveau | L X l pi | Revêtement » — le niveau
# est libre (Rez-de-jardin, Mezzanine…), c'est la cellule dimensions qui ancre
rooms = []
for rm in re.finditer(
r"\|\s*([A-ZÀ-Ü][^|]{1,28}?)\s*\|\s*([A-Za-zÀ-ü][A-Za-zÀ-ü0-9'’ -]{1,20}?)"
r"\s*\|\s*([\d.,]+\s*X\s*[\d.,]+\s*(?:pi|m))\s*\|\s*([^|]{0,25}?)\s*(?=\|)", text):
rooms.append({"nom": rm.group(1).strip(), "niveau": rm.group(2).strip(),
"dimensions": rm.group(3).strip(),
"revetement": rm.group(4).strip()})
if rooms:
details["pieces"] = rooms[:30]
# comptes dérivés du tableau des pièces (appliqués seulement si le
# champ manque encore — apply_detail n'écrase jamais une valeur)
nb_ch = sum(1 for r in rooms if r["nom"].lower().startswith("chambre"))
nb_sdb = sum(1 for r in rooms if r["nom"].lower().startswith("salle de bain"))
nb_se = sum(1 for r in rooms
if r["nom"].lower().startswith(("salle d'eau", "salle d’eau")))
if nb_ch and "bedrooms" not in out:
out["bedrooms"] = nb_ch
if nb_sdb and "bathrooms" not in out:
out["bathrooms"] = nb_sdb
if nb_se and "powder_rooms" not in out:
out["powder_rooms"] = nb_se
# description longue de la fiche : bloc « fiche-description » (h4 +
# de
) — le lien statcounter « Voir … » est retiré avant l'aplatissage
md = re.search(r'class="fiche-description[^"]*".*?
(.*?)
', html, re.S)
if md:
d = re.sub(r"
", " ", md.group(1), flags=re.S)
d = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", d))).strip()
if len(d) > 60:
out["description"] = d[:4000]
if details:
out["details"] = details
return out
def _deslug(s: str) -> str:
r = re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()
return (r.replace("Monteregie", "Montérégie").replace("Quebec", "Québec")
.replace("Montreal", "Montréal").replace("Abitibi Temiscamingue", "Abitibi-Témiscamingue")
.replace("Gaspesie", "Gaspésie").replace("Cote Nord", "Côte-Nord")
.replace("Ile", "Île").replace("Saguenay Lac St Jean", "Saguenay–Lac-Saint-Jean"))