# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/beaulieu_groupe.py : connecteur Beaulieu Groupe Immobilier / BGI # (beaulieugroupeimmobilier.com — Rimouski, Bas-Saint-Laurent ; distinct des # Immeubles Beaulieu et Collin `immeubles_bc` et de Beaulieu Gestion à # Rivière-du-Loup). WordPress + Elementor avec un shortcode maison : # - archive /batiment/?category=residentiel : cartes `div.card-batiment` # par immeuble avec statut « COMPLET » ou « Locaux disponibles : N » ; # - fiche /batiment// : description de l'immeuble + cartes d'unités # `div.card-local` (« 418 Alcide-C.-Horth, app #206 | 4 1/2 ») dont le # `p.card-text` vaut « Indisponible » (image générique Complet.png) ou, # pour les unités libres, « Superficie : N pi² » avec photo réelle. # Le connecteur ne visite que les fiches des immeubles NON marqués COMPLET # (politesse) et n'émet que les unités non « Indisponible ». Aucun prix # publié -> price=None, rien d'inventé. Tout le parc affiché est à Rimouski. # external_id stable : slug de la fiche + numéro d'appartement # (418-rue-alcide-c-horth--app-206). # robots.txt Yoast ouvert (Disallow vide), sitemap public. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://beaulieugroupeimmobilier.com" LIST_URL = f"{BASE}/batiment/?category=residentiel" # « 418 Alcide-C.-Horth, app #206 | 4 1/2 » -> adresse, n° app, type _UNIT_RE = re.compile(r"^(.*?),?\s*app\s*#\s*(\w+)\s*(?:\|\s*(.+))?$", re.I) _BG_IMG_RE = re.compile(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)") _AREA_RE = re.compile(r"superficie\s*:?\s*([\d\s,]+)\s*pi", re.I) class BeaulieuGroupeConnector(BaseConnector): source_id = "beaulieu_groupe" request_delay = 0.6 max_detail = 12 # plafond de fiches visitées par sync def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") listings: dict[str, Listing] = {} visited = 0 for card in soup.select("div.card-batiment"): link = card.select_one('a[href*="/batiment/"]') if link is None: continue url = link["href"].strip() status = "" for p in card.select("p"): t = p.get_text(" ", strip=True) if t and "VOIR LA FICHE" not in t.upper(): status = t break # les immeubles COMPLET n'ont aucune unité libre : fiche non visitée if "COMPLET" in status.upper() or visited >= self.max_detail: continue visited += 1 try: self._parse_building(url, listings) except Exception: continue return list(listings.values()) # -- fiche d'immeuble (/batiment//) ------------------------------------- def _parse_building(self, url: str, listings: dict[str, Listing]) -> None: m = re.search(r"/batiment/([^/?#]+)", url) if not m: return slug = m.group(1) soup = BeautifulSoup(self.get(url).text, "html.parser") # description de l'immeuble (paragraphes Elementor avant la liste) paras = [p.get_text(" ", strip=True) for p in soup.select(".elementor-widget-text-editor p")] description = "\n".join(t for t in paras if len(t) >= 40)[:1200] for card in soup.select("div.card-local"): h5 = card.select_one("h5") info = card.select_one("p.card-text") if h5 is None: continue head = re.sub(r"\s+", " ", h5.get_text(" ", strip=True)) info_txt = re.sub( r"\s+", " ", info.get_text(" ", strip=True)) if info else "" if re.search(r"(?i)indisponible", info_txt): continue # unité louée m_unit = _UNIT_RE.match(head) if not m_unit: continue # carte sans n° d'app (bandeau) addr, app_no, type_txt = (m_unit.group(1).strip(), m_unit.group(2), (m_unit.group(3) or "").strip()) ext_id = f"{slug}--app-{app_no.lower()}" if ext_id in listings: continue area = None m_area = _AREA_RE.search(info_txt) if m_area: try: area = float(m_area.group(1).replace(" ", "") .replace(" ", "").replace(",", ".")) except ValueError: area = None images: list[str] = [] img = card.select_one(".card-img-top") if img is not None: m_bg = _BG_IMG_RE.search(img.get("style", "")) if m_bg and m_bg.group(1).startswith("http") \ and "Complet" not in m_bg.group(1): images.append(m_bg.group(1)) listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=head, address=f"{addr}, Rimouski" if addr else "", city="Rimouski", # tout le parc affiché est à Rimouski unit_type=normalize_unit_type(type_txt), price=None, # aucun loyer publié sur le site price_label="", availability="", # libre = non « Indisponible » ; pas de date publiée area_sqft=area, description=description, images=images, )