7d1a89475a
- Category codes: RES/DES/BAR/BAK (was full English words) - score field: use 'value' from Score model (was 'score', which doesn't exist) - rank_index: pass array position (no rank field in response) - fetch_bookmarks: no query params (user param caused 405) - notes: correctly None (stored separately, not in ranking results) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
167 lines
5.6 KiB
Python
167 lines
5.6 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_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 [],
|
|
}
|