# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/qldc.py : Québec Location de Chalets (quebeclocationdechalets.com) # — répertoire de chalets en ligne depuis 2004, contact direct avec les # propriétaires (pas de réservation en ligne), ~1 700 chalets. # # Méthode : pagination de la liste globale /chalets-a-louer?page=N (site # ASP.NET WebForms, 12 cartes/page, HTML statique — la pagination « infinie » # accepte le paramètre ?page). Cartes : id stable (/chalet-a-louer/), # titre, région + ville, capacité, chambres, photo, et souvent un prix # « à partir de » (encadré .ListPrix : « Nuit 395$ » ou « Semaine 1030$ »). # La page détail (via self.detail, cache BD) en variante ?map=o ajoute # lat/lng (champs cachés InfoLocalisation_hf_lat/long — absents de la page # de base), grille de tarifs, description, no CITQ, sdb/lits, commodités # et photos. # # Prix : ~40 % des fiches seulement ont la grille de tarifs ; les autres ont # soit un tarif en texte libre (ctl16_lblvchTarif_Terme, parfois avec # montants — attention aux dépôts), soit rien du tout (contact direct). # Ordre de préférence : grille détail > texte libre détail > encadré de la # carte liste. Beaucoup de fiches n'affichent réellement aucun prix. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import math import re import time from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import StListing from .base import StConnector BASE = "https://www.quebeclocationdechalets.com" LISTE = BASE + "/chalets-a-louer" _MONTANT = re.compile(r"(\d[\d\s ]*(?:[.,]\d{2})?)\s*\$") # libellés de la région affichée → forme canonique Lou-Ka (le reste passe # tel quel à normalize_region dans finalize()) _REGIONS = { "Saguenay-Lac-St-Jean": "Saguenay–Lac-Saint-Jean", "Estrie-Cantons-de-lest": "Cantons-de-l'Est", "Laval-Rive-nord": "Laval", } def _prix_nuit(periode: str, prix: str) -> float | None: """(« Week-end 2 nuits », « 995$ - 1195$ ») → 497.5 (le plus bas).""" montants = [] for m in _MONTANT.finditer(prix or ""): try: montants.append(float( re.sub(r"[\s ]", "", m.group(1)).replace(",", "."))) except ValueError: pass if not montants: return None val = min(montants) lab = (periode or "").lower() m = re.search(r"(\d+)\s*(?:nuit|jour)", lab) if m and int(m.group(1)) >= 1: return round(val / int(m.group(1)), 2) if "sem" in lab: return round(val / 7, 2) if "mois" in lab: return None return val class QuebecLocationDeChalets(StConnector): source_id = "qldc" def fetch(self) -> list[StListing]: listings: list[StListing] = [] vus: set[str] = set() page, max_page = 1, 1 while page <= max_page: html = self.get(LISTE, params={"page": page}).text soup = BeautifulSoup(html, "html.parser") if page == 1: # « 1722 chalets à louer » → nombre de pages m = re.search(r"(\d+)\s+chalets à louer", html) if m: max_page = math.ceil(int(m.group(1)) / 12) + 2 nouveaux = 0 for a in soup.select('a[href^="/chalet-a-louer/"]'): lst = self._carte(a) if lst is not None and lst.external_id not in vus: vus.add(lst.external_id) listings.append(lst) nouveaux += 1 if nouveaux == 0 and page > 1: break page += 1 for lst in listings: # « v2 » : tarif en texte libre ajouté au parseur détail cle = hashlib.sha1(("|".join([ lst.title, lst.city, lst.region, str(lst.capacity), str(lst.bedrooms), ]) + time.strftime("|%Y-%m|v2")).encode("utf-8")).hexdigest() try: d = self.detail(lst.external_id, cle, lambda u=lst.url: self._detail(u)) except Exception: d = {} if not d: continue # le prix de la page détail prime ; sinon on garde celui de la # carte de liste (« à partir de … ») if d.get("price_night") is not None: lst.price_night = d["price_night"] if d.get("price_label"): lst.price_label = d["price_label"] lst.description = d.get("description") or "" lst.citq = d.get("citq") or "" lst.amenities = d.get("amenities") or [] lst.lat = d.get("lat") lst.lng = d.get("lng") lst.pets = d.get("pets") if d.get("bathrooms") is not None: lst.bathrooms = d["bathrooms"] if d.get("beds") is not None: lst.beds = d["beds"] if d.get("capacity") is not None: lst.capacity = d["capacity"] if d.get("bedrooms") is not None: lst.bedrooms = d["bedrooms"] if d.get("images"): lst.images = d["images"] lst.details.update(d.get("details") or {}) return listings # -- carte de la liste ------------------------------------------------------ def _carte(self, lien) -> StListing | None: m = re.match(r"/chalet-a-louer/(\d+)$", lien.get("href", "")) if m is None: return None # on ancre sur le

