use serde_json::Value;
use tail_fin_common::TailFinError;
use crate::types::{
CartItem, CartPreview, Category, CategoryDetail, Discover, FlashSaleItem, MallShop,
ProductDetail, ProductModel, RecommendedItem, RelatedItems, Review, Reviews, SearchItem,
SearchResults, ShopInfo, ShopItems, UserMatch, UserSearchResults,
};
pub fn parse_search_items(
body: &Value,
keyword: &str,
page: u32,
) -> Result<SearchResults, TailFinError> {
let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
if err_code != 0 {
let msg = body
.get("error_msg")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if err_code == 90309999 {
return Err(TailFinError::Api(format!(
"Shopee anti-bot wall (error 90309999): {msg}. \
The attached Chrome tab needs more browsing history / \
a completed order before Shopee will trust it for search."
)));
}
return Err(TailFinError::Api(format!("Shopee error {err_code}: {msg}")));
}
let items_arr = body
.get("items")
.and_then(|v| v.as_array())
.ok_or_else(|| TailFinError::Parse("missing `items` array in search response".into()))?;
let mut items = Vec::with_capacity(items_arr.len());
for raw in items_arr {
let basic = raw.get("item_basic").unwrap_or(raw);
items.push(parse_one_item(basic));
}
let total_count = body
.get("total_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
Ok(SearchResults {
keyword: keyword.to_string(),
total_count,
items,
page,
})
}
pub fn parse_recommend_pdp(body: &Value) -> Vec<RecommendedItem> {
body.pointer("/data/sections")
.and_then(|v| v.as_array())
.map(|sections| {
sections
.iter()
.flat_map(|s| {
s.get("units")
.and_then(|u| u.as_array())
.map(|a| a.iter().map(parse_recommended_item).collect::<Vec<_>>())
.unwrap_or_default()
})
.collect()
})
.unwrap_or_default()
}
pub fn parse_hot_sales(body: &Value) -> Vec<RecommendedItem> {
body.pointer("/data/item_cards")
.and_then(|v| v.as_array())
.map(|cards| cards.iter().map(parse_one_hot_sale_card).collect())
.unwrap_or_default()
}
fn parse_recommended_item(v: &Value) -> RecommendedItem {
let (rating_star, rating_count) = read_rating(v).unwrap_or((0.0, 0));
RecommendedItem {
itemid: pick_u64(v, &["itemid", "item_id"]),
shopid: pick_u64(v, &["shopid", "shop_id"]),
name: pick_str(v, &["name", "title"]).unwrap_or_default(),
price: pick_u64(v, &["price"]),
currency: pick_str(v, &["currency"]),
stock: pick_i64(v, &["stock"]),
is_sold_out: false,
rating_star,
rating_count,
image: pick_str(v, &["image"]),
shop_location: pick_str(v, &["shop_location"]),
}
}
fn parse_one_hot_sale_card(card: &Value) -> RecommendedItem {
let item_data = card.get("item_data").unwrap_or(&Value::Null);
let asset = card
.get("item_card_displayed_asset")
.unwrap_or(&Value::Null);
let price = asset
.pointer("/display_price/price")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let (rating_star, rating_count) = read_rating(item_data).unwrap_or((0.0, 0));
RecommendedItem {
itemid: pick_u64(item_data, &["itemid", "item_id"]),
shopid: pick_u64(item_data, &["shopid", "shop_id"]),
name: pick_str(asset, &["name"]).unwrap_or_default(),
price,
currency: pick_str(item_data, &["currency"]),
stock: pick_i64(item_data, &["stock"]),
is_sold_out: false,
rating_star,
rating_count,
image: pick_str(asset, &["image"]),
shop_location: pick_str(asset, &["shop_location"]),
}
}
pub fn combine_related(
hot_sales_body: Option<&Value>,
recommend_body: Option<&Value>,
source_shopid: u64,
source_itemid: u64,
) -> RelatedItems {
RelatedItems {
source_shopid,
source_itemid,
hot_sales: hot_sales_body.map(parse_hot_sales).unwrap_or_default(),
recommended: recommend_body.map(parse_recommend_pdp).unwrap_or_default(),
}
}
fn parse_one_item(b: &Value) -> SearchItem {
let item_rating = b.get("item_rating");
let rating_star = item_rating
.and_then(|r| r.get("rating_star"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let rating_count = item_rating
.and_then(|r| r.get("rating_count"))
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_u64())
.unwrap_or(0);
SearchItem {
itemid: b.get("itemid").and_then(|v| v.as_u64()).unwrap_or(0),
shopid: b.get("shopid").and_then(|v| v.as_u64()).unwrap_or(0),
name: b
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
price: b.get("price").and_then(|v| v.as_u64()).unwrap_or(0),
price_min: b.get("price_min").and_then(|v| v.as_u64()).unwrap_or(0),
price_max: b.get("price_max").and_then(|v| v.as_u64()).unwrap_or(0),
currency: b
.get("currency")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
stock: b.get("stock").and_then(|v| v.as_i64()).unwrap_or(0),
historical_sold: b
.get("historical_sold")
.and_then(|v| v.as_i64())
.unwrap_or(0),
liked_count: b.get("liked_count").and_then(|v| v.as_u64()).unwrap_or(0),
rating_star,
rating_count,
shop_location: b
.get("shop_location")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
image: b
.get("image")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
}
}
pub fn parse_daily_discover(body: &Value) -> (Vec<RecommendedItem>, u64) {
let feeds = body
.pointer("/data/feeds")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|f| f.get("centralised_item_card"))
.filter(|card| card.get("item_data").is_some())
.map(parse_one_hot_sale_card)
.collect()
})
.unwrap_or_default();
let total = body
.pointer("/data/feed_total")
.and_then(|v| v.as_u64())
.unwrap_or(0);
(feeds, total)
}
pub fn parse_flash_sale_items(body: &Value) -> Vec<FlashSaleItem> {
body.pointer("/data/items")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(parse_one_flash_sale_item).collect())
.unwrap_or_default()
}
fn parse_one_flash_sale_item(v: &Value) -> FlashSaleItem {
FlashSaleItem {
itemid: pick_u64(v, &["itemid", "item_id"]),
shopid: pick_u64(v, &["shopid", "shop_id"]),
name: pick_str(v, &["name", "title"]).unwrap_or_default(),
price: pick_u64(v, &["price"]),
raw_discount: v
.get("raw_discount")
.and_then(|x| x.as_u64())
.map(|n| n.min(u64::from(u32::MAX)) as u32)
.unwrap_or(0),
end_time: pick_i64(v, &["end_time"]),
stock: pick_i64(v, &["stock"]),
image: pick_str(v, &["image"]),
promotionid: pick_u64(v, &["promotionid", "promotion_id"]),
}
}
pub fn parse_mall_shops(body: &Value) -> Vec<MallShop> {
body.pointer("/data/shops")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(parse_one_mall_shop).collect())
.unwrap_or_default()
}
fn parse_one_mall_shop(v: &Value) -> MallShop {
MallShop {
shopid: pick_u64(v, &["shopid", "shop_id"]),
url: pick_str(v, &["url"]).unwrap_or_default(),
image: pick_str(v, &["image"]),
promo_text: pick_str(v, &["promo_text"]),
}
}
pub fn combine_discover(
discover_body: Option<&Value>,
flash_sale_body: Option<&Value>,
mall_shops_body: Option<&Value>,
) -> Discover {
let (feeds, feed_total) = discover_body.map(parse_daily_discover).unwrap_or_default();
Discover {
feeds,
feed_total,
flash_sale: flash_sale_body
.map(parse_flash_sale_items)
.unwrap_or_default(),
mall_shops: mall_shops_body.map(parse_mall_shops).unwrap_or_default(),
}
}
pub fn parse_category_tree(body: &Value) -> Vec<Category> {
body.pointer("/data/category_list")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(parse_one_category).collect())
.unwrap_or_default()
}
fn parse_one_category(v: &Value) -> Category {
Category {
catid: pick_u64(v, &["catid", "cat_id"]),
parent_catid: pick_u64(v, &["parent_catid", "parent_cat_id"]),
name: pick_str(v, &["name"]).unwrap_or_default(),
display_name: pick_str(v, &["display_name"]).unwrap_or_default(),
image: pick_str(v, &["image"]),
level: v
.get("level")
.and_then(|x| x.as_u64())
.map(|n| n.min(u64::from(u32::MAX)) as u32)
.unwrap_or(0),
children: v
.get("children")
.and_then(|x| x.as_array())
.map(|a| a.iter().map(parse_one_category).collect())
.unwrap_or_default(),
}
}
pub fn parse_fe_category_detail(body: &Value) -> Option<CategoryDetail> {
let cat = body
.pointer("/data/categories")
.and_then(|v| v.as_array())
.and_then(|a| a.first())?;
let display_name = cat
.get("display_name")
.and_then(extract_default_locale_value)
.unwrap_or_default();
let parent_cat_id = cat
.get("parent_cat_id")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|x| x.as_u64())
.or_else(|| cat.get("parent_cat_id").and_then(|v| v.as_u64()));
Some(CategoryDetail {
catid: pick_u64(cat, &["catid", "cat_id"]),
name: pick_str(cat, &["name"]).unwrap_or_default(),
display_name,
level: cat
.get("level")
.and_then(|x| x.as_u64())
.map(|n| n.min(u64::from(u32::MAX)) as u32)
.unwrap_or(0),
parent_cat_id,
image: pick_str(cat, &["image"]),
})
}
fn extract_default_locale_value(v: &Value) -> Option<String> {
let arr = v.as_array()?;
if arr.is_empty() {
return None;
}
if let Some(default) = arr
.iter()
.find(|e| e.get("is_default").and_then(|d| d.as_bool()) == Some(true))
{
return default
.get("value")
.and_then(|x| x.as_str())
.map(str::to_string);
}
arr.first()
.and_then(|e| e.get("value").and_then(|x| x.as_str()))
.map(str::to_string)
}
pub fn parse_shop_items(body: &Value, shopid: u64, page: u32) -> Result<ShopItems, TailFinError> {
let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
if err_code != 0 {
let msg = body
.get("error_msg")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if err_code == 90309999 {
return Err(TailFinError::Api(format!(
"Shopee anti-bot wall (error 90309999) on shop items: {msg}. \
Profile trust may have reverted."
)));
}
return Err(TailFinError::Api(format!(
"Shopee error {err_code} on shop items: {msg}"
)));
}
let items: Vec<RecommendedItem> = body
.pointer("/centralize_item_card/item_cards")
.or_else(|| body.pointer("/data/centralize_item_card/item_cards"))
.and_then(|v| v.as_array())
.map(|a| a.iter().map(parse_one_shop_item_card).collect())
.unwrap_or_default();
let total_count = body
.get("total_count")
.and_then(|v| v.as_u64())
.or_else(|| body.pointer("/data/total").and_then(|v| v.as_u64()))
.unwrap_or(0);
let nomore = body
.get("nomore")
.and_then(|v| v.as_bool())
.or_else(|| body.pointer("/data/no_more").and_then(|v| v.as_bool()))
.unwrap_or(false);
Ok(ShopItems {
shopid,
page,
total_count,
nomore,
items,
})
}
fn parse_one_shop_item_card(card: &Value) -> RecommendedItem {
let asset = card
.get("item_card_displayed_asset")
.unwrap_or(&Value::Null);
let (rating_star, rating_count) = read_rating(card).unwrap_or((0.0, 0));
let price = asset
.pointer("/display_price/price")
.and_then(|v| v.as_u64())
.or_else(|| {
card.pointer("/item_card_display_price/price")
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
RecommendedItem {
itemid: pick_u64(card, &["itemid", "item_id"]),
shopid: pick_u64(card, &["shopid", "shop_id"]),
name: pick_str(asset, &["name"]).unwrap_or_default(),
price,
currency: pick_str(card, &["currency"]),
stock: -1,
is_sold_out: card
.get("is_sold_out")
.and_then(|v| v.as_bool())
.unwrap_or(false),
rating_star,
rating_count,
image: pick_str(asset, &["image"]),
shop_location: pick_str(asset, &["shop_location"]),
}
}
pub fn parse_shop_info(body: &Value) -> Result<ShopInfo, TailFinError> {
let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
if err_code != 0 {
let msg = body
.get("error_msg")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if err_code == 90309999 {
return Err(TailFinError::Api(format!(
"Shopee anti-bot wall (error 90309999) on get_shop_info: {msg}. \
Profile trust may have reverted."
)));
}
return Err(TailFinError::Api(format!(
"Shopee error {err_code} on get_shop_info: {msg}"
)));
}
let data = body
.get("data")
.ok_or_else(|| TailFinError::Parse("missing `data` in get_shop_info response".into()))?;
Ok(ShopInfo {
shop_id: pick_u64(data, &["shop_id", "shopid"]),
user_id: pick_u64(data, &["user_id", "userid"]),
name: pick_str(data, &["name"]).unwrap_or_default(),
place: pick_str(data, &["place"]),
is_official_shop: data
.get("is_official_shop")
.and_then(|v| v.as_bool())
.unwrap_or(false),
is_shopee_verified: data
.get("is_shopee_verified")
.and_then(|v| v.as_bool())
.unwrap_or(false),
holiday_mode: data
.get("holiday_mode")
.and_then(|v| v.as_bool())
.unwrap_or(false),
item_count: pick_u64(data, &["item_count"]),
follower_count: pick_u64(data, &["follower_count"]),
rating_star: data
.get("rating_star")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
rating_good: pick_u64(data, &["rating_good"]),
rating_normal: pick_u64(data, &["rating_normal"]),
rating_bad: pick_u64(data, &["rating_bad"]),
response_rate: data
.get("response_rate")
.and_then(|v| v.as_u64())
.map(|n| n.min(u64::from(u32::MAX)) as u32)
.unwrap_or(0),
response_time: pick_u64(data, &["response_time"]),
ctime: pick_i64(data, &["ctime"]),
last_active_time: pick_i64(data, &["last_active_time"]),
})
}
pub fn parse_reviews(body: &Value, shopid: u64, itemid: u64) -> Result<Reviews, TailFinError> {
let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
if err_code != 0 {
let msg = body
.get("error_msg")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if err_code == 90309999 {
return Err(TailFinError::Api(format!(
"Shopee anti-bot wall (error 90309999) on get_ratings: {msg}. \
Profile trust may have reverted."
)));
}
return Err(TailFinError::Api(format!(
"Shopee error {err_code} on get_ratings: {msg}"
)));
}
let data = body
.get("data")
.ok_or_else(|| TailFinError::Parse("missing `data` in get_ratings response".into()))?;
let ratings: Vec<Review> = data
.get("ratings")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(parse_one_review).collect())
.unwrap_or_default();
Ok(Reviews {
itemid,
shopid,
item_rating_star: data
.get("item_rating_star")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
item_rating_count: pick_u64(data, &["item_rating_count"]),
has_more: data
.get("has_more")
.and_then(|v| v.as_bool())
.unwrap_or(false),
ratings,
})
}
fn parse_one_review(v: &Value) -> Review {
Review {
cmtid: pick_u64(v, &["cmtid"]),
itemid: pick_u64(v, &["itemid", "item_id"]),
shopid: pick_u64(v, &["shopid", "shop_id"]),
rating_star: v
.get("rating_star")
.and_then(|x| x.as_u64())
.map(|n| n.min(u64::from(u32::MAX)) as u32)
.unwrap_or(0),
comment: pick_str(v, &["comment"]).unwrap_or_default(),
images: v
.get("images")
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
ctime: pick_i64(v, &["ctime"]),
author_username: pick_str(v, &["author_username"]).unwrap_or_default(),
anonymous: v
.get("anonymous")
.and_then(|x| x.as_bool())
.unwrap_or(false),
}
}
pub fn parse_search_user(body: &Value, keyword: &str) -> Result<UserSearchResults, TailFinError> {
let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
if err_code != 0 {
let msg = body
.get("error_msg")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if err_code == 90309999 {
return Err(TailFinError::Api(format!(
"Shopee anti-bot wall (error 90309999) on search_user: {msg}. \
Profile trust may have reverted."
)));
}
return Err(TailFinError::Api(format!(
"Shopee error {err_code} on search_user: {msg}"
)));
}
let users: Vec<UserMatch> = body
.pointer("/data/users")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(parse_one_user_match).collect())
.unwrap_or_default();
Ok(UserSearchResults {
keyword: keyword.to_string(),
users,
})
}
fn parse_one_user_match(v: &Value) -> UserMatch {
UserMatch {
shopid: pick_u64(v, &["shopid", "shop_id"]),
userid: pick_u64(v, &["userid", "user_id"]),
username: pick_str(v, &["username"]).unwrap_or_default(),
shopname: pick_str(v, &["shopname"]).unwrap_or_default(),
nickname: pick_str(v, &["nickname"]).unwrap_or_default(),
portrait: pick_str(v, &["portrait"]),
shop_rating: v.get("shop_rating").and_then(|x| x.as_f64()).unwrap_or(0.0),
follower_count: pick_u64(v, &["follower_count"]),
products: pick_u64(v, &["products"]),
is_official_shop: v
.get("is_official_shop")
.and_then(|x| x.as_bool())
.unwrap_or(false),
shopee_verified_flag: v
.get("shopee_verified_flag")
.and_then(|x| x.as_u64())
.map(|n| n.min(u64::from(u32::MAX)) as u32)
.unwrap_or(0),
response_rate: v
.get("response_rate")
.and_then(|x| x.as_u64())
.map(|n| n.min(u64::from(u32::MAX)) as u32)
.unwrap_or(0),
response_time: pick_u64(v, &["response_time"]),
country: pick_str(v, &["country"]).unwrap_or_default(),
}
}
pub fn parse_cart_mini(body: &Value) -> Result<CartPreview, TailFinError> {
let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
if err_code != 0 {
let msg = body
.get("error_msg")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if err_code == 90309999 {
return Err(TailFinError::Api(format!(
"Shopee anti-bot wall (error 90309999) on cart/mini: {msg}. \
The attached Chrome profile's trust may have reverted; \
manually browse + add-to-cart a few times then retry."
)));
}
return Err(TailFinError::Api(format!(
"Shopee error {err_code} on cart/mini: {msg}"
)));
}
let data = body
.get("data")
.ok_or_else(|| TailFinError::Parse("missing `data` in cart/mini response".into()))?;
let recent_items: Vec<CartItem> = data
.get("recent_cart_item_details")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(parse_one_cart_item).collect())
.unwrap_or_default();
Ok(CartPreview {
total_count: data
.get("total_cart_item_count")
.and_then(|v| v.as_u64())
.unwrap_or(0),
unique_count: data
.get("unique_cart_item_count")
.and_then(|v| v.as_u64())
.unwrap_or(0),
recent_items,
})
}
fn parse_one_cart_item(v: &Value) -> CartItem {
CartItem {
itemid: pick_u64(v, &["itemid", "item_id"]),
shopid: pick_u64(v, &["shopid", "shop_id"]),
modelid: pick_u64(v, &["modelid", "model_id"]),
name: pick_str(v, &["name", "title"]).unwrap_or_default(),
price: pick_u64(v, &["price"]),
image: pick_str(v, &["image"]),
status: pick_i64(v, &["status"]),
is_add_on_sub_item: v
.get("is_add_on_sub_item")
.and_then(|x| x.as_bool())
.unwrap_or(false),
}
}
pub fn parse_product_detail(body: &Value) -> Result<ProductDetail, TailFinError> {
let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
if err_code != 0 {
let msg = body
.get("error_msg")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if err_code == 90309999 {
return Err(TailFinError::Api(format!(
"Shopee anti-bot wall (error 90309999): {msg}. \
The attached Chrome tab needs more browsing history / \
a completed order before Shopee will trust it for \
product detail."
)));
}
if err_code == 4 {
return Err(TailFinError::Api(format!(
"Shopee item not found (error 4): {msg}. \
The product may have been removed or the itemid/shopid \
pair is wrong."
)));
}
return Err(TailFinError::Api(format!("Shopee error {err_code}: {msg}")));
}
let item = unwrap_item(body).ok_or_else(|| {
TailFinError::Parse("missing item object in product-detail response".into())
})?;
let pr = body.pointer("/data/product_review");
let pi = body.pointer("/data/product_images");
let pa = body.pointer("/data/product_attributes");
let sd = body.pointer("/data/shop_detailed");
let (rating_star, rating_count) = read_rating(item).unwrap_or_else(|| {
pr.map(|r| {
let s = r.get("rating_star").and_then(|v| v.as_f64()).unwrap_or(0.0);
let c = r
.get("rating_count")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_u64())
.unwrap_or(0);
(s, c)
})
.unwrap_or((0.0, 0))
});
let images = read_images(item)
.or_else(|| {
pi.and_then(|v| v.get("images"))
.and_then(read_images_from_value)
})
.unwrap_or_default();
let cover_image = item
.get("image")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| images.first().cloned());
let models: Vec<ProductModel> = item
.get("models")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(parse_one_model).collect())
.or_else(|| {
pa.and_then(|v| v.get("models"))
.and_then(|v| v.as_array())
.map(|a| a.iter().map(parse_one_model).collect())
})
.unwrap_or_default();
Ok(ProductDetail {
itemid: pick_u64(item, &["item_id", "itemid"]),
shopid: pick_u64(item, &["shop_id", "shopid"]),
name: pick_str(item, &["title", "name"]).unwrap_or_default(),
description: pick_str(item, &["description"]),
price: pick_u64(item, &["price"]),
price_min: pick_u64(item, &["price_min"]),
price_max: pick_u64(item, &["price_max"]),
currency: pick_str(item, &["currency"]),
stock: pick_i64(item, &["stock"]),
historical_sold: pick_i64(item, &["historical_sold"]),
liked_count: pick_u64(item, &["liked_count"]),
rating_star,
rating_count,
shop_location: pick_str(item, &["shop_location"]).or_else(|| {
sd.and_then(|v| v.get("shop_location"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}),
image: cover_image,
images,
models,
})
}
fn unwrap_item(body: &Value) -> Option<&Value> {
let candidates: [Option<&Value>; 4] = [
body.pointer("/data/item"),
body.pointer("/data"),
body.pointer("/item"),
Some(body),
];
for c in candidates.iter().flatten() {
let has_id = c.get("item_id").and_then(|v| v.as_u64()).is_some()
|| c.get("itemid").and_then(|v| v.as_u64()).is_some();
if has_id {
return Some(c);
}
}
None
}
fn pick_u64(v: &Value, keys: &[&str]) -> u64 {
for k in keys {
if let Some(n) = v.get(k).and_then(|x| x.as_u64()) {
return n;
}
}
0
}
fn pick_i64(v: &Value, keys: &[&str]) -> i64 {
for k in keys {
if let Some(n) = v.get(k).and_then(|x| x.as_i64()) {
return n;
}
}
0
}
fn pick_str(v: &Value, keys: &[&str]) -> Option<String> {
for k in keys {
if let Some(s) = v.get(k).and_then(|x| x.as_str()) {
return Some(s.to_string());
}
}
None
}
fn read_rating(item: &Value) -> Option<(f64, u64)> {
let r = item.get("item_rating")?;
let s = r.get("rating_star").and_then(|v| v.as_f64())?;
let c = r
.get("rating_count")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_u64())
.unwrap_or(0);
Some((s, c))
}
fn read_images(item: &Value) -> Option<Vec<String>> {
item.get("images").and_then(|v| v.as_array()).and_then(|a| {
let v: Vec<String> = a
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if v.is_empty() {
None
} else {
Some(v)
}
})
}
fn read_images_from_value(v: &Value) -> Option<Vec<String>> {
v.as_array().map(|a| {
a.iter()
.filter_map(|v| {
v.as_str().map(|s| s.to_string()).or_else(|| {
v.get("image_id")
.or_else(|| v.get("image_hash"))
.and_then(|x| x.as_str())
.map(|s| s.to_string())
})
})
.collect()
})
}
fn parse_one_model(v: &Value) -> ProductModel {
let price = v
.pointer("/price/single_value")
.and_then(|x| x.as_u64())
.or_else(|| v.get("price").and_then(|x| x.as_u64()))
.unwrap_or(0);
ProductModel {
modelid: pick_u64(v, &["model_id", "modelid"]),
name: v
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
price,
stock: v.get("stock").and_then(|v| v.as_i64()).unwrap_or(0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fixture() -> Value {
json!({
"error": 0,
"error_msg": null,
"total_count": 8000,
"items": [
{
"itemid": 12345,
"shopid": 67890,
"item_basic": {
"itemid": 12345,
"shopid": 67890,
"name": "iPhone 15 Pro 256GB 黑色",
"price": 3999000000_u64,
"price_min": 3999000000_u64,
"price_max": 5499000000_u64,
"currency": "TWD",
"stock": 99,
"historical_sold": 100,
"liked_count": 250,
"shop_location": "新北市",
"image": "abc123def456",
"item_rating": {
"rating_star": 4.85,
"rating_count": [123, 5, 10, 20, 30, 58]
}
}
},
{
"itemid": 99999,
"shopid": 11111,
"item_basic": {
"itemid": 99999,
"shopid": 11111,
"name": "Stock-hidden item",
"price": 50000000,
"price_min": 50000000,
"price_max": 50000000,
"currency": "TWD",
"stock": -1,
"historical_sold": -1,
"liked_count": 0,
"shop_location": "Singapore",
"image": null,
"item_rating": null
}
}
]
})
}
#[test]
fn parses_two_items_and_echoes_keyword() {
let r = parse_search_items(&fixture(), "iPhone", 0).expect("parse");
assert_eq!(r.keyword, "iPhone");
assert_eq!(r.total_count, 8000);
assert_eq!(r.items.len(), 2);
}
#[test]
fn first_item_has_full_fields_populated() {
let r = parse_search_items(&fixture(), "iPhone", 0).unwrap();
let it = &r.items[0];
assert_eq!(it.itemid, 12345);
assert_eq!(it.shopid, 67890);
assert_eq!(it.name, "iPhone 15 Pro 256GB 黑色");
assert_eq!(it.price, 3_999_000_000);
assert_eq!(it.price_min, 3_999_000_000);
assert_eq!(it.price_max, 5_499_000_000);
assert_eq!(it.currency.as_deref(), Some("TWD"));
assert_eq!(it.stock, 99);
assert_eq!(it.historical_sold, 100);
assert_eq!(it.liked_count, 250);
assert!((it.rating_star - 4.85).abs() < 1e-6);
assert_eq!(it.rating_count, 123);
assert_eq!(it.shop_location.as_deref(), Some("新北市"));
assert_eq!(it.image.as_deref(), Some("abc123def456"));
}
#[test]
fn second_item_handles_hidden_stock_and_null_rating() {
let r = parse_search_items(&fixture(), "iPhone", 0).unwrap();
let it = &r.items[1];
assert_eq!(it.stock, -1);
assert_eq!(it.historical_sold, -1);
assert_eq!(it.rating_star, 0.0);
assert_eq!(it.rating_count, 0);
assert!(it.image.is_none());
}
#[test]
fn parse_falls_back_to_outer_when_item_basic_missing() {
let body = json!({
"error": 0,
"items": [{
"itemid": 1,
"shopid": 2,
"name": "flat-shape item",
"price": 1000000,
"price_min": 1000000,
"price_max": 1000000
}]
});
let r = parse_search_items(&body, "x", 0).unwrap();
assert_eq!(r.items.len(), 1);
assert_eq!(r.items[0].name, "flat-shape item");
assert_eq!(r.items[0].price, 1_000_000);
}
#[test]
fn captcha_wall_surfaces_distinct_error() {
let body = json!({
"error": 90309999,
"error_msg": "anti-bot triggered"
});
let err = parse_search_items(&body, "x", 0).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("90309999"),
"expected captcha-wall hint, got: {msg}"
);
assert!(msg.contains("trust") || msg.contains("history"));
}
#[test]
fn other_error_codes_surface_generically() {
let body = json!({
"error": 44,
"error_msg": "must login"
});
let err = parse_search_items(&body, "x", 0).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("44"));
assert!(msg.contains("must login"));
}
#[test]
fn missing_items_field_is_parse_error() {
let body = json!({ "error": 0 });
let err = parse_search_items(&body, "x", 0).unwrap_err();
assert!(err.to_string().contains("missing"));
}
fn detail_fixture_pdp() -> Value {
json!({
"error": 0,
"data": {
"item": {
"itemid": 12345,
"shopid": 67890,
"name": "iPhone 15 Pro 256GB 黑色",
"description": "All-new iPhone 15 Pro with titanium design.",
"price": 3999000000_u64,
"price_min": 3999000000_u64,
"price_max": 5499000000_u64,
"currency": "TWD",
"stock": 99,
"historical_sold": 100,
"liked_count": 250,
"shop_location": "新北市",
"image": "abc123",
"images": ["abc123", "def456", "ghi789"],
"item_rating": {
"rating_star": 4.85,
"rating_count": [123, 5, 10, 20, 30, 58]
},
"models": [
{
"modelid": 11111,
"name": "黑色,256GB",
"price": 3999000000_u64,
"stock": 50
},
{
"modelid": 22222,
"name": "白色,512GB",
"price": 4799000000_u64,
"stock": -1
}
]
}
}
})
}
fn detail_fixture_legacy() -> Value {
json!({
"error": 0,
"item": {
"itemid": 999,
"shopid": 888,
"name": "Old item",
"price": 1000000,
"price_min": 1000000,
"price_max": 1000000
}
})
}
#[test]
fn parses_pdp_wrapper_shape() {
let r = parse_product_detail(&detail_fixture_pdp()).expect("parse");
assert_eq!(r.itemid, 12345);
assert_eq!(r.shopid, 67890);
assert_eq!(r.name, "iPhone 15 Pro 256GB 黑色");
assert_eq!(
r.description.as_deref(),
Some("All-new iPhone 15 Pro with titanium design.")
);
assert_eq!(r.price, 3_999_000_000);
assert_eq!(r.currency.as_deref(), Some("TWD"));
assert_eq!(r.stock, 99);
assert!((r.rating_star - 4.85).abs() < 1e-6);
assert_eq!(r.rating_count, 123);
}
#[test]
fn parses_image_gallery() {
let r = parse_product_detail(&detail_fixture_pdp()).unwrap();
assert_eq!(r.images, vec!["abc123", "def456", "ghi789"]);
assert_eq!(r.image.as_deref(), Some("abc123"));
}
#[test]
fn item_images_with_only_null_elements_falls_through_to_fan_out() {
let body = json!({
"error": 0,
"data": {
"item": {
"itemid": 1,
"shopid": 2,
"name": "x",
"price": 100,
"price_min": 100,
"price_max": 100,
"images": [null, null]
},
"product_images": {
"images": [{ "image_id": "fallback_aaa" }]
}
}
});
let r = parse_product_detail(&body).unwrap();
assert_eq!(r.images, vec!["fallback_aaa"]);
}
#[test]
fn parses_variant_models() {
let r = parse_product_detail(&detail_fixture_pdp()).unwrap();
assert_eq!(r.models.len(), 2);
assert_eq!(r.models[0].modelid, 11111);
assert_eq!(r.models[0].name, "黑色,256GB");
assert_eq!(r.models[0].stock, 50);
assert_eq!(r.models[1].stock, -1);
assert_eq!(r.models[1].price, 4_799_000_000);
}
#[test]
fn parses_legacy_wrapper_shape() {
let r = parse_product_detail(&detail_fixture_legacy()).expect("parse legacy");
assert_eq!(r.itemid, 999);
assert_eq!(r.shopid, 888);
assert_eq!(r.name, "Old item");
assert!(r.description.is_none());
assert!(r.images.is_empty());
assert!(r.models.is_empty());
}
#[test]
fn detail_captcha_wall_surfaces_distinct_error() {
let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
let err = parse_product_detail(&body).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("90309999"));
assert!(msg.contains("trust") || msg.contains("history"));
}
#[test]
fn detail_item_not_found_surfaces_distinct_error() {
let body = json!({ "error": 4, "error_msg": "item not found" });
let err = parse_product_detail(&body).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found"));
assert!(msg.contains("removed") || msg.contains("itemid"));
}
#[test]
fn detail_missing_item_object_is_parse_error() {
let body = json!({ "error": 0, "data": { "version": "x" } });
let err = parse_product_detail(&body).unwrap_err();
assert!(err.to_string().contains("missing item"));
}
#[test]
fn detail_falls_back_to_root_when_unwrapped() {
let body = json!({
"error": 0,
"itemid": 7,
"shopid": 8,
"name": "root-shape",
"price": 500000,
"price_min": 500000,
"price_max": 500000
});
let r = parse_product_detail(&body).unwrap();
assert_eq!(r.itemid, 7);
assert_eq!(r.name, "root-shape");
}
fn detail_fixture_modern_pdp() -> Value {
json!({
"error": 0,
"data": {
"item": {
"item_id": 43968123553_u64,
"shop_id": 188277742,
"title": "Apple iPhone 17 Pro 256GB 手機",
"description": "Apple iPhone 17 Pro 256GB...",
"currency": "TWD",
"price": 3990000000_u64,
"price_min": 3990000000_u64,
"price_max": 3990000000_u64,
"stock": 50,
"historical_sold": 25,
"liked_count": 100,
"image": "img_hash_aaa",
"images": ["img_hash_aaa", "img_hash_bbb"],
"shop_location": "新北市",
"item_rating": {
"rating_star": 4.92,
"rating_count": [50, 1, 0, 2, 5, 42]
},
"models": [
{
"model_id": 296491431379_u64,
"name": "黑色,256GB",
"price": 3990000000_u64,
"stock": 50
}
]
}
}
})
}
#[test]
fn parses_modern_pdp_shape() {
let r = parse_product_detail(&detail_fixture_modern_pdp()).expect("parse modern");
assert_eq!(r.itemid, 43_968_123_553);
assert_eq!(r.shopid, 188_277_742);
assert_eq!(r.name, "Apple iPhone 17 Pro 256GB 手機");
assert_eq!(r.price, 3_990_000_000);
assert_eq!(r.currency.as_deref(), Some("TWD"));
assert_eq!(r.stock, 50);
assert_eq!(r.historical_sold, 25);
assert!((r.rating_star - 4.92).abs() < 1e-6);
assert_eq!(r.rating_count, 50);
}
#[test]
fn modern_pdp_image_and_models_round_trip() {
let r = parse_product_detail(&detail_fixture_modern_pdp()).unwrap();
assert_eq!(r.images, vec!["img_hash_aaa", "img_hash_bbb"]);
assert_eq!(r.image.as_deref(), Some("img_hash_aaa"));
assert_eq!(r.models.len(), 1);
assert_eq!(r.models[0].modelid, 296_491_431_379);
assert_eq!(r.models[0].name, "黑色,256GB");
assert_eq!(r.models[0].price, 3_990_000_000);
}
#[test]
fn modern_pdp_falls_back_to_product_review_when_item_rating_absent() {
let mut body = detail_fixture_modern_pdp();
body["data"]["item"]
.as_object_mut()
.unwrap()
.remove("item_rating");
body["data"]["product_review"] = json!({
"rating_star": 4.5,
"rating_count": [10, 0, 0, 1, 2, 7]
});
let r = parse_product_detail(&body).unwrap();
assert!((r.rating_star - 4.5).abs() < 1e-6);
assert_eq!(r.rating_count, 10);
}
#[test]
fn modern_pdp_falls_back_to_product_images_with_image_id() {
let mut body = detail_fixture_modern_pdp();
body["data"]["item"]
.as_object_mut()
.unwrap()
.remove("images");
body["data"]["product_images"] = json!({
"images": [
{ "image_id": "fan_aaa" },
{ "image_id": "fan_bbb" }
]
});
let r = parse_product_detail(&body).unwrap();
assert_eq!(r.images, vec!["fan_aaa", "fan_bbb"]);
}
fn recommend_pdp_fixture() -> Value {
json!({
"error": null,
"data": {
"sections": [{
"key": "you_may_also_like",
"total": 600,
"units": [
{
"itemid": 28991283813_u64,
"shopid": 188277742,
"name": "iPhone 17 Pro 512GB",
"price": 4690000000_u64,
"currency": "TWD",
"stock": 1,
"image": "img_aaa",
"shop_location": "桃園市",
"item_rating": {
"rating_star": 4.989,
"rating_count": [553, 1, 0, 0, 2, 550]
}
},
{
"itemid": 99,
"shopid": 88,
"name": "iPhone 16",
"price": 2990000000_u64,
"stock": -1,
"image": "img_bbb"
}
]
}]
}
})
}
#[test]
fn parses_recommend_pdp_units() {
let r = parse_recommend_pdp(&recommend_pdp_fixture());
assert_eq!(r.len(), 2);
assert_eq!(r[0].itemid, 28_991_283_813);
assert_eq!(r[0].shopid, 188_277_742);
assert_eq!(r[0].name, "iPhone 17 Pro 512GB");
assert_eq!(r[0].price, 4_690_000_000);
assert_eq!(r[0].rating_count, 553);
assert!((r[0].rating_star - 4.989).abs() < 1e-3);
assert_eq!(r[1].stock, -1);
}
#[test]
fn empty_sections_yield_empty_recommendations() {
let body = json!({ "data": { "sections": [] } });
assert!(parse_recommend_pdp(&body).is_empty());
let body = json!({ "data": {} });
assert!(parse_recommend_pdp(&body).is_empty());
}
fn hot_sales_fixture() -> Value {
json!({
"error": null,
"data": {
"card_set": { "card_set_name": "FTSS TPFS card" },
"item_cards": [
{
"item_data": {
"itemid": 40518550283_u64,
"shopid": 109729156,
"item_rating": {
"rating_star": 5.0,
"rating_count": [82, 0, 0, 0, 0, 82]
}
},
"item_card_displayed_asset": {
"name": "iPhone 17 Pro Max 256G 全新",
"image": "tw-hot-aaa",
"display_price": {
"price": 4697900000_u64,
"strikethrough_price": null
}
}
}
]
}
})
}
#[test]
fn parses_hot_sales_split_fields() {
let r = parse_hot_sales(&hot_sales_fixture());
assert_eq!(r.len(), 1);
let c = &r[0];
assert_eq!(c.itemid, 40_518_550_283);
assert_eq!(c.shopid, 109_729_156);
assert_eq!(c.name, "iPhone 17 Pro Max 256G 全新");
assert_eq!(c.image.as_deref(), Some("tw-hot-aaa"));
assert_eq!(c.price, 4_697_900_000);
assert_eq!(c.rating_count, 82);
assert!((c.rating_star - 5.0).abs() < 1e-6);
}
#[test]
fn missing_item_cards_yields_empty_hot_sales() {
let body = json!({ "data": {} });
assert!(parse_hot_sales(&body).is_empty());
}
#[test]
fn combine_related_pairs_both_endpoints() {
let r = combine_related(
Some(&hot_sales_fixture()),
Some(&recommend_pdp_fixture()),
999,
888,
);
assert_eq!(r.source_shopid, 999);
assert_eq!(r.source_itemid, 888);
assert_eq!(r.hot_sales.len(), 1);
assert_eq!(r.recommended.len(), 2);
}
#[test]
fn combine_related_handles_one_side_missing() {
let r = combine_related(Some(&hot_sales_fixture()), None, 1, 2);
assert_eq!(r.hot_sales.len(), 1);
assert!(r.recommended.is_empty());
}
#[test]
fn search_results_carry_page_index() {
let r = parse_search_items(&fixture(), "iPhone", 3).unwrap();
assert_eq!(r.page, 3);
}
fn cart_fixture() -> Value {
json!({
"error": 0,
"error_msg": null,
"data": {
"total_cart_item_count": 22,
"unique_cart_item_count": 20,
"translation_status": 0,
"recent_cart_item_details": [
{
"itemid": 44302564613_u64,
"shopid": 983412696,
"modelid": 227845841315_u64,
"bundle_deal_id": 0,
"is_add_on_sub_item": false,
"name": "COSTCO 好市多 Webber Naturals 甘胺酸鋅膠囊 240粒",
"price": 81900000,
"is_wholesale_price": false,
"status": 1,
"image": "tw-img-aaa",
"promotion_type": 0
},
{
"itemid": 12345,
"shopid": 678,
"modelid": 0,
"is_add_on_sub_item": true,
"name": "Add-on freebie",
"price": 0,
"status": 1,
"image": null
}
]
}
})
}
#[test]
fn parses_cart_mini_counts_and_items() {
let r = parse_cart_mini(&cart_fixture()).expect("parse");
assert_eq!(r.total_count, 22);
assert_eq!(r.unique_count, 20);
assert_eq!(r.recent_items.len(), 2);
let it = &r.recent_items[0];
assert_eq!(it.itemid, 44_302_564_613);
assert_eq!(it.shopid, 983_412_696);
assert_eq!(it.modelid, 227_845_841_315);
assert_eq!(it.name, "COSTCO 好市多 Webber Naturals 甘胺酸鋅膠囊 240粒");
assert_eq!(it.price, 81_900_000);
assert_eq!(it.image.as_deref(), Some("tw-img-aaa"));
assert_eq!(it.status, 1);
assert!(!it.is_add_on_sub_item);
}
#[test]
fn parses_cart_add_on_sub_item_flag() {
let r = parse_cart_mini(&cart_fixture()).unwrap();
let add_on = &r.recent_items[1];
assert!(add_on.is_add_on_sub_item);
assert_eq!(add_on.price, 0);
assert_eq!(add_on.modelid, 0);
assert!(add_on.image.is_none());
}
#[test]
fn empty_cart_yields_zero_counts_and_no_items() {
let body = json!({
"error": 0,
"data": {
"total_cart_item_count": 0,
"unique_cart_item_count": 0
}
});
let r = parse_cart_mini(&body).unwrap();
assert_eq!(r.total_count, 0);
assert_eq!(r.unique_count, 0);
assert!(r.recent_items.is_empty());
}
#[test]
fn cart_captcha_wall_surfaces_distinct_error() {
let body = json!({
"error": 90309999,
"error_msg": "anti-bot triggered"
});
let err = parse_cart_mini(&body).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("90309999"));
assert!(msg.contains("trust") || msg.contains("warm"));
}
#[test]
fn cart_handles_null_error_field_as_success() {
let body = json!({
"error": null,
"data": {
"total_cart_item_count": 1,
"unique_cart_item_count": 1,
"recent_cart_item_details": []
}
});
let r = parse_cart_mini(&body).unwrap();
assert_eq!(r.total_count, 1);
}
#[test]
fn cart_missing_data_is_parse_error() {
let body = json!({ "error": 0 });
let err = parse_cart_mini(&body).unwrap_err();
assert!(err.to_string().contains("missing `data`"));
}
fn daily_discover_fixture() -> Value {
json!({
"error": null,
"data": {
"feed_total": 500,
"feeds": [
{
"type": "centralised_item_card",
"centralised_item_card": {
"item_data": {
"itemid": 1001_u64,
"shopid": 2001_u64,
"item_rating": {
"rating_star": 4.7,
"rating_count": [42, 0, 0, 1, 5, 36]
}
},
"item_card_displayed_asset": {
"name": "discover-feed-aaa",
"image": "tw-feed-aaa",
"display_price": { "price": 199000000_u64 }
}
}
},
{
"type": "centralised_item_card",
"centralised_item_card": {
"item_data": { "itemid": 1002, "shopid": 2002 },
"item_card_displayed_asset": {
"name": "discover-feed-bbb",
"image": "tw-feed-bbb",
"display_price": { "price": 99000000_u64 }
}
}
}
]
}
})
}
#[test]
fn parses_daily_discover_feeds_via_hot_sale_layout() {
let (feeds, total) = parse_daily_discover(&daily_discover_fixture());
assert_eq!(total, 500);
assert_eq!(feeds.len(), 2);
assert_eq!(feeds[0].itemid, 1001);
assert_eq!(feeds[0].name, "discover-feed-aaa");
assert_eq!(feeds[0].price, 199_000_000);
assert!((feeds[0].rating_star - 4.7).abs() < 1e-3);
assert_eq!(feeds[0].rating_count, 42);
}
#[test]
fn daily_discover_skips_non_centralised_card_feeds() {
let body = json!({
"data": {
"feed_total": 100,
"feeds": [
{ "type": "ads_item_card", "ads_item_card": { "id": 999 } },
{
"type": "centralised_item_card",
"centralised_item_card": {
"item_data": { "itemid": 7, "shopid": 8 },
"item_card_displayed_asset": {
"name": "real-item",
"display_price": { "price": 50000000_u64 }
}
}
}
]
}
});
let (feeds, _total) = parse_daily_discover(&body);
assert_eq!(feeds.len(), 1);
assert_eq!(feeds[0].name, "real-item");
}
#[test]
fn daily_discover_skips_centralised_card_without_item_data() {
let body = json!({
"data": {
"feed_total": 50,
"feeds": [
{
"type": "centralised_item_card",
"centralised_item_card": {
"item_card_displayed_asset": {
"name": "orphan-no-item-data",
"display_price": { "price": 1000000_u64 }
}
}
},
{
"type": "centralised_item_card",
"centralised_item_card": {
"item_data": { "itemid": 9, "shopid": 10 },
"item_card_displayed_asset": {
"name": "real-item",
"display_price": { "price": 50000000_u64 }
}
}
}
]
}
});
let (feeds, _) = parse_daily_discover(&body);
assert_eq!(feeds.len(), 1);
assert_eq!(feeds[0].itemid, 9);
assert_eq!(feeds[0].name, "real-item");
}
fn flash_sale_fixture() -> Value {
json!({
"error": 0,
"data": {
"items": [
{
"itemid": 9001_u64,
"shopid": 8001_u64,
"name": "P&G ARIEL 4D炭酸洗衣膠球",
"price": 18300000_u64,
"raw_discount": 69,
"end_time": 1777521600,
"stock": 692,
"image": "https://mms.img.susercontent.com/full-url-aaa",
"promotionid": 245737892360192_u64
}
]
}
})
}
#[test]
fn parses_flash_sale_items() {
let r = parse_flash_sale_items(&flash_sale_fixture());
assert_eq!(r.len(), 1);
let it = &r[0];
assert_eq!(it.itemid, 9001);
assert_eq!(it.shopid, 8001);
assert_eq!(it.price, 18_300_000);
assert_eq!(it.raw_discount, 69);
assert_eq!(it.end_time, 1_777_521_600);
assert_eq!(it.stock, 692);
assert!(it.image.as_deref().unwrap().starts_with("https://"));
assert_eq!(it.promotionid, 245_737_892_360_192);
}
fn mall_shops_fixture() -> Value {
json!({
"error": null,
"data": {
"shops": [
{
"shopid": 23047686,
"url": "https://shopee.tw/cookingstar",
"image": "tw-shop-aaa",
"promo_text": "無門檻8折券"
},
{
"shopid": 26221748,
"url": "https://shopee.tw/tokuyo",
"image": null,
"promo_text": null
}
]
}
})
}
#[test]
fn parses_mall_shops() {
let r = parse_mall_shops(&mall_shops_fixture());
assert_eq!(r.len(), 2);
assert_eq!(r[0].shopid, 23_047_686);
assert_eq!(r[0].url, "https://shopee.tw/cookingstar");
assert_eq!(r[0].image.as_deref(), Some("tw-shop-aaa"));
assert_eq!(r[0].promo_text.as_deref(), Some("無門檻8折券"));
assert!(r[1].image.is_none());
assert!(r[1].promo_text.is_none());
}
#[test]
fn combine_discover_pairs_all_three() {
let d = combine_discover(
Some(&daily_discover_fixture()),
Some(&flash_sale_fixture()),
Some(&mall_shops_fixture()),
);
assert_eq!(d.feeds.len(), 2);
assert_eq!(d.feed_total, 500);
assert_eq!(d.flash_sale.len(), 1);
assert_eq!(d.mall_shops.len(), 2);
}
#[test]
fn combine_discover_handles_missing_endpoints() {
let d = combine_discover(Some(&daily_discover_fixture()), None, None);
assert_eq!(d.feeds.len(), 2);
assert!(d.flash_sale.is_empty());
assert!(d.mall_shops.is_empty());
}
#[test]
fn missing_top_level_keys_yield_empty_collections() {
let empty = json!({});
let (feeds, total) = parse_daily_discover(&empty);
assert!(feeds.is_empty());
assert_eq!(total, 0);
assert!(parse_flash_sale_items(&empty).is_empty());
assert!(parse_mall_shops(&empty).is_empty());
assert!(parse_category_tree(&empty).is_empty());
}
fn category_tree_fixture() -> Value {
json!({
"data": {
"category_list": [
{
"catid": 11040766,
"parent_catid": 0,
"name": "Women's Apparel",
"display_name": "女生衣著",
"image": "17f3879a1872099681d7b85101e187db",
"level": 1,
"children": null
},
{
"catid": 11041120,
"parent_catid": 0,
"name": "Books & Magazines",
"display_name": "書籍及雜誌",
"image": "abc123",
"level": 1,
"children": null
}
]
}
})
}
#[test]
fn parses_category_tree_top_level() {
let r = parse_category_tree(&category_tree_fixture());
assert_eq!(r.len(), 2);
assert_eq!(r[0].catid, 11_040_766);
assert_eq!(r[0].parent_catid, 0);
assert_eq!(r[0].name, "Women's Apparel");
assert_eq!(r[0].display_name, "女生衣著");
assert_eq!(
r[0].image.as_deref(),
Some("17f3879a1872099681d7b85101e187db")
);
assert_eq!(r[0].level, 1);
assert!(r[0].children.is_empty());
}
#[test]
fn category_tree_recursive_children_are_parsed() {
let body = json!({
"data": {
"category_list": [{
"catid": 1,
"parent_catid": 0,
"name": "parent",
"display_name": "父",
"level": 1,
"children": [
{
"catid": 11,
"parent_catid": 1,
"name": "child-a",
"display_name": "子A",
"level": 2,
"children": null
},
{
"catid": 12,
"parent_catid": 1,
"name": "child-b",
"display_name": "子B",
"level": 2,
"children": [{
"catid": 121,
"parent_catid": 12,
"name": "grandchild",
"display_name": "孫",
"level": 3,
"children": null
}]
}
]
}]
}
});
let r = parse_category_tree(&body);
assert_eq!(r.len(), 1);
assert_eq!(r[0].children.len(), 2);
assert_eq!(r[0].children[0].catid, 11);
assert_eq!(r[0].children[0].level, 2);
assert_eq!(r[0].children[1].children.len(), 1);
assert_eq!(r[0].children[1].children[0].catid, 121);
assert_eq!(r[0].children[1].children[0].level, 3);
}
#[test]
fn missing_category_list_yields_empty() {
let body = json!({ "data": {} });
assert!(parse_category_tree(&body).is_empty());
}
#[test]
fn category_list_with_non_array_value_degrades_gracefully() {
let null_body = json!({ "data": { "category_list": null } });
assert!(parse_category_tree(&null_body).is_empty());
let string_body = json!({ "data": { "category_list": "oops" } });
assert!(parse_category_tree(&string_body).is_empty());
let object_body = json!({ "data": { "category_list": { "wrong": "shape" } } });
assert!(parse_category_tree(&object_body).is_empty());
}
fn fe_category_detail_subcategory_fixture() -> Value {
json!({
"data": {
"categories": [
{
"catid": 11042305,
"parent_cat_id": [11040766],
"name": "Pants",
"display_name": [
{ "lang": "zh-Hant", "value": "長褲", "is_default": true },
{ "lang": "zh-Hans", "value": "Pants", "is_default": false },
{ "lang": "en", "value": "Pants", "is_default": false }
],
"image": "tw-11134258-7rbkc-m8ej6wv3w0yvd7",
"level": 2,
"block_buyer_platform": null
}
]
}
})
}
#[test]
fn fe_category_detail_subcategory_parses() {
let cat = parse_fe_category_detail(&fe_category_detail_subcategory_fixture())
.expect("subcategory fixture parses to Some");
assert_eq!(cat.catid, 11_042_305);
assert_eq!(cat.name, "Pants");
assert_eq!(cat.display_name, "長褲");
assert_eq!(cat.level, 2);
assert_eq!(cat.parent_cat_id, Some(11_040_766));
assert_eq!(
cat.image.as_deref(),
Some("tw-11134258-7rbkc-m8ej6wv3w0yvd7")
);
}
#[test]
fn fe_category_detail_top_level_has_no_parent() {
let body = json!({
"data": {
"categories": [{
"catid": 11040766,
"parent_cat_id": null,
"name": "Women's Apparel",
"display_name": [
{ "lang": "zh-Hant", "value": "女生衣著", "is_default": true }
],
"image": "17f3879a1872099681d7b85101e187db",
"level": 1,
"block_buyer_platform": null
}]
}
});
let cat = parse_fe_category_detail(&body).expect("top-level fixture parses");
assert_eq!(cat.catid, 11_040_766);
assert_eq!(cat.name, "Women's Apparel");
assert_eq!(cat.display_name, "女生衣著");
assert_eq!(cat.level, 1);
assert!(cat.parent_cat_id.is_none(), "top-level has no parent");
}
#[test]
fn fe_category_detail_accepts_scalar_parent_cat_id() {
let body = json!({
"data": {
"categories": [{
"catid": 999,
"parent_cat_id": 100,
"name": "scalar",
"display_name": [{ "lang": "zh-Hant", "value": "X", "is_default": true }],
"level": 2
}]
}
});
let cat = parse_fe_category_detail(&body).expect("scalar parent fixture parses");
assert_eq!(cat.parent_cat_id, Some(100));
}
#[test]
fn fe_category_detail_picks_first_when_no_default_locale() {
let body = json!({
"data": {
"categories": [{
"catid": 1,
"parent_cat_id": null,
"name": "n",
"display_name": [
{ "lang": "en", "value": "fallback", "is_default": false }
],
"level": 1
}]
}
});
let cat = parse_fe_category_detail(&body).expect("fallback fixture parses");
assert_eq!(cat.display_name, "fallback");
}
#[test]
fn fe_category_detail_malformed_default_returns_none_not_other_locale() {
let body = json!({
"data": {
"categories": [{
"catid": 1,
"parent_cat_id": null,
"name": "n",
"display_name": [
{ "lang": "zh-Hant", "is_default": true },
{ "lang": "en", "value": "WRONG_LOCALE", "is_default": false }
],
"level": 1
}]
}
});
let cat = parse_fe_category_detail(&body).expect("malformed-default fixture parses");
assert_eq!(cat.display_name, "");
}
#[test]
fn fe_category_detail_missing_data_yields_none() {
assert!(parse_fe_category_detail(&json!({})).is_none());
assert!(parse_fe_category_detail(&json!({ "data": {} })).is_none());
assert!(parse_fe_category_detail(&json!({ "data": { "categories": [] } })).is_none());
assert!(parse_fe_category_detail(&json!({ "data": { "categories": null } })).is_none());
}
fn shop_info_fixture() -> Value {
json!({
"error": 0,
"error_msg": null,
"data": {
"shop_id": 1530245671_u64,
"user_id": 1531065367_u64,
"last_active_time": 1777515787,
"holiday_mode": false,
"place": "700 臺南市中西區和意路68號",
"is_shopee_verified": false,
"is_official_shop": true,
"item_count": 400,
"rating_star": 4.987321002386635,
"response_rate": 50,
"name": "miko 米可|手機門號配件專賣",
"ctime": 1745843453,
"response_time": 0,
"follower_count": 10539,
"rating_bad": 2,
"rating_good": 6964,
"rating_normal": 14
}
})
}
#[test]
fn parses_shop_info_full_fields() {
let r = parse_shop_info(&shop_info_fixture()).expect("parse");
assert_eq!(r.shop_id, 1_530_245_671);
assert_eq!(r.user_id, 1_531_065_367);
assert_eq!(r.name, "miko 米可|手機門號配件專賣");
assert_eq!(r.place.as_deref(), Some("700 臺南市中西區和意路68號"));
assert!(r.is_official_shop);
assert!(!r.is_shopee_verified);
assert!(!r.holiday_mode);
assert_eq!(r.item_count, 400);
assert_eq!(r.follower_count, 10_539);
assert!((r.rating_star - 4.987_321).abs() < 1e-3);
assert_eq!(r.rating_good, 6964);
assert_eq!(r.rating_normal, 14);
assert_eq!(r.rating_bad, 2);
assert_eq!(r.response_rate, 50);
assert_eq!(r.ctime, 1_745_843_453);
}
#[test]
fn shop_info_captcha_wall_surfaces_distinct_error() {
let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
let err = parse_shop_info(&body).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("90309999"));
assert!(msg.contains("trust") || msg.contains("Profile"));
}
#[test]
fn shop_info_missing_data_is_parse_error() {
let body = json!({ "error": 0 });
let err = parse_shop_info(&body).unwrap_err();
assert!(err.to_string().contains("missing `data`"));
}
#[test]
fn shop_info_sparse_response_defaults_cleanly() {
let body = json!({
"error": 0,
"data": {
"shop_id": 999,
"name": "new-shop",
"rating_star": 0.0
}
});
let r = parse_shop_info(&body).unwrap();
assert_eq!(r.shop_id, 999);
assert_eq!(r.name, "new-shop");
assert_eq!(r.item_count, 0);
assert_eq!(r.follower_count, 0);
assert!(!r.is_official_shop);
assert!(r.place.is_none());
}
fn reviews_fixture() -> Value {
json!({
"error": 0,
"data": {
"ratings": [
{
"cmtid": 96399040702_u64,
"itemid": 42818537019_u64,
"shopid": 109729156,
"rating_star": 5,
"comment": "",
"ctime": 1776650189,
"author_username": "k*****s",
"anonymous": true
},
{
"cmtid": 96399040703_u64,
"itemid": 42818537019_u64,
"shopid": 109729156,
"rating_star": 4,
"comment": "包裝完整,送達快速",
"ctime": 1776650200,
"author_username": "alice123",
"anonymous": false,
"images": ["tw-img-rev-aaa", "tw-img-rev-bbb"]
}
],
"item_rating_star": 4.5,
"item_rating_count": 6,
"has_more": true
}
})
}
#[test]
fn parses_reviews_with_summary_and_pagination_flag() {
let r = parse_reviews(&reviews_fixture(), 109_729_156, 42_818_537_019).expect("parse");
assert_eq!(r.shopid, 109_729_156);
assert_eq!(r.itemid, 42_818_537_019);
assert!((r.item_rating_star - 4.5).abs() < 1e-6);
assert_eq!(r.item_rating_count, 6);
assert!(r.has_more);
assert_eq!(r.ratings.len(), 2);
}
#[test]
fn reviews_first_entry_anonymous_no_text_no_images() {
let r = parse_reviews(&reviews_fixture(), 109_729_156, 42_818_537_019).unwrap();
let rev = &r.ratings[0];
assert_eq!(rev.cmtid, 96_399_040_702);
assert_eq!(rev.rating_star, 5);
assert!(rev.comment.is_empty());
assert!(rev.anonymous);
assert!(rev.images.is_empty());
assert_eq!(rev.author_username, "k*****s");
}
#[test]
fn reviews_second_entry_with_text_and_images() {
let r = parse_reviews(&reviews_fixture(), 109_729_156, 42_818_537_019).unwrap();
let rev = &r.ratings[1];
assert_eq!(rev.rating_star, 4);
assert_eq!(rev.comment, "包裝完整,送達快速");
assert!(!rev.anonymous);
assert_eq!(rev.images, vec!["tw-img-rev-aaa", "tw-img-rev-bbb"]);
}
#[test]
fn reviews_captcha_wall_surfaces_distinct_error() {
let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
let err = parse_reviews(&body, 1, 2).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("90309999"));
}
#[test]
fn reviews_empty_ratings_is_valid() {
let body = json!({
"error": 0,
"data": {
"ratings": [],
"item_rating_star": 0.0,
"item_rating_count": 0,
"has_more": false
}
});
let r = parse_reviews(&body, 1, 2).unwrap();
assert!(r.ratings.is_empty());
assert_eq!(r.item_rating_count, 0);
assert!(!r.has_more);
}
fn shop_items_fixture() -> Value {
json!({
"error": 0,
"error_msg": "",
"total_count": 28,
"nomore": true,
"centralize_item_card": {
"item_cards": [
{
"itemid": 1449269277_u64,
"shopid": 355141,
"is_sold_out": false,
"item_rating": {
"rating_star": 4.934240362811791,
"rating_count": [882, 2, 2, 12, 20, 846]
},
"liked_count": 999,
"item_card_display_price": {
"price": 103100000_u64
},
"item_card_displayed_asset": {
"name": "Wilson 籃球 R68",
"image": "tw-img-shop-aaa",
"display_price": { "price": 103100000_u64 },
"sold_count": { "text": "已售出 1000+" }
}
},
{
"itemid": 9999_u64,
"shopid": 355141,
"is_sold_out": true,
"item_card_display_price": { "price": 50000000_u64 },
"item_card_displayed_asset": {
"name": "OOS item",
"image": "tw-img-shop-bbb",
"display_price": { "price": 50000000_u64 }
}
}
]
}
})
}
#[test]
fn parses_shop_items_with_pagination_metadata() {
let r = parse_shop_items(&shop_items_fixture(), 355141, 0).expect("parse");
assert_eq!(r.shopid, 355141);
assert_eq!(r.page, 0);
assert_eq!(r.total_count, 28);
assert!(r.nomore);
assert_eq!(r.items.len(), 2);
}
#[test]
fn shop_items_first_card_has_full_fields() {
let r = parse_shop_items(&shop_items_fixture(), 355141, 0).unwrap();
let it = &r.items[0];
assert_eq!(it.itemid, 1_449_269_277);
assert_eq!(it.shopid, 355141);
assert_eq!(it.name, "Wilson 籃球 R68");
assert_eq!(it.image.as_deref(), Some("tw-img-shop-aaa"));
assert_eq!(it.price, 103_100_000);
assert!((it.rating_star - 4.934_240).abs() < 1e-3);
assert_eq!(it.rating_count, 882);
assert_eq!(it.stock, -1);
assert!(!it.is_sold_out);
}
#[test]
fn shop_items_sold_out_card_sets_is_sold_out_flag() {
let r = parse_shop_items(&shop_items_fixture(), 355141, 0).unwrap();
let oos = &r.items[1];
assert!(oos.name.contains("OOS"));
assert_eq!(oos.stock, -1);
assert!(oos.is_sold_out);
}
#[test]
fn shop_items_page_index_round_trips() {
let r = parse_shop_items(&shop_items_fixture(), 355141, 3).unwrap();
assert_eq!(r.page, 3);
}
#[test]
fn shop_items_captcha_wall_surfaces_distinct_error() {
let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
let err = parse_shop_items(&body, 1, 0).unwrap_err();
assert!(err.to_string().contains("90309999"));
}
#[test]
fn shop_items_missing_centralize_item_card_yields_empty() {
let body = json!({ "error": 0, "total_count": 0, "nomore": true });
let r = parse_shop_items(&body, 1, 0).unwrap();
assert!(r.items.is_empty());
assert!(r.nomore);
}
fn shop_items_rcmd_fixture() -> Value {
json!({
"error": 0,
"data": {
"total": 1553,
"no_more": false,
"centralize_item_card": {
"item_cards": [
{
"itemid": 71204627_u64,
"shopid": 355141,
"is_sold_out": false,
"item_rating": {
"rating_star": 4.8,
"rating_count": [50, 0, 1, 2, 5, 42]
},
"item_card_displayed_asset": {
"name": "rcmd-item-aaa",
"image": "tw-img-rcmd-aaa",
"display_price": { "price": 75000000_u64 }
}
}
]
}
}
})
}
#[test]
fn parses_rcmd_items_wrapped_shape() {
let r = parse_shop_items(&shop_items_rcmd_fixture(), 355141, 0).expect("parse rcmd");
assert_eq!(r.shopid, 355141);
assert_eq!(r.total_count, 1553);
assert!(!r.nomore); assert_eq!(r.items.len(), 1);
let it = &r.items[0];
assert_eq!(it.itemid, 71_204_627);
assert_eq!(it.name, "rcmd-item-aaa");
assert_eq!(it.price, 75_000_000);
assert_eq!(it.stock, -1);
assert!(!it.is_sold_out);
}
fn search_user_fixture() -> Value {
json!({
"error": null,
"data": {
"users": [
{
"shopid": 1276618414_u64,
"userid": 1277122037_u64,
"username": "orz_orz_orz",
"shopname": " 銘鈺標識",
"nickname": " 銘鈺標識",
"portrait": "tw-11134216-81ztn-mgj5ztv0q51k66",
"shop_rating": 4.981132075471698,
"follower_count": 137,
"products": 277,
"is_official_shop": false,
"shopee_verified_flag": 1,
"response_rate": 100,
"response_time": 3855,
"country": "tw"
}
]
}
})
}
#[test]
fn parses_search_user_shop_match() {
let r = parse_search_user(&search_user_fixture(), "籃球").expect("parse");
assert_eq!(r.keyword, "籃球");
assert_eq!(r.users.len(), 1);
let u = &r.users[0];
assert_eq!(u.shopid, 1_276_618_414);
assert_eq!(u.userid, 1_277_122_037);
assert_eq!(u.username, "orz_orz_orz");
assert_eq!(u.shopname, " 銘鈺標識");
assert_eq!(
u.portrait.as_deref(),
Some("tw-11134216-81ztn-mgj5ztv0q51k66")
);
assert!((u.shop_rating - 4.981).abs() < 1e-3);
assert_eq!(u.follower_count, 137);
assert_eq!(u.products, 277);
assert!(!u.is_official_shop);
assert_eq!(u.shopee_verified_flag, 1);
assert_eq!(u.response_rate, 100);
assert_eq!(u.country, "tw");
}
#[test]
fn search_user_handles_null_error_field_as_success() {
let r = parse_search_user(&search_user_fixture(), "x").unwrap();
assert!(!r.users.is_empty());
}
#[test]
fn search_user_captcha_wall_surfaces_distinct_error() {
let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
let err = parse_search_user(&body, "x").unwrap_err();
assert!(err.to_string().contains("90309999"));
}
#[test]
fn search_user_empty_users_is_valid() {
let body = json!({ "error": 0, "data": { "users": [] } });
let r = parse_search_user(&body, "non-existent-keyword").unwrap();
assert!(r.users.is_empty());
}
#[test]
fn search_user_missing_data_yields_empty_users() {
let body = json!({ "error": 0 });
let r = parse_search_user(&body, "x").unwrap();
assert!(r.users.is_empty());
}
}