Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1#!/usr/bin/env python32# -*- coding: utf-8 -*-3"""4Détection des moteurs de réservation des hôtels du Québec (dataset SIT/Tourinsoft).56Usage :7 .venv/bin/python scripts/detect_hotel_engines.py [--dataset /tmp/sit_hotels.json]8 [--out data/hotels_engines.json]9 [--threads 16] [--timeout 10]1011Fonctionnement :12 1. Charge le dataset officiel « Hôtels — SIT Québec » (JSON Tourinsoft, ~1 910 hôtels).13 S'il n'est pas présent au chemin --dataset, il est téléchargé automatiquement.14 2. Garde les entrées avec un site web http(s) valide (champ SiteInternets[].Coordonnees).15 3. Sonde chaque site EN DIRECT (requests, timeout 10 s, User-Agent navigateur,16 ~16 threads, aucun service anti-bot payant) : homepage puis, au besoin, les17 pages « réserver / reservation / booking » liées depuis la homepage.18 4. Détecte le moteur de réservation par motifs dans l'URL, le HTML et les href :19 reservit (custid/hotelid), mews (GUID distributor), synxis (chain/hotel),20 travelclick/iHotelier, et une quinzaine d'autres moteurs fréquents ; sinon21 « autre » (href du bouton réserver hors domaine conservé) ou « aucun ».22 Un site injoignable/bloquant est marqué « error ».23 5. Écrit au fur et à mesure (flush périodique) data/hotels_engines.json :24 {"generated": <epoch>, "hotels": [{"sit_id","citq","name","city","region",25 "website","engine","engine_ids":{...},"booking_url"}]}2627Le script est RÉSUMABLE : les sit_id déjà présents dans le fichier de sortie sont28sautés — on peut l'interrompre et le relancer sans perdre le travail accompli.2930Ce fichier servira ensuite à écrire des connecteurs génériques Reservit/Mews/SynXis.31"""3233import argparse34import html as html_mod35import json36import os37import re38import sys39import threading40import time41import urllib342from concurrent.futures import ThreadPoolExecutor, as_completed43from urllib.parse import urljoin, urlparse4445import requests4647urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)4849DATASET_URL = (50 "https://api-v3.tourinsoft.com/api/syndications/mto.tourinsoft.com/"51 "535f2f61-f91a-47c1-827d-ecc908c17dba?format=json"52)5354HEADERS = {55 "User-Agent": (56 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "57 "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"58 ),59 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",60 "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8",61}6263# ---------------------------------------------------------------------------64# Détection des moteurs65# ---------------------------------------------------------------------------6667RE_HREF = re.compile(r"""(?:href|src|action|data-href|data-url)\s*=\s*["']([^"'<>]+)""", re.I)68RE_URL_IN_TEXT = re.compile(r"https?://[^\s\"'<>\\)]+", re.I)6970# Motifs d'ids Reservit71RE_RESERVIT_PATH = re.compile(r"reservit\.com/(?:fo|engine)/booking/(\d+)/(\d+)", re.I)72RE_RESERVIT_CUSTOMERID = re.compile(r"indexmodal\.php[^\"'\s]*?customerid=(\d+)", re.I)73RE_CUSTID = re.compile(r"[?&](?:custid|customerid)=(\d+)", re.I)74RE_HOTELID = re.compile(r"[?&](?:hotelid|hotid)=(\d+)", re.I)7576RE_MEWS = re.compile(r"mews\.com/distributor/([0-9a-fA-F-]{32,36})", re.I)77RE_SYNXIS_CHAIN = re.compile(r"[?&]chain=(\d+)", re.I)78RE_SYNXIS_HOTEL = re.compile(r"[?&]hotel=(\d+)", re.I)79RE_TRAVELCLICK_ID = re.compile(r"reservations\.travelclick\.com/(\d+)", re.I)80RE_IHOTELIER_HOTELID = re.compile(r"[?&]hotelid=(\d+)", re.I)81RE_CLOUDBEDS = re.compile(r"hotels\.cloudbeds\.com/(?:[a-z]{2}/)?reservation[s]?/([A-Za-z0-9_-]+)", re.I)8283# Moteurs « génériques » : nom -> motif de reconnaissance dans une URL/du HTML84GENERIC_ENGINES = [85 ("lodgify", re.compile(r"lodgify\.com", re.I)),86 ("cloudbeds", re.compile(r"cloudbeds\.com", re.I)),87 ("bookingsuite", re.compile(r"bookingsuite", re.I)),88 ("resengo", re.compile(r"resengo", re.I)),89 ("roomraccoon", re.compile(r"roomraccoon", re.I)),90 ("guestline", re.compile(r"guestline", re.I)),91 ("siteminder", re.compile(r"siteminder|thebookingbutton|direct-book\.com", re.I)),92 ("d-edge", re.compile(r"availpro|d-edge\.com|secure-hotel-booking\.com", re.I)),93 ("webrez", re.compile(r"webrez", re.I)),94 ("innroad", re.compile(r"innroad", re.I)),95 ("resnexus", re.compile(r"resnexus", re.I)),96 ("freetobook", re.compile(r"freetobook", re.I)),97 ("hotello", re.compile(r"hotello", re.I)),98 ("sirvoy", re.compile(r"sirvoy", re.I)),99 ("littlehotelier", re.compile(r"little\s*hotelier|littlehotelier", re.I)),100]101102# Mots-clés pour repérer les liens « réserver » dans une page103RE_BOOK_LINK = re.compile(104 r"r[ée]serv|booking|book[-_ /]?now|booknow|disponibilit|availab|reservation",105 re.I,106)107108109def detect_in_text(text):110 """Cherche un moteur connu dans un bout de texte (URL ou HTML).111112 Retourne (engine, engine_ids, matched_url) ou (None, {}, None).113 """114 if not text:115 return None, {}, None116 # Neutraliser les entités HTML (& -> &) pour que les regex de117 # paramètres d'URL fonctionnent aussi dans le HTML brut.118 text = html_mod.unescape(text)119120 def first_url(pattern):121 """URL complète contenant le motif, pour booking_url."""122 for m in RE_URL_IN_TEXT.finditer(text):123 if pattern.search(m.group(0)):124 return m.group(0)125 return text if pattern.search(text) and text.startswith("http") else None126127 # --- Reservit ---128 if re.search(r"secure\.reservit\.com|reservit\.com", text, re.I):129 booking_url = first_url(re.compile(r"reservit\.com", re.I))130 ids = {}131 m = RE_RESERVIT_PATH.search(text)132 if m:133 ids["custid"], ids["hotelid"] = m.group(1), m.group(2)134 else:135 # Les paramètres sont cherchés d'abord dans l'URL reservit elle-même136 # (où « id= » désigne le custid, ex. reserhotel.php?action=resa&id=58),137 # puis dans le texte complet en repli.138 scopes = [booking_url or "", text]139 for scope in scopes:140 if "custid" not in ids:141 m = (RE_RESERVIT_CUSTOMERID.search(scope)142 or RE_CUSTID.search(scope)143 or (re.search(r"[?&]id=(\d+)", scope)144 if "reservit" in scope.lower() else None))145 if m:146 ids["custid"] = m.group(1)147 if "hotelid" not in ids:148 m = RE_HOTELID.search(scope)149 if m:150 ids["hotelid"] = m.group(1)151 return "reservit", ids, booking_url152153 # --- Mews ---154 m = RE_MEWS.search(text)155 if m:156 return "mews", {"distributor": m.group(1)}, first_url(RE_MEWS)157158 # --- SynXis ---159 if re.search(r"be\.synxis\.com", text, re.I):160 ids = {}161 m = RE_SYNXIS_CHAIN.search(text)162 if m:163 ids["chain"] = m.group(1)164 m = RE_SYNXIS_HOTEL.search(text)165 if m:166 ids["hotel"] = m.group(1)167 return "synxis", ids, first_url(re.compile(r"be\.synxis\.com", re.I))168169 # --- TravelClick / iHotelier ---170 if re.search(r"travelclick\.com|ihotelier\.com", text, re.I):171 ids = {}172 m = RE_TRAVELCLICK_ID.search(text)173 if m:174 ids["hotelid"] = m.group(1)175 elif re.search(r"bookings\.ihotelier\.com", text, re.I):176 m = RE_IHOTELIER_HOTELID.search(text)177 if m:178 ids["hotelid"] = m.group(1)179 return "travelclick", ids, first_url(re.compile(r"travelclick\.com|ihotelier\.com", re.I))180181 # --- Autres moteurs fréquents ---182 for name, pattern in GENERIC_ENGINES:183 if pattern.search(text):184 ids = {}185 if name == "cloudbeds":186 m = RE_CLOUDBEDS.search(text)187 if m:188 ids["slug"] = m.group(1)189 return name, ids, first_url(pattern)190191 return None, {}, None192193194# ---------------------------------------------------------------------------195# Sonde d'un hôtel196# ---------------------------------------------------------------------------197198199def fetch(session, url, timeout):200 """GET tolérant : suit les redirections, ignore les erreurs SSL."""201 return session.get(url, timeout=timeout, headers=HEADERS,202 verify=False, allow_redirects=True)203204205def extract_booking_candidates(html, base_url):206 """Liens 'réserver/booking' d'une page : (internes, externes)."""207 internal, external = [], []208 base_host = urlparse(base_url).netloc.lower().lstrip("www.")209 seen = set()210 for m in RE_HREF.finditer(html):211 href = m.group(1).strip()212 if not href or href.startswith(("#", "mailto:", "tel:", "javascript:")):213 continue214 # Contexte : le href lui-même ou le texte proche du lien215 start = max(0, m.start() - 120)216 context = html[start:m.end() + 180]217 if not RE_BOOK_LINK.search(href) and not RE_BOOK_LINK.search(context):218 continue219 absu = urljoin(base_url, href)220 if not absu.startswith("http") or absu in seen:221 continue222 seen.add(absu)223 host = urlparse(absu).netloc.lower().lstrip("www.")224 if host == base_host:225 internal.append(absu)226 else:227 external.append(absu)228 return internal, external229230231def probe_hotel(hotel, timeout):232 """Sonde le site d'un hôtel ; retourne l'enregistrement final."""233 rec = {234 "sit_id": hotel["sit_id"],235 "citq": hotel["citq"],236 "name": hotel["name"],237 "city": hotel["city"],238 "region": hotel["region"],239 "website": hotel["website"],240 "engine": "aucun",241 "engine_ids": {},242 "booking_url": None,243 }244 website = hotel["website"]245246 # 0. Le « site web » du répertoire est parfois déjà une URL de moteur247 engine, ids, url = detect_in_text(website)248 if engine:249 rec.update(engine=engine, engine_ids=ids, booking_url=url or website)250 return rec251252 session = requests.Session()253 try:254 resp = fetch(session, website, timeout)255 html = resp.text or ""256 final_url = str(resp.url)257 except Exception:258 rec["engine"] = "error"259 return rec260261 if resp.status_code >= 400 or not html:262 rec["engine"] = "error"263 return rec264265 # 1. Moteur visible directement dans la homepage (liens, iframes, scripts)266 engine, ids, url = detect_in_text(html)267 if engine:268 rec.update(engine=engine, engine_ids=ids, booking_url=url)269 return rec270271 # 2. Suivre les liens « réserver / booking » trouvés dans le HTML272 internal, external = extract_booking_candidates(html, final_url)273274 # 2a. Les hrefs externes sont souvent l'URL du moteur elle-même275 for href in external[:6]:276 engine, ids, url = detect_in_text(href)277 if engine:278 rec.update(engine=engine, engine_ids=ids, booking_url=url or href)279 return rec280281 # 2b. Visiter jusqu'à 2 pages internes de réservation282 for href in internal[:2]:283 try:284 r2 = fetch(session, href, timeout)285 if r2.status_code >= 400:286 continue287 engine, ids, url = detect_in_text(r2.text or "")288 if engine:289 rec.update(engine=engine, engine_ids=ids, booking_url=url)290 return rec291 # La page interne peut rediriger vers le moteur292 engine, ids, url = detect_in_text(str(r2.url))293 if engine:294 rec.update(engine=engine, engine_ids=ids, booking_url=url or str(r2.url))295 return rec296 except Exception:297 continue298299 # 3. Rien de connu : « autre » si un bouton réserver pointe hors domaine300 if external:301 rec.update(engine="autre", booking_url=external[0])302 return rec303304305# ---------------------------------------------------------------------------306# Chargement du dataset SIT307# ---------------------------------------------------------------------------308309310def load_sit_hotels(dataset_path):311 """Télécharge (si absent) puis parse le dataset SIT ; retourne la liste312 des hôtels avec site web http(s) valide."""313 if not os.path.exists(dataset_path):314 print(f"Téléchargement du dataset SIT -> {dataset_path}", flush=True)315 r = requests.get(DATASET_URL, timeout=120, headers=HEADERS)316 r.raise_for_status()317 with open(dataset_path, "wb") as f:318 f.write(r.content)319320 with open(dataset_path, "r", encoding="utf-8") as f:321 data = json.load(f)322 records = data.get("value") if isinstance(data, dict) else data323324 hotels = []325 for rec in records:326 # Site web327 website = None328 for it in rec.get("SiteInternets") or []:329 u = (it.get("Coordonnees") or "").strip()330 if u.lower().startswith(("http://", "https://")):331 website = u332 break333 if not website:334 continue335336 # Ville337 city = None338 for adr in rec.get("Adresses") or []:339 city = adr.get("Municipalite") or city340 if city:341 break342343 # Région touristique : ZoneTypeId == 7 dans ZonesGeographiquesTypes344 region = None345 type7 = {zt.get("ZoneId") for zt in rec.get("ZonesGeographiquesTypes") or []346 if zt.get("ZoneTypeId") == 7}347 for z in rec.get("ZonesGeographiquess") or []:348 if z.get("ZoneId") in type7:349 region = z.get("ZoneLibelle")350 break351352 # Numéro CITQ (enregistrement hébergement)353 citq = None354 for n in rec.get("NumeroEnregistrementHebergements") or []:355 citq = n.get("Numerodenregistrementdhebergement") or citq356 if citq:357 break358359 hotels.append({360 "sit_id": rec.get("SyndicObjectID"),361 "citq": citq,362 "name": rec.get("SyndicObjectName"),363 "city": city,364 "region": region,365 "website": website,366 })367 return hotels368369370# ---------------------------------------------------------------------------371# Boucle principale (résumable, flush périodique)372# ---------------------------------------------------------------------------373374375def main():376 ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])377 ap.add_argument("--dataset", default="/tmp/sit_hotels.json",378 help="chemin local du JSON SIT (téléchargé si absent)")379 ap.add_argument("--out", default="data/hotels_engines.json",380 help="fichier de sortie (repris s'il existe)")381 ap.add_argument("--threads", type=int, default=16)382 ap.add_argument("--timeout", type=float, default=10.0)383 args = ap.parse_args()384385 hotels = load_sit_hotels(args.dataset)386 print(f"{len(hotels)} hôtels avec site web dans le dataset SIT", flush=True)387388 # Reprise : charger la sortie existante389 results = []390 done_ids = set()391 if os.path.exists(args.out):392 try:393 with open(args.out, "r", encoding="utf-8") as f:394 prev = json.load(f)395 results = prev.get("hotels") or []396 done_ids = {h.get("sit_id") for h in results}397 print(f"Reprise : {len(done_ids)} hôtels déjà sondés", flush=True)398 except Exception as e:399 print(f"Sortie existante illisible ({e}) — on repart de zéro", flush=True)400401 todo = [h for h in hotels if h["sit_id"] not in done_ids]402 print(f"{len(todo)} hôtels à sonder", flush=True)403404 lock = threading.Lock()405 os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)406407 def flush():408 tmp = args.out + ".tmp"409 with open(tmp, "w", encoding="utf-8") as f:410 json.dump({"generated": int(time.time()), "hotels": results},411 f, ensure_ascii=False, indent=1)412 os.replace(tmp, args.out)413414 completed = 0415 with ThreadPoolExecutor(max_workers=args.threads) as ex:416 futures = {ex.submit(probe_hotel, h, args.timeout): h for h in todo}417 for fut in as_completed(futures):418 h = futures[fut]419 try:420 rec = fut.result()421 except Exception:422 rec = {"sit_id": h["sit_id"], "citq": h["citq"], "name": h["name"],423 "city": h["city"], "region": h["region"],424 "website": h["website"], "engine": "error",425 "engine_ids": {}, "booking_url": None}426 with lock:427 results.append(rec)428 completed += 1429 if completed % 20 == 0:430 flush()431 print(f" {completed}/{len(todo)} sondés "432 f"(dernier: {rec['name']} -> {rec['engine']})", flush=True)433434 flush()435436 # Bilan437 from collections import Counter438 dist = Counter(r["engine"] for r in results)439 print(f"\nTerminé : {len(results)} hôtels dans {args.out}")440 for eng, n in dist.most_common():441 print(f" {eng:15s} {n}")442443444if __name__ == "__main__":445 main()446