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