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/publimaison.py : Publimaison.ca (FSBO + transferts Centris)5#6# 2e plateforme « vente par le propriétaire » du Québec (~5 400 annonces),7# qui accepte aussi les transferts Centris de courtiers (« No. MLS » affiché).8# Recherche rendue SERVEUR pilotée par le pseudo-hash d'URL9# (/fr/recherche/?hash=/…/nbr_item=8/page=N, page 0-indexée, 8 cartes/page,10# total « Résultats de 1 à 8 sur NNNN »). Chaque carte porte déjà : réf MLS,11# type, ville, prix, chambres, adresse, description et photo extralarge.12# La fiche détail (HTML, sans JSON-LD) ajoute galerie complète, code postal,13# pièces et dimensions, taxes et évaluation — enrichissement cache + plafond.14#15# source_id « publimaison_ag_qc » : l'infixe _ag_ active la dédup Centris —16# quand la réf de la carte est un n° MLS déjà porté par la bannière du17# courtier, la fiche est masquée ; les annonces de particuliers (réf interne)18# restent toujours visibles. Aucun double-comptage.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import html as _html23import os24import re2526from .base import BaseConnector27from . import _detailutil as du28from ..normalize import parse_price29from ..schema import PropertyListing3031SITE = "https://www.publimaison.ca"32HASH = ("/show=liste/regions=/villes=/type_propriete=/categories=/prix_min=0"33 "/prix_max=0/caracteristiques=/chambres=0/salles_bain=0/etat=1"34 "/parution=4/construction=5/trier_par=3/existante=true/nbr_item=8")35DETAIL_LIMIT = int(os.environ.get("IMMOKA_PUBLIMAISON_DETAIL_LIMIT",36 os.environ.get("IMMOKA_DETAIL_LIMIT", "300")))37AGENCY = "Publimaison"3839_CARD_RE = re.compile(40 r'href="/fr/annonce/(\d+)/([a-z0-9-]+?)-a-vendre-([a-z0-9-]+)"'41 r'\s+title="(\d{6,9}) - ([^"]+?) à vendre"', re.I)42_TOTAL_RE = re.compile(r"sur\s+(\d{2,6})\s*</p>")43# fiche détail : « original » = pleine résolution (extrabig/extrathumbnail =44# variantes). La galerie fotorama porte data-full="…fr_<type>_original_…" où le45# slug de type suit celui de l'annonce (maison, condo, terrain, duplex, chalet…)46# — NE PAS figer « maison » : 44 % de l'inventaire n'en est pas. L'avatar du47# courtier (fr_original_…, sans slug de type) ne matche aucun des deux motifs.48_GALLERY_RE = re.compile(r'data-full="(https://image\.publimaison\.ca/[^"]+)"', re.I)49_PHOTO_RE = re.compile(50 r"https://image\.publimaison\.ca/fr_[a-z0-9_]+?_original_[^\"'\\ ]+\.jpg", re.I)51# page /carte : seul le marqueur de la propriété a des littéraux dans LatLng(…)52# (le centre par défaut 46.8032826,-71.242796 passe par des variables)53_LATLNG_RE = re.compile(54 r"google\.maps\.LatLng\(\s*(-?\d{1,2}\.\d+)\s*,\s*(-?\d{2,3}\.\d+)\s*\)")555657class PublimaisonConnector(BaseConnector):58 source_id = "publimaison_ag_qc"59 request_delay = 0.2560 max_pages = 900 # garde-fou (8 annonces/page)6162 def fetch(self) -> list[PropertyListing]:63 by_id: dict[str, PropertyListing] = {}64 total, dry = None, 065 for page in range(self.max_pages):66 url = f"{SITE}/fr/recherche/?hash={HASH}/page={page}"67 try:68 html = self.get(url).text69 except Exception:70 break71 if total is None:72 m = _TOTAL_RE.search(html)73 total = int(m.group(1)) if m else 074 before = len(by_id)75 for lst in self._cards(html):76 by_id.setdefault(lst.external_id, lst)77 # les premières pages contiennent des items promus qui se78 # chevauchent : on ne s'arrête qu'après 3 pages sans rien de neuf.79 dry = dry + 1 if len(by_id) == before else 080 if dry >= 3:81 break82 if total and len(by_id) >= total:83 break84 listings = list(by_id.values())85 # v2 : galerie toutes catégories (v1 ne captait que fr_maison_*),86 # description fiche-description, prix d'en-tête, GPS via /carte87 du.enrich(self, listings, DETAIL_LIMIT, parse_publimaison_detail,88 key="v2", fetch_html=self._detail_html)89 return listings9091 def _detail_html(self, url: str) -> str:92 """Fiche détail + onglet /carte (marqueur Google Maps -> lat/lng) ;93 la carte est best-effort : son échec ne coûte pas la fiche."""94 html = self.get(url).text95 try:96 html += "\n" + self.get(url.rstrip("/") + "/carte").text97 except Exception:98 pass99 return html100101 # -- cartes de résultats ----------------------------------------------------102 def _cards(self, html: str):103 for blk in html.split('maison_resultat_recherche')[1:]:104 blk = blk[:4000]105 m = _CARD_RE.search(blk)106 if not m:107 continue108 aid, type_s, region_s, ref, type_txt = m.groups()109110 def field(cls: str) -> str:111 fm = re.search(r'class="' + cls + r'">.*?>([^<]+)</a>', blk, re.S)112 return _html.unescape(fm.group(1)).strip() if fm else ""113114 ville = re.search(r'class="ville">([^<]+)<', blk)115 desc = re.search(r'class="description">([^<]+)<', blk)116 img = re.search(r'src="(https://image\.publimaison\.ca/[^"]+)"', blk)117 prix_txt = field("prix") # « 500 000 $ -4 Ch. »118 mb = re.search(r"-\s*(\d{1,2})\s*Ch", prix_txt)119120 # réf 7-8 chiffres = n° MLS (transfert Centris) -> clé de dédup ;121 # sinon annonce de particulier -> id d'annonce Publimaison.122 is_mls = len(ref) in (7, 8)123 yield PropertyListing(124 source=self.source_id,125 external_id=ref if is_mls else aid,126 url=f"{SITE}/fr/annonce/{aid}/{type_s}-a-vendre-{region_s}",127 title=field("adresse") or type_txt,128 address=field("adresse"),129 city=_html.unescape(ville.group(1)).strip() if ville else "",130 region=_deslug(region_s),131 property_type=type_txt,132 price=parse_price(prix_txt),133 price_label=prix_txt.split("-")[0].strip(),134 bedrooms=int(mb.group(1)) if mb else None,135 mls=ref if is_mls else "",136 description=_html.unescape(desc.group(1)).strip() if desc else "",137 images=[img.group(1).replace("_large_", "_extralarge_")] if img else [],138 agency=AGENCY,139 )140141142# ---------------------------------------------------------------------------143def parse_publimaison_detail(html: str) -> dict:144 """Fiche détail (HTML pur, + page /carte concaténée) : galerie originale145 complète (fotorama data-full, ordre de la fiche), code postal, année, prix,146 pièces et dimensions, taxes/évaluation, superficies, GPS du marqueur."""147 out: dict = {}148 details: dict = {}149150 seen, imgs = set(), []151 for u in (_GALLERY_RE.findall(html) or _PHOTO_RE.findall(html)):152 if u not in seen:153 seen.add(u)154 imgs.append(u)155 if imgs:156 out["images"] = imgs157158 # GPS : marqueur de la page /carte (littéraux), borné au Québec ;159 # on saute le centre par défaut de la carte au cas où.160 for mlat, mlng in _LATLNG_RE.findall(html):161 try:162 lat, lng = float(mlat), float(mlng)163 except ValueError:164 continue165 if (44.0 <= lat <= 63.0 and -80.5 <= lng <= -56.0166 and (round(lat, 4), round(lng, 4)) != (46.8033, -71.2428)):167 out["lat"], out["lng"] = lat, lng168 break169170 text = du.flatten(html)171172 # prix d'en-tête « … J7Y0A5 (No. MLS …) | 324 900 $ | Détails | … »173 # (les cartes « Prix sur demande » ont parfois un prix sur la fiche)174 mp = re.search(r"\|\s*([\d][\d\s]{1,11}\$)\s*\|\s*Détails\b", text)175 if mp:176 p = parse_price(mp.group(1))177 if p:178 out["price"] = p179 out["price_label"] = re.sub(r"\s+", " ", mp.group(1)).strip()180181 # « 128 Rue Léonard, Châteauguay (Montérégie) J6K1N8 (No. MLS 14082277) »182 mh = re.search(r"([A-Z]\d[A-Z]\s?\d[A-Z]\d)\s*(?:\(No\. MLS|\|)", text)183 if mh:184 details["postal_code"] = mh.group(1).strip()185186 # bloc « Données de base » (Libellé | Valeur, N/D = absent) :187 # Construction | 2007 | … | Pièces | 8 | Chambre(s) | 2 | Salle(s) de bain | 1188 my = re.search(r"(?:Année de construction|Construction)\s*\|\s*((?:1[6-9]|20)\d{2})\b", text)189 if my:190 out["year_built"] = int(my.group(1))191 mb2 = re.search(r"Chambre\(s\)\s*\|\s*(\d{1,2})\b", text)192 if mb2 and int(mb2.group(1)) > 0:193 out["bedrooms"] = int(mb2.group(1))194 ms2 = re.search(r"Salle\(s\) de bain\s*\|\s*(\d{1,2})\b", text)195 if ms2 and int(ms2.group(1)) > 0:196 out["bathrooms"] = int(ms2.group(1))197 mse = re.search(r"Salle\(s\) d[’']eau\s*\|\s*(\d{1,2})\b", text)198 if mse and int(mse.group(1)) > 0:199 out["powder_rooms"] = int(mse.group(1))200 mpc = re.search(r"Pièces\s*\|\s*(\d{1,2})\b", text)201 if mpc and int(mpc.group(1)) > 0:202 details["Nombre de pièces"] = int(mpc.group(1))203 met = re.search(r"Nombre d[’']étage\(s\)\s*\|\s*(\d{1,2})\b", text)204 if met and int(met.group(1)) > 0:205 details["Étages"] = int(met.group(1))206 for lab in ("Dimensions du bâtiment", "Dimensions du terrain"):207 mdim = re.search(re.escape(lab) + r"\s*\|\s*([\d][^|]{0,24}?)\s*\|", text)208 if mdim:209 val = mdim.group(1).strip()210 details[lab] = val211 if lab == "Dimensions du terrain" and "lot_sqft" not in out:212 mlx = re.match(r"([\d.,]+)\s*[xX]\s*([\d.,]+)\s*(pi|m)\b", val)213 if mlx:214 try:215 a = float(mlx.group(1).replace(",", "."))216 b = float(mlx.group(2).replace(",", "."))217 except ValueError:218 a = b = 0.0219 if a and b:220 f = 10.7639 if mlx.group(3) == "m" else 1221 out["lot_sqft"] = round(a * b * f, 0)222223 for label, key in [("Évaluation totale", "Évaluation municipale"),224 ("Taxes municipales", "Taxes municipales"),225 ("Taxes scolaires", "Taxes scolaires")]:226 mv = re.search(re.escape(label) + r"\s*\|\s*([\d\s,]+\$)", text)227 if mv:228 details[key] = mv.group(1).strip()229230 # superficies (habitable / terrain), formats « NNNN pi² » ou « N X M pi »231 ma = re.search(r"(?:Superficie habitable|Sup\. habitable)\s*\|\s*([\d\s,.]+)\s*pi", text)232 if ma:233 try:234 out["area_sqft"] = float(ma.group(1).replace(" ", "").replace(",", "."))235 except ValueError:236 pass237 ml = re.search(r"Superficie du terrain\s*\|\s*([\d\s,.]+)\s*pi", text)238 if ml:239 try:240 out["lot_sqft"] = float(ml.group(1).replace(" ", "").replace(",", "."))241 except ValueError:242 pass243244 # tableau des pièces : « Nom | Niveau | L X l pi | Revêtement » — le niveau245 # est libre (Rez-de-jardin, Mezzanine…), c'est la cellule dimensions qui ancre246 rooms = []247 for rm in re.finditer(248 r"\|\s*([A-ZÀ-Ü][^|]{1,28}?)\s*\|\s*([A-Za-zÀ-ü][A-Za-zÀ-ü0-9'’ -]{1,20}?)"249 r"\s*\|\s*([\d.,]+\s*X\s*[\d.,]+\s*(?:pi|m))\s*\|\s*([^|]{0,25}?)\s*(?=\|)", text):250 rooms.append({"nom": rm.group(1).strip(), "niveau": rm.group(2).strip(),251 "dimensions": rm.group(3).strip(),252 "revetement": rm.group(4).strip()})253 if rooms:254 details["pieces"] = rooms[:30]255 # comptes dérivés du tableau des pièces (appliqués seulement si le256 # champ manque encore — apply_detail n'écrase jamais une valeur)257 nb_ch = sum(1 for r in rooms if r["nom"].lower().startswith("chambre"))258 nb_sdb = sum(1 for r in rooms if r["nom"].lower().startswith("salle de bain"))259 nb_se = sum(1 for r in rooms260 if r["nom"].lower().startswith(("salle d'eau", "salle d’eau")))261 if nb_ch and "bedrooms" not in out:262 out["bedrooms"] = nb_ch263 if nb_sdb and "bathrooms" not in out:264 out["bathrooms"] = nb_sdb265 if nb_se and "powder_rooms" not in out:266 out["powder_rooms"] = nb_se267268 # description longue de la fiche : bloc « fiche-description » (h4 + <div>269 # de <p>) — le lien statcounter « Voir … » est retiré avant l'aplatissage270 md = re.search(r'class="fiche-description[^"]*".*?<div>(.*?)</div>', html, re.S)271 if md:272 d = re.sub(r"<a\b.*?</a>", " ", md.group(1), flags=re.S)273 d = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", d))).strip()274 if len(d) > 60:275 out["description"] = d[:4000]276277 if details:278 out["details"] = details279 return out280281282def _deslug(s: str) -> str:283 r = re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()284 return (r.replace("Monteregie", "Montérégie").replace("Quebec", "Québec")285 .replace("Montreal", "Montréal").replace("Abitibi Temiscamingue", "Abitibi-Témiscamingue")286 .replace("Gaspesie", "Gaspésie").replace("Cote Nord", "Côte-Nord")287 .replace("Ile", "Île").replace("Saguenay Lac St Jean", "Saguenay–Lac-Saint-Jean"))288