(lien-titre) pour ne traiter chaque carte qu'une # fois (le même href apparaît aussi sur la photo) h3 = lien.find_parent("h3") if h3 is None: return None eid = m.group(1) carte = h3.parent # conteneur de la carte titre = lien.get_text(" ", strip=True) region = ville = "" bloc = h3.find_next_sibling("div") if bloc is not None: morceaux = [t.strip() for t in bloc.stripped_strings if t.strip()] if len(morceaux) >= 2: region, ville = morceaux[0], morceaux[1] elif morceaux: region = morceaux[0] capacite = chambres = None texte = carte.get_text(" ", strip=True) if carte else "" m2 = re.search(r"Capacité\s*(\d+)", texte) if m2: capacite = float(m2.group(1)) m2 = re.search(r"Chambres\s*(\d+)", texte) if m2: chambres = float(m2.group(1)) images = [] conteneur = carte.parent if carte is not None else None img = conteneur.find("img", src=re.compile("PhotoChalets")) \ if conteneur is not None else None if img is not None: images.append(urljoin(BASE, img["src"].split("?")[0])) # encadré de prix de la carte (« à partir de / Nuit 395$ » ou # « Semaine 1030$ ») — repli si la page détail n'affiche aucun tarif. # NE PAS remonter plus haut que la carte : on attraperait le prix # d'une carte voisine. prix_label, prix_nuit = "", None bloc = carte.select_one(".ListPrix") if carte is not None else None if bloc is not None: prix_label = re.sub(r"\s+", " ", bloc.get_text(" ", strip=True)) prix_nuit = _prix_nuit(prix_label, prix_label) return StListing( source=self.source_id, external_id=eid, url=f"{BASE}/chalet-a-louer/{eid}", title=titre, property_type="Chalet", city=ville, region=_REGIONS.get(region, region), price_night=prix_nuit, price_label=prix_label, capacity=capacite, bedrooms=chambres, images=images, ) # -- page détail (?map=o : contenu complet + géo) ---------------------------- def _detail(self, url: str) -> dict: html = self.get(url, params={"map": "o"}).text soup = BeautifulSoup(html, "html.parser") d: dict = {"details": {}} def _champ(id_, conv=str): el = soup.find(id=id_) if el is None: return None val = (el.get("value") or el.get_text(" ", strip=True)).strip() if not val: return None try: return conv(val.replace(",", ".")) except ValueError: return None lat = _champ("InfoLocalisation_hf_lat", float) lng = _champ("InfoLocalisation_hf_long", float) if lat is not None and lng is not None: d["lat"], d["lng"] = lat, lng ville = soup.find(id="InfoLocalisation_lblVille") if ville is not None and ville.get_text(strip=True): d["details"]["ville"] = ville.get_text(strip=True) lac = soup.find(id="InfoLocalisation_lblLacRiviere") if lac is not None and lac.get_text(strip=True): d["details"]["plan_eau"] = lac.get_text(strip=True) d["capacity"] = _champ("InfoCapaciteConfort_lblintCapacite", float) d["bedrooms"] = _champ("InfoCapaciteConfort_lblintChambre", float) d["bathrooms"] = _champ("InfoCapaciteConfort_lblSalleDeBain", float) lits = 0 for id_ in ("InfoCapaciteConfort_lblLitSimple", "InfoCapaciteConfort_lblLitdouble", "InfoCapaciteConfort_lblintLitQueen", "InfoCapaciteConfort_lblintLitKing"): n = _champ(id_, float) if n: lits += int(n) if lits: d["beds"] = float(lits) # grille de tarifs : lignes (période | prix | commentaires) — # prix/nuit = le plus bas de toutes les lignes grille = soup.find(id="ctl16_pnlGrilleTarrif") meilleurs = [] if grille is not None: for ligne in grille.select("div.flex.flex-wrap"): cols = [c.get_text(" ", strip=True) for c in ligne.find_all("div", recursive=False)] if len(cols) >= 2 and "$" in cols[1]: pn = _prix_nuit(cols[0] + " " + (cols[2] if len(cols) > 2 else ""), cols[1]) if pn: meilleurs.append(pn) if meilleurs: d["price_night"] = min(meilleurs) fourchette = soup.find(itemprop="priceRange") if fourchette is not None: d["price_label"] = re.sub(r"\s+", " ", fourchette.get_text(" ", strip=True)) if "price_night" not in d: d["price_night"] = _prix_nuit(d["price_label"], d["price_label"]) # tarif en texte libre (fiches sans grille) : on ne retient que les # phrases avec un montant ET une période (nuit/jour/semaine), en # ignorant dépôts et cautions if "price_night" not in d: terme = soup.find(id="ctl16_lblvchTarif_Terme") if terme is not None: candidats = [] # split en phrases sans casser les décimales (« 129.00$ ») for phrase in re.split(r"[\n;•]|\.(?!\d)", terme.get_text("\n", strip=True)): if "$" not in phrase \ or re.search(r"(?i)d[ée]p[ôo]t|caution|rabais", phrase) \ or not re.search(r"(?i)nuit|jour|sem", phrase): continue pn = _prix_nuit(phrase, phrase) if pn: candidats.append((pn, phrase.strip())) if candidats: pn, phrase = min(candidats) d["price_night"] = pn d.setdefault("price_label", re.sub(r"\s+", " ", phrase)[:120]) desc = soup.find(id="InfoDescription_pnlDescription") if desc is not None: texte = desc.get_text("\n", strip=True) texte = re.sub(r"^Descriptif de la location\n?", "", texte) d["description"] = texte[:5000] citq = soup.find(id="InfoDescription_lblvchNumCITQ") if citq is not None: m = re.search(r"(\d{4,8})", citq.get_text(" ", strip=True)) if m: d["citq"] = m.group(1) restr = soup.find(id="InfoDescription_lblvchRestriction") if restr is not None and restr.get_text(strip=True): texte = restr.get_text(" ", strip=True) d["details"]["restrictions"] = texte[:1000] if re.search(r"animaux\s+(permis|accept|admis)", texte, re.I): d["pets"] = "oui" elif re.search(r"animaux\s+(non|interdit|refus)|pas d.animaux", texte, re.I): d["pets"] = "non" # équipements : libellés dont l'icône n'est pas « -red » (= absent) amen: list[str] = [] exclus = ("Maximum de personnes", "Nombre Chambres", "Salles de bain", "Lits simples", "Lits doubles", "Lits Queen", "Lits King") for img in soup.select("img[src*='tailwind-img']"): p = img.find_parent("div") p = p.find("p") if p is not None else None if p is None: continue libelle = p.get_text(" ", strip=True) if (not libelle or libelle in exclus or libelle in amen or "-red" in (img.get("src") or "")): continue amen.append(libelle) if libelle == "Animaux" and "pets" not in d: d["pets"] = "oui" if amen: d["amenities"] = [a for a in amen if a not in ("Animaux", "Fumeur")] # animaux : icône rouge = interdit for img in soup.select("img[alt='Animaux'][src*='-red']"): d.setdefault("pets", "non") images: list[str] = [] for m in re.finditer(r"images/PhotoChalets/[\w./-]+\.(?:jpe?g|png|webp)", html, re.I): src = urljoin(BASE + "/", m.group(0)) if src not in images: images.append(src) if images: d["images"] = images[:20] return d