# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/projection_immo.py : connecteur Projection Immobilière # (projectionimmobiliere.com — Amqui, Matane, Rimouski, Lac-au-Saumon ; # 200+ appartements, rare acteur structuré de la Matapédia). Squarespace, # tout rendu serveur. Pas de moteur d'annonces : une page par IMMEUBLE # (/logements-/, découvertes via sitemap.xml) portant un # indicateur « Disponible: Oui/Non », une description des logements # (typologies réelles), la liste « À proximité », la carte (mapLat/mapLng) # et la galerie. Le connecteur visite chaque page d'immeuble (l'indicateur # n'existe nulle part ailleurs) et n'émet que les immeubles « Disponible: # Oui » — granularité immeuble assumée (précédent immeubles_guillot), sans # prix (jamais publié). robots.txt Squarespace : les UA IA listés partagent # le MÊME groupe de règles que « * » (seuls /config, /search, /api/… sont # interdits) -> pages d'annonces permises à tous les agents ; aucune page # CGU publiée sur le site. Exclusions : /espaces-commerciaux (commercial), # « Location court terme », projet Perle Noire (condos en vente). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://www.projectionimmobiliere.com" SITEMAP_URL = f"{BASE}/sitemap.xml" # pages d'immeubles résidentiels : /logements-/ _BUILDING_RE = re.compile(r"/logements-([a-z-]+)/([^/<]+)$") _CITIES = {"amqui": "Amqui", "matane": "Matane", "rimouski": "Rimouski", "lac-au-saumon": "Lac-au-Saumon"} _DISPO_RE = re.compile(r"Disponible\s*:?\s*([^\n]{1,80})") _MAP_RE = re.compile(r'"mapLat"\s*:\s*(-?\d+\.\d+).{0,80}?"mapLng"\s*:\s*(-?\d+\.\d+)', re.S) class ProjectionImmoConnector(BaseConnector): source_id = "projection_immo" request_delay = 0.7 max_pages = 40 # garde-fou pages d'immeubles par sync def fetch(self) -> list[Listing]: xml = self.get(SITEMAP_URL).text urls: list[tuple[str, str, str]] = [] for loc in re.findall(r"([^<]+)", xml): m = _BUILDING_RE.search(loc) if m: urls.append((loc, m.group(1), m.group(2))) listings: dict[str, Listing] = {} for url, city_slug, slug in urls[: self.max_pages]: try: lst = self._parse_building(url, city_slug, slug) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst return list(listings.values()) # -- page immeuble (/logements-/) ----------------------------------- def _parse_building(self, url: str, city_slug: str, slug: str): html = self.get(url).text soup = BeautifulSoup(html, "html.parser") main = soup.select_one("main#page") or soup # indicateur de disponibilité : « Disponible: Oui. » / « Non. » — # absent = aucun signal, on n'invente rien -> immeuble sauté m = _DISPO_RE.search(main.get_text("\n", strip=True)) if not m: return None dispo_raw = f"Disponible : {m.group(1).strip()}" if re.match(r"^non\b", m.group(1).strip(), re.I): return None # adresse civique = premier h4 « 280, Boul Dion, Matane. » address = "" for h4 in main.select("h4"): t = h4.get_text(" ", strip=True).rstrip(".") if re.match(r"^\d+[,\s]", t): address = t break # description des logements (typologies réelles saisies par l'agence) desc_parts = [p.get_text(" ", strip=True) for p in main.select("p.sqsrte-large") if p.get_text(strip=True) and "Disponible" not in p.get_text()] description = re.sub(r"[ \t]+", " ", "\n".join(desc_parts)).strip()[:1200] # « À proximité » : lignes courtes entre ce titre et le suivant amenities: list[str] = [] prox = main.find(["h2", "h3"], string=re.compile("proximit", re.I)) if prox: for sib in prox.find_all_next(["p", "li", "h2", "h3"]): if sib.name in ("h2", "h3"): break t = sib.get_text(" ", strip=True) if t and len(t) <= 60 and "Disponible" not in t: amenities.append(f"À proximité : {t}") # p et li imbriqués produisent des doublons : dédupliquer en gardant l'ordre amenities = list(dict.fromkeys(amenities))[:12] # galerie Squarespace (logos exclus) images: list[str] = [] for im in main.select("img"): u = im.get("data-src") or im.get("src") or "" if "squarespace-cdn" not in u or "logo" in u.lower(): continue u = "https:" + u if u.startswith("//") else u if u.startswith("http") and u not in images: images.append(u) lat = lng = None mm = _MAP_RE.search(html) if mm: lat, lng = float(mm.group(1)), float(mm.group(2)) return Listing( source=self.source_id, external_id=slug, url=url, title=address or slug, address=address, city=_CITIES.get(city_slug, city_slug.replace("-", " ").title()), availability=dispo_raw, description=description, amenities=amenities, images=images[:20], lat=lat, lng=lng, )