SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
5.8 KB · 136 lines python
Raw Blame History
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