# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/habitations_jeanne.py : connecteur Les Habitations Jeanne inc. # (leshabitationsjeanne.com — Baie-Comeau, Côte-Nord, 200+ appartements). # Site Wix rendu serveur : la page /appartements-meubles présente des cartes # d'unités réelles (adresse civique, numéro d'appartement « App. 12 », type # « appartement 5 ½ », galerie de photos Wix) — location mensuelle, meublé. # La page /appartements-non-meubles est une vitrine (texte + adresses du parc, # aucune carte d'unité) : elle est parcourue avec le même parseur et # contribuera des annonces si des cartes y apparaissent un jour. # Aucun prix ni disponibilité publiés (granularité vitrine assumée, comme # immeubles_guillot) ; les photos et types sont réels, unité par unité. # robots.txt Wix ouvert (Disallow *?lightbox= seulement), sitemap XML. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector BASE = "https://www.leshabitationsjeanne.com" # (url, meublé) — les deux pages du site ; seuls les meublés ont des cartes PAGES = [ (f"{BASE}/appartements-meubles", True), (f"{BASE}/appartements-non-meubles", False), ] _APP_RE = re.compile(r"App\.\s*(\d+[A-Za-z]?)", re.I) _TYPE_RE = re.compile(r"([1-9])\s*(?:½|1/2)") # adresse civique : numéro puis nom de voie (« 3, avenue du Père-Arnaud », # « 20 Roberval ») — le vrai filtre est l'appariement avec le bloc « App. » suivant _ADDR_RE = re.compile(r"^\d+[A-Za-z]?,?\s+\D") def _slug(text: str) -> str: """Texte -> slug stable ascii (« 3, avenue du Père-Arnaud » -> « 3-avenue-du-pere-arnaud »).""" s = strip_accents(text.lower()) return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-") def _full_size(url: str) -> str: """URL wixstatic redimensionnée -> média pleine taille (coupe au /v1/).""" return url.split("/v1/")[0] if "/v1/" in url else url class HabitationsJeanneConnector(BaseConnector): source_id = "habitations_jeanne" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for url, furnished in PAGES: try: html = self.get(url).text except Exception: continue self._parse_page(html, url, furnished, listings) return list(listings.values()) # -- cartes d'unités (Wix SSR) -------------------------------------------------- def _parse_page(self, html: str, page_url: str, furnished: bool, listings: dict[str, Listing]) -> None: soup = BeautifulSoup(html, "html.parser") # séquence des blocs de texte riche : une adresse civique suivie du bloc # « App. N / appartement X ½ » = une carte d'unité blocks = soup.select('div[data-testid="richTextElement"]') for i, blk in enumerate(blocks): addr = re.sub(r"\s+", " ", blk.get_text(" ", strip=True)) if not _ADDR_RE.match(addr) or _APP_RE.search(addr): continue if i + 1 >= len(blocks): continue info = re.sub(r"\s+", " ", blocks[i + 1].get_text(" ", strip=True)) m_app = _APP_RE.search(info) if not m_app: continue app_no = m_app.group(1) m_type = _TYPE_RE.search(info) unit_label = f"{m_type.group(1)} ½" if m_type else "" ext_id = f"{_slug(addr)}--app-{app_no.lower()}" if not ext_id or ext_id in listings: continue # galerie : images wixstatic de la carte (ancêtre contenant adresse + App.) images: list[str] = [] card = blk while card is not None and "App." not in card.get_text(): card = card.parent if card is not None: for im in card.select("img[src]"): u = _full_size(im["src"]) if u.startswith("https://static.wixstatic.com") and u not in images: images.append(u) title = f"{addr} — App. {app_no}" if unit_label: title += f" ({unit_label}{' meublé' if furnished else ''})" listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=page_url, title=title, address=addr, city="Baie-Comeau", # tout le parc affiché est à Baie-Comeau unit_type=normalize_unit_type(unit_label), price=None, # jamais publié sur le site price_label="", availability="", furnished=furnished, images=images[:15], )