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%
10.5 KB · 249 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/memoriachalets.py : Memoria Chalets (memoriachalets.com)4#   — gestionnaire de Magog, ~110 fiches FR (Cantons-de-l'Est surtout,5#   quelques Laurentides / Centre-du-Québec).6#7# Méthode : WordPress, CPT `cpt_chalets`, HTML serveur. Sitemap8#   /cpt_chalets-sitemap.xml (lastmod fiable ; doublons /en/ écartés) →9#   pages /locations/<slug>/. La fiche est balisée par classes :10#   h2.location-title (« Le Hibou – Magog »), p.location-specs11#   (« 4 CHAMBRES - 1.5 SALLES DE BAIN - 8 PERSONNES »), p.location-adress12#   (adresse complète), p.starting-price (« À partir de 199,00$ par jour » —13#   une partie du parc est au mois/31 jours minimum : prix nuit alors absent,14#   la mention est conservée dans details), p.citq (« CITQ #296459 … » ou15#   « (Location minimum de 31 jours) ») et ul de .accomosdations-filters16#   (commodités, dont « Animaux interdit/admis »). Pas de géo dans le HTML.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re2122from bs4 import BeautifulSoup2324from ...normalize import strip_accents25from ..schema import StListing, parse_price_night26from .base import StConnector2728SITEMAP = "https://memoriachalets.com/cpt_chalets-sitemap.xml"2930_URL_DETAIL = re.compile(31    r"https://(?:www\.)?memoriachalets\.com/locations/([\w%-]+)/?$")3233_SPECS_RE = re.compile(34    r"([\d.,]+)\s*CHAMBRES?\s*[-·—]\s*([\d.,]+)\s*SALLES?\s+DE\s+BAIN"35    r"\s*[-·—]\s*([\d.,]+)\s*PERSONNES?", re.I)3637_CP_RE = re.compile(r"^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$")   # code postal3839# mot-clé (titre) → type canonique ; défaut : Chalet40_TYPE_HINTS = [("condo", "Condo"), ("appartement", "Appartement"),41               ("apt", "Appartement"), ("loft", "Loft"), ("studio", "Studio"),42               ("maison", "Maison")]4344# indices de région dans le texte (leur parc : Estrie surtout)45_REGION_HINTS = [46    ("estrie", "Cantons-de-l'Est"), ("cantons-de-l'est", "Cantons-de-l'Est"),47    ("laurentides", "Laurentides"), ("lanaudiere", "Lanaudière"),48    ("centre-du-quebec", "Centre-du-Québec"), ("mauricie", "Mauricie"),49    ("charlevoix", "Charlevoix"), ("monteregie", "Montérégie"),50]5152# villes connues du parc → région touristique53_VILLE_REGION = {54    "magog": "Cantons-de-l'Est", "orford": "Cantons-de-l'Est",55    "eastman": "Cantons-de-l'Est", "austin": "Cantons-de-l'Est",56    "ayer's cliff": "Cantons-de-l'Est", "ayers cliff": "Cantons-de-l'Est",57    "north hatley": "Cantons-de-l'Est", "sherbrooke": "Cantons-de-l'Est",58    "sainte-catherine-de-hatley": "Cantons-de-l'Est",59    "canton d'orford": "Cantons-de-l'Est", "bromont": "Cantons-de-l'Est",60    "sutton": "Cantons-de-l'Est", "stukely-sud": "Cantons-de-l'Est",61    "bolton-est": "Cantons-de-l'Est", "potton": "Cantons-de-l'Est",62    "mansonville": "Cantons-de-l'Est", "coaticook": "Cantons-de-l'Est",63    "piopolis": "Cantons-de-l'Est", "lac-megantic": "Cantons-de-l'Est",64}6566_RUE_RE = re.compile(r"^(rue|chemin|ch\.|avenue|av\.|route|rte|boulevard|"67                     r"boul\.?|montee|impasse|allee|place)\b")686970def _num(raw: str) -> float | None:71    try:72        return float(raw.replace(",", "."))73    except (TypeError, ValueError):74        return None757677class MemoriaChalets(StConnector):78    source_id = "memoriachalets"7980    # -- inventaire (sitemap FR + lastmod) -----------------------------------81    def _sitemap_urls(self) -> dict[str, tuple[str, str]]:82        """slug -> (url détail FR, lastmod)."""83        xml = self.get(SITEMAP).text84        urls: dict[str, tuple[str, str]] = {}85        for bloc in re.findall(r"<url>(.*?)</url>", xml, re.S):86            m = re.search(r"<loc>([^<]+)</loc>", bloc)87            if not m:88                continue89            loc = m.group(1).strip()90            mu = _URL_DETAIL.match(loc)91            if not mu or mu.group(1) == "locations":92                continue93            lastmod = re.search(r"<lastmod>([^<]+)</lastmod>", bloc)94            urls.setdefault(mu.group(1),95                            (loc, lastmod.group(1) if lastmod else ""))96        return urls9798    # -- page détail ---------------------------------------------------------99    def _detail(self, url: str) -> dict:100        html = self.get(url).text101        soup = BeautifulSoup(html, "html.parser")102        d: dict = {}103104        h2 = soup.select_one("h2.location-title") or soup.find("h2")105        if h2 is not None:106            d["title"] = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))107108        specs = soup.select_one("p.location-specs")109        if specs is not None:110            m = _SPECS_RE.search(specs.get_text(" ", strip=True))111            if m:112                d["bedrooms"] = _num(m.group(1))113                d["bathrooms"] = _num(m.group(2))114                d["capacity"] = _num(m.group(3))115116        # « 207, Rue des Pruches, Magog, Québec, J1X 0M9, Canada »117        adresse = soup.select_one("p.location-adress")118        if adresse is not None:119            texte = re.sub(r"\s+", " ", adresse.get_text(" ", strip=True))120            d["address"] = texte121            restants = []122            for p in texte.split(","):123                # code postal parfois collé à la ville (« Bromont J2L 2C1 »)124                p = re.sub(r"\b[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d\b", "",125                           p).strip()126                k = strip_accents(p).lower()127                if (not p or _CP_RE.match(p) or k in ("quebec", "qc", "canada")128                        or re.match(r"^(appartement|app|unite)\b", k)):129                    continue130                restants.append(p)131            if restants:132                ville = restants[-1]133                # adresse sans virgule (« Rue des Noyers Orford ») :134                # la ville est le dernier mot135                if _RUE_RE.match(strip_accents(ville).lower()):136                    ville = ville.split()[-1]137                d["city"] = ville138139        prix = soup.select_one("p.starting-price")140        if prix is not None:141            label = re.sub(r"\s+", " ", prix.get_text(" ", strip=True))142            label = re.sub(r"^(À partir de\s+)+", "À partir de ", label, flags=re.I)143            d["price_label"] = label144145        citq = soup.select_one("p.citq")146        if citq is not None:147            texte = citq.get_text(" ", strip=True)148            m = re.search(r"CITQ\s*#?\s*(\d{6})", texte)149            if m:150                d["citq"] = m.group(1)151            m = re.search(r"\(([^)]*[Ll]ocation minimum[^)]*)\)", texte)152            if m:153                d["location_minimum"] = m.group(1).strip()154155        # commodités (dont le statut animaux)156        amen: list[str] = []157        for span in soup.select(".accomosdations-filters li span"):158            t = re.sub(r"\s+", " ", span.get_text(" ", strip=True))159            if t and t not in amen:160                amen.append(t)161        if amen:162            d["amenities"] = amen163        for a in amen:164            k = strip_accents(a).lower()165            if "animaux" in k or "chien" in k:166                d["pets"] = "non" if ("interdit" in k or "refus" in k167                                      or "non admis" in k) else "oui"168169        # description : paragraphes de la 1re colonne de la fiche170        col = soup.select_one('[itemprop="articleBody"] div')171        if col is not None:172            morceaux = []173            for p in col.find_all("p"):174                classes = " ".join(p.get("class") or [])175                if any(c in classes for c in176                       ("location-specs", "location-adress",177                        "starting-price", "citq")):178                    continue179                t = p.get_text(" ", strip=True)180                if t:181                    morceaux.append(re.sub(r"\s+", " ", t))182            if morceaux:183                d["description"] = "\n".join(morceaux)[:5000]184185        # photos (uploads, sans logo/icônes ; dédoublonnage sur le nom de186        # base sans suffixe de taille -WxH)187        imgs, vus = [], set()188        for u in re.findall(r'(https://(?:www\.)?memoriachalets\.com/'189                            r'wp-content/uploads/[^"\'\s>]+\.(?:jpe?g|webp))',190                            html):191            base = re.sub(r"-\d+x\d+(?=\.\w+$)", "", u)192            nom = strip_accents(base).lower()193            if base in vus or "logo" in nom or "icon" in nom:194                continue195            vus.add(base)196            imgs.append(re.sub(r"-\d+x\d+(?=\.\w+$)", "", u))197        d["images"] = imgs[:20]198        return d199200    # -- contrat --------------------------------------------------------------201    def fetch(self) -> list[StListing]:202        listings: list[StListing] = []203        for slug, (url, lastmod) in self._sitemap_urls().items():204            try:205                d = self.detail(slug, lastmod or "sans-lastmod",206                                lambda u=url: self._detail(u))207            except Exception:      # une fiche cassée ≠ inventaire perdu208                d = {}209            if not d.get("title"):210                continue211212            hay = strip_accents(d["title"]).lower()213            ptype = next((c for n, c in _TYPE_HINTS if n in hay), "Chalet")214215            # région : ville connue, sinon indice textuel (« en Estrie »…)216            ville = d.get("city", "")217            region = _VILLE_REGION.get(strip_accents(ville).lower(), "")218            if not region:219                texte = strip_accents(" ".join(220                    (d.get("address", ""), d.get("description", "")))).lower()221                region = next((r for n, r in _REGION_HINTS if n in texte), "")222223            details = {}224            if d.get("location_minimum"):225                details["location_minimum"] = d["location_minimum"]226227            listings.append(StListing(228                source=self.source_id,229                external_id=slug,                 # slug WP, stable230                url=url,231                title=d["title"],232                property_type=ptype,233                address=d.get("address", ""),234                city=ville,235                region=region,236                price_night=parse_price_night(d.get("price_label", "")),237                price_label=d.get("price_label", ""),238                capacity=d.get("capacity"),239                bedrooms=d.get("bedrooms"),240                bathrooms=d.get("bathrooms"),241                pets=d.get("pets"),242                citq=d.get("citq", ""),243                description=d.get("description", ""),244                amenities=d.get("amenities") or [],245                details=details,246                images=d.get("images") or [],247            ))248        return listings249