# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immo_3r.py : connecteur IMMO 3R (immo3r.com) # Gestion immobilière locative en Mauricie / Centre-du-Québec : # Trois-Rivières (Cap-de-la-Madeleine), Shawinigan, Bécancour, Nicolet, # Saint-Maurice. WordPress rendu serveur : pages ville /location// # avec cartes (type, rue, ville, prix, badge dispo-oui/dispo-non) ; pages # détail /apartments// avec JSON-LD schema.org (Offer → Apartment : # adresse postale complète, description, commodités, photos). # Granularité : une fiche par modèle d'appartement (adresse + typologie), # pas par unité individuelle. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://immo3r.com" # Pages ville (rendues serveur, toutes les cartes présentes dans le HTML) CITY_PAGES = [ (f"{BASE}/location/trois-rivieres/", "Trois-Rivières"), (f"{BASE}/location/shawinigan/", "Shawinigan"), (f"{BASE}/location/becancour/", "Bécancour"), (f"{BASE}/location/nicolet/", "Nicolet"), (f"{BASE}/location/saint-maurice/", "Saint-Maurice"), ] # Badges de disponibilité conservés (cartes) — « En construction » et # « Non disponible » = pas louable maintenant, on exclut. KEEP_STATUS = {"disponible", "bientôt disponible", "bientot disponible"} PRICE_RE = re.compile(r"([\d\s ,]+)\s*\$") IMG_JUNK_RE = re.compile(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", re.I) class Immo3RConnector(BaseConnector): source_id = "immo_3r" request_delay = 0.7 def fetch(self) -> list[Listing]: listings: list[Listing] = [] seen: set[str] = set() for page_url, city in CITY_PAGES: try: html = self.get(page_url).text except Exception: continue soup = BeautifulSoup(html, "html.parser") for a in soup.select('a[href*="/apartments/"]'): try: card = a.select_one("article.apartment-single") if card is None: continue url = a.get("href", "").split("?")[0] slug = url.rstrip("/").split("/")[-1] if not slug or slug in seen: continue status_el = card.select_one(".dispo-oui, .dispo-non") status = (status_el.get_text(" ", strip=True) if status_el else "") if status.strip().lower() not in KEEP_STATUS: continue seen.add(slug) # type d'unité : badge « 5 1/2 » (ou le titre en secours) rooms_el = card.select_one(".apartment-single__rooms") sector_el = card.select_one(".apartment-single__sector") title_txt = (sector_el.get_text(" ", strip=True) if sector_el else "") unit_type = normalize_unit_type( rooms_el.get_text(" ", strip=True) if rooms_el else title_txt) # « Rue des Prairies, Trois-Rivières » (2e span du titre) street = "" h2 = card.select_one(".apartment-single__title") if h2 is not None: spans = [s for s in h2.find_all("span", recursive=False) if "apartment-single__sector" not in (s.get("class") or [])] if spans: street = spans[0].get_text(" ", strip=True) street = re.sub(r"\s*,\s*" + re.escape(city) + r"$", "", street).strip(" ,") price = None price_label = "" price_el = card.select_one(".apartment-single__price") if price_el is not None: price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) m = PRICE_RE.search(price_label) if m: try: price = float(m.group(1) .replace(" ", "") .replace(" ", "") .replace(",", "")) except ValueError: pass # page détail (JSON-LD) via cache BD — revisitée # seulement si la carte liste a changé card_key = hashlib.sha1( f"{status}|{price_label}|{title_txt}|{street}" .encode("utf-8")).hexdigest() d = self.detail(slug, card_key, lambda url=url: self._fetch_detail(url)) title = d.get("name") or title_txt or slug address = d.get("address") or street listings.append(Listing( source=self.source_id, external_id=slug, url=url, title=title, address=address, sector=d.get("sector", ""), city=d.get("city") or city, unit_type=unit_type, price=price if price is not None else d.get("price"), price_label=price_label, availability=status, description=d.get("description", ""), amenities=d.get("amenities") or [], images=d.get("images") or [], )) except Exception: continue return listings def _fetch_detail(self, url: str) -> dict: """Champs riches depuis le JSON-LD `Offer` de la page détail : adresse postale, secteur, description, prix, commodités, photos.""" out: dict = {} try: html = self.get(url).text except Exception: return out offer = None for m in re.finditer( r'', html): try: data = json.loads(m.group(1)) except ValueError: continue if isinstance(data, dict) and data.get("@type") == "Offer" \ and isinstance(data.get("itemOffered"), dict): offer = data break if offer is None: return out apt = offer.get("itemOffered") or {} out["name"] = (apt.get("name") or "").strip() addr = apt.get("address") or {} street = (addr.get("streetAddress") or "").strip() if street: out["address"] = street if addr.get("addressLocality"): out["city"] = addr["addressLocality"].strip() out["description"] = (offer.get("description") or "").strip()[:900] out["amenities"] = [ f["name"].strip() for f in (apt.get("amenityFeature") or []) if isinstance(f, dict) and f.get("name") and f.get("value")][:25] spec = offer.get("priceSpecification") or {} try: out["price"] = float(spec.get("price")) except (TypeError, ValueError): pass for prop in offer.get("additionalProperty") or []: if isinstance(prop, dict) and prop.get("name") == "Secteur": out["sector"] = (prop.get("value") or "").strip() imgs = offer.get("image") or [] if isinstance(imgs, str): imgs = [imgs] out["images"] = [u for u in dict.fromkeys(imgs) if isinstance(u, str) and not IMG_JUNK_RE.search(u)][:25] return out