From 8d74d04b4372bd33bc7c5dd3f14a2d772de408da Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 1 Sep 2026 20:32:10 +0700 Subject: [PATCH] feat(stalk): add mobile-legends, free-fire, genshin, twitter, tiktok, instagram Ported from Shirokami-API scraper/stalk/*.js: - /stalk/mobile-legends (gempaytopup CSRF+POST) - /stalk/free-fire (freefirecommunity API + field transform) - /stalk/genshin-impact (enka.network __data.json) - /stalk/twitter (fxtwitter API) - /stalk/tiktok (rehydration JSON parse) - /stalk/instagram (mollygram HTML scrape) Upstream status: twitter/genshin verified working live (elonmusk 241M followers). github+youtube verified earlier. tiktok WAF-blocked, instagram mollygram-captcha blocked, mobile-legends gempaytopup changed POST->GET+JS (all upstream dead for VPS IP; code faithful to source). --- src/application/stalk.rs | 42 +++ src/infrastructure/repository/stalk.rs | 356 +++++++++++++++++++++++++ src/presentation/handler/stalk.rs | 73 +++++ src/presentation/router.rs | 24 ++ stalk_tiktok.py | 64 +++++ 5 files changed, 559 insertions(+) create mode 100644 stalk_tiktok.py diff --git a/src/application/stalk.rs b/src/application/stalk.rs index 2965205..846a655 100644 --- a/src/application/stalk.rs +++ b/src/application/stalk.rs @@ -17,3 +17,45 @@ pub async fn youtube(username: &str) -> Result { .await .map_err(ScrapingError::Http) } + +/// Mobile Legends player stalk. +pub async fn mobile_legends(user_id: &str, zone_id: &str) -> Result { + Repo::fetch_ml_stalk(user_id, zone_id) + .await + .map_err(ScrapingError::Http) +} + +/// Free Fire player stalk. +pub async fn free_fire(user_id: &str) -> Result { + Repo::fetch_ff_stalk(user_id) + .await + .map_err(ScrapingError::Http) +} + +/// Genshin Impact player stalk. +pub async fn genshin(user_id: &str) -> Result { + Repo::fetch_genshin_stalk(user_id) + .await + .map_err(ScrapingError::Http) +} + +/// Twitter/X user stalk. +pub async fn twitter(username: &str) -> Result { + Repo::fetch_twitter_stalk(username) + .await + .map_err(ScrapingError::Http) +} + +/// TikTok user stalk. +pub async fn tiktok(username: &str) -> Result { + Repo::fetch_tiktok_stalk(username) + .await + .map_err(ScrapingError::Http) +} + +/// Instagram user stalk. +pub async fn instagram(username: &str) -> Result { + Repo::fetch_instagram_stalk(username) + .await + .map_err(ScrapingError::Http) +} diff --git a/src/infrastructure/repository/stalk.rs b/src/infrastructure/repository/stalk.rs index 6079261..cdb8c90 100644 --- a/src/infrastructure/repository/stalk.rs +++ b/src/infrastructure/repository/stalk.rs @@ -416,3 +416,359 @@ fn video_from_grid_video(gv: &Value) -> Value { } v } + +// --------------------------------------------------------------------------- +// Mobile Legends — gempaytopup.com stalk (CSRF token + POST) +// --------------------------------------------------------------------------- + +pub async fn fetch_ml_stalk(user_id: &str, zone_id: &str) -> Result { + // 1. GET to obtain CSRF token + cookies + let resp = http_client() + .client() + .get("https://www.gempaytopup.com") + .header( + USER_AGENT, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36", + ) + .send() + .await + .map_err(|e| format!("HTTP: {}", e))?; + let cookies = resp + .headers() + .get_all("set-cookie") + .iter() + .map(|v| { + v.to_str() + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .to_string() + }) + .collect::>() + .join("; "); + let body = resp.text().await.map_err(|e| format!("Body: {}", e))?; + + let csrf_re = Regex::new(r#""#).unwrap(); + let csrf = csrf_re + .captures(&body) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()); + + let (csrf, cookies) = match (csrf, cookies.is_empty()) { + (Some(c), false) => (c, cookies), + _ => return Ok(json!({"error": "Gagal mendapatkan CSRF token atau cookie."})), + }; + + // 2. POST stalk-ml + let payload = json!({"uid": user_id, "zone": zone_id}); + let resp = http_client() + .client() + .post("https://www.gempaytopup.com/stalk-ml") + .header("X-CSRF-Token", &csrf) + .header("Content-Type", "application/json") + .header("Cookie", &cookies) + .json(&payload) + .send() + .await + .map_err(|e| format!("HTTP: {}", e))?; + let data: Value = resp.json().await.map_err(|e| format!("JSON: {}", e))?; + Ok(data) +} + +// --------------------------------------------------------------------------- +// Free Fire — freefirecommunity API +// --------------------------------------------------------------------------- + +pub async fn fetch_ff_stalk(user_id: &str) -> Result { + let url = format!( + "https://discordbot.freefirecommunity.com/player_info_api?uid={}®ion=id", + user_id + ); + let resp = http_client() + .client() + .get(&url) + .header("Origin", "https://www.freefirecommunity.com") + .header( + "Referer", + "https://www.freefirecommunity.com/ff-account-info/", + ) + .header(USER_AGENT, "Mozilla/5.0 (Linux; Android 10; K)") + .header("Accept", "/") + .send() + .await + .map_err(|e| format!("HTTP: {}", e))?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + let data: Value = resp.json().await.map_err(|e| format!("JSON: {}", e))?; + + let safe = |v: &Value| -> Value { + if v.is_null() { + Value::String("N/A".into()) + } else { + v.clone() + } + }; + let arr_join = |v: &Value| -> Value { + v.as_array() + .map(|a| { + Value::String( + a.iter() + .filter_map(|x| x.as_str()) + .collect::>() + .join(", "), + ) + }) + .unwrap_or(Value::String("-".into())) + }; + let fmt_time = |v: &Value| -> Value { + // unix timestamp -> YYYY-MM-DD (approx via chrono) + v.as_i64() + .map(|ts| { + use chrono::{TimeZone, Utc}; + match Utc.timestamp_opt(ts, 0) { + chrono::LocalResult::Single(dt) => { + Value::String(dt.format("%Y-%m-%d %H:%M:%S").to_string()) + } + _ => Value::String("N/A".into()), + } + }) + .unwrap_or(Value::String("N/A".into())) + }; + + let d = &data["player_info"]; + let b = &d["basicInfo"]; + let c = &d["creditScoreInfo"]; + let p = &d["petInfo"]; + let prof = &d["profileInfo"]; + let s = &d["socialInfo"]; + + // battle tags + let tags = s["battleTag"].as_array().cloned().unwrap_or_default(); + let tag_counts = s["battleTagCount"].as_array().cloned().unwrap_or_default(); + let battle_tags = if tags.is_empty() { + Value::String("N/A".into()) + } else { + Value::String( + tags.iter() + .enumerate() + .map(|(i, t)| { + let count = tag_counts.get(i).and_then(|v| v.as_i64()).unwrap_or(0); + format!("{} ({}x)", t.as_str().unwrap_or(""), count) + }) + .collect::>() + .join("\n"), + ) + }; + + Ok(json!({ + "nickname": safe(&b["nickname"]), + "accountId": safe(&b["accountId"]), + "region": safe(&b["region"]), + "level": safe(&b["level"]), + "liked": safe(&b["liked"]), + "rank": safe(&b["rank"]), + "maxRank": safe(&b["maxRank"]), + "csRank": safe(&b["csRank"]), + "exp": safe(&b["exp"]), + "createAt": fmt_time(&b["createAt"]), + "lastLoginAt": fmt_time(&b["lastLoginAt"]), + "rankingPoints": safe(&b["rankingPoints"]), + "releaseVersion": safe(&b["releaseVersion"]), + "seasonId": safe(&b["seasonId"]), + "primeLevel": b["primeLevel"]["level"].is_null().then(|| Value::String("-".into())).unwrap_or_else(|| safe(&b["primeLevel"]["level"])), + "diamondCost": safe(&d["diamondCostRes"]["diamondCost"]), + "petName": safe(&p["name"]), + "petLevel": safe(&p["level"]), + "petExp": safe(&p["exp"]), + "petSkinId": safe(&p["skinId"]), + "petSkillId": safe(&p["selectedSkillId"]), + "avatarId": safe(&prof["avatarId"]), + "clothes": arr_join(&prof["clothes"]), + "equipedSkills": arr_join(&prof["equipedSkills"]), + "battleTags": battle_tags, + "language": safe(&s["language"]), + "rankShow": safe(&s["rankShow"]), + "signature": safe(&s["signature"]), + "creditScore": safe(&c["creditScore"]), + "rewardState": safe(&c["rewardState"]), + "bannerImage": format!("https://discordbot.freefirecommunity.com/banner_image_api?uid={}®ion=id", user_id), + "outfitImage": format!("https://discordbot.freefirecommunity.com/outfit_image_api?uid={}®ion=id", user_id), + })) +} + +// --------------------------------------------------------------------------- +// Genshin Impact — enka.network public API +// --------------------------------------------------------------------------- + +pub async fn fetch_genshin_stalk(user_id: &str) -> Result { + let url = format!("https://enka.network/u/{}/__data.json", user_id); + let resp = http_client() + .client() + .get(&url) + .header( + USER_AGENT, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36", + ) + .send() + .await + .map_err(|e| format!("HTTP: {}", e))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err("User tidak ditemukan".into()); + } + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + let data: Value = resp.json().await.map_err(|e| format!("JSON: {}", e))?; + // Pass through the raw JSON (Enka __data.json is already structured). + Ok(data) +} + +// --------------------------------------------------------------------------- +// Twitter — fxtwitter API +// --------------------------------------------------------------------------- + +pub async fn fetch_twitter_stalk(username: &str) -> Result { + let url = format!("https://api.fxtwitter.com/{}", username); + let resp = http_client() + .client() + .get(&url) + .header( + USER_AGENT, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0 Safari/537.36", + ) + .send() + .await + .map_err(|e| format!("HTTP: {}", e))?; + let data: Value = resp.json().await.map_err(|e| format!("JSON: {}", e))?; + + // Transform to the source's shape + let u = &data["user"]; + Ok(json!({ + "message": data.get("message").cloned().unwrap_or(Value::Null), + "user": { + "id": u.get("id").cloned().unwrap_or(Value::Null), + "url": u.get("url").cloned().unwrap_or(Value::Null), + "screen_name": u.get("screen_name").cloned().unwrap_or(Value::Null), + "name": u.get("name").cloned().unwrap_or(Value::Null), + "location": u.get("location").cloned().unwrap_or(Value::Null), + "description": u.get("description").cloned().unwrap_or(Value::Null), + "followers": u.get("followers").cloned().unwrap_or(Value::Null), + "following": u.get("following").cloned().unwrap_or(Value::Null), + "likes": u.get("likes").cloned().unwrap_or(Value::Null), + "banner_url": u.get("banner_url").cloned().unwrap_or(Value::Null), + "avatar_url": u.get("avatar_url").cloned().unwrap_or(Value::Null), + "joined_at": u.get("joined").cloned().unwrap_or(Value::Null), + "website": u.get("website").cloned().unwrap_or(Value::Null), + } + })) +} + +// --------------------------------------------------------------------------- +// TikTok — __UNIVERSAL_DATA_FOR_REHYDRATION__ JSON (V1 direct, no browser) +// --------------------------------------------------------------------------- + +pub async fn fetch_tiktok_stalk(username: &str) -> Result { + let url = format!("https://www.tiktok.com/@{username}?_t=ZS-8tHANz7ieoS&_r=1"); + let resp = http_client() + .client() + .get(&url) + .header(USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36") + .send() + .await + .map_err(|e| format!("HTTP: {}", e))?; + let html = resp.text().await.map_err(|e| format!("Body: {}", e))?; + + // Extract __UNIVERSAL_DATA_FOR_REHYDRATION__ JSON + let marker = r#"__UNIVERSAL_DATA_FOR_REHYDRATION__"#; + let start = html.find(marker).ok_or("User tidak ditemukan")?; + let json_start = html[start..] + .find('>') + .map(|i| start + i + 1) + .ok_or("User tidak ditemukan")?; + let json_end = html[json_start..] + .find("") + .map(|i| json_start + i) + .ok_or("User tidak ditemukan")?; + let json_str = &html[json_start..json_end]; + + let parsed: Value = serde_json::from_str(json_str).map_err(|e| format!("JSON: {}", e))?; + let user_info = parsed + .pointer("/__DEFAULT_SCOPE__/webapp.user-detail/userInfo/user") + .cloned() + .ok_or("User tidak ditemukan")?; + let stats = parsed + .pointer("/__DEFAULT_SCOPE__/webapp.user-detail/userInfo/stats") + .cloned() + .unwrap_or(Value::Null); + + Ok(json!({ + "userInfo": { + "id": user_info.get("id").cloned().unwrap_or(Value::Null), + "username": user_info.get("uniqueId").cloned().unwrap_or(Value::Null), + "name": user_info.get("nickname").cloned().unwrap_or(Value::Null), + "avatar": user_info.get("avatarLarger").cloned().unwrap_or(Value::Null), + "bio": user_info.get("signature").cloned().unwrap_or(Value::Null), + "verified": user_info.get("verified").cloned().unwrap_or(Value::Bool(false)), + "totalFollowers": stats.get("followerCount").cloned().unwrap_or(Value::from(0)), + "totalFollowing": stats.get("followingCount").cloned().unwrap_or(Value::from(0)), + "totalLikes": stats.get("heart").cloned().unwrap_or(Value::from(0)), + "totalVideos": stats.get("videoCount").cloned().unwrap_or(Value::from(0)), + "totalFriends": stats.get("friendCount").cloned().unwrap_or(Value::from(0)), + } + })) +} + +// --------------------------------------------------------------------------- +// Instagram — media.mollygram.com HTML scrape +// --------------------------------------------------------------------------- + +pub async fn fetch_instagram_stalk(username: &str) -> Result { + let url = format!("https://media.mollygram.com/?url={}", urlencode(username)); + let resp = http_client() + .client() + .get(&url) + .header("accept", "*/*") + .header("origin", "https://mollygram.com") + .header("referer", "https://mollygram.com/") + .header(USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36") + .send() + .await + .map_err(|e| format!("HTTP: {}", e))?; + let body = resp.text().await.map_err(|e| format!("Body: {}", e))?; + + let re = |pat: &str| -> Option { + let rx = Regex::new(pat).ok()?; + rx.captures(&body) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().trim().to_string()) + }; + + let avatar = re(r#"]*class="[^"]*rounded-circle[^"]*"[^>]*src="([^"]+)""#) + .or_else(|| re(r#"]*src="([^"]+)"[^>]*class="[^"]*rounded-circle[^"]*""#)); + let uname = re(r#"

