use serde_json::{json, Value};
use tail_fin_common::{BrowserSession, TailFinError};
use crate::parsing::{
combine_discover, combine_related, parse_cart_mini, parse_category_tree,
parse_fe_category_detail, parse_product_detail, parse_reviews, parse_search_items,
parse_search_user, parse_shop_info, parse_shop_items,
};
use crate::site::ShopeeRegion;
use crate::types::{
CartPreview, Category, CategoryPage, Discover, HomepageBundle, ProductDetail, RelatedItems,
Reviews, SearchResults, ShopInfo, ShopItems, UserSearchResults,
};
const CAPTURE_PATTERN: &str = "*shopee.*/api/*";
pub struct ShopeeBrowserClient {
session: BrowserSession,
region: ShopeeRegion,
capture_installed: tokio::sync::OnceCell<()>,
}
impl ShopeeBrowserClient {
pub fn new(session: BrowserSession, region: ShopeeRegion) -> Self {
Self {
session,
region,
capture_installed: tokio::sync::OnceCell::const_new(),
}
}
pub fn region(&self) -> ShopeeRegion {
self.region
}
pub fn session(&self) -> &BrowserSession {
&self.session
}
async fn ensure_capture_installed(&self) -> Result<(), TailFinError> {
self.capture_installed
.get_or_try_init(|| async {
self.session.capture_responses(CAPTURE_PATTERN).await?;
let _ = self
.session
.cdp_command("Network.setBypassServiceWorker", json!({ "bypass": true }))
.await;
Ok::<(), TailFinError>(())
})
.await?;
Ok(())
}
pub async fn search(&self, keyword: &str) -> Result<SearchResults, TailFinError> {
self.search_page(keyword, 0).await
}
pub async fn search_page(
&self,
keyword: &str,
page: u32,
) -> Result<SearchResults, TailFinError> {
self.ensure_capture_installed().await?;
let encoded = url_encode(keyword);
let url = format!(
"{}/search?keyword={}&page={}",
self.region.base_url(),
encoded,
page,
);
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if let Ok(current) = self.session.get_url().await {
if !current.contains("/search") {
return Err(TailFinError::Api(format!(
"navigation landed on {current} instead of /search — \
Shopee likely served a CAPTCHA / verify page. Solve \
it manually in the attached Chrome window and retry."
)));
}
}
let captured = self.session.get_captured_responses().await?;
let mut found: Option<Value> = None;
let want_offset = format!("newest={}", page * 60);
let mut search_urls_seen: Vec<String> = Vec::new();
for r in &captured {
if r.url.contains("/api/v4/search/search_items") {
search_urls_seen.push(r.url.clone());
if r.url.contains(&want_offset) {
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
found = Some(v);
}
}
}
}
let body = found.ok_or_else(|| {
TailFinError::Api(format!(
"no /api/v4/search/search_items response captured for {want_offset}. \
search_items URLs we DID see ({} total): {:?}",
search_urls_seen.len(),
search_urls_seen
.iter()
.map(|u| u.split('?').nth(1).unwrap_or(""))
.collect::<Vec<_>>(),
))
})?;
parse_search_items(&body, keyword, page)
}
pub async fn product_detail(
&self,
shopid: u64,
itemid: u64,
) -> Result<ProductDetail, TailFinError> {
self.ensure_capture_installed().await?;
let url = format!("{}/product/{}/{}", self.region.base_url(), shopid, itemid);
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if let Ok(current) = self.session.get_url().await {
let canonical = format!("/product/{shopid}/{itemid}");
let seo_slug = format!("-i.{shopid}.{itemid}");
if !current.contains(&canonical) && !current.contains(&seo_slug) {
return Err(TailFinError::Api(format!(
"navigation landed on {current} — expected a product \
page for shopid={shopid} itemid={itemid}. CAPTCHA \
redirect, or the item was removed."
)));
}
}
let captured = self.session.get_captured_responses().await?;
let mut found: Option<Value> = None;
let mut detail_url_seen = String::new();
let item_modern = format!("item_id={itemid}");
let shop_modern = format!("shop_id={shopid}");
let item_legacy = format!("itemid={itemid}");
let shop_legacy = format!("shopid={shopid}");
for r in &captured {
let is_detail_endpoint =
r.url.contains("/api/v4/pdp/get_pc") || r.url.contains("/api/v4/item/get");
if !is_detail_endpoint {
continue;
}
let our_ids = (r.url.contains(&item_modern) || r.url.contains(&item_legacy))
&& (r.url.contains(&shop_modern) || r.url.contains(&shop_legacy));
if our_ids {
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
found = Some(v);
detail_url_seen = r.url.clone();
}
}
}
let body = found.ok_or_else(|| {
let urls: Vec<&str> = captured
.iter()
.filter(|r| r.url.contains("/api/v4/"))
.map(|r| r.url.as_str())
.collect();
TailFinError::Api(format!(
"no /api/v4/pdp/get_pc or /api/v4/item/get response captured \
for shopid={shopid} itemid={itemid}. \
Captured /api/v4/* URLs ({} total): {:?}",
urls.len(),
urls
))
})?;
parse_product_detail(&body).map_err(|e| {
let data_keys: Vec<&str> = body
.pointer("/data")
.and_then(|v| v.as_object())
.map(|o| o.keys().map(|s| s.as_str()).collect())
.unwrap_or_default();
let item_keys: Vec<&str> = body
.pointer("/data/item")
.and_then(|v| v.as_object())
.map(|o| o.keys().map(|s| s.as_str()).collect())
.unwrap_or_default();
TailFinError::Api(format!(
"{e} (url: {detail_url_seen})\n \
data.* keys: {data_keys:?}\n \
data.item.* keys: {item_keys:?}"
))
})
}
pub async fn related_products(
&self,
shopid: u64,
itemid: u64,
) -> Result<RelatedItems, TailFinError> {
self.ensure_capture_installed().await?;
let url = format!("{}/product/{}/{}", self.region.base_url(), shopid, itemid);
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if let Ok(current) = self.session.get_url().await {
let canonical = format!("/product/{shopid}/{itemid}");
let seo_slug = format!("-i.{shopid}.{itemid}");
if !current.contains(&canonical) && !current.contains(&seo_slug) {
return Err(TailFinError::Api(format!(
"navigation landed on {current} — expected a product \
page for shopid={shopid} itemid={itemid} during \
related_products. CAPTCHA redirect, or the item \
was removed."
)));
}
}
let captured = self.session.get_captured_responses().await?;
let item_marker = format!("item_id={itemid}");
let mut hot_sales_body: Option<Value> = None;
let mut recommend_body: Option<Value> = None;
for r in captured {
if r.url.contains("/api/v4/pdp/hot_sales/get_item_cards")
&& r.url.contains(&item_marker)
{
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
hot_sales_body = Some(v);
}
} else if r.url.contains("/api/v4/recommend/product_detail_page") {
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
recommend_body = Some(v);
}
}
}
Ok(combine_related(
hot_sales_body.as_ref(),
recommend_body.as_ref(),
shopid,
itemid,
))
}
pub async fn cart_preview(&self) -> Result<CartPreview, TailFinError> {
self.ensure_capture_installed().await?;
let url = format!("{}/", self.region.base_url());
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let captured = self.session.get_captured_responses().await?;
let mut found: Option<Value> = None;
for r in captured {
if r.url.contains("/api/v4/cart/mini") {
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
found = Some(v);
}
}
}
let body = found.ok_or_else(|| {
TailFinError::Api(
"no /api/v4/cart/mini response captured. Did the tab \
reach the homepage? CAPTCHA / verify-page would block \
the header init."
.to_string(),
)
})?;
parse_cart_mini(&body)
}
pub async fn discover(&self) -> Result<Discover, TailFinError> {
self.ensure_capture_installed().await?;
let url = format!("{}/", self.region.base_url());
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let captured = self.session.get_captured_responses().await?;
let mut discover_body: Option<Value> = None;
let mut flash_sale_body: Option<Value> = None;
let mut mall_shops_body: Option<Value> = None;
for r in captured {
let parse = || serde_json::from_str::<Value>(&r.body).ok();
if r.url.contains("/api/v4/homepage/get_daily_discover") {
discover_body = parse();
} else if r.url.contains("/api/v4/flash_sale/flash_sale_get_items") {
flash_sale_body = parse();
} else if r.url.contains("/api/v4/homepage/mall_shops") {
mall_shops_body = parse();
}
}
Ok(combine_discover(
discover_body.as_ref(),
flash_sale_body.as_ref(),
mall_shops_body.as_ref(),
))
}
pub async fn category_tree(&self) -> Result<Vec<Category>, TailFinError> {
self.ensure_capture_installed().await?;
let url = format!("{}/", self.region.base_url());
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let captured = self.session.get_captured_responses().await?;
let mut found: Option<Value> = None;
for r in captured {
if r.url.contains("/api/v4/pages/get_homepage_category_list") {
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
found = Some(v);
}
}
}
let body = found.ok_or_else(|| {
TailFinError::Api(
"no /api/v4/pages/get_homepage_category_list response captured. \
Did the homepage load? CAPTCHA / verify-page would block the \
sidebar init."
.to_string(),
)
})?;
Ok(parse_category_tree(&body))
}
pub async fn category(
&self,
parent_catid: u64,
leaf_catid: u64,
page: u32,
) -> Result<CategoryPage, TailFinError> {
self.ensure_capture_installed().await?;
if page != 0 {
return Err(TailFinError::Api(format!(
"category() v1 supports page=0 only (got page={page}); pagination \
beyond page 0 is queued for a follow-up. Use page=0 to fetch the \
first 60 items."
)));
}
let url = format!(
"{}/category-cat.{}.{}",
self.region.base_url(),
parent_catid,
leaf_catid,
);
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let click_result = self
.session
.eval(
r#"
(function(){
const buttons = Array.from(document.querySelectorAll('button.shopee-sort-by-options__option'));
if (buttons.length === 0) return { ok: false, reason: 'no_sort_buttons' };
// Prefer "最新" (by=ctime) — semantically meaningful + stable across categories.
let target = buttons.find(b => b.textContent.trim() === '最新' && b.getAttribute('aria-pressed') !== 'true');
// Fallback: any inactive sort. Triggers the same SPA refetch even if the label drifts.
if (!target) target = buttons.find(b => b.getAttribute('aria-pressed') !== 'true');
if (!target) return { ok: false, reason: 'all_sorts_active', total: buttons.length };
const text = target.textContent.trim();
target.click();
return { ok: true, clicked: text };
})()
"#,
)
.await
.map_err(|e| TailFinError::Api(format!("sort-click eval failed: {e:?}")))?;
let click_ok = click_result
.get("ok")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !click_ok {
let reason = click_result
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
return Err(TailFinError::Api(format!(
"sort-click failed (reason={reason}): {click_result}. \
Page may have rendered without the sort tabs \
(different layout? CAPTCHA? warm the profile up)."
)));
}
let _ = self.session.wait_for_network_idle(8_000, 1_500).await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if let Ok(current) = self.session.get_url().await {
if !current.contains("cat.") {
return Err(TailFinError::Api(format!(
"navigation landed on {current} instead of a category page — \
Shopee likely served a CAPTCHA / verify page. Solve \
it manually in the attached Chrome window and retry."
)));
}
}
let captured = self.session.get_captured_responses().await?;
let want_offset = format!("newest={}", page * 60);
let mut items_body: Option<Value> = None;
let mut detail_body: Option<Value> = None;
let mut search_urls_seen: Vec<String> = Vec::new();
for r in &captured {
if r.url.contains("/api/v4/search/search_items") {
search_urls_seen.push(r.url.clone());
if r.url.contains(&want_offset) && r.url.contains("PAGE_CATEGORY") {
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
items_body = Some(v);
}
}
} else if r.url.contains("/api/v4/search/get_fe_category_detail")
&& r.url.contains(&format!("catids={leaf_catid}"))
{
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
detail_body = Some(v);
}
}
}
let landing = self.session.get_url().await.unwrap_or_default();
let items_body = items_body.ok_or_else(|| {
const MAX_DIAG_URLS: usize = 20;
let all_api_total = captured.iter().filter(|r| r.url.contains("/api/")).count();
let all_api: Vec<&str> = captured
.iter()
.filter(|r| r.url.contains("/api/"))
.take(MAX_DIAG_URLS)
.map(|r| r.url.as_str())
.collect();
TailFinError::Api(format!(
"no /api/v4/search/search_items?...&scenario=PAGE_CATEGORY response \
captured for parent={parent_catid} leaf={leaf_catid} {want_offset}.\n \
Landing URL: {landing}\n \
search_items URLs we DID see ({} total): {:?}\n \
/api/ URLs (showing first {} of {}): {:?}",
search_urls_seen.len(),
search_urls_seen
.iter()
.map(|u| u.split('?').nth(1).unwrap_or(""))
.collect::<Vec<_>>(),
all_api.len(),
all_api_total,
all_api,
))
})?;
let search = parse_search_items(&items_body, "", page)?;
let category = detail_body.as_ref().and_then(|b| {
let parsed = parse_fe_category_detail(b);
if parsed.is_none() {
eprintln!(
"[shopee] get_fe_category_detail body parsed empty for \
leaf_catid={leaf_catid} — degrading category metadata to None"
);
}
parsed
});
let has_more = (u64::from(page) + 1) * 60 < search.total_count;
Ok(CategoryPage {
category,
items: search.items,
total_count: search.total_count,
page,
has_more,
})
}
pub async fn shop_info(&self, shopid: u64, itemid: u64) -> Result<ShopInfo, TailFinError> {
self.ensure_capture_installed().await?;
let url = format!("{}/product/{}/{}", self.region.base_url(), shopid, itemid);
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let id_marker = format!("shop_id={shopid}");
let captured = self.session.get_captured_responses().await?;
let mut found: Option<Value> = None;
for r in captured {
if r.url.contains("/api/v4/promotion/get_shop_info") && r.url.contains(&id_marker) {
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
found = Some(v);
}
}
}
let body = found.ok_or_else(|| {
TailFinError::Api(format!(
"no /api/v4/promotion/get_shop_info response captured for {id_marker}. \
Did the PDP load? CAPTCHA / verify-page would block the seller card."
))
})?;
parse_shop_info(&body)
}
pub async fn reviews(&self, shopid: u64, itemid: u64) -> Result<Reviews, TailFinError> {
self.ensure_capture_installed().await?;
let url = format!("{}/product/{}/{}", self.region.base_url(), shopid, itemid);
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if let Ok(current) = self.session.get_url().await {
let canonical = format!("/product/{shopid}/{itemid}");
let seo_slug = format!("-i.{shopid}.{itemid}");
if !current.contains(&canonical) && !current.contains(&seo_slug) {
return Err(TailFinError::Api(format!(
"navigation landed on {current} — expected a product \
page for shopid={shopid} itemid={itemid} during \
reviews. CAPTCHA redirect, or the item was removed."
)));
}
}
let item_marker = format!("itemid={itemid}");
let shop_marker = format!("shopid={shopid}");
let captured = self.session.get_captured_responses().await?;
let mut found: Option<Value> = None;
for r in captured {
if r.url.contains("/api/v2/item/get_ratings")
&& r.url.contains(&item_marker)
&& r.url.contains(&shop_marker)
{
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
found = Some(v);
}
}
}
let body = found.ok_or_else(|| {
TailFinError::Api(format!(
"no /api/v2/item/get_ratings response captured for \
shopid={shopid} itemid={itemid}. Did the PDP load?"
))
})?;
parse_reviews(&body, shopid, itemid)
}
pub async fn shop_items(&self, shopid: u64, page: u32) -> Result<ShopItems, TailFinError> {
self.ensure_capture_installed().await?;
let url = format!("{}/shop/{}?page={}", self.region.base_url(), shopid, page);
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let captured = self.session.get_captured_responses().await?;
let mut search_items_body: Option<Value> = None;
let mut rcmd_items_body: Option<Value> = None;
for r in &captured {
if r.url.contains("/api/v4/shop/search_items") && search_items_body.is_none() {
search_items_body = serde_json::from_str(&r.body).ok();
} else if r.url.contains("/api/v4/shop/rcmd_items") && rcmd_items_body.is_none() {
rcmd_items_body = serde_json::from_str(&r.body).ok();
}
}
let body = search_items_body.or(rcmd_items_body).ok_or_else(|| {
let shop_urls: Vec<&str> = captured
.iter()
.filter(|r| r.url.contains("/api/v4/shop/"))
.map(|r| r.url.as_str())
.collect();
TailFinError::Api(format!(
"no shop/search_items or shop/rcmd_items response captured \
for shopid={shopid}. shop/* URLs we DID see ({}): {:?}",
shop_urls.len(),
shop_urls
))
})?;
parse_shop_items(&body, shopid, page)
}
pub async fn search_user(&self, keyword: &str) -> Result<UserSearchResults, TailFinError> {
self.ensure_capture_installed().await?;
let encoded = url_encode(keyword);
let url = format!(
"{}/search?keyword={}&searchType=user",
self.region.base_url(),
encoded
);
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let captured = self.session.get_captured_responses().await?;
let mut found: Option<Value> = None;
for r in captured {
if r.url.contains("/api/v4/search/search_user") {
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
found = Some(v);
}
}
}
let body = found.ok_or_else(|| {
TailFinError::Api(
"no /api/v4/search/search_user response captured. \
Did the search page load? CAPTCHA / verify-page \
would block the SPA."
.to_string(),
)
})?;
parse_search_user(&body, keyword)
}
pub async fn homepage_bundle(&self) -> Result<HomepageBundle, TailFinError> {
self.ensure_capture_installed().await?;
let url = format!("{}/", self.region.base_url());
navigate_force(&self.session, &url).await?;
let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let captured = self.session.get_captured_responses().await?;
let routed = route_homepage_responses(&captured)?;
let cart = routed
.cart_body
.as_ref()
.and_then(|b| match parse_cart_mini(b) {
Ok(c) => Some(c),
Err(e) => {
eprintln!(
"[tail-fin-shopee::homepage_bundle] cart_mini parse failed, \
degrading cart to None: {e}"
);
None
}
});
let discover = combine_discover(
routed.discover_body.as_ref(),
routed.flash_sale_body.as_ref(),
routed.mall_shops_body.as_ref(),
);
let categories = routed
.categories_body
.as_ref()
.map(parse_category_tree)
.unwrap_or_default();
Ok(HomepageBundle {
cart,
discover,
categories,
})
}
}
#[derive(Debug)]
struct RoutedHomepage {
cart_body: Option<Value>,
categories_body: Option<Value>,
discover_body: Option<Value>,
flash_sale_body: Option<Value>,
mall_shops_body: Option<Value>,
}
fn route_homepage_responses(
captured: &[tail_fin_common::CapturedResponse],
) -> Result<RoutedHomepage, TailFinError> {
let mut cart_body: Option<Value> = None;
let mut categories_body: Option<Value> = None;
let mut discover_body: Option<Value> = None;
let mut flash_sale_body: Option<Value> = None;
let mut mall_shops_body: Option<Value> = None;
for r in captured {
let maybe_set = |slot: &mut Option<Value>| {
if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
*slot = Some(v);
}
};
if r.url.contains("/api/v4/cart/mini") {
maybe_set(&mut cart_body);
} else if r.url.contains("/api/v4/pages/get_homepage_category_list") {
maybe_set(&mut categories_body);
} else if r.url.contains("/api/v4/homepage/get_daily_discover") {
maybe_set(&mut discover_body);
} else if r.url.contains("/api/v4/flash_sale/flash_sale_get_items") {
maybe_set(&mut flash_sale_body);
} else if r.url.contains("/api/v4/homepage/mall_shops") {
maybe_set(&mut mall_shops_body);
}
}
let nothing_captured = cart_body.is_none()
&& categories_body.is_none()
&& discover_body.is_none()
&& flash_sale_body.is_none()
&& mall_shops_body.is_none();
if nothing_captured {
return Err(TailFinError::Api(
"no homepage endpoints captured — CAPTCHA / verify-page \
likely blocked the homepage render."
.to_string(),
));
}
Ok(RoutedHomepage {
cart_body,
categories_body,
discover_body,
flash_sale_body,
mall_shops_body,
})
}
async fn navigate_force(session: &BrowserSession, url: &str) -> Result<(), TailFinError> {
let url_lit = serde_json::to_string(url)
.map_err(|e| TailFinError::Api(format!("failed to encode navigation URL: {e}")))?;
let pre = session.get_url().await.unwrap_or_default();
let _ = session.eval(&format!("location.assign({url_lit})")).await;
const POLL_INTERVAL_MS: u64 = 200;
const ATTEMPTS: u32 = 30;
const RETRY_AT: u32 = ATTEMPTS / 2;
for attempt in 0..ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS)).await;
if let Ok(now) = session.get_url().await {
if now != pre {
return Ok(());
}
}
if attempt == RETRY_AT {
let _ = session.eval(&format!("location.assign({url_lit})")).await;
}
}
Ok(())
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match *b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
b' ' => out.push('+'),
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_encode_handles_ascii_and_unicode() {
assert_eq!(url_encode("iPhone 15 Pro"), "iPhone+15+Pro");
assert_eq!(url_encode("hello"), "hello");
assert_eq!(url_encode("手機"), "%E6%89%8B%E6%A9%9F");
}
fn captured(url: &str, body: &str) -> tail_fin_common::CapturedResponse {
serde_json::from_value(json!({
"url": url,
"status": 200,
"headers": [],
"body": body,
"is_binary": false
}))
.expect("CapturedResponse fixture")
}
#[test]
fn route_homepage_empty_buffer_hard_fails() {
let err = route_homepage_responses(&[]).unwrap_err();
assert!(
err.to_string().contains("no homepage endpoints captured"),
"expected hard-fail message, got: {err}"
);
}
#[test]
fn route_homepage_categories_only_succeeds() {
let captured = vec![captured(
"https://shopee.tw/api/v4/pages/get_homepage_category_list",
r#"{"data":{"category_list":[]}}"#,
)];
let routed = route_homepage_responses(&captured).expect("partial capture should succeed");
assert!(routed.cart_body.is_none());
assert!(routed.categories_body.is_some());
assert!(routed.discover_body.is_none());
assert!(routed.flash_sale_body.is_none());
assert!(routed.mall_shops_body.is_none());
}
#[test]
fn route_homepage_routes_each_endpoint_to_its_slot() {
let captured = vec![
captured(
"https://shopee.tw/api/v4/cart/mini",
r#"{"error":0,"data":{"total_cart_item_count":1,"unique_cart_item_count":1}}"#,
),
captured(
"https://shopee.tw/api/v4/pages/get_homepage_category_list",
r#"{"data":{"category_list":[]}}"#,
),
captured(
"https://shopee.tw/api/v4/homepage/get_daily_discover",
r#"{"data":{"feeds":[],"feed_total":0}}"#,
),
captured(
"https://shopee.tw/api/v4/flash_sale/flash_sale_get_items",
r#"{"error":0,"data":{"items":[]}}"#,
),
captured(
"https://shopee.tw/api/v4/homepage/mall_shops",
r#"{"data":{"shops":[]}}"#,
),
];
let routed = route_homepage_responses(&captured).expect("full capture");
assert!(routed.cart_body.is_some());
assert!(routed.categories_body.is_some());
assert!(routed.discover_body.is_some());
assert!(routed.flash_sale_body.is_some());
assert!(routed.mall_shops_body.is_some());
}
#[test]
fn route_homepage_corrupt_body_doesnt_drop_earlier_good_one() {
let captured = vec![
captured(
"https://shopee.tw/api/v4/cart/mini",
r#"{"error":0,"data":{"total_cart_item_count":5}}"#,
),
captured("https://shopee.tw/api/v4/cart/mini", "<<not json>>"),
];
let routed = route_homepage_responses(&captured).expect("partial capture");
let cart = routed.cart_body.expect("first good body should survive");
assert_eq!(
cart.pointer("/data/total_cart_item_count")
.and_then(|v| v.as_u64()),
Some(5)
);
}
#[test]
fn route_homepage_ignores_unrelated_urls() {
let captured = vec![
captured(
"https://shopee.tw/api/v4/search/search_items?keyword=iPhone",
r#"{"items":[],"total_count":0}"#,
),
captured(
"https://shopee.tw/api/v4/pages/get_homepage_category_list",
r#"{"data":{"category_list":[]}}"#,
),
];
let routed = route_homepage_responses(&captured).expect("partial capture");
assert!(routed.categories_body.is_some());
assert!(routed.cart_body.is_none());
assert!(routed.discover_body.is_none());
}
}