# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/oxford.py : connecteur Appartements Oxford (appartoxford.com) # Complexe locatif de Lennoxville (Sherbrooke), près de l'Université # Bishop's. WordPress + Elementor : une fiche par TYPE d'unité (2½ tout # inclus, 3½, 4½, 5½), listées dans le menu « Logements » de la page # d'accueil. Chaque fiche affiche le prix (« À partir de 760$ / par mois ») # et, seulement quand des unités sont libres, un encart de disponibilité # (« 1 unité disponible 1er octobre 2026 », widget Elementor à visibilité # conditionnelle) : les fiches SANS cet encart ne sont pas des annonces # courantes et sont écartées. Caractéristiques (listes à pictos), description # et galerie extraites de la fiche. Peu de requêtes (accueil + ~4 fiches) : # pas de cache détail nécessaire — la disponibilité doit être relue à chaque # synchronisation. # ----------------------------------------------------------------------------- from __future__ import annotations import re from urllib.parse import unquote from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://appartoxford.com" # variantes redimensionnées WordPress -> pleine taille _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) # « 1 unité disponible 1er octobre 2026 » / « 2 unités disponibles maintenant » _AVAIL_RE = re.compile(r"(\d+|une?)\s+unit[ée]s?\s+disponibles?\b", re.I) _NO_AVAIL_RE = re.compile(r"aucune\s+unit[ée]", re.I) # bruit d'en-tête dans les listes à pictos (coordonnées du site) _HEADER_NOISE_RE = re.compile(r"\+?1?\s?8\d{2}[-\s]\d{3}|Lennoxville,\s*Sherbrooke", re.I) _MAX_FICHES = 12 # garde-fou (le menu compte ~4 fiches) def _clean_unit_type(raw: str) -> str: ut = normalize_unit_type(raw or "") if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): return ut return "" class OxfordConnector(BaseConnector): source_id = "oxford" request_delay = 0.7 def fetch(self) -> list[Listing]: home = self.get(BASE + "/").text soup = BeautifulSoup(home, "html.parser") # fiches du menu « Logements » : liens /appartement// urls: list[str] = [] for a in soup.select('a[href*="/appartement/"]'): url = (a.get("href") or "").split("#")[0] if not url.startswith("http"): url = BASE + url if url.rstrip("/") not in [u.rstrip("/") for u in urls]: urls.append(url) listings: dict[str, Listing] = {} for url in urls[:_MAX_FICHES]: try: lst = self._parse_fiche(url) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst return list(listings.values()) # -- fiche type d'unité ------------------------------------------------------- def _parse_fiche(self, url: str) -> Listing | None: resp = self.get(url) url = resp.url or url # les anciens slugs redirigent (301) m = re.search(r"/appartement/([^/?#]+)", url) if not m: return None # slug décodé + minuscule : « tres-grand-4½-a-louer » (dédoublonne # les variantes d'encodage %C2%BD / %c2%bd) ext_id = unquote(m.group(1)).lower() html = resp.text soup = BeautifulSoup(html, "html.parser") text = soup.get_text(" ", strip=True) # encart de disponibilité : widget texte Elementor à visibilité # conditionnelle, présent seulement si des unités sont libres availability = "" for w in soup.select(".elementor-widget-text-editor"): t = re.sub(r"\s+", " ", w.get_text(" ", strip=True)).strip() if _AVAIL_RE.match(t) and len(t) < 90: availability = t break if not availability or _NO_AVAIL_RE.search(availability): return None # pas d'unité libre = pas d'annonce h1 = soup.select_one("h1") title = re.sub(r"\s+", " ", h1.get_text(" ", strip=True)).strip() \ if h1 else ext_id # prix : « À partir de | 1000$ / par mois » (widgets bannière) price_label = "" m_price = re.search( r"À partir de\s*(\d[\d\s,]*\$\s*(?:/\s*par mois|/\s*mois)?)", text) if m_price: price_label = "À partir de " + re.sub( r"\s+", " ", m_price.group(1)).strip() # caractéristiques : listes à pictos Elementor (sans les coordonnées # affichées dans l'en-tête du site) amenities: list[str] = [] for li in soup.select(".elementor-icon-list-text"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip() if (t and len(t) < 90 and t not in amenities and not _HEADER_NOISE_RE.search(t)): amenities.append(t) # description : contenu de l'article (widget theme-post-content de la # section Caractéristiques) description = "" for w in soup.select(".elementor-widget-theme-post-content, " ".elementor-widget-text-editor"): t = re.sub(r"\s+", " ", w.get_text(" ", strip=True)).strip() if len(t) > 150 and not _AVAIL_RE.match(t): description = t[:1500] break # galerie : photos des téléversements (logos/pictos exclus) images: list[str] = [] for u in re.findall( r"https://appartoxford\.com/wp-content/uploads/[^\"'()\s]+" r"\.(?:jpg|jpeg|webp)", html, re.I): u = _SIZE_SUFFIX.sub("", u) if u not in images and not re.search(r"logo|icon", u, re.I): images.append(u) return Listing( source=self.source_id, external_id=ext_id, url=url, title=title, address="", sector="Lennoxville", # affiché en en-tête du site city="Sherbrooke", unit_type=_clean_unit_type(title), price_label=price_label, availability=availability, description=description, amenities=amenities[:25], images=images[:30], )