wanderlust-chatbot / scripts /processors /normalize_hotels.py
Kiriten892's picture
feat: security audit fixes, performance improvements & global data pipeline
dea44a6
Raw
History Blame Contribute Delete
6.75 kB
"""
Normalize scraped hotel data → backend hotels.json seeder schema.
Outputs to scraped_data/hotels_international.json (separate from VN hotels).
"""
import json
import logging
import re
import os
from typing import Optional
from scripts.scrapers.config import PATHS
logger = logging.getLogger(__name__)
# Default vendor ID for scraped hotels (system vendor)
DEFAULT_VENDOR_ID = "000000000000000000000001"
HOTEL_AMENITY_NORMALIZE = {
"wifi": "Free WiFi",
"wi-fi": "Free WiFi",
"internet": "Free WiFi",
"pool": "Swimming Pool",
"swimming": "Swimming Pool",
"gym": "Gym",
"fitness": "Gym",
"spa": "Spa",
"restaurant": "Restaurant",
"bar": "Bar",
"parking": "Parking",
"airport": "Airport Shuttle",
"shuttle": "Airport Shuttle",
"breakfast": "Breakfast Included",
"air conditioning": "Air Conditioning",
"ac": "Air Conditioning",
"front desk": "24-hour Front Desk",
"24-hour": "24-hour Front Desk",
"laundry": "Laundry Service",
"concierge": "Concierge",
"business": "Business Center",
"meeting": "Meeting Rooms",
"room service": "Room Service",
}
def _slugify(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[\s_-]+", "-", text)
return text[:80]
def _normalize_amenities(raw_amenities: list) -> list:
"""Normalize amenity strings to standardized names."""
normalized = set()
for amenity in raw_amenities:
if not amenity:
continue
lower = amenity.lower()
matched = False
for keyword, standard in HOTEL_AMENITY_NORMALIZE.items():
if keyword in lower:
normalized.add(standard)
matched = True
break
if not matched and len(amenity) > 2:
normalized.add(amenity.title())
return sorted(list(normalized))[:15] # Cap at 15 amenities
def _convert_price_to_vnd(price_usd: Optional[float], exchange_rate: float = 25000) -> int:
"""Convert USD price/night to VND."""
if not price_usd:
return 1_000_000 # Default ~$40/night in VND
return int(price_usd * exchange_rate)
def normalize_hotel(raw: dict, index: int) -> Optional[dict]:
"""
Convert raw scraped hotel dict to backend hotels.json schema entry.
Returns None if minimum required fields are missing.
"""
name = (raw.get("name") or "").strip()
if not name or len(name) < 3:
return None
dest_id = raw.get("destination_id", "unknown")
city = raw.get("city", "")
country = raw.get("country", "")
exchange_rate = raw.get("exchange_rate", 25000)
lat = raw.get("lat")
lon = raw.get("lon")
if lat is None or lon is None:
# Skip hotels without coordinates (can't display on map)
return None
star_rating = raw.get("star_rating") or 3
star_rating = min(max(int(star_rating), 1), 5)
rating = raw.get("rating")
if rating is not None:
try:
rating = float(rating)
if rating > 5:
rating = round(rating / 2, 1)
rating = round(min(max(rating, 1.0), 5.0), 1)
except (TypeError, ValueError):
rating = 3.5
else:
rating = 3.5
price_usd = raw.get("price_per_night")
lowest_price_vnd = _convert_price_to_vnd(price_usd, exchange_rate)
amenities = _normalize_amenities(raw.get("amenities") or [])
images = raw.get("images") or []
if not images:
images = [] # No placeholder images
hotel_type = raw.get("property_type") or "HOTEL"
# Build locationId reference (will be created as new location)
location_id = f"loc_{_slugify(city)}_001"
return {
"hotelID": f"hotel_{dest_id}_{index:03d}",
"vendorId": DEFAULT_VENDOR_ID,
"locationId": location_id,
"city": city,
"country": country,
"name": name,
"slug": _slugify(name),
"hotelType": hotel_type,
"starRating": star_rating,
"address": (raw.get("address") or f"{city}, {country}").strip(),
"latitude": float(lat),
"longitude": float(lon),
"description": (raw.get("description") or f"Welcome to {name} in {city}.").strip(),
"shortDescription": f"{star_rating}-star {hotel_type.lower().replace('_', ' ')} in {city}",
"phone": raw.get("phone") or "",
"email": "",
"website": raw.get("website") or "",
"images": images[:5],
"amenities": amenities,
"policies": {
"cancellation": "Free cancellation up to 24 hours before check-in",
"pets": False,
"smoking": False,
},
"approvalStatus": "APPROVED",
"status": "ACTIVE",
"featured": rating >= 4.0 and star_rating >= 4,
"verified": True,
"averageRating": rating,
"totalReviews": raw.get("reviews_count") or 0,
"totalRooms": 50,
"lowestPrice": lowest_price_vnd,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
"_source": raw.get("source", "unknown"),
}
def save_hotels(scraped_cities: list) -> dict:
"""
Process all scraped hotel data, normalize, deduplicate by name+city,
and save to hotels_international.json.
"""
output_path = PATHS["hotels_backend"]
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Load existing if any
existing = []
if os.path.exists(output_path):
with open(output_path, "r", encoding="utf-8") as f:
existing = json.load(f)
existing_keys = {(h["name"].lower(), h.get("city", "").lower()) for h in existing}
added = 0
skipped = 0
index_start = len(existing)
for city_data in scraped_cities:
raw_hotels = city_data.get("hotels", [])
city_name = city_data.get("city", "")
for i, raw in enumerate(raw_hotels):
key = (raw.get("name", "").lower(), city_name.lower())
if key in existing_keys:
skipped += 1
continue
normalized = normalize_hotel(raw, index_start + added)
if normalized is None:
skipped += 1
continue
existing.append(normalized)
existing_keys.add(key)
added += 1
with open(output_path, "w", encoding="utf-8") as f:
json.dump(existing, f, ensure_ascii=False, indent=2)
stats = {
"total_hotels": len(existing),
"added": added,
"skipped": skipped,
"output_file": output_path,
}
logger.info(f"Hotel save: +{added} added, {skipped} skipped → {len(existing)} total → {output_path}")
return stats