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%
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