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:
jtricerolph 2026-07-05 12:06:30 +00:00
commit e05054172f
50 changed files with 11860 additions and 0 deletions

View 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