Files
simbarag/utils/beli_service.py
ryan b79229a5b0 Add random friend ranking refresh to Beli integration
Each nightly profile refresh now also picks FRIEND_SAMPLE_SIZE (5) random
followed users and fetches their rankings, building up a picture of friends'
lists over time. Rows are tagged with friend_beli_user_id/friend_name so the
agent tool can surface attribution ("via Alex").

Requires migration to add friend_beli_user_id/friend_name columns and update
unique_together on beli_restaurants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 20:52:14 -04:00

183 lines
6.2 KiB
Python

"""
Beli API client.
Reverse-engineered from ProjectBarks/beli-api (mirrored at git.torrtle.co/ryan/beli-api).
Hosts and auth flow discovered from observed traffic — may break without notice.
Auth: POST /api/token/ → {access, refresh} (access: 20 min, refresh: 7 days)
The API rejects requests without an Origin header (app uses capacitor://localhost;
https://localhost also works per external clients).
"""
import base64
import json
import logging
import random
import time
import httpx
logger = logging.getLogger(__name__)
HOST_ONBOARD = "https://backoffice-service-onboarding-t57o3dxfca-nn.a.run.app"
HOST_API = "https://backoffice-service-t57o3dxfca-nn.a.run.app"
# 3-letter category codes used by the Beli API
RANKING_CATEGORIES = ["RES", "DES", "BAR", "BAK"]
# 350 ms between requests — community-polite interval per API reference
REQUEST_SPACING = 0.35
_USER_AGENTS = [
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Linux; Android 16; SM-S928U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.91 Mobile Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15",
]
def _pick_ua() -> str:
return random.choice(_USER_AGENTS)
def _base_headers(access_token: str | None = None, ua: str | None = None) -> dict:
h = {
"User-Agent": ua or _pick_ua(),
"Origin": "https://localhost",
"Content-Type": "application/json",
}
if access_token:
h["Authorization"] = f"Bearer {access_token}"
return h
def _decode_jwt_claims(token: str) -> dict:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
def is_token_expired(token: str | None, skew_seconds: int = 60) -> bool:
if not token:
return True
try:
return _decode_jwt_claims(token)["exp"] - skew_seconds <= time.time()
except Exception:
return True
async def authenticate(email: str, password: str) -> dict:
"""Login with email+password. Returns {access_token, refresh_token, user_id}."""
ua = _pick_ua()
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{HOST_ONBOARD}/api/token/",
json={"email": email, "password": password},
headers=_base_headers(ua=ua),
timeout=15,
)
resp.raise_for_status()
data = resp.json()
claims = _decode_jwt_claims(data["access"])
return {
"access_token": data["access"],
"refresh_token": data["refresh"],
"user_id": claims["user_id"],
}
async def refresh_access_token(refresh_token: str) -> str:
"""Exchange a refresh token for a new access token."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{HOST_ONBOARD}/api/token/refresh/",
json={"refresh": refresh_token},
headers=_base_headers(),
timeout=15,
)
resp.raise_for_status()
return resp.json()["access"]
async def fetch_user_rankings(
access_token: str, user_id: str, category: str
) -> list[dict]:
"""Fetch a user's ranked places for one category (RES/DES/BAR/BAK)."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{HOST_API}/api/get-ranking/",
params={"user": user_id, "category": category},
headers=_base_headers(access_token),
timeout=30,
)
resp.raise_for_status()
return resp.json().get("results") or []
async def fetch_following(access_token: str, user_id: str) -> list[dict]:
"""Fetch users that user_id follows. Returns list of user objects with id, full_name, username."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{HOST_API}/api/following/{user_id}/",
headers=_base_headers(access_token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
# Endpoint returns either a paginated {results:[]} or bare list
if isinstance(data, list):
return data
return data.get("results") or []
async def fetch_bookmarks(access_token: str) -> list[dict]:
"""Fetch the authenticated user's bookmarked (want-to-try) places.
The endpoint takes no query params — it returns bookmarks for whoever
owns the bearer token.
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{HOST_API}/api/get-bookmark/",
headers=_base_headers(access_token),
timeout=30,
)
resp.raise_for_status()
return resp.json().get("results") or []
def extract_business(item: dict, rank_index: int | None = None) -> dict | None:
"""Pull place fields from a ranking or bookmark result item.
Ranking items have a nested 'business' object (integer id) plus top-level
score fields. The score is stored as 'value' (sentiment seed sent by client;
server computes the displayed 0-10 score server-side).
Rank is the position in the results list, not a field in the response.
Notes are stored separately via data-user-business-text; we don't fetch them.
"""
biz = item.get("business") or item
biz_id = biz.get("id") or item.get("business_id")
if not biz_id:
return None
price_key = biz.get("price_key")
if not price_key:
price_int = biz.get("price")
if price_int:
price_key = "$" * price_int
return {
"beli_business_id": str(biz_id),
"name": biz.get("name", ""),
"city": biz.get("city"),
"neighborhood": biz.get("neighborhood") or biz.get("borough"),
"cuisine": biz.get("cuisines") or [],
"price_range": price_key,
"rank_index": rank_index,
"score": item.get("value"),
"notes": None,
"tags": item.get("tags") or [],
}