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%
8.1 KB · 201 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/trouve_ton_appart.py : connecteur Trouve-Ton-Appart5#   (trouve-ton-appart.com — regroupe 3 propriétaires familiaux : Construction6#    Léandre Demers, Immeubles Des Rochers, Immeubles Morency). ~229 fiches7#    dans Lotbinière (Laurier-Station, St-Flavien, St-Agapit, St-Apollinaire),8#    Lévis (St-Nicolas, St-Rédempteur, St-Romuald), Québec, Ste-Marie et9#    Trois-Rivières. Page /logements/ rendue serveur (PHP maison « Dix-Onze ») :10#    sections <h2>Ville</h2> puis cartes div.loge — pastille img.dispo11#    green.png = Disponible / red.png = Non disponible. On n'ingère QUE les12#    unités disponibles (pastille verte). Fiche /logements/logement/<id>/<slug>/13#    (via cache BD) : description, grandeur + prix, propriétaire gestionnaire14#    (nom + téléphone + courriel) et photo principale.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, infer_city, normalize_unit_type24from .base import BaseConnector2526BASE = "https://www.trouve-ton-appart.com"27LIST_URL = f"{BASE}/logements/"2829# « 940 $ » (span.plus) puis « / mois 6½ » dans la même rangée30PRICE_RE = re.compile(r"([\d\s]+)\s*\$")31TYPE_RE = re.compile(r"/\s*mois\s*([\d]\s*½|Studio|Loft)", re.I)32LOGEMENT_URL_RE = re.compile(r"/logements/logement/(\d+)/([^/?#]+)")33PHONE_RE = re.compile(r"(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})")34# photos : /app_photos/<id>/pp_<hash>.<ext> (pleine taille, hors /thumbnails/)35PHOTO_RE = re.compile(r"/app_photos/\d+/(?!thumbnails/)[^\"'\s)]+"36                      r"\.(?:jpe?g|png|webp)", re.I)3738# villes du site -> ville canonique Lou-Ka (le reste passe par infer_city)39CITY_MAP = {40    "Ste-Marie-de-Beauce": "Sainte-Marie",41    "St-Agapit": "Saint-Agapit",42    "St-Apollinaire": "Saint-Apollinaire",43    "St-Flavien": "Saint-Flavien",44}454647class TrouveTonAppartConnector(BaseConnector):48    source_id = "trouve_ton_appart"49    request_delay = 0.650    max_details = 40     # garde-fou fiches détail (vraies requêtes par sync)5152    def fetch(self) -> list[Listing]:53        listings: list[Listing] = []54        html = self.get(LIST_URL).text55        soup = BeautifulSoup(html, "html.parser")56        self._fetched = 05758        container = soup.select_one("#logements") or soup59        city_label = ""60        for el in container.find_all(["h2", "div"], recursive=True):61            if el.name == "h2":62                city_label = el.get_text(strip=True)63                continue64            if "loge" not in (el.get("class") or []):65                continue66            try:67                lst = self._parse_card(el, city_label)68                if lst is not None:69                    listings.append(lst)70            except Exception:71                continue72        return listings7374    # -- carte (div.loge d'une section <h2>Ville</h2>) -------------------------------75    def _parse_card(self, card, city_label: str) -> Listing | None:76        # pastille de disponibilité : on n'ingère que le vert (Disponible)77        badge = card.select_one("img.dispo")78        badge_src = (badge.get("src") or "") if badge else ""79        if "green" not in badge_src:80            return None8182        link = card.select_one('a[href*="/logements/logement/"]')83        if not link:84            return None85        m = LOGEMENT_URL_RE.search(link.get("href") or "")86        if not m:87            return None88        ext_id, slug = m.group(1), m.group(2)89        url = f"{BASE}/logements/logement/{ext_id}/{slug}/"9091        detail_div = card.select_one("div.detail")92        text = re.sub(r"\s+", " ",93                      detail_div.get_text(" ", strip=True)) if detail_div else ""94        addr_el = card.select_one("div.detail div.plus")95        street = addr_el.get_text(strip=True) if addr_el else ""9697        # prix : le <span class="plus"> de la rangée prix (« 940 $ »), pour ne98        # pas absorber un numéro civique du genre « route 273 » dans le montant99        price = None100        price_label = ""101        unit_type = ""102        price_el = detail_div.select_one("span.plus") if detail_div else None103        pm = PRICE_RE.search(price_el.get_text(" ", strip=True)) if price_el \104            else None105        if pm:106            try:107                val = float(re.sub(r"\s", "", pm.group(1)))108                if 100 <= val <= 20000:109                    price = val110                    price_label = f"{int(val)} $ / mois"111            except ValueError:112                pass113        tm = TYPE_RE.search(text)114        if tm:115            unit_type = normalize_unit_type(tm.group(1))116117        city = CITY_MAP.get(city_label) or infer_city(city_label,118                                                      default=city_label)119120        # vignette de la liste (repli si la fiche n'a pas de photo pleine taille)121        images: list[str] = []122        thumb = card.select_one("img.image")123        if thumb and thumb.get("src"):124            src = thumb["src"]125            images.append(src if src.startswith("http") else BASE + src)126127        lst = Listing(128            source=self.source_id,129            external_id=ext_id,130            url=url,131            title=f"{street}, {city_label}" if street else city_label,132            address=f"{street}, {city_label}" if street else "",133            city=city,134            unit_type=unit_type,135            price=price,136            price_label=price_label,137            availability="Disponible",138            images=images,139        )140141        key = hashlib.sha1(f"{street}|{text}|{city_label}|green"142                           .encode("utf-8")).hexdigest()143        try:144            d = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u))145            self._apply_detail(lst, d)146        except Exception:147            pass148        return lst149150    # -- fiche détail (/logements/logement/<id>/<slug>/) -----------------------------151    def _fetch_detail(self, url: str) -> dict:152        """Description, grandeur/prix, propriétaire (nom, tél., courriel), photos."""153        if self._fetched >= self.max_details:154            raise RuntimeError("budget de fiches détail atteint")155        self._fetched += 1156        html = self.get(url).text157        soup = BeautifulSoup(html, "html.parser")158        out: dict = {}159160        # description : premier <p> sous <h2>Description</h2>161        for h2 in soup.find_all("h2"):162            if "description" in h2.get_text(strip=True).lower():163                p = h2.find_next("p")164                if p:165                    out["description"] = re.sub(166                        r"\s+", " ", p.get_text(" ", strip=True)).strip()[:1200]167                break168169        # propriétaire gestionnaire : <h3> sous « Pour location » + tel:/mailto:170        anchor = soup.find("a", attrs={"id": "location"})171        h3 = anchor.find_next("h3") if anchor else soup.find("h3")172        if h3 and h3.get_text(strip=True):173            out["manager"] = h3.get_text(strip=True)174        m = re.search(r'href="mailto:([^"?]+)"', html)175        if m:176            out["email"] = m.group(1).strip().lower()177        m = re.search(r'href="tel:\+?1?(\d{10})"', html)178        if m:179            d10 = m.group(1)180            out["phone"] = f"{d10[:3]}-{d10[3:6]}-{d10[6:]}"181182        # photos pleine taille (/app_photos/<id>/pp_*.ext hors miniatures)183        photos = [u if u.startswith("http") else BASE + u184                  for u in dict.fromkeys(PHOTO_RE.findall(html))]185        if photos:186            out["images"] = photos[:20]187        return out188189    def _apply_detail(self, lst: Listing, d: dict) -> None:190        if not d:191            return192        if d.get("description"):193            lst.description = d["description"]194        contact = {k: d[k] for k in ("phone", "email") if d.get(k)}195        if d.get("manager"):196            contact["name"] = d["manager"]197        if contact:198            lst.details["contact"] = contact199        if d.get("images"):200            lst.images = d["images"]201