SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
3 days agolast push
HTML 98.9% Python 0.6%

court terme : 4 connecteurs niche QC — Tremblant Living (sitemap + JSON-LD Streamline), Gîtes du Passant (annuaire Yapla Terroir et Saveurs), Kijiji locations de vacances c814 (Bright Data), Hébergement Québec (site mort, disabled)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent eedf296

4 changed files +734 −0

added louka/shortterm/connectors/gitespassant.py +224 −0
@@ -0,0 +1,224 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/gitespassant.py : Gîtes et Auberges du Passant certifiés
4 +# (réseau officiel de la Fédération des Agricotours du Québec — marques
5 +# déposées « Gîte du Passant », « Auberge du Passant », « Maison de
6 +# Campagne »). L'ancien giteetaubergedupassant.com ne répond plus : le
7 +# répertoire vit maintenant sur membres.terroiretsaveurs.com (plateforme
8 +# Yapla/MemboGo de Terroir et Saveurs du Québec).
9 +#
10 +# Méthode (annuaire Yapla, aucun anti-bot) :
11 +# 1. LISTE : POST /fr/repertoire/pagination/pageNumber/1 avec
12 +# categorie_detablissement[]=36680 (« Hébergement ») → le script
13 +# memboGo.directory.scroll.list["organization"] contient TOUS les ids de
14 +# membres (une vingtaine). Le même appel filtré par regionadministrative
15 +# (19 régions touristiques) donne la région de chaque membre.
16 +# 2. FICHE (cache self.detail, rafraîchie chaque mois) :
17 +# /fr/repertoire/detailorganization/id/<id> — HTML serveur : nom (h1),
18 +# description, « Types d'hébergement » (Gîte/Auberge du Passant, Maison
19 +# de Campagne…), numéro CITQ (#attestation), classification (soleils),
20 +# adresse postale (spans), téléphone, site web, photos du carrousel.
21 +# Pas de prix affiché (les tarifs sont chez chaque établissement).
22 +# -----------------------------------------------------------------------------
23 +from __future__ import annotations
24 +
25 +import html as _html
26 +import json
27 +import os
28 +import re
29 +import time
30 +
31 +from ..schema import StListing
32 +from .base import StConnector
33 +
34 +BASE = "https://membres.terroiretsaveurs.com"
35 +PAGINATION = BASE + "/fr/repertoire/pagination/pageNumber/1"
36 +FICHE = BASE + "/fr/repertoire/detailorganization/id/{id}"
37 +
38 +CAT_HEBERGEMENT = "36680" # option « Hébergement » du formulaire
39 +
40 +# valeur de l'option regionadministrative → région touristique canonique
41 +_REGIONS = {
42 + "316581": "Abitibi-Témiscamingue",
43 + "316582": "Bas-Saint-Laurent",
44 + "316584": "Cantons-de-l'Est",
45 + "316585": "Centre-du-Québec",
46 + "316586": "Charlevoix",
47 + "316583": "Chaudière-Appalaches",
48 + "316587": "Côte-Nord",
49 + "591978": "Eeyou Istchee Baie-James",
50 + "316589": "Gaspésie",
51 + "316590": "Îles-de-la-Madeleine",
52 + "316588": "Lanaudière",
53 + "316591": "Laurentides",
54 + "316592": "Laval",
55 + "316593": "Mauricie",
56 + "316594": "Montérégie",
57 + "316595": "Outaouais",
58 + "316596": "Montréal",
59 + "316597": "Québec",
60 + "316598": "Saguenay–Lac-Saint-Jean",
61 +}
62 +
63 +# « Types d'hébergement » de la fiche → type canonique Lou-Ka
64 +_TYPES = [
65 + ("gite du passant", "Gîte"), ("gite", "Gîte"),
66 + ("auberge du passant", "Auberge"), ("auberge", "Auberge"),
67 + ("maison de campagne", "Maison"), ("maison de ville", "Maison"),
68 + ("chalet", "Chalet"),
69 +]
70 +
71 +_TAG_RE = re.compile(r"<[^>]+>")
72 +
73 +
74 +def _text(fragment: str) -> str:
75 + return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()
76 +
77 +
78 +class GitesPassant(StConnector):
79 + source_id = "gites_passant"
80 +
81 + # -- liste (ids de membres) --------------------------------------------
82 + def _ids(self, region_value: str = "") -> list[str]:
83 + data = {"activetab": "organization", "search_type": "advanced",
84 + "categorie_detablissement[]": CAT_HEBERGEMENT}
85 + if region_value:
86 + data["regionadministrative"] = region_value
87 + resp = self.post(PAGINATION, data=data,
88 + headers={"X-Requested-With": "XMLHttpRequest"})
89 + m = re.search(r'scroll\.list\["organization"\]\s*=\s*(\[.*?\]);',
90 + resp.text, re.S)
91 + if not m:
92 + return []
93 + try:
94 + groups = json.loads(m.group(1))
95 + except ValueError:
96 + return []
97 + out: list[str] = []
98 + for g in groups:
99 + out.extend(str(i) for i in (g or {}).get("items") or [])
100 + return out
101 +
102 + # -- fiche ---------------------------------------------------------------
103 + def _fiche(self, member_id: str) -> dict:
104 + brut = self.get(FICHE.format(id=member_id)).text
105 + # les fiches truffent le HTML de <script> inline (carrousel, vidéo…)
106 + h = re.sub(r"(?s)<script[^>]*>.*?</script>", " ", brut)
107 + d: dict = {}
108 +
109 + h1s = re.findall(r"<h1[^>]*>(.*?)</h1>", h, re.S)
110 + titres = [_text(x) for x in h1s if _text(x) and _text(x) != "Répertoire"]
111 + if titres:
112 + d["title"] = titres[-1]
113 +
114 + m = re.search(r"(?s)<h2>\s*Description\s*</h2>(.*?)"
115 + r'<(?:h2|div class="carousel|/section)', h)
116 + if m:
117 + d["description"] = _text(m.group(1))[:5000]
118 +
119 + m = re.search(r'(?s)id="hebergement"[^>]*>(.*?)</div>', h)
120 + if m:
121 + d["types"] = _text(m.group(1)).replace("Types d'hébergement :",
122 + "").strip()
123 + m = re.search(r'(?s)id="attestation"[^>]*>.*?<p>\s*(\d{4,8})\s*</p>', h)
124 + if m:
125 + d["citq"] = m.group(1)
126 + m = re.search(r'(?s)id="classification"[^>]*>.*?<p[^>]*>(.*?)</p>', h)
127 + if m:
128 + classement = _text(m.group(1))
129 + if classement not in ("", "0"):
130 + d["classification"] = classement
131 +
132 + # adresse : <p><span>rue</span><br/><span>ville</span> <span>(Québec)…
133 + m = re.search(r"(?s)Coordonnées</h3>(.*?)</p>", h)
134 + if m:
135 + spans = [_text(s) for s in
136 + re.findall(r"<span[^>]*>(.*?)</span>", m.group(1))]
137 + spans = [s for s in spans if s and not s.startswith("Site Web")]
138 + if spans:
139 + d["street"] = spans[0]
140 + if len(spans) > 1:
141 + d["city"] = spans[1]
142 + for s in spans[2:]:
143 + if re.match(r"^[A-Z]\d[A-Z]\s?\d[A-Z]\d$", s):
144 + d["postal"] = s
145 + mm = re.search(r"Téléphone\s*:\s*([\d ().+-]{7,20})", m.group(1))
146 + if mm:
147 + d["phone"] = mm.group(1).strip()
148 + m = re.search(r'id="site"[^>]*>.*?href="([^"]+)"', h, re.S)
149 + if m:
150 + d["website"] = m.group(1)
151 +
152 + # photos : le carrousel de la fiche seulement (ailleurs sur la page,
153 + # les « Suggestions d'articles » ont aussi des images membres)
154 + logo = ""
155 + m = re.search(r'(?s)id="logo">\s*<img[^>]+src="([^"]+)"', brut)
156 + if m:
157 + logo = m.group(1)
158 + imgs: list[str] = []
159 + debut = brut.find('id="myCarousel"')
160 + if debut >= 0:
161 + fin = brut.find("carousel-control", debut)
162 + zone = brut[debut:fin if fin > debut else debut + 40000]
163 + for u in re.findall(r'(https://cdn\.ca\.yapla\.com/[^\s"\'{}]*'
164 + r'member/organization/[^\s"\'{}]+'
165 + r'\.(?:jpe?g|png|webp))', zone):
166 + if u != logo and u not in imgs:
167 + imgs.append(u)
168 + if imgs:
169 + d["images"] = imgs[:15]
170 + return d
171 +
172 + # -- contrat ------------------------------------------------------------
173 + def fetch(self) -> list[StListing]:
174 + limit = int(os.environ.get("LOUKA_GITES_LIMIT", "0") or 0)
175 +
176 + ids = self._ids()
177 + regions: dict[str, str] = {}
178 + for value, canon in _REGIONS.items():
179 + for mid in self._ids(value):
180 + regions.setdefault(mid, canon)
181 + if limit and len(regions) >= len(ids):
182 + break
183 +
184 + listings: list[StListing] = []
185 + for mid in ids:
186 + # fiche rafraîchie une fois par mois (pas de lastmod côté liste)
187 + det = self.detail(mid, time.strftime("%Y-%m"),
188 + lambda i=mid: self._fiche(i))
189 + if not det.get("title"):
190 + continue
191 +
192 + types = det.get("types") or ""
193 + tl = types.lower()
194 + ptype = next((canon for needle, canon in _TYPES if needle in tl),
195 + "Gîte")
196 +
197 + details = {k: v for k, v in {
198 + "types_hebergement": types,
199 + "classification_citq": det.get("classification"),
200 + "telephone": det.get("phone"),
201 + "site_web": det.get("website"),
202 + "reseau": "Gîtes et Auberges du Passant certifiés "
203 + "(Terroir et Saveurs du Québec)",
204 + }.items() if v}
205 +
206 + street = det.get("street") or ""
207 + city = det.get("city") or ""
208 + listings.append(StListing(
209 + source=self.source_id,
210 + external_id=mid,
211 + url=FICHE.format(id=mid),
212 + title=det["title"],
213 + property_type=ptype,
214 + address=", ".join(p for p in (street, city) if p),
215 + city=city,
216 + region=regions.get(mid, ""),
217 + citq=det.get("citq") or "",
218 + description=det.get("description") or "",
219 + details=details,
220 + images=det.get("images") or [],
221 + ))
222 + if limit and len(listings) >= limit:
223 + break
224 + return listings
added louka/shortterm/connectors/hebergementquebec.py +30 −0
@@ -0,0 +1,30 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/hebergementquebec.py : Hébergement Québec — SOURCE MORTE.
4 +#
5 +# Vérifié le 2026-08-23 :
6 +# - hebergementquebec.net (l'ancien répertoire d'hébergements de la CITQ)
7 +# redirige en 301 vers https://citq.qc.ca/fr/index.php — le site
8 +# d'information de la Corporation de l'industrie touristique du Québec,
9 +# qui n'affiche aucune annonce ni répertoire d'établissements scrapable.
10 +# - hebergementquebec.com est aujourd'hui un hébergeur WEB (« Hébergement
11 +# Web Québécois — à partir de 15 $/mois », Alice Média) : rien à voir
12 +# avec l'hébergement touristique.
13 +# - Aucun autre domaine équivalent trouvé (Serper, gl=ca hl=fr).
14 +#
15 +# Le module est conservé (avec disabled = True, donc exclu du registre) au
16 +# cas où le répertoire renaîtrait ; l'inventaire CITQ officiel reste couvert
17 +# par le connecteur bonjourquebec.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +from ..schema import StListing
22 +from .base import StConnector
23 +
24 +
25 +class HebergementQuebec(StConnector):
26 + source_id = "hebergementquebec"
27 + disabled = True # site disparu (voir en-tête)
28 +
29 + def fetch(self) -> list[StListing]:
30 + return []
added louka/shortterm/connectors/kijiji.py +317 −0
@@ -0,0 +1,317 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/kijiji.py : Kijiji (kijiji.ca) — petites annonces, catégorie
4 +# « Locations de vacances » à destination du Québec (c814 : les annonces y
5 +# sont classées par province de LA PROPRIÉTÉ, pas de l'annonceur — la
6 +# recherche « quebec/c800 » retournait des condos en Floride affichés
7 +# depuis Québec).
8 +#
9 +# Méthode (Kijiji est un Next.js derrière un anti-bot : HTML via Bright Data
10 +# Web Unlocker, Scrapfly ASP en secours — même recette que le connecteur
11 +# Airbnb) :
12 +# 1. LISTE : /b-vacation-rentals-quebec/canada/c814l0 (+ /page-N/) —
13 +# __NEXT_DATA__ → __APOLLO_STATE__ → searchResultsPageByUrl → results
14 +# (topListings + mainListings, ~40/page, totalCount ≈ 75). Chaque entité
15 +# StandardListing donne titre, prix (cents), photos, attributs canoniques.
16 +# 2. DÉTAIL (cache self.detail) : la page /v-…/<id> embarque le même
17 +# APOLLO_STATE avec en plus la description complète, les coordonnées
18 +# GPS, toutes les photos et les attributs en clair (« 2 bedrooms and
19 +# den », région touristique dans l'attribut « city », animaux…).
20 +#
21 +# Filtres court terme : on ne garde que les annonces OFFER qui ressemblent à
22 +# un hébergement (attributs chambres/personnes/type de vacances présents —
23 +# la catégorie contient aussi maillots de bain, machines à espresso…) et on
24 +# écarte les locations au mois (« 31 jours et plus », monthly…). Le prix
25 +# Kijiji est un montant sans période : price_night n'est rempli que si le
26 +# texte précise « /nuit » ou « /semaine », sinon le montant affiché va dans
27 +# details.prix_affiche.
28 +#
29 +# Réglage env : LOUKA_KIJIJI_LIMIT (nb max d'annonces, pour tester petit).
30 +# -----------------------------------------------------------------------------
31 +from __future__ import annotations
32 +
33 +import json
34 +import os
35 +import re
36 +import time
37 +
38 +import requests
39 +
40 +from ..schema import StListing, normalize_region, parse_price_night, REGIONS
41 +from .airbnb import _region_from_latlng
42 +from .base import StConnector
43 +
44 +BRIGHTDATA_API = "https://api.brightdata.com/request"
45 +BASE = "https://www.kijiji.ca"
46 +LISTE = BASE + "/b-vacation-rentals-quebec/canada/{page}c814l0"
47 +
48 +# attributs canoniques qui signent un vrai hébergement
49 +_ATTRS_HEBERGEMENT = {"numberbedrooms", "maxpeople", "vacationtype",
50 + "numberbathrooms", "minnights"}
51 +
52 +# location au mois (ou plus) : hors mandat court terme
53 +_MENSUEL_RE = re.compile(
54 + r"au mois|par mois|/\s*mois|mensuel|monthly|per\s+month|/\s*month"
55 + r"|3[01]\s*jours\s*(?:et plus|minimum|min)|month(?:ly)?\s+rental", re.I)
56 +
57 +_NUIT_RE = re.compile(r"(\d[\d\s,.]{0,9})\s*\$\s*(?:/|par|la|per)?\s*"
58 + r"(?:nuit|night)", re.I)
59 +_SEMAINE_RE = re.compile(r"(\d[\d\s,.]{0,9})\s*\$\s*(?:/|par|la|per)?\s*"
60 + r"(?:sem(?:aine)?|week)", re.I)
61 +
62 +_TYPE_HINTS = [
63 + ("chalet", "Chalet"), ("cottage", "Chalet"), ("cabin", "Chalet"),
64 + ("chaumière", "Chalet"), ("condo", "Condo"), ("appartement", "Appartement"),
65 + ("apartment", "Appartement"), ("loft", "Loft"), ("studio", "Studio"),
66 + ("maison", "Maison"), ("house", "Maison"), ("gîte", "Gîte"),
67 + ("gite", "Gîte"), ("auberge", "Auberge"), ("yourte", "Yourte"),
68 + ("yurt", "Yourte"), ("dôme", "Dôme"), ("dome", "Dôme"),
69 + ("chambre", "Chambre"), ("room", "Chambre"), ("camping", "Camping"),
70 + ("roulotte", "Prêt-à-camper"), ("trailer", "Prêt-à-camper"),
71 +]
72 +
73 +
74 +def _num(texts: list[str]) -> float | None:
75 + """Premier nombre d'une liste de valeurs Kijiji (« 2 bedrooms and den »)."""
76 + for t in texts or []:
77 + m = re.search(r"(\d+(?:[.,]5)?)", str(t))
78 + if m:
79 + return float(m.group(1).replace(",", "."))
80 + return None
81 +
82 +
83 +def _attrs(entity: dict) -> dict[str, list[str]]:
84 + """{canonicalName: values (humaines si présentes, sinon canoniques)}."""
85 + out: dict[str, list[str]] = {}
86 + for a in ((entity.get("attributes") or {}).get("all") or []):
87 + name = (a or {}).get("canonicalName") or ""
88 + vals = a.get("values") or a.get("canonicalValues") or []
89 + if name:
90 + out[name] = [str(v) for v in vals]
91 + return out
92 +
93 +
94 +class KijijiCt(StConnector):
95 + source_id = "kijiji_ct"
96 + request_delay = 0.5
97 +
98 + # -- fetch HTML (anti-bot) ----------------------------------------------
99 + def _brightdata(self, url: str) -> str:
100 + key = os.environ.get("BRIGHTDATA_API_KEY")
101 + if not key:
102 + return ""
103 + wait = self.request_delay - (time.time() - self._last_request)
104 + if wait > 0:
105 + time.sleep(wait)
106 + try:
107 + resp = requests.post(
108 + BRIGHTDATA_API,
109 + headers={"Authorization": f"Bearer {key}",
110 + "Content-Type": "application/json"},
111 + json={"zone": os.environ.get("BRIGHTDATA_ZONE", "web_unlocker1"),
112 + "url": url, "format": "raw"},
113 + timeout=150)
114 + except requests.RequestException:
115 + return ""
116 + finally:
117 + self._last_request = time.time()
118 + return resp.text if resp.status_code == 200 else ""
119 +
120 + def _html(self, url: str) -> str:
121 + html = self._brightdata(url)
122 + if "__NEXT_DATA__" in html:
123 + return html
124 + return self.get_scrapfly(url, render_js=False, asp=True)
125 +
126 + # -- parse APOLLO_STATE ---------------------------------------------------
127 + @staticmethod
128 + def _apollo(html: str) -> dict:
129 + m = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>',
130 + html, re.S)
131 + if not m:
132 + return {}
133 + try:
134 + data = json.loads(m.group(1))
135 + except ValueError:
136 + return {}
137 + return (data.get("props") or {}).get("pageProps", {}) \
138 + .get("__APOLLO_STATE__") or {}
139 +
140 + @staticmethod
141 + def _search_page(apollo: dict) -> tuple[list[dict], int]:
142 + """(entités StandardListing de la page, totalCount)."""
143 + root = apollo.get("ROOT_QUERY") or {}
144 + for key, srp in root.items():
145 + if not key.startswith("searchResultsPageByUrl"):
146 + continue
147 + res = (srp or {}).get("results") or {}
148 + total = ((srp or {}).get("pagination") or {}).get("totalCount") or 0
149 + refs: list[str] = []
150 + for rk, rv in res.items():
151 + if rk.startswith(("mainListings", "topListings")) \
152 + and isinstance(rv, list):
153 + refs.extend(x.get("__ref") for x in rv
154 + if isinstance(x, dict) and x.get("__ref"))
155 + return [apollo[r] for r in refs if r in apollo], int(total)
156 + return [], 0
157 +
158 + # -- détail ----------------------------------------------------------------
159 + def _detail(self, url: str, eid: str) -> dict:
160 + apollo = self._apollo(self._html(url))
161 + e = apollo.get(f"StandardListing:{eid}") or {}
162 + if not e:
163 + return {}
164 + attrs = _attrs(e)
165 + loc = e.get("location") or {}
166 + coords = loc.get("coordinates") or {}
167 + return {
168 + "description": (e.get("description") or "")[:5000],
169 + "images": [u for u in (e.get("imageUrls") or [])
170 + if isinstance(u, str) and u.startswith("https://")][:20],
171 + "address": loc.get("address") or "",
172 + "lat": coords.get("latitude"),
173 + "lng": coords.get("longitude"),
174 + "attrs": attrs,
175 + "region": (attrs.get("city") or [""])[0], # région touristique QC
176 + }
177 +
178 + # -- contrat ------------------------------------------------------------
179 + def fetch(self) -> list[StListing]:
180 + limit = int(os.environ.get("LOUKA_KIJIJI_LIMIT", "0") or 0)
181 +
182 + entities: list[dict] = []
183 + vus: set[str] = set()
184 + page, total = 1, None
185 + while page <= 10:
186 + seg = "" if page == 1 else f"page-{page}/"
187 + ents, tot = self._search_page(self._apollo(
188 + self._html(LISTE.format(page=seg))))
189 + if not ents:
190 + break
191 + total = tot or total
192 + nouveaux = 0
193 + for e in ents:
194 + eid = str(e.get("id") or "")
195 + if eid and eid not in vus:
196 + vus.add(eid)
197 + entities.append(e)
198 + nouveaux += 1
199 + if nouveaux == 0 or (total and len(vus) >= total):
200 + break
201 + if limit and len(entities) >= limit * 3: # marge pour les filtres
202 + break
203 + page += 1
204 +
205 + listings: list[StListing] = []
206 + for e in entities:
207 + eid = str(e.get("id") or "")
208 + url = e.get("url") or ""
209 + title = (e.get("title") or "").strip()
210 + if not eid or not url or not title:
211 + continue
212 + if (e.get("type") or "OFFER") != "OFFER":
213 + continue
214 + attrs = _attrs(e)
215 + if not (_ATTRS_HEBERGEMENT & set(attrs)):
216 + continue # maillots de bain, cafetières, vans…
217 + texte = f"{title}\n{e.get('description') or ''}"
218 + if _MENSUEL_RE.search(texte):
219 + continue # location au mois : hors mandat
220 +
221 + key = json.dumps([title, e.get("imageCount"),
222 + (e.get("price") or {}).get("amount")],
223 + ensure_ascii=False)
224 + try:
225 + det = self.detail(eid, key,
226 + lambda u=url, i=eid: self._detail(u, i))
227 + except Exception: # une fiche cassée ≠ annonce perdue
228 + det = {}
229 + if det.get("attrs"):
230 + attrs = det["attrs"]
231 + texte = (f"{title}\n"
232 + f"{det.get('description') or e.get('description') or ''}")
233 + if _MENSUEL_RE.search(texte):
234 + continue
235 +
236 + # prix : montant en cents, période seulement si le texte la donne
237 + price_night = None
238 + price_label = ""
239 + amount = (e.get("price") or {}).get("amount")
240 + montant = round(amount / 100, 2) if isinstance(
241 + amount, (int, float)) and amount else None
242 + m = _NUIT_RE.search(texte)
243 + if m:
244 + price_label = f"{m.group(1).strip()} $ / nuit"
245 + elif _SEMAINE_RE.search(texte):
246 + price_label = f"{_SEMAINE_RE.search(texte).group(1).strip()}" \
247 + " $ / semaine"
248 + elif montant:
249 + nuit = (attrs.get("minnights") or ["1"])[0]
250 + if str(nuit) in ("", "1"): # 1 nuit min : montant ≈ par nuit
251 + price_night = montant
252 + price_label = f"{montant:g} $"
253 + if price_label and price_night is None:
254 + price_night = parse_price_night(price_label)
255 +
256 + hay = title.lower()
257 + ptype = next((canon for needle, canon in _TYPE_HINTS
258 + if needle in hay), "")
259 +
260 + pets = None
261 + if attrs.get("petsallowed"):
262 + v = attrs["petsallowed"][0].lower()
263 + pets = "oui" if v in ("1", "yes", "oui") else "non"
264 +
265 + address = det.get("address") or (e.get("location") or {}).get(
266 + "address") or ""
267 + # « 60 Rue Quaile, Otter Lake, QC J0X 2P0 » → ville = Otter Lake
268 + m = re.search(r"([^,]+),\s*(?:QC|Qu[ée]bec)\b", address)
269 + city = m.group(1).strip() if m else ""
270 + coords = ((e.get("location") or {}).get("coordinates") or {})
271 + lat = det.get("lat") if det.get("lat") is not None \
272 + else coords.get("latitude")
273 + lng = det.get("lng") if det.get("lng") is not None \
274 + else coords.get("longitude")
275 +
276 + # région : attribut « city » de Kijiji (souvent la région
277 + # touristique), sinon le point GPS (centroïde le plus proche)
278 + region = normalize_region(det.get("region") or "")
279 + if region not in REGIONS:
280 + region = _region_from_latlng(lat, lng)
281 +
282 + details = {k: v for k, v in {
283 + "prix_affiche": montant if price_night is None else None,
284 + "min_nights": (attrs.get("minnights") or [None])[0],
285 + "vacation_type": (attrs.get("vacationtype") or [None])[0],
286 + "disponible_du": (attrs.get("availabilitystartdate")
287 + or [None])[0],
288 + "disponible_au": (attrs.get("availabilityenddate")
289 + or [None])[0],
290 + }.items() if v}
291 +
292 + listings.append(StListing(
293 + source=self.source_id,
294 + external_id=eid,
295 + url=url,
296 + title=title,
297 + property_type=ptype,
298 + address=address,
299 + city=city,
300 + region=region,
301 + price_night=price_night,
302 + price_label=price_label,
303 + capacity=_num(attrs.get("maxpeople")),
304 + bedrooms=_num(attrs.get("numberbedrooms")),
305 + bathrooms=_num(attrs.get("numberbathrooms")),
306 + pets=pets,
307 + description=det.get("description") or "",
308 + details=details,
309 + images=det.get("images")
310 + or [u for u in (e.get("imageUrls") or [])
311 + if isinstance(u, str)][:20],
312 + lat=lat,
313 + lng=lng,
314 + ))
315 + if limit and len(listings) >= limit:
316 + break
317 + return listings
added louka/shortterm/connectors/tremblantliving.py +163 −0
@@ -0,0 +1,163 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/tremblantliving.py : Tremblant Living (tremblantliving.com →
4 +# tremblantliving.ca) — agence de chalets et condos de luxe à Mont-Tremblant
5 +# (~35 propriétés, moteur Streamline VRS sur WordPress).
6 +#
7 +# Méthode : property-sitemap.xml (~37 fiches /property/ et /rental/, lastmod
8 +# = clé du cache détail). Chaque page détail embarque un JSON-LD schema.org
9 +# VacationRental complet : identifiant Streamline (unit_id), chambres,
10 +# salles de bain, capacité, note/avis, adresse, lat/lng, photos (galerie
11 +# streamlinevrs.com). La description longue vient du bloc
12 +# <div class="description block">, les commodités des <li class="amenity_item">.
13 +# Pas de prix statique : les tarifs passent par l'API Streamline
14 +# (admin-ajax.php), bloquée par Cloudflare en POST — price_night reste vide.
15 +# Les /monthly-rentals/ (units-sitemap.xml) sont du long terme : ignorés.
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import html as _html
20 +import json
21 +import os
22 +import re
23 +
24 +from ..schema import StListing
25 +from .base import StConnector
26 +
27 +SITE = "https://www.tremblantliving.ca"
28 +SITEMAP = SITE + "/property-sitemap.xml"
29 +
30 +# type déduit du nom de la fiche (agence ~100 % chalets et condos)
31 +_TYPE_HINTS = [
32 + ("penthouse", "Condo"), ("condo", "Condo"), ("appartement", "Appartement"),
33 + ("apartment", "Appartement"), ("loft", "Loft"), ("studio", "Studio"),
34 + ("estate", "Maison"), ("maison", "Maison"), ("house", "Maison"),
35 + ("villa", "Maison"), ("chalet", "Chalet"), ("cottage", "Chalet"),
36 + ("cabin", "Chalet"), ("lodge", "Chalet"),
37 +]
38 +
39 +_TAG_RE = re.compile(r"<[^>]+>")
40 +
41 +
42 +def _text(fragment: str) -> str:
43 + return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()
44 +
45 +
46 +def _f(v) -> float | None:
47 + try:
48 + return float(v) if v is not None else None
49 + except (TypeError, ValueError):
50 + return None
51 +
52 +
53 +class TremblantLiving(StConnector):
54 + source_id = "tremblant_living"
55 +
56 + # -- page détail --------------------------------------------------------
57 + def _detail(self, url: str) -> dict:
58 + h = self.get(url).text
59 + d: dict = {}
60 +
61 + for block in re.findall(r'<script type="application/ld\+json"[^>]*>'
62 + r"(.*?)</script>", h, re.S):
63 + try:
64 + ld = json.loads(block)
65 + except ValueError:
66 + continue
67 + if ld.get("@type") == "VacationRental":
68 + d["ld"] = ld
69 + break
70 +
71 + # description longue : <div class="description block"><article>…
72 + m = re.search(r'(?s)<div class="description block[^"]*"[^>]*>.*?'
73 + r"<article[^>]*>(.*?)</article>", h)
74 + if m:
75 + texte = re.sub(r"<br\s*/?>", "\n", m.group(1))
76 + texte = _html.unescape(_TAG_RE.sub(" ", texte))
77 + texte = re.sub(r"[ \t]+", " ", texte)
78 + texte = re.sub(r"\n\s+", "\n", texte).strip()
79 + d["description"] = texte[:5000]
80 +
81 + # commodités : <li class="amenity_item"> avec coche (les entêtes de
82 + # catégorie sont des <li> en gras sans icône fa-check)
83 + amen: list[str] = []
84 + for li in re.findall(r'(?s)<li class="amenity_item"[^>]*>(.*?)</li>', h):
85 + if "fa-check" not in li:
86 + continue
87 + t = _text(li)
88 + if t and t not in amen:
89 + amen.append(t)
90 + if amen:
91 + d["amenities"] = amen
92 + return d
93 +
94 + # -- contrat ------------------------------------------------------------
95 + def fetch(self) -> list[StListing]:
96 + limit = int(os.environ.get("LOUKA_TREMBLANT_LIMIT", "0") or 0)
97 + xml = self.get(SITEMAP).text
98 + entries = re.findall(r"(?s)<url>\s*<loc>([^<]+)</loc>"
99 + r"(?:\s*<lastmod>([^<]*)</lastmod>)?", xml)
100 +
101 + listings: list[StListing] = []
102 + vus: set[str] = set()
103 + for url, lastmod in entries:
104 + parts = [p for p in url.split("/") if p]
105 + # …/property/<slug>/ ou …/rental/<slug>/ (les /monthly-rentals/
106 + # sont dans units-sitemap.xml : long terme, hors mandat)
107 + if len(parts) < 4 or parts[-2] not in ("property", "rental"):
108 + continue
109 + slug = parts[-1]
110 + if slug in vus:
111 + continue
112 + vus.add(slug)
113 +
114 + det = self.detail(slug, lastmod or "v1",
115 + lambda u=url: self._detail(u))
116 + ld = det.get("ld") or {}
117 + if not ld:
118 + continue
119 + place = ld.get("containsPlace") or {}
120 + addr = ld.get("address") or {}
121 + agg = ld.get("aggregateRating") or {}
122 + occupancy = (place.get("occupancy") or {}).get("value")
123 +
124 + title = _text(str(ld.get("name") or slug))
125 + hay = f"{title} {slug}".lower()
126 + ptype = next((canon for needle, canon in _TYPE_HINTS
127 + if needle in hay), "Chalet")
128 +
129 + amen = det.get("amenities") or []
130 + pets = "oui" if any("pet friendly" in a.lower()
131 + for a in amen) else None
132 +
133 + imgs = ld.get("image") or []
134 + if isinstance(imgs, str):
135 + imgs = [imgs]
136 + reviews = agg.get("reviewCount")
137 + listings.append(StListing(
138 + source=self.source_id,
139 + external_id=str(ld.get("identifier") or slug),
140 + url=url,
141 + title=title,
142 + property_type=ptype,
143 + address=_text(str(addr.get("streetAddress") or "")),
144 + city=_text(str(addr.get("addressLocality") or "Mont-Tremblant")),
145 + region="Laurentides",
146 + capacity=_f(occupancy),
147 + bedrooms=_f(place.get("numberOfBedrooms")),
148 + bathrooms=_f(place.get("numberOfBathroomsTotal")),
149 + pets=pets,
150 + rating=_f(agg.get("ratingValue")),
151 + reviews=int(reviews) if reviews else None,
152 + description=det.get("description")
153 + or _text(str(ld.get("description") or "")),
154 + amenities=amen,
155 + details={"postal_code": addr.get("postalCode") or ""},
156 + images=[u for u in imgs if isinstance(u, str)
157 + and u.startswith("https://")][:20],
158 + lat=_f(ld.get("latitude")),
159 + lng=_f(ld.get("longitude")),
160 + ))
161 + if limit and len(listings) >= limit:
162 + break
163 + return listings
164