# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/devcore.py : connecteur Devcore Group (devcore.ca — 555 portes à # Sept-Îles + Gatineau/Maniwaki/Thurso/Val-d'Or ; siège à Gatineau). # WordPress + WPML. Le site n'expose pas d'unités individuelles : chaque # « milieu de vie » (CPT `properties`) est une page marketing par complexe. # L'API REST wp-json est ouverte et PRIVILÉGIÉE ici (4 requêtes par sync, # zéro parsing de gabarit) : /wp/v2/properties + les taxonomies # property_city (ville), property_type (« 1 chambre », « Studio »…) et # property_price (fourchettes ; « … / mois » = locatif, « $100,000… » = # vente). Une annonce Lou-Ka = un complexe locatif : prix seulement si la # page annonce « à partir de N$ » (jamais de valeur inventée), typologies # en commodités, fourchettes en détails. Filtres : propriétés de l'Ontario # (Cornwall, Ottawa, Limoges…) exclues, vente-seulement exclue, logements # corporatifs (moyen terme) exclus, doublons éditoriaux (pages dupliquées # du même complexe) dédoublonnés par ville+description. La page /location/ # du site est périmée (fiches 404, métadonnées croisées) : non utilisée. # robots.txt ouvert, sitemap Yoast. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import re from bs4 import BeautifulSoup from ..schema import Listing, strip_accents from .base import BaseConnector BASE = "https://devcore.ca" API = f"{BASE}/wp-json/wp/v2" # villes du Québec : nom de terme property_city -> (ville Lou-Ka, secteur) _QC_CITIES = { "sept-iles": ("Sept-Îles", ""), "gatineau": ("Gatineau", ""), "aylmer": ("Gatineau", "Aylmer"), "hull": ("Gatineau", "Hull"), "val d'or": ("Val-d'Or", ""), "val-d'or": ("Val-d'Or", ""), "thurso": ("Thurso", ""), "maniwaki": ("Maniwaki", ""), } # suffixe de redimensionnement WordPress (« -1024x683.jpg » -> pleine taille) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) _FROM_PRICE_RE = re.compile( r"(?:à partir de|a partir de|starting at)\s*([\d\s, ]{3,9})\s*\$" r"|(?:à partir de|a partir de|starting at)\s*\$\s*([\d\s, ]{3,9})", re.I) _AVAIL_RE = re.compile( r"Disponible\s+(?:pour|d[èe]s|en|le)\s+[\wà-ÿ']+(?:\s+\d{4})?", re.I) def _city_key(name: str) -> str: return strip_accents(htmllib.unescape(name or "").strip().lower()) \ .replace("’", "'") class DevcoreConnector(BaseConnector): source_id = "devcore" request_delay = 0.6 # -- appels API ------------------------------------------------------------ def _terms(self, taxonomy: str) -> dict[int, str]: data = self.get(f"{API}/{taxonomy}?per_page=100").json() return {t["id"]: htmllib.unescape(t["name"]) for t in data} def fetch(self) -> list[Listing]: cities = self._terms("property_city") types = self._terms("property_type") prices = self._terms("property_price") props = self.get(f"{API}/properties?per_page=100").json() candidates: list[Listing] = [] for p in props: try: lst = self._parse_property(p, cities, types, prices) except Exception: continue if lst is not None: candidates.append(lst) # dédoublonnage éditorial : le site duplique certains complexes sous # plusieurs slugs (« Domaine de la Baie » x3…) — clé = ville + titre # normalisé (suffixe « , Ville » retiré) ; on garde le plus petit id # WP (le plus ancien = le plus stable d'une synchro à l'autre) dedup: dict[str, Listing] = {} for lst in sorted(candidates, key=lambda l: int(l.external_id)): t = strip_accents(lst.title.lower()) t = re.sub(rf",\s*{re.escape(strip_accents(lst.city.lower()))}\s*$", "", t) key = f"{lst.city}|{re.sub(r'[^a-z0-9]+', ' ', t).strip()}" if key not in dedup: dedup[key] = lst return list(dedup.values()) # -- une propriété (complexe locatif) --------------------------------------- def _parse_property(self, p: dict, cities: dict, types: dict, prices: dict) -> Listing | None: link = p.get("link") or "" if "/en/" in link: # doublons de traduction WPML return None title = htmllib.unescape((p.get("title") or {}).get("rendered", "")).strip() if re.search(r"corporatif|corporate", title, re.I): return None # logement corporatif = moyen terme # locatif seulement : au moins une fourchette « … / mois » ou « / month » price_labels = [prices[i] for i in p.get("property_price", []) if i in prices] monthly = [l for l in price_labels if re.search(r"/\s*(mois|month)", l, re.I)] if not monthly: return None # vente ou page sans données # ville (taxonomie property_city) : Québec seulement city = sector = "" for i in p.get("property_city", []): hit = _QC_CITIES.get(_city_key(cities.get(i, ""))) if hit: city, sector = hit break if not city: return None # Ontario (Cornwall, Limoges…) ou vide # contenu rendu : description, images, disponibilité, « à partir de » content = (p.get("content") or {}).get("rendered", "") soup = BeautifulSoup(content, "html.parser") paras = [re.sub(r"\s+", " ", el.get_text(" ", strip=True)) for el in soup.find_all(["p", "h2", "h3", "li"])] paras = [t for t in paras if len(t) > 2 and t not in ("Découvrez le projet",)] description = "\n".join(dict.fromkeys(paras))[:1500] if not description: return None # page coquille (« projet à venir ») text = soup.get_text(" ", strip=True) price_label = "" m = _FROM_PRICE_RE.search(text) if m: amount = re.sub(r"[\s, ]", "", m.group(1) or m.group(2)) price_label = f"à partir de {amount}$" availability = "" m = _AVAIL_RE.search(text) if m: availability = re.sub(r"\s+", " ", m.group(0)).strip() images: list[str] = [] for img in soup.select("img[src]"): u = _SIZE_SUFFIX.sub("", img["src"].strip()) if (u.startswith("http") and "/wp-content/uploads/" in u and not u.lower().endswith(".svg") and u not in images): images.append(u) # typologies offertes (taxonomie property_type) -> commodités affichables type_names = list(dict.fromkeys( types[i] for i in p.get("property_type", []) if i in types)) amenities = [f"Unités : {t}" for t in type_names] details: dict = {"complex": title} if type_names: details["unit_types"] = type_names if monthly: details["price_ranges"] = monthly return Listing( source=self.source_id, external_id=str(p["id"]), # id WordPress stable url=link, title=title, sector=sector, city=city, unit_type="", # complexe multi-typologies price=None, # rempli par finalize() si price_label price_label=price_label, availability=availability, description=description, amenities=amenities, details=details, images=images[:20], )