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/trylon.py : connecteur Trylon Montréal (trylonmontreal.com)5# Immeuble unique au 1400, avenue des Pins Ouest (centre-ville, au pied6# du Mont-Royal). WordPress/Elementor rendu serveur : la page /fr/des-pins/7# liste les unités disponibles sous forme de sections « Semi-Meublé - <mois>8# <année> » suivies de cartes (type, prix $/mo, étage, surface, inclusions).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, normalize_unit_type17from .base import BaseConnector1819BASE = "https://trylonmontreal.com"20PAGE_URL = f"{BASE}/fr/des-pins/"2122ADDRESS = "1400, avenue des Pins Ouest, Montréal"23BUILDING_AMENITIES = ["Piscine chauffée intérieure", "Salle d'entraînement",24 "Terrasse sur le toit"]2526IMG_RE = re.compile(27 r"https://trylonmontreal\.com/wp-content/uploads/"28 r"[^\"\s\\]+?\.(?:jpg|jpeg|png|webp)", re.I)2930AVAIL_RE = re.compile(31 r"^(?:Semi-)?(?:Non-)?Meubl[ée]\s*[-–]\s*\w+\s*\d{4}$|"32 r"^Disponible[\w\s]*$|^Libre[\w\s]*$", re.I)33UNIT_RE = re.compile(r"^Appartement\s+\d\s*(?:1/2|½)", re.I)34PRICE_RE = re.compile(r"\$\s*([\d\s,]+)\s*/\s*mo", re.I)353637class TrylonConnector(BaseConnector):38 source_id = "trylon"39 request_delay = 0.64041 def fetch(self) -> list[Listing]:42 listings: list[Listing] = []43 try:44 html = self.get(PAGE_URL).text45 except Exception:46 return listings47 soup = BeautifulSoup(html, "html.parser")4849 # Photos de l'immeuble (les cartes d'unités n'ont pas de galerie propre)50 images = [u for u in dict.fromkeys(IMG_RE.findall(html))51 if not re.search(r"logo|icon|favicon|-\d+x\d+\.", u, re.I)][:20]5253 # Parcours en ordre du document : un en-tête « Semi-Meublé - <mois> »54 # définit la disponibilité des cartes qui suivent.55 availability = ""56 current: dict | None = None57 seen: dict[str, int] = {}5859 # Contact de l'immeuble (liens mailto:/tel: structurés dans la page)60 contact: dict = {}61 m = re.search(r'href="mailto:([^"?]+)"', html)62 if m:63 contact["email"] = m.group(1).strip().lower()64 m = re.search(r'href="tel:\+?1?(\d{3})(\d{3})(\d{4})"', html)65 if m:66 contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"6768 def flush():69 nonlocal current70 if not current or not current.get("unit_type"):71 current = None72 return73 ut = current["unit_type"]74 price = current.get("price")75 floor = current.get("floor", "")76 base_id = re.sub(r"[^\w.\-]", "",77 f"despins-{ut.replace('½', '.5')}-{floor}"78 f"-{int(price) if price else 'na'}")79 seen[base_id] = seen.get(base_id, 0) + 180 ext_id = base_id if seen[base_id] == 1 else f"{base_id}-{seen[base_id]}"81 desc_bits = [b for b in (82 f"Étage : {floor}" if floor else "",83 f"Surface : {current.get('sqft', '')}" if current.get("sqft") else "",84 current.get("furnished", ""),85 ) if b]86 # Étage/surface aussi en commodités : la normalisation dérive87 # details.floor et area_sqft depuis les items de liste.88 extras = [b for b in desc_bits if not b.startswith("Semi")89 and not b.startswith("Non") and not b.startswith("Meubl")]90 listings.append(Listing(91 source=self.source_id,92 external_id=ext_id,93 url=PAGE_URL,94 title=f"Appartement {ut} — 1400 des Pins",95 address=ADDRESS,96 sector="Centre-ville",97 city="Montréal",98 unit_type=ut,99 price=price,100 price_label=current.get("price_label", ""),101 availability=current.get("availability", ""),102 description=" | ".join(desc_bits)[:600],103 amenities=list(dict.fromkeys(104 current.get("inclusions", []) + extras + BUILDING_AMENITIES)),105 details={"contact": dict(contact)} if contact else {},106 images=images,107 ))108 current = None109110 widgets = soup.select(".elementor-widget-heading .elementor-heading-title,"111 " .elementor-widget-text-editor"112 " .elementor-widget-container")113 for w in widgets:114 try:115 text = w.get_text(" ", strip=True)116 except Exception:117 continue118 if not text:119 continue120 if AVAIL_RE.match(text):121 flush()122 availability = text123 continue124 if UNIT_RE.match(text):125 flush()126 furnished = ""127 m = re.match(r"(Semi-|Non-)?Meubl[ée]", availability, re.I)128 if m:129 furnished = m.group(0)130 current = {131 "unit_type": normalize_unit_type(text),132 "availability": availability,133 "furnished": furnished,134 }135 continue136 if current is not None:137 m = PRICE_RE.search(text)138 if m and "price" not in current:139 num = m.group(1).replace(" ", "").replace(" ", "").replace(",", "")140 try:141 val = float(num)142 if 100 <= val <= 20000:143 current["price"] = val144 current["price_label"] = m.group(0)145 except ValueError:146 pass147 continue148 m = re.search(r"Étage\s*:\s*([\w]+)", text)149 if m:150 current["floor"] = m.group(1)151 m = re.search(r"Surface\s*:\s*([\d\s]+\s*pc)", text)152 if m:153 current["sqft"] = m.group(1).strip()154 m = re.search(r"Inclusions?\s*:\s*(.+?)(?:$|CONTACTER)", text)155 if m:156 current["inclusions"] = [157 a.strip(" .").capitalize()158 for a in re.split(r",| et ", m.group(1)) if a.strip(" .")]159 flush()160 return listings161