SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
6.6 KB · 161 lines python
Raw Blame History
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