# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/gitesauquebec.py : GitesAuQuebec.com — annuaire indépendant de # gîtes et auberges (B&B). Annuaire en fin de vie : l'inventaire ACTIF est # aujourd'hui minuscule (~7 fiches, « Page 1 de 1 » sur la recherche sans # critère), mais les fiches restantes sont riches et le connecteur suivra # l'inventaire s'il remonte. # # Méthode (ASP.NET WebForms, aucun anti-bot) : # 1. LISTE : GET /Resultats.aspx sans critère → toutes les annonces actives # (cartes en tables imbriquées : lien /, tarif « 145$ - 205$ nuit »). # La pagination (« Page 1 de N ») est un postback __VIEWSTATE : tant que # N = 1 on n'en a pas besoin ; si N > 1 un avertissement est émis (le # rejouer n'a rien à répliquer aujourd'hui, l'annuaire tient sur 1 page). # 2. FICHE / (cache self.detail, clé mensuelle) : contrôles ASP.NET # stables — titre « PL- : Nom », CPH_litInfoGen (type, capacité, # chambres, salles de bain, lits, animaux, fumeurs), localisation # (région / ville), CPH_lblDesc, CPH_lblNoEtablissementValeur (CITQ), # CPH_hidAdrMap (« lat, lng »), CPH_pnlTarif, équipements (img alt), # photos /_photos/grand/. # ----------------------------------------------------------------------------- from __future__ import annotations import re import sys import time from ..schema import StListing, normalize_region from .base import StConnector BASE = "https://www.gitesauquebec.com" RESULTATS = f"{BASE}/Resultats.aspx" _ID_RE = re.compile(r"href='/(\d+)'") _PAGE_RE = re.compile(r"Page\s+(\d+)\s+de\s+(\d+)") _TARIF_RE = re.compile(r"(\d[\d\s,.]*\$[^<]{0,40})") _COORD_RE = re.compile( r'id="CPH_hidAdrMap" value="(-?\d+\.\d+),\s*(-?\d+\.\d+)"') _NUM_RE = re.compile(r"(\d+)") # « Type hébergement » de l'annuaire → type canonique Lou-Ka _TYPES = {"gîte": "Gîte", "gite": "Gîte", "auberge": "Auberge", "b&b": "Gîte", "couette et café": "Gîte"} # l'annuaire écrit « Cantons de l'est / Estrie », « Centre du Québec »… _REGIONS = {"cantons de l'est / estrie": "Cantons-de-l'Est", "centre du québec": "Centre-du-Québec"} class GitesAuQuebec(StConnector): source_id = "gitesauquebec" request_delay = 1.0 # -- liste ------------------------------------------------------------------ def _liste(self) -> dict[str, str]: """Annonces actives → {id: libellé de tarif de la carte}.""" html = self.get(RESULTATS).text m = _PAGE_RE.search(re.sub(r"<[^>]+>", " ", html)) if m and int(m.group(2)) > 1: print(f"[gitesauquebec] pagination inattendue ({m.group(0)}) : " "seule la page 1 est lue (postback __VIEWSTATE à rejouer)", file=sys.stderr) tarifs: dict[str, str] = {} # une carte = tout le HTML entre deux liens de fiche successifs morceaux = _ID_RE.split(html) for i in range(1, len(morceaux), 2): gid, bloc = morceaux[i], morceaux[i + 1] if gid in tarifs and tarifs[gid]: continue tarif = "" j = bloc.find("TARIFICATION") if j >= 0: mm = _TARIF_RE.search(re.sub(r"<[^>]+>", " ", bloc[j:j + 800])) if mm: tarif = re.sub(r"\s+", " ", mm.group(1)).strip() tarifs[gid] = tarif return tarifs # -- fiche ------------------------------------------------------------------ def _fetch_fiche(self, gid: str) -> dict: from bs4 import BeautifulSoup html = self.get(f"{BASE}/{gid}").text soup = BeautifulSoup(html, "html.parser") d: dict = {} # en-tête « PL-3778 : Gite du Village » (préfixe variable : PL, DI…) h1 = soup.find(string=re.compile(rf"[A-Z]{{1,3}}-{gid}\s*:")) if h1: d["nom"] = h1.split(":", 1)[1].strip() m = re.match(rf"([A-Z]{{1,3}}-{gid})", h1.strip()) if m: d["no_annonce"] = m.group(1) if not d.get("nom"): # repli : = « Gîte <ville>, <région>, <nom…>, XX-<id> » title = soup.title.get_text(strip=True) if soup.title else "" m = re.match(rf"[^,]+,[^,]+,\s*(.+?),?\s*([A-Z]{{1,3}}-{gid})$", title) if m: d["nom"], d["no_annonce"] = m.group(1).strip(), m.group(2) if not d.get("nom"): return {} # Informations générales : « Étiquette : Valeur » dans CPH_litInfoGen infos: dict[str, str] = {} bloc = soup.find(id="CPH_litInfoGen") if bloc is not None: for cell in bloc.get_text("\n").split("\n"): label, sep, val = cell.partition(":") if sep and val.strip(): infos[label.strip().lower()] = val.strip() d["type"] = infos.get("type hébergement", "") m = _NUM_RE.search(infos.get("capacité d'accueil", "")) if m: d["capacity"] = int(m.group(1)) m = _NUM_RE.search(infos.get("chambres", "")) if m: d["bedrooms"] = int(m.group(1)) m = _NUM_RE.search(infos.get("salles de bain", "")) if m: d["bathrooms"] = int(m.group(1)) d["lits"] = infos.get("lits", "") animaux = infos.get("animaux permis", "").lower() if animaux.startswith("oui"): d["pets"] = "oui" elif animaux.startswith("non"): d["pets"] = "non" # Localisation : lignes « Région : / Ville : » du tableau texte = soup.get_text("\n") for champ, cle in (("Région", "region"), ("Ville", "ville")): m = re.search(rf"{champ}\s*:\s*\n+\s*([^\n]+)", texte) if m: d[cle] = m.group(1).strip() desc = soup.find(id="CPH_lblDesc") if desc is not None: d["description"] = desc.get_text("\n", strip=True)[:2500] citq = soup.find(id="CPH_lblNoEtablissementValeur") if citq is not None: d["citq"] = citq.get_text(strip=True) m = _COORD_RE.search(html) if m: d["lat"], d["lng"] = float(m.group(1)), float(m.group(2)) tarif = soup.find(id="CPH_pnlTarif") if tarif is not None: d["tarif"] = re.sub( r"\s+", " ", tarif.get_text(" ", strip=True).removeprefix("Tarification") ).strip()[:300] # équipements & activités : alt des pictogrammes amen: list[str] = [] for img in soup.find_all("img", src=re.compile("equipement")): alt = (img.get("alt") or "").strip() if alt and alt not in amen: amen.append(alt) d["amenities"] = amen[:40] imgs: list[str] = [] for img in soup.find_all("img", src=re.compile(r"/_photos/")): u = img.get("src") or "" u = re.sub(r"/_photos/(thumb/)?", "/_photos/grand/", u) if not u.startswith("http"): u = BASE + u if u not in imgs: imgs.append(u) d["images"] = imgs[:12] return d # -- contrat ---------------------------------------------------------------- def fetch(self) -> list[StListing]: month = time.strftime("%Y-%m") # re-visite mensuelle (tarifs) listings: list[StListing] = [] for gid, tarif_carte in sorted(self._liste().items(), key=lambda kv: int(kv[0])): try: d = self.detail(gid, month, lambda g=gid: self._fetch_fiche(g)) except Exception as exc: # noqa: BLE001 print(f"[gitesauquebec] fiche {gid} : {exc}", file=sys.stderr) continue if not d.get("nom"): continue price_label = d.get("tarif") or tarif_carte region = d.get("region", "") region = _REGIONS.get(region.lower(), normalize_region(region)) details = {k: v for k, v in { "no_annonce": d.get("no_annonce", f"PL-{gid}"), "lits": d.get("lits", ""), }.items() if v} listings.append(StListing( source=self.source_id, external_id=gid, url=f"{BASE}/{gid}", title=d["nom"], property_type=_TYPES.get(d.get("type", "").lower(), "Gîte"), city=d.get("ville", ""), region=region, price_label=price_label, capacity=float(d["capacity"]) if d.get("capacity") else None, bedrooms=float(d["bedrooms"]) if d.get("bedrooms") else None, bathrooms=(float(d["bathrooms"]) if d.get("bathrooms") else None), pets=d.get("pets"), citq=d.get("citq", ""), description=d.get("description", ""), amenities=d.get("amenities") or [], details=details, images=d.get("images") or [], lat=d.get("lat"), lng=d.get("lng"), )) return listings