SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
6.4 KB · 155 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/lespac.py : LesPAC (lespac.com) — petites annonces du Québec5#   UNIQUEMENT l'immobilier ACHAT-VENTE (pas la location, pas les entreprises) :6#     b37 résidentiel · b38 terrains · b39 commercial-industriel ·7#     b40 chalets · b41 fermes · b42 immeubles à revenus8#   Les pages « /quebec/… _b{cat}k{page}R2.jsa » (toute la province) embarquent9#   `var searchResponse = {…}` côté serveur : 20 annonces/page + totalPages.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import html as _html14import json15import os16import re1718from ..schema import PropertyListing19from .base import BaseConnector2021from . import _detailutil as du2223BASE = "https://www.lespac.com"24DETAIL_LIMIT = int(os.environ.get("IMMOKA_LESPAC_DETAIL_LIMIT", "400"))25CATEGORIES = [26    (37, "immobilier-achat-vente-residentiel", "Maison"),27    (38, "immobilier-achat-vente-terrains", "Terrain"),28    (39, "immobilier-achat-vente-commercial-industriel", "Commercial"),29    (40, "immobilier-achat-vente-chalets", "Chalet"),30    (41, "immobilier-achat-vente-fermes", "Fermette"),31    (42, "immobilier-achat-vente-immeubles-a-revenus", "Immeuble à revenus"),32]33_RE_RESP = re.compile(r"var searchResponse = (\{.*?\});\s*[\r\n]", re.S)343536class LesPacConnector(BaseConnector):37    source_id = "lespac"38    request_delay = 0.93940    def _search_page(self, slug: str, cat: int, page: int) -> dict | None:41        url = f"{BASE}/quebec/{slug}_b{cat}k{page}R2.jsa"42        m = _RE_RESP.search(self.get(url).text)43        return json.loads(m.group(1)) if m else None4445    def _to_listing(self, r: dict, ptype: str) -> PropertyListing | None:46        lid = str(r.get("listingPublicId") or "")47        url = (r.get("listingDisplayUrl") or "").split("?")[0]48        if not lid or not url:49            return None50        # caractéristiques structurées (« Type de propriété », « Chambres »…)51        chars = {c.get("label", ""): str(c.get("value", ""))52                 for c in r.get("characteristics") or [] if c.get("label")}53        def as_int(label):54            m = re.search(r"\d+", chars.get(label, ""))55            return int(m.group(0)) if m else None56        # ville = 1er segment du chemin de l'annonce57        seg = url.replace(BASE + "/", "").split("/")58        city = seg[0].replace("-", " ").title() if seg else ""59        images = [i["formattableImageUrl"].replace("%FORMAT%", "zoomedGallery")60                  for i in r.get("images") or [] if i.get("formattableImageUrl")]61        return PropertyListing(62            source=self.source_id,63            external_id=lid,64            url=url,65            title=r.get("title") or "",66            city=city,67            property_type=chars.get("Type de propriété") or ptype,68            price=r.get("price"),69            price_label=r.get("priceLabel") or "",70            bedrooms=as_int("Chambres"),71            bathrooms=as_int("Salles de bain") or as_int("Salle de bain"),72            year_built=as_int("Année de construction"),73            description=(r.get("description") or "")[:2000],74            features=[f"{k} : {v}" for k, v in chars.items()],75            images=images,76            broker_name=r.get("advertiserName") or "LesPAC (particuliers)",77            agency="LesPAC Québec",78        )7980    def fetch(self) -> list[PropertyListing]:81        out: dict[str, PropertyListing] = {}82        for cat, slug, ptype in CATEGORIES:83            page, total_pages = 1, 184            while page <= total_pages:85                try:86                    d = self._search_page(slug, cat, page)87                except Exception:88                    break89                if not d:90                    break91                total_pages = min(int(d.get("totalPages") or 1), 400)92                for r in d.get("searchResults") or []:93                    lst = self._to_listing(r, ptype)94                    if lst is not None:95                        out.setdefault(lst.uid, lst)96                page += 197        listings = list(out.values())98        du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v1")99        return listings100101102def _clean(s: str) -> str:103    return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip()104105106def _parse_lespac_detail(html: str) -> dict:107    """Fiche LesPAC : description complète, adresse civique, caractéristiques108    (boîte « Caractéristiques » : <p><span>Label</span><span>Valeur</span></p>)109    et galerie pleine taille (binary/basephoto)."""110    out: dict = {}111    md = re.search(r'class="description"[^>]*>(.*?)</(?:p|div)>', html, re.S | re.I)112    if md:113        desc = _clean(md.group(1))114        if desc:115            out["description"] = desc[:6000]116    features, details = [], {}117    mbox = re.search(r'>Caractéristiques</p>\s*<div class="box">(.*?)</div>',118                     html, re.S)119    if mbox:120        for lm, vm in re.findall(r"<p><span>(.*?)</span>\s*<span>(.*?)</span>",121                                 mbox.group(1), re.S):122            label, value = _clean(lm), _clean(vm)123            if not label or not value:124                continue125            features.append(f"{label} : {value}")126            details[label] = value127            if label == "Adresse" and re.match(r"\s*\d", value):128                out["address"] = value129            elif label == "Année":130                my = re.search(r"(18|19|20)\d{2}", value)131                if my:132                    out["year_built"] = int(my.group(0))133            elif label == "Type de propriété":134                out["property_type"] = value135            elif "chambre" in label.lower():136                mn = re.search(r"\d+", value)137                if mn:138                    out["bedrooms"] = int(mn.group(0))139            elif "salle" in label.lower() and "bain" in label.lower():140                mn = re.search(r"\d+", value)141                if mn:142                    out["bathrooms"] = int(mn.group(0))143    if features:144        out["features"] = features145    if details:146        out["details"] = details147    imgs, seen = [], set()148    for u in re.findall(r'https://cdn\.lespac\.com/binary/basephoto/\d+\.jpg', html):149        if u not in seen:150            seen.add(u)151            imgs.append(u)152    if imgs:153        out["images"] = imgs154    return out155