Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Fix exact hotel deal ranking and cache concurrency
Browse files- app/flight_sellers.py +1 -1
- app/hotel_sellers.py +74 -12
- app/main.py +6 -5
app/flight_sellers.py
CHANGED
|
@@ -14,7 +14,7 @@ list Google shows under a fare (`booking_options`), each with the seller name,
|
|
| 14 |
its own price and a booking request URL.
|
| 15 |
|
| 16 |
Cost discipline: a search and a seller lookup are billed separately. The app
|
| 17 |
-
checks result rows through a bounded
|
| 18 |
each seller table by booking token for SELLERS_TTL_S. A repeated row/search
|
| 19 |
therefore reuses its exact quote rather than spending another upstream call.
|
| 20 |
|
|
|
|
| 14 |
its own price and a booking request URL.
|
| 15 |
|
| 16 |
Cost discipline: a search and a seller lookup are billed separately. The app
|
| 17 |
+
checks result rows through a bounded request pool, while this module caches
|
| 18 |
each seller table by booking token for SELLERS_TTL_S. A repeated row/search
|
| 19 |
therefore reuses its exact quote rather than spending another upstream call.
|
| 20 |
|
app/hotel_sellers.py
CHANGED
|
@@ -34,10 +34,18 @@ import hashlib
|
|
| 34 |
import json
|
| 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"
|
| 42 |
|
| 43 |
SELLERS_TTL_S = int(os.getenv("HOTEL_SELLERS_TTL_S", "3600")) # 1 hour
|
|
@@ -50,8 +58,8 @@ SELLERS_CACHE = os.getenv("HOTEL_SELLERS_CACHE", "/tmp/rp_hotel_sellers_cache.js
|
|
| 50 |
# "MakeMyTrip.com" unmapped, because the entry had been derived by the old code.
|
| 51 |
# The key now carries this version, so changing the logic changes the key and
|
| 52 |
# stale derivations can never be served as current.
|
| 53 |
-
CACHE_VERSION = "
|
| 54 |
-
HTTP_TIMEOUT_S = float(os.getenv("HOTEL_SELLERS_TIMEOUT_S", "
|
| 55 |
|
| 56 |
def _norm(name: str) -> str:
|
| 57 |
return re.sub(r"[^a-z0-9]", "", (name or "").lower())
|
|
@@ -100,6 +108,32 @@ def is_live() -> bool:
|
|
| 100 |
return bool(os.getenv("SERPAPI_KEY")) and os.getenv("HOTEL_SELLERS_MOCK") != "1"
|
| 101 |
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def _cache_load() -> Dict:
|
| 104 |
try:
|
| 105 |
with open(SELLERS_CACHE, "r", encoding="utf-8") as fh:
|
|
@@ -109,13 +143,40 @@ def _cache_load() -> Dict:
|
|
| 109 |
|
| 110 |
|
| 111 |
def _cache_save(data: Dict) -> None:
|
|
|
|
| 112 |
try:
|
| 113 |
now = time.time()
|
| 114 |
pruned = {k: v for k, v in data.items() if now - v.get("at", 0) < SELLERS_TTL_S}
|
| 115 |
-
|
|
|
|
|
|
|
| 116 |
json.dump(pruned, fh)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
except Exception:
|
| 118 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
|
| 121 |
def _params(city: str, checkin: str, checkout: str, adults: int) -> Dict:
|
|
@@ -233,8 +294,8 @@ def search_hotels(
|
|
| 233 |
out: List[Dict] = []
|
| 234 |
nights = _nights(checkin, checkout)
|
| 235 |
for p in (data.get("properties") or []):
|
| 236 |
-
total =
|
| 237 |
-
night =
|
| 238 |
stay, derived = _stay_total(total, night, nights)
|
| 239 |
if stay is None:
|
| 240 |
continue # neither a total nor enough to derive one
|
|
@@ -244,6 +305,7 @@ def search_hotels(
|
|
| 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 "",
|
|
@@ -257,9 +319,11 @@ def search_hotels(
|
|
| 257 |
"vendors": [],
|
| 258 |
"book_url": p.get("link"),
|
| 259 |
})
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
| 263 |
|
| 264 |
|
| 265 |
def sellers_for(
|
|
@@ -282,8 +346,7 @@ def sellers_for(
|
|
| 282 |
ck = hashlib.sha1(
|
| 283 |
f"v{CACHE_VERSION}|{property_token}|{checkin}|{checkout}|{adults}".encode()
|
| 284 |
).hexdigest()[:20]
|
| 285 |
-
|
| 286 |
-
hit = cache.get(ck)
|
| 287 |
if hit and time.time() - hit.get("at", 0) < SELLERS_TTL_S:
|
| 288 |
return dict(hit["payload"], source=hit["payload"].get("source", "serpapi") + " (cached)")
|
| 289 |
|
|
@@ -346,6 +409,5 @@ def sellers_for(
|
|
| 346 |
default=min((r["price_inr"] for r in rows), default=None),
|
| 347 |
),
|
| 348 |
}
|
| 349 |
-
|
| 350 |
-
_cache_save(cache)
|
| 351 |
return payload
|
|
|
|
| 34 |
import json
|
| 35 |
import os
|
| 36 |
import re
|
| 37 |
+
import tempfile
|
| 38 |
+
import threading
|
| 39 |
import time
|
| 40 |
+
from contextlib import contextmanager
|
| 41 |
from datetime import datetime
|
| 42 |
from typing import Dict, List, Optional, Tuple
|
| 43 |
|
| 44 |
+
try:
|
| 45 |
+
import fcntl
|
| 46 |
+
except ImportError: # pragma: no cover - production and supported dev are Unix
|
| 47 |
+
fcntl = None
|
| 48 |
+
|
| 49 |
SERPAPI_URL = "https://serpapi.com/search"
|
| 50 |
|
| 51 |
SELLERS_TTL_S = int(os.getenv("HOTEL_SELLERS_TTL_S", "3600")) # 1 hour
|
|
|
|
| 58 |
# "MakeMyTrip.com" unmapped, because the entry had been derived by the old code.
|
| 59 |
# The key now carries this version, so changing the logic changes the key and
|
| 60 |
# stale derivations can never be served as current.
|
| 61 |
+
CACHE_VERSION = "3" # v3: base rows carry tax basis; cache writes are serialized
|
| 62 |
+
HTTP_TIMEOUT_S = float(os.getenv("HOTEL_SELLERS_TIMEOUT_S", "45"))
|
| 63 |
|
| 64 |
def _norm(name: str) -> str:
|
| 65 |
return re.sub(r"[^a-z0-9]", "", (name or "").lower())
|
|
|
|
| 108 |
return bool(os.getenv("SERPAPI_KEY")) and os.getenv("HOTEL_SELLERS_MOCK") != "1"
|
| 109 |
|
| 110 |
|
| 111 |
+
_CACHE_THREAD_LOCK = threading.Lock()
|
| 112 |
+
_CACHE_LOCK_FILE = f"{SELLERS_CACHE}.lock"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@contextmanager
|
| 116 |
+
def _cache_guard():
|
| 117 |
+
"""Serialize JSON read/merge/write across threads and uvicorn workers."""
|
| 118 |
+
with _CACHE_THREAD_LOCK:
|
| 119 |
+
lock_fh = None
|
| 120 |
+
try:
|
| 121 |
+
lock_fh = open(_CACHE_LOCK_FILE, "a+", encoding="utf-8")
|
| 122 |
+
if fcntl is not None:
|
| 123 |
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
|
| 124 |
+
except OSError:
|
| 125 |
+
if lock_fh is not None:
|
| 126 |
+
lock_fh.close()
|
| 127 |
+
lock_fh = None
|
| 128 |
+
try:
|
| 129 |
+
yield
|
| 130 |
+
finally:
|
| 131 |
+
if lock_fh is not None:
|
| 132 |
+
if fcntl is not None:
|
| 133 |
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
|
| 134 |
+
lock_fh.close()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
def _cache_load() -> Dict:
|
| 138 |
try:
|
| 139 |
with open(SELLERS_CACHE, "r", encoding="utf-8") as fh:
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
def _cache_save(data: Dict) -> None:
|
| 146 |
+
temp_path = None
|
| 147 |
try:
|
| 148 |
now = time.time()
|
| 149 |
pruned = {k: v for k, v in data.items() if now - v.get("at", 0) < SELLERS_TTL_S}
|
| 150 |
+
cache_dir = os.path.dirname(SELLERS_CACHE) or "."
|
| 151 |
+
fd, temp_path = tempfile.mkstemp(prefix=".rp-hotel-cache-", dir=cache_dir)
|
| 152 |
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
| 153 |
json.dump(pruned, fh)
|
| 154 |
+
fh.flush()
|
| 155 |
+
os.fsync(fh.fileno())
|
| 156 |
+
os.replace(temp_path, SELLERS_CACHE)
|
| 157 |
+
temp_path = None
|
| 158 |
except Exception:
|
| 159 |
pass
|
| 160 |
+
finally:
|
| 161 |
+
if temp_path:
|
| 162 |
+
try:
|
| 163 |
+
os.unlink(temp_path)
|
| 164 |
+
except OSError:
|
| 165 |
+
pass
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _cache_get(key: str) -> Optional[Dict]:
|
| 169 |
+
with _cache_guard():
|
| 170 |
+
return _cache_load().get(key)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _cache_put(key: str, payload: Dict) -> None:
|
| 174 |
+
# Merge into the latest image while holding the cross-worker lock. Without
|
| 175 |
+
# this, simultaneous whole-list quote calls overwrite one another's token.
|
| 176 |
+
with _cache_guard():
|
| 177 |
+
cache = _cache_load()
|
| 178 |
+
cache[key] = {"at": time.time(), "payload": payload}
|
| 179 |
+
_cache_save(cache)
|
| 180 |
|
| 181 |
|
| 182 |
def _params(city: str, checkin: str, checkout: str, adults: int) -> Dict:
|
|
|
|
| 294 |
out: List[Dict] = []
|
| 295 |
nights = _nights(checkin, checkout)
|
| 296 |
for p in (data.get("properties") or []):
|
| 297 |
+
total, total_taxed = _amount_taxed(p.get("total_rate"))
|
| 298 |
+
night, night_taxed = _amount_taxed(p.get("rate_per_night"))
|
| 299 |
stay, derived = _stay_total(total, night, nights)
|
| 300 |
if stay is None:
|
| 301 |
continue # neither a total nor enough to derive one
|
|
|
|
| 305 |
# A per-night rate is multiplied out, never substituted.
|
| 306 |
"price_inr": stay,
|
| 307 |
"total_derived": derived,
|
| 308 |
+
"taxes_included": total_taxed if total is not None else night_taxed,
|
| 309 |
"nightly_inr": night,
|
| 310 |
"currency": "INR",
|
| 311 |
"name": p.get("name") or "",
|
|
|
|
| 319 |
"vendors": [],
|
| 320 |
"book_url": p.get("link"),
|
| 321 |
})
|
| 322 |
+
# Google orders properties by its relevance blend. RewardPilot's base list
|
| 323 |
+
# promises cheapest-stay-first, so sort explicitly before applying the scan
|
| 324 |
+
# cap; truncating first can discard a cheaper property later in the page.
|
| 325 |
+
out.sort(key=lambda h: float(h["price_inr"]))
|
| 326 |
+
return out[:max_results], "serpapi" if out else "serpapi (no properties)"
|
| 327 |
|
| 328 |
|
| 329 |
def sellers_for(
|
|
|
|
| 346 |
ck = hashlib.sha1(
|
| 347 |
f"v{CACHE_VERSION}|{property_token}|{checkin}|{checkout}|{adults}".encode()
|
| 348 |
).hexdigest()[:20]
|
| 349 |
+
hit = _cache_get(ck)
|
|
|
|
| 350 |
if hit and time.time() - hit.get("at", 0) < SELLERS_TTL_S:
|
| 351 |
return dict(hit["payload"], source=hit["payload"].get("source", "serpapi") + " (cached)")
|
| 352 |
|
|
|
|
| 409 |
default=min((r["price_inr"] for r in rows), default=None),
|
| 410 |
),
|
| 411 |
}
|
| 412 |
+
_cache_put(ck, payload)
|
|
|
|
| 413 |
return payload
|
app/main.py
CHANGED
|
@@ -631,12 +631,12 @@ def flights_sellers(req: FlightSellersRequest, request: Request = None):
|
|
| 631 |
platform with the loudest discount.
|
| 632 |
|
| 633 |
Billed per call upstream. The client checks a result list through a bounded
|
| 634 |
-
|
| 635 |
interactive while exact post-offer ranking is assembled.
|
| 636 |
"""
|
| 637 |
if request is not None:
|
| 638 |
# One round-trip search can contain 20 outbound + 20 return rows. The
|
| 639 |
-
# client
|
| 640 |
# quickly; allow one complete scan without self-rate-limiting it.
|
| 641 |
_limit(request, "fls", rate=60) # 60/min per IP per worker
|
| 642 |
import flight_sellers
|
|
@@ -798,11 +798,12 @@ def hotels_sellers(req: HotelSellersRequest, request: Request = None):
|
|
| 798 |
hotel itself sold at 11,025. No card reward closes a gap like that, which is
|
| 799 |
why the platform has to be priced and not just the card.
|
| 800 |
|
| 801 |
-
Billed per call upstream
|
| 802 |
-
|
|
|
|
| 803 |
"""
|
| 804 |
if request is not None:
|
| 805 |
-
_limit(request, "hts", rate=
|
| 806 |
import hotel_sellers
|
| 807 |
from channels import compare_channels, hotel_brand_key
|
| 808 |
from availability import normalize_city
|
|
|
|
| 631 |
platform with the loudest discount.
|
| 632 |
|
| 633 |
Billed per call upstream. The client checks a result list through a bounded
|
| 634 |
+
request pool and reuses this endpoint's token cache, so base fares stay
|
| 635 |
interactive while exact post-offer ranking is assembled.
|
| 636 |
"""
|
| 637 |
if request is not None:
|
| 638 |
# One round-trip search can contain 20 outbound + 20 return rows. The
|
| 639 |
+
# client bounds concurrency, but cache hits can finish
|
| 640 |
# quickly; allow one complete scan without self-rate-limiting it.
|
| 641 |
_limit(request, "fls", rate=60) # 60/min per IP per worker
|
| 642 |
import flight_sellers
|
|
|
|
| 798 |
hotel itself sold at 11,025. No card reward closes a gap like that, which is
|
| 799 |
why the platform has to be priced and not just the card.
|
| 800 |
|
| 801 |
+
Billed per call upstream. The client checks its capped result list through a
|
| 802 |
+
bounded queue and this endpoint caches by property/dates, so post-offer
|
| 803 |
+
ranking never borrows one property's rate table for another.
|
| 804 |
"""
|
| 805 |
if request is not None:
|
| 806 |
+
_limit(request, "hts", rate=60) # one capped whole-list scan + fast retry/min
|
| 807 |
import hotel_sellers
|
| 808 |
from channels import compare_channels, hotel_brand_key
|
| 809 |
from availability import normalize_city
|