# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/mercini.py : connecteur Groupe Mercini (Gestion Faleschini Mercier) # (groupemercini.com — Limoilou, Duberger, Sainte-Foy, Beauport, Charlesbourg, # Saint-Sauveur, Sillery… + Lévis). WordPress + thème WpResidence, tout est # rendu serveur. Liste /appartements/ paginée (12 cartes/page) : titre complet # (alt de l'image), catégorie (« 4½ », « Studio ou 2½ », « Chambre »…), statut # (« Disponible pour visite », « Bientôt disponible »), prix, chambres/sdb. # Fiche détail (via cache BD) : adresse complète avec code postal # (.property_categs), secteur (taxonomie property_city), description riche # (n° de référence REF…, inclusions, animaux…), caractéristiques, galerie # complète (#owl-demo). Les lat/lng du thème sont erronées (latitude négative # identique partout) : ignorées, le géocodeur Lou-Ka prend le relais. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re import unicodedata from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://groupemercini.com" LIST_URL = f"{BASE}/appartements/" # suffixe de redimensionnement WordPress (« -525x328.jpg » -> pleine taille) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) _BG_URL_RE = re.compile(r"background-image\s*:\s*url\(([^)]+)\)") _REF_RE = re.compile(r"\bREF\w+") def _plain(txt: str) -> str: """Normalise l'Unicode décoratif du site (𝐏𝐫𝐢𝐱 -> Prix, 𝘙𝘦́𝘧 -> Réf).""" return unicodedata.normalize("NFKC", txt or "") def _clean_img(url: str) -> str: return _SIZE_SUFFIX.sub("", url.strip()) class MerciniConnector(BaseConnector): source_id = "mercini" request_delay = 0.6 max_pages = 10 # garde-fou de pagination (3 pages actuellement) max_details = 60 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for page in range(1, self.max_pages + 1): url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/" try: html = self.get(url).text except Exception: break soup = BeautifulSoup(html, "html.parser") cards = soup.select("div.property_listing") if not cards: break for card in cards: try: self._parse_card(card, listings) except Exception: continue # fiches détail (cache BD) : adresse complète, secteur, description, # caractéristiques, galerie — max self.max_details vraies requêtes self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte liste (WpResidence) ---------------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one('h4 a[href*="/estate_property/"]') if not link: return url = link["href"] m = re.search(r"/estate_property/([^/]+)/?", url) if not m: return ext_id = m.group(1) # slug WordPress stable if ext_id in listings: return # titre complet : alt de l'image (le h4 est tronqué par le thème) img = card.select_one("img[alt]") title = _plain(img["alt"].strip()) if img and img.get("alt") else "" if not title: title = link.get_text(strip=True) # statut (« Disponible pour visite », « Bientôt disponible », « Loué ») ribbon = card.select_one(".ribbon-inside") status = ribbon.get_text(strip=True) if ribbon else "" if re.search(r"lou[ée]", status, re.I): return # déjà loué # disponibilité : « Disponible : … » ou « Livraison … » du titre ; # sinon le statut, seulement s'il parle de disponibilité (pas « Nouveau ») availability = "" m_av = re.search(r"(?:Disponible\s*:|Livraison\s*:?)\s*([^|]+)", title, re.I) if m_av: availability = m_av.group(1).strip() elif re.search(r"disponible", status, re.I): availability = status # secteur affiché dans le titre (« … | Secteur : Duberger | … ») sector = "" m_sec = re.search(r"Secteur\s*:\s*([^|]+)", title, re.I) if m_sec: sector = m_sec.group(1).strip() # type d'unité : « 4-1/2 » du titre, sinon la catégorie de la carte tag = card.select_one(".action_tag_wrapper") category = tag.get_text(strip=True) if tag else "" unit_type = normalize_unit_type(title.replace("-1/2", " 1/2")) if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo", unit_type or ""): unit_type = normalize_unit_type(category) price_el = card.select_one('[class*="propery_price"]') price_label = price_el.get_text(" ", strip=True) if price_el else "" # chambres / salles de bain structurées sur la carte amenities: list[str] = [] rooms = card.select_one('[class^="inforoom"]') if rooms: amenities.append(rooms.get_text(" ", strip=True)) # « 2 Chambres » baths = card.select_one('[class^="infobath"]') if baths: txt = baths.get_text(" ", strip=True) # « 1.5 Baths » m_b = re.match(r"([\d.]+)", txt) if m_b: amenities.append(f"{m_b.group(1)} salle(s) de bain") images = [] for im in card.select(".carousel-item img[src]"): u = _clean_img(im["src"]) if u.startswith("http") and u not in images: images.append(u) details: dict = {} if status: details["status"] = status if category: details["category"] = category listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=title, sector=sector, city="Québec", # affiné par la fiche détail unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, amenities=amenities, details=details, images=images, ) # -- fiche détail (WpResidence) ---------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Adresse complète + taxonomies, description, caractéristiques, chambres/sdb (overview), galerie complète (#owl-demo).""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # « 3135 Rue Dubé, Québec, QC G1M 2N2, Duberger, Québec, Québec » : # le texte hors liens = adresse civique ; les liens = taxonomies # property_city (secteur) et property_area (ville) categs = soup.select_one(".property_categs") if categs: labels: list[str] = [] for a in categs.select("a"): t = a.get_text(strip=True) if t and t not in labels: labels.append(t) a.extract() # ne garder que l'adresse en texte address = re.sub(r"\s+", " ", categs.get_text(" ", strip=True)) out["address"] = address.strip(" ,") out["taxonomies"] = labels # description riche (bullets ▪️ : inclusions, animaux, référence…) desc_el = soup.select_one(".wpestate_property_description") if desc_el: txt = _plain(desc_el.get_text("\n", strip=True)) txt = re.sub(r"^Description\n", "", txt) txt = re.sub(r"[ \t]+", " ", txt) out["description"] = txt.strip()[:1500] m_ref = _REF_RE.search(out["description"]) if m_ref: out["reference"] = m_ref.group(0) # overview : « 2 Chambres », « 1.5 Salles de bain » out["overview"] = [ re.sub(r"\s+", " ", el.get_text(" ", strip=True)) for el in soup.select(".overview_element") if re.search(r"chambre|salle", el.get_text(), re.I)] # caractéristiques (« Entrée indépendante », « Stationnement extérieur ») feats: list[str] = [] for el in soup.select(".listing_detail"): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if t and not t.lower().startswith("other features") and t not in feats: feats.append(t) out["features"] = feats[:20] # galerie complète : carrousel #owl-demo (background-image 1170x660) images: list[str] = [] owl = soup.select_one("#owl-demo") for div in owl.select("div.item[style]") if owl else []: m = _BG_URL_RE.search(div["style"]) if m: u = _clean_img(m.group(1).strip("'\" ")) if u.startswith("http") and u not in images: images.append(u) out["images"] = images[:30] return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return if d.get("address"): lst.address = d["address"] # taxonomies : secteur (Duberger, Limoilou…) + ville (Québec/Lévis) labels = d.get("taxonomies") or [] if any("lévis" in t.lower() or "levis" in t.lower() for t in labels): lst.city = "Lévis" sectors = [t for t in labels if t not in ("Québec", "Quebec", "Lévis", "Levis")] if sectors and not lst.sector: lst.sector = sectors[0] if d.get("description"): lst.description = d["description"] if d.get("reference"): lst.details["reference"] = d["reference"] extra = (d.get("overview") or []) + (d.get("features") or []) if extra: lst.amenities = list(dict.fromkeys(lst.amenities + extra)) if d.get("images"): lst.images = d["images"]