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%
7.3 KB · 174 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (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 LOCATION résidentielle :6#     b457 logements · b458 chambres & colocation · b460 résidences pour aînés7#   Les pages « /montreal/…_b{cat}g17567k{page}R2.jsa » embarquent8#   `var searchResponse = {…}` côté serveur : 20-24 annonces/page + totalPages.9#   Le site force une ville d'ancrage (g17567 = Montréal) mais le jeu de10#   résultats couvre TOUTE la province, simplement trié par distance — vérifié :11#   l'ancre Québec (g15398) donne le même totalPages. Adapté du connecteur12#   « achat-vente » d'Immo-Ka (agent-courtage/immoka).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import html as _html17import json18import os19import re2021from ..schema import Listing22from .base import BaseConnector2324from . import _detailutil as du2526BASE = "https://www.lespac.com"27ANCHOR = "montreal"                      # ville d'ancrage (tri par distance)28ANCHOR_GEO = "g17567"29DETAIL_LIMIT = int(os.environ.get("LOUKA_LESPAC_DETAIL_LIMIT", "400"))30CATEGORIES = [31    (457, "immobilier-location-logements", ""),32    (458, "immobilier-location-colocataires", "Chambre"),33    (460, "immobilier-location-residences-pour-aines", ""),34]35_RE_RESP = re.compile(r"var searchResponse = (\{.*?\});\s*[\r\n]", re.S)36_RE_DEMI = re.compile(r"(\d+)\s*(?:½|1/2)")373839def _clean(s: str) -> str:40    return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip()414243class LesPacConnector(BaseConnector):44    source_id = "lespac"45    request_delay = 0.94647    def _search_page(self, slug: str, cat: int, page: int) -> dict | None:48        url = f"{BASE}/{ANCHOR}/{slug}_b{cat}{ANCHOR_GEO}k{page}R2.jsa"49        m = _RE_RESP.search(self.get(url).text)50        return json.loads(m.group(1)) if m else None5152    def _to_listing(self, r: dict, unit_default: str) -> Listing | None:53        lid = str(r.get("listingPublicId") or "")54        url = (r.get("listingDisplayUrl") or "").split("?")[0]55        if not lid or not url:56            return None57        title = r.get("title") or ""58        unit_type = unit_default59        if not unit_type:60            m = _RE_DEMI.search(title)61            if m:62                unit_type = f"{m.group(1)}½"63        # prix : LesPAC affiche « Par mois » / « Par semaine » dans priceNote64        note = (r.get("priceNote") or "").strip()65        price, price_label = None, ""66        if r.get("price") is not None and ("mois" in note.lower() or not note):67            price = float(r["price"])68            price_label = f"{r.get('priceLabel') or ''} par mois".strip()69        details: dict = {}70        if note and "mois" not in note.lower():71            details["Fréquence du loyer"] = note        # ex. « Par semaine »72        track = r.get("searchPageTrackingInfo") or {}73        region = ((track.get("listing-region-code") or {}).get("value") or "")74        if region:75            details["Région"] = region76        # « Montréal / Centre-Sud / Centre-Ville » → ville + secteur ;77        # « Autres Provinces » est souvent une erreur de classement LesPAC :78        # on laisse vide, la vraie ville viendra de l'adresse en fiche détail79        city_label = r.get("cityLabel") or ""80        seg = [s.strip() for s in city_label.split("/") if s.strip()]81        city = seg[0] if seg else ""82        sector = " / ".join(seg[1:]) if len(seg) > 1 else ""83        if city.lower() == "autres provinces":84            city = ""85        images = [i["formattableImageUrl"].replace("%FORMAT%", "zoomedGallery")86                  for i in r.get("images") or [] if i.get("formattableImageUrl")]87        return Listing(88            source=self.source_id,89            external_id=lid,90            url=url,91            title=title,92            city=city,93            sector=sector,94            unit_type=unit_type,95            price=price,96            price_label=price_label,97            description=(r.get("description") or "")[:2000],98            details=details,99            images=images,100        )101102    def fetch(self) -> list[Listing]:103        out: dict[str, Listing] = {}104        for cat, slug, unit_default in CATEGORIES:105            page, total_pages = 1, 1106            while page <= total_pages:107                try:108                    d = self._search_page(slug, cat, page)109                except Exception:110                    break111                if not d:112                    break113                total_pages = min(int(d.get("totalPages") or 1), 400)114                fresh = 0115                for r in d.get("searchResults") or []:116                    lst = self._to_listing(r, unit_default)117                    if lst is not None and lst.uid not in out:118                        out[lst.uid] = lst119                        fresh += 1120                if fresh == 0 and page > 1:   # fin réelle malgré totalPages121                    break122                page += 1123        listings = list(out.values())124        du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v1")125        return listings126127128def _parse_lespac_detail(html: str) -> dict:129    """Fiche LesPAC : description complète, adresse civique, caractéristiques130    (boîte « Caractéristiques » : <p><span>Label</span><span>Valeur</span></p>,131    valeurs parfois enrobées de liens) et galerie pleine taille (basephoto)."""132    out: dict = {}133    md = re.search(r'<div id="description">.*?<p class="title">Description</p>\s*<p>(.*?)</p>',134                   html, re.S | re.I)135    if md:136        desc = _clean(md.group(1))137        if desc:138            out["description"] = desc[:6000]139    amenities, details = [], {}140    mbox = re.search(r'>Caractéristiques</p>\s*<div class="box">(.*?)</div>',141                     html, re.S)142    if mbox:143        for lm, vm in re.findall(r"<p><span>(.*?)</span>\s*<span>(.*?)</span>",144                                 mbox.group(1), re.S):145            label, value = _clean(lm), _clean(vm)146            if not label or not value:147                continue148            amenities.append(f"{label} : {value}")149            details[label] = value150            if label == "Adresse":151                # « 439 Rue Bellevue, Municipalité de Saint-Donat, QC, Canada »152                parts = [p.strip() for p in value.split(",") if p.strip()]153                if parts and re.match(r"\s*\d", parts[0]):154                    out["address"] = parts[0]155                if len(parts) >= 3 and parts[-2].upper() == "QC":156                    ville = re.sub(r"^(?:Municipalité|Ville|Paroisse|Canton)"157                                   r"(?:\s+de\s+|\s+d[e']\s*)?", "", parts[-3]).strip()158                    if ville:159                        out["city"] = ville160            elif label == "Nombre de pièces":161                out["unit_type"] = value          # ex. « 4 1/2 pièces »162    if amenities:163        out["amenities"] = amenities164    if details:165        out["details"] = details166    imgs, seen = [], set()167    for u in re.findall(r'https://cdn\.lespac\.com/binary/basephoto/\d+\.jpg', html):168        if u not in seen:169            seen.add(u)170            imgs.append(u)171    if imgs:172        out["images"] = imgs173    return out174