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.2 KB · 247 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/origine.py : Ôrigine artisans hôteliers (originehotels.com)4#   — ex-Hôtellerie Champêtre : réseau de 35 hôtels et auberges indépendants5#   dans 15 régions touristiques du Québec. Réservation en direct sur la fiche.6#7# Méthode : site Drupal, HTML serveur, accessible en direct (le sitemap ne8#   liste pas les fiches → l'annuaire /fr/hotels-auberges fournit l'inventaire,9#   liens /fr/hotels-auberges/<région>/<slug>). La fiche (cache détail « v1 »)10#   est très balisée :11#     h1 (titre, div .visually-hidden à écarter), .header-hotel-place12#     (« La Malbaie - Charlevoix » → ville + région), .hotel-map-address h413#     (adresse civique), .hotel-map-citq (« Numéro CITQ: 012272 »),14#     .hotel-map-view (data-lat/data-lng), .hotel-map-infos (téléphone, site15#     web de l'hôtel), .text-cta-head/.text-cta-content (présentation),16#     .activity-list-services-item (« Services offerts »),17#     .utility-list-item (types de chambre — leur div prix est toujours vide).18#   PRIX : les seuls montants de la fiche sont les forfaits de l'hôtel19#     (.package-item-v2 : « Tarif du jour 209,00$ », « Évasion | 2 nuits20#     698,00$ »…). price_night = minimum des forfaits ramenés à la nuit21#     (« | N nuits » → ÷ N) en écartant les échelles de rabais long séjour22#     (« 3e nuitée à 50% », « 5 nuits et plus… ») dont le total est ambigu.23#   PHOTOS : /sites/default/files/styles/<style>/public/ dans les répertoires24#     hotel-gallerie (héro), galerie-texte-image et hotel-chamgres-et-services25#     (sic) ; on écarte forfaits-hotels-packages-galerie et order_email_hotels26#     (images d'AUTRES hôtels / bons de commande). Dédoublonnage sur le nom de27#     fichier (variantes .webp/.jpeg + styles 1x/1.5x/2x), on garde la 2x.28#   capacity/bedrooms restent None (hôtel : les unités varient) ; les types de29#   chambre vont dans details["room_types"].30# -----------------------------------------------------------------------------31from __future__ import annotations3233import re34import sys35from urllib.parse import unquote3637from bs4 import BeautifulSoup3839from ...normalize import strip_accents40from ..schema import StListing, normalize_region41from .base import StConnector4243BASE = "https://originehotels.com"44INDEX = BASE + "/fr/hotels-auberges"4546# liens de fiche dans l'annuaire : /fr/hotels-auberges/<région>/<slug>47_FICHE_RE = re.compile(r'href="(/fr/hotels-auberges/([\w-]+)/([\w-]+))"')4849# segments de région d'URL que normalize_region ne résout pas seul50_URL_REGIONS = {51    "cote-nord-duplessis": "Côte-Nord",52    "cote-nord-manicouagan": "Côte-Nord",53    "cantons-de-l-est": "Cantons-de-l'Est",54    "chaudiere-appalaches": "Chaudière-Appalaches",55}5657# répertoires Drupal des photos propres à l'hôtel (héro, galerie, chambres)58_IMG_RE = re.compile(59    r'(/sites/default/files/styles/([^/"]+)/public/'60    r'(?:hotel-gallerie|galerie-texte-image|hotel-chamgres-et-services)/'61    r'[^"\s,]+?\.(?:jpe?g|webp|png)(?:\?itok=[\w-]+)?)', re.I)6263# forfaits à écarter du calcul de prix : échelles de rabais long séjour64_LADDER_RE = re.compile(r"\d+\s*e\s+nuit|nuits?\s+et\s+plus", re.I)65_NIGHTS_RE = re.compile(r"\|\s*(\d+)\s+nuits", re.I)66_MONEY_RE = re.compile(r"(\d[\d\s ]*?)(?:\s*[.,]\s*(\d{1,2}))?\s*\$")676869def _txt(node) -> str:70    return re.sub(r"\s+", " ", node.get_text(" ", strip=True)) if node else ""717273def _money(raw: str) -> float | None:74    m = _MONEY_RE.search(raw or "")75    if not m:76        return None77    val = float(re.sub(r"[\s ]", "", m.group(1)) + "." + (m.group(2) or "0"))78    return val if 0 < val < 100000 else None798081class Origine(StConnector):82    source_id = "origine"8384    # -- inventaire (annuaire) -------------------------------------------------85    def _fiches(self) -> list[tuple[str, str, str]]:86        """[(slug, url fiche, segment région d'URL)] dans l'ordre d'affichage."""87        html = self.get(INDEX).text88        fiches, vus = [], set()89        for path, region_seg, slug in _FICHE_RE.findall(html):90            if slug in vus or slug == "fiche-de-satisfaction":91                continue92            vus.add(slug)93            fiches.append((slug, BASE + path, region_seg))94        return fiches9596    # -- page fiche → dict sérialisable (cache détail) --------------------------97    def _detail(self, url: str) -> dict:98        html = self.get(url).text99        soup = BeautifulSoup(html, "html.parser")100        d: dict = {}101102        h1 = soup.find("h1")103        if h1 is not None:104            for hidden in h1.select(".visually-hidden"):105                hidden.decompose()106            d["title"] = _txt(h1)107108        # « La Malbaie - Charlevoix » → ville + région touristique109        place = _txt(soup.select_one(".header-hotel-place"))110        if place:111            parts = re.split(r"\s+-\s+", place)112            if len(parts) >= 2:113                d["city"], d["region"] = parts[0], parts[-1]114            else:115                d["city"] = place116117        d["address"] = _txt(soup.select_one(".hotel-map-address h4"))118119        m = re.search(r"CITQ\D{0,12}(\d{6})",120                      _txt(soup.select_one(".hotel-map-citq")))121        if m:122            d["citq"] = m.group(1)123124        view = soup.select_one(".hotel-map-view")125        if view is not None:126            try:127                d["lat"] = float(view.get("data-lat"))128                d["lng"] = float(view.get("data-lng"))129            except (TypeError, ValueError):130                pass131132        infos = soup.select_one(".hotel-map-infos")133        if infos is not None:134            tel = infos.select_one("a.phone-link")135            site = infos.select_one("a.website-link")136            if tel is not None:137                d["phone"] = _txt(tel)138            if site is not None and site.get("href"):139                d["website"] = site["href"].strip()140141        # présentation : accroche + texte du premier bloc text-cta142        morceaux = [_txt(soup.select_one(".text-cta-head"))]143        cont = soup.select_one(".text-cta-content")144        if cont is not None:145            morceaux.append("\n".join(146                t for p in cont.find_all(["p", "li", "h3", "h4"])147                if (t := re.sub(r"\s+", " ", p.get_text(" ", strip=True))))148                or _txt(cont))149        d["description"] = re.sub(r"\*{2,}", "",150                                  "\n".join(m for m in morceaux if m)).strip()151152        # « Services offerts »153        amen: list[str] = []154        for li in soup.select(".activity-list-services-item"):155            t = _txt(li)156            if t and t not in amen:157                amen.append(t)158        d["amenities"] = amen159160        # types de chambre (leur div prix est vide sur tout le réseau)161        rooms: list[str] = []162        for hd in soup.select(".utility-list-item .utility-list-item-heading"):163            t = _txt(hd)164            if t and t not in rooms:165                rooms.append(t)166        d["room_types"] = rooms167168        # forfaits de l'hôtel → prix « à partir de » ramené à la nuit169        best = None170        for item in soup.select(".package-item-v2"):171            titre = _txt(item.select_one(".package-item-v2-heading"))172            if _LADDER_RE.search(titre):173                continue174            prix = _money(_txt(item.select_one(".package-item-v2-price-value")))175            if prix is None:176                continue177            m = _NIGHTS_RE.search(titre)178            if m and int(m.group(1)) > 0:179                prix = round(prix / int(m.group(1)), 2)180            if best is None or prix < best:181                best = prix182        if best is not None:183            d["price_night"] = best184185        # photos : dédoublonnage nom de fichier, préférence au style 2x186        imgs: dict[str, tuple[int, str]] = {}187        for u, style in _IMG_RE.findall(html):188            nom = unquote(u.split("?")[0].rsplit("/", 1)[-1])189            nom = re.sub(r"\.\w+$", "", nom).lower()190            score = 3 if style.endswith("_2x") else \191                2 if style.endswith("_1_5x") else 1192            if ".webp" not in u.lower():193                score += 1          # à score de style égal, préférer le jpeg194            if nom not in imgs or score > imgs[nom][0]:195                imgs[nom] = (score, BASE + u)196        d["images"] = [u for _, u in imgs.values()][:40]197        return d198199    # -- contrat ---------------------------------------------------------------200    def fetch(self) -> list[StListing]:201        listings: list[StListing] = []202        for slug, url, region_seg in self._fiches():203            try:204                d = self.detail(slug, "v1", lambda u=url: self._detail(u))205            except Exception as exc:   # une fiche cassée ≠ inventaire perdu206                print(f"[origine] {slug} : {exc}", file=sys.stderr)207                d = {}208            if not d.get("title"):209                continue210211            titre = d["title"]212            ptype = ("Auberge" if re.search(r"\bauberge\b",213                                            strip_accents(titre).lower())214                     else "Hôtel")215216            region = normalize_region(d.get("region", ""))217            if not region:218                region = _URL_REGIONS.get(219                    region_seg, normalize_region(region_seg.replace("-", " ")))220221            prix = d.get("price_night")222            details = {"room_types": d.get("room_types") or []}223            for k in ("phone", "website"):224                if d.get(k):225                    details[k] = d[k]226227            listings.append(StListing(228                source=self.source_id,229                external_id=slug,               # slug de la fiche, stable230                url=url,231                title=titre,232                property_type=ptype,233                address=d.get("address", ""),234                city=d.get("city", ""),235                region=region,236                price_night=prix,237                price_label=(f"À partir de {prix:g} $ / nuit" if prix else ""),238                citq=d.get("citq", ""),239                description=d.get("description", "")[:5000],240                amenities=d.get("amenities") or [],241                details=details,242                images=d.get("images") or [],243                lat=d.get("lat"),244                lng=d.get("lng"),245            ))246        return listings247