Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Hotels: stay totals, not nightly rates
Browse files- app/hotel_sellers.py +87 -15
- app/hotels_provider.py +41 -11
app/hotel_sellers.py
CHANGED
|
@@ -35,6 +35,7 @@ import json
|
|
| 35 |
import os
|
| 36 |
import re
|
| 37 |
import time
|
|
|
|
| 38 |
from typing import Dict, List, Optional, Tuple
|
| 39 |
|
| 40 |
SERPAPI_URL = "https://serpapi.com/search"
|
|
@@ -139,6 +140,17 @@ def _get(params: Dict) -> Dict:
|
|
| 139 |
return r.json()
|
| 140 |
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
def _amount(block: Optional[Dict]) -> Optional[float]:
|
| 143 |
"""Google returns {'lowest': '₹19,382', 'extracted_lowest': 19382}."""
|
| 144 |
if not isinstance(block, dict):
|
|
@@ -152,6 +164,47 @@ def _amount(block: Optional[Dict]) -> Optional[float]:
|
|
| 152 |
return None
|
| 153 |
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
def _is_direct(source: str, hotel_name: str) -> bool:
|
| 156 |
"""Google lists the hotel's own site under the property's name (and the
|
| 157 |
occasional 'Official site'). Treat that as booking direct."""
|
|
@@ -178,15 +231,19 @@ def search_hotels(
|
|
| 178 |
return [], f"serpapi error ({data['error']})"
|
| 179 |
|
| 180 |
out: List[Dict] = []
|
|
|
|
| 181 |
for p in (data.get("properties") or []):
|
| 182 |
total = _amount(p.get("total_rate"))
|
| 183 |
night = _amount(p.get("rate_per_night"))
|
| 184 |
-
|
| 185 |
-
|
|
|
|
| 186 |
out.append({
|
| 187 |
# price_inr is the STAY TOTAL, which is what the card is charged and
|
| 188 |
# therefore what any offer minimum or cap must be judged against.
|
| 189 |
-
|
|
|
|
|
|
|
| 190 |
"nightly_inr": night,
|
| 191 |
"currency": "INR",
|
| 192 |
"name": p.get("name") or "",
|
|
@@ -240,39 +297,54 @@ def sellers_for(
|
|
| 240 |
return dict(empty, source=f"serpapi error ({data['error']})")
|
| 241 |
|
| 242 |
hotel_name = data.get("name") or ""
|
|
|
|
| 243 |
rows: List[Dict] = []
|
| 244 |
for pr in (data.get("prices") or []):
|
| 245 |
src = pr.get("source") or ""
|
| 246 |
-
total =
|
| 247 |
-
night =
|
| 248 |
-
|
|
|
|
| 249 |
continue
|
| 250 |
key = "direct" if _is_direct(src, hotel_name) else channel_for_vendor(src)
|
| 251 |
rows.append({
|
| 252 |
"seller": src,
|
| 253 |
"channel_key": key, # None => a vendor outside our set
|
| 254 |
-
"price_inr":
|
|
|
|
|
|
|
| 255 |
"nightly_inr": night,
|
| 256 |
"book_url": pr.get("link") or None,
|
| 257 |
})
|
| 258 |
if not rows:
|
| 259 |
return dict(empty, source="serpapi (no vendor prices)")
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
channel_prices: Dict[str, float] = {}
|
| 262 |
-
for
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
| 269 |
|
| 270 |
payload = {
|
| 271 |
"live": True,
|
| 272 |
"source": "serpapi",
|
| 273 |
"sellers": rows,
|
| 274 |
"channel_prices": channel_prices,
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
}
|
| 277 |
cache[ck] = {"at": time.time(), "payload": payload}
|
| 278 |
_cache_save(cache)
|
|
|
|
| 35 |
import os
|
| 36 |
import re
|
| 37 |
import time
|
| 38 |
+
from datetime import datetime
|
| 39 |
from typing import Dict, List, Optional, Tuple
|
| 40 |
|
| 41 |
SERPAPI_URL = "https://serpapi.com/search"
|
|
|
|
| 140 |
return r.json()
|
| 141 |
|
| 142 |
|
| 143 |
+
def _nights(checkin: str, checkout: str) -> Optional[int]:
|
| 144 |
+
"""Nights in the stay, or None when the dates are unusable."""
|
| 145 |
+
try:
|
| 146 |
+
a = datetime.strptime(checkin, "%Y-%m-%d").date()
|
| 147 |
+
b = datetime.strptime(checkout, "%Y-%m-%d").date()
|
| 148 |
+
except (ValueError, TypeError):
|
| 149 |
+
return None
|
| 150 |
+
n = (b - a).days
|
| 151 |
+
return n if n > 0 else None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
def _amount(block: Optional[Dict]) -> Optional[float]:
|
| 155 |
"""Google returns {'lowest': '₹19,382', 'extracted_lowest': 19382}."""
|
| 156 |
if not isinstance(block, dict):
|
|
|
|
| 164 |
return None
|
| 165 |
|
| 166 |
|
| 167 |
+
def _amount_taxed(block: Optional[Dict]) -> Tuple[Optional[float], bool]:
|
| 168 |
+
"""Same figure, plus whether it INCLUDES taxes and fees.
|
| 169 |
+
|
| 170 |
+
extracted_lowest is the with-tax figure; extracted_before_taxes_fees is
|
| 171 |
+
not. Returning them through one function meant a before-tax quote and a
|
| 172 |
+
with-tax quote were compared as equals in the same min(), quietly
|
| 173 |
+
favouring whichever vendor happened to omit its taxes.
|
| 174 |
+
"""
|
| 175 |
+
if not isinstance(block, dict):
|
| 176 |
+
return None, False
|
| 177 |
+
v = block.get("extracted_lowest")
|
| 178 |
+
if v is not None:
|
| 179 |
+
try:
|
| 180 |
+
return float(v), True
|
| 181 |
+
except (TypeError, ValueError):
|
| 182 |
+
return None, False
|
| 183 |
+
v = block.get("extracted_before_taxes_fees")
|
| 184 |
+
try:
|
| 185 |
+
return (float(v), False) if v is not None else (None, False)
|
| 186 |
+
except (TypeError, ValueError):
|
| 187 |
+
return None, False
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _stay_total(total: Optional[float], night: Optional[float], nights: Optional[int]):
|
| 191 |
+
"""The STAY TOTAL, and whether we had to derive it.
|
| 192 |
+
|
| 193 |
+
NEVER substitutes a per-NIGHT rate for a stay total. That substitution was
|
| 194 |
+
a silent 5x error on a five-night stay: the field is documented as the
|
| 195 |
+
total, channel_prices takes min() across vendors, so the ONE vendor that
|
| 196 |
+
omitted total_rate became "cheapest" by a factor of the night count - and
|
| 197 |
+
that figure then became the base every offer minimum and cap was judged
|
| 198 |
+
against. When total_rate is missing we multiply out instead, and say so;
|
| 199 |
+
when we cannot even do that, the caller drops the row.
|
| 200 |
+
"""
|
| 201 |
+
if total is not None:
|
| 202 |
+
return total, False
|
| 203 |
+
if night is not None and nights:
|
| 204 |
+
return round(night * nights, 2), True
|
| 205 |
+
return None, False
|
| 206 |
+
|
| 207 |
+
|
| 208 |
def _is_direct(source: str, hotel_name: str) -> bool:
|
| 209 |
"""Google lists the hotel's own site under the property's name (and the
|
| 210 |
occasional 'Official site'). Treat that as booking direct."""
|
|
|
|
| 231 |
return [], f"serpapi error ({data['error']})"
|
| 232 |
|
| 233 |
out: List[Dict] = []
|
| 234 |
+
nights = _nights(checkin, checkout)
|
| 235 |
for p in (data.get("properties") or []):
|
| 236 |
total = _amount(p.get("total_rate"))
|
| 237 |
night = _amount(p.get("rate_per_night"))
|
| 238 |
+
stay, derived = _stay_total(total, night, nights)
|
| 239 |
+
if stay is None:
|
| 240 |
+
continue # neither a total nor enough to derive one
|
| 241 |
out.append({
|
| 242 |
# price_inr is the STAY TOTAL, which is what the card is charged and
|
| 243 |
# therefore what any offer minimum or cap must be judged against.
|
| 244 |
+
# A per-night rate is multiplied out, never substituted.
|
| 245 |
+
"price_inr": stay,
|
| 246 |
+
"total_derived": derived,
|
| 247 |
"nightly_inr": night,
|
| 248 |
"currency": "INR",
|
| 249 |
"name": p.get("name") or "",
|
|
|
|
| 297 |
return dict(empty, source=f"serpapi error ({data['error']})")
|
| 298 |
|
| 299 |
hotel_name = data.get("name") or ""
|
| 300 |
+
nights = _nights(checkin, checkout)
|
| 301 |
rows: List[Dict] = []
|
| 302 |
for pr in (data.get("prices") or []):
|
| 303 |
src = pr.get("source") or ""
|
| 304 |
+
total, total_taxed = _amount_taxed(pr.get("total_rate"))
|
| 305 |
+
night, night_taxed = _amount_taxed(pr.get("rate_per_night"))
|
| 306 |
+
stay, derived = _stay_total(total, night, nights)
|
| 307 |
+
if not src or stay is None:
|
| 308 |
continue
|
| 309 |
key = "direct" if _is_direct(src, hotel_name) else channel_for_vendor(src)
|
| 310 |
rows.append({
|
| 311 |
"seller": src,
|
| 312 |
"channel_key": key, # None => a vendor outside our set
|
| 313 |
+
"price_inr": stay,
|
| 314 |
+
"total_derived": derived, # multiplied out from the nightly rate
|
| 315 |
+
"taxes_included": total_taxed if total is not None else night_taxed,
|
| 316 |
"nightly_inr": night,
|
| 317 |
"book_url": pr.get("link") or None,
|
| 318 |
})
|
| 319 |
if not rows:
|
| 320 |
return dict(empty, source="serpapi (no vendor prices)")
|
| 321 |
|
| 322 |
+
# COMPARE LIKE WITH LIKE. A with-tax quote and a before-tax quote are not
|
| 323 |
+
# the same number, so a channel is priced from its tax-inclusive rows when
|
| 324 |
+
# it has any, and only falls back to before-tax when that is all it has -
|
| 325 |
+
# otherwise the vendor that omits its taxes wins on a technicality.
|
| 326 |
channel_prices: Dict[str, float] = {}
|
| 327 |
+
for taxed_only in (True, False):
|
| 328 |
+
for r in rows:
|
| 329 |
+
k = r["channel_key"]
|
| 330 |
+
if not k or (taxed_only and not r["taxes_included"]):
|
| 331 |
+
continue
|
| 332 |
+
if taxed_only or k not in channel_prices:
|
| 333 |
+
cur = channel_prices.get(k)
|
| 334 |
+
if cur is None or r["price_inr"] < cur:
|
| 335 |
+
channel_prices[k] = r["price_inr"]
|
| 336 |
|
| 337 |
payload = {
|
| 338 |
"live": True,
|
| 339 |
"source": "serpapi",
|
| 340 |
"sellers": rows,
|
| 341 |
"channel_prices": channel_prices,
|
| 342 |
+
# Same like-with-like rule as channel_prices: the headline "cheapest"
|
| 343 |
+
# must not be a before-tax quote beating with-tax ones.
|
| 344 |
+
"priced_total_inr": min(
|
| 345 |
+
(r["price_inr"] for r in rows if r["taxes_included"]),
|
| 346 |
+
default=min((r["price_inr"] for r in rows), default=None),
|
| 347 |
+
),
|
| 348 |
}
|
| 349 |
cache[ck] = {"at": time.time(), "payload": payload}
|
| 350 |
_cache_save(cache)
|
app/hotels_provider.py
CHANGED
|
@@ -36,6 +36,7 @@ Environment (never hardcoded):
|
|
| 36 |
import os
|
| 37 |
import json
|
| 38 |
import time
|
|
|
|
| 39 |
from typing import List, Optional, Dict, Tuple
|
| 40 |
from urllib.parse import quote
|
| 41 |
|
|
@@ -129,15 +130,40 @@ def _vendor_label(platform: str) -> str:
|
|
| 129 |
return _VENDOR_LABEL.get((platform or "").lower(), (platform or "OTA").title())
|
| 130 |
|
| 131 |
|
| 132 |
-
def _price_of(prop: Dict) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
p = prop.get("price") or {}
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
return 0.0
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
def _rating5(prop: Dict) -> float:
|
| 142 |
"""Normalize guestRating to a 0-5 scale (StayingAPI uses ratingScale, often 10)."""
|
| 143 |
r = prop.get("guestRating")
|
|
@@ -150,10 +176,10 @@ def _rating5(prop: Dict) -> float:
|
|
| 150 |
return 0.0
|
| 151 |
|
| 152 |
|
| 153 |
-
def _normalize(prop: Dict, fallback_city: str) -> Optional[Dict]:
|
| 154 |
if not isinstance(prop, dict) or not prop.get("name"):
|
| 155 |
return None
|
| 156 |
-
price = _price_of(prop)
|
| 157 |
if price <= 0:
|
| 158 |
return None # dated price is best-effort; can't score without one
|
| 159 |
loc = prop.get("location") or {}
|
|
@@ -266,7 +292,7 @@ def search_hotels(
|
|
| 266 |
# 1) fresh completed result cached -> instant
|
| 267 |
hit = cache["search"].get(skey)
|
| 268 |
if hit and (time.time() - hit.get("at", 0)) < ttl:
|
| 269 |
-
return _finish(hit.get("data") or [], city, max_results, "stayingapi (cached)")
|
| 270 |
|
| 271 |
# 2) a job is already in flight for this exact search -> resume it (no new credits)
|
| 272 |
job = cache["jobs"].get(skey)
|
|
@@ -285,7 +311,7 @@ def search_hotels(
|
|
| 285 |
cache["search"][skey] = {"at": time.time(), "data": payload or []}
|
| 286 |
cache["jobs"].pop(skey, None)
|
| 287 |
_save_cache(cache)
|
| 288 |
-
return _finish(payload or [], city, max_results, "stayingapi")
|
| 289 |
if state == "pending":
|
| 290 |
# still scraping; the jobId is saved so the next call resumes the same job
|
| 291 |
return _mock_hotels(city, checkin, checkout, adults), "sample (stayingapi still scraping - retry shortly)"
|
|
@@ -295,8 +321,12 @@ def search_hotels(
|
|
| 295 |
return _mock_hotels(city, checkin, checkout, adults), f"sample (stayingapi {payload})"
|
| 296 |
|
| 297 |
|
| 298 |
-
def _finish(props: List[Dict], city: str, max_results: int, source: str
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
hotels.sort(key=lambda h: h["price_inr"])
|
| 301 |
if not hotels:
|
| 302 |
return _mock_hotels(city, "", "", 2), "sample (stayingapi empty)"
|
|
|
|
| 36 |
import os
|
| 37 |
import json
|
| 38 |
import time
|
| 39 |
+
from datetime import datetime
|
| 40 |
from typing import List, Optional, Dict, Tuple
|
| 41 |
from urllib.parse import quote
|
| 42 |
|
|
|
|
| 130 |
return _VENDOR_LABEL.get((platform or "").lower(), (platform or "OTA").title())
|
| 131 |
|
| 132 |
|
| 133 |
+
def _price_of(prop: Dict, nights: Optional[int] = None) -> float:
|
| 134 |
+
"""The STAY TOTAL for this property.
|
| 135 |
+
|
| 136 |
+
nightlyPrice used to be accepted as a total whenever totalPrice was
|
| 137 |
+
missing, which understated that property by the night count and let it win
|
| 138 |
+
a cheapest-vendor comparison it should have lost - the same substitution
|
| 139 |
+
fixed in hotel_sellers._stay_total. A nightly figure is now multiplied out
|
| 140 |
+
by the stay length; with no usable dates it is refused rather than passed
|
| 141 |
+
off as a total.
|
| 142 |
+
"""
|
| 143 |
p = prop.get("price") or {}
|
| 144 |
+
v = p.get("totalPrice")
|
| 145 |
+
if isinstance(v, (int, float)) and v > 0:
|
| 146 |
+
return float(v)
|
| 147 |
+
v = p.get("nightlyPrice")
|
| 148 |
+
if isinstance(v, (int, float)) and v > 0:
|
| 149 |
+
return round(float(v) * nights, 2) if nights else 0.0
|
| 150 |
+
# "price" is unlabelled; trust it only as a last resort, as a total.
|
| 151 |
+
v = p.get("price")
|
| 152 |
+
if isinstance(v, (int, float)) and v > 0:
|
| 153 |
+
return float(v)
|
| 154 |
return 0.0
|
| 155 |
|
| 156 |
|
| 157 |
+
def _nights_between(checkin: str, checkout: str) -> Optional[int]:
|
| 158 |
+
try:
|
| 159 |
+
a = datetime.strptime(checkin, "%Y-%m-%d").date()
|
| 160 |
+
b = datetime.strptime(checkout, "%Y-%m-%d").date()
|
| 161 |
+
except (ValueError, TypeError):
|
| 162 |
+
return None
|
| 163 |
+
n = (b - a).days
|
| 164 |
+
return n if n > 0 else None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
def _rating5(prop: Dict) -> float:
|
| 168 |
"""Normalize guestRating to a 0-5 scale (StayingAPI uses ratingScale, often 10)."""
|
| 169 |
r = prop.get("guestRating")
|
|
|
|
| 176 |
return 0.0
|
| 177 |
|
| 178 |
|
| 179 |
+
def _normalize(prop: Dict, fallback_city: str, nights: Optional[int] = None) -> Optional[Dict]:
|
| 180 |
if not isinstance(prop, dict) or not prop.get("name"):
|
| 181 |
return None
|
| 182 |
+
price = _price_of(prop, nights)
|
| 183 |
if price <= 0:
|
| 184 |
return None # dated price is best-effort; can't score without one
|
| 185 |
loc = prop.get("location") or {}
|
|
|
|
| 292 |
# 1) fresh completed result cached -> instant
|
| 293 |
hit = cache["search"].get(skey)
|
| 294 |
if hit and (time.time() - hit.get("at", 0)) < ttl:
|
| 295 |
+
return _finish(hit.get("data") or [], city, max_results, "stayingapi (cached)", checkin, checkout)
|
| 296 |
|
| 297 |
# 2) a job is already in flight for this exact search -> resume it (no new credits)
|
| 298 |
job = cache["jobs"].get(skey)
|
|
|
|
| 311 |
cache["search"][skey] = {"at": time.time(), "data": payload or []}
|
| 312 |
cache["jobs"].pop(skey, None)
|
| 313 |
_save_cache(cache)
|
| 314 |
+
return _finish(payload or [], city, max_results, "stayingapi", checkin, checkout)
|
| 315 |
if state == "pending":
|
| 316 |
# still scraping; the jobId is saved so the next call resumes the same job
|
| 317 |
return _mock_hotels(city, checkin, checkout, adults), "sample (stayingapi still scraping - retry shortly)"
|
|
|
|
| 321 |
return _mock_hotels(city, checkin, checkout, adults), f"sample (stayingapi {payload})"
|
| 322 |
|
| 323 |
|
| 324 |
+
def _finish(props: List[Dict], city: str, max_results: int, source: str,
|
| 325 |
+
checkin: str = "", checkout: str = "") -> Tuple[List[Dict], str]:
|
| 326 |
+
# The stay length, so a nightly-only quote can be multiplied out instead of
|
| 327 |
+
# masquerading as a total.
|
| 328 |
+
nights = _nights_between(checkin, checkout)
|
| 329 |
+
hotels = [h for h in (_normalize(p, city, nights) for p in props) if h]
|
| 330 |
hotels.sort(key=lambda h: h["price_inr"])
|
| 331 |
if not hotels:
|
| 332 |
return _mock_hotels(city, "", "", 2), "sample (stayingapi empty)"
|