SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
10.1 KB · 231 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        # clé v2 : ajoute GPS (carte Google de la fiche), prix de la fiche,125        # description non tronquée et repli d'images detBigNWNew126        du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v2")127        return listings128129130# bloc prix de la fiche : <div class="price"><p>1 950 $</p><p>À partir de|131# Ferme|Par mois|Par semaine|Prix sur demande…</p></div>132_RE_PRICE_BOX = re.compile(r'<div class="price">\s*<p>(.*?)</p>\s*<p>(.*?)</p>',133                           re.S)134# fréquences non mensuelles : le montant n'est PAS un loyer mensuel135_RE_NON_MENSUEL = re.compile(r"(?i)semaine|jour|nuit|heure|saison|ann[ée]e")136137138def _parse_lespac_detail(html: str) -> dict:139    """Fiche LesPAC : description complète, adresse civique, coordonnées GPS140    (carte statique Google de la fiche), prix (bloc « price »), caractéristiques141    (boîte « Caractéristiques » : <p><span>Label</span><span>Valeur</span></p>,142    valeurs parfois enrobées de liens) et galerie pleine taille (basephoto)."""143    out: dict = {}144    # description : tout le contenu après <p class="title">Description</p>145    # jusqu'à la fin du conteneur (certaines fiches ont plusieurs <p>)146    md = re.search(r'<div id="description">.*?<p class="title">Description</p>(.*?)</div>',147                   html, re.S | re.I)148    if md:149        desc = _clean(md.group(1))150        if desc:151            out["description"] = desc[:6000]152153    # GPS : chaque fiche embarque une carte statique Google + un lien154    # « voir sur Google Maps » q=lat,lng — coordonnées fournies par la source155    # (précision variable : centroïde de ville quand l'annonceur n'a pas donné156    # d'adresse ; finalize rejette tout point hors Québec)157    coords = du.gmaps_coords(html)158    if coords:159        out["lat"], out["lng"] = coords160161    amenities, details = [], {}162163    # prix affiché sur la fiche : « 1 950 $ | À partir de », « 1 700 $ |164    # Ferme », « 2 150 $ | Par mois »… — mensuel sauf mention contraire ;165    # bloc vide (« Prix sur demande », « Gratuit ») : rien n'est inventé166    mp = _RE_PRICE_BOX.search(html)167    if mp:168        montant, note = _clean(mp.group(1)), _clean(mp.group(2))169        mv = re.search(r"(\d[\d\s ]*(?:[.,]\d{2})?)\s*\$", montant)170        if mv:171            if _RE_NON_MENSUEL.search(note):172                # loyer non mensuel (chalet à la semaine…) : pas de price,173                # mais l'info reste affichable174                details["Fréquence du loyer"] = note175            else:176                try:177                    val = float(re.sub(r"[\s  ]", "", mv.group(1))178                                .replace(",", "."))179                except ValueError:180                    val = None181                if val and 100 <= val <= 20000:182                    out["price"] = val183                    label = f"{montant} par mois"184                    if note.lower().startswith("à partir"):185                        label = f"à partir de {montant} par mois"186                    out["price_label"] = label187    mbox = re.search(r'>Caractéristiques</p>\s*<div class="box">(.*?)</div>',188                     html, re.S)189    if mbox:190        for lm, vm in re.findall(r"<p><span>(.*?)</span>\s*<span>(.*?)</span>",191                                 mbox.group(1), re.S):192            label, value = _clean(lm), _clean(vm)193            if not label or not value:194                continue195            amenities.append(f"{label} : {value}")196            details[label] = value197            if label == "Adresse":198                # « 439 Rue Bellevue, Municipalité de Saint-Donat, QC, Canada »199                parts = [p.strip() for p in value.split(",") if p.strip()]200                if parts and re.match(r"\s*\d", parts[0]):201                    out["address"] = parts[0]202                if len(parts) >= 3 and parts[-2].upper() == "QC":203                    ville = re.sub(r"^(?:Municipalité|Ville|Paroisse|Canton)"204                                   r"(?:\s+de\s+|\s+d[e']\s*)?", "", parts[-3]).strip()205                    if ville:206                        out["city"] = ville207            elif label == "Nombre de pièces":208                out["unit_type"] = value          # ex. « 4 1/2 pièces »209    if amenities:210        out["amenities"] = amenities211    if details:212        out["details"] = details213    # galerie pleine taille (basephoto) ; repli : ids des variantes214    # detBigNWNew/detThumb re-mappés sur basephoto (certaines fiches ne215    # servent que ces formats) — les annonces réellement sans photo216    # (« Prix sur demande » type démarchage) restent sans image217    imgs, seen = [], set()218    for u in re.findall(r'https://cdn\.lespac\.com/binary/basephoto/\d+\.jpg', html):219        if u not in seen:220            seen.add(u)221            imgs.append(u)222    if not imgs:223        for u in re.findall(224                r'https://cdn\.lespac\.com/binary/detBigNWNew/\d+\.jpg', html):225            if u not in seen:226                seen.add(u)227                imgs.append(u)228    if imgs:229        out["images"] = imgs230    return out231