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%
16.9 KB · 446 lines python
Raw Blame History
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 (&amp; -> &) 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