Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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