Court terme : connecteur origine — reseau Origine artisans hoteliers (35 hotels/auberges, prix forfaits ramenes a la nuit)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 changed file +246 −0
added
louka/shortterm/connectors/origine.py
+246 −0
@@ -0,0 +1,246 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Location court terme | ||||
| 3 | +# connectors/origine.py : Ôrigine artisans hôteliers (originehotels.com) | ||||
| 4 | +# — ex-Hôtellerie Champêtre : réseau de 35 hôtels et auberges indépendants | ||||
| 5 | +# dans 15 régions touristiques du Québec. Réservation en direct sur la fiche. | ||||
| 6 | +# | ||||
| 7 | +# Méthode : site Drupal, HTML serveur, accessible en direct (le sitemap ne | ||||
| 8 | +# liste pas les fiches → l'annuaire /fr/hotels-auberges fournit l'inventaire, | ||||
| 9 | +# liens /fr/hotels-auberges/<région>/<slug>). La fiche (cache détail « v1 ») | ||||
| 10 | +# est très balisée : | ||||
| 11 | +# h1 (titre, div .visually-hidden à écarter), .header-hotel-place | ||||
| 12 | +# (« La Malbaie - Charlevoix » → ville + région), .hotel-map-address h4 | ||||
| 13 | +# (adresse civique), .hotel-map-citq (« Numéro CITQ: 012272 »), | ||||
| 14 | +# .hotel-map-view (data-lat/data-lng), .hotel-map-infos (téléphone, site | ||||
| 15 | +# web de l'hôtel), .text-cta-head/.text-cta-content (présentation), | ||||
| 16 | +# .activity-list-services-item (« Services offerts »), | ||||
| 17 | +# .utility-list-item (types de chambre — leur div prix est toujours vide). | ||||
| 18 | +# PRIX : les seuls montants de la fiche sont les forfaits de l'hôtel | ||||
| 19 | +# (.package-item-v2 : « Tarif du jour 209,00$ », « Évasion | 2 nuits | ||||
| 20 | +# 698,00$ »…). price_night = minimum des forfaits ramenés à la nuit | ||||
| 21 | +# (« | N nuits » → ÷ N) en écartant les échelles de rabais long séjour | ||||
| 22 | +# (« 3e nuitée à 50% », « 5 nuits et plus… ») dont le total est ambigu. | ||||
| 23 | +# PHOTOS : /sites/default/files/styles/<style>/public/ dans les répertoires | ||||
| 24 | +# hotel-gallerie (héro), galerie-texte-image et hotel-chamgres-et-services | ||||
| 25 | +# (sic) ; on écarte forfaits-hotels-packages-galerie et order_email_hotels | ||||
| 26 | +# (images d'AUTRES hôtels / bons de commande). Dédoublonnage sur le nom de | ||||
| 27 | +# fichier (variantes .webp/.jpeg + styles 1x/1.5x/2x), on garde la 2x. | ||||
| 28 | +# capacity/bedrooms restent None (hôtel : les unités varient) ; les types de | ||||
| 29 | +# chambre vont dans details["room_types"]. | ||||
| 30 | +# ----------------------------------------------------------------------------- | ||||
| 31 | +from __future__ import annotations | ||||
| 32 | + | ||||
| 33 | +import re | ||||
| 34 | +import sys | ||||
| 35 | +from urllib.parse import unquote | ||||
| 36 | + | ||||
| 37 | +from bs4 import BeautifulSoup | ||||
| 38 | + | ||||
| 39 | +from ...normalize import strip_accents | ||||
| 40 | +from ..schema import StListing, normalize_region | ||||
| 41 | +from .base import StConnector | ||||
| 42 | + | ||||
| 43 | +BASE = "https://originehotels.com" | ||||
| 44 | +INDEX = BASE + "/fr/hotels-auberges" | ||||
| 45 | + | ||||
| 46 | +# liens de fiche dans l'annuaire : /fr/hotels-auberges/<région>/<slug> | ||||
| 47 | +_FICHE_RE = re.compile(r'href="(/fr/hotels-auberges/([\w-]+)/([\w-]+))"') | ||||
| 48 | + | ||||
| 49 | +# segments de région d'URL que normalize_region ne résout pas seul | ||||
| 50 | +_URL_REGIONS = { | ||||
| 51 | + "cote-nord-duplessis": "Côte-Nord", | ||||
| 52 | + "cote-nord-manicouagan": "Côte-Nord", | ||||
| 53 | + "cantons-de-l-est": "Cantons-de-l'Est", | ||||
| 54 | + "chaudiere-appalaches": "Chaudière-Appalaches", | ||||
| 55 | +} | ||||
| 56 | + | ||||
| 57 | +# répertoires Drupal des photos propres à l'hôtel (héro, galerie, chambres) | ||||
| 58 | +_IMG_RE = re.compile( | ||||
| 59 | + r'(/sites/default/files/styles/([^/"]+)/public/' | ||||
| 60 | + r'(?:hotel-gallerie|galerie-texte-image|hotel-chamgres-et-services)/' | ||||
| 61 | + r'[^"\s,]+?\.(?:jpe?g|webp|png)(?:\?itok=[\w-]+)?)', re.I) | ||||
| 62 | + | ||||
| 63 | +# forfaits à écarter du calcul de prix : échelles de rabais long séjour | ||||
| 64 | +_LADDER_RE = re.compile(r"\d+\s*e\s+nuit|nuits?\s+et\s+plus", re.I) | ||||
| 65 | +_NIGHTS_RE = re.compile(r"\|\s*(\d+)\s+nuits", re.I) | ||||
| 66 | +_MONEY_RE = re.compile(r"(\d[\d\s ]*?)(?:\s*[.,]\s*(\d{1,2}))?\s*\$") | ||||
| 67 | + | ||||
| 68 | + | ||||
| 69 | +def _txt(node) -> str: | ||||
| 70 | + return re.sub(r"\s+", " ", node.get_text(" ", strip=True)) if node else "" | ||||
| 71 | + | ||||
| 72 | + | ||||
| 73 | +def _money(raw: str) -> float | None: | ||||
| 74 | + m = _MONEY_RE.search(raw or "") | ||||
| 75 | + if not m: | ||||
| 76 | + return None | ||||
| 77 | + val = float(re.sub(r"[\s ]", "", m.group(1)) + "." + (m.group(2) or "0")) | ||||
| 78 | + return val if 0 < val < 100000 else None | ||||
| 79 | + | ||||
| 80 | + | ||||
| 81 | +class Origine(StConnector): | ||||
| 82 | + source_id = "origine" | ||||
| 83 | + | ||||
| 84 | + # -- inventaire (annuaire) ------------------------------------------------- | ||||
| 85 | + def _fiches(self) -> list[tuple[str, str, str]]: | ||||
| 86 | + """[(slug, url fiche, segment région d'URL)] dans l'ordre d'affichage.""" | ||||
| 87 | + html = self.get(INDEX).text | ||||
| 88 | + fiches, vus = [], set() | ||||
| 89 | + for path, region_seg, slug in _FICHE_RE.findall(html): | ||||
| 90 | + if slug in vus or slug == "fiche-de-satisfaction": | ||||
| 91 | + continue | ||||
| 92 | + vus.add(slug) | ||||
| 93 | + fiches.append((slug, BASE + path, region_seg)) | ||||
| 94 | + return fiches | ||||
| 95 | + | ||||
| 96 | + # -- page fiche → dict sérialisable (cache détail) -------------------------- | ||||
| 97 | + def _detail(self, url: str) -> dict: | ||||
| 98 | + html = self.get(url).text | ||||
| 99 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 100 | + d: dict = {} | ||||
| 101 | + | ||||
| 102 | + h1 = soup.find("h1") | ||||
| 103 | + if h1 is not None: | ||||
| 104 | + for hidden in h1.select(".visually-hidden"): | ||||
| 105 | + hidden.decompose() | ||||
| 106 | + d["title"] = _txt(h1) | ||||
| 107 | + | ||||
| 108 | + # « La Malbaie - Charlevoix » → ville + région touristique | ||||
| 109 | + place = _txt(soup.select_one(".header-hotel-place")) | ||||
| 110 | + if place: | ||||
| 111 | + parts = re.split(r"\s+-\s+", place) | ||||
| 112 | + if len(parts) >= 2: | ||||
| 113 | + d["city"], d["region"] = parts[0], parts[-1] | ||||
| 114 | + else: | ||||
| 115 | + d["city"] = place | ||||
| 116 | + | ||||
| 117 | + d["address"] = _txt(soup.select_one(".hotel-map-address h4")) | ||||
| 118 | + | ||||
| 119 | + m = re.search(r"CITQ\D{0,12}(\d{6})", | ||||
| 120 | + _txt(soup.select_one(".hotel-map-citq"))) | ||||
| 121 | + if m: | ||||
| 122 | + d["citq"] = m.group(1) | ||||
| 123 | + | ||||
| 124 | + view = soup.select_one(".hotel-map-view") | ||||
| 125 | + if view is not None: | ||||
| 126 | + try: | ||||
| 127 | + d["lat"] = float(view.get("data-lat")) | ||||
| 128 | + d["lng"] = float(view.get("data-lng")) | ||||
| 129 | + except (TypeError, ValueError): | ||||
| 130 | + pass | ||||
| 131 | + | ||||
| 132 | + infos = soup.select_one(".hotel-map-infos") | ||||
| 133 | + if infos is not None: | ||||
| 134 | + tel = infos.select_one("a.phone-link") | ||||
| 135 | + site = infos.select_one("a.website-link") | ||||
| 136 | + if tel is not None: | ||||
| 137 | + d["phone"] = _txt(tel) | ||||
| 138 | + if site is not None and site.get("href"): | ||||
| 139 | + d["website"] = site["href"].strip() | ||||
| 140 | + | ||||
| 141 | + # présentation : accroche + texte du premier bloc text-cta | ||||
| 142 | + morceaux = [_txt(soup.select_one(".text-cta-head"))] | ||||
| 143 | + cont = soup.select_one(".text-cta-content") | ||||
| 144 | + if cont is not None: | ||||
| 145 | + morceaux.append("\n".join( | ||||
| 146 | + t for p in cont.find_all(["p", "li", "h3", "h4"]) | ||||
| 147 | + if (t := re.sub(r"\s+", " ", p.get_text(" ", strip=True)))) | ||||
| 148 | + or _txt(cont)) | ||||
| 149 | + d["description"] = re.sub(r"\*{2,}", "", | ||||
| 150 | + "\n".join(m for m in morceaux if m)).strip() | ||||
| 151 | + | ||||
| 152 | + # « Services offerts » | ||||
| 153 | + amen: list[str] = [] | ||||
| 154 | + for li in soup.select(".activity-list-services-item"): | ||||
| 155 | + t = _txt(li) | ||||
| 156 | + if t and t not in amen: | ||||
| 157 | + amen.append(t) | ||||
| 158 | + d["amenities"] = amen | ||||
| 159 | + | ||||
| 160 | + # types de chambre (leur div prix est vide sur tout le réseau) | ||||
| 161 | + rooms: list[str] = [] | ||||
| 162 | + for hd in soup.select(".utility-list-item .utility-list-item-heading"): | ||||
| 163 | + t = _txt(hd) | ||||
| 164 | + if t and t not in rooms: | ||||
| 165 | + rooms.append(t) | ||||
| 166 | + d["room_types"] = rooms | ||||
| 167 | + | ||||
| 168 | + # forfaits de l'hôtel → prix « à partir de » ramené à la nuit | ||||
| 169 | + best = None | ||||
| 170 | + for item in soup.select(".package-item-v2"): | ||||
| 171 | + titre = _txt(item.select_one(".package-item-v2-heading")) | ||||
| 172 | + if _LADDER_RE.search(titre): | ||||
| 173 | + continue | ||||
| 174 | + prix = _money(_txt(item.select_one(".package-item-v2-price-value"))) | ||||
| 175 | + if prix is None: | ||||
| 176 | + continue | ||||
| 177 | + m = _NIGHTS_RE.search(titre) | ||||
| 178 | + if m and int(m.group(1)) > 0: | ||||
| 179 | + prix = round(prix / int(m.group(1)), 2) | ||||
| 180 | + if best is None or prix < best: | ||||
| 181 | + best = prix | ||||
| 182 | + if best is not None: | ||||
| 183 | + d["price_night"] = best | ||||
| 184 | + | ||||
| 185 | + # photos : dédoublonnage nom de fichier, préférence au style 2x | ||||
| 186 | + imgs: dict[str, tuple[int, str]] = {} | ||||
| 187 | + for u, style in _IMG_RE.findall(html): | ||||
| 188 | + nom = unquote(u.split("?")[0].rsplit("/", 1)[-1]) | ||||
| 189 | + nom = re.sub(r"\.\w+$", "", nom).lower() | ||||
| 190 | + score = 3 if style.endswith("_2x") else \ | ||||
| 191 | + 2 if style.endswith("_1_5x") else 1 | ||||
| 192 | + if ".webp" not in u.lower(): | ||||
| 193 | + score += 1 # à score de style égal, préférer le jpeg | ||||
| 194 | + if nom not in imgs or score > imgs[nom][0]: | ||||
| 195 | + imgs[nom] = (score, BASE + u) | ||||
| 196 | + d["images"] = [u for _, u in imgs.values()][:40] | ||||
| 197 | + return d | ||||
| 198 | + | ||||
| 199 | + # -- contrat --------------------------------------------------------------- | ||||
| 200 | + def fetch(self) -> list[StListing]: | ||||
| 201 | + listings: list[StListing] = [] | ||||
| 202 | + for slug, url, region_seg in self._fiches(): | ||||
| 203 | + try: | ||||
| 204 | + d = self.detail(slug, "v1", lambda u=url: self._detail(u)) | ||||
| 205 | + except Exception as exc: # une fiche cassée ≠ inventaire perdu | ||||
| 206 | + print(f"[origine] {slug} : {exc}", file=sys.stderr) | ||||
| 207 | + d = {} | ||||
| 208 | + if not d.get("title"): | ||||
| 209 | + continue | ||||
| 210 | + | ||||
| 211 | + titre = d["title"] | ||||
| 212 | + ptype = ("Auberge" if re.search(r"\bauberge\b", | ||||
| 213 | + strip_accents(titre).lower()) | ||||
| 214 | + else "Hôtel") | ||||
| 215 | + | ||||
| 216 | + region = normalize_region(d.get("region", "")) | ||||
| 217 | + if not region: | ||||
| 218 | + region = _URL_REGIONS.get( | ||||
| 219 | + region_seg, normalize_region(region_seg.replace("-", " "))) | ||||
| 220 | + | ||||
| 221 | + prix = d.get("price_night") | ||||
| 222 | + details = {"room_types": d.get("room_types") or []} | ||||
| 223 | + for k in ("phone", "website"): | ||||
| 224 | + if d.get(k): | ||||
| 225 | + details[k] = d[k] | ||||
| 226 | + | ||||
| 227 | + listings.append(StListing( | ||||
| 228 | + source=self.source_id, | ||||
| 229 | + external_id=slug, # slug de la fiche, stable | ||||
| 230 | + url=url, | ||||
| 231 | + title=titre, | ||||
| 232 | + property_type=ptype, | ||||
| 233 | + address=d.get("address", ""), | ||||
| 234 | + city=d.get("city", ""), | ||||
| 235 | + region=region, | ||||
| 236 | + price_night=prix, | ||||
| 237 | + price_label=(f"À partir de {prix:g} $ / nuit" if prix else ""), | ||||
| 238 | + citq=d.get("citq", ""), | ||||
| 239 | + description=d.get("description", "")[:5000], | ||||
| 240 | + amenities=d.get("amenities") or [], | ||||
| 241 | + details=details, | ||||
| 242 | + images=d.get("images") or [], | ||||
| 243 | + lat=d.get("lat"), | ||||
| 244 | + lng=d.get("lng"), | ||||
| 245 | + )) | ||||
| 246 | + return listings | ||||
| 247 | |||||