# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/chaletsauquebec.py : ChaletsAuQuebec.com — « le plus grand # répertoire de chalets à louer par le propriétaire au Québec » (~3 900). # # Méthode (site ASP.NET/Telerik, pagination des listes en postback → évitée) : # 1. la vue carte de chaque région (`/chalets-a-louer/quebec// # default.aspx?ar=c`) embarque TOUT l'inventaire régional dans la # variable JS `gAddressPoints = [[lat, lng, "id"], …]` → inventaire # complet + géolocalisation en 17 requêtes ; # 2. page détail `/` (cache self.detail) : microdonnées schema.org # (priceRange, geo), h1, Région/Ville, icônes (capacité, chambres, # salles de bain, animaux), commodités par groupe, no CITQ, photos. # ----------------------------------------------------------------------------- from __future__ import annotations import re import sys from ..schema import StListing from .base import StConnector BASE = "https://www.chaletsauquebec.com" INDEX = f"{BASE}/chalets-a-louer/quebec" _POINTS_RE = re.compile(r"gAddressPoints\s*=\s*(\[\[.*?\]\])\s*;", re.S) _REGION_HREF_RE = re.compile(r'href="(/chalets-a-louer/quebec/[a-z0-9-]+)"') # dernier mot avant « à louer » dans : « … - Chalet à louer Ville | … » _TITLE_TYPE_RE = re.compile(r"([\w’'-]+)\s+à louer\s", re.UNICODE) _REGION_RE = re.compile(r"Région\s*:\s*<a[^>]*>([^<]+)</a>") _VILLE_RE = re.compile(r"Ville\s*:\s*<a[^>]*>([^<]+)</a>") _PHOTO_RE = re.compile(r"/_photos/grand/[^\"'\s]+", re.I) # groupes de commodités = équipements de la propriété (le reste = contexte) _AMEN_GROUPS = ("cuisine", "communications", "général", "general", "équipements de loisir", "equipements de loisir", "équipements extérieur", "equipements exterieur") class ChaletsAuQuebec(StConnector): source_id = "chaletsauquebec" request_delay = 0.35 # vieux site costaud ; ~3 900 pages détail (cache) # -- inventaire (vues carte régionales) ----------------------------------- def _region_slugs(self) -> list[str]: html = self.get(INDEX).text slugs = [] for href in _REGION_HREF_RE.findall(html): slug = href.rsplit("/", 1)[-1] if slug not in slugs: slugs.append(slug) return slugs def _region_points(self, slug: str) -> list[tuple[str, float, float]]: """[(id, lat, lng), …] de la vue carte d'une région (inventaire complet).""" import json url = f"{BASE}/chalets-a-louer/quebec/{slug}/default.aspx?ar=c" m = _POINTS_RE.search(self.get(url).text) if not m: return [] out = [] for pt in json.loads(m.group(1)): try: lat, lng, ext = float(pt[0]), float(pt[1]), str(pt[2]) except (TypeError, ValueError, IndexError): continue out.append((ext, lat, lng)) return out # -- page détail ----------------------------------------------------------- def _fetch_detail(self, ext: str) -> dict: from bs4 import BeautifulSoup html = self.get(f"{BASE}/{ext}").text soup = BeautifulSoup(html, "html.parser") d: dict = {} h1 = soup.find("h1") d["title"] = h1.get_text(" ", strip=True) if h1 else "" tt = soup.find("title") types = _TITLE_TYPE_RE.findall(tt.get_text()) if tt else [] d["property_type"] = types[-1].strip() if types else "" pr = soup.find("meta", attrs={"itemprop": "priceRange"}) d["price_label"] = (pr.get("content") or "").strip() if pr else "" for prop, key in (("latitude", "lat"), ("longitude", "lng")): tag = soup.find("meta", attrs={"itemprop": prop}) if tag and tag.get("content"): try: d[key] = float(tag["content"]) except ValueError: pass # évaluations (microdonnées AggregateRating, note sur 5) agg = soup.find(attrs={"itemprop": "aggregateRating"}) if agg is not None: for prop, key, cast in (("ratingValue", "rating", float), ("reviewCount", "reviews", int)): tag = agg.find("meta", attrs={"itemprop": prop}) if tag and tag.get("content"): try: d[key] = cast(tag["content"]) except ValueError: pass m = _REGION_RE.search(html) d["region"] = m.group(1).strip() if m else "" m = _VILLE_RE.search(html) d["city"] = m.group(1).strip() if m else "" # icônes du résumé : capacité, chambres, sdb, animaux + drapeaux amenities: list[str] = [] for td in soup.select("td.styleTxtIcon"): txt = td.get_text(" ", strip=True) low = txt.lower() mn = re.search(r"(\d+)", txt) if "pers" in low and mn: d["capacity"] = float(mn.group(1)) elif "chambre" in low and mn: if int(mn.group(1)) > 0: # « 0 chambre » = non renseigné d["bedrooms"] = float(mn.group(1)) elif "salle" in low and "bain" in low and mn: d["bathrooms"] = float(mn.group(1)) elif low.startswith("animaux"): d["pets"] = ("non" if "non permis" in low else "conditions" if "restriction" in low else "oui" if "permis" in low else None) elif low.startswith("fumeur"): d.setdefault("details", {})["fumeurs"] = txt elif txt: amenities.append(txt) # Foyer, Internet, Spa, Bord de l'eau… # commodités par groupe + no CITQ + lits details = d.setdefault("details", {}) for td in soup.select("td.titreItemComm"): name = td.get_text(" ", strip=True) val_td = td.find_next_sibling("td") if not name or val_td is None: continue vals = [v.get_text(" ", strip=True) for v in val_td.select(".paddingValComm")] vals = [v for v in vals if v] low = name.lower() if "citq" in low: d["citq"] = vals[0] if vals else val_td.get_text(strip=True) elif low == "chambres": # « - 3 lits simples - 6 lits doubles … » blob = " ".join(vals) n = sum(int(x) for x in re.findall(r"(\d+)\s+lits?", blob)) if n: d["beds"] = float(n) details["lits"] = blob elif low.startswith(_AMEN_GROUPS): amenities.extend(vals) elif vals: # activités été/hiver à proximité… details[name] = vals seen: set[str] = set() d["amenities"] = [a for a in amenities if not (a in seen or seen.add(a))] # description (retirer les tables d'icônes/commodités, garder le texte) sec = soup.find(id="idSectionDescription") if sec is not None: for t in sec.find_all("table"): t.decompose() lines = [ln for ln in sec.get_text("\n", strip=True).split("\n") if ln and not ln.isupper()] # titres de section en CAPS d["description"] = "\n".join(lines).strip() imgs: list[str] = [] seen_img: set[str] = set() # même fichier en /Grand/ et /grand/ for u in _PHOTO_RE.findall(html): u = BASE + u if u.startswith("/") else u if u.lower() not in seen_img: seen_img.add(u.lower()) imgs.append(u) d["images"] = imgs return d # -- contrat ---------------------------------------------------------------- def fetch(self) -> list[StListing]: points: dict[str, tuple[float, float]] = {} for slug in self._region_slugs(): for ext, lat, lng in self._region_points(slug): points.setdefault(ext, (lat, lng)) listings, errors = [], 0 for ext, (lat, lng) in points.items(): try: d = self.detail(ext, "pdp1", lambda e=ext: self._fetch_detail(e)) except Exception as exc: # noqa: BLE001 — annonce retirée / réseau errors += 1 if errors <= 5: print(f"[chaletsauquebec] détail {ext} : {exc}", file=sys.stderr) continue if not d.get("title"): continue # page vide / annonce retirée listings.append(StListing( source=self.source_id, external_id=ext, url=f"{BASE}/{ext}", title=d.get("title", ""), property_type=d.get("property_type", ""), city=d.get("city", ""), region=d.get("region", ""), price_label=d.get("price_label", ""), capacity=d.get("capacity"), bedrooms=d.get("bedrooms"), beds=d.get("beds"), bathrooms=d.get("bathrooms"), pets=d.get("pets"), citq=str(d.get("citq", "") or ""), rating=d.get("rating"), reviews=d.get("reviews"), description=d.get("description", ""), amenities=d.get("amenities", []), details=d.get("details", {}), images=d.get("images", []), lat=d.get("lat", lat), lng=d.get("lng", lng), )) if errors: print(f"[chaletsauquebec] {errors} pages détail en erreur (ignorées)", file=sys.stderr) return listings