Combines Booking.com Playwright scraper (from forecasting), direct booking engine scraper (ported from laptop-archive/guestline-monitor), and Newbook own-hotel rates into one focused tool. Four views: Bookability, Market View (with price index badges + direct rate sub-rows), Direct Rates (per-competitor room breakdown, min-stay flags, hotel config/discovery), Rate Analysis (advance purchase curve, DOW chart, rate timeline, comparison table). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
166 lines
6.4 KiB
Python
166 lines
6.4 KiB
Python
import re
|
|
import httpx
|
|
from datetime import date, timedelta
|
|
from .base import BaseProfile
|
|
|
|
API_BASE = "https://api.travelclick.com"
|
|
TOKEN_URL = f"{API_BASE}/oauth/token-referer?grant_type=client_credentials"
|
|
|
|
HEADERS_BASE = {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
async def _get_token(client, referer: str) -> str:
|
|
r = await client.post(TOKEN_URL, headers={**HEADERS_BASE, "Referer": referer}, timeout=20)
|
|
r.raise_for_status()
|
|
return r.json()["access_token"]
|
|
|
|
|
|
def _auth_headers(token: str, referer: str) -> dict:
|
|
return {**HEADERS_BASE, "Authorization": f"Bearer {token}", "Referer": referer}
|
|
|
|
|
|
class TravelClickProfile(BaseProfile):
|
|
name = "travelclick"
|
|
label = "TravelClick / Amadeus"
|
|
required_params = [
|
|
{"key": "hotel_code", "label": "Hotel Code",
|
|
"help": "Numeric hotel ID, e.g. 77346 — visible in the booking engine network requests"},
|
|
{"key": "booking_url", "label": "Booking URL Base",
|
|
"help": "e.g. https://reservations.bespokehotels.com/noelarmshotel/book/dates-of-stay"},
|
|
]
|
|
|
|
@classmethod
|
|
def detect(cls, url: str) -> dict | None:
|
|
m = re.search(r'(https://reservations\.bespokehotels\.com/[^/]+/book/[^?#\s]+)', url)
|
|
if not m:
|
|
return None
|
|
booking_url = m.group(1)
|
|
# hotel_code is in inline JS as bookingEngineHotelId: '77346' on the booking page
|
|
hotel_code = ""
|
|
try:
|
|
r = httpx.get(booking_url, timeout=10, follow_redirects=True,
|
|
headers={"User-Agent": "Mozilla/5.0"})
|
|
hm = re.search(r'bookingEngineHotelId\s*:\s*[\'"](\d+)[\'"]', r.text)
|
|
if hm:
|
|
hotel_code = hm.group(1)
|
|
except Exception:
|
|
pass
|
|
return {"booking_url": booking_url, "hotel_code": hotel_code}
|
|
|
|
async def fetch_category_names(self, client, params: dict) -> tuple[dict[str, str], dict[str, str]]:
|
|
hotel_code = params["hotel_code"]
|
|
referer = params["booking_url"]
|
|
token = await _get_token(client, referer)
|
|
r = await client.get(
|
|
f"{API_BASE}/be5-entity/v2/hotels/{hotel_code}/content",
|
|
params={"include": "roomtypes,rateplans", "lang": "EN_US"},
|
|
headers=_auth_headers(token, referer),
|
|
timeout=30,
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
# roomtypes[].roomTypeId (numeric, used as roomId in avail) -> roomTypeName
|
|
room_labels = {
|
|
str(rt["roomTypeId"]): rt.get("roomTypeName", str(rt["roomTypeId"]))
|
|
for rt in data.get("roomtypes", [])
|
|
}
|
|
# ratePlans[].rateplanId (numeric, used as rateId in avail) -> rateplanName
|
|
rate_labels = {
|
|
str(rp["rateplanId"]): rp.get("rateplanName", str(rp["rateplanId"]))
|
|
for rp in data.get("ratePlans", [])
|
|
}
|
|
return room_labels, rate_labels
|
|
|
|
async def fetch_arrival_dates(self, client, params: dict) -> list[str]:
|
|
hotel_code = params["hotel_code"]
|
|
referer = params["booking_url"]
|
|
token = await _get_token(client, referer)
|
|
today = date.today()
|
|
end = today + timedelta(days=365)
|
|
body = {
|
|
"hotelCode": int(hotel_code),
|
|
"currency": "GBP",
|
|
"lang": "EN_US",
|
|
"dateIn": today.isoformat(),
|
|
"dateOut": end.isoformat(),
|
|
"multiRoomOccupancy": [{"adults": 2, "infant": 0, "children": 0}],
|
|
"bookerIdentifier": "",
|
|
"partnerIdentifier": "",
|
|
}
|
|
r = await client.post(
|
|
f"{API_BASE}/be5-shop/v1/hotel/{hotel_code}/basicavail/multi-room",
|
|
json=body,
|
|
headers=_auth_headers(token, referer),
|
|
timeout=30,
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
return [
|
|
d["date"]
|
|
for d in data.get("dates", [])
|
|
if d.get("isAvailable") and d["date"] >= today.isoformat()
|
|
]
|
|
|
|
async def fetch_night_rates(self, client, params: dict, arrival: str, nights: int = 1) -> list[dict]:
|
|
hotel_code = params["hotel_code"]
|
|
referer = params["booking_url"]
|
|
token = await _get_token(client, referer)
|
|
departure = (date.fromisoformat(arrival) + timedelta(days=nights)).isoformat()
|
|
body = {
|
|
"roomStay": {
|
|
"startDate": arrival,
|
|
"endDate": departure,
|
|
"guestCount": {"adults": 2, "infants": 0, "children": {"ages": None, "count": 0}},
|
|
"roomQuantity": 1,
|
|
"productSearchCriteria": {
|
|
"sortingPreference": "SORT_BY_ORDER",
|
|
"includeUnavailable": False,
|
|
},
|
|
},
|
|
"languageCode": "EN_US",
|
|
"disableLocaitonSharing": False,
|
|
"currencyCode": "GBP",
|
|
"tpaExtension": [],
|
|
"includeMemberRate": True,
|
|
"includeNightlyRates": True,
|
|
}
|
|
r = await client.post(
|
|
f"{API_BASE}/be5-shop/v2/hotels/{hotel_code}/avail",
|
|
json=body,
|
|
headers=_auth_headers(token, referer),
|
|
timeout=30,
|
|
)
|
|
if r.status_code == 404:
|
|
return []
|
|
r.raise_for_status()
|
|
|
|
data = r.json()
|
|
# Response: roomStays[0].roomtypes[].products[] (both Regular rates and Packages)
|
|
room_stay = (data.get("roomStays") or [{}])[0]
|
|
rooms = []
|
|
for rt in room_stay.get("roomtypes", []):
|
|
room_id = str(rt["roomtypeId"])
|
|
for product in rt.get("products", []):
|
|
rate_id = str(product["productId"])
|
|
nightly = product.get("nightlyRates", [])
|
|
if not nightly:
|
|
continue
|
|
avail = nightly[0].get("inventoryCount", 0)
|
|
prices = [
|
|
{"amountBeforeTax": n["amountBeforeTax"], "amountAfterTax": n.get("amountTotal", n["amountBeforeTax"])}
|
|
for n in nightly
|
|
]
|
|
rooms.append({
|
|
"roomId": room_id,
|
|
"rateId": rate_id,
|
|
"availability": avail,
|
|
"prices": prices,
|
|
"min_stay_nights": None, # min-stay comes from basicavail per date
|
|
"currencyCode": "GBP",
|
|
})
|
|
|
|
return rooms
|