rewardpilot-api / app /dining_availability.py
sammy786's picture
Deploy current accuracy engine, rich content APIs, and verified offer gate
9acbdec
Raw History Blame Contribute Delete
19 kB
"""
Restaurant-specific dine-in platform discovery.
City coverage is not restaurant coverage: the fact that a dining platform
operates in Mumbai does not prove that a particular Taftoon branch can take
payment there. This module searches every supported platform's public,
indexed restaurant pages and returns the evidence URL. A missing result is
deliberately reported as ``not_found`` (not ``unavailable``), because search
absence cannot prove that a restaurant is absent from a logged-in app.
The app uses only ``available`` rows in its savings calculation. Offer text in
the search result is carried as a labelled lead and is never priced: table/time
slot offers change and may not stack with a bank offer.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Dict, List, Optional
from urllib.parse import urlparse
try:
import fcntl
except ImportError: # pragma: no cover - production and supported dev are Unix
fcntl = None
SERPAPI_URL = "https://serpapi.com/search"
HTTP_TIMEOUT_S = float(os.getenv("DINING_AVAILABILITY_TIMEOUT_S", "12"))
CACHE_TTL_S = int(os.getenv("DINING_AVAILABILITY_TTL_S", str(4 * 3600)))
CACHE_FILE = os.getenv("DINING_AVAILABILITY_CACHE", "/tmp/rp_dining_availability_cache.json")
CACHE_VERSION = "5" # v5: canonical identity match + exact-page verification
PLATFORMS = {
"dineout": {
"name": "Swiggy Dineout",
"domain": "swiggy.com",
"path": "/restaurants/",
"marker": "dineout",
"query_site": "site:swiggy.com/restaurants",
"query_label": "Swiggy Dineout bank offer discount",
},
"district": {
"name": "District (Zomato)",
"domain": "district.in",
"path": "/dining/",
"marker": "dining",
"query_site": "site:district.in/dining",
"query_label": "District dining bank offer discount",
},
"eazydiner": {
"name": "EazyDiner / PayEazy",
"domain": "eazydiner.com",
"path": "/",
"marker": "",
"query_site": "site:eazydiner.com",
"query_label": "EazyDiner PayEazy restaurant offer discount",
# A review page alone does not prove that a diner can book/pay for an
# offer. Search text must also expose a booking/payment/deal capability.
"needs_offer_marker": True,
"page_marker": re.compile(
r"pay\s*eazy|payment\s+offer|get\s+(?:up\s*to|upto)\s+\d+(?:\.\d+)?\s*%\s*off",
re.I,
),
},
"magicpin": {
"name": "magicpin",
"domain": "magicpin.in",
"path": "/restaurant/",
"marker": "/store/",
"query_site": "site:magicpin.in",
"query_label": "magicpin dine in magicpay voucher offer discount",
"needs_offer_marker": True,
},
"nearbuy": {
"name": "nearbuy",
"domain": "nearbuy.com",
"path": "/",
"marker": "",
"query_site": "site:nearbuy.com",
"query_label": "nearbuy restaurant walk-in deal voucher offer discount",
"needs_offer_marker": True,
},
}
_PAYMENT_MARKER_RE = re.compile(
r"\b(?:pay\s*eazy|magicpay|voucher|deal|offer|"
r"discount|cashback|save|walk[ -]?in|\d+(?:\.\d+)?\s*%\s*off)\b",
re.I,
)
def _http_client(**kwargs):
import httpx
return httpx.Client(**kwargs)
_NAME_STOP = {
"the", "and", "restaurant", "restaurants", "bar", "kitchen", "cafe",
"coffee", "house", "eatery", "dining", "lounge", "bistro", "grill",
}
_OFFER_RE = re.compile(
r"(?:\b(?:flat\s+|up\s*to\s+|upto\s+)?\d+(?:\.\d+)?\s*%\s*(?:off|discount|cashback)"
r"(?:\s+up\s+to\s+(?:₹|rs\.?\s*)\s*[\d,]+)?"
r"|\b(?:flat\s+)?(?:₹|rs\.?\s*)\s*[\d,]+\s*(?:off|discount|cashback)\b"
r"|\bcashback\s+\d+(?:\.\d+)?\s*%)",
re.I,
)
_ADDRESS_STOP = {
"road", "rd", "street", "st", "opposite", "opp", "near", "block",
"floor", "unit", "shop", "maharashtra", "india", "building", "centre",
"center", "west", "east", "north", "south", "bank", "sidbi", "of",
}
def _compact(value: str) -> str:
return re.sub(r"[^a-z0-9]", "", (value or "").lower())
def _tokens(value: str) -> List[str]:
return [
token for token in re.findall(r"[a-z0-9]+", (value or "").lower())
if token not in _NAME_STOP and len(token) > 1
]
def _restaurant_match(restaurant: str, haystack: str) -> bool:
"""Conservative restaurant identity match for a search result.
The compact full name is preferred. The token fallback handles provider
titles that omit generic suffixes such as "Bar & Kitchen".
"""
compact_name = _compact(restaurant)
compact_hay = _compact(haystack)
if len(compact_name) >= 4 and compact_name in compact_hay:
return True
tokens = _tokens(restaurant)
if not tokens:
return False
hay_tokens = set(_tokens(haystack))
return all(token in hay_tokens for token in tokens)
def _platform_url(channel_key: str, link: str) -> bool:
cfg = PLATFORMS[channel_key]
try:
parsed = urlparse(link)
except Exception:
return False
host = parsed.netloc.lower().split(":")[0]
path = parsed.path.lower()
if host != cfg["domain"] and not host.endswith("." + cfg["domain"]):
return False
if cfg["path"] not in path:
return False
if cfg.get("marker") and cfg["marker"] not in path:
return False
# Each platform also indexes category, editorial, menu and brand pages.
# Accept only the canonical shape of one restaurant branch.
if channel_key == "eazydiner":
parts = [p for p in path.split("/") if p]
if len(parts) != 2 or not re.search(r"-\d{5,}$", parts[-1]):
return False
if channel_key == "magicpin" and not re.search(
r"/restaurant/[^/]+/store/[a-z0-9]+/?$", path, re.I
):
return False
if channel_key == "nearbuy":
parts = [p for p in path.split("/") if p]
if len(parts) != 2 or parts[1] in {
"clp", "collection", "location", "all-offers", "c",
}:
return False
return True
def _city_match(city: str, haystack: str) -> bool:
city_tokens = _tokens(city)
if not city_tokens:
return False
hay_tokens = set(_tokens(haystack))
aliases = {
"mumbai": {"mumbai", "bombay"},
"bengaluru": {"bengaluru", "bangalore"},
"delhi": {"delhi", "gurgaon", "gurugram", "noida", "ncr"},
"kolkata": {"kolkata", "calcutta"},
"chennai": {"chennai", "madras"},
"kochi": {"kochi", "cochin"},
}
wanted = aliases.get(city_tokens[0], {city_tokens[0]})
return bool(wanted & hay_tokens)
def _offer_texts(text: str) -> List[str]:
found = []
seen = set()
for match in _OFFER_RE.finditer(text or ""):
value = " ".join(match.group(0).replace("*", "").split())
key = value.casefold()
if key in seen:
continue
seen.add(key)
found.append(value)
if len(found) >= 3:
break
return found
def _address_hint(address: Optional[str], city: str) -> str:
"""Best locality-like component from a provider address.
Full postal addresses make Google queries brittle. Prefer a comma-separated
component with several meaningful words ("Bandra Kurla Complex"), excluding
state, road and unit fragments. The hint is also used to reject the wrong
branch when several restaurants share a name in one city.
"""
city_tokens = set(_tokens(city))
choices = []
parts = (address or "").split(",")
# Google commonly starts a long address with the venue/building itself;
# that is not the locality that platform URLs use.
if len(parts) >= 4:
parts = parts[1:]
for part in parts:
raw = " ".join(part.split())
tokens = [t for t in _tokens(raw) if t not in _ADDRESS_STOP and t not in city_tokens and not t.isdigit()]
if not tokens or any(char.isdigit() for char in raw):
continue
choices.append((len(tokens), len(raw), raw))
return max(choices, default=(0, 0, ""))[2]
def _branch_match(address: Optional[str], city: str, haystack: str) -> bool:
hint = _address_hint(address, city)
if not hint:
return True
wanted = [t for t in _tokens(hint) if t not in _ADDRESS_STOP]
if wanted == ["bkc"]:
wanted = ["bandra", "kurla"]
hay_tokens = set(_tokens(haystack))
overlap = sum(1 for token in wanted if token in hay_tokens)
# One-word localities (Powai, BKC) must match exactly. Longer hints need at
# least two tokens, preventing "Taftoon Powai" from proving the BKC branch.
return overlap >= min(2, len(wanted))
def parse_platform_results(
channel_key: str, restaurant: str, city: str, data: Dict,
address: Optional[str] = None,
) -> Dict:
"""Pick the first direct listing that proves restaurant + city + platform."""
listed_without_payment = None
for row in data.get("organic_results") or []:
link = str(row.get("link") or "").strip()
title = " ".join(str(row.get("title") or "").split())
snippet = " ".join(str(row.get("snippet") or row.get("description") or "").split())
haystack = " ".join((title, snippet, link))
if not _platform_url(channel_key, link):
continue
# Search snippets often contain nearby/related restaurant carousels.
# The canonical page title + URL must identify the requested venue;
# snippets are evidence for branch and offer capability only.
if not _restaurant_match(restaurant, " ".join((title, link))):
continue
if not _city_match(city, haystack):
continue
if not _branch_match(address, city, haystack):
continue
advertised = _offer_texts(" ".join((title, snippet)))
matched = {
"channel_key": channel_key,
"channel_name": PLATFORMS[channel_key]["name"],
"status": "available",
"listing_url": link,
"evidence_title": title or None,
"evidence_snippet": snippet or None,
"advertised_offer": advertised[0] if advertised else None,
"advertised_offers": advertised,
}
if PLATFORMS[channel_key].get("needs_offer_marker"):
# magicpin contains delivery-only virtual outlets alongside dine-in
# stores. Never turn one of those into a bill-payment route.
if "delivery only" in haystack.lower():
continue
if not _PAYMENT_MARKER_RE.search(" ".join((title, snippet))):
matched["status"] = "listed"
listed_without_payment = listed_without_payment or matched
continue
return matched
if listed_without_payment:
return listed_without_payment
return {
"channel_key": channel_key,
"channel_name": PLATFORMS[channel_key]["name"],
"status": "not_found",
"listing_url": None,
"evidence_title": None,
"evidence_snippet": None,
"advertised_offer": None,
"advertised_offers": [],
}
def _exact_page_has_payment(channel_key: str, url: str) -> bool:
"""Verify payment capability that an exact listing's snippet omitted.
Only a canonical URL accepted by ``_platform_url`` reaches this fetch.
Any failure leaves the result ``listed`` and therefore unpriced.
"""
marker = PLATFORMS[channel_key].get("page_marker")
if not marker or not _platform_url(channel_key, url):
return False
try:
with _http_client(
timeout=min(HTTP_TIMEOUT_S, 8.0),
follow_redirects=True,
headers={"User-Agent": "RewardPilot/1.0 restaurant-offer-verifier"},
) as client:
response = client.get(url)
response.raise_for_status()
if not _platform_url(channel_key, str(response.url)):
return False
return bool(marker.search(response.text[:1_000_000]))
except Exception:
return False
_CACHE_THREAD_LOCK = threading.Lock()
_CACHE_LOCK_FILE = f"{CACHE_FILE}.lock"
def _cache_load() -> Dict:
try:
with open(CACHE_FILE, encoding="utf-8") as fh:
return json.load(fh)
except Exception: # noqa: BLE001
return {}
def _cache_save(data: Dict) -> None:
temp_path = None
try:
now = time.time()
pruned = {k: v for k, v in data.items() if now - v.get("at", 0) < CACHE_TTL_S}
cache_dir = os.path.dirname(CACHE_FILE) or "."
fd, temp_path = tempfile.mkstemp(prefix=".rp-dining-cache-", dir=cache_dir)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(pruned, fh)
fh.flush()
os.fsync(fh.fileno())
os.replace(temp_path, CACHE_FILE)
temp_path = None
except Exception: # noqa: BLE001
pass
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
def _cache_get(key: str) -> Optional[Dict]:
with _CACHE_THREAD_LOCK:
lock_fh = None
try:
lock_fh = open(_CACHE_LOCK_FILE, "a+", encoding="utf-8")
if fcntl is not None:
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
return _cache_load().get(key)
except OSError:
return _cache_load().get(key)
finally:
if lock_fh is not None:
if fcntl is not None:
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
lock_fh.close()
def _cache_put(key: str, payload: Dict) -> None:
with _CACHE_THREAD_LOCK:
lock_fh = None
try:
lock_fh = open(_CACHE_LOCK_FILE, "a+", encoding="utf-8")
if fcntl is not None:
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
cache = _cache_load()
cache[key] = {"at": time.time(), "payload": payload}
_cache_save(cache)
except OSError:
pass
finally:
if lock_fh is not None:
if fcntl is not None:
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
lock_fh.close()
def _cache_key(restaurant: str, city: str, address: Optional[str] = None) -> str:
raw = f"v{CACHE_VERSION}|{restaurant.strip().lower()}|{city.strip().lower()}|{(address or '').strip().lower()}"
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:20]
def _search(channel_key: str, restaurant: str, city: str, address: Optional[str] = None) -> Dict:
cfg = PLATFORMS[channel_key]
hint = _address_hint(address, city)
# A strict site: query proves identity but Google returns a skeletal snippet.
# The natural-language form keeps the direct page near the top AND exposes
# the listing's visible offer text; parse_platform_results still enforces
# the exact domain/path, restaurant, city and branch before accepting it.
query = f'"{restaurant}" "{city}" {cfg["query_label"]}'
if hint:
query += f' "{hint}"'
params = {
"engine": "google",
"q": query,
"hl": "en",
"num": 10,
"api_key": (os.getenv("SERPAPI_KEY") or "").strip(),
}
with _http_client(timeout=HTTP_TIMEOUT_S) as client:
response = client.get(SERPAPI_URL, params=params)
response.raise_for_status()
return response.json()
def availability_for(restaurant: str, city: str, address: Optional[str] = None) -> Dict:
"""Return evidence-backed listing status for every supported dining app."""
name = " ".join((restaurant or "").split())
where = " ".join((city or "").split())
checked_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
empty = {
"live": False,
"source": "none",
"restaurant": name,
"city": where,
"address": address or None,
"checked_at": checked_at,
"platforms": [
{
"channel_key": key,
"channel_name": cfg["name"],
"status": "unverified",
"listing_url": None,
"evidence_title": None,
"evidence_snippet": None,
"advertised_offer": None,
"advertised_offers": [],
}
for key, cfg in PLATFORMS.items()
],
}
if len(name) < 2 or len(where) < 2:
return dict(empty, source="none (restaurant and city required)")
if not (os.getenv("SERPAPI_KEY") or "").strip():
return dict(empty, source="none (no SERPAPI_KEY)")
cache_key = _cache_key(name, where, address)
cached = _cache_get(cache_key)
if cached and time.time() - cached.get("at", 0) < CACHE_TTL_S:
payload = dict(cached["payload"])
payload["source"] = payload.get("source", "serpapi") + " (cached)"
if address:
payload["address"] = address
return payload
def check_platform(key: str):
try:
data = _search(key, name, where, address)
if data.get("error"):
raise RuntimeError(str(data["error"]))
row = parse_platform_results(key, name, where, data, address)
if row.get("status") == "listed" and row.get("listing_url"):
if _exact_page_has_payment(key, row["listing_url"]):
row["status"] = "available"
return row, True
except Exception: # noqa: BLE001 - each platform degrades independently
return {
"channel_key": key,
"channel_name": PLATFORMS[key]["name"],
"status": "unverified",
"listing_url": None,
"evidence_title": None,
"evidence_snippet": None,
"advertised_offer": None,
"advertised_offers": [],
}, False
# Platforms are independent; parallel requests cap perceived latency at one
# upstream timeout instead of five. The registry order remains stable.
with ThreadPoolExecutor(max_workers=len(PLATFORMS)) as executor:
checked = list(executor.map(check_platform, PLATFORMS.keys()))
platforms = [row for row, _ok in checked]
searches_ok = sum(1 for _row, ok in checked if ok)
payload = {
"live": searches_ok == len(PLATFORMS),
"source": "serpapi" if searches_ok else "serpapi error",
"restaurant": name,
"city": where,
"address": address or None,
"checked_at": checked_at,
"platforms": platforms,
}
# Cache a complete search only. A transient platform error must not become
# the restaurant's answer for the next four hours.
if searches_ok == len(PLATFORMS):
_cache_put(cache_key, payload)
return payload