SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
7.8 KB · 191 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/dupin_despres.py : connecteur Dupin Després (dupindespres.com)5#   Gestionnaire de Repentigny / Rive-Nord (Les Cimes Repentigny, Quartier du6#   Moulin, immeubles rue Notre-Dame…). WordPress (thème d'agence « Voyou »,7#   classes vy_*) rendu serveur : /appartements-a-louer + /page/N liste des8#   cartes par UNITÉ (titre, typologie, ville, prix, bandeau de disponibilité,9#   badge 55 ans+). La page détail fournit description, photos et l'adresse10#   civique (fil d'Ariane JSON-LD, ex. « #118-325 rue Notre-Dame Repentigny »).11#   Granularité : unité.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import json16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://dupindespres.com"24LIST_URL = f"{BASE}/appartements-a-louer"25MAX_PAGES = 102627IMG_RE = re.compile(28    r"https://dupindespres\.com/app/uploads/[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I)29SKIP_IMG_RE = re.compile(r"logo|favicon|icon|-\d{2,4}x\d{2,4}\.", re.I)30PHONE_RE = re.compile(r"(\d{3})\s*(\d{3})[\s-]*(\d{4})")31# dernier maillon du fil d'Ariane JSON-LD : « #118-325 rue Notre-Dame Repentigny »32CRUMB_RE = re.compile(r'"name"\s*:\s*"(#[^"]+)"')333435class DupinDespresConnector(BaseConnector):36    source_id = "dupin_despres"37    request_delay = 0.838    max_images = 123940    # -- page détail (cachée en BD via self.detail) ---------------------------41    def _detail(self, url: str) -> dict:42        try:43            html = self.get(url).text44        except Exception:45            return {}46        soup = BeautifulSoup(html, "html.parser")47        payload: dict = {}4849        # adresse : dernier maillon du fil d'Ariane JSON-LD (« #118-325 rue … »)50        for script in soup.find_all("script", type="application/ld+json"):51            m = CRUMB_RE.findall(script.string or "")52            if m:53                addr = json.loads(f'"{m[-1]}"') if "\\" in m[-1] else m[-1]54                # « #118-325 rue Notre-Dame Repentigny » -> « 325 rue Notre-Dame »55                addr = re.sub(r"^#?\d+\s*-\s*", "", addr).strip()56                payload["address"] = addr57                break5859        # description : paragraphes du corps de la fiche60        body = soup.select_one(".vy_main_body")61        if body is not None:62            paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))63                     for p in body.find_all("p")]64            desc = " ".join(t for t in paras if len(t) > 40)65            if desc:66                payload["description"] = desc[:1500]6768        # contact (téléphone affiché sur la fiche)69        m = re.search(r'href="tel:\+?1?(\d{10})"', html)70        if m:71            d = m.group(1)72            payload["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}"7374        # photos de la fiche75        imgs = [u for u in dict.fromkeys(IMG_RE.findall(html))76                if not SKIP_IMG_RE.search(u)]77        payload["images"] = imgs[: self.max_images]78        return payload7980    # -- fetch -----------------------------------------------------------------81    def fetch(self) -> list[Listing]:82        listings: list[Listing] = []83        seen: set[str] = set()8485        for page in range(1, MAX_PAGES + 1):86            url = LIST_URL if page == 1 else f"{LIST_URL}/page/{page}"87            try:88                html = self.get(url).text89            except Exception:90                break91            soup = BeautifulSoup(html, "html.parser")92            items = soup.select(".vy_rentals_listing_item")93            if not items:94                break9596            new_on_page = 097            for it in items:98                link = it.select_one("a.vy_link_cover[href]")99                if link is None:100                    continue101                href = link["href"].split("?")[0].rstrip("/")102                slug = href.rsplit("/", 1)[-1]103                if not slug or "/page/" in href or slug in seen:104                    continue105                seen.add(slug)106                new_on_page += 1107108                title = ""109                el = it.select_one(".vy_rentals_listing_item_title")110                if el is not None:111                    title = re.sub(r"\s+", " ", el.get_text(" ", strip=True))112113                # <span>4 ½</span> | <span>Repentigny</span> — certaines cartes114                # n'ont pas de span typologie (ex. Les Cimes Saint-Sulpice)115                unit_type = city = ""116                info = it.select_one(".vy_rentals_listing_item_info")117                if info is not None:118                    spans = [s.get_text(" ", strip=True)119                             for s in info.find_all("span")]120                    for s in (s for s in spans if s):121                        if re.search(r"½|1/2|studio|loft|chambre", s, re.I):122                            unit_type = normalize_unit_type(s)123                        else:124                            city = s125126                price = None127                price_label = ""128                el = it.select_one(".vy_rentals_listing_item_price")129                if el is not None:130                    price_label = el.get_text(" ", strip=True)131                    m = re.search(r"(\d[\d\s,]*)\s*\$", price_label)132                    if m:133                        try:134                            val = float(m.group(1).replace(" ", "")135                                        .replace(" ", "").replace(",", ""))136                            if 300 <= val <= 20000:137                                price = val138                        except ValueError:139                            pass140141                availability = ""142                el = it.select_one(".vy_bannerinfo_text")143                if el is not None:144                    availability = el.get_text(" ", strip=True)145146                amenities: list[str] = []147                if it.select_one(".vy_badge.--fiftyfive") is not None:148                    amenities.append("55 ans et plus")149150                card_img = ""151                img = it.select_one("img[data-src]")152                if img is not None:153                    card_img = img.get("data-src") or ""154155                key = f"{unit_type}|{city}|{price_label}|{availability}"156                det = self.detail(slug, key, lambda u=href: self._detail(u))157158                images = list(det.get("images") or [])159                if card_img and card_img not in images:160                    images.insert(0, card_img)161                details: dict = {}162                if det.get("phone"):163                    details["contact"] = {"phone": det["phone"]}164165                # adresse : fiche détail, sinon dérivée du slug de l'URL166                address = det.get("address") or ""167                if not address:168                    m = re.match(r"^\d+-(.+?)(?:-repentigny|-terrebonne)?$", slug)169                    if m and re.search(r"[a-z]{3}", m.group(1)):170                        address = m.group(1).replace("-", " ")171172                listings.append(Listing(173                    source=self.source_id,174                    external_id=slug,175                    url=href,176                    title=title or f"Appartement {unit_type}",177                    address=address,178                    city=city,179                    unit_type=unit_type,180                    price=price,181                    price_label=price_label,182                    availability=availability,183                    description=det.get("description", ""),184                    amenities=amenities,185                    details=details,186                    images=images[: self.max_images],187                ))188            if new_on_page == 0:189                break190        return listings191