# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/girs.py : connecteur GIRS — Gestion Immobilière de la Rive Sud # (girs.ca). Gestionnaire de Sainte-Marie (Beauce) : immeubles locatifs # neufs à Scott, Saint-Isidore et La Guadeloupe (Chaudière-Appalaches) + # Carleton-sur-Mer et New Richmond (Gaspésie). # Site Duda (assets irp.cdn-website.com), rendu CÔTÉ SERVEUR : # - le menu « À louer » du hub /location regroupe les immeubles par ville # (« Scott, Chaudières-Appalaches » -> /location/scott/rue-amanda-gustave) ; # - chaque page d'immeuble est une VITRINE : nom/rue (h1), description, # pictogrammes de commodités (BALCON PRIVÉ, CLIMATISATION, « CHAT ET # CHIEN ACCEPTÉ (sous conditions) »…), photos — mais AUCUNE liste # d'unités, AUCUN prix, AUCUNE disponibilité (location par téléphone / # formulaire) -> 1 annonce « catalogue » par immeuble, prix None et # availability vide (rien d'inventé) ; # - le type d'unités n'est cité qu'en prose (« appartements de type 4 ½ », # « 4 ½ et 5 ½ ») : unit_type n'est rempli que si UN seul type est # mentionné, sinon la description fait foi. # external_id stable : slug du chemin (/location/scott/rue-amanda-gustave -> # scott-rue-amanda-gustave). La section « À vendre » du menu est ignorée. # robots.txt : permissif (sitemap public). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://www.girs.ca" HUB_URL = f"{BASE}/location" # « Scott, Chaudières-Appalaches » -> ville « Scott » (le site épelle la # région « Chaudières-Appalaches », coquille conservée telle quelle côté brut) _CITY_RE = re.compile(r"^\s*([^,]+?)\s*(?:,|$)") _TYPE_RE = re.compile(r"\b([1-6])\s*½") _IMG_RE = re.compile( r'https://irp\.cdn-website\.com/[^"\'\s\)]+' r'\.(?:jpg|jpeg|png|webp)', re.I) _SKIP_IMG = re.compile(r"logo|favicon|icon|pexels|silhouette", re.I) def _slug(path: str) -> str: """« /location/scott/rue-amanda-gustave » -> « scott-rue-amanda-gustave ».""" return re.sub(r"[^a-z0-9]+", "-", path.strip("/").removeprefix("location/").lower()).strip("-") def _mostly_upper(t: str) -> bool: letters = [c for c in t if c.isalpha()] if len(letters) < 5: return False return sum(c.isupper() for c in letters) / len(letters) >= 0.7 class GirsConnector(BaseConnector): source_id = "girs" request_delay = 0.6 max_images = 12 # -- hub /location : immeubles « À louer » groupés par ville ----------------- def _buildings(self) -> list[tuple[str, str, str]]: """[(path, nom d'immeuble, ville)] depuis le menu « À louer ».""" soup = BeautifulSoup(self.get(HUB_URL).text, "html.parser") out: list[tuple[str, str, str]] = [] seen: set[str] = set() nav = soup.select_one("ul.unav-top") if nav is None: return out for top_li in nav.find_all("li", recursive=False): top_link = top_li.find("a") top_txt = top_link.get_text(" ", strip=True) if top_link else "" if not top_txt.lower().startswith("à louer"): continue # « À vendre », « Nos services »… ignorés for city_li in top_li.select("ul li"): city_link = city_li.find("a") if not city_link: continue m = _CITY_RE.match(city_link.get_text(" ", strip=True)) city = m.group(1).strip() if m else "" for a in city_li.select("ul a[href^='/location/']"): path = a.get("href", "").strip().rstrip("/") name = re.sub(r"\s+", " ", a.get_text(" ", strip=True)) # seules les pages d'immeuble (/location//) # sont des annonces ; les pages de ville sont ignorées if len(path.strip("/").split("/")) < 3: continue if path and name and path not in seen: seen.add(path) out.append((path, name, city)) return out # -- page d'immeuble --------------------------------------------------------- def _parse_building(self, path: str, name: str, city: str) -> Listing | None: url = f"{BASE}{path}" soup = BeautifulSoup(self.get(url).text, "html.parser") main = soup.select_one("#dm_content") or soup.body if main is None: return None h1 = main.select_one("h1") street = h1.get_text(" ", strip=True) if h1 else name # description et commodités : blocs de paragraphes Duda. Les légendes # des pictogrammes sont parfois éclatées sur plusieurs

du même # bloc (« ENVIRONNEMENT CALME » / « ET PAISIBLE ») -> on travaille au # niveau du bloc `div.dmNewParagraph` pour les recoller. paras: list[str] = [] amenities: list[str] = [] for div in main.select("div.dmNewParagraph"): if div.find(re.compile(r"^h[1-6]$")): continue # titres de section (ESPACE DE VIE, COMMODITÉS…) t = re.sub(r"\s+", " ", div.get_text(" ", strip=True).replace("", "") ).strip() if not t or len(t) < 4: continue # le test « majuscules » ignore les précisions entre parenthèses # (« CHAT ET CHIEN ACCEPTÉ (sous conditions) ») if _mostly_upper(re.sub(r"\([^)]*\)", "", t)): # pictogramme de commodité (« BALCON PRIVÉ », « INTERNET # ILLIMITÉ », « CHAT ET CHIEN ACCEPTÉ (sous conditions) »…) if 5 <= len(t) <= 70 and t not in amenities: amenities.append(t) elif len(t) >= 60 and len(paras) < 6: paras.append(t) description = "\n".join(paras)[:1400] # type d'unités : seulement si UN seul type est cité en prose types = sorted(set(_TYPE_RE.findall( f"{description} {' '.join(amenities)}".replace("1/2", "½") .replace(" ½", "½")))) unit_type = f"{types[0]}½" if len(types) == 1 else "" # animaux : le site publie « CHAT ET CHIEN ACCEPTÉ (sous conditions) » pets = None for t in amenities: if re.search(r"(?i)chat|chien|animau", t): pets = ("conditions" if re.search(r"(?i)condition", t) else "oui") # photos de l'immeuble (CDN Duda), hors logos et images génériques images: list[str] = [] for u in dict.fromkeys(_IMG_RE.findall(str(main))): if not _SKIP_IMG.search(u) and u not in images: images.append(u) return Listing( source=self.source_id, external_id=_slug(path), url=url, title=f"{name} — {city}" if city else name, address=street if street.lower() != name.lower() or re.search(r"(?i)rue|avenue|boulevard|chemin|rang|\d", street) else "", sector="", city=city, unit_type=unit_type, price=None, # aucun loyer publié sur le site price_label="", availability="", # disponibilités par téléphone : rien d'inventé description=description, pets=pets, amenities=amenities[:30], images=images[: self.max_images], ) # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for path, name, city in self._buildings(): try: lst = self._parse_building(path, name, city) except Exception: continue if lst is not None and lst.external_id not in listings: listings[lst.external_id] = lst return list(listings.values())