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%
9.1 KB · 219 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/gitesauquebec.py : GitesAuQuebec.com — annuaire indépendant de4# gîtes et auberges (B&B). Annuaire en fin de vie : l'inventaire ACTIF est5# aujourd'hui minuscule (~7 fiches, « Page 1 de 1 » sur la recherche sans6# critère), mais les fiches restantes sont riches et le connecteur suivra7# l'inventaire s'il remonte.8#9# Méthode (ASP.NET WebForms, aucun anti-bot) :10#   1. LISTE : GET /Resultats.aspx sans critère → toutes les annonces actives11#      (cartes en tables imbriquées : lien /<id>, tarif « 145$ - 205$ nuit »).12#      La pagination (« Page 1 de N ») est un postback __VIEWSTATE : tant que13#      N = 1 on n'en a pas besoin ; si N > 1 un avertissement est émis (le14#      rejouer n'a rien à répliquer aujourd'hui, l'annuaire tient sur 1 page).15#   2. FICHE /<id> (cache self.detail, clé mensuelle) : contrôles ASP.NET16#      stables — titre « PL-<id> : Nom », CPH_litInfoGen (type, capacité,17#      chambres, salles de bain, lits, animaux, fumeurs), localisation18#      (région / ville), CPH_lblDesc, CPH_lblNoEtablissementValeur (CITQ),19#      CPH_hidAdrMap (« lat, lng »), CPH_pnlTarif, équipements (img alt),20#      photos /_photos/grand/.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import re25import sys26import time2728from ..schema import StListing, normalize_region29from .base import StConnector3031BASE = "https://www.gitesauquebec.com"32RESULTATS = f"{BASE}/Resultats.aspx"3334_ID_RE = re.compile(r"href='/(\d+)'")35_PAGE_RE = re.compile(r"Page\s+(\d+)\s+de\s+(\d+)")36_TARIF_RE = re.compile(r"(\d[\d\s,.]*\$[^<]{0,40})")37_COORD_RE = re.compile(38    r'id="CPH_hidAdrMap" value="(-?\d+\.\d+),\s*(-?\d+\.\d+)"')39_NUM_RE = re.compile(r"(\d+)")4041# « Type hébergement » de l'annuaire → type canonique Lou-Ka42_TYPES = {"gîte": "Gîte", "gite": "Gîte", "auberge": "Auberge",43          "b&b": "Gîte", "couette et café": "Gîte"}4445# l'annuaire écrit « Cantons de l'est / Estrie », « Centre du Québec »…46_REGIONS = {"cantons de l'est / estrie": "Cantons-de-l'Est",47            "centre du québec": "Centre-du-Québec"}484950class GitesAuQuebec(StConnector):51    source_id = "gitesauquebec"52    request_delay = 1.05354    # -- liste ------------------------------------------------------------------55    def _liste(self) -> dict[str, str]:56        """Annonces actives → {id: libellé de tarif de la carte}."""57        html = self.get(RESULTATS).text58        m = _PAGE_RE.search(re.sub(r"<[^>]+>", " ", html))59        if m and int(m.group(2)) > 1:60            print(f"[gitesauquebec] pagination inattendue ({m.group(0)}) : "61                  "seule la page 1 est lue (postback __VIEWSTATE à rejouer)",62                  file=sys.stderr)63        tarifs: dict[str, str] = {}64        # une carte = tout le HTML entre deux liens de fiche successifs65        morceaux = _ID_RE.split(html)66        for i in range(1, len(morceaux), 2):67            gid, bloc = morceaux[i], morceaux[i + 1]68            if gid in tarifs and tarifs[gid]:69                continue70            tarif = ""71            j = bloc.find("TARIFICATION")72            if j >= 0:73                mm = _TARIF_RE.search(re.sub(r"<[^>]+>", " ", bloc[j:j + 800]))74                if mm:75                    tarif = re.sub(r"\s+", " ", mm.group(1)).strip()76            tarifs[gid] = tarif77        return tarifs7879    # -- fiche ------------------------------------------------------------------80    def _fetch_fiche(self, gid: str) -> dict:81        from bs4 import BeautifulSoup82        html = self.get(f"{BASE}/{gid}").text83        soup = BeautifulSoup(html, "html.parser")84        d: dict = {}8586        # en-tête « PL-3778 : Gite du Village » (préfixe variable : PL, DI…)87        h1 = soup.find(string=re.compile(rf"[A-Z]{{1,3}}-{gid}\s*:"))88        if h1:89            d["nom"] = h1.split(":", 1)[1].strip()90            m = re.match(rf"([A-Z]{{1,3}}-{gid})", h1.strip())91            if m:92                d["no_annonce"] = m.group(1)93        if not d.get("nom"):94            # repli : <title> = « Gîte <ville>, <région>, <nom…>, XX-<id> »95            title = soup.title.get_text(strip=True) if soup.title else ""96            m = re.match(rf"[^,]+,[^,]+,\s*(.+?),?\s*([A-Z]{{1,3}}-{gid})$",97                         title)98            if m:99                d["nom"], d["no_annonce"] = m.group(1).strip(), m.group(2)100        if not d.get("nom"):101            return {}102103        # Informations générales : « Étiquette :&nbsp;Valeur » dans CPH_litInfoGen104        infos: dict[str, str] = {}105        bloc = soup.find(id="CPH_litInfoGen")106        if bloc is not None:107            for cell in bloc.get_text("\n").split("\n"):108                label, sep, val = cell.partition(":")109                if sep and val.strip():110                    infos[label.strip().lower()] = val.strip()111        d["type"] = infos.get("type hébergement", "")112        m = _NUM_RE.search(infos.get("capacité d'accueil", ""))113        if m:114            d["capacity"] = int(m.group(1))115        m = _NUM_RE.search(infos.get("chambres", ""))116        if m:117            d["bedrooms"] = int(m.group(1))118        m = _NUM_RE.search(infos.get("salles de bain", ""))119        if m:120            d["bathrooms"] = int(m.group(1))121        d["lits"] = infos.get("lits", "")122        animaux = infos.get("animaux permis", "").lower()123        if animaux.startswith("oui"):124            d["pets"] = "oui"125        elif animaux.startswith("non"):126            d["pets"] = "non"127128        # Localisation : lignes « Région : / Ville : » du tableau129        texte = soup.get_text("\n")130        for champ, cle in (("Région", "region"), ("Ville", "ville")):131            m = re.search(rf"{champ}\s*:\s*\n+\s*([^\n]+)", texte)132            if m:133                d[cle] = m.group(1).strip()134135        desc = soup.find(id="CPH_lblDesc")136        if desc is not None:137            d["description"] = desc.get_text("\n", strip=True)[:2500]138139        citq = soup.find(id="CPH_lblNoEtablissementValeur")140        if citq is not None:141            d["citq"] = citq.get_text(strip=True)142143        m = _COORD_RE.search(html)144        if m:145            d["lat"], d["lng"] = float(m.group(1)), float(m.group(2))146147        tarif = soup.find(id="CPH_pnlTarif")148        if tarif is not None:149            d["tarif"] = re.sub(150                r"\s+", " ",151                tarif.get_text(" ", strip=True).removeprefix("Tarification")152            ).strip()[:300]153154        # équipements & activités : alt des pictogrammes155        amen: list[str] = []156        for img in soup.find_all("img", src=re.compile("equipement")):157            alt = (img.get("alt") or "").strip()158            if alt and alt not in amen:159                amen.append(alt)160        d["amenities"] = amen[:40]161162        imgs: list[str] = []163        for img in soup.find_all("img", src=re.compile(r"/_photos/")):164            u = img.get("src") or ""165            u = re.sub(r"/_photos/(thumb/)?", "/_photos/grand/", u)166            if not u.startswith("http"):167                u = BASE + u168            if u not in imgs:169                imgs.append(u)170        d["images"] = imgs[:12]171        return d172173    # -- contrat ----------------------------------------------------------------174    def fetch(self) -> list[StListing]:175        month = time.strftime("%Y-%m")      # re-visite mensuelle (tarifs)176        listings: list[StListing] = []177        for gid, tarif_carte in sorted(self._liste().items(),178                                       key=lambda kv: int(kv[0])):179            try:180                d = self.detail(gid, month,181                                lambda g=gid: self._fetch_fiche(g))182            except Exception as exc:  # noqa: BLE001183                print(f"[gitesauquebec] fiche {gid} : {exc}", file=sys.stderr)184                continue185            if not d.get("nom"):186                continue187188            price_label = d.get("tarif") or tarif_carte189            region = d.get("region", "")190            region = _REGIONS.get(region.lower(), normalize_region(region))191            details = {k: v for k, v in {192                "no_annonce": d.get("no_annonce", f"PL-{gid}"),193                "lits": d.get("lits", ""),194            }.items() if v}195196            listings.append(StListing(197                source=self.source_id,198                external_id=gid,199                url=f"{BASE}/{gid}",200                title=d["nom"],201                property_type=_TYPES.get(d.get("type", "").lower(), "Gîte"),202                city=d.get("ville", ""),203                region=region,204                price_label=price_label,205                capacity=float(d["capacity"]) if d.get("capacity") else None,206                bedrooms=float(d["bedrooms"]) if d.get("bedrooms") else None,207                bathrooms=(float(d["bathrooms"])208                           if d.get("bathrooms") else None),209                pets=d.get("pets"),210                citq=d.get("citq", ""),211                description=d.get("description", ""),212                amenities=d.get("amenities") or [],213                details=details,214                images=d.get("images") or [],215                lat=d.get("lat"),216                lng=d.get("lng"),217            ))218        return listings219