Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/terrasse_pionniers.py : connecteur Terrasse des Pionniers5# (terrassedespionniers.ca — appartements locatifs neufs long terme au6# 973, rue Saint-Faustin à Mont-Blanc (Laurentides), près de la station7# de ski ; 16 unités 4½ + 4 unités 5½). WordPress/Elementor + JetEngine8# rendu SERVEUR : l'accueil expose une carte par TYPOLOGIE (badge de9# disponibilité dynamique « Disponible »/« Non-Disponible », nb d'unités,10# prix « à partir de », superficie) ; la fiche liste les inclusions11# (« Le loyer comprend : ») et la galerie. Granularité TYPOLOGIE12# (external_id = slug WordPress de la fiche, stable). Le badge de13# disponibilité est repris tel quel dans `availability` (peut être14# « Non-Disponible » quand tout est loué — bail au 1er juillet).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type23from .base import BaseConnector2425BASE = "https://terrassedespionniers.ca"26HOME_URL = f"{BASE}/"27ADDRESS = "973, rue Saint-Faustin"28CITY = "Mont-Blanc"2930TYPE_RE = re.compile(r"^\s*(\d)\s*½\s*$")31COUNT_RE = re.compile(r"(\d+)\s*Unités", re.I)32PRICE_RE = re.compile(r"à partir de\s*([\d\s ,]+)\s*\$", re.I)33AREA_RE = re.compile(r"([\d\s ]+)\s*pi²")34BADGE_RE = re.compile(r"^(Non-)?Disponible", re.I)35IMG_RE = re.compile(36 r"https://terrassedespionniers\.ca/wp-content/uploads/"37 r"[^\"\s\\]+?\.(?:jpe?g|png|webp)", re.I)38IMG_SIZE_RE = re.compile(r"-\d+x\d+(?=\.(?:jpe?g|png|webp))", re.I)394041class TerrassePionniersConnector(BaseConnector):42 source_id = "terrasse_pionniers"43 request_delay = 0.84445 def fetch(self) -> list[Listing]:46 try:47 soup = BeautifulSoup(self.get(HOME_URL).text, "html.parser")48 except Exception:49 return []50 listings: list[Listing] = []51 for card in soup.select(".jet-listing-grid__item"):52 wrap = card.select_one("[data-url]")53 url = wrap["data-url"] if wrap else ""54 if not url or "/appartements/" not in url:55 continue56 ext_id = url.rstrip("/").rsplit("/", 1)[-1]57 text = re.sub(r"\s+", " ", card.get_text(" ", strip=True))5859 unit_type = ""60 for h in card.select("h1, h2, h3, h4, h5, h6"):61 m = TYPE_RE.match(h.get_text(" ", strip=True))62 if m:63 unit_type = normalize_unit_type(f"{m.group(1)} 1/2")64 break65 if not unit_type:66 continue6768 badge = ""69 for h in card.select("h6"):70 m = BADGE_RE.match(h.get_text(" ", strip=True))71 if m:72 badge = h.get_text(" ", strip=True)73 break74 price = None75 m = PRICE_RE.search(text)76 if m:77 try:78 price = float(re.sub(r"[\s ,]", "", m.group(1)))79 except ValueError:80 pass81 area = None82 m = AREA_RE.search(text)83 if m:84 try:85 area = float(re.sub(r"[\s ]", "", m.group(1)))86 except ValueError:87 pass88 m = COUNT_RE.search(text)89 nb_units = m.group(1) if m else ""9091 # fiche typologie (cache BD) : description, inclusions, galerie92 detail = self.detail(ext_id, f"{badge}|{price}|{area}",93 lambda u=url: self._detail(u))94 details: dict = {}95 if nb_units:96 details["nb_unites"] = nb_units97 listings.append(Listing(98 source=self.source_id,99 external_id=ext_id, # slug WP de la fiche typologie100 url=url,101 title=f"Appartement {unit_type} — Terrasse des Pionniers",102 address=ADDRESS,103 city=CITY,104 unit_type=unit_type,105 price=price,106 price_label=f"à partir de {price:.0f} $ /mois" if price else "",107 availability=badge,108 area_sqft=area,109 furnished=False, # « Non meublés » sur la fiche110 description=detail.get("description", "")[:1500],111 amenities=detail.get("amenities") or [],112 details=details,113 images=detail.get("images") or [],114 ))115 return listings116117 # -- fiche typologie (4½ / 5½) ------------------------------------------------118 def _detail(self, url: str) -> dict:119 try:120 html = self.get(url).text121 except Exception:122 return {}123 soup = BeautifulSoup(html, "html.parser")124 out: dict = {}125 text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))126 # description : phrase de présentation + emplacement127 parts: list[str] = []128 m = re.search(r"(Chaque \d ½ offre[^|]+?\.)\s", text)129 if m:130 parts.append(m.group(1).strip())131 m = re.search(r"Emplacement idéal\s*:\s*([^.]+\.)", text)132 if m:133 parts.append("Emplacement : " + m.group(1).strip())134 out["description"] = " ".join(parts)135 # inclusions : « Le loyer comprend : » + description des unités136 amen: list[str] = []137 m = re.search(r"Le loyer comprend\s*:\s*(.+?)Exclus de la location",138 text)139 if m:140 for item in re.split(r"(?<=[a-zà-ü)\.])\s+(?=[A-ZÉÈÀ])",141 m.group(1).strip()):142 item = item.strip(" .")143 if 3 < len(item) < 90 and item not in amen:144 amen.append(item)145 out["amenities"] = amen[:15]146 imgs = []147 for u in dict.fromkeys(IMG_RE.findall(html)):148 u = IMG_SIZE_RE.sub("", u)149 if not re.search(r"logo|icon|favicon|ImageWithFallback", u, re.I) \150 and u not in imgs:151 imgs.append(u)152 out["images"] = imgs[:20]153 return out154