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/oxford.py : connecteur Appartements Oxford (appartoxford.com)5# Complexe locatif de Lennoxville (Sherbrooke), près de l'Université6# Bishop's. WordPress + Elementor : une fiche par TYPE d'unité (2½ tout7# inclus, 3½, 4½, 5½), listées dans le menu « Logements » de la page8# d'accueil. Chaque fiche affiche le prix (« À partir de 760$ / par mois »)9# et, seulement quand des unités sont libres, un encart de disponibilité10# (« 1 unité disponible 1er octobre 2026 », widget Elementor à visibilité11# conditionnelle) : les fiches SANS cet encart ne sont pas des annonces12# courantes et sont écartées. Caractéristiques (listes à pictos), description13# et galerie extraites de la fiche. Peu de requêtes (accueil + ~4 fiches) :14# pas de cache détail nécessaire — la disponibilité doit être relue à chaque15# synchronisation.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from urllib.parse import unquote2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type26from .base import BaseConnector2728BASE = "https://appartoxford.com"2930# variantes redimensionnées WordPress -> pleine taille31_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)3233# « 1 unité disponible 1er octobre 2026 » / « 2 unités disponibles maintenant »34_AVAIL_RE = re.compile(r"(\d+|une?)\s+unit[ée]s?\s+disponibles?\b", re.I)35_NO_AVAIL_RE = re.compile(r"aucune\s+unit[ée]", re.I)3637# bruit d'en-tête dans les listes à pictos (coordonnées du site)38_HEADER_NOISE_RE = re.compile(r"\+?1?\s?8\d{2}[-\s]\d{3}|Lennoxville,\s*Sherbrooke",39 re.I)4041_MAX_FICHES = 12 # garde-fou (le menu compte ~4 fiches)424344def _clean_unit_type(raw: str) -> str:45 ut = normalize_unit_type(raw or "")46 if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""):47 return ut48 return ""495051class OxfordConnector(BaseConnector):52 source_id = "oxford"53 request_delay = 0.75455 def fetch(self) -> list[Listing]:56 home = self.get(BASE + "/").text57 soup = BeautifulSoup(home, "html.parser")5859 # fiches du menu « Logements » : liens /appartement/<slug>/60 urls: list[str] = []61 for a in soup.select('a[href*="/appartement/"]'):62 url = (a.get("href") or "").split("#")[0]63 if not url.startswith("http"):64 url = BASE + url65 if url.rstrip("/") not in [u.rstrip("/") for u in urls]:66 urls.append(url)6768 listings: dict[str, Listing] = {}69 for url in urls[:_MAX_FICHES]:70 try:71 lst = self._parse_fiche(url)72 except Exception:73 continue74 if lst and lst.external_id not in listings:75 listings[lst.external_id] = lst76 return list(listings.values())7778 # -- fiche type d'unité -------------------------------------------------------79 def _parse_fiche(self, url: str) -> Listing | None:80 resp = self.get(url)81 url = resp.url or url # les anciens slugs redirigent (301)82 m = re.search(r"/appartement/([^/?#]+)", url)83 if not m:84 return None85 # slug décodé + minuscule : « tres-grand-4½-a-louer » (dédoublonne86 # les variantes d'encodage %C2%BD / %c2%bd)87 ext_id = unquote(m.group(1)).lower()8889 html = resp.text90 soup = BeautifulSoup(html, "html.parser")91 text = soup.get_text(" ", strip=True)9293 # encart de disponibilité : widget texte Elementor à visibilité94 # conditionnelle, présent seulement si des unités sont libres95 availability = ""96 for w in soup.select(".elementor-widget-text-editor"):97 t = re.sub(r"\s+", " ", w.get_text(" ", strip=True)).strip()98 if _AVAIL_RE.match(t) and len(t) < 90:99 availability = t100 break101 if not availability or _NO_AVAIL_RE.search(availability):102 return None # pas d'unité libre = pas d'annonce103104 h1 = soup.select_one("h1")105 title = re.sub(r"\s+", " ", h1.get_text(" ", strip=True)).strip() \106 if h1 else ext_id107108 # prix : « À partir de | 1000$ / par mois » (widgets bannière)109 price_label = ""110 m_price = re.search(111 r"À partir de\s*(\d[\d\s,]*\$\s*(?:/\s*par mois|/\s*mois)?)", text)112 if m_price:113 price_label = "À partir de " + re.sub(114 r"\s+", " ", m_price.group(1)).strip()115116 # caractéristiques : listes à pictos Elementor (sans les coordonnées117 # affichées dans l'en-tête du site)118 amenities: list[str] = []119 for li in soup.select(".elementor-icon-list-text"):120 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()121 if (t and len(t) < 90 and t not in amenities122 and not _HEADER_NOISE_RE.search(t)):123 amenities.append(t)124125 # description : contenu de l'article (widget theme-post-content de la126 # section Caractéristiques)127 description = ""128 for w in soup.select(".elementor-widget-theme-post-content, "129 ".elementor-widget-text-editor"):130 t = re.sub(r"\s+", " ", w.get_text(" ", strip=True)).strip()131 if len(t) > 150 and not _AVAIL_RE.match(t):132 description = t[:1500]133 break134135 # galerie : photos des téléversements (logos/pictos exclus)136 images: list[str] = []137 for u in re.findall(138 r"https://appartoxford\.com/wp-content/uploads/[^\"'()\s]+"139 r"\.(?:jpg|jpeg|webp)", html, re.I):140 u = _SIZE_SUFFIX.sub("", u)141 if u not in images and not re.search(r"logo|icon", u, re.I):142 images.append(u)143144 return Listing(145 source=self.source_id,146 external_id=ext_id,147 url=url,148 title=title,149 address="",150 sector="Lennoxville", # affiché en en-tête du site151 city="Sherbrooke",152 unit_type=_clean_unit_type(title),153 price_label=price_label,154 availability=availability,155 description=description,156 amenities=amenities[:25],157 images=images[:30],158 )159