sammy786 commited on
Commit
fdfa16a
·
1 Parent(s): e983f36

Hotels: stay totals, not nightly rates

Browse files
Files changed (2) hide show
  1. app/hotel_sellers.py +87 -15
  2. 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
- if total is None and night is None:
185
- continue
 
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
- "price_inr": total if total is not None else night,
 
 
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 = _amount(pr.get("total_rate"))
247
- night = _amount(pr.get("rate_per_night"))
248
- if not src or (total is None and night is None):
 
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": total if total is not None else night,
 
 
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 r in rows:
263
- k = r["channel_key"]
264
- if not k:
265
- continue
266
- cur = channel_prices.get(k)
267
- if cur is None or r["price_inr"] < cur:
268
- channel_prices[k] = r["price_inr"]
 
 
269
 
270
  payload = {
271
  "live": True,
272
  "source": "serpapi",
273
  "sellers": rows,
274
  "channel_prices": channel_prices,
275
- "priced_total_inr": min((r["price_inr"] for r in rows), default=None),
 
 
 
 
 
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
- for k in ("totalPrice", "nightlyPrice", "price"):
135
- v = p.get(k)
136
- if isinstance(v, (int, float)) and v > 0:
137
- return float(v)
 
 
 
 
 
 
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) -> Tuple[List[Dict], str]:
299
- hotels = [h for h in (_normalize(p, city) for p in props) if h]
 
 
 
 
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)"