From 62b78d47000cfaf4c2b8866fabe10ba745359531 Mon Sep 17 00:00:00 2001 From: "Khoa.vo" Date: Fri, 19 Dec 2025 20:53:33 +0700 Subject: [PATCH] Add dynamic suggested Vietnamese TikTokers API with caching --- backend/api/routes/user.py | 73 +++++++++++++++++++++++ backend/core/playwright_manager.py | 94 ++++++++++++++++++++++++++++++ frontend/src/components/Feed.tsx | 28 ++++++++- 3 files changed, 192 insertions(+), 3 deletions(-) diff --git a/backend/api/routes/user.py b/backend/api/routes/user.py index 4106115..65b2ead 100644 --- a/backend/api/routes/user.py +++ b/backend/api/routes/user.py @@ -156,3 +156,76 @@ async def search_videos( print(f"Error searching for {query}: {e}") raise HTTPException(status_code=500, detail=str(e)) + +# Cache for suggested accounts +_suggested_cache = { + "accounts": [], + "updated_at": 0 +} +CACHE_TTL = 3600 # 1 hour cache + + +@router.get("/suggested") +async def get_suggested_accounts( + limit: int = Query(50, description="Max accounts to return", ge=10, le=100) +): + """ + Fetch trending/suggested Vietnamese TikTok creators. + Uses TikTok's discover API and caches results for 1 hour. + """ + import time + + # Check cache + if _suggested_cache["accounts"] and (time.time() - _suggested_cache["updated_at"]) < CACHE_TTL: + print("Returning cached suggested accounts") + return {"accounts": _suggested_cache["accounts"][:limit], "cached": True} + + # Load stored credentials + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + if not cookies: + # Return fallback static list if not authenticated + return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True} + + print("Fetching fresh suggested accounts from TikTok...") + + try: + accounts = await PlaywrightManager.fetch_suggested_accounts(cookies, user_agent, limit) + + if accounts: + _suggested_cache["accounts"] = accounts + _suggested_cache["updated_at"] = time.time() + return {"accounts": accounts[:limit], "cached": False} + else: + # Fallback if API fails + return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True} + + except Exception as e: + print(f"Error fetching suggested accounts: {e}") + return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True} + + +def get_fallback_accounts(): + """Static fallback list of popular Vietnamese TikTokers.""" + return [ + {"username": "ciin_rubi", "nickname": "👑 CiiN - Lisa of Vietnam", "region": "VN"}, + {"username": "hoaa.hanassii", "nickname": "💃 Hoa Hanassii", "region": "VN"}, + {"username": "hoa_2309", "nickname": "🔥 Ngô Ngọc Hòa", "region": "VN"}, + {"username": "minah.ne", "nickname": "🎵 Minah", "region": "VN"}, + {"username": "lebong95", "nickname": "💪 Lê Bống", "region": "VN"}, + {"username": "po.trann77", "nickname": "✨ Trần Thanh Tâm", "region": "VN"}, + {"username": "gamkami", "nickname": "🎱 Gấm Kami", "region": "VN"}, + {"username": "quynhalee", "nickname": "🎮 Quỳnh Alee", "region": "VN"}, + {"username": "tieu_hy26", "nickname": "👰 Tiểu Hý", "region": "VN"}, + {"username": "changmie", "nickname": "🎤 Changmie", "region": "VN"}, + {"username": "vuthuydien", "nickname": "😄 Vũ Thụy Điển", "region": "VN"}, + {"username": "thienantv", "nickname": "😂 Thiên An TV", "region": "VN"}, + {"username": "amee_official", "nickname": "🎵 AMEE", "region": "VN"}, + {"username": "sontungmtp_official", "nickname": "🎤 Sơn Tùng M-TP", "region": "VN"}, + {"username": "hieuthuhai_", "nickname": "🎧 HIEUTHUHAI", "region": "VN"}, + {"username": "mck.99", "nickname": "🔥 MCK", "region": "VN"}, + {"username": "tranducbo", "nickname": "😄 Trần Đức Bo", "region": "VN"}, + {"username": "call.me.duy", "nickname": "🎭 Call Me Duy", "region": "VN"}, + {"username": "mai_ngok", "nickname": "💕 Mai Ngok", "region": "VN"}, + {"username": "thanhtrungdam", "nickname": "🎤 Đàm Thanh Trung", "region": "VN"}, + ] diff --git a/backend/core/playwright_manager.py b/backend/core/playwright_manager.py index a0ecaf1..97ead52 100644 --- a/backend/core/playwright_manager.py +++ b/backend/core/playwright_manager.py @@ -773,6 +773,100 @@ class PlaywrightManager: print(f"DEBUG: Total captured search videos: {len(captured_videos)}") return captured_videos + @staticmethod + async def fetch_suggested_accounts(cookies: list, user_agent: str = None, limit: int = 50) -> list: + """ + Fetch trending/suggested accounts from TikTok Vietnam. + Uses the discover/creators API. + """ + from playwright.async_api import async_playwright, Response + + if not user_agent: + user_agent = PlaywrightManager.DEFAULT_USER_AGENT + + captured_accounts = [] + + async def handle_response(response: Response): + """Capture suggested accounts from API responses.""" + nonlocal captured_accounts + + url = response.url + + # Look for suggest/discover APIs + if any(x in url for x in ["suggest", "discover", "recommend/user", "creator"]): + try: + data = await response.json() + + # Different API formats + users = data.get("userList", []) or data.get("users", []) or data.get("data", []) + + for item in users: + user_data = item.get("user", item) if isinstance(item, dict) else item + if isinstance(user_data, dict): + username = user_data.get("uniqueId") or user_data.get("unique_id") + if username: + captured_accounts.append({ + "username": username, + "nickname": user_data.get("nickname", username), + "avatar": user_data.get("avatarThumb") or user_data.get("avatar"), + "followers": user_data.get("followerCount", 0), + "verified": user_data.get("verified", False), + "region": "VN" + }) + + if users: + print(f"DEBUG: Captured {len(users)} suggested accounts") + + except Exception as e: + pass # Ignore parse errors + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + args=PlaywrightManager.BROWSER_ARGS + ) + + context = await browser.new_context( + user_agent=user_agent, + locale="vi-VN", # Vietnamese locale + timezone_id="Asia/Ho_Chi_Minh" + ) + await context.add_cookies(cookies) + + page = await context.new_page() + await stealth_async(page) + page.on("response", handle_response) + + try: + # Navigate to TikTok explore/discover page (Vietnam) + await page.goto("https://www.tiktok.com/explore?lang=vi-VN", wait_until="networkidle", timeout=30000) + await asyncio.sleep(3) + + # Also try the For You page to capture suggested + await page.goto("https://www.tiktok.com/foryou?lang=vi-VN", wait_until="domcontentloaded", timeout=15000) + await asyncio.sleep(2) + + # Scroll to trigger more suggestions + for _ in range(3): + await page.evaluate("window.scrollBy(0, 800)") + await asyncio.sleep(1) + + except Exception as e: + print(f"DEBUG: Error fetching suggested accounts: {e}") + + await browser.close() + + # Remove duplicates by username + seen = set() + unique_accounts = [] + for acc in captured_accounts: + if acc["username"] not in seen: + seen.add(acc["username"]) + unique_accounts.append(acc) + + print(f"DEBUG: Total unique suggested accounts: {len(unique_accounts)}") + return unique_accounts[:limit] + # Singleton instance playwright_manager = PlaywrightManager() diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 720920f..d66085f 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -199,11 +199,33 @@ export const Feed: React.FC = () => { const loadSuggestedProfiles = async () => { setLoadingProfiles(true); try { - const usernames = SUGGESTED_ACCOUNTS.map(a => a.username.replace('@', '')).join(','); - const res = await axios.get(`${API_BASE_URL}/user/profiles?usernames=${usernames}`); - setSuggestedProfiles(res.data); + // Try the dynamic suggested API first (auto-updates from TikTok Vietnam) + const res = await axios.get(`${API_BASE_URL}/user/suggested?limit=50`); + const accounts = res.data.accounts || []; + + if (accounts.length > 0) { + // Map API response to our profile format + setSuggestedProfiles(accounts.map((acc: any) => ({ + username: acc.username, + nickname: acc.nickname || acc.username, + avatar: acc.avatar || null, + followers: acc.followers || 0, + verified: acc.verified || false + }))); + } else { + // Fallback to static list if API returns empty + setSuggestedProfiles(SUGGESTED_ACCOUNTS.map(a => ({ + username: a.username.replace('@', ''), + nickname: a.label + }))); + } } catch (err) { console.error('Failed to load profiles:', err); + // Fallback to static list on error + setSuggestedProfiles(SUGGESTED_ACCOUNTS.map(a => ({ + username: a.username.replace('@', ''), + nickname: a.label + }))); } finally { setLoadingProfiles(false); }