spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/beaudoin.py : connecteur Société Beaudoin immobilier5# (beaudoinimmobilier.ca — Longueuil, Boucherville, Montréal :6# Anjou, Lachine, Saint-Léonard). Thème WordPress Houzez : les logements7# sont des posts « property » exposés par l'API REST8# (/wp-json/wp/v2/properties) avec prix, adresse géocodée, lat/lng,9# taxonomies (type d'unité, ville, disponibilité, commodités) et10# galerie d'images (IDs de médias résolus via /wp-json/wp/v2/media).11# Le site couvre d'autres régions : seul le Grand Montréal est conservé.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import html as htmllib16import re1718from ..schema import Listing, normalize_unit_type, strip_accents19from .base import BaseConnector2021BASE = "https://www.beaudoinimmobilier.ca"22API = f"{BASE}/index.php/wp-json/wp/v2"2324# Villes admissibles (Grand Montréal) — comparées sans accents, en minuscules.25_ALLOWED_CITIES = (26 "montreal", "longueuil", "boucherville", "brossard", "saint-lambert",27 "laval", "lachine", "anjou", "saint-leonard", "saint-hubert",28)293031def _clean(txt: str) -> str:32 txt = re.sub(r"<[^>]+>", " ", txt or "")33 return re.sub(r"\s+", " ", htmllib.unescape(txt)).strip()343536def _additional_features(serialized: str) -> list[str]:37 """Champ Houzez « additional_features » (PHP sérialisé) -> libellés.3839 Paires titre/valeur (« Eau chaude » / « incluse ») -> « Eau chaude incluse ».40 """41 toks = re.findall(r's:\d+:"((?:[^"\\]|\\.)*)"', serialized or "")42 out: list[str] = []43 i = 044 while True:45 try:46 ti = toks.index("fave_additional_feature_title", i)47 except ValueError:48 break49 title = toks[ti + 1].strip() if ti + 1 < len(toks) else ""50 value = ""51 if ti + 3 < len(toks) and toks[ti + 2] == "fave_additional_feature_value":52 value = toks[ti + 3].strip()53 label = f"{title} {value}".strip()54 if title and label not in out:55 out.append(label)56 i = ti + 257 return out585960class BeaudoinConnector(BaseConnector):61 source_id = "beaudoin"62 request_delay = 0.563 max_pages = 5 # garde-fou (5 x 100 propriétés)64 max_media_ids = 30 # images max par annonce6566 # -- helpers REST ----------------------------------------------------------67 def _terms(self, taxonomy: str) -> dict[int, str]:68 try:69 data = self.get(f"{API}/{taxonomy}?per_page=100").json()70 return {t["id"]: htmllib.unescape(t["name"]) for t in data}71 except Exception:72 return {}7374 def _media_urls(self, ids: list[str]) -> list[str]:75 ids = [i for i in ids if str(i).isdigit()][: self.max_media_ids]76 if not ids:77 return []78 try:79 url = (f"{API}/media?include={','.join(map(str, ids))}"80 f"&per_page=100&_fields=id,source_url")81 data = self.get(url).json()82 by_id = {str(m["id"]): m.get("source_url", "") for m in data}83 return [by_id[str(i)] for i in ids if by_id.get(str(i))]84 except Exception:85 return []8687 @staticmethod88 def _split_city(name: str) -> tuple[str, str]:89 """'Montréal Anjou' -> ('Montréal', 'Anjou') ; 'Longueuil' -> (ville, '')."""90 name = name.strip()91 if strip_accents(name.lower()).startswith("montreal"):92 sector = name[len("Montréal"):].strip(" -–")93 return "Montréal", sector94 return name, ""9596 @staticmethod97 def _city_allowed(name: str) -> bool:98 key = strip_accents(name.lower()).replace(" ", "-")99 return any(tok in key for tok in _ALLOWED_CITIES)100101 # -- fetch ------------------------------------------------------------------102 def fetch(self) -> list[Listing]:103 types = self._terms("property_type")104 statuses = self._terms("property_status")105 cities = self._terms("property_city")106 features = self._terms("property_feature")107108 props: list[dict] = []109 for page in range(1, self.max_pages + 1):110 try:111 batch = self.get(112 f"{API}/properties?per_page=100&page={page}").json()113 except Exception:114 break115 if not isinstance(batch, list) or not batch:116 break117 props.extend(batch)118 if len(batch) < 100:119 break120121 listings: list[Listing] = []122 for p in props:123 try:124 lst = self._parse_property(p, types, statuses, cities, features)125 except Exception:126 continue127 if lst:128 listings.append(lst)129 return listings130131 def _parse_property(self, p: dict, types: dict, statuses: dict,132 cities: dict, features: dict) -> Listing | None:133 meta = p.get("property_meta") or {}134135 def m1(key: str) -> str:136 v = meta.get(key) or []137 return str(v[0]).strip() if v and v[0] is not None else ""138139 # Ville / secteur (taxonomie property_city, ex. « Montréal Anjou »)140 city_name = next((cities[i] for i in (p.get("property_city") or [])141 if i in cities), "")142 if not city_name or not self._city_allowed(city_name):143 return None # hors Grand Montréal144 city, sector = self._split_city(city_name)145146 title = _clean((p.get("title") or {}).get("rendered") or "")147148 # Adresse géocodée : « 7340, Avenue Guy, Anjou, Montréal, ... Canada »149 map_addr = m1("fave_property_map_address")150 address = ", ".join(s.strip() for s in map_addr.split(",")[:2]) if map_addr else ""151 address = re.sub(r"\s+", " ", address).strip()152153 # Type d'unité (taxonomie « 3 1/2 », « 4 1/2 Penthouse », ...)154 type_name = next((types[i] for i in (p.get("property_type") or [])155 if i in types), "")156 unit_type = normalize_unit_type(type_name)157158 # Prix mensuel (champ Houzez)159 price = None160 price_label = ""161 raw_price = m1("fave_property_price")162 if raw_price:163 try:164 price = float(re.sub(r"[^\d.]", "", raw_price))165 except ValueError:166 price = None167 postfix = m1("fave_property_price_postfix") or "mois"168 price_label = f"{raw_price}$ / {postfix}"169 if price is not None and not (100 <= price <= 20000):170 price = None171172 # Disponibilité (taxonomie property_status, « 08 - Disponible pour août »)173 avail = next((statuses[i] for i in (p.get("property_status") or [])174 if i in statuses), "")175 availability = re.sub(r"^\d+\s*-\s*", "", avail)176177 amenities = [features[i] for i in (p.get("property_feature") or [])178 if i in features]179 # « Caractéristiques additionnelles » Houzez (PHP sérialisé) :180 # Eau chaude incluse, Lave-vaisselle inclus, Chiens et chats acceptés…181 for label in _additional_features(m1("additional_features")):182 if label not in amenities:183 amenities.append(label)184185 # Superficie structurée (fave_property_size, préfixe « Pieds carrés »)186 area_sqft = None187 raw_size = m1("fave_property_size")188 size_prefix = m1("fave_property_size_prefix").lower()189 if raw_size:190 try:191 val = float(raw_size.replace(",", "."))192 except ValueError:193 val = None194 if val and 80 <= val <= 20000 and (195 not size_prefix or "pied" in size_prefix196 or "pi" in size_prefix or "sq" in size_prefix):197 area_sqft = val198199 # Chambres / salles de bain (champs Houzez) -> commodités affichables200 beds = m1("fave_property_bedrooms")201 baths = m1("fave_property_bathrooms")202 if beds.isdigit() and int(beds) > 0:203 amenities.append(f"{beds} chambre{'s' if int(beds) > 1 else ''}")204 if baths.isdigit() and int(baths) > 0:205 amenities.append(206 f"{baths} salle{'s' if int(baths) > 1 else ''} de bain")207208 # Coordonnées209 lat = lng = None210 try:211 lat = float(m1("houzez_geolocation_lat"))212 lng = float(m1("houzez_geolocation_long"))213 except (ValueError, TypeError):214 lat = lng = None215216 # Galerie d'images (IDs de médias -> URLs)217 images = self._media_urls(meta.get("fave_property_images") or [])218 if not images:219 thumb = m1("_thumbnail_id")220 if thumb:221 images = self._media_urls([thumb])222223 description = _clean((p.get("content") or {}).get("rendered") or "")[:600]224225 return Listing(226 source=self.source_id,227 external_id=str(p.get("id")),228 url=p.get("link") or "",229 title=title or f"Logement {p.get('id')}",230 address=address,231 sector=sector,232 city=city,233 unit_type=unit_type,234 price=price,235 price_label=price_label,236 availability=availability,237 area_sqft=area_sqft,238 description=description,239 amenities=amenities,240 images=images,241 lat=lat,242 lng=lng,243 )244