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/projection_immo.py : connecteur Projection Immobilière5# (projectionimmobiliere.com — Amqui, Matane, Rimouski, Lac-au-Saumon ;6# 200+ appartements, rare acteur structuré de la Matapédia). Squarespace,7# tout rendu serveur. Pas de moteur d'annonces : une page par IMMEUBLE8# (/logements-<ville>/<slug>, découvertes via sitemap.xml) portant un9# indicateur « Disponible: Oui/Non », une description des logements10# (typologies réelles), la liste « À proximité », la carte (mapLat/mapLng)11# et la galerie. Le connecteur visite chaque page d'immeuble (l'indicateur12# n'existe nulle part ailleurs) et n'émet que les immeubles « Disponible:13# Oui » — granularité immeuble assumée (précédent immeubles_guillot), sans14# prix (jamais publié). robots.txt Squarespace : les UA IA listés partagent15# le MÊME groupe de règles que « * » (seuls /config, /search, /api/… sont16# interdits) -> pages d'annonces permises à tous les agents ; aucune page17# CGU publiée sur le site. Exclusions : /espaces-commerciaux (commercial),18# « Location court terme », projet Perle Noire (condos en vente).19# -----------------------------------------------------------------------------20from __future__ import annotations2122import re2324from bs4 import BeautifulSoup2526from ..schema import Listing27from .base import BaseConnector2829BASE = "https://www.projectionimmobiliere.com"30SITEMAP_URL = f"{BASE}/sitemap.xml"3132# pages d'immeubles résidentiels : /logements-<ville>/<slug>33_BUILDING_RE = re.compile(r"/logements-([a-z-]+)/([^/<]+)$")34_CITIES = {"amqui": "Amqui", "matane": "Matane", "rimouski": "Rimouski",35 "lac-au-saumon": "Lac-au-Saumon"}36_DISPO_RE = re.compile(r"Disponible\s*:?\s*([^\n]{1,80})")37_MAP_RE = re.compile(r'"mapLat"\s*:\s*(-?\d+\.\d+).{0,80}?"mapLng"\s*:\s*(-?\d+\.\d+)',38 re.S)394041class ProjectionImmoConnector(BaseConnector):42 source_id = "projection_immo"43 request_delay = 0.744 max_pages = 40 # garde-fou pages d'immeubles par sync4546 def fetch(self) -> list[Listing]:47 xml = self.get(SITEMAP_URL).text48 urls: list[tuple[str, str, str]] = []49 for loc in re.findall(r"<loc>([^<]+)</loc>", xml):50 m = _BUILDING_RE.search(loc)51 if m:52 urls.append((loc, m.group(1), m.group(2)))5354 listings: dict[str, Listing] = {}55 for url, city_slug, slug in urls[: self.max_pages]:56 try:57 lst = self._parse_building(url, city_slug, slug)58 except Exception:59 continue60 if lst and lst.external_id not in listings:61 listings[lst.external_id] = lst62 return list(listings.values())6364 # -- page immeuble (/logements-<ville>/<slug>) -----------------------------------65 def _parse_building(self, url: str, city_slug: str, slug: str):66 html = self.get(url).text67 soup = BeautifulSoup(html, "html.parser")68 main = soup.select_one("main#page") or soup6970 # indicateur de disponibilité : « Disponible: Oui. » / « Non. » —71 # absent = aucun signal, on n'invente rien -> immeuble sauté72 m = _DISPO_RE.search(main.get_text("\n", strip=True))73 if not m:74 return None75 dispo_raw = f"Disponible : {m.group(1).strip()}"76 if re.match(r"^non\b", m.group(1).strip(), re.I):77 return None7879 # adresse civique = premier h4 « 280, Boul Dion, Matane. »80 address = ""81 for h4 in main.select("h4"):82 t = h4.get_text(" ", strip=True).rstrip(".")83 if re.match(r"^\d+[,\s]", t):84 address = t85 break8687 # description des logements (typologies réelles saisies par l'agence)88 desc_parts = [p.get_text(" ", strip=True)89 for p in main.select("p.sqsrte-large")90 if p.get_text(strip=True)91 and "Disponible" not in p.get_text()]92 description = re.sub(r"[ \t]+", " ", "\n".join(desc_parts)).strip()[:1200]9394 # « À proximité » : lignes courtes entre ce titre et le suivant95 amenities: list[str] = []96 prox = main.find(["h2", "h3"], string=re.compile("proximit", re.I))97 if prox:98 for sib in prox.find_all_next(["p", "li", "h2", "h3"]):99 if sib.name in ("h2", "h3"):100 break101 t = sib.get_text(" ", strip=True)102 if t and len(t) <= 60 and "Disponible" not in t:103 amenities.append(f"À proximité : {t}")104 # p et li imbriqués produisent des doublons : dédupliquer en gardant l'ordre105 amenities = list(dict.fromkeys(amenities))[:12]106107 # galerie Squarespace (logos exclus)108 images: list[str] = []109 for im in main.select("img"):110 u = im.get("data-src") or im.get("src") or ""111 if "squarespace-cdn" not in u or "logo" in u.lower():112 continue113 u = "https:" + u if u.startswith("//") else u114 if u.startswith("http") and u not in images:115 images.append(u)116117 lat = lng = None118 mm = _MAP_RE.search(html)119 if mm:120 lat, lng = float(mm.group(1)), float(mm.group(2))121122 return Listing(123 source=self.source_id,124 external_id=slug,125 url=url,126 title=address or slug,127 address=address,128 city=_CITIES.get(city_slug, city_slug.replace("-", " ").title()),129 availability=dispo_raw,130 description=description,131 amenities=amenities,132 images=images[:20],133 lat=lat,134 lng=lng,135 )136