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
2 days agolast push
HTML 98.9% Python 0.6%

court terme : 2e vague — 5 connecteurs (Expedia, Hipcamp, Glamping Hub, Parcs Canada, Sinistar, Corporate Stays) + 3 sources fermées documentées

- expedia : SSR + rendu Scrapfly ASP (pile Vrbo), 13 destinations QC × 2 tris
- hipcamp : GraphQL public (LandsSearch bbox QC + détail camper), ~350 terrains
- glampinghub : endpoint AJAX ouvert, 208 fiches QC, prix CAD seulement
- parcscanada : API GoingToCamp (reservation.pc.gc.ca), oTENTik/Ôasis/MicrOcube
  des 12 terrains québécois, prix via feeDetails
- sinistar : index Algolia public prod_housings (state:QC + quadtree bbox),
  ~5 600 hébergements de relogement, pas de prix public (assumé)
- corporatestays : API REST WordPress properties, 47 meublés MTL/QC
- tripadvisor (vertical locations fermé nov. 2024), sonder (liquidée nov. 2025,
  devenue affilié Booking), blueground (aucun marché QC) : disabled documentés

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

9 changed files +1,396 −0

added louka/shortterm/connectors/blueground.py +28 −0
@@ -0,0 +1,28 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/blueground.py : Blueground (theblueground.com) — SOURCE NON
4 +# VIABLE (2026-08) : aucun inventaire au Québec.
5 +#
6 +# Constat au 2026-08-23 : le sitemap officiel (theblueground.com/sitemap.xml)
7 +# énumère les 37 marchés de la plateforme par code ville (ath, atl, …, tor,
8 +# tyo, vie, wdc, zrh) — le SEUL marché canadien est Toronto (« tor ») ;
9 +# ni Montréal ni aucune ville québécoise n'existe (l'URL
10 +# /furnished-apartments-montreal-canada renvoie un 404 applicatif).
11 +# Blueground n'opère donc pas au Québec ; rien à agréger.
12 +#
13 +# À réévaluer si la plateforme ouvre Montréal : la mécanique serait alors
14 +# simple (pages ville server-rendered + sitemaps par code ville, ex.
15 +# sitemap.tor.xml, aucune protection anti-bot rencontrée).
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +from ..schema import StListing
20 +from .base import StConnector
21 +
22 +
23 +class Blueground(StConnector):
24 + source_id = "blueground"
25 + disabled = True # aucun marché québécois (Toronto = seul marché canadien)
26 +
27 + def fetch(self) -> list[StListing]:
28 + return []
added louka/shortterm/connectors/corporatestays.py +201 −0
@@ -0,0 +1,201 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/corporatestays.py : Corporate Stays (corporatestays.com)
4 +#
5 +# Meublés corporatifs (siège à Montréal), ~450 unités dans les Amériques dont
6 +# ~47 au Québec (Montréal, Québec, Laval). Site WordPress (plugin HomeRunner) ;
7 +# l'API REST WP expose tout, aucun anti-bot :
8 +# 1. TAXONOMIE : GET /wp-json/wp/v2/property-states?per_page=100 →
9 +# ids des états québécois (slugs « pq » et « qc », les deux coexistent).
10 +# 2. LISTE : GET /wp-json/wp/v2/properties?property-states=<ids>&
11 +# per_page=100&page=N — chaque fiche est complète : titre, description
12 +# (content.rendered), acf.{bedrooms, bathrooms, maximum_guests,
13 +# minimum_stay, geolocation lat/lng/adresse, currency, base_daily_rate},
14 +# lowest_rent/average_rent ($/nuit CAD), photos (acf.photos[].sizes),
15 +# taxonomies embarquées cities/types, subtitle = nom de l'immeuble.
16 +# 3. COMMODITÉS : GET /wp-json/wp/v2/property_amenities?include=… pour
17 +# traduire les ids en libellés (une passe, mise en cache mémoire).
18 +# Séjours de 30 nuits minimum en général, mais le prix affiché est bien un
19 +# tarif À LA NUIT en CAD (base_daily_rate) → price_night renseigné.
20 +#
21 +# Réglage env : LOUKA_CORPORATESTAYS_LIMIT (nb max d'annonces, 0 = tout).
22 +# -----------------------------------------------------------------------------
23 +from __future__ import annotations
24 +
25 +import html as _html
26 +import os
27 +import re
28 +
29 +from ..schema import StListing
30 +from .airbnb import _region_from_latlng
31 +from .base import StConnector
32 +
33 +API = "https://corporatestays.com/wp-json/wp/v2"
34 +
35 +# slugs de la taxonomie property-states considérés québécois
36 +_QC_STATE_SLUGS = {"qc", "pq", "quebec"}
37 +
38 +# ville → (ville affichée, région touristique)
39 +_CITIES = {
40 + "montreal": ("Montréal", "Montréal"),
41 + "quebec city": ("Québec", "Québec"),
42 + "quebec": ("Québec", "Québec"),
43 + "laval": ("Laval", "Laval"),
44 + "gatineau": ("Gatineau", "Outaouais"),
45 +}
46 +
47 +_TYPES = {
48 + "apartment": "Appartement", "condo": "Condo", "studio": "Studio",
49 + "loft": "Loft", "house": "Maison", "townhouse": "Maison",
50 + "penthouse": "Appartement", "suite": "Appartement",
51 +}
52 +
53 +_TAG_RE = re.compile(r"<[^>]+>")
54 +
55 +
56 +def _num(v) -> float | None:
57 + try:
58 + return float(v) if v not in (None, "") else None
59 + except (TypeError, ValueError):
60 + return None
61 +
62 +
63 +def _strip_html(txt: str) -> str:
64 + return re.sub(r"\s+", " ", _TAG_RE.sub(" ", _html.unescape(txt or ""))).strip()
65 +
66 +
67 +class CorporateStays(StConnector):
68 + source_id = "corporatestays"
69 + request_delay = 0.6
70 +
71 + def _get_json(self, url: str):
72 + return self.get(url, headers={"Accept": "application/json"}).json()
73 +
74 + # -- taxonomies -----------------------------------------------------------
75 + def _qc_state_ids(self) -> list[int]:
76 + states = self._get_json(
77 + f"{API}/property-states?per_page=100&_fields=id,slug,count")
78 + return [s["id"] for s in states
79 + if (s.get("slug") or "").lower() in _QC_STATE_SLUGS
80 + and s.get("count")]
81 +
82 + def _amenity_names(self, ids: set[int]) -> dict[int, str]:
83 + names: dict[int, str] = {}
84 + todo = sorted(ids)
85 + for i in range(0, len(todo), 100):
86 + chunk = ",".join(str(x) for x in todo[i:i + 100])
87 + try:
88 + for a in self._get_json(f"{API}/property_amenities?per_page=100"
89 + f"&_fields=id,name&include={chunk}"):
90 + names[a["id"]] = _html.unescape(a.get("name") or "").strip()
91 + except Exception: # noqa: BLE001 — libellés manquants ≠ blocage
92 + break
93 + return names
94 +
95 + # -- liste ----------------------------------------------------------------
96 + def _all_items(self, state_ids: list[int]) -> list[dict]:
97 + items: list[dict] = []
98 + states = ",".join(str(i) for i in state_ids)
99 + page = 1
100 + while page <= 20:
101 + batch = self._get_json(f"{API}/properties?property-states={states}"
102 + f"&per_page=100&page={page}")
103 + if not isinstance(batch, list) or not batch:
104 + break
105 + items.extend(batch)
106 + if len(batch) < 100:
107 + break
108 + page += 1
109 + return items
110 +
111 + # -- contrat --------------------------------------------------------------
112 + def fetch(self) -> list[StListing]:
113 + limit = int(os.environ.get("LOUKA_CORPORATESTAYS_LIMIT", "0") or 0)
114 + state_ids = self._qc_state_ids()
115 + if not state_ids:
116 + return []
117 + items = self._all_items(state_ids)
118 + if limit:
119 + items = items[:limit]
120 +
121 + amen_ids = {a for it in items for a in (it.get("property_amenities")
122 + or []) if isinstance(a, int)}
123 + amen_names = self._amenity_names(amen_ids) if amen_ids else {}
124 +
125 + listings: list[StListing] = []
126 + seen: set[str] = set()
127 + for it in items:
128 + pid = str(it.get("id") or "").strip()
129 + url = (it.get("link") or "").strip()
130 + title = _strip_html((it.get("title") or {}).get("rendered") or "")
131 + if not pid or pid in seen or not url or not title:
132 + continue
133 + seen.add(pid)
134 +
135 + acf = it.get("acf") or {}
136 + geo = acf.get("geolocation") or {}
137 + cities = it.get("cities") or []
138 + raw_city = (cities[0].get("name") or "").strip() if cities else ""
139 + city, region = _CITIES.get(raw_city.lower(), (raw_city, ""))
140 + lat, lng = _num(geo.get("lat")), _num(geo.get("lng"))
141 + if not region:
142 + region = _region_from_latlng(lat, lng)
143 +
144 + types = it.get("types") or []
145 + raw_type = (types[0].get("name") or "").strip() if types else ""
146 + ptype = _TYPES.get(raw_type.lower(), raw_type)
147 +
148 + # prix à la nuit (CAD) : tarif de base, sinon plus bas tarif affiché
149 + price = None
150 + if (acf.get("currency") or "CAD").upper() == "CAD":
151 + price = _num(acf.get("base_daily_rate")) \
152 + or _num(it.get("lowest_rent"))
153 + price_label = (f"À partir de {price:g} $/nuit"
154 + if price is not None else "")
155 +
156 + images = []
157 + for ph in acf.get("photos") or []:
158 + sizes = (ph or {}).get("sizes") or {}
159 + u = (sizes.get("large") or sizes.get("homelocal-medium")
160 + or sizes.get("medium") or "")
161 + if u.startswith("https://") and u not in images:
162 + images.append(u)
163 + if len(images) >= 15:
164 + break
165 +
166 + amenities = [amen_names[a] for a in (it.get("property_amenities")
167 + or []) if amen_names.get(a)]
168 + rating = _num(it.get("average_rating"))
169 + reviews = it.get("total_reviews")
170 + details = {k: v for k, v in {
171 + "building": _strip_html(it.get("subtitle") or ""),
172 + "min_stay": _num(acf.get("minimum_stay")),
173 + "avg_rate_night": _num(it.get("average_rent")),
174 + "corporate": True,
175 + }.items() if v not in (None, "", 0)}
176 +
177 + listings.append(StListing(
178 + source=self.source_id,
179 + external_id=pid,
180 + url=url,
181 + title=title,
182 + property_type=ptype,
183 + address=(geo.get("address") or "").strip(),
184 + city=city,
185 + region=region,
186 + price_night=price,
187 + price_label=price_label,
188 + capacity=_num(acf.get("maximum_guests")),
189 + bedrooms=_num(acf.get("bedrooms")),
190 + bathrooms=_num(acf.get("bathrooms")),
191 + rating=rating if rating else None,
192 + reviews=int(reviews) if reviews else None,
193 + description=_strip_html((it.get("content") or {})
194 + .get("rendered") or "")[:3000],
195 + amenities=amenities,
196 + details=details,
197 + images=images,
198 + lat=lat,
199 + lng=lng,
200 + ).finalize())
201 + return listings
added louka/shortterm/connectors/expedia.py +253 −0
@@ -0,0 +1,253 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/expedia.py : Expedia (expedia.ca) — locations de vacances au Québec
4 +# (onglet « Homes » : maisons, chalets, condos… — PAS les hôtels).
5 +#
6 +# Méthode : même pile que Vrbo (groupe Expedia) — anti-bot Akamai + page
7 +# client-side. Le SSR (__PLUGIN_STATE__ → controllers.stores.searchResults)
8 +# ne porte que les 3 premières cartes (vérifié 2026-08-23), le reste arrive
9 +# par GraphQL après hydratation → Scrapfly ASP avec rendu JS + js_scenario de
10 +# scrolls, puis parsing DOM des cartes `[data-stid="lodging-card-responsive"]`.
11 +# Le filtre « Homes » de la recherche = paramètre d'URL
12 +# `categorySearch=vacation_rentals_option` (exclut les hôtels classiques).
13 +#
14 +# Limites assumées : ~19 cartes rendues par requête (liste virtualisée, comme
15 +# Vrbo) → on multiplie destinations × tris (RECOMMENDED + PRICE_LOW_TO_HIGH)
16 +# pour élargir la couverture ; pas de lat/lng ni d'adresse sur les cartes.
17 +# Recherche SANS dates : Expedia auto-assigne des dates (~2 semaines) et
18 +# affiche un prix par nuit avant taxes → price_label « à partir de … ».
19 +# (AVEC dates explicites la disponibilité ralentit le rendu : 3 cartes et
20 +# aucun prix dans le snapshot — vérifié 2026-08-23, ne pas en remettre.)
21 +# L'inventaire recoupe en partie Vrbo (même groupe) mais avec ses propres ids
22 +# et des exclusivités hôtelières-résidentielles (apparts-hôtels, glamping).
23 +#
24 +# Réglage env : LOUKA_EXPEDIA_LIMIT (nb max d'annonces, pour tester petit).
25 +# -----------------------------------------------------------------------------
26 +from __future__ import annotations
27 +
28 +import os
29 +import re
30 +import sys
31 +from urllib.parse import quote
32 +
33 +from bs4 import BeautifulSoup
34 +
35 +from ..schema import StListing
36 +from .base import StConnector
37 +
38 +# (destination Expedia, ville affichée par défaut, région touristique QC)
39 +DESTINATIONS = [
40 + ("Mont-Tremblant, Quebec, Canada", "Mont-Tremblant", "Laurentides"),
41 + ("Saint-Sauveur, Quebec, Canada", "Saint-Sauveur", "Laurentides"),
42 + ("Magog, Quebec, Canada", "Magog", "Cantons-de-l'Est"),
43 + ("Bromont, Quebec, Canada", "Bromont", "Cantons-de-l'Est"),
44 + ("Baie-Saint-Paul, Quebec, Canada", "Baie-Saint-Paul", "Charlevoix"),
45 + ("La Malbaie, Quebec, Canada", "La Malbaie", "Charlevoix"),
46 + ("Quebec City, Quebec, Canada", "Québec", "Québec"),
47 + ("Montreal, Quebec, Canada", "Montréal", "Montréal"),
48 + ("Perce, Quebec, Canada", "Percé", "Gaspésie"),
49 + ("Rimouski, Quebec, Canada", "Rimouski", "Bas-Saint-Laurent"),
50 + ("Saguenay, Quebec, Canada", "Saguenay", "Saguenay–Lac-Saint-Jean"),
51 + ("Shawinigan, Quebec, Canada", "Shawinigan", "Mauricie"),
52 + ("Gatineau, Quebec, Canada", "Gatineau", "Outaouais"),
53 +]
54 +
55 +# Deux tris par destination pour dépasser la vingtaine de cartes rendues
56 +SORTS = ["RECOMMENDED", "PRICE_LOW_TO_HIGH"]
57 +
58 +# Mot-clé du libellé type Expedia (en) → type canonique Lou-Ka
59 +TYPE_MAP = {
60 + "apartment": "Appartement", "apart-hotel": "Appartement",
61 + "aparthotel": "Appartement", "condo": "Condo", "chalet": "Chalet",
62 + "cabin": "Chalet", "cottage": "Chalet", "house": "Maison",
63 + "home": "Maison", "villa": "Maison", "townhouse": "Maison",
64 + "bungalow": "Maison", "studio": "Studio", "loft": "Loft",
65 + "room": "Chambre", "guesthouse": "Gîte", "bed & breakfast": "Gîte",
66 + "lodge": "Auberge", "hostel": "Auberge", "yurt": "Yourte",
67 + "tiny house": "Mini-maison", "houseboat": "Autre",
68 +}
69 +
70 +_ID_RE = re.compile(r"\.h(\d+)\.Hotel-Information")
71 +_TYPELINE_RE = re.compile(r"^(?:Entire|Private|Shared)\s+(.+?)(?:\s+by\s+Vrbo)?$",
72 + re.I)
73 +_SLEEPS_RE = re.compile(r"Sleeps\s+(\d+)")
74 +_BEDROOMS_RE = re.compile(r"(\d+)\s*bedrooms?")
75 +_BATHROOMS_RE = re.compile(r"([\d.]+)\s*bathrooms?")
76 +_RATING_RE = re.compile(r"([\d.]+)\s*out of 10")
77 +_REVIEWS_RE = re.compile(r"\((\d[\d,\s]*)\s*reviews?\)")
78 +_PRICE_RE = re.compile(r"The current price is CA\s*\$([\d,]+)") # \s : &nbsp;
79 +
80 +# Segments de carte qui ne sont PAS une localité (drapeaux, commodités…)
81 +_NOT_A_PLACE = re.compile(
82 + r"refundable|reserve now|sign in|member price|pool|hot tub|washer|dryer|"
83 + r"kitchen|parking|wifi|out of 10|review|current price|previous price|"
84 + r"total|includes|off\b|ad\b|sleeps|photo gallery|show (previous|next)|"
85 + r"more information|opens", re.I)
86 +# Hors Québec possible dans les résultats frontaliers (Gatineau → Ottawa…)
87 +_OUT_OF_QC = re.compile(r"ottawa|ontario|vermont|new hampshire|new york|maine",
88 + re.I)
89 +
90 +
91 +class Expedia(StConnector):
92 + source_id = "expedia"
93 + request_delay = 1.0
94 +
95 + # -- parsing d'une carte ----------------------------------------------------
96 + def _parse_card(self, card, city: str, region: str) -> StListing | None:
97 + link = card.select_one('a[data-stid="open-product-information"]') \
98 + or card.select_one('a[href*=".Hotel-Information"]')
99 + href = (link.get("href") if link else "") or ""
100 + m = _ID_RE.search(href)
101 + if not m:
102 + return None # carte commanditée / lien de connexion
103 + external_id = m.group(1)
104 + url = "https://www.expedia.ca" + href.split("?")[0].lstrip()
105 + if not url.startswith("https://www.expedia.ca/"):
106 + url = f"https://www.expedia.ca/h{external_id}.Hotel-Information"
107 +
108 + title = ""
109 + for h in card.find_all("h3"):
110 + if "is-visually-hidden" not in " ".join(h.get("class") or []):
111 + title = h.get_text(strip=True)
112 + break
113 + if not title:
114 + return None
115 +
116 + segs = list(card.stripped_strings)
117 + blob = " | ".join(segs)
118 + if _OUT_OF_QC.search(blob):
119 + return None
120 +
121 + # ligne type (« Entire home by Vrbo ») + ligne config (« Sleeps 4 · … »)
122 + property_type = ""
123 + capacity = bedrooms = bathrooms = None
124 + try:
125 + i_title = segs.index(title)
126 + except ValueError:
127 + i_title = 0
128 + place = ""
129 + for seg in segs[i_title + 1:]:
130 + tm = _TYPELINE_RE.match(seg)
131 + if tm and len(seg) < 60:
132 + kind = tm.group(1).strip().lower()
133 + property_type = next(
134 + (v for k, v in TYPE_MAP.items() if k in kind), "Autre")
135 + continue
136 + if _SLEEPS_RE.search(seg):
137 + sm = _SLEEPS_RE.search(seg)
138 + capacity = float(sm.group(1))
139 + bm = _BEDROOMS_RE.search(seg)
140 + if bm:
141 + bedrooms = float(bm.group(1))
142 + elif "studio" in seg.lower():
143 + bedrooms = 0.0
144 + am = _BATHROOMS_RE.search(seg)
145 + if am:
146 + bathrooms = float(am.group(1))
147 + continue
148 + # première ligne « libre » après titre/type/config = localité
149 + if (not place and seg != title and len(seg) < 60
150 + and not _NOT_A_PLACE.search(seg)
151 + and not re.match(r"^[\d($]", seg)):
152 + place = seg
153 + if place:
154 + city = place
155 +
156 + rating = reviews = None
157 + rm = _RATING_RE.search(blob)
158 + if rm:
159 + try:
160 + rating = round(float(rm.group(1)) / 2, 2) # /10 → /5
161 + except ValueError:
162 + pass
163 + vm = _REVIEWS_RE.search(blob)
164 + if vm:
165 + reviews = int(re.sub(r"[\s,]", "", vm.group(1)))
166 +
167 + # prix par nuit avant taxes (dates indicatives passées dans l'URL)
168 + price_night, price_label = None, ""
169 + pm = _PRICE_RE.search(blob)
170 + if pm:
171 + try:
172 + price_night = float(pm.group(1).replace(",", ""))
173 + except ValueError:
174 + price_night = None
175 + if price_night:
176 + price_label = (f"à partir de {price_night:.0f} $ / nuit "
177 + "(prochaines dates, avant taxes)")
178 +
179 + images = []
180 + for img in card.select("img[src]"):
181 + src = img.get("src") or ""
182 + if src.startswith("https://images.trvl-media.com/") \
183 + and src not in images:
184 + images.append(src)
185 + if len(images) >= 5:
186 + break
187 +
188 + return StListing(
189 + source=self.source_id,
190 + external_id=external_id,
191 + url=url,
192 + title=title,
193 + property_type=property_type,
194 + city=city,
195 + region=region,
196 + price_night=price_night,
197 + price_label=price_label,
198 + capacity=capacity,
199 + bedrooms=bedrooms,
200 + bathrooms=bathrooms,
201 + rating=rating,
202 + reviews=reviews,
203 + images=images,
204 + )
205 +
206 + # -- contrat -----------------------------------------------------------------
207 + def fetch(self) -> list[StListing]:
208 + limit = int(os.environ.get("LOUKA_EXPEDIA_LIMIT", "0") or 0)
209 +
210 + # scrolls progressifs : déclenche le fetch client + rend les cartes
211 + scenario = [{"wait": 1500}]
212 + for y in (4000, 9000, 15000):
213 + scenario += [{"scroll": {"y": y}}, {"wait": 1500}]
214 +
215 + listings: dict[str, StListing] = {}
216 + for dest, city, region in DESTINATIONS:
217 + for sort in SORTS:
218 + if limit and len(listings) >= limit:
219 + return list(listings.values())
220 + url = ("https://www.expedia.ca/Hotel-Search?destination="
221 + + quote(dest)
222 + + "&adults=2&categorySearch=vacation_rentals_option")
223 + if sort != "RECOMMENDED":
224 + url += f"&sort={sort}"
225 + cards = []
226 + for attempt in (1, 2): # le rendu revient parfois vide
227 + try:
228 + html = self.get_scrapfly(
229 + url, render_js=True, asp=True, rendering_wait=2000,
230 + wait_for_selector='[data-stid="lodging-card-responsive"]',
231 + js_scenario=scenario)
232 + except Exception as exc: # noqa: BLE001 — une requête ratée ≠ sync ratée
233 + print(f"[expedia] {city} ({sort}) : {exc}",
234 + file=sys.stderr)
235 + continue
236 + soup = BeautifulSoup(html or "", "html.parser")
237 + cards = soup.select('[data-stid="lodging-card-responsive"]')
238 + if cards:
239 + break
240 + print(f"[expedia] {city} ({sort}) : 0 carte rendue"
241 + f" (essai {attempt})", file=sys.stderr)
242 + n_before = len(listings)
243 + for card in cards:
244 + try:
245 + lst = self._parse_card(card, city, region)
246 + except Exception: # noqa: BLE001
247 + continue
248 + if lst and lst.external_id not in listings:
249 + listings[lst.external_id] = lst
250 + print(f"[expedia] {city} ({sort}) :"
251 + f" {len(listings) - n_before} nouvelles"
252 + f" (total {len(listings)})", file=sys.stderr)
253 + return list(listings.values())
added louka/shortterm/connectors/glampinghub.py +177 −0
@@ -0,0 +1,177 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/glampinghub.py : Glamping Hub (glampinghub.com) — hébergements
4 +# insolites (dômes, yourtes, pods, cabanes…), ~200 fiches au Québec.
5 +#
6 +# Méthode : l'endpoint AJAX de la page de recherche est ouvert (aucun anti-bot) :
7 +# GET https://glampinghub.com/search-accommodations/?lang=en&page=N
8 +# &q=Quebec, Canada&location={"state": "Quebec", "country": "Canada"}
9 +# &adults=2&…&numberOfResultsPerPage=24
10 +# → search_results[…] + total_results. Chaque fiche est TRÈS riche : nom,
11 +# catégorie, ville + coordonnées, prix (estimated_rate.daily_rate en devise
12 +# originale, CAD au Québec), chambres/lits, capacité par unité
13 +# (units_distribution), note sur 5, commodités (nested_features), photos.
14 +# Une seule requête paginée suffit — pas de page détail nécessaire.
15 +#
16 +# URL publique : https://glampinghub.com<absolute_url_en>. Région touristique
17 +# déduite des coordonnées (centroïdes partagés avec airbnb.py).
18 +# Réglage env : LOUKA_GLAMPINGHUB_LIMIT (nb max de fiches, 0 = tout).
19 +# -----------------------------------------------------------------------------
20 +from __future__ import annotations
21 +
22 +import os
23 +
24 +from ..schema import StListing
25 +from .base import StConnector
26 +from .airbnb import _region_from_latlng
27 +
28 +SITE = "https://glampinghub.com"
29 +API = f"{SITE}/search-accommodations/"
30 +PAGE_SIZE = 24
31 +
32 +# Catégorie Glamping Hub → type canonique Lou-Ka
33 +_CATEGORIES = {
34 + "cabins": "Chalet", "cottages": "Chalet", "log cabins": "Chalet",
35 + "vacation rentals": "Maison", "villas": "Maison",
36 + "designer rentals": "Maison", "unique stays": "Autre",
37 + "tiny houses": "Mini-maison", "hobbit houses": "Mini-maison",
38 + "domes": "Dôme", "yurts": "Yourte", "tipis": "Prêt-à-camper",
39 + "pods": "Prêt-à-camper", "bell tents": "Prêt-à-camper",
40 + "safari tents": "Prêt-à-camper", "tented cabins": "Prêt-à-camper",
41 + "canvas tents": "Prêt-à-camper", "airstreams": "Prêt-à-camper",
42 + "caravans": "Prêt-à-camper", "campervans": "Prêt-à-camper",
43 + "tree houses": "Autre", "barns": "Autre", "boats": "Autre",
44 + "islands": "Autre", "castles": "Autre", "condos": "Condo",
45 + "apartments": "Appartement", "lodges": "Auberge",
46 + "bed and breakfasts": "Gîte",
47 +}
48 +
49 +
50 +def _num(v) -> float | None:
51 + try:
52 + return float(v) if v not in (None, "") else None
53 + except (TypeError, ValueError):
54 + return None
55 +
56 +
57 +class GlampingHub(StConnector):
58 + source_id = "glampinghub"
59 + request_delay = 0.8
60 +
61 + # -- liste --------------------------------------------------------------
62 + def _search_page(self, page: int) -> dict:
63 + resp = self.get(API, params={
64 + "lang": "en",
65 + "page": page,
66 + "q": "Quebec, Canada",
67 + "location": '{"state": "Quebec", "country": "Canada"}',
68 + "adults": 2, "children": 0, "infants": 0,
69 + "sort": "-ranking_engine_boost",
70 + "source": "rentalsearch",
71 + "numberOfResultsPerPage": PAGE_SIZE,
72 + }, headers={"Accept": "application/json",
73 + "X-Requested-With": "XMLHttpRequest",
74 + "Referer": f"{SITE}/rentalsearch/"})
75 + return resp.json()
76 +
77 + def _all_items(self) -> list[dict]:
78 + items: list[dict] = []
79 + page, total = 0, 1
80 + while len(items) < min(total, 2000) and page < 100:
81 + data = self._search_page(page)
82 + batch = data.get("search_results") or []
83 + total = data.get("total_results") or 0
84 + if not batch:
85 + break
86 + items.extend(batch)
87 + page += 1
88 + return items
89 +
90 + # -- contrat --------------------------------------------------------------
91 + def fetch(self) -> list[StListing]:
92 + limit = int(os.environ.get("LOUKA_GLAMPINGHUB_LIMIT", "0") or 0)
93 + listings: list[StListing] = []
94 + seen: set[str] = set()
95 + for it in self._all_items():
96 + gid = str(it.get("id") or "").strip()
97 + title = (it.get("name_en") or "").strip()
98 + loc = it.get("location") or {}
99 + if not gid or gid in seen or not title:
100 + continue
101 + if (loc.get("state_en") or "").strip().lower() != "quebec":
102 + continue
103 + seen.add(gid)
104 +
105 + lat = lng = None
106 + coords = (loc.get("coords") or "").split(",")
107 + if len(coords) == 2:
108 + lat, lng = _num(coords[0]), _num(coords[1])
109 +
110 + path = it.get("absolute_url_en") or ""
111 + if not path:
112 + for u in it.get("absolute_url") or []:
113 + if (u or {}).get("lang") == "en":
114 + path = u.get("url") or ""
115 + break
116 +
117 + images = []
118 + for ph in (it.get("images") or [])[:15]:
119 + u = (ph or {}).get("url") or ""
120 + if u.startswith("//"):
121 + u = "https:" + u
122 + if u.startswith("https://") and u not in images:
123 + images.append(u)
124 +
125 + # prix : tarif nuit de base en devise originale (CAD au Québec)
126 + price_night, price_label = None, ""
127 + rate = it.get("estimated_rate") or {}
128 + daily = _num(rate.get("daily_rate"))
129 + if daily and (it.get("original_currency") or "") == "CAD":
130 + price_night = daily
131 + price_label = f"à partir de {daily:.0f} $ CAD / nuit"
132 +
133 + units = it.get("units_distribution") or []
134 + capacity = max((_num(u.get("capacity")) or 0 for u in units),
135 + default=0) or None
136 +
137 + amenities = []
138 + for f in it.get("nested_features") or []:
139 + name = (f or {}).get("name_en") or ""
140 + if f.get("active") and name and name not in amenities:
141 + amenities.append(name)
142 +
143 + category = (it.get("category_en") or "").strip()
144 + details = {k: v for k, v in {
145 + "category": category,
146 + "units": len(units) or None,
147 + "min_stay": rate.get("min_stay"),
148 + "verified": bool(it.get("verified_accommodation")) or None,
149 + "remarkable": [f.get("name_en") for f in
150 + (it.get("remarkable_features") or [])
151 + if (f or {}).get("name_en")] or None,
152 + }.items() if v}
153 +
154 + rating = _num(it.get("average_rate"))
155 + listings.append(StListing(
156 + source=self.source_id,
157 + external_id=gid,
158 + url=SITE + path if path else SITE,
159 + title=title,
160 + property_type=_CATEGORIES.get(category.lower(), "Autre"),
161 + city=loc.get("city_en") or "",
162 + region=_region_from_latlng(lat, lng),
163 + price_night=price_night,
164 + price_label=price_label,
165 + capacity=capacity,
166 + bedrooms=_num(it.get("bedrooms_number")),
167 + beds=_num(it.get("beds_number")),
168 + rating=rating if rating else None,
169 + amenities=amenities,
170 + details=details,
171 + images=images,
172 + lat=lat,
173 + lng=lng,
174 + ))
175 + if limit and len(listings) >= limit:
176 + break
177 + return listings
added louka/shortterm/connectors/hipcamp.py +256 −0
@@ -0,0 +1,256 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/hipcamp.py : Hipcamp (hipcamp.com) — camping et prêt-à-camper
4 +# chez des hôtes privés (terres agricoles, boisés…), ~350 terrains au Québec.
5 +#
6 +# Méthode : le site (Next.js) parle à un GraphQL public, accessible sans
7 +# session avec les en-têtes HIPCAMP-API-KEY (clé publique embarquée dans le
8 +# bundle JS) et HIPCAMP-PLATFORM: Web.
9 +# 1. LISTE : POST https://www.hipcamp.com/graphql/search — requête
10 +# LandsSearch(landFilter: {boundingBox: <bbox Québec>}) paginée par
11 +# offset/limit (50 par page). Chaque « land » (terrain, souvent
12 +# multi-emplacements) : nom, ville, coordonnées, prix/nuit (« CA$54.00 »),
13 +# types d'hébergement (tent/rv/house), photos, % de recommandations.
14 +# Le bbox mord sur l'Ontario et le Maine : on garde stateAbbrvName == QC.
15 +# 2. DÉTAIL (cache self.detail) : POST /graphql/camper — requête Land par
16 +# maskedId : description (overview, souvent bilingue avec no CITQ),
17 +# capacité max, nb d'emplacements par type, commodités et activités.
18 +# 3. Photos : CDN Cloudinary (https://hipcamp-res.cloudinary.com/…).
19 +#
20 +# Pas de note sur 5 chez Hipcamp : % de recommandations → details. Région
21 +# touristique déduite des coordonnées (centroïdes partagés avec airbnb.py).
22 +# Réglage env : LOUKA_HIPCAMP_LIMIT (nb max de terrains, 0 = tout).
23 +# -----------------------------------------------------------------------------
24 +from __future__ import annotations
25 +
26 +import os
27 +import re
28 +import sys
29 +
30 +from ..schema import StListing
31 +from .base import StConnector
32 +from .airbnb import _in_quebec, _region_from_latlng
33 +
34 +SITE = "https://www.hipcamp.com"
35 +GRAPHQL_SEARCH = f"{SITE}/graphql/search"
36 +GRAPHQL_CAMPER = f"{SITE}/graphql/camper"
37 +CDN = "https://hipcamp-res.cloudinary.com"
38 +
39 +# Clé API publique (embarquée dans le bundle JS du site, module 79018)
40 +HEADERS = {
41 + "Content-Type": "application/json",
42 + "Accept": "application/json",
43 + "HIPCAMP-API-KEY": "Dp7qfhE8y8cTx73qSYu8b6M2",
44 + "HIPCAMP-PLATFORM": "Web",
45 +}
46 +
47 +# Zone habitée du Québec (sud, ouest, nord, est) — même bbox qu'airbnb.py
48 +QC_BBOX = (44.95, -79.80, 52.20, -56.90)
49 +PAGE_SIZE = 50
50 +
51 +LIST_QUERY = """query LandsSearch($landFilter: LandFilterInput!, $privateOffset: Int, $privateLimit: Int) {
52 + lands(landFilter: $landFilter) {
53 + privateLands(offset: $privateOffset, limit: $privateLimit) {
54 + total
55 + edges {
56 + availableAccommodationKeys
57 + availableCampsitesCount
58 + node {
59 + allAccommodationKeys
60 + cityName
61 + coordinate { latitude longitude }
62 + countryCode
63 + id
64 + maskedId
65 + name
66 + stateAbbrvName
67 + locationSummary
68 + topPhotos { filename }
69 + url
70 + }
71 + pricePerNight { symbol format minorAmount }
72 + }
73 + }
74 + }
75 +}"""
76 +
77 +DETAIL_QUERY = """query Land($landId: ID!, $landIdType: LandIdTypeEnum!) {
78 + land(landId: $landId, landIdType: $landIdType) {
79 + maskedId fullName cityName countyName overview subheader
80 + maxSiteCapacity campsiteCount structureCount rvCount tentCount
81 + recommendsPercentage recommendsCount bookingsCount
82 + coordinate { lat lng }
83 + minPricePerNight { amount format isoCode }
84 + coreAmenities: landCampFeatures(type: CORE_AMENITY) { name }
85 + basicAmenities: landCampFeatures(type: BASIC_AMENITY) { name }
86 + activities: landCampFeatures(type: ACTIVITY) { name }
87 + }
88 +}"""
89 +
90 +_PRICE_FMT_RE = re.compile(r"CA\$([\d,]+(?:\.\d{2})?)")
91 +
92 +
93 +def _photo_url(filename: str) -> str:
94 + """URL CDN d'une photo — deux formats de filename coexistent."""
95 + if not filename:
96 + return ""
97 + if filename.startswith("images/"): # chemin complet : pas de transform
98 + return f"{CDN}/{filename}"
99 + return f"{CDN}/f_auto,c_limit,w_1120,q_auto/{filename}"
100 +
101 +
102 +def _property_type(keys: list[str]) -> str:
103 + """tent/rv = emplacements nus, house = unités bâties (cabane, dôme…)."""
104 + ks = {str(k).lower() for k in (keys or [])}
105 + if "house" in ks:
106 + return "Chalet" if not (ks & {"tent", "rv"}) else "Prêt-à-camper"
107 + return "Camping"
108 +
109 +
110 +def _price_cad(price: dict) -> tuple[float | None, str]:
111 + fmt = (price or {}).get("format") or ""
112 + m = _PRICE_FMT_RE.search(fmt)
113 + if not m:
114 + return None, fmt
115 + return float(m.group(1).replace(",", "")), f"{fmt} / nuit"
116 +
117 +
118 +class Hipcamp(StConnector):
119 + source_id = "hipcamp"
120 + request_delay = 0.5
121 +
122 + def _graphql(self, url: str, query: str, variables: dict) -> dict:
123 + resp = self.post(url, json={"query": query, "variables": variables},
124 + headers=HEADERS)
125 + data = resp.json()
126 + if data.get("errors"):
127 + raise RuntimeError(str(data["errors"])[:200])
128 + return data.get("data") or {}
129 +
130 + # -- liste ------------------------------------------------------------------
131 + def _all_edges(self) -> list[dict]:
132 + s, w, n, e = QC_BBOX
133 + land_filter = {"boundingBox": {
134 + "northeastLatitude": n, "northeastLongitude": e,
135 + "southwestLatitude": s, "southwestLongitude": w}}
136 + edges: list[dict] = []
137 + offset, total = 0, 1
138 + while offset < min(total, 3000):
139 + data = self._graphql(GRAPHQL_SEARCH, LIST_QUERY, {
140 + "landFilter": land_filter,
141 + "privateOffset": offset, "privateLimit": PAGE_SIZE})
142 + page = ((data.get("lands") or {}).get("privateLands") or {})
143 + batch = page.get("edges") or []
144 + total = page.get("total") or 0
145 + if not batch:
146 + break
147 + edges.extend(batch)
148 + offset += PAGE_SIZE
149 + return edges
150 +
151 + # -- détail (cache BD) --------------------------------------------------------
152 + def _fetch_detail(self, masked_id: str) -> dict:
153 + data = self._graphql(GRAPHQL_CAMPER, DETAIL_QUERY,
154 + {"landId": masked_id, "landIdType": "MASKED"})
155 + land = data.get("land") or {}
156 + if not land:
157 + return {}
158 + amenities = []
159 + for grp in ("coreAmenities", "basicAmenities"):
160 + for it in land.get(grp) or []:
161 + name = (it or {}).get("name") or ""
162 + if name and name not in amenities:
163 + amenities.append(name)
164 + activities = [a.get("name") for a in (land.get("activities") or [])
165 + if (a or {}).get("name")]
166 + overview = re.sub(r"\s+", " ", land.get("overview") or "").strip()
167 + price = land.get("minPricePerNight") or {}
168 + return {
169 + "description": overview[:4000],
170 + "subheader": land.get("subheader") or "",
171 + "county": land.get("countyName") or "",
172 + "capacity": land.get("maxSiteCapacity"),
173 + "campsites": land.get("campsiteCount"),
174 + "structures": land.get("structureCount"),
175 + "amenities": amenities,
176 + "activities": activities,
177 + "recommends_pct": land.get("recommendsPercentage"),
178 + "recommends_count": land.get("recommendsCount"),
179 + "min_price": (price.get("amount")
180 + if price.get("isoCode") == "CAD" else None),
181 + }
182 +
183 + # -- contrat --------------------------------------------------------------
184 + def fetch(self) -> list[StListing]:
185 + limit = int(os.environ.get("LOUKA_HIPCAMP_LIMIT", "0") or 0)
186 + listings: list[StListing] = []
187 + seen: set[str] = set()
188 + for edge in self._all_edges():
189 + node = edge.get("node") or {}
190 + mid = str(node.get("maskedId") or "").strip()
191 + title = (node.get("name") or "").strip()
192 + if not mid or mid in seen or not title:
193 + continue
194 + if (node.get("stateAbbrvName") or "").upper() != "QC":
195 + continue
196 + seen.add(mid)
197 +
198 + coord = node.get("coordinate") or {}
199 + lat, lng = coord.get("latitude"), coord.get("longitude")
200 + if lat is not None and lng is not None and not _in_quebec(lat, lng):
201 + continue
202 +
203 + images = []
204 + for ph in (node.get("topPhotos") or [])[:15]:
205 + u = _photo_url((ph or {}).get("filename") or "")
206 + if u and u not in images:
207 + images.append(u)
208 +
209 + keys = node.get("allAccommodationKeys") or []
210 + price_night, price_label = _price_cad(edge.get("pricePerNight"))
211 +
212 + # clé de cache détail : sous-ensemble stable de la carte liste
213 + key = f"{title}|{','.join(sorted(keys))}|{len(images)}"
214 + try:
215 + det = self.detail(mid, key,
216 + lambda m=mid: self._fetch_detail(m))
217 + except Exception as exc: # noqa: BLE001 — détail cassé ≠ perdu
218 + print(f"[hipcamp] détail {mid} : {exc}", file=sys.stderr)
219 + det = {}
220 +
221 + if price_night is None and det.get("min_price"):
222 + price_night = float(det["min_price"])
223 + price_label = f"à partir de {price_night:.0f} $ / nuit"
224 +
225 + details = {k: v for k, v in {
226 + "accommodation_keys": ", ".join(keys) or None,
227 + "campsites": det.get("campsites"),
228 + "structures": det.get("structures"),
229 + "county": det.get("county"),
230 + "recommends_pct": det.get("recommends_pct"),
231 + "recommends_count": det.get("recommends_count"),
232 + "activities": det.get("activities") or None,
233 + }.items() if v}
234 +
235 + cap = det.get("capacity")
236 + listings.append(StListing(
237 + source=self.source_id,
238 + external_id=mid,
239 + url=SITE + (node.get("url") or f"/en-CA/land/{mid}"),
240 + title=title,
241 + property_type=_property_type(keys),
242 + city=node.get("cityName") or "",
243 + region=_region_from_latlng(lat, lng),
244 + price_night=price_night,
245 + price_label=price_label,
246 + capacity=float(cap) if cap else None,
247 + description=det.get("description") or "",
248 + amenities=det.get("amenities") or [],
249 + details=details,
250 + images=images,
251 + lat=lat,
252 + lng=lng,
253 + ))
254 + if limit and len(listings) >= limit:
255 + break
256 + return listings
added louka/shortterm/connectors/parcscanada.py +194 −0
@@ -0,0 +1,194 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/parcscanada.py : Parcs Canada (reservation.pc.gc.ca) —
4 +# hébergements « prêt-à-camper » des parcs nationaux et lieux historiques
5 +# fédéraux AU QUÉBEC : oTENTik, Ôasis, MicrOcube, tentes de prospecteur…
6 +# (Forillon, La Mauricie, Archipel-de-Mingan, canaux de Lachine et
7 +# de Saint-Ours). ~60 unités.
8 +#
9 +# Méthode : le système de réservation (GoingToCamp/Aspira, derrière un WAF
10 +# Azure qui laisse passer les requêtes au User-Agent de navigateur) expose
11 +# une API JSON publique :
12 +# 1. GET /api/resourcecategory → id de catégorie → nom fr (oTENTik, Ôasis,
13 +# Yourte, Chalet rustique, Tente de prospecteur, MicrOcube…) ;
14 +# 2. GET /api/resourcelocation/resources?resourceLocationId=<id> → TOUTES
15 +# les ressources d'un terrain (emplacements + hébergements) : nom,
16 +# description fr, capacité, photos, catégorie. On ne garde que les
17 +# catégories « hébergement » (table KEEP ci-dessous) ;
18 +# 3. prix (cache self.detail, clé mensuelle) : POST /api/resource/feeDetails
19 +# ?resourceId=…&startDate=<J+35> → feeTotal = tarif d'une nuit.
20 +#
21 +# Les ids sont des entiers négatifs (int32 min + n). Aucune géoloc par unité
22 +# dans l'API : lat/lng et région touristique viennent de la table statique
23 +# des terrains québécois (QC_LOCATIONS) — fait géographique stable.
24 +# Pas de page publique par unité : l'URL pointe la recherche du terrain.
25 +# Réglage env : LOUKA_PARCSCANADA_LIMIT (nb max d'unités, 0 = tout).
26 +# -----------------------------------------------------------------------------
27 +from __future__ import annotations
28 +
29 +import datetime
30 +import html as _html
31 +import os
32 +import re
33 +import sys
34 +
35 +from ..schema import StListing
36 +from .base import StConnector
37 +
38 +BASE = "https://reservation.pc.gc.ca"
39 +
40 +HEADERS = {"Accept": "application/json, text/plain, */*",
41 + "Accept-Language": "fr",
42 + "Referer": BASE + "/"}
43 +
44 +# Terrains de Parcs Canada au Québec : id → (nom court, région touristique,
45 +# lat, lng approximatifs du terrain). Les autres provinces sont ignorées.
46 +QC_LOCATIONS = {
47 + -2147483625: ("Forillon – Des-Rosiers", "Gaspésie", 48.879, -64.295),
48 + -2147483626: ("Forillon – Petit-Gaspé", "Gaspésie", 48.812, -64.279),
49 + -2147483627: ("Forillon – Cap-Bon-Ami", "Gaspésie", 48.868, -64.235),
50 + -2147483534: ("Forillon – Arrière-pays", "Gaspésie", 48.850, -64.350),
51 + -2147483575: ("La Mauricie – Mistagance", "Mauricie", 46.790, -72.940),
52 + -2147483573: ("La Mauricie – Rivière-à-la-Pêche", "Mauricie",
53 + 46.672, -72.920),
54 + -2147483524: ("La Mauricie – Saint-Jean-des-Piles", "Mauricie",
55 + 46.669, -72.752),
56 + -2147483578: ("La Mauricie – Wapizagonke", "Mauricie", 46.757, -73.005),
57 + -2147483570: ("Archipel-de-Mingan", "Côte-Nord", 50.222, -63.600),
58 + -2147483571: ("Canal-de-Lachine", "Montréal", 45.430, -73.670),
59 + -2147483548: ("Canal-de-Saint-Ours", "Montérégie", 45.869, -73.150),
60 + -2147483513: ("Canal-de-Chambly", "Montérégie", 45.441, -73.283),
61 +}
62 +
63 +# Catégories « hébergement » (les emplacements de camping nus sont exclus)
64 +KEEP = {
65 + -2147483647: "Yourte", # Yourte
66 + -2147483646: "Chalet", # Chalet
67 + -2147483645: "Chalet", # Chalet rustique
68 + -2147483644: "Prêt-à-camper", # Ôasis (cocon suspendu)
69 + -2147483643: "Prêt-à-camper", # oTENTik
70 + -2147483642: "Mini-maison", # MicrOcube
71 + -2147483635: "Prêt-à-camper", # Camping tout équipé
72 + -2147483634: "Chalet", # Chalet en arrière-pays
73 + -2147483633: "Yourte", # Yourte en arrière-pays
74 + -2147483631: "Prêt-à-camper", # Tipi
75 + -2147483630: "Prêt-à-camper", # Tente de prospecteur
76 + -2147483629: "Refuge", # Abri en zone d'arrière-pays
77 +}
78 +
79 +_TAG_RE = re.compile(r"<[^>]+>")
80 +
81 +
82 +def _fr(localized: list[dict], *keys: str) -> dict:
83 + by_culture = {v.get("cultureName"): v for v in (localized or [])}
84 + return by_culture.get("fr-CA") or by_culture.get("en-CA") or {}
85 +
86 +
87 +def _pets(description: str) -> str | None:
88 + low = description.lower()
89 + if re.search(r"accueille les animaux|animaux .{0,20}(admis|accept|bienvenu)",
90 + low):
91 + return "oui"
92 + if re.search(r"n'accueille pas les animaux|animaux .{0,20}(interdits|"
93 + r"pas admis|non admis)|aucun animal", low):
94 + return "non"
95 + return None
96 +
97 +
98 +class ParcsCanada(StConnector):
99 + source_id = "parcscanada"
100 + request_delay = 0.5
101 +
102 + def _api(self, path: str, **params) -> object:
103 + resp = self.get(BASE + path, params=params or None, headers=HEADERS)
104 + return resp.json()
105 +
106 + # -- prix d'une nuit (cache BD, clé mensuelle) -------------------------------
107 + def _fetch_fee(self, resource_id: int) -> dict:
108 + start = (datetime.date.today()
109 + + datetime.timedelta(days=35)).isoformat()
110 + resp = self.post(
111 + BASE + "/api/resource/feeDetails",
112 + params={"resourceId": resource_id, "startDate": start,
113 + "boatLength": 0, "bookingCategoryId": 0,
114 + "entryPointResourceId": 0, "exitPointResourceId": 0},
115 + json=[], headers=HEADERS)
116 + fees = (resp.json() or {}).get("resourceFeeDetails") or []
117 + total = sum(f.get("feeTotal") or 0 for f in fees)
118 + return {"fee": round(total, 2)} if total > 0 else {}
119 +
120 + # -- contrat --------------------------------------------------------------
121 + def fetch(self) -> list[StListing]:
122 + limit = int(os.environ.get("LOUKA_PARCSCANADA_LIMIT", "0") or 0)
123 +
124 + cat_names: dict[int, str] = {}
125 + for c in self._api("/api/resourcecategory") or []:
126 + name = _fr(c.get("localizedValues")).get("name") or ""
127 + if name:
128 + cat_names[c.get("resourceCategoryId")] = name
129 +
130 + month = datetime.date.today().strftime("%Y-%m") # prix revus au mois
131 + listings: list[StListing] = []
132 + for loc_id, (loc_name, region, lat, lng) in QC_LOCATIONS.items():
133 + try:
134 + resources = self._api("/api/resourcelocation/resources",
135 + resourceLocationId=loc_id) or {}
136 + except Exception as exc: # noqa: BLE001 — un terrain HS ≠ tout perdu
137 + print(f"[parcscanada] terrain {loc_name} : {exc}",
138 + file=sys.stderr)
139 + continue
140 +
141 + for res in resources.values():
142 + cat_id = res.get("resourceCategoryId")
143 + if cat_id not in KEEP:
144 + continue
145 + rid = res.get("resourceId")
146 + fr = _fr(res.get("localizedValues"))
147 + name = (fr.get("name") or "").strip()
148 + if rid is None or not name:
149 + continue
150 +
151 + desc = _html.unescape(_TAG_RE.sub(
152 + " ", (fr.get("description") or "").replace("<br>", "\n")))
153 + desc = re.sub(r"[ \t]+", " ", desc).strip()
154 +
155 + images = []
156 + for ph in (res.get("photos") or [])[:12]:
157 + u = ((ph or {}).get("photoUrlResult") or {}).get("url") or ""
158 + if u.startswith("https://") and u not in images:
159 + images.append(u)
160 +
161 + cat_fr = cat_names.get(cat_id, KEEP[cat_id])
162 + title = f"{cat_fr} {name} – {loc_name}"
163 +
164 + try:
165 + fee = self.detail(str(rid), month,
166 + lambda r=rid: self._fetch_fee(r))
167 + except Exception as exc: # noqa: BLE001 — le prix est optionnel
168 + print(f"[parcscanada] tarif {rid} : {exc}", file=sys.stderr)
169 + fee = {}
170 + price = fee.get("fee")
171 +
172 + cap = res.get("maxCapacity")
173 + listings.append(StListing(
174 + source=self.source_id,
175 + external_id=str(rid),
176 + url=(f"{BASE}/create-booking/results?resourceLocationId="
177 + f"{loc_id}&searchTabGroupId=0&bookingCategoryId=0"),
178 + title=title,
179 + property_type=KEEP[cat_id],
180 + address=loc_name,
181 + region=region,
182 + price_night=float(price) if price else None,
183 + price_label=(f"{price:.2f} $ / nuit" if price else ""),
184 + capacity=float(cap) if cap else None,
185 + pets=_pets(desc),
186 + description=desc[:4000],
187 + details={"parc": loc_name, "categorie": cat_fr},
188 + images=images,
189 + lat=lat,
190 + lng=lng,
191 + ).finalize())
192 + if limit and len(listings) >= limit:
193 + return listings
194 + return listings
added louka/shortterm/connectors/sinistar.py +226 −0
@@ -0,0 +1,226 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/sinistar.py : Sinistar (sinistar.ca)
4 +#
5 +# Plateforme québécoise de relogement temporaire pour sinistrés (assurance
6 +# habitation) : ~5 600 logements meublés au Québec, partout en province.
7 +# SPA Next.js, mais la recherche passe par un index Algolia public :
8 +# 1. LISTE : POST https://RKSJN2W5I1-dsn.algolia.net/1/indexes/prod_housings/
9 +# query (clé search-only embarquée dans les bundles du site) avec
10 +# facetFilters state:QC. Chaque hit : reference, ville, type, chambres,
11 +# lits, sdb, photos, _geoloc. L'index plafonne à 1 000 enregistrements
12 +# par requête (paginationLimitedTo) → couverture par quadrillage
13 +# insideBoundingBox : une cellule qui dépasse 1 000 hits est subdivisée
14 +# en 4 (quadtree, même esprit que le connecteur Airbnb).
15 +# 2. DÉTAIL (cache self.detail) : GET https://sinistar.ca/fr/housing/<ref>
16 +# — la page embarque le JSON du logement dans le flux React Flight
17 +# (self.__next_f.push) : description, commodités, capacité.
18 +# 3. AUCUN PRIX PUBLIC : le tarif est négocié entre l'hôte et l'assureur
19 +# (« couvert par l'assurance ») → price_night=None, prix absent assumé.
20 +# La région touristique est déduite des coordonnées (centroïdes Airbnb).
21 +#
22 +# Réglage env : LOUKA_SINISTAR_LIMIT (nb max d'annonces, 0 = tout ; utile
23 +# pour tester petit sans payer les ~5 600 pages détail du premier run).
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import json
28 +import os
29 +import re
30 +import sys
31 +
32 +from ..schema import StListing
33 +from .airbnb import _region_from_latlng
34 +from .base import StConnector
35 +
36 +SITE = "https://sinistar.ca"
37 +ALGOLIA_URL = ("https://RKSJN2W5I1-dsn.algolia.net/1/indexes/"
38 + "prod_housings/query")
39 +ALGOLIA_HEADERS = {
40 + "x-algolia-application-id": "RKSJN2W5I1",
41 + # clé search-only publique (extraite des bundles JS de sinistar.ca)
42 + "x-algolia-api-key": "4dbe623a9ffc1181003e9d98cad4c05b",
43 + "Content-Type": "application/json",
44 +}
45 +
46 +# Zone habitée du Québec (sud, ouest, nord, est) pour le quadrillage géo.
47 +QC_BBOX = (44.95, -79.80, 62.00, -56.90)
48 +
49 +# type Sinistar → (type canonique Lou-Ka, libellé français pour le titre)
50 +_TYPES = {
51 + "house": ("Maison", "Maison meublée"),
52 + "cityhouse": ("Maison", "Maison de ville meublée"),
53 + "semidetached": ("Maison", "Maison jumelée meublée"),
54 + "appartment": ("Appartement", "Appartement meublé"),
55 + "apartment": ("Appartement", "Appartement meublé"),
56 + "condo": ("Condo", "Condo meublé"),
57 + "cottage": ("Chalet", "Chalet meublé"),
58 + "loft": ("Loft", "Loft meublé"),
59 + "hotel": ("Auberge", "Hébergement hôtelier"),
60 +}
61 +
62 +_FLIGHT_RE = re.compile(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', re.S)
63 +
64 +
65 +def _num(v) -> float | None:
66 + try:
67 + return float(v) if v not in (None, "") else None
68 + except (TypeError, ValueError):
69 + return None
70 +
71 +
72 +def _decode_flight(html: str) -> str:
73 + """Concatène les segments React Flight (chaînes JS échappées → UTF-8)."""
74 + out = []
75 + for chunk in _FLIGHT_RE.findall(html):
76 + try:
77 + s = chunk.encode("utf-8").decode("unicode_escape")
78 + out.append(s.encode("latin-1", "replace").decode("utf-8", "replace"))
79 + except (UnicodeDecodeError, UnicodeEncodeError):
80 + continue
81 + return "".join(out)
82 +
83 +
84 +class Sinistar(StConnector):
85 + source_id = "sinistar"
86 + request_delay = 0.4
87 +
88 + # -- liste (Algolia, quadtree géo) ----------------------------------------
89 + def _query(self, params: str) -> dict:
90 + resp = self.post(ALGOLIA_URL, headers=ALGOLIA_HEADERS,
91 + json={"params": params})
92 + return resp.json()
93 +
94 + def _all_hits(self) -> list[dict]:
95 + hits: list[dict] = []
96 + seen: set[str] = set()
97 + stack = [QC_BBOX]
98 + while stack:
99 + s, w, n, e = stack.pop()
100 + box = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}"
101 + data = self._query(
102 + "hitsPerPage=1000"
103 + "&facetFilters=%5B%5B%22state%3AQC%22%5D%5D"
104 + f"&insideBoundingBox={box}")
105 + nb = data.get("nbHits") or 0
106 + if nb > 1000 and (n - s) > 0.002:
107 + # cellule saturée (limite Algolia) → subdivision en 4
108 + mlat, mlng = (s + n) / 2, (w + e) / 2
109 + stack.extend([(s, w, mlat, mlng), (s, mlng, mlat, e),
110 + (mlat, w, n, mlng), (mlat, mlng, n, e)])
111 + continue
112 + for h in data.get("hits") or []:
113 + oid = h.get("objectID") or ""
114 + if oid and oid not in seen:
115 + seen.add(oid)
116 + hits.append(h)
117 + return hits
118 +
119 + # -- détail (page housing, cache BD) ---------------------------------------
120 + def _fetch_detail(self, reference: str) -> dict:
121 + resp = self.get(f"{SITE}/fr/housing/{reference}")
122 + blob = _decode_flight(resp.text)
123 + i = blob.find(f'"reference":{reference}')
124 + if i < 0:
125 + return {}
126 + line = blob[blob.rfind("\n", 0, i) + 1:]
127 + line = line[:line.find("\n")] if "\n" in line else line
128 + try:
129 + payload = json.loads(line.split(":", 1)[1])
130 + except (ValueError, IndexError):
131 + return {}
132 + housing = {}
133 +
134 + def walk(o):
135 + nonlocal housing
136 + if housing:
137 + return
138 + if isinstance(o, dict):
139 + if str(o.get("reference")) == reference and "amenities" in o:
140 + housing = o
141 + return
142 + for v in o.values():
143 + walk(v)
144 + elif isinstance(o, list):
145 + for v in o:
146 + walk(v)
147 +
148 + walk(payload)
149 + if not housing:
150 + return {}
151 + amenities = []
152 + for cat in (housing.get("amenities") or {}).values():
153 + for a in (cat if isinstance(cat, list) else []):
154 + if isinstance(a, str):
155 + amenities.append(a.replace("_", " ").strip())
156 + return {
157 + "description": (housing.get("description") or "").strip(),
158 + "amenities": amenities,
159 + "capacity": _num(housing.get("capacity")),
160 + }
161 +
162 + # -- contrat --------------------------------------------------------------
163 + def fetch(self) -> list[StListing]:
164 + limit = int(os.environ.get("LOUKA_SINISTAR_LIMIT", "0") or 0)
165 + listings: list[StListing] = []
166 + seen: set[str] = set()
167 + for h in self._all_hits():
168 + ref = str(h.get("reference") or "").strip()
169 + city = (h.get("city") or "").strip()
170 + if not ref or ref in seen or not city:
171 + continue
172 + seen.add(ref)
173 +
174 + geo = h.get("_geoloc") or {}
175 + lat, lng = _num(geo.get("lat")), _num(geo.get("lng"))
176 + ptype, label = _TYPES.get((h.get("type") or "").strip().lower(),
177 + ("Autre", "Logement meublé"))
178 + bedrooms = _num(h.get("bedrooms"))
179 + title = label
180 + if bedrooms:
181 + title += f" {int(bedrooms)} chambre{'s' if bedrooms > 1 else ''}"
182 + title += f" à {city}"
183 +
184 + images = [p.get("url") for p in (h.get("pictures") or [])[:15]
185 + if isinstance(p.get("url"), str)
186 + and p["url"].startswith("https://")]
187 + if not images and isinstance(
188 + (h.get("coverPicture") or {}).get("url"), str):
189 + images = [h["coverPicture"]["url"]]
190 +
191 + # clé de cache détail : sous-ensemble stable du hit liste
192 + key = json.dumps([ref, h.get("type"), h.get("bedrooms"),
193 + h.get("beds"), h.get("bathrooms"), len(images)],
194 + ensure_ascii=False)
195 + try:
196 + det = self.detail(ref, key, lambda r=ref: self._fetch_detail(r))
197 + except Exception as exc: # une fiche cassée ≠ annonce perdue
198 + print(f"[sinistar] détail {ref} en échec : {exc}",
199 + file=sys.stderr)
200 + det = {}
201 +
202 + listings.append(StListing(
203 + source=self.source_id,
204 + external_id=ref,
205 + url=f"{SITE}/fr/housing/{ref}",
206 + title=title,
207 + property_type=ptype,
208 + city=city,
209 + region=_region_from_latlng(lat, lng),
210 + price_night=None, # tarif négocié avec l'assureur
211 + price_label="",
212 + capacity=det.get("capacity"),
213 + bedrooms=bedrooms,
214 + beds=_num(h.get("beds")),
215 + bathrooms=_num(h.get("bathrooms")),
216 + description=det.get("description") or "",
217 + amenities=det.get("amenities") or [],
218 + details={"relocation": True,
219 + "sinistar_type": h.get("type") or ""},
220 + images=images,
221 + lat=lat,
222 + lng=lng,
223 + ).finalize())
224 + if limit and len(listings) >= limit:
225 + break
226 + return listings
added louka/shortterm/connectors/sonder.py +30 −0
@@ -0,0 +1,30 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/sonder.py : Sonder (sonder.com) — SOURCE NON VIABLE (2026-08)
4 +#
5 +# Sonder Holdings (appartements-hôtels, plusieurs immeubles à Montréal) a
6 +# annoncé sa liquidation le 9 novembre 2025 après la rupture de son entente
7 +# de licence avec Marriott ; les opérations ont cessé immédiatement
8 +# (faillite, clients évincés — actions désormais cotées SONDQ en OTC).
9 +#
10 +# Constat au 2026-08-23 : le domaine sonder.com est devenu un AGRÉGATEUR
11 +# AFFILIÉ sans inventaire propre — les pages « /all/canada/quebec/montreal »
12 +# (CloudFront, contournable via Bright Data) listent des hôtels et annonces
13 +# tiers avec ids « BC-… » et liens /redirect-partner vers booking.com
14 +# (campagne d'affiliation bc_cid=SONDER, collection « formerly-sonder »).
15 +# Scraper ce site ne produirait que des doublons de notre connecteur Booking,
16 +# attribués à la mauvaise source. On garde le module en éteignoir au cas où
17 +# la marque renaîtrait avec un vrai inventaire.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +from ..schema import StListing
22 +from .base import StConnector
23 +
24 +
25 +class Sonder(StConnector):
26 + source_id = "sonder"
27 + disabled = True # liquidation nov. 2025 ; sonder.com = affilié Booking
28 +
29 + def fetch(self) -> list[StListing]:
30 + return []
added louka/shortterm/connectors/tripadvisor.py +31 −0
@@ -0,0 +1,31 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/tripadvisor.py : TripAdvisor Vacation Rentals — SOURCE NON VIABLE
4 +#
5 +# TripAdvisor a FERMÉ son produit de location de vacances le 1er novembre 2024
6 +# (fin des réservations directes, fermeture de FlipKey, Holiday Lettings et
7 +# Niumba — annonce Skift/communiqué de septembre 2024). Vérifié au 2026-08-23 :
8 +# toutes les URL /VacationRentals-g<geo>-Reviews-… (tripadvisor.ca ET .com,
9 +# testées via Scrapfly ASP : Québec g155033, Montréal g155032, Mont-Tremblant
10 +# g155059, New York g28953) répondent 301 → la page /Hotels-g<geo>-…-Hotels.html
11 +# ou 404. Il n'existe plus ni vertical « locations de vacances », ni API/JSON
12 +# embarqué correspondant ; les hébergements « autres » affichés dans la
13 +# recherche hôtels sont des fiches d'avis renvoyant vers des OTA tierces
14 +# (Booking, Vrbo, Expedia) que nos autres connecteurs couvrent déjà — les
15 +# scraper ne produirait que des doublons mal attribués.
16 +#
17 +# On garde le module en éteignoir au cas où TripAdvisor relancerait un vrai
18 +# inventaire de locations (l'auto-registre l'exclut tant que disabled = True).
19 +# -----------------------------------------------------------------------------
20 +from __future__ import annotations
21 +
22 +from ..schema import StListing
23 +from .base import StConnector
24 +
25 +
26 +class TripAdvisor(StConnector):
27 + source_id = "tripadvisor"
28 + disabled = True # produit locations de vacances fermé le 2024-11-01
29 +
30 + def fetch(self) -> list[StListing]:
31 + return []
32