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/beaulieu_groupe.py : connecteur Beaulieu Groupe Immobilier / BGI5# (beaulieugroupeimmobilier.com — Rimouski, Bas-Saint-Laurent ; distinct des6# Immeubles Beaulieu et Collin `immeubles_bc` et de Beaulieu Gestion à7# Rivière-du-Loup). WordPress + Elementor avec un shortcode maison :8# - archive /batiment/?category=residentiel : cartes `div.card-batiment`9# par immeuble avec statut « COMPLET » ou « Locaux disponibles : N » ;10# - fiche /batiment/<slug>/ : description de l'immeuble + cartes d'unités11# `div.card-local` (« 418 Alcide-C.-Horth, app #206 | 4 1/2 ») dont le12# `p.card-text` vaut « Indisponible » (image générique Complet.png) ou,13# pour les unités libres, « Superficie : N pi² » avec photo réelle.14# Le connecteur ne visite que les fiches des immeubles NON marqués COMPLET15# (politesse) et n'émet que les unités non « Indisponible ». Aucun prix16# publié -> price=None, rien d'inventé. Tout le parc affiché est à Rimouski.17# external_id stable : slug de la fiche + numéro d'appartement18# (418-rue-alcide-c-horth--app-206).19# robots.txt Yoast ouvert (Disallow vide), sitemap public.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import re2425from bs4 import BeautifulSoup2627from ..schema import Listing, normalize_unit_type28from .base import BaseConnector2930BASE = "https://beaulieugroupeimmobilier.com"31LIST_URL = f"{BASE}/batiment/?category=residentiel"3233# « 418 Alcide-C.-Horth, app #206 | 4 1/2 » -> adresse, n° app, type34_UNIT_RE = re.compile(r"^(.*?),?\s*app\s*#\s*(\w+)\s*(?:\|\s*(.+))?$", re.I)35_BG_IMG_RE = re.compile(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)")36_AREA_RE = re.compile(r"superficie\s*:?\s*([\d\s,]+)\s*pi", re.I)373839class BeaulieuGroupeConnector(BaseConnector):40 source_id = "beaulieu_groupe"41 request_delay = 0.642 max_detail = 12 # plafond de fiches visitées par sync4344 def fetch(self) -> list[Listing]:45 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")4647 listings: dict[str, Listing] = {}48 visited = 049 for card in soup.select("div.card-batiment"):50 link = card.select_one('a[href*="/batiment/"]')51 if link is None:52 continue53 url = link["href"].strip()54 status = ""55 for p in card.select("p"):56 t = p.get_text(" ", strip=True)57 if t and "VOIR LA FICHE" not in t.upper():58 status = t59 break60 # les immeubles COMPLET n'ont aucune unité libre : fiche non visitée61 if "COMPLET" in status.upper() or visited >= self.max_detail:62 continue63 visited += 164 try:65 self._parse_building(url, listings)66 except Exception:67 continue68 return list(listings.values())6970 # -- fiche d'immeuble (/batiment/<slug>/) -------------------------------------71 def _parse_building(self, url: str, listings: dict[str, Listing]) -> None:72 m = re.search(r"/batiment/([^/?#]+)", url)73 if not m:74 return75 slug = m.group(1)76 soup = BeautifulSoup(self.get(url).text, "html.parser")7778 # description de l'immeuble (paragraphes Elementor avant la liste)79 paras = [p.get_text(" ", strip=True)80 for p in soup.select(".elementor-widget-text-editor p")]81 description = "\n".join(t for t in paras if len(t) >= 40)[:1200]8283 for card in soup.select("div.card-local"):84 h5 = card.select_one("h5")85 info = card.select_one("p.card-text")86 if h5 is None:87 continue88 head = re.sub(r"\s+", " ", h5.get_text(" ", strip=True))89 info_txt = re.sub(90 r"\s+", " ", info.get_text(" ", strip=True)) if info else ""91 if re.search(r"(?i)indisponible", info_txt):92 continue # unité louée9394 m_unit = _UNIT_RE.match(head)95 if not m_unit:96 continue # carte sans n° d'app (bandeau)97 addr, app_no, type_txt = (m_unit.group(1).strip(),98 m_unit.group(2),99 (m_unit.group(3) or "").strip())100101 ext_id = f"{slug}--app-{app_no.lower()}"102 if ext_id in listings:103 continue104105 area = None106 m_area = _AREA_RE.search(info_txt)107 if m_area:108 try:109 area = float(m_area.group(1).replace(" ", "")110 .replace(" ", "").replace(",", "."))111 except ValueError:112 area = None113114 images: list[str] = []115 img = card.select_one(".card-img-top")116 if img is not None:117 m_bg = _BG_IMG_RE.search(img.get("style", ""))118 if m_bg and m_bg.group(1).startswith("http") \119 and "Complet" not in m_bg.group(1):120 images.append(m_bg.group(1))121122 listings[ext_id] = Listing(123 source=self.source_id,124 external_id=ext_id,125 url=url,126 title=head,127 address=f"{addr}, Rimouski" if addr else "",128 city="Rimouski", # tout le parc affiché est à Rimouski129 unit_type=normalize_unit_type(type_txt),130 price=None, # aucun loyer publié sur le site131 price_label="",132 availability="", # libre = non « Indisponible » ; pas de date publiée133 area_sqft=area,134 description=description,135 images=images,136 )137