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 : 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>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent df32e7b

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