#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Détection des moteurs de réservation des hôtels du Québec (dataset SIT/Tourinsoft). Usage : .venv/bin/python scripts/detect_hotel_engines.py [--dataset /tmp/sit_hotels.json] [--out data/hotels_engines.json] [--threads 16] [--timeout 10] Fonctionnement : 1. Charge le dataset officiel « Hôtels — SIT Québec » (JSON Tourinsoft, ~1 910 hôtels). S'il n'est pas présent au chemin --dataset, il est téléchargé automatiquement. 2. Garde les entrées avec un site web http(s) valide (champ SiteInternets[].Coordonnees). 3. Sonde chaque site EN DIRECT (requests, timeout 10 s, User-Agent navigateur, ~16 threads, aucun service anti-bot payant) : homepage puis, au besoin, les pages « réserver / reservation / booking » liées depuis la homepage. 4. Détecte le moteur de réservation par motifs dans l'URL, le HTML et les href : reservit (custid/hotelid), mews (GUID distributor), synxis (chain/hotel), travelclick/iHotelier, et une quinzaine d'autres moteurs fréquents ; sinon « autre » (href du bouton réserver hors domaine conservé) ou « aucun ». Un site injoignable/bloquant est marqué « error ». 5. Écrit au fur et à mesure (flush périodique) data/hotels_engines.json : {"generated": , "hotels": [{"sit_id","citq","name","city","region", "website","engine","engine_ids":{...},"booking_url"}]} Le script est RÉSUMABLE : les sit_id déjà présents dans le fichier de sortie sont sautés — on peut l'interrompre et le relancer sans perdre le travail accompli. Ce fichier servira ensuite à écrire des connecteurs génériques Reservit/Mews/SynXis. """ import argparse import html as html_mod import json import os import re import sys import threading import time import urllib3 from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urljoin, urlparse import requests urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) DATASET_URL = ( "https://api-v3.tourinsoft.com/api/syndications/mto.tourinsoft.com/" "535f2f61-f91a-47c1-827d-ecc908c17dba?format=json" ) HEADERS = { "User-Agent": ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8", } # --------------------------------------------------------------------------- # Détection des moteurs # --------------------------------------------------------------------------- RE_HREF = re.compile(r"""(?:href|src|action|data-href|data-url)\s*=\s*["']([^"'<>]+)""", re.I) RE_URL_IN_TEXT = re.compile(r"https?://[^\s\"'<>\\)]+", re.I) # Motifs d'ids Reservit RE_RESERVIT_PATH = re.compile(r"reservit\.com/(?:fo|engine)/booking/(\d+)/(\d+)", re.I) RE_RESERVIT_CUSTOMERID = re.compile(r"indexmodal\.php[^\"'\s]*?customerid=(\d+)", re.I) RE_CUSTID = re.compile(r"[?&](?:custid|customerid)=(\d+)", re.I) RE_HOTELID = re.compile(r"[?&](?:hotelid|hotid)=(\d+)", re.I) RE_MEWS = re.compile(r"mews\.com/distributor/([0-9a-fA-F-]{32,36})", re.I) RE_SYNXIS_CHAIN = re.compile(r"[?&]chain=(\d+)", re.I) RE_SYNXIS_HOTEL = re.compile(r"[?&]hotel=(\d+)", re.I) RE_TRAVELCLICK_ID = re.compile(r"reservations\.travelclick\.com/(\d+)", re.I) RE_IHOTELIER_HOTELID = re.compile(r"[?&]hotelid=(\d+)", re.I) RE_CLOUDBEDS = re.compile(r"hotels\.cloudbeds\.com/(?:[a-z]{2}/)?reservation[s]?/([A-Za-z0-9_-]+)", re.I) # Moteurs « génériques » : nom -> motif de reconnaissance dans une URL/du HTML GENERIC_ENGINES = [ ("lodgify", re.compile(r"lodgify\.com", re.I)), ("cloudbeds", re.compile(r"cloudbeds\.com", re.I)), ("bookingsuite", re.compile(r"bookingsuite", re.I)), ("resengo", re.compile(r"resengo", re.I)), ("roomraccoon", re.compile(r"roomraccoon", re.I)), ("guestline", re.compile(r"guestline", re.I)), ("siteminder", re.compile(r"siteminder|thebookingbutton|direct-book\.com", re.I)), ("d-edge", re.compile(r"availpro|d-edge\.com|secure-hotel-booking\.com", re.I)), ("webrez", re.compile(r"webrez", re.I)), ("innroad", re.compile(r"innroad", re.I)), ("resnexus", re.compile(r"resnexus", re.I)), ("freetobook", re.compile(r"freetobook", re.I)), ("hotello", re.compile(r"hotello", re.I)), ("sirvoy", re.compile(r"sirvoy", re.I)), ("littlehotelier", re.compile(r"little\s*hotelier|littlehotelier", re.I)), ] # Mots-clés pour repérer les liens « réserver » dans une page RE_BOOK_LINK = re.compile( r"r[ée]serv|booking|book[-_ /]?now|booknow|disponibilit|availab|reservation", re.I, ) def detect_in_text(text): """Cherche un moteur connu dans un bout de texte (URL ou HTML). Retourne (engine, engine_ids, matched_url) ou (None, {}, None). """ if not text: return None, {}, None # Neutraliser les entités HTML (& -> &) pour que les regex de # paramètres d'URL fonctionnent aussi dans le HTML brut. text = html_mod.unescape(text) def first_url(pattern): """URL complète contenant le motif, pour booking_url.""" for m in RE_URL_IN_TEXT.finditer(text): if pattern.search(m.group(0)): return m.group(0) return text if pattern.search(text) and text.startswith("http") else None # --- Reservit --- if re.search(r"secure\.reservit\.com|reservit\.com", text, re.I): booking_url = first_url(re.compile(r"reservit\.com", re.I)) ids = {} m = RE_RESERVIT_PATH.search(text) if m: ids["custid"], ids["hotelid"] = m.group(1), m.group(2) else: # Les paramètres sont cherchés d'abord dans l'URL reservit elle-même # (où « id= » désigne le custid, ex. reserhotel.php?action=resa&id=58), # puis dans le texte complet en repli. scopes = [booking_url or "", text] for scope in scopes: if "custid" not in ids: m = (RE_RESERVIT_CUSTOMERID.search(scope) or RE_CUSTID.search(scope) or (re.search(r"[?&]id=(\d+)", scope) if "reservit" in scope.lower() else None)) if m: ids["custid"] = m.group(1) if "hotelid" not in ids: m = RE_HOTELID.search(scope) if m: ids["hotelid"] = m.group(1) return "reservit", ids, booking_url # --- Mews --- m = RE_MEWS.search(text) if m: return "mews", {"distributor": m.group(1)}, first_url(RE_MEWS) # --- SynXis --- if re.search(r"be\.synxis\.com", text, re.I): ids = {} m = RE_SYNXIS_CHAIN.search(text) if m: ids["chain"] = m.group(1) m = RE_SYNXIS_HOTEL.search(text) if m: ids["hotel"] = m.group(1) return "synxis", ids, first_url(re.compile(r"be\.synxis\.com", re.I)) # --- TravelClick / iHotelier --- if re.search(r"travelclick\.com|ihotelier\.com", text, re.I): ids = {} m = RE_TRAVELCLICK_ID.search(text) if m: ids["hotelid"] = m.group(1) elif re.search(r"bookings\.ihotelier\.com", text, re.I): m = RE_IHOTELIER_HOTELID.search(text) if m: ids["hotelid"] = m.group(1) return "travelclick", ids, first_url(re.compile(r"travelclick\.com|ihotelier\.com", re.I)) # --- Autres moteurs fréquents --- for name, pattern in GENERIC_ENGINES: if pattern.search(text): ids = {} if name == "cloudbeds": m = RE_CLOUDBEDS.search(text) if m: ids["slug"] = m.group(1) return name, ids, first_url(pattern) return None, {}, None # --------------------------------------------------------------------------- # Sonde d'un hôtel # --------------------------------------------------------------------------- def fetch(session, url, timeout): """GET tolérant : suit les redirections, ignore les erreurs SSL.""" return session.get(url, timeout=timeout, headers=HEADERS, verify=False, allow_redirects=True) def extract_booking_candidates(html, base_url): """Liens 'réserver/booking' d'une page : (internes, externes).""" internal, external = [], [] base_host = urlparse(base_url).netloc.lower().lstrip("www.") seen = set() for m in RE_HREF.finditer(html): href = m.group(1).strip() if not href or href.startswith(("#", "mailto:", "tel:", "javascript:")): continue # Contexte : le href lui-même ou le texte proche du lien start = max(0, m.start() - 120) context = html[start:m.end() + 180] if not RE_BOOK_LINK.search(href) and not RE_BOOK_LINK.search(context): continue absu = urljoin(base_url, href) if not absu.startswith("http") or absu in seen: continue seen.add(absu) host = urlparse(absu).netloc.lower().lstrip("www.") if host == base_host: internal.append(absu) else: external.append(absu) return internal, external def probe_hotel(hotel, timeout): """Sonde le site d'un hôtel ; retourne l'enregistrement final.""" rec = { "sit_id": hotel["sit_id"], "citq": hotel["citq"], "name": hotel["name"], "city": hotel["city"], "region": hotel["region"], "website": hotel["website"], "engine": "aucun", "engine_ids": {}, "booking_url": None, } website = hotel["website"] # 0. Le « site web » du répertoire est parfois déjà une URL de moteur engine, ids, url = detect_in_text(website) if engine: rec.update(engine=engine, engine_ids=ids, booking_url=url or website) return rec session = requests.Session() try: resp = fetch(session, website, timeout) html = resp.text or "" final_url = str(resp.url) except Exception: rec["engine"] = "error" return rec if resp.status_code >= 400 or not html: rec["engine"] = "error" return rec # 1. Moteur visible directement dans la homepage (liens, iframes, scripts) engine, ids, url = detect_in_text(html) if engine: rec.update(engine=engine, engine_ids=ids, booking_url=url) return rec # 2. Suivre les liens « réserver / booking » trouvés dans le HTML internal, external = extract_booking_candidates(html, final_url) # 2a. Les hrefs externes sont souvent l'URL du moteur elle-même for href in external[:6]: engine, ids, url = detect_in_text(href) if engine: rec.update(engine=engine, engine_ids=ids, booking_url=url or href) return rec # 2b. Visiter jusqu'à 2 pages internes de réservation for href in internal[:2]: try: r2 = fetch(session, href, timeout) if r2.status_code >= 400: continue engine, ids, url = detect_in_text(r2.text or "") if engine: rec.update(engine=engine, engine_ids=ids, booking_url=url) return rec # La page interne peut rediriger vers le moteur engine, ids, url = detect_in_text(str(r2.url)) if engine: rec.update(engine=engine, engine_ids=ids, booking_url=url or str(r2.url)) return rec except Exception: continue # 3. Rien de connu : « autre » si un bouton réserver pointe hors domaine if external: rec.update(engine="autre", booking_url=external[0]) return rec # --------------------------------------------------------------------------- # Chargement du dataset SIT # --------------------------------------------------------------------------- def load_sit_hotels(dataset_path): """Télécharge (si absent) puis parse le dataset SIT ; retourne la liste des hôtels avec site web http(s) valide.""" if not os.path.exists(dataset_path): print(f"Téléchargement du dataset SIT -> {dataset_path}", flush=True) r = requests.get(DATASET_URL, timeout=120, headers=HEADERS) r.raise_for_status() with open(dataset_path, "wb") as f: f.write(r.content) with open(dataset_path, "r", encoding="utf-8") as f: data = json.load(f) records = data.get("value") if isinstance(data, dict) else data hotels = [] for rec in records: # Site web website = None for it in rec.get("SiteInternets") or []: u = (it.get("Coordonnees") or "").strip() if u.lower().startswith(("http://", "https://")): website = u break if not website: continue # Ville city = None for adr in rec.get("Adresses") or []: city = adr.get("Municipalite") or city if city: break # Région touristique : ZoneTypeId == 7 dans ZonesGeographiquesTypes region = None type7 = {zt.get("ZoneId") for zt in rec.get("ZonesGeographiquesTypes") or [] if zt.get("ZoneTypeId") == 7} for z in rec.get("ZonesGeographiquess") or []: if z.get("ZoneId") in type7: region = z.get("ZoneLibelle") break # Numéro CITQ (enregistrement hébergement) citq = None for n in rec.get("NumeroEnregistrementHebergements") or []: citq = n.get("Numerodenregistrementdhebergement") or citq if citq: break hotels.append({ "sit_id": rec.get("SyndicObjectID"), "citq": citq, "name": rec.get("SyndicObjectName"), "city": city, "region": region, "website": website, }) return hotels # --------------------------------------------------------------------------- # Boucle principale (résumable, flush périodique) # --------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[1]) ap.add_argument("--dataset", default="/tmp/sit_hotels.json", help="chemin local du JSON SIT (téléchargé si absent)") ap.add_argument("--out", default="data/hotels_engines.json", help="fichier de sortie (repris s'il existe)") ap.add_argument("--threads", type=int, default=16) ap.add_argument("--timeout", type=float, default=10.0) args = ap.parse_args() hotels = load_sit_hotels(args.dataset) print(f"{len(hotels)} hôtels avec site web dans le dataset SIT", flush=True) # Reprise : charger la sortie existante results = [] done_ids = set() if os.path.exists(args.out): try: with open(args.out, "r", encoding="utf-8") as f: prev = json.load(f) results = prev.get("hotels") or [] done_ids = {h.get("sit_id") for h in results} print(f"Reprise : {len(done_ids)} hôtels déjà sondés", flush=True) except Exception as e: print(f"Sortie existante illisible ({e}) — on repart de zéro", flush=True) todo = [h for h in hotels if h["sit_id"] not in done_ids] print(f"{len(todo)} hôtels à sonder", flush=True) lock = threading.Lock() os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True) def flush(): tmp = args.out + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump({"generated": int(time.time()), "hotels": results}, f, ensure_ascii=False, indent=1) os.replace(tmp, args.out) completed = 0 with ThreadPoolExecutor(max_workers=args.threads) as ex: futures = {ex.submit(probe_hotel, h, args.timeout): h for h in todo} for fut in as_completed(futures): h = futures[fut] try: rec = fut.result() except Exception: rec = {"sit_id": h["sit_id"], "citq": h["citq"], "name": h["name"], "city": h["city"], "region": h["region"], "website": h["website"], "engine": "error", "engine_ids": {}, "booking_url": None} with lock: results.append(rec) completed += 1 if completed % 20 == 0: flush() print(f" {completed}/{len(todo)} sondés " f"(dernier: {rec['name']} -> {rec['engine']})", flush=True) flush() # Bilan from collections import Counter dist = Counter(r["engine"] for r in results) print(f"\nTerminé : {len(results)} hôtels dans {args.out}") for eng, n in dist.most_common(): print(f" {eng:15s} {n}") if __name__ == "__main__": main()