SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.6 KB · 142 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/axia.py : connecteur AXIA Appartements (axiaappartements.com)5#   Complexe locatif neuf à Lachine (Montréal), géré par Pur Immobilia.6#   Site vitrine WordPress/WPBakery une page : deux configurations7#   (2 chambres 4½ et 3 chambres 5½) avec superficie et prix8#   « à partir de » — 1 annonce par configuration.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, parse_price17from .base import BaseConnector1819BASE = "https://www.axiaappartements.com"2021ADDRESS = "200, boul. Saint-Joseph, Lachine (Québec) H8S 2L3"2223IMG_RE = re.compile(24    r"https://www\.axiaappartements\.com/wp-content/uploads/"25    r"[^\"\s\\]+?\.(?:jpg|jpeg|webp)", re.I)262728class AxiaConnector(BaseConnector):29    source_id = "axia"30    request_delay = 0.63132    def fetch(self) -> list[Listing]:33        listings: list[Listing] = []34        try:35            html = self.get(BASE + "/").text36        except Exception:37            return listings38        soup = BeautifulSoup(html, "html.parser")3940        # Photos du complexe (icônes/logos exclus)41        images = [u for u in dict.fromkeys(IMG_RE.findall(html))42                  if not re.search(r"icone|logo|favicon|fleche|-\d+x\d+\.",43                                   u, re.I)][:25]4445        # Description (meta + paragraphe « APPARTEMENTS » sur les unités)46        desc = ""47        og = soup.find("meta", attrs={"property": "og:description"}) or \48            soup.find("meta", attrs={"name": "description"})49        if og and og.get("content"):50            desc = og["content"].strip()51        for p in soup.find_all("p"):52            txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))53            if "électroménagers" in txt:      # descriptif des unités54                desc = (desc + " " + txt).strip()55                break56        desc = desc[:600]57        amenities = []58        for el in soup.select(".icon-label"):59            txt = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()60            if txt and txt not in amenities:61                amenities.append(txt)6263        # Contact (liens tel:/mailto: structurés — location résidentielle)64        contact: dict = {}65        tel = soup.select_one('a[href^="tel:"]')66        if tel:67            digits = re.sub(r"\D", "", tel.get("href", ""))[-10:]68            if len(digits) == 10:69                contact["phone"] = "-".join(70                    (digits[:3], digits[3:6], digits[6:]))71        mail = soup.select_one('a[href^="mailto:"]')72        if mail:73            contact["email"] = mail.get("href", "")[7:].split("?")[0]7475        # Disponibilité (texte du site) + promotion (dans la description,76        # pour ne pas fausser la date de disponibilité normalisée)77        page_text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))78        availability = "Disponible"79        if re.search(r"Maintenant disponible à la location", page_text, re.I):80            availability = "Maintenant disponible à la location"81        m = re.search(r"(\d\s*MOIS OFFERTS[^<*]{0,80})", page_text, re.I)82        if m:83            promo = "Promotion : " + re.sub(r"\s+", " ", m.group(1)).strip()84            desc = (desc + " — " + promo)[:600]8586        # Cartes de prix : « 2 chambres (4 ½) », superficie, « À partir de … $ »87        for card in soup.select(".price-card"):88            try:89                lst = self._parse_card(card, images, desc, amenities,90                                       availability, contact)91            except Exception:92                continue93            if lst:94                listings.append(lst)95        return listings9697    def _parse_card(self, card, images, desc, amenities,98                    availability, contact=None) -> Listing | None:99        head = card.get_text(" ", strip=True)          # « 2 chambres (4 ½) »100        m = re.search(r"(\d)\s*chambres?", head, re.I)101        mtype = re.search(r"\(\s*(\d)\s*(?:½|1/2)\s*\)", head)102        if not (m or mtype):103            return None104        if mtype:105            unit_type = f"{mtype.group(1)}½"106        else:107            n = int(m.group(1))108            unit_type = {0: "Studio", 1: "3½", 2: "4½", 3: "5½"}.get(109                n, f"{n} chambres")110111        # superficie et prix dans les blocs suivants du même conteneur112        container = card.parent113        sqft = price_label = ""114        if container:115            sq = container.select_one(".superficie")116            if sq:117                sqft = sq.get_text(" ", strip=True)118            mt = container.select_one(".montant")119            if mt:120                price_label = mt.get_text(" ", strip=True)121122        price = parse_price(price_label)123        n_ch = m.group(1) if m else {"4½": "2", "5½": "3"}.get(unit_type, "")124125        return Listing(126            source=self.source_id,127            external_id=f"axia-lachine-{unit_type.replace('½', '.5')}",128            url=BASE + "/#appartements",129            title=f"AXIA Appartements — {n_ch} chambres ({unit_type})",130            address=ADDRESS,131            sector="Lachine",132            city="Montréal",133            unit_type=unit_type,134            price=price,135            price_label=re.sub(r"\s+", " ", price_label).strip(),136            availability=availability,137            description=(f"{sqft}. {desc}" if sqft else desc)[:600],138            amenities=amenities[:15],139            details={"contact": dict(contact)} if contact else {},140            images=images,141        )142