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%
12.5 KB · 289 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/pourvoiries.py : Fédération des pourvoiries du Québec4# (pourvoiries.com) — ~340 pourvoiries avec hébergement (chalets, camps,5# pavillons, prêt-à-camper) partout en région.6#7# Méthode (TYPO3, aucun anti-bot) :8#   1. sitemap officiel des établissements (/sitemap/outfitters/sitemap.xml)9#      → inventaire complet des fiches /pourvoiries/<slug>-<zone>-<permis> ;10#   2. fiche établissement (cache self.detail, clé mensuelle) : nom, ville +11#      région (bandeau), description, coordonnées GPS (« Latitude : 48.805 »),12#      no d'établissement CITQ, période d'ouverture, type de restauration,13#      unités d'hébergement de l'onglet Hébergements (Pavillon / Chalet /14#      Camp / Prêt-à-camper… avec capacité et chambres), photos du carrousel ;15#   3. prix : les fiches n'affichent pas de tarif d'hébergement ; le sitemap16#      des forfaits (/sitemap/packages/sitemap.xml, slug préfixé du no de17#      permis) donne un prix « par personne / nuit » pour ~80 pourvoiries →18#      price_label du forfait le plus bas (à défaut d'un vrai prix/nuit).19#20# Une annonce = un établissement (les unités individuelles ne sont pas21# réservables en ligne — la liste des unités va dans details["unites"]).22# Seules les fiches avec au moins une unité d'hébergement sont retenues.23# Réglage env : LOUKA_POURVOIRIES_LIMIT (nb max de fiches, 0 = tout).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re29import sys30import time3132from ..schema import StListing, normalize_region33from .base import StConnector3435BASE = "https://www.pourvoiries.com"36SITEMAP_FICHES = f"{BASE}/sitemap/outfitters/sitemap.xml"37SITEMAP_FORFAITS = f"{BASE}/sitemap/packages/sitemap.xml"38PREFIX_FICHE = f"{BASE}/pourvoiries/"39PREFIX_FORFAIT = f"{BASE}/forfaits/"4041_LOC_RE = re.compile(r"<loc>\s*(https?://[^<\s]+)")42_PERMIS_RE = re.compile(r"(\d{2}-\d{3})$")           # fin du slug établissement43_FORFAIT_PERMIS_RE = re.compile(r"^(\d{2}-\d{3})-")  # début du slug forfait44_LAT_RE = re.compile(r"Latitude\s*:\s*(-?\d+\.\d+)")45_LNG_RE = re.compile(r"Longitude\s*:\s*(-?\d+\.\d+)")46_CAP_RE = re.compile(r"Pour\s+(\d+)\s+personne", re.I)47_CH_RE = re.compile(r"(\d+)\s+chambre", re.I)48_PRIX_FORFAIT_RE = re.compile(49    r'<strong class="price">\s*([\d\s,.  ]+)\s*\$', re.S)5051# Régions FPQ → région touristique canonique (le reste passe par52# normalize_region : Mauricie, Outaouais, Côte-Nord…)53_REGIONS_FPQ = {54    "gaspésie et îles-de-la-madeleine": "Gaspésie",55    "gaspesie et iles-de-la-madeleine": "Gaspésie",56    "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean",57    "nord-du-québec": "Nord-du-Québec",58    "baie-james": "Eeyou Istchee Baie-James",59}6061# Titre de section d'hébergement → type canonique Lou-Ka62_TYPE_UNITE = {63    "chalet": "Chalet", "pavillon": "Auberge", "auberge": "Auberge",64    "camp": "Refuge", "refuge": "Refuge", "yourte": "Yourte",65    "dôme": "Dôme", "tente": "Prêt-à-camper",66    "prêt-à-camper": "Prêt-à-camper", "camping": "Camping",67    "chambre": "Chambre", "maison": "Maison", "condo": "Condo",68}697071def _property_type(types_unites: list[str]) -> str:72    """Type dominant de l'établissement — le chalet prime (offre principale)."""73    canon = [_TYPE_UNITE.get(t.strip().lower(), "") for t in types_unites]74    for pref in ("Chalet", "Auberge", "Yourte", "Dôme", "Prêt-à-camper",75                 "Refuge", "Maison", "Condo", "Chambre", "Camping"):76        if pref in canon:77            return pref78    return "Chalet"798081class Pourvoiries(StConnector):82    source_id = "pourvoiries"83    request_delay = 0.88485    # -- fiche établissement --------------------------------------------------86    def _fetch_fiche(self, slug: str) -> dict:87        from bs4 import BeautifulSoup88        html = self.get(PREFIX_FICHE + slug).text89        soup = BeautifulSoup(html, "html.parser")90        d: dict = {}9192        h1 = soup.select_one("h1.page-title")93        if not h1:94            return {}95        d["nom"] = h1.get_text(" ", strip=True)9697        # bandeau : « Rivière-Bonjour, Gaspésie et Îles-de-la-Madeleine »98        banner = soup.select_one(".banner-single .region")99        if banner:100            loc = banner.get_text(" ", strip=True)101            ville, _, region = loc.partition(", ")102            d["ville"], d["region"] = ville.strip(), region.strip()103104        # description (premier bloc sous le h2 « Description »)105        for h2 in soup.find_all("h2"):106            if h2.get_text(strip=True).lower() == "description":107                paras = [p.get_text(" ", strip=True)108                         for p in h2.find_all_next("p", limit=4)]109                d["description"] = "\n".join(x for x in paras if x)[:2500]110                break111112        # onglet Informations : paires h3 → p113        infos: dict[str, str] = {}114        for h3 in soup.find_all("h3"):115            p = h3.find_next_sibling("p")116            if p is not None:117                infos[h3.get_text(" ", strip=True).lower()] = \118                    p.get_text(" ", strip=True)119        for label, key in (("numéro d'établissement", "citq"),120                           ("période d'ouverture", "ouverture"),121                           ("type de restauration", "restauration"),122                           ("type de pourvoirie", "type_pourvoirie"),123                           ("langue de service", "langues")):124            for k, v in infos.items():125                if k.startswith(label):126                    d[key] = v127                    break128129        m = _LAT_RE.search(html)130        if m:131            d["lat"] = float(m.group(1))132        m = _LNG_RE.search(html)133        if m:134            d["lng"] = float(m.group(1))135136        # onglet Hébergements : sections (h2.block-title) → cartes d'unités137        unites: list[dict] = []138        heb = soup.find(id="hebergements")139        if heb is not None:140            for bloc in heb.select(".block-slides"):141                t = bloc.select_one("h2.block-title")142                type_u = t.get_text(" ", strip=True) if t else ""143                for card in bloc.select(".card"):144                    titre = card.select_one(".card-title")145                    if titre is None:146                        continue147                    txt = card.get_text(" ", strip=True)148                    cap = _CAP_RE.search(txt)149                    ch = _CH_RE.search(txt)150                    unites.append({151                        "type": type_u,152                        "nom": titre.get_text(" ", strip=True),153                        "capacite": int(cap.group(1)) if cap else None,154                        "chambres": int(ch.group(1)) if ch else None,155                        "etoiles": len(card.select(".card-icons-stars "156                                                   ".icon-star")) or None,157                    })158        d["unites"] = unites159160        # photos du carrousel principal161        imgs: list[str] = []162        slider = soup.select_one(".block-slider-img")163        if slider is not None:164            for img in slider.find_all("img"):165                u = img.get("data-src") or img.get("src") or ""166                if u.startswith("/"):167                    u = BASE + u168                if u.startswith("https://") and u not in imgs:169                    imgs.append(u)170        d["images"] = imgs[:12]171        return d172173    # -- page forfait (prix « par personne / nuit ») ---------------------------174    def _fetch_forfait(self, slug: str) -> dict:175        html = self.get(PREFIX_FORFAIT + slug).text176        m = _PRIX_FORFAIT_RE.search(html)177        if not m:178            return {}179        try:180            prix = float(re.sub(r"[\s  ]", "", m.group(1)).replace(",", "."))181        except ValueError:182            return {}183        unite = ""184        mm = re.search(r'class="card-pricing">.*?<p>([^<]+)</p>', html, re.S)185        if mm:186            unite = mm.group(1).strip()187        return {"prix": prix, "unite": unite}188189    # -- inventaire ------------------------------------------------------------190    def fetch(self) -> list[StListing]:191        limit = int(os.environ.get("LOUKA_POURVOIRIES_LIMIT", "0") or 0)192        month = time.strftime("%Y-%m")      # re-visite mensuelle des fiches193194        xml = self.get(SITEMAP_FICHES).text195        slugs = sorted({loc[len(PREFIX_FICHE):].strip("/")196                        for loc in _LOC_RE.findall(xml)197                        if loc.startswith(PREFIX_FICHE)})198199        # forfaits groupés par no de permis (slug « 01-501-… »)200        forfaits: dict[str, list[str]] = {}201        try:202            xmlf = self.get(SITEMAP_FORFAITS).text203            for loc in _LOC_RE.findall(xmlf):204                if not loc.startswith(PREFIX_FORFAIT):205                    continue206                fslug = loc[len(PREFIX_FORFAIT):].strip("/")207                m = _FORFAIT_PERMIS_RE.match(fslug)208                if m:209                    forfaits.setdefault(m.group(1), []).append(fslug)210        except Exception as exc:  # noqa: BLE001 — les forfaits sont optionnels211            print(f"[pourvoiries] sitemap forfaits : {exc}", file=sys.stderr)212213        listings: list[StListing] = []214        for slug in slugs:215            try:216                d = self.detail(slug, month, lambda s=slug: self._fetch_fiche(s))217            except Exception as exc:  # noqa: BLE001218                print(f"[pourvoiries] fiche {slug} : {exc}", file=sys.stderr)219                continue220            unites = d.get("unites") or []221            if not d.get("nom") or not unites:   # pas d'hébergement → hors sujet222                continue223224            # prix : forfait le moins cher de la pourvoirie (par pers. / nuit)225            price_label = ""226            m = _PERMIS_RE.search(slug)227            permis = m.group(1) if m else ""228            best: dict = {}229            for fslug in forfaits.get(permis, []):230                try:231                    f = self.detail(f"forfait:{fslug}", month,232                                    lambda s=fslug: self._fetch_forfait(s))233                except Exception as exc:  # noqa: BLE001234                    print(f"[pourvoiries] forfait {fslug} : {exc}",235                          file=sys.stderr)236                    continue237                # seuls les forfaits tarifés à la nuit (ou au jour) sont238                # comparables — un prix « par personne / séjour » fausserait239                # le prix/nuit dérivé par finalize(). Attention : « séjour »240                # contient « jour », d'où l'exclusion explicite.241                unite = f.get("unite", "").lower()242                if "jour" not in unite and "nuit" not in unite:243                    continue244                if "séjour" in unite or "sejour" in unite:245                    continue246                if f.get("prix") and (not best or f["prix"] < best["prix"]):247                    best = f248            if best:249                unite = best.get("unite") or "par personne / nuit"250                price_label = (f"forfait à partir de {best['prix']:.0f} $ "251                               f"{unite}")252253            region = d.get("region", "")254            region = _REGIONS_FPQ.get(region.lower(), normalize_region(region))255256            caps = [u["capacite"] for u in unites if u.get("capacite")]257            chs = [u["chambres"] for u in unites if u.get("chambres")]258            details = {k: v for k, v in {259                "permis": permis,260                "nb_unites": len(unites),261                "unites": unites[:40],262                "ouverture": d.get("ouverture", ""),263                "restauration": d.get("restauration", ""),264                "type_pourvoirie": d.get("type_pourvoirie", ""),265                "langues": d.get("langues", ""),266            }.items() if v}267268            listings.append(StListing(269                source=self.source_id,270                external_id=slug,271                url=PREFIX_FICHE + slug,272                title=d["nom"],273                property_type=_property_type([u["type"] for u in unites]),274                city=d.get("ville", ""),275                region=region,276                price_label=price_label,277                capacity=float(max(caps)) if caps else None,278                bedrooms=float(max(chs)) if chs else None,279                citq=d.get("citq", ""),280                description=d.get("description", ""),281                details=details,282                images=d.get("images") or [],283                lat=d.get("lat"),284                lng=d.get("lng"),285            ))286            if limit and len(listings) >= limit:287                break288        return listings289