SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.2 KB · 149 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/elements.py : connecteur Quartier les Éléments5#   (quartierleselements.com — Lévis, secteur Saint-Romuald, 5 phases).6#   Navigation : phase -> étage (plan interactif <area data-available>)7#   -> fiche d'unité (type, superficie, prix).8# -----------------------------------------------------------------------------9from __future__ import annotations1011import re1213from bs4 import BeautifulSoup1415from ..schema import Listing, infer_city, normalize_unit_type, parse_price16from .base import BaseConnector1718BASE = "https://www.quartierleselements.com"19ROOT = f"{BASE}/appartements-condos-locatifs-levis"20SECTOR = "Saint-Romuald"21ADDRESS = "1432, rue de Jupiter, Lévis"22# Bureau de location affiché sur chaque fiche d'unité23CONTACT = {"phone": "581-922-3334", "email": "info@quartierleselements.com"}242526class ElementsConnector(BaseConnector):27    source_id = "elements"28    request_delay = 0.529    max_floor_pages = 40      # garde-fou3031    def fetch(self) -> list[Listing]:32        # 1) Découvrir les pages d'étages de chaque phase33        floor_urls: list[str] = []34        for phase in range(1, 6):35            try:36                html = self.get(f"{ROOT}/phase-{phase}/").text37            except Exception:38                continue39            for path in sorted(set(re.findall(40                    rf'href="(/appartements-condos-locatifs-levis/'41                    rf'phase-{phase}/etage-\d+/)"', html))):42                floor_urls.append(BASE + path)4344        # 2) Repérer les unités disponibles sur les plans d'étages45        unit_urls: list[str] = []46        for url in floor_urls[:self.max_floor_pages]:47            try:48                html = self.get(url).text49            except Exception:50                continue51            for tag in re.findall(r"<area\b[^>]*>", html, re.S):52                if 'data-available="1"' not in tag:53                    continue54                m = re.search(r'href="([^"]+)"', tag)55                if m and m.group(1) not in unit_urls:56                    unit_urls.append(m.group(1))5758        # 3) Fiche de chaque unité disponible59        listings: list[Listing] = []60        for path in unit_urls:61            full_url = path if path.startswith("http") else BASE + path62            try:63                html = self.get(full_url).text64            except Exception:65                continue66            try:67                soup = BeautifulSoup(html, "html.parser")68                text = soup.get_text("\n", strip=True)6970                num = re.search(r"Unité\s+(\w+)", text)71                unit_no = num.group(1) if num else path.strip("/").split("/")[-1]72                phase_m = re.search(r"phase-(\d)", path)73                phase = phase_m.group(1) if phase_m else "?"7475                type_m = re.search(r"Grandeur\s*:\s*([^\n]+)", text)76                unit_type = normalize_unit_type(type_m.group(1)) if type_m else ""7778                prix_line = ""79                pm = re.search(r"Prix\s*:\s*([^\n]+)", text)80                price = None81                if pm:82                    prix_line = pm.group(1).strip()83                    price = parse_price(prix_line)8485                availability = "Disponible"86                am = re.search(r"pour\s+([a-zû]+\s+20\d\d)", prix_line, re.I)87                if am:88                    availability = f"Disponible ({am.group(1)})"8990                # superficies structurées : la brute = superficie du logement91                # (le parsing générique prendrait le min, donc la terrasse)92                area_sqft = None93                desc_parts = []94                for label in ("Superficie brute", "Superficie terrasse",95                              "Superficie totale"):96                    dm = re.search(rf"{label}\s*:\s*([^\n]+)", text)97                    if dm:98                        desc_parts.append(f"{label} : {dm.group(1).strip()}")99                        if label == "Superficie brute":100                            nm = re.search(r"(\d[\d\s]*(?:[.,]\d+)?)\s*pi",101                                           dm.group(1))102                            if nm:103                                area_sqft = float(104                                    nm.group(1).replace(" ", "").replace(",", "."))105                extra = re.search(r"Avec boudoir|Avec bureau", text)106                amenities = [extra.group(0)] if extra else []107                tm = re.search(r"Superficie terrasse", text)108                if tm:109                    amenities.append("Terrasse")110111                # étage structuré (segment /etage-N/ de l'URL)112                details: dict = {"contact": dict(CONTACT)}113                em = re.search(r"/etage-(\d+)/", full_url)114                if em:115                    details["floor"] = int(em.group(1))116117                imgs = re.findall(118                    r'(?:src|href)="((?:https?://[^"]+|/)?uploads/[^"]+'119                    r'\.(?:jpg|jpeg|png|webp))"', html, re.I)120                images = []121                for u in dict.fromkeys(imgs):122                    if not u.startswith("http"):123                        u = BASE + ("/" + u.lstrip("/"))124                    images.append(u)125126                listings.append(Listing(127                    source=self.source_id,128                    external_id=f"phase-{phase}-unite-{unit_no}",129                    url=full_url,130                    title=f"Quartier les Éléments — Phase {phase}, "131                          f"unité {unit_no} ({unit_type})",132                    address=ADDRESS,133                    sector=SECTOR,134                    city=infer_city(SECTOR),135                    unit_type=unit_type,136                    price=price,137                    price_label=f"Prix : {prix_line}" if prix_line else "",138                    availability=availability,139                    area_sqft=area_sqft,140                    description=" | ".join(desc_parts),141                    amenities=amenities,142                    details=details,143                    images=images,144                ))145            except Exception:146                continue147148        return listings149