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/roussin.py : connecteur Immeubles Roussin (immeublesroussin.com)5# Immeubles/projets résidentiels (L'Aromate, L'Allié, La Vigie, etc.) —6# une annonce par type d'unité et par immeuble, prix « à partir de ».7# Les pages commerciales (locaux, bureaux, entrepôts) sont exclues.8# Commodités : paires icône+texte Elementor (Gym, Eau chaude, Animaux9# acceptés (20lb et moins)…) ; contact : liens tel:/mailto: structurés.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, infer_city, normalize_unit_type, parse_price18from .base import BaseConnector1920BASE = "https://immeublesroussin.com"21INDEX = f"{BASE}/location-condo-appartement-quebec/"2223# Lignes de prix : « Loft à partir de 1200$ », « 3 ½ à partir de 1595$ »...24PRICE_LINE_RE = re.compile(25 r"((?:\d\s*(?:½|1/2))|Loft|Studio|Maison)[^<>$]{0,30}?"26 r"à\s+partir\s+de\s*([\d][\d\s ,]*)\s*\$",27 re.I,28)29IMG_RE = re.compile(30 r"https://immeublesroussin\.com/wp-content/uploads/[^\"'\\\s\)]+"31 r"\.(?:jpg|jpeg|png|webp)", re.I)32IMG_NOISE_RE = re.compile(r"favicon|logo|icon|cropped|header", re.I)33ADDR_RE = re.compile(34 r"\d{2,5}[,]?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|place)"35 r"\s[^<>{}\"]{3,60}", re.I)36BUILDING_RE = re.compile(r"/location-condo-appartement-quebec/([a-z0-9\-]+)/?$")373839class RoussinConnector(BaseConnector):40 source_id = "roussin"41 request_delay = 0.642 max_buildings = 30 # garde-fou4344 def fetch(self) -> list[Listing]:45 # 1) Découvrir les pages d'immeubles résidentiels46 html = self.get(INDEX).text47 slugs: list[str] = []48 for href in re.findall(r'href="([^"]+)"', html):49 m = BUILDING_RE.search(href.split("#")[0].split("?")[0])50 if m and m.group(1) not in slugs:51 slugs.append(m.group(1))5253 listings: list[Listing] = []54 for slug in slugs[: self.max_buildings]:55 url = f"{INDEX}{slug}/"56 try:57 page = self.get(url).text58 except Exception:59 continue60 try:61 listings.extend(self._parse_building(slug, url, page))62 except Exception:63 continue64 return listings6566 def _parse_building(self, slug: str, url: str, page: str) -> list[Listing]:67 soup = BeautifulSoup(page, "html.parser")6869 # Nom + secteur depuis le <title> :70 # « L'Aromate - Location de condos à Ste-Foy | Immeubles Roussin »71 title_tag = soup.find("title")72 raw_title = title_tag.get_text(strip=True) if title_tag else slug73 name = re.split(r"\s+[-–|]\s+", raw_title)[0].strip() or slug74 if re.search(r"louer|condos? neufs?", name, re.I):75 # Titre générique (« Condos à louer à Lévis ») : essayer la partie76 # après « : », sinon un nom dérivé du slug.77 m = re.search(r":\s*([^|]+)", raw_title)78 name = (m.group(1).strip() if m79 else slug.replace("-", " ").strip().title())8081 # Description (og:description)82 desc = ""83 og = soup.find("meta", attrs={"property": "og:description"})84 if og and og.get("content"):85 desc = og["content"].strip()[:600]8687 # Adresse civique (en excluant le bureau administratif de Roussin)88 address = ""89 for cand in ADDR_RE.findall(page):90 if re.search(r"bureau", cand, re.I):91 continue92 address = re.sub(r"\s+", " ", cand).strip().rstrip(",")93 break9495 # Secteur : mots-clés dans le titre puis dans l'adresse96 sector = ""97 m = re.search(r"(Sainte-Foy|Ste-Foy|L[ée]vis|Beauport|Sillery|"98 r"Cap-Rouge|St-Augustin)", f"{raw_title} {address}", re.I)99 if m:100 sector = m.group(1)101102 # Images (galerie de l'immeuble)103 images = [u for u in dict.fromkeys(IMG_RE.findall(page))104 if not IMG_NOISE_RE.search(u)][:25]105106 # Commodités : paires icône (« iconeGym.jpg ») + widget texte Elementor107 amenities: list[str] = []108 for img in soup.select('img[src*="icone"]'):109 cont = img.find_parent(class_="elementor-container")110 if not cont:111 continue112 for te in cont.select(".elementor-widget-text-editor "113 ".elementor-widget-container"):114 t = te.get_text(" ", strip=True)115 if 2 < len(t) < 45 and t not in amenities:116 amenities.append(t)117 amenities = amenities[:20]118119 # Contact du gestionnaire (liens tel:/mailto: structurés)120 details: dict = {}121 contact: dict = {}122 tel = soup.select_one('a[href^="tel:"]')123 if tel:124 tm = re.search(r"\(?([2-9]\d{2})\)?[\s.%20\-]*(\d{3})[\s.\-]?(\d{4})",125 (tel.get("href") or "").replace("%20", ""))126 if tm:127 contact["phone"] = f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}"128 mail = soup.select_one('a[href^="mailto:"]')129 if mail:130 contact["email"] = (mail.get("href") or "")[7:].split("?")[0]131 if contact:132 details["contact"] = contact133134 # Types d'unités avec prix « à partir de »135 text = soup.get_text(" ", strip=True)136 seen: set[str] = set()137 out: list[Listing] = []138 for type_raw, amount in PRICE_LINE_RE.findall(text):139 unit_type = normalize_unit_type(type_raw)140 if not unit_type or unit_type in seen:141 continue142 seen.add(unit_type)143 price_label = f"{type_raw.strip()} à partir de {amount.strip()}$"144 key = re.sub(r"[^a-z0-9]+", "-", unit_type.lower().replace("½", "5"))145 out.append(Listing(146 source=self.source_id,147 external_id=f"{slug}--{key}",148 url=url,149 title=f"{name} — {unit_type}",150 address=address,151 sector=sector,152 city=infer_city(sector),153 unit_type=unit_type,154 price=parse_price(f"{amount}$"),155 price_label=price_label,156 description=desc,157 amenities=list(amenities),158 details=dict(details),159 images=images,160 ))161162 # Aucun prix affiché : une annonce par immeuble quand même163 if not out:164 out.append(Listing(165 source=self.source_id,166 external_id=slug,167 url=url,168 title=name,169 address=address,170 sector=sector,171 city=infer_city(sector),172 description=desc,173 amenities=list(amenities),174 details=dict(details),175 images=images,176 ))177 return out178