# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/novelo.py : connecteur Les Appartements Novelo inc. # (appartementsnovelo.com — Saguenay, secteur Jonquière/Arvida ; projet du # Groupe Dumont, ~50-100 appartements rénovés). WordPress + Elementor + WPML, # tout rendu serveur sur une page unique /louer-un-appartement/ : une section # par type d'unité (« Appartement — Loft », « — 3 1/2 », « — 4 1/2 », # « — 5 1/2 ») avec prix « À partir de 901$/ mois », description, liste des # services offerts et galerie. Granularité par TYPE (pas d'unités # individuelles ni de dates) — types, prix et photos réels, précédent # immeubles_guillot. Adresse du complexe : rue Langelier, Saguenay. # robots.txt WP standard (wp-admin seulement), sitemap. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://appartementsnovelo.com" LIST_URL = f"{BASE}/louer-un-appartement/" _HEAD_RE = re.compile(r"^Appartement\s*[—–-]\s*(.+)$", re.I) _PRICE_RE = re.compile(r"À partir de\s*\d[\d\s]*\$\s*(?:/\s*mois)?", re.I) # suffixe de redimensionnement WordPress (« -300x200.jpg » -> pleine taille) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) def _slugify(label: str) -> str: """« 4 1/2 » -> « 4-1-2 » ; « Loft » -> « loft » (identifiant stable).""" return re.sub(r"[^a-z0-9]+", "-", strip_accents(label.lower())).strip("-") class NoveloConnector(BaseConnector): source_id = "novelo" request_delay = 0.6 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} # une section Elementor de premier niveau par type d'appartement for h in soup.find_all("h2"): m = _HEAD_RE.match(h.get_text(" ", strip=True)) if not m: continue try: self._parse_section(h, m.group(1).strip(), listings) except Exception: continue return list(listings.values()) # -- section « Appartement — » --------------------------------------------- def _parse_section(self, heading, unit_label: str, listings: dict[str, Listing]) -> None: ext_id = _slugify(unit_label) if not ext_id or ext_id in listings: return # remonter à la section Elementor de premier niveau sec = heading while sec.parent is not None and sec.name != "section": sec = sec.parent if sec.name != "section": return # prix « À partir de 901$/ mois » (le plus bas du type) price_label = "" m = _PRICE_RE.search(sec.get_text(" ", strip=True)) if m: price_label = re.sub(r"\s+", " ", m.group(0)).strip() # description : paragraphes de la section (hors mentions de contact) paras: list[str] = [] for p in sec.find_all("p"): txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if not txt or _PRICE_RE.search(txt): continue if re.search(r"contactez|enquête de crédit|418-|courriel", txt, re.I): continue paras.append(txt) description = "\n".join(paras)[:1500] # services offerts / caractéristiques : items de liste de la section amenities: list[str] = [] for li in sec.find_all("li"): txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if txt and txt not in amenities: amenities.append(txt) # galerie de la section (photos réelles, pleine taille) images: list[str] = [] for img in sec.find_all("img", src=True): u = img["src"] if not u.startswith("http") or "/wp-content/uploads/" not in u: continue u = _SIZE_SUFFIX.sub("", u) if u not in images: images.append(u) listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=f"{LIST_URL}#{ext_id}", title=f"Appartement {unit_label} — Les Appartements Novelo", address="2469, rue Langelier", # complexe (bureau de location) city="Saguenay", unit_type=normalize_unit_type(unit_label), price=parse_price(price_label.replace("À partir de", "").strip()), price_label=price_label, description=description, amenities=amenities[:20], images=images[:25], )