# ----------------------------------------------------------------------------- # 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"))