([^<]+)

"#); + let fullname = re(r#"

([^<]+)

"#); + let bio = re(r#"

]*>([^<]*)

"#); + let posts = re(r#"([^<]+)\s*]*>\s*posts\s*"#); + let followers = + re(r#"([^<]+)\s*]*>\s*followers\s*"#); + let following = + re(r#"([^<]+)\s*]*>\s*following\s*"#); + + Ok(json!({ + "avatar": avatar, + "name": fullname.unwrap_or_default(), + "username": uname.unwrap_or_else(|| username.to_string()), + "posts": posts.unwrap_or_default(), + "followers": followers.unwrap_or_default(), + "following": following.unwrap_or_default(), + "bio": bio.unwrap_or_default(), + })) +} + +fn urlencode(s: &str) -> String { + s.replace('%', "%25") + .replace('&', "%26") + .replace('?', "%3F") +} diff --git a/src/presentation/handler/stalk.rs b/src/presentation/handler/stalk.rs index 35363fb..dc09c4d 100644 --- a/src/presentation/handler/stalk.rs +++ b/src/presentation/handler/stalk.rs @@ -42,3 +42,76 @@ pub async fn youtube_handler( let result = use_cases::youtube(&username).await?; Ok((StatusCode::OK, Json(result))) } + +/// GET /stalk/mobile-legends?user_id=...&zone_id=... (aliases: userid, zoneid) +pub async fn mobile_legends_handler( + Query(params): Query, +) -> Result<(StatusCode, Json), AppError> { + let user_id = params + .user_id + .or(params.userid) + .ok_or_else(|| AppError::BadRequest("Missing 'user_id' parameter".to_string()))?; + let zone_id = params + .zone_id + .or(params.zoneid) + .ok_or_else(|| AppError::BadRequest("Missing 'zone_id' parameter".to_string()))?; + let result = use_cases::mobile_legends(&user_id, &zone_id).await?; + Ok((StatusCode::OK, Json(result))) +} + +/// GET /stalk/free-fire?user_id=... (alias: userid) +pub async fn free_fire_handler( + Query(params): Query, +) -> Result<(StatusCode, Json), AppError> { + let user_id = params + .user_id + .or(params.userid) + .ok_or_else(|| AppError::BadRequest("Missing 'user_id' parameter".to_string()))?; + let result = use_cases::free_fire(&user_id).await?; + Ok((StatusCode::OK, Json(result))) +} + +/// GET /stalk/genshin-impact?user_id=... (alias: userid) +pub async fn genshin_handler( + Query(params): Query, +) -> Result<(StatusCode, Json), AppError> { + let user_id = params + .user_id + .or(params.userid) + .ok_or_else(|| AppError::BadRequest("Missing 'user_id' parameter".to_string()))?; + let result = use_cases::genshin(&user_id).await?; + Ok((StatusCode::OK, Json(result))) +} + +/// GET /stalk/twitter?username=... +pub async fn twitter_handler( + Query(params): Query, +) -> Result<(StatusCode, Json), AppError> { + let username = params + .username + .ok_or_else(|| AppError::BadRequest("Missing 'username' parameter".to_string()))?; + let result = use_cases::twitter(&username).await?; + Ok((StatusCode::OK, Json(result))) +} + +/// GET /stalk/tiktok?username=... +pub async fn tiktok_handler( + Query(params): Query, +) -> Result<(StatusCode, Json), AppError> { + let username = params + .username + .ok_or_else(|| AppError::BadRequest("Missing 'username' parameter".to_string()))?; + let result = use_cases::tiktok(&username).await?; + Ok((StatusCode::OK, Json(result))) +} + +/// GET /stalk/instagram?username=... +pub async fn instagram_handler( + Query(params): Query, +) -> Result<(StatusCode, Json), AppError> { + let username = params + .username + .ok_or_else(|| AppError::BadRequest("Missing 'username' parameter".to_string()))?; + let result = use_cases::instagram(&username).await?; + Ok((StatusCode::OK, Json(result))) +} diff --git a/src/presentation/router.rs b/src/presentation/router.rs index d1cc4d7..653b25e 100644 --- a/src/presentation/router.rs +++ b/src/presentation/router.rs @@ -282,6 +282,30 @@ pub fn build_router(app_state: Arc) -> anyhow::Result { "/stalk/youtube", axum::routing::get(crate::presentation::handler::stalk::youtube_handler), ) + .route( + "/stalk/mobile-legends", + axum::routing::get(crate::presentation::handler::stalk::mobile_legends_handler), + ) + .route( + "/stalk/free-fire", + axum::routing::get(crate::presentation::handler::stalk::free_fire_handler), + ) + .route( + "/stalk/genshin-impact", + axum::routing::get(crate::presentation::handler::stalk::genshin_handler), + ) + .route( + "/stalk/twitter", + axum::routing::get(crate::presentation::handler::stalk::twitter_handler), + ) + .route( + "/stalk/tiktok", + axum::routing::get(crate::presentation::handler::stalk::tiktok_handler), + ) + .route( + "/stalk/instagram", + axum::routing::get(crate::presentation::handler::stalk::instagram_handler), + ) // Weebs routes .route( "/weebs/anime-info", diff --git a/stalk_tiktok.py b/stalk_tiktok.py new file mode 100644 index 0000000..9d19d7c --- /dev/null +++ b/stalk_tiktok.py @@ -0,0 +1,64 @@ +"""Extract TikTok user-detail JSON (__UNIVERSAL_DATA_FOR_REHYDRATION__) via Playwright. + +The direct HTTP path gets a SlardarWAF challenge page; a real browser with JS +executes the challenge and exposes the __UNIVERSAL_DATA_FOR_REHYDRATION__ script. +Usage: python3 stalk_tiktok.py +""" +import sys +import asyncio +import json +import os + +os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "/usr/local/share/ms-playwright") + +from playwright.async_api import async_playwright + + +async def main(username): + url = f"https://www.tiktok.com/@{username}?_t=ZS-8tHANz7ieoS&_r=1" + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + args=["--no-sandbox", "--disable-bypass", "--disable-blink-features=AutomationControlled"], + ) + context = await browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + viewport={"width": 1366, "height": 768}, + java_script_enabled=True, + locale="en-US", + ) + page = await context.new_page() + try: + await page.goto(url, timeout=60000, wait_until="domcontentloaded") + except Exception as e: + await browser.close() + print(json.dumps({"success": False, "error": f"Navigation: {e}"})) + return + + # Wait for the rehydration script to appear (WAF challenge may take a few seconds) + found = None + for _ in range(30): + try: + found = await page.evaluate( + """() => { + const s = document.querySelector('script#__UNIVERSAL_DATA_FOR_REHYDRATION__'); + return s ? s.textContent : null; + }""" + ) + if found: + break + except Exception: + pass + await page.wait_for_timeout(1000) + + await browser.close() + + if not found: + print(json.dumps({"success": False, "error": "Rehydration data not found (WAF challenge)"})) + return + + print(json.dumps({"success": True, "data": found})) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1])) \ No newline at end of file