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/devcore.py : connecteur Devcore Group (devcore.ca — 555 portes à5# Sept-Îles + Gatineau/Maniwaki/Thurso/Val-d'Or ; siège à Gatineau).6# WordPress + WPML. Le site n'expose pas d'unités individuelles : chaque7# « milieu de vie » (CPT `properties`) est une page marketing par complexe.8# L'API REST wp-json est ouverte et PRIVILÉGIÉE ici (4 requêtes par sync,9# zéro parsing de gabarit) : /wp/v2/properties + les taxonomies10# property_city (ville), property_type (« 1 chambre », « Studio »…) et11# property_price (fourchettes ; « … / mois » = locatif, « $100,000… » =12# vente). Une annonce Lou-Ka = un complexe locatif : prix seulement si la13# page annonce « à partir de N$ » (jamais de valeur inventée), typologies14# en commodités, fourchettes en détails. Filtres : propriétés de l'Ontario15# (Cornwall, Ottawa, Limoges…) exclues, vente-seulement exclue, logements16# corporatifs (moyen terme) exclus, doublons éditoriaux (pages dupliquées17# du même complexe) dédoublonnés par ville+description. La page /location/18# du site est périmée (fiches 404, métadonnées croisées) : non utilisée.19# robots.txt ouvert, sitemap Yoast.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as htmllib24import re2526from bs4 import BeautifulSoup2728from ..schema import Listing, strip_accents29from .base import BaseConnector3031BASE = "https://devcore.ca"32API = f"{BASE}/wp-json/wp/v2"3334# villes du Québec : nom de terme property_city -> (ville Lou-Ka, secteur)35_QC_CITIES = {36 "sept-iles": ("Sept-Îles", ""),37 "gatineau": ("Gatineau", ""),38 "aylmer": ("Gatineau", "Aylmer"),39 "hull": ("Gatineau", "Hull"),40 "val d'or": ("Val-d'Or", ""),41 "val-d'or": ("Val-d'Or", ""),42 "thurso": ("Thurso", ""),43 "maniwaki": ("Maniwaki", ""),44}4546# suffixe de redimensionnement WordPress (« -1024x683.jpg » -> pleine taille)47_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)4849_FROM_PRICE_RE = re.compile(50 r"(?:à partir de|a partir de|starting at)\s*([\d\s, ]{3,9})\s*\$"51 r"|(?:à partir de|a partir de|starting at)\s*\$\s*([\d\s, ]{3,9})", re.I)52_AVAIL_RE = re.compile(53 r"Disponible\s+(?:pour|d[èe]s|en|le)\s+[\wà-ÿ']+(?:\s+\d{4})?", re.I)545556def _city_key(name: str) -> str:57 return strip_accents(htmllib.unescape(name or "").strip().lower()) \58 .replace("’", "'")596061class DevcoreConnector(BaseConnector):62 source_id = "devcore"63 request_delay = 0.66465 # -- appels API ------------------------------------------------------------66 def _terms(self, taxonomy: str) -> dict[int, str]:67 data = self.get(f"{API}/{taxonomy}?per_page=100").json()68 return {t["id"]: htmllib.unescape(t["name"]) for t in data}6970 def fetch(self) -> list[Listing]:71 cities = self._terms("property_city")72 types = self._terms("property_type")73 prices = self._terms("property_price")74 props = self.get(f"{API}/properties?per_page=100").json()7576 candidates: list[Listing] = []77 for p in props:78 try:79 lst = self._parse_property(p, cities, types, prices)80 except Exception:81 continue82 if lst is not None:83 candidates.append(lst)8485 # dédoublonnage éditorial : le site duplique certains complexes sous86 # plusieurs slugs (« Domaine de la Baie » x3…) — clé = ville + titre87 # normalisé (suffixe « , Ville » retiré) ; on garde le plus petit id88 # WP (le plus ancien = le plus stable d'une synchro à l'autre)89 dedup: dict[str, Listing] = {}90 for lst in sorted(candidates, key=lambda l: int(l.external_id)):91 t = strip_accents(lst.title.lower())92 t = re.sub(rf",\s*{re.escape(strip_accents(lst.city.lower()))}\s*$", "", t)93 key = f"{lst.city}|{re.sub(r'[^a-z0-9]+', ' ', t).strip()}"94 if key not in dedup:95 dedup[key] = lst96 return list(dedup.values())9798 # -- une propriété (complexe locatif) ---------------------------------------99 def _parse_property(self, p: dict, cities: dict, types: dict,100 prices: dict) -> Listing | None:101 link = p.get("link") or ""102 if "/en/" in link: # doublons de traduction WPML103 return None104 title = htmllib.unescape((p.get("title") or {}).get("rendered", "")).strip()105 if re.search(r"corporatif|corporate", title, re.I):106 return None # logement corporatif = moyen terme107108 # locatif seulement : au moins une fourchette « … / mois » ou « / month »109 price_labels = [prices[i] for i in p.get("property_price", []) if i in prices]110 monthly = [l for l in price_labels if re.search(r"/\s*(mois|month)", l, re.I)]111 if not monthly:112 return None # vente ou page sans données113114 # ville (taxonomie property_city) : Québec seulement115 city = sector = ""116 for i in p.get("property_city", []):117 hit = _QC_CITIES.get(_city_key(cities.get(i, "")))118 if hit:119 city, sector = hit120 break121 if not city:122 return None # Ontario (Cornwall, Limoges…) ou vide123124 # contenu rendu : description, images, disponibilité, « à partir de »125 content = (p.get("content") or {}).get("rendered", "")126 soup = BeautifulSoup(content, "html.parser")127128 paras = [re.sub(r"\s+", " ", el.get_text(" ", strip=True))129 for el in soup.find_all(["p", "h2", "h3", "li"])]130 paras = [t for t in paras if len(t) > 2 and t not in ("Découvrez le projet",)]131 description = "\n".join(dict.fromkeys(paras))[:1500]132 if not description:133 return None # page coquille (« projet à venir »)134135 text = soup.get_text(" ", strip=True)136 price_label = ""137 m = _FROM_PRICE_RE.search(text)138 if m:139 amount = re.sub(r"[\s, ]", "", m.group(1) or m.group(2))140 price_label = f"à partir de {amount}$"141 availability = ""142 m = _AVAIL_RE.search(text)143 if m:144 availability = re.sub(r"\s+", " ", m.group(0)).strip()145146 images: list[str] = []147 for img in soup.select("img[src]"):148 u = _SIZE_SUFFIX.sub("", img["src"].strip())149 if (u.startswith("http") and "/wp-content/uploads/" in u150 and not u.lower().endswith(".svg") and u not in images):151 images.append(u)152153 # typologies offertes (taxonomie property_type) -> commodités affichables154 type_names = list(dict.fromkeys(155 types[i] for i in p.get("property_type", []) if i in types))156 amenities = [f"Unités : {t}" for t in type_names]157158 details: dict = {"complex": title}159 if type_names:160 details["unit_types"] = type_names161 if monthly:162 details["price_ranges"] = monthly163164 return Listing(165 source=self.source_id,166 external_id=str(p["id"]), # id WordPress stable167 url=link,168 title=title,169 sector=sector,170 city=city,171 unit_type="", # complexe multi-typologies172 price=None, # rempli par finalize() si price_label173 price_label=price_label,174 availability=availability,175 description=description,176 amenities=amenities,177 details=details,178 images=images[:20],179 )180