# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/trylon.py : connecteur Trylon Montréal (trylonmontreal.com) # Immeuble unique au 1400, avenue des Pins Ouest (centre-ville, au pied # du Mont-Royal). WordPress/Elementor rendu serveur : la page /fr/des-pins/ # liste les unités disponibles sous forme de sections « Semi-Meublé - # » suivies de cartes (type, prix $/mo, étage, surface, inclusions). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://trylonmontreal.com" PAGE_URL = f"{BASE}/fr/des-pins/" ADDRESS = "1400, avenue des Pins Ouest, Montréal" BUILDING_AMENITIES = ["Piscine chauffée intérieure", "Salle d'entraînement", "Terrasse sur le toit"] IMG_RE = re.compile( r"https://trylonmontreal\.com/wp-content/uploads/" r"[^\"\s\\]+?\.(?:jpg|jpeg|png|webp)", re.I) AVAIL_RE = re.compile( r"^(?:Semi-)?(?:Non-)?Meubl[ée]\s*[-–]\s*\w+\s*\d{4}$|" r"^Disponible[\w\s]*$|^Libre[\w\s]*$", re.I) UNIT_RE = re.compile(r"^Appartement\s+\d\s*(?:1/2|½)", re.I) PRICE_RE = re.compile(r"\$\s*([\d\s,]+)\s*/\s*mo", re.I) class TrylonConnector(BaseConnector): source_id = "trylon" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(PAGE_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") # Photos de l'immeuble (les cartes d'unités n'ont pas de galerie propre) images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|favicon|-\d+x\d+\.", u, re.I)][:20] # Parcours en ordre du document : un en-tête « Semi-Meublé - » # définit la disponibilité des cartes qui suivent. availability = "" current: dict | None = None seen: dict[str, int] = {} # Contact de l'immeuble (liens mailto:/tel: structurés dans la page) contact: dict = {} m = re.search(r'href="mailto:([^"?]+)"', html) if m: contact["email"] = m.group(1).strip().lower() m = re.search(r'href="tel:\+?1?(\d{3})(\d{3})(\d{4})"', html) if m: contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" def flush(): nonlocal current if not current or not current.get("unit_type"): current = None return ut = current["unit_type"] price = current.get("price") floor = current.get("floor", "") base_id = re.sub(r"[^\w.\-]", "", f"despins-{ut.replace('½', '.5')}-{floor}" f"-{int(price) if price else 'na'}") seen[base_id] = seen.get(base_id, 0) + 1 ext_id = base_id if seen[base_id] == 1 else f"{base_id}-{seen[base_id]}" desc_bits = [b for b in ( f"Étage : {floor}" if floor else "", f"Surface : {current.get('sqft', '')}" if current.get("sqft") else "", current.get("furnished", ""), ) if b] # Étage/surface aussi en commodités : la normalisation dérive # details.floor et area_sqft depuis les items de liste. extras = [b for b in desc_bits if not b.startswith("Semi") and not b.startswith("Non") and not b.startswith("Meubl")] listings.append(Listing( source=self.source_id, external_id=ext_id, url=PAGE_URL, title=f"Appartement {ut} — 1400 des Pins", address=ADDRESS, sector="Centre-ville", city="Montréal", unit_type=ut, price=price, price_label=current.get("price_label", ""), availability=current.get("availability", ""), description=" | ".join(desc_bits)[:600], amenities=list(dict.fromkeys( current.get("inclusions", []) + extras + BUILDING_AMENITIES)), details={"contact": dict(contact)} if contact else {}, images=images, )) current = None widgets = soup.select(".elementor-widget-heading .elementor-heading-title," " .elementor-widget-text-editor" " .elementor-widget-container") for w in widgets: try: text = w.get_text(" ", strip=True) except Exception: continue if not text: continue if AVAIL_RE.match(text): flush() availability = text continue if UNIT_RE.match(text): flush() furnished = "" m = re.match(r"(Semi-|Non-)?Meubl[ée]", availability, re.I) if m: furnished = m.group(0) current = { "unit_type": normalize_unit_type(text), "availability": availability, "furnished": furnished, } continue if current is not None: m = PRICE_RE.search(text) if m and "price" not in current: num = m.group(1).replace(" ", "").replace(" ", "").replace(",", "") try: val = float(num) if 100 <= val <= 20000: current["price"] = val current["price_label"] = m.group(0) except ValueError: pass continue m = re.search(r"Étage\s*:\s*([\w]+)", text) if m: current["floor"] = m.group(1) m = re.search(r"Surface\s*:\s*([\d\s]+\s*pc)", text) if m: current["sqft"] = m.group(1).strip() m = re.search(r"Inclusions?\s*:\s*(.+?)(?:$|CONTACTER)", text) if m: current["inclusions"] = [ a.strip(" .").capitalize() for a in re.split(r",| et ", m.group(1)) if a.strip(" .")] flush() return listings