Commit
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>
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 : | ||
| 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 | |||