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/qldc.py : Québec Location de Chalets (quebeclocationdechalets.com)4# — répertoire de chalets en ligne depuis 2004, contact direct avec les5# propriétaires (pas de réservation en ligne), ~1 700 chalets.6#7# Méthode : pagination de la liste globale /chalets-a-louer?page=N (site8# ASP.NET WebForms, 12 cartes/page, HTML statique — la pagination « infinie »9# accepte le paramètre ?page). Cartes : id stable (/chalet-a-louer/<id>),10# titre, région + ville, capacité, chambres, photo, et souvent un prix11# « à partir de » (encadré .ListPrix : « Nuit 395$ » ou « Semaine 1030$ »).12# La page détail (via self.detail, cache BD) en variante ?map=o ajoute13# lat/lng (champs cachés InfoLocalisation_hf_lat/long — absents de la page14# de base), grille de tarifs, description, no CITQ, sdb/lits, commodités15# et photos.16#17# Prix : ~40 % des fiches seulement ont la grille de tarifs ; les autres ont18# soit un tarif en texte libre (ctl16_lblvchTarif_Terme, parfois avec19# montants — attention aux dépôts), soit rien du tout (contact direct).20# Ordre de préférence : grille détail > texte libre détail > encadré de la21# carte liste. Beaucoup de fiches n'affichent réellement aucun prix.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import hashlib26import math27import re28import time29from urllib.parse import urljoin3031from bs4 import BeautifulSoup3233from ..schema import StListing34from .base import StConnector3536BASE = "https://www.quebeclocationdechalets.com"37LISTE = BASE + "/chalets-a-louer"3839_MONTANT = re.compile(r"(\d[\d\s ]*(?:[.,]\d{2})?)\s*\$")4041# libellés de la région affichée → forme canonique Lou-Ka (le reste passe42# tel quel à normalize_region dans finalize())43_REGIONS = {44 "Saguenay-Lac-St-Jean": "Saguenay–Lac-Saint-Jean",45 "Estrie-Cantons-de-lest": "Cantons-de-l'Est",46 "Laval-Rive-nord": "Laval",47}484950def _prix_nuit(periode: str, prix: str) -> float | None:51 """(« Week-end 2 nuits », « 995$ - 1195$ ») → 497.5 (le plus bas)."""52 montants = []53 for m in _MONTANT.finditer(prix or ""):54 try:55 montants.append(float(56 re.sub(r"[\s ]", "", m.group(1)).replace(",", ".")))57 except ValueError:58 pass59 if not montants:60 return None61 val = min(montants)62 lab = (periode or "").lower()63 m = re.search(r"(\d+)\s*(?:nuit|jour)", lab)64 if m and int(m.group(1)) >= 1:65 return round(val / int(m.group(1)), 2)66 if "sem" in lab:67 return round(val / 7, 2)68 if "mois" in lab:69 return None70 return val717273class QuebecLocationDeChalets(StConnector):74 source_id = "qldc"7576 def fetch(self) -> list[StListing]:77 listings: list[StListing] = []78 vus: set[str] = set()79 page, max_page = 1, 180 while page <= max_page:81 html = self.get(LISTE, params={"page": page}).text82 soup = BeautifulSoup(html, "html.parser")83 if page == 1: # « 1722 chalets à louer » → nombre de pages84 m = re.search(r"(\d+)\s+chalets à louer", html)85 if m:86 max_page = math.ceil(int(m.group(1)) / 12) + 287 nouveaux = 088 for a in soup.select('a[href^="/chalet-a-louer/"]'):89 lst = self._carte(a)90 if lst is not None and lst.external_id not in vus:91 vus.add(lst.external_id)92 listings.append(lst)93 nouveaux += 194 if nouveaux == 0 and page > 1:95 break96 page += 19798 for lst in listings:99 # « v2 » : tarif en texte libre ajouté au parseur détail100 cle = hashlib.sha1(("|".join([101 lst.title, lst.city, lst.region,102 str(lst.capacity), str(lst.bedrooms),103 ]) + time.strftime("|%Y-%m|v2")).encode("utf-8")).hexdigest()104 try:105 d = self.detail(lst.external_id, cle,106 lambda u=lst.url: self._detail(u))107 except Exception:108 d = {}109 if not d:110 continue111 # le prix de la page détail prime ; sinon on garde celui de la112 # carte de liste (« à partir de … »)113 if d.get("price_night") is not None:114 lst.price_night = d["price_night"]115 if d.get("price_label"):116 lst.price_label = d["price_label"]117 lst.description = d.get("description") or ""118 lst.citq = d.get("citq") or ""119 lst.amenities = d.get("amenities") or []120 lst.lat = d.get("lat")121 lst.lng = d.get("lng")122 lst.pets = d.get("pets")123 if d.get("bathrooms") is not None:124 lst.bathrooms = d["bathrooms"]125 if d.get("beds") is not None:126 lst.beds = d["beds"]127 if d.get("capacity") is not None:128 lst.capacity = d["capacity"]129 if d.get("bedrooms") is not None:130 lst.bedrooms = d["bedrooms"]131 if d.get("images"):132 lst.images = d["images"]133 lst.details.update(d.get("details") or {})134 return listings135136 # -- carte de la liste ------------------------------------------------------137 def _carte(self, lien) -> StListing | None:138 m = re.match(r"/chalet-a-louer/(\d+)$", lien.get("href", ""))139 if m is None:140 return None141 # on ancre sur le <h3> (lien-titre) pour ne traiter chaque carte qu'une142 # fois (le même href apparaît aussi sur la photo)143 h3 = lien.find_parent("h3")144 if h3 is None:145 return None146 eid = m.group(1)147 carte = h3.parent # conteneur de la carte148 titre = lien.get_text(" ", strip=True)149150 region = ville = ""151 bloc = h3.find_next_sibling("div")152 if bloc is not None:153 morceaux = [t.strip() for t in bloc.stripped_strings if t.strip()]154 if len(morceaux) >= 2:155 region, ville = morceaux[0], morceaux[1]156 elif morceaux:157 region = morceaux[0]158159 capacite = chambres = None160 texte = carte.get_text(" ", strip=True) if carte else ""161 m2 = re.search(r"Capacité\s*(\d+)", texte)162 if m2:163 capacite = float(m2.group(1))164 m2 = re.search(r"Chambres\s*(\d+)", texte)165 if m2:166 chambres = float(m2.group(1))167168 images = []169 conteneur = carte.parent if carte is not None else None170 img = conteneur.find("img", src=re.compile("PhotoChalets")) \171 if conteneur is not None else None172 if img is not None:173 images.append(urljoin(BASE, img["src"].split("?")[0]))174175 # encadré de prix de la carte (« à partir de / Nuit 395$ » ou176 # « Semaine 1030$ ») — repli si la page détail n'affiche aucun tarif.177 # NE PAS remonter plus haut que la carte : on attraperait le prix178 # d'une carte voisine.179 prix_label, prix_nuit = "", None180 bloc = carte.select_one(".ListPrix") if carte is not None else None181 if bloc is not None:182 prix_label = re.sub(r"\s+", " ", bloc.get_text(" ", strip=True))183 prix_nuit = _prix_nuit(prix_label, prix_label)184185 return StListing(186 source=self.source_id,187 external_id=eid,188 url=f"{BASE}/chalet-a-louer/{eid}",189 title=titre,190 property_type="Chalet",191 city=ville,192 region=_REGIONS.get(region, region),193 price_night=prix_nuit,194 price_label=prix_label,195 capacity=capacite,196 bedrooms=chambres,197 images=images,198 )199200 # -- page détail (?map=o : contenu complet + géo) ----------------------------201 def _detail(self, url: str) -> dict:202 html = self.get(url, params={"map": "o"}).text203 soup = BeautifulSoup(html, "html.parser")204 d: dict = {"details": {}}205206 def _champ(id_, conv=str):207 el = soup.find(id=id_)208 if el is None:209 return None210 val = (el.get("value") or el.get_text(" ", strip=True)).strip()211 if not val:212 return None213 try:214 return conv(val.replace(",", "."))215 except ValueError:216 return None217218 lat = _champ("InfoLocalisation_hf_lat", float)219 lng = _champ("InfoLocalisation_hf_long", float)220 if lat is not None and lng is not None:221 d["lat"], d["lng"] = lat, lng222 ville = soup.find(id="InfoLocalisation_lblVille")223 if ville is not None and ville.get_text(strip=True):224 d["details"]["ville"] = ville.get_text(strip=True)225 lac = soup.find(id="InfoLocalisation_lblLacRiviere")226 if lac is not None and lac.get_text(strip=True):227 d["details"]["plan_eau"] = lac.get_text(strip=True)228229 d["capacity"] = _champ("InfoCapaciteConfort_lblintCapacite", float)230 d["bedrooms"] = _champ("InfoCapaciteConfort_lblintChambre", float)231 d["bathrooms"] = _champ("InfoCapaciteConfort_lblSalleDeBain", float)232 lits = 0233 for id_ in ("InfoCapaciteConfort_lblLitSimple",234 "InfoCapaciteConfort_lblLitdouble",235 "InfoCapaciteConfort_lblintLitQueen",236 "InfoCapaciteConfort_lblintLitKing"):237 n = _champ(id_, float)238 if n:239 lits += int(n)240 if lits:241 d["beds"] = float(lits)242243 # grille de tarifs : lignes (période | prix | commentaires) —244 # prix/nuit = le plus bas de toutes les lignes245 grille = soup.find(id="ctl16_pnlGrilleTarrif")246 meilleurs = []247 if grille is not None:248 for ligne in grille.select("div.flex.flex-wrap"):249 cols = [c.get_text(" ", strip=True)250 for c in ligne.find_all("div", recursive=False)]251 if len(cols) >= 2 and "$" in cols[1]:252 pn = _prix_nuit(cols[0] + " " + (cols[2] if len(cols) > 2253 else ""), cols[1])254 if pn:255 meilleurs.append(pn)256 if meilleurs:257 d["price_night"] = min(meilleurs)258 fourchette = soup.find(itemprop="priceRange")259 if fourchette is not None:260 d["price_label"] = re.sub(r"\s+", " ",261 fourchette.get_text(" ", strip=True))262 if "price_night" not in d:263 d["price_night"] = _prix_nuit(d["price_label"],264 d["price_label"])265266 # tarif en texte libre (fiches sans grille) : on ne retient que les267 # phrases avec un montant ET une période (nuit/jour/semaine), en268 # ignorant dépôts et cautions269 if "price_night" not in d:270 terme = soup.find(id="ctl16_lblvchTarif_Terme")271 if terme is not None:272 candidats = []273 # split en phrases sans casser les décimales (« 129.00$ »)274 for phrase in re.split(r"[\n;•]|\.(?!\d)",275 terme.get_text("\n", strip=True)):276 if "$" not in phrase \277 or re.search(r"(?i)d[ée]p[ôo]t|caution|rabais", phrase) \278 or not re.search(r"(?i)nuit|jour|sem", phrase):279 continue280 pn = _prix_nuit(phrase, phrase)281 if pn:282 candidats.append((pn, phrase.strip()))283 if candidats:284 pn, phrase = min(candidats)285 d["price_night"] = pn286 d.setdefault("price_label", re.sub(r"\s+", " ", phrase)[:120])287288 desc = soup.find(id="InfoDescription_pnlDescription")289 if desc is not None:290 texte = desc.get_text("\n", strip=True)291 texte = re.sub(r"^Descriptif de la location\n?", "", texte)292 d["description"] = texte[:5000]293 citq = soup.find(id="InfoDescription_lblvchNumCITQ")294 if citq is not None:295 m = re.search(r"(\d{4,8})", citq.get_text(" ", strip=True))296 if m:297 d["citq"] = m.group(1)298 restr = soup.find(id="InfoDescription_lblvchRestriction")299 if restr is not None and restr.get_text(strip=True):300 texte = restr.get_text(" ", strip=True)301 d["details"]["restrictions"] = texte[:1000]302 if re.search(r"animaux\s+(permis|accept|admis)", texte, re.I):303 d["pets"] = "oui"304 elif re.search(r"animaux\s+(non|interdit|refus)|pas d.animaux",305 texte, re.I):306 d["pets"] = "non"307308 # équipements : libellés dont l'icône n'est pas « -red » (= absent)309 amen: list[str] = []310 exclus = ("Maximum de personnes", "Nombre Chambres", "Salles de bain",311 "Lits simples", "Lits doubles", "Lits Queen", "Lits King")312 for img in soup.select("img[src*='tailwind-img']"):313 p = img.find_parent("div")314 p = p.find("p") if p is not None else None315 if p is None:316 continue317 libelle = p.get_text(" ", strip=True)318 if (not libelle or libelle in exclus or libelle in amen319 or "-red" in (img.get("src") or "")):320 continue321 amen.append(libelle)322 if libelle == "Animaux" and "pets" not in d:323 d["pets"] = "oui"324 if amen:325 d["amenities"] = [a for a in amen if a not in ("Animaux", "Fumeur")]326 # animaux : icône rouge = interdit327 for img in soup.select("img[alt='Animaux'][src*='-red']"):328 d.setdefault("pets", "non")329330 images: list[str] = []331 for m in re.finditer(r"images/PhotoChalets/[\w./-]+\.(?:jpe?g|png|webp)",332 html, re.I):333 src = urljoin(BASE + "/", m.group(0))334 if src not in images:335 images.append(src)336 if images:337 d["images"] = images[:20]338 return d339