SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.6 KB · 224 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/morin.py : connecteur Constructions Morin (constructionsmorin.com)5#   Constructeur-locateur : apparts neufs tout inclus à Sherbrooke6#   (St-Élie/Rock-Forest, Fleurimont), Ascot Corner, East Angus, Windsor et7#   Lac-Mégantic. WordPress + Avada : la page /appartements-a-louer/ est8#   rendue serveur — un bloc .appartement_list_block par TYPE d'unité9#   disponible dans un immeuble (adresse h3, ville/secteur, projet, badge de10#   type « 4 ½ », « N disponibles », « À partir de … $ », date « Disponible11#   dès le … », chambres/sdb/stationnement). Granularité = type d'unité par12#   immeuble (les unités individuelles de la fiche n'ont ni prix ni dispo13#   propres). Les fiches (via self.detail, cache BD) ajoutent la description14#   (inclusions) et la galerie. external_id = slug projet/unité (stable).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://constructionsmorin.com"27LIST_URL = f"{BASE}/appartements-a-louer/"2829_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)30_KNOWN_CITIES = ["Sherbrooke", "Ascot Corner", "East Angus", "Windsor",31                 "Lac-Mégantic"]323334class MorinConnector(BaseConnector):35    source_id = "morin"36    request_delay = 0.637    max_details = 25      # garde-fou fiches détail (vraies requêtes)3839    def fetch(self) -> list[Listing]:40        html = self.get(LIST_URL).text41        soup = BeautifulSoup(html, "html.parser")4243        listings: dict[str, Listing] = {}44        for block in soup.select(".appartement_list_block"):45            try:46                lst = self._parse_block(block)47            except Exception:48                continue49            if lst and lst.external_id not in listings:50                listings[lst.external_id] = lst5152        # fiches (cache BD) : description (inclusions) + galerie du projet53        self._fetched = 054        for lst in listings.values():55            key = hashlib.sha1(56                f"{lst.title}|{lst.price_label}|{lst.availability}"57                .encode("utf-8")).hexdigest()58            try:59                payload = self.detail(lst.external_id, key,60                                      lambda u=lst.url: self._fetch_detail(u))61            except Exception:62                continue63            if payload.get("description"):64                lst.description = payload["description"]65            if payload.get("images"):66                lst.images = list(dict.fromkeys(payload["images"] + lst.images))[:20]67            if payload.get("area_label") and lst.area_sqft is None:68                # « Superficie: 860-1140 pi² » -> borne basse (plage réelle69                # conservée en commodité)70                m = re.match(r"([\d\s]+)", payload["area_label"])71                if m:72                    try:73                        val = float(m.group(1).replace(" ", ""))74                        if 80 <= val <= 20000:75                            lst.area_sqft = val76                    except ValueError:77                        pass78                lst.amenities = list(dict.fromkeys(79                    lst.amenities + [f"Superficie : {payload['area_label']}"]))80        return list(listings.values())8182    # -- un bloc = un type d'unité disponible dans un immeuble ------------------------83    def _parse_block(self, block) -> Listing | None:84        link = block.select_one("a[href*='/appartements-a-louer/']")85        if not link:86            return None87        url = link["href"]88        m = re.search(r"/appartements-a-louer/([^?#]+?)/?$", url)89        if not m:90            return None91        slug = m.group(1).strip("/")           # « horizon/5565-…-4-1-2 »9293        h3 = block.select_one("h3")94        address = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip() if h3 else ""9596        # ville + secteur : « Ascot Corner », « Sherbrooke Secteur St-Élie/… »97        sec_el = block.select_one(".appartement_list_information_secteur")98        sec_txt = re.sub(r"\s+", " ",99                         sec_el.get_text(" ", strip=True)).strip() if sec_el else ""100        city, sector = "", ""101        for c in _KNOWN_CITIES:102            if sec_txt.lower().startswith(c.lower()):103                city = c104                sector = re.sub(r"^Secteur\s+", "", sec_txt[len(c):].strip())105                break106        if not city:107            city = sec_txt108109        project_el = sec_el.find_next_sibling("div") if sec_el else None110        project = re.sub(r"\s+", " ", project_el.get_text(" ", strip=True)).strip() \111            if project_el else ""112113        # type d'unité : badge « 4 ½ »114        badge = block.select_one(".appartement_list_badge_libre")115        unit_type = normalize_unit_type(116            badge.get_text(" ", strip=True) if badge else "")117        if not re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison",118                            unit_type or ""):119            unit_type = ""120121        # « Disponible dès le 1er février 2027! » (bandeau de la carte)122        tag = block.select_one(".appartement_list_tag")123        availability = re.sub(r"\s+", " ",124                              tag.get_text(" ", strip=True)).strip() if tag else ""125126        # « À partir de | 1395$ »127        prix_el = block.select_one(".appartement_list_information_prix_montant")128        price_label = ""129        price = None130        if prix_el:131            amount = prix_el.get_text(" ", strip=True)132            price_label = f"À partir de {amount}"133            price = parse_price(re.sub(r"(\d)\s(\d{3})", r"\1\2", amount))134135        # commodités structurées de la carte : dispo/nb unités, chambres, sdb,136        # stationnement, superficie137        amenities: list[str] = []138        libre = block.select_one(".appartement_list_libre_txt")139        if libre:140            amenities.append(re.sub(r"\s+", " ",141                                    libre.get_text(" ", strip=True)).strip())142        nb = block.select_one(".appartement_list_nb_appartement")143        if nb:144            amenities.append(re.sub(r"\s+", " ",145                                    nb.get_text(" ", strip=True)).strip())146        for unit in block.select(".appartement_list_info_block > div"):147            val = unit.select_one(".appartement_list_info_block_unit span")148            lab = unit.select_one(".appartement_list_info_block_unit_title")149            if val and lab:150                amenities.append(f"{val.get_text(strip=True)} "151                                 f"{lab.get_text(strip=True)}")152        area = None153        m2 = re.search(r"Superficie\s*:\s*([\d\s]+)(?:-||à)?([\d\s]*)pi",154                       block.get_text(" ", strip=True))155        if m2:156            try:157                area = float(m2.group(1).replace(" ", ""))158            except ValueError:159                pass160161        # visuel de la carte (background-image du bloc)162        images: list[str] = []163        bg = block.select_one(".appartement_list_image_bg")164        if bg:165            mi = re.search(r"url\('([^']+)'\)", bg.get("style") or "")166            if mi and mi.group(1).startswith("http"):167                images.append(_VARIANT_IMG.sub("", mi.group(1)))168169        title = f"{unit_type}{address}" if unit_type else address170        if project:171            title += f" ({project})"172173        return Listing(174            source=self.source_id,175            external_id=slug,176            url=url,177            title=title,178            address=address,179            sector=sector,180            city=city,181            unit_type=unit_type,182            price=price,183            price_label=price_label,184            availability=availability,185            area_sqft=area,186            amenities=amenities,187            details={"project": project} if project else {},188            images=images,189        )190191    # -- fiche type d'unité -------------------------------------------------------192    def _fetch_detail(self, url: str) -> dict:193        if self._fetched >= self.max_details:194            raise RuntimeError("budget de fiches détail atteint")195        self._fetched += 1196        html = self.get(url).text197        soup = BeautifulSoup(html, "html.parser")198        out: dict = {}199200        # « Votre appartement luxueux comprend : » + liste d'inclusions201        head = soup.find(string=re.compile(r"appartement.*comprend", re.I))202        if head:203            ul = head.parent.find_next("ul")204            if ul:205                items = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip(" ;")206                         for li in ul.select("li")]207                out["description"] = "Votre appartement comprend : " + \208                                     " ; ".join(t for t in items if t)[:1400]209210        # « Superficie: 860-1140 pi² » (fiche du type d'unité)211        ma = re.search(r"Superficie\s*:\s*([\d\s]+(?:[-–à]\s*[\d\s]+)?)\s*pi",212                       soup.get_text(" ", strip=True))213        if ma:214            out["area_label"] = re.sub(r"\s+", " ", ma.group(1)).strip() + " pi²"215216        images: list[str] = []217        for img in soup.select("img[src*='/wp-content/uploads/']"):218            src = _VARIANT_IMG.sub("", str(img.get("src") or ""))219            if src.startswith("http") and src not in images \220                    and not re.search(r"logo|icon|favicon|Projet-", src):221                images.append(src)222        out["images"] = images[:15]223        return out224