# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/roussin.py : connecteur Immeubles Roussin (immeublesroussin.com) # Immeubles/projets résidentiels (L'Aromate, L'Allié, La Vigie, etc.) — # une annonce par type d'unité et par immeuble, prix « à partir de ». # Les pages commerciales (locaux, bureaux, entrepôts) sont exclues. # Commodités : paires icône+texte Elementor (Gym, Eau chaude, Animaux # acceptés (20lb et moins)…) ; contact : liens tel:/mailto: structurés. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://immeublesroussin.com" INDEX = f"{BASE}/location-condo-appartement-quebec/" # Lignes de prix : « Loft à partir de 1200$ », « 3 ½ à partir de 1595$ »... PRICE_LINE_RE = re.compile( r"((?:\d\s*(?:½|1/2))|Loft|Studio|Maison)[^<>$]{0,30}?" r"à\s+partir\s+de\s*([\d][\d\s ,]*)\s*\$", re.I, ) IMG_RE = re.compile( r"https://immeublesroussin\.com/wp-content/uploads/[^\"'\\\s\)]+" r"\.(?:jpg|jpeg|png|webp)", re.I) IMG_NOISE_RE = re.compile(r"favicon|logo|icon|cropped|header", re.I) ADDR_RE = re.compile( r"\d{2,5}[,]?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|place)" r"\s[^<>{}\"]{3,60}", re.I) BUILDING_RE = re.compile(r"/location-condo-appartement-quebec/([a-z0-9\-]+)/?$") class RoussinConnector(BaseConnector): source_id = "roussin" request_delay = 0.6 max_buildings = 30 # garde-fou def fetch(self) -> list[Listing]: # 1) Découvrir les pages d'immeubles résidentiels html = self.get(INDEX).text slugs: list[str] = [] for href in re.findall(r'href="([^"]+)"', html): m = BUILDING_RE.search(href.split("#")[0].split("?")[0]) if m and m.group(1) not in slugs: slugs.append(m.group(1)) listings: list[Listing] = [] for slug in slugs[: self.max_buildings]: url = f"{INDEX}{slug}/" try: page = self.get(url).text except Exception: continue try: listings.extend(self._parse_building(slug, url, page)) except Exception: continue return listings def _parse_building(self, slug: str, url: str, page: str) -> list[Listing]: soup = BeautifulSoup(page, "html.parser") # Nom + secteur depuis le : # « L'Aromate - Location de condos à Ste-Foy | Immeubles Roussin » title_tag = soup.find("title") raw_title = title_tag.get_text(strip=True) if title_tag else slug name = re.split(r"\s+[-–|]\s+", raw_title)[0].strip() or slug if re.search(r"louer|condos? neufs?", name, re.I): # Titre générique (« Condos à louer à Lévis ») : essayer la partie # après « : », sinon un nom dérivé du slug. m = re.search(r":\s*([^|]+)", raw_title) name = (m.group(1).strip() if m else slug.replace("-", " ").strip().title()) # Description (og:description) desc = "" og = soup.find("meta", attrs={"property": "og:description"}) if og and og.get("content"): desc = og["content"].strip()[:600] # Adresse civique (en excluant le bureau administratif de Roussin) address = "" for cand in ADDR_RE.findall(page): if re.search(r"bureau", cand, re.I): continue address = re.sub(r"\s+", " ", cand).strip().rstrip(",") break # Secteur : mots-clés dans le titre puis dans l'adresse sector = "" m = re.search(r"(Sainte-Foy|Ste-Foy|L[ée]vis|Beauport|Sillery|" r"Cap-Rouge|St-Augustin)", f"{raw_title} {address}", re.I) if m: sector = m.group(1) # Images (galerie de l'immeuble) images = [u for u in dict.fromkeys(IMG_RE.findall(page)) if not IMG_NOISE_RE.search(u)][:25] # Commodités : paires icône (« iconeGym.jpg ») + widget texte Elementor amenities: list[str] = [] for img in soup.select('img[src*="icone"]'): cont = img.find_parent(class_="elementor-container") if not cont: continue for te in cont.select(".elementor-widget-text-editor " ".elementor-widget-container"): t = te.get_text(" ", strip=True) if 2 < len(t) < 45 and t not in amenities: amenities.append(t) amenities = amenities[:20] # Contact du gestionnaire (liens tel:/mailto: structurés) details: dict = {} contact: dict = {} tel = soup.select_one('a[href^="tel:"]') if tel: tm = re.search(r"\(?([2-9]\d{2})\)?[\s.%20\-]*(\d{3})[\s.\-]?(\d{4})", (tel.get("href") or "").replace("%20", "")) if tm: contact["phone"] = f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}" mail = soup.select_one('a[href^="mailto:"]') if mail: contact["email"] = (mail.get("href") or "")[7:].split("?")[0] if contact: details["contact"] = contact # Types d'unités avec prix « à partir de » text = soup.get_text(" ", strip=True) seen: set[str] = set() out: list[Listing] = [] for type_raw, amount in PRICE_LINE_RE.findall(text): unit_type = normalize_unit_type(type_raw) if not unit_type or unit_type in seen: continue seen.add(unit_type) price_label = f"{type_raw.strip()} à partir de {amount.strip()}$" key = re.sub(r"[^a-z0-9]+", "-", unit_type.lower().replace("½", "5")) out.append(Listing( source=self.source_id, external_id=f"{slug}--{key}", url=url, title=f"{name} — {unit_type}", address=address, sector=sector, city=infer_city(sector), unit_type=unit_type, price=parse_price(f"{amount}$"), price_label=price_label, description=desc, amenities=list(amenities), details=dict(details), images=images, )) # Aucun prix affiché : une annonce par immeuble quand même if not out: out.append(Listing( source=self.source_id, external_id=slug, url=url, title=name, address=address, sector=sector, city=infer_city(sector), description=desc, amenities=list(amenities), details=dict(details), images=images, )) return out