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/girs.py : connecteur GIRS — Gestion Immobilière de la Rive Sud5# (girs.ca). Gestionnaire de Sainte-Marie (Beauce) : immeubles locatifs6# neufs à Scott, Saint-Isidore et La Guadeloupe (Chaudière-Appalaches) +7# Carleton-sur-Mer et New Richmond (Gaspésie).8# Site Duda (assets irp.cdn-website.com), rendu CÔTÉ SERVEUR :9# - le menu « À louer » du hub /location regroupe les immeubles par ville10# (« Scott, Chaudières-Appalaches » -> /location/scott/rue-amanda-gustave) ;11# - chaque page d'immeuble est une VITRINE : nom/rue (h1), description,12# pictogrammes de commodités (BALCON PRIVÉ, CLIMATISATION, « CHAT ET13# CHIEN ACCEPTÉ (sous conditions) »…), photos — mais AUCUNE liste14# d'unités, AUCUN prix, AUCUNE disponibilité (location par téléphone /15# formulaire) -> 1 annonce « catalogue » par immeuble, prix None et16# availability vide (rien d'inventé) ;17# - le type d'unités n'est cité qu'en prose (« appartements de type 4 ½ »,18# « 4 ½ et 5 ½ ») : unit_type n'est rempli que si UN seul type est19# mentionné, sinon la description fait foi.20# external_id stable : slug du chemin (/location/scott/rue-amanda-gustave ->21# scott-rue-amanda-gustave). La section « À vendre » du menu est ignorée.22# robots.txt : permissif (sitemap public).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import re2728from bs4 import BeautifulSoup2930from ..schema import Listing31from .base import BaseConnector3233BASE = "https://www.girs.ca"34HUB_URL = f"{BASE}/location"3536# « Scott, Chaudières-Appalaches » -> ville « Scott » (le site épelle la37# région « Chaudières-Appalaches », coquille conservée telle quelle côté brut)38_CITY_RE = re.compile(r"^\s*([^,]+?)\s*(?:,|$)")39_TYPE_RE = re.compile(r"\b([1-6])\s*½")40_IMG_RE = re.compile(41 r'https://irp\.cdn-website\.com/[^"\'\s\)]+'42 r'\.(?:jpg|jpeg|png|webp)', re.I)43_SKIP_IMG = re.compile(r"logo|favicon|icon|pexels|silhouette", re.I)444546def _slug(path: str) -> str:47 """« /location/scott/rue-amanda-gustave » -> « scott-rue-amanda-gustave »."""48 return re.sub(r"[^a-z0-9]+", "-",49 path.strip("/").removeprefix("location/").lower()).strip("-")505152def _mostly_upper(t: str) -> bool:53 letters = [c for c in t if c.isalpha()]54 if len(letters) < 5:55 return False56 return sum(c.isupper() for c in letters) / len(letters) >= 0.7575859class GirsConnector(BaseConnector):60 source_id = "girs"61 request_delay = 0.662 max_images = 126364 # -- hub /location : immeubles « À louer » groupés par ville -----------------65 def _buildings(self) -> list[tuple[str, str, str]]:66 """[(path, nom d'immeuble, ville)] depuis le menu « À louer »."""67 soup = BeautifulSoup(self.get(HUB_URL).text, "html.parser")68 out: list[tuple[str, str, str]] = []69 seen: set[str] = set()70 nav = soup.select_one("ul.unav-top")71 if nav is None:72 return out73 for top_li in nav.find_all("li", recursive=False):74 top_link = top_li.find("a")75 top_txt = top_link.get_text(" ", strip=True) if top_link else ""76 if not top_txt.lower().startswith("à louer"):77 continue # « À vendre », « Nos services »… ignorés78 for city_li in top_li.select("ul li"):79 city_link = city_li.find("a")80 if not city_link:81 continue82 m = _CITY_RE.match(city_link.get_text(" ", strip=True))83 city = m.group(1).strip() if m else ""84 for a in city_li.select("ul a[href^='/location/']"):85 path = a.get("href", "").strip().rstrip("/")86 name = re.sub(r"\s+", " ", a.get_text(" ", strip=True))87 # seules les pages d'immeuble (/location/<ville>/<immeuble>)88 # sont des annonces ; les pages de ville sont ignorées89 if len(path.strip("/").split("/")) < 3:90 continue91 if path and name and path not in seen:92 seen.add(path)93 out.append((path, name, city))94 return out9596 # -- page d'immeuble ---------------------------------------------------------97 def _parse_building(self, path: str, name: str, city: str) -> Listing | None:98 url = f"{BASE}{path}"99 soup = BeautifulSoup(self.get(url).text, "html.parser")100 main = soup.select_one("#dm_content") or soup.body101 if main is None:102 return None103104 h1 = main.select_one("h1")105 street = h1.get_text(" ", strip=True) if h1 else name106107 # description et commodités : blocs de paragraphes Duda. Les légendes108 # des pictogrammes sont parfois éclatées sur plusieurs <p> du même109 # bloc (« ENVIRONNEMENT CALME » / « ET PAISIBLE ») -> on travaille au110 # niveau du bloc `div.dmNewParagraph` pour les recoller.111 paras: list[str] = []112 amenities: list[str] = []113 for div in main.select("div.dmNewParagraph"):114 if div.find(re.compile(r"^h[1-6]$")):115 continue # titres de section (ESPACE DE VIE, COMMODITÉS…)116 t = re.sub(r"\s+", " ",117 div.get_text(" ", strip=True).replace("", "")118 ).strip()119 if not t or len(t) < 4:120 continue121 # le test « majuscules » ignore les précisions entre parenthèses122 # (« CHAT ET CHIEN ACCEPTÉ (sous conditions) »)123 if _mostly_upper(re.sub(r"\([^)]*\)", "", t)):124 # pictogramme de commodité (« BALCON PRIVÉ », « INTERNET125 # ILLIMITÉ », « CHAT ET CHIEN ACCEPTÉ (sous conditions) »…)126 if 5 <= len(t) <= 70 and t not in amenities:127 amenities.append(t)128 elif len(t) >= 60 and len(paras) < 6:129 paras.append(t)130 description = "\n".join(paras)[:1400]131132 # type d'unités : seulement si UN seul type est cité en prose133 types = sorted(set(_TYPE_RE.findall(134 f"{description} {' '.join(amenities)}".replace("1/2", "½")135 .replace(" ½", "½"))))136 unit_type = f"{types[0]}½" if len(types) == 1 else ""137138 # animaux : le site publie « CHAT ET CHIEN ACCEPTÉ (sous conditions) »139 pets = None140 for t in amenities:141 if re.search(r"(?i)chat|chien|animau", t):142 pets = ("conditions" if re.search(r"(?i)condition", t)143 else "oui")144145 # photos de l'immeuble (CDN Duda), hors logos et images génériques146 images: list[str] = []147 for u in dict.fromkeys(_IMG_RE.findall(str(main))):148 if not _SKIP_IMG.search(u) and u not in images:149 images.append(u)150151 return Listing(152 source=self.source_id,153 external_id=_slug(path),154 url=url,155 title=f"{name} — {city}" if city else name,156 address=street if street.lower() != name.lower() or157 re.search(r"(?i)rue|avenue|boulevard|chemin|rang|\d", street)158 else "",159 sector="",160 city=city,161 unit_type=unit_type,162 price=None, # aucun loyer publié sur le site163 price_label="",164 availability="", # disponibilités par téléphone : rien d'inventé165 description=description,166 pets=pets,167 amenities=amenities[:30],168 images=images[: self.max_images],169 )170171 # -- fetch -----------------------------------------------------------------172 def fetch(self) -> list[Listing]:173 listings: dict[str, Listing] = {}174 for path, name, city in self._buildings():175 try:176 lst = self._parse_building(path, name, city)177 except Exception:178 continue179 if lst is not None and lst.external_id not in listings:180 listings[lst.external_id] = lst181 return list(listings.values())182