Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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 : 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