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/bons_locataires.py : Bons Locataires (bonslocataires.com) — agence5# de location Montréal/Laval/Longueuil. WordPress, aucun anti-bot (seul6# mod_security bloque les sitemaps XML — on ne les utilise pas).7# Source de vérité : la page carte MapListPro (~8 Mo) dont le tableau JSON8# inline contient les 1 600+ emplacements avec lat/lng, taxonomies9# (région/quartier/taille) et description complète. Le site garde les10# logements loués sur la carte (« DÉJÀ LOUÉ », pin PouceVert) : on ne11# conserve que les annonces encore offertes, et on valide que leur page12# (locationUrl) répond toujours 200 (certains vieux liens sont morts).13# Prix et disponibilité en texte libre -> regex. ~20-40 annonces actives.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as H18import json19import re2021from ..schema import Listing, normalize_unit_type22from .base import BaseConnector2324MAP_PAGE = ("https://www.bonslocataires.com/logement-appartement-a-louer-"25 "enquete-proprio-credit-locataire-visite-virtuelle-360/")2627_PRICE_RE = re.compile(r"(\d{3,4})\s*\$\s*(?:/|par)?\s*mois", re.I)28_PRICE_ALT_RE = re.compile(r"(\d{3,4})\s*\$")29_IMG_RE = re.compile(r'src="(https?://www\.bonslocataires\.com/wp-content/'30 r'uploads/[^"]+)"')31_LOC_ID_RE = re.compile(r"loc-(\d+)")32_TAG_RE = re.compile(r"<[^>]+>")333435def _texte(fragment: str) -> str:36 return re.sub(r"\s+\n", "\n", H.unescape(_TAG_RE.sub("", fragment or ""))).strip()373839class BonsLocatairesConnector(BaseConnector):40 source_id = "bons_locataires"41 request_delay = 1.042 timeout = 120 # la page carte fait ~8 Mo4344 def _map_objects(self) -> list[dict]:45 import time46 html = ""47 for attempt in range(3): # mod_security coupe parfois la page 8 Mo48 try:49 html = self.get(MAP_PAGE).text50 break51 except Exception:52 if attempt == 2:53 raise54 time.sleep(20 * (attempt + 1))55 i = html.find('[{"title":')56 if i < 0:57 return []58 depth, end = 0, None59 for j in range(i, len(html)):60 if html[j] == "[":61 depth += 162 elif html[j] == "]":63 depth -= 164 if depth == 0:65 end = j + 166 break67 try:68 return json.loads(html[i:end])69 except ValueError:70 return []7172 @staticmethod73 def _available(obj: dict) -> bool:74 blob = (obj.get("title", "") + (obj.get("description") or "")75 + (obj.get("icon") or "") + (obj.get("pinImageUrl") or ""))76 return "LOU" not in blob.upper() or "PouceVert" not in blob7778 def fetch(self) -> list[Listing]:79 out: list[Listing] = []80 for obj in self._map_objects():81 desc_html = obj.get("description") or ""82 blob = (obj.get("title", "") + desc_html).upper()83 pin = (obj.get("pinImageUrl") or "") + (obj.get("icon") or "")84 if "DÉJÀ LOU" in blob or "DEJA LOU" in blob or "LOUÉ!" in blob \85 or "PouceVert" in pin:86 continue87 url = (obj.get("locationUrl") or "").replace("http://", "https://")88 if not url:89 continue90 try: # vieux liens morts -> annonce écartée91 page_html = self.get(url).text92 except Exception:93 continue94 m = _LOC_ID_RE.search(obj.get("cssClass") or "")95 eid = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1]9697 cats = obj.get("customCategories") or {}98 def _first(tax: str) -> str:99 terms = cats.get(tax) or []100 return terms[0].get("name", "") if terms else ""101 city = _first("map_location_categories_region") or "Montréal"102 sector = _first("map_location_categories_city")103 taille = _first("map_location_categories_taille")104105 title = _texte(obj.get("title", "")).strip("* ")106 unit_type = normalize_unit_type(taille or title)107 if not re.match(r"^\d½$|^6½\+$|^Studio$|^Loft$|^Chambre", unit_type or ""):108 unit_type = ""109110 # la fiche WordPress (déjà téléchargée pour la validation) est plus111 # riche que le popup carte : prix, galerie, disponibilité112 page_main = ""113 m = re.search(r'<div class="entry-content[^"]*">(.*?)</div>\s*'114 r'(?:<footer|<div class="entry-footer)', page_html, re.S)115 if not m:116 m = re.search(r"<article\b.*?</article>", page_html, re.S)117 if m:118 page_main = m.group(0)119 page_text = _texte(page_main)120121 description = _texte(desc_html)122 if 100 < len(page_text) and len(page_text) > len(description):123 description = page_text124 price = None # le popup carte prime : texte ciblé125 for texte in (_texte(desc_html), page_text):126 m = _PRICE_RE.search(texte) or _PRICE_ALT_RE.search(texte)127 if m and 400 <= float(m.group(1)) <= 5000:128 price = float(m.group(1))129 break130 availability = ""131 m = re.search(r"[Dd]isponible[^.\n<]{0,70}", description)132 if m:133 availability = m.group(0).strip()134 images = []135 for u in _IMG_RE.findall(page_main) + _IMG_RE.findall(desc_html):136 u = u.replace("http://", "https://")137 if u not in images:138 images.append(u)139 try:140 lat, lng = float(obj.get("latitude")), float(obj.get("longitude"))141 except (TypeError, ValueError):142 lat = lng = None143 out.append(Listing(144 source=self.source_id,145 external_id=str(eid),146 url=url,147 title=title,148 sector=sector,149 city=city,150 unit_type=unit_type,151 price=price,152 price_label=(f"{price:,.0f} $/mois".replace(",", " ")153 if price else ""),154 availability=availability,155 description=description[:6000],156 images=images,157 lat=lat,158 lng=lng,159 ))160 return out161