Add Rate Monitor app — Booking.com + direct booking engine competitor rates
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>
This commit is contained in:
commit
e05054172f
50 changed files with 11860 additions and 0 deletions
166
backend/services/direct_profiles/travelclick.py
Normal file
166
backend/services/direct_profiles/travelclick.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue