SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
13.4 KB · 288 lines python
Raw Blame History
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