1use serde_json::Value;
7use tail_fin_common::TailFinError;
8
9use crate::types::{
10 CartItem, CartPreview, Category, CategoryDetail, Discover, FlashSaleItem, MallShop,
11 ProductDetail, ProductModel, RecommendedItem, RelatedItems, Review, Reviews, SearchItem,
12 SearchResults, ShopInfo, ShopItems, UserMatch, UserSearchResults,
13};
14
15pub fn parse_search_items(
28 body: &Value,
29 keyword: &str,
30 page: u32,
31) -> Result<SearchResults, TailFinError> {
32 let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
35 if err_code != 0 {
36 let msg = body
37 .get("error_msg")
38 .and_then(|v| v.as_str())
39 .unwrap_or("")
40 .to_string();
41 if err_code == 90309999 {
45 return Err(TailFinError::Api(format!(
46 "Shopee anti-bot wall (error 90309999): {msg}. \
47 The attached Chrome tab needs more browsing history / \
48 a completed order before Shopee will trust it for search."
49 )));
50 }
51 return Err(TailFinError::Api(format!("Shopee error {err_code}: {msg}")));
52 }
53
54 let items_arr = body
55 .get("items")
56 .and_then(|v| v.as_array())
57 .ok_or_else(|| TailFinError::Parse("missing `items` array in search response".into()))?;
58
59 let mut items = Vec::with_capacity(items_arr.len());
60 for raw in items_arr {
61 let basic = raw.get("item_basic").unwrap_or(raw);
65 items.push(parse_one_item(basic));
66 }
67
68 let total_count = body
69 .get("total_count")
70 .and_then(|v| v.as_u64())
71 .unwrap_or(0);
72
73 Ok(SearchResults {
74 keyword: keyword.to_string(),
75 total_count,
76 items,
77 page,
78 })
79}
80
81pub fn parse_recommend_pdp(body: &Value) -> Vec<RecommendedItem> {
91 body.pointer("/data/sections")
92 .and_then(|v| v.as_array())
93 .map(|sections| {
94 sections
95 .iter()
96 .flat_map(|s| {
97 s.get("units")
98 .and_then(|u| u.as_array())
99 .map(|a| a.iter().map(parse_recommended_item).collect::<Vec<_>>())
100 .unwrap_or_default()
101 })
102 .collect()
103 })
104 .unwrap_or_default()
105}
106
107pub fn parse_hot_sales(body: &Value) -> Vec<RecommendedItem> {
120 body.pointer("/data/item_cards")
121 .and_then(|v| v.as_array())
122 .map(|cards| cards.iter().map(parse_one_hot_sale_card).collect())
123 .unwrap_or_default()
124}
125
126fn parse_recommended_item(v: &Value) -> RecommendedItem {
127 let (rating_star, rating_count) = read_rating(v).unwrap_or((0.0, 0));
128 RecommendedItem {
129 itemid: pick_u64(v, &["itemid", "item_id"]),
130 shopid: pick_u64(v, &["shopid", "shop_id"]),
131 name: pick_str(v, &["name", "title"]).unwrap_or_default(),
132 price: pick_u64(v, &["price"]),
133 currency: pick_str(v, &["currency"]),
134 stock: pick_i64(v, &["stock"]),
135 is_sold_out: false,
139 rating_star,
140 rating_count,
141 image: pick_str(v, &["image"]),
142 shop_location: pick_str(v, &["shop_location"]),
143 }
144}
145
146fn parse_one_hot_sale_card(card: &Value) -> RecommendedItem {
147 let item_data = card.get("item_data").unwrap_or(&Value::Null);
148 let asset = card
149 .get("item_card_displayed_asset")
150 .unwrap_or(&Value::Null);
151
152 let price = asset
155 .pointer("/display_price/price")
156 .and_then(|v| v.as_u64())
157 .unwrap_or(0);
158 let (rating_star, rating_count) = read_rating(item_data).unwrap_or((0.0, 0));
159
160 RecommendedItem {
161 itemid: pick_u64(item_data, &["itemid", "item_id"]),
162 shopid: pick_u64(item_data, &["shopid", "shop_id"]),
163 name: pick_str(asset, &["name"]).unwrap_or_default(),
164 price,
165 currency: pick_str(item_data, &["currency"]),
166 stock: pick_i64(item_data, &["stock"]),
167 is_sold_out: false,
170 rating_star,
171 rating_count,
172 image: pick_str(asset, &["image"]),
173 shop_location: pick_str(asset, &["shop_location"]),
174 }
175}
176
177pub fn combine_related(
182 hot_sales_body: Option<&Value>,
183 recommend_body: Option<&Value>,
184 source_shopid: u64,
185 source_itemid: u64,
186) -> RelatedItems {
187 RelatedItems {
188 source_shopid,
189 source_itemid,
190 hot_sales: hot_sales_body.map(parse_hot_sales).unwrap_or_default(),
191 recommended: recommend_body.map(parse_recommend_pdp).unwrap_or_default(),
192 }
193}
194
195fn parse_one_item(b: &Value) -> SearchItem {
196 let item_rating = b.get("item_rating");
197 let rating_star = item_rating
198 .and_then(|r| r.get("rating_star"))
199 .and_then(|v| v.as_f64())
200 .unwrap_or(0.0);
201 let rating_count = item_rating
204 .and_then(|r| r.get("rating_count"))
205 .and_then(|v| v.as_array())
206 .and_then(|a| a.first())
207 .and_then(|v| v.as_u64())
208 .unwrap_or(0);
209
210 SearchItem {
211 itemid: b.get("itemid").and_then(|v| v.as_u64()).unwrap_or(0),
212 shopid: b.get("shopid").and_then(|v| v.as_u64()).unwrap_or(0),
213 name: b
214 .get("name")
215 .and_then(|v| v.as_str())
216 .unwrap_or("")
217 .to_string(),
218 price: b.get("price").and_then(|v| v.as_u64()).unwrap_or(0),
219 price_min: b.get("price_min").and_then(|v| v.as_u64()).unwrap_or(0),
220 price_max: b.get("price_max").and_then(|v| v.as_u64()).unwrap_or(0),
221 currency: b
222 .get("currency")
223 .and_then(|v| v.as_str())
224 .map(|s| s.to_string()),
225 stock: b.get("stock").and_then(|v| v.as_i64()).unwrap_or(0),
226 historical_sold: b
227 .get("historical_sold")
228 .and_then(|v| v.as_i64())
229 .unwrap_or(0),
230 liked_count: b.get("liked_count").and_then(|v| v.as_u64()).unwrap_or(0),
231 rating_star,
232 rating_count,
233 shop_location: b
234 .get("shop_location")
235 .and_then(|v| v.as_str())
236 .map(|s| s.to_string()),
237 image: b
238 .get("image")
239 .and_then(|v| v.as_str())
240 .map(|s| s.to_string()),
241 }
242}
243
244pub fn parse_daily_discover(body: &Value) -> (Vec<RecommendedItem>, u64) {
269 let feeds = body
270 .pointer("/data/feeds")
271 .and_then(|v| v.as_array())
272 .map(|a| {
273 a.iter()
274 .filter_map(|f| f.get("centralised_item_card"))
280 .filter(|card| card.get("item_data").is_some())
281 .map(parse_one_hot_sale_card)
282 .collect()
283 })
284 .unwrap_or_default();
285 let total = body
286 .pointer("/data/feed_total")
287 .and_then(|v| v.as_u64())
288 .unwrap_or(0);
289 (feeds, total)
290}
291
292pub fn parse_flash_sale_items(body: &Value) -> Vec<FlashSaleItem> {
294 body.pointer("/data/items")
295 .and_then(|v| v.as_array())
296 .map(|a| a.iter().map(parse_one_flash_sale_item).collect())
297 .unwrap_or_default()
298}
299
300fn parse_one_flash_sale_item(v: &Value) -> FlashSaleItem {
301 FlashSaleItem {
302 itemid: pick_u64(v, &["itemid", "item_id"]),
303 shopid: pick_u64(v, &["shopid", "shop_id"]),
304 name: pick_str(v, &["name", "title"]).unwrap_or_default(),
305 price: pick_u64(v, &["price"]),
306 raw_discount: v
307 .get("raw_discount")
308 .and_then(|x| x.as_u64())
309 .map(|n| n.min(u64::from(u32::MAX)) as u32)
310 .unwrap_or(0),
311 end_time: pick_i64(v, &["end_time"]),
312 stock: pick_i64(v, &["stock"]),
313 image: pick_str(v, &["image"]),
314 promotionid: pick_u64(v, &["promotionid", "promotion_id"]),
315 }
316}
317
318pub fn parse_mall_shops(body: &Value) -> Vec<MallShop> {
320 body.pointer("/data/shops")
321 .and_then(|v| v.as_array())
322 .map(|a| a.iter().map(parse_one_mall_shop).collect())
323 .unwrap_or_default()
324}
325
326fn parse_one_mall_shop(v: &Value) -> MallShop {
327 MallShop {
328 shopid: pick_u64(v, &["shopid", "shop_id"]),
329 url: pick_str(v, &["url"]).unwrap_or_default(),
330 image: pick_str(v, &["image"]),
331 promo_text: pick_str(v, &["promo_text"]),
332 }
333}
334
335pub fn combine_discover(
340 discover_body: Option<&Value>,
341 flash_sale_body: Option<&Value>,
342 mall_shops_body: Option<&Value>,
343) -> Discover {
344 let (feeds, feed_total) = discover_body.map(parse_daily_discover).unwrap_or_default();
345 Discover {
346 feeds,
347 feed_total,
348 flash_sale: flash_sale_body
349 .map(parse_flash_sale_items)
350 .unwrap_or_default(),
351 mall_shops: mall_shops_body.map(parse_mall_shops).unwrap_or_default(),
352 }
353}
354
355pub fn parse_category_tree(body: &Value) -> Vec<Category> {
363 body.pointer("/data/category_list")
364 .and_then(|v| v.as_array())
365 .map(|a| a.iter().map(parse_one_category).collect())
366 .unwrap_or_default()
367}
368
369fn parse_one_category(v: &Value) -> Category {
370 Category {
371 catid: pick_u64(v, &["catid", "cat_id"]),
376 parent_catid: pick_u64(v, &["parent_catid", "parent_cat_id"]),
377 name: pick_str(v, &["name"]).unwrap_or_default(),
378 display_name: pick_str(v, &["display_name"]).unwrap_or_default(),
379 image: pick_str(v, &["image"]),
380 level: v
381 .get("level")
382 .and_then(|x| x.as_u64())
383 .map(|n| n.min(u64::from(u32::MAX)) as u32)
384 .unwrap_or(0),
385 children: v
389 .get("children")
390 .and_then(|x| x.as_array())
391 .map(|a| a.iter().map(parse_one_category).collect())
392 .unwrap_or_default(),
393 }
394}
395
396pub fn parse_fe_category_detail(body: &Value) -> Option<CategoryDetail> {
416 let cat = body
417 .pointer("/data/categories")
418 .and_then(|v| v.as_array())
419 .and_then(|a| a.first())?;
420
421 let display_name = cat
422 .get("display_name")
423 .and_then(extract_default_locale_value)
424 .unwrap_or_default();
425
426 let parent_cat_id = cat
427 .get("parent_cat_id")
428 .and_then(|v| v.as_array())
429 .and_then(|a| a.first())
430 .and_then(|x| x.as_u64())
431 .or_else(|| cat.get("parent_cat_id").and_then(|v| v.as_u64()));
434
435 Some(CategoryDetail {
436 catid: pick_u64(cat, &["catid", "cat_id"]),
437 name: pick_str(cat, &["name"]).unwrap_or_default(),
438 display_name,
439 level: cat
440 .get("level")
441 .and_then(|x| x.as_u64())
442 .map(|n| n.min(u64::from(u32::MAX)) as u32)
443 .unwrap_or(0),
444 parent_cat_id,
445 image: pick_str(cat, &["image"]),
446 })
447}
448
449fn extract_default_locale_value(v: &Value) -> Option<String> {
461 let arr = v.as_array()?;
462 if arr.is_empty() {
463 return None;
464 }
465 if let Some(default) = arr
466 .iter()
467 .find(|e| e.get("is_default").and_then(|d| d.as_bool()) == Some(true))
468 {
469 return default
470 .get("value")
471 .and_then(|x| x.as_str())
472 .map(str::to_string);
473 }
474 arr.first()
478 .and_then(|e| e.get("value").and_then(|x| x.as_str()))
479 .map(str::to_string)
480}
481
482pub fn parse_shop_items(body: &Value, shopid: u64, page: u32) -> Result<ShopItems, TailFinError> {
502 let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
503 if err_code != 0 {
504 let msg = body
505 .get("error_msg")
506 .and_then(|v| v.as_str())
507 .unwrap_or("")
508 .to_string();
509 if err_code == 90309999 {
510 return Err(TailFinError::Api(format!(
511 "Shopee anti-bot wall (error 90309999) on shop items: {msg}. \
512 Profile trust may have reverted."
513 )));
514 }
515 return Err(TailFinError::Api(format!(
516 "Shopee error {err_code} on shop items: {msg}"
517 )));
518 }
519
520 let items: Vec<RecommendedItem> = body
524 .pointer("/centralize_item_card/item_cards")
525 .or_else(|| body.pointer("/data/centralize_item_card/item_cards"))
526 .and_then(|v| v.as_array())
527 .map(|a| a.iter().map(parse_one_shop_item_card).collect())
528 .unwrap_or_default();
529
530 let total_count = body
532 .get("total_count")
533 .and_then(|v| v.as_u64())
534 .or_else(|| body.pointer("/data/total").and_then(|v| v.as_u64()))
535 .unwrap_or(0);
536
537 let nomore = body
540 .get("nomore")
541 .and_then(|v| v.as_bool())
542 .or_else(|| body.pointer("/data/no_more").and_then(|v| v.as_bool()))
543 .unwrap_or(false);
544
545 Ok(ShopItems {
546 shopid,
547 page,
548 total_count,
549 nomore,
550 items,
551 })
552}
553
554fn parse_one_shop_item_card(card: &Value) -> RecommendedItem {
559 let asset = card
560 .get("item_card_displayed_asset")
561 .unwrap_or(&Value::Null);
562
563 let (rating_star, rating_count) = read_rating(card).unwrap_or((0.0, 0));
564 let price = asset
569 .pointer("/display_price/price")
570 .and_then(|v| v.as_u64())
571 .or_else(|| {
572 card.pointer("/item_card_display_price/price")
573 .and_then(|v| v.as_u64())
574 })
575 .unwrap_or(0);
576
577 RecommendedItem {
578 itemid: pick_u64(card, &["itemid", "item_id"]),
579 shopid: pick_u64(card, &["shopid", "shop_id"]),
580 name: pick_str(asset, &["name"]).unwrap_or_default(),
581 price,
582 currency: pick_str(card, &["currency"]),
583 stock: -1,
592 is_sold_out: card
593 .get("is_sold_out")
594 .and_then(|v| v.as_bool())
595 .unwrap_or(false),
596 rating_star,
597 rating_count,
598 image: pick_str(asset, &["image"]),
599 shop_location: pick_str(asset, &["shop_location"]),
600 }
601}
602
603pub fn parse_shop_info(body: &Value) -> Result<ShopInfo, TailFinError> {
610 let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
611 if err_code != 0 {
612 let msg = body
613 .get("error_msg")
614 .and_then(|v| v.as_str())
615 .unwrap_or("")
616 .to_string();
617 if err_code == 90309999 {
618 return Err(TailFinError::Api(format!(
619 "Shopee anti-bot wall (error 90309999) on get_shop_info: {msg}. \
620 Profile trust may have reverted."
621 )));
622 }
623 return Err(TailFinError::Api(format!(
624 "Shopee error {err_code} on get_shop_info: {msg}"
625 )));
626 }
627 let data = body
628 .get("data")
629 .ok_or_else(|| TailFinError::Parse("missing `data` in get_shop_info response".into()))?;
630
631 Ok(ShopInfo {
632 shop_id: pick_u64(data, &["shop_id", "shopid"]),
633 user_id: pick_u64(data, &["user_id", "userid"]),
634 name: pick_str(data, &["name"]).unwrap_or_default(),
635 place: pick_str(data, &["place"]),
636 is_official_shop: data
637 .get("is_official_shop")
638 .and_then(|v| v.as_bool())
639 .unwrap_or(false),
640 is_shopee_verified: data
641 .get("is_shopee_verified")
642 .and_then(|v| v.as_bool())
643 .unwrap_or(false),
644 holiday_mode: data
645 .get("holiday_mode")
646 .and_then(|v| v.as_bool())
647 .unwrap_or(false),
648 item_count: pick_u64(data, &["item_count"]),
649 follower_count: pick_u64(data, &["follower_count"]),
650 rating_star: data
651 .get("rating_star")
652 .and_then(|v| v.as_f64())
653 .unwrap_or(0.0),
654 rating_good: pick_u64(data, &["rating_good"]),
655 rating_normal: pick_u64(data, &["rating_normal"]),
656 rating_bad: pick_u64(data, &["rating_bad"]),
657 response_rate: data
658 .get("response_rate")
659 .and_then(|v| v.as_u64())
660 .map(|n| n.min(u64::from(u32::MAX)) as u32)
661 .unwrap_or(0),
662 response_time: pick_u64(data, &["response_time"]),
663 ctime: pick_i64(data, &["ctime"]),
664 last_active_time: pick_i64(data, &["last_active_time"]),
665 })
666}
667
668pub fn parse_reviews(body: &Value, shopid: u64, itemid: u64) -> Result<Reviews, TailFinError> {
675 let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
676 if err_code != 0 {
677 let msg = body
678 .get("error_msg")
679 .and_then(|v| v.as_str())
680 .unwrap_or("")
681 .to_string();
682 if err_code == 90309999 {
683 return Err(TailFinError::Api(format!(
684 "Shopee anti-bot wall (error 90309999) on get_ratings: {msg}. \
685 Profile trust may have reverted."
686 )));
687 }
688 return Err(TailFinError::Api(format!(
689 "Shopee error {err_code} on get_ratings: {msg}"
690 )));
691 }
692
693 let data = body
694 .get("data")
695 .ok_or_else(|| TailFinError::Parse("missing `data` in get_ratings response".into()))?;
696
697 let ratings: Vec<Review> = data
698 .get("ratings")
699 .and_then(|v| v.as_array())
700 .map(|a| a.iter().map(parse_one_review).collect())
701 .unwrap_or_default();
702
703 Ok(Reviews {
704 itemid,
705 shopid,
706 item_rating_star: data
707 .get("item_rating_star")
708 .and_then(|v| v.as_f64())
709 .unwrap_or(0.0),
710 item_rating_count: pick_u64(data, &["item_rating_count"]),
711 has_more: data
712 .get("has_more")
713 .and_then(|v| v.as_bool())
714 .unwrap_or(false),
715 ratings,
716 })
717}
718
719fn parse_one_review(v: &Value) -> Review {
720 Review {
721 cmtid: pick_u64(v, &["cmtid"]),
722 itemid: pick_u64(v, &["itemid", "item_id"]),
723 shopid: pick_u64(v, &["shopid", "shop_id"]),
724 rating_star: v
725 .get("rating_star")
726 .and_then(|x| x.as_u64())
727 .map(|n| n.min(u64::from(u32::MAX)) as u32)
728 .unwrap_or(0),
729 comment: pick_str(v, &["comment"]).unwrap_or_default(),
730 images: v
731 .get("images")
732 .and_then(|x| x.as_array())
733 .map(|a| {
734 a.iter()
735 .filter_map(|x| x.as_str().map(|s| s.to_string()))
736 .collect()
737 })
738 .unwrap_or_default(),
739 ctime: pick_i64(v, &["ctime"]),
740 author_username: pick_str(v, &["author_username"]).unwrap_or_default(),
741 anonymous: v
742 .get("anonymous")
743 .and_then(|x| x.as_bool())
744 .unwrap_or(false),
745 }
746}
747
748pub fn parse_search_user(body: &Value, keyword: &str) -> Result<UserSearchResults, TailFinError> {
754 let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
758 if err_code != 0 {
759 let msg = body
760 .get("error_msg")
761 .and_then(|v| v.as_str())
762 .unwrap_or("")
763 .to_string();
764 if err_code == 90309999 {
765 return Err(TailFinError::Api(format!(
766 "Shopee anti-bot wall (error 90309999) on search_user: {msg}. \
767 Profile trust may have reverted."
768 )));
769 }
770 return Err(TailFinError::Api(format!(
771 "Shopee error {err_code} on search_user: {msg}"
772 )));
773 }
774
775 let users: Vec<UserMatch> = body
776 .pointer("/data/users")
777 .and_then(|v| v.as_array())
778 .map(|a| a.iter().map(parse_one_user_match).collect())
779 .unwrap_or_default();
780
781 Ok(UserSearchResults {
782 keyword: keyword.to_string(),
783 users,
784 })
785}
786
787fn parse_one_user_match(v: &Value) -> UserMatch {
788 UserMatch {
789 shopid: pick_u64(v, &["shopid", "shop_id"]),
790 userid: pick_u64(v, &["userid", "user_id"]),
791 username: pick_str(v, &["username"]).unwrap_or_default(),
792 shopname: pick_str(v, &["shopname"]).unwrap_or_default(),
793 nickname: pick_str(v, &["nickname"]).unwrap_or_default(),
794 portrait: pick_str(v, &["portrait"]),
795 shop_rating: v.get("shop_rating").and_then(|x| x.as_f64()).unwrap_or(0.0),
796 follower_count: pick_u64(v, &["follower_count"]),
797 products: pick_u64(v, &["products"]),
798 is_official_shop: v
799 .get("is_official_shop")
800 .and_then(|x| x.as_bool())
801 .unwrap_or(false),
802 shopee_verified_flag: v
803 .get("shopee_verified_flag")
804 .and_then(|x| x.as_u64())
805 .map(|n| n.min(u64::from(u32::MAX)) as u32)
806 .unwrap_or(0),
807 response_rate: v
808 .get("response_rate")
809 .and_then(|x| x.as_u64())
810 .map(|n| n.min(u64::from(u32::MAX)) as u32)
811 .unwrap_or(0),
812 response_time: pick_u64(v, &["response_time"]),
813 country: pick_str(v, &["country"]).unwrap_or_default(),
814 }
815}
816
817pub fn parse_cart_mini(body: &Value) -> Result<CartPreview, TailFinError> {
830 let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
834 if err_code != 0 {
835 let msg = body
836 .get("error_msg")
837 .and_then(|v| v.as_str())
838 .unwrap_or("")
839 .to_string();
840 if err_code == 90309999 {
845 return Err(TailFinError::Api(format!(
846 "Shopee anti-bot wall (error 90309999) on cart/mini: {msg}. \
847 The attached Chrome profile's trust may have reverted; \
848 manually browse + add-to-cart a few times then retry."
849 )));
850 }
851 return Err(TailFinError::Api(format!(
852 "Shopee error {err_code} on cart/mini: {msg}"
853 )));
854 }
855
856 let data = body
857 .get("data")
858 .ok_or_else(|| TailFinError::Parse("missing `data` in cart/mini response".into()))?;
859
860 let recent_items: Vec<CartItem> = data
861 .get("recent_cart_item_details")
862 .and_then(|v| v.as_array())
863 .map(|a| a.iter().map(parse_one_cart_item).collect())
864 .unwrap_or_default();
865
866 Ok(CartPreview {
867 total_count: data
868 .get("total_cart_item_count")
869 .and_then(|v| v.as_u64())
870 .unwrap_or(0),
871 unique_count: data
872 .get("unique_cart_item_count")
873 .and_then(|v| v.as_u64())
874 .unwrap_or(0),
875 recent_items,
876 })
877}
878
879fn parse_one_cart_item(v: &Value) -> CartItem {
880 CartItem {
881 itemid: pick_u64(v, &["itemid", "item_id"]),
882 shopid: pick_u64(v, &["shopid", "shop_id"]),
883 modelid: pick_u64(v, &["modelid", "model_id"]),
884 name: pick_str(v, &["name", "title"]).unwrap_or_default(),
885 price: pick_u64(v, &["price"]),
886 image: pick_str(v, &["image"]),
887 status: pick_i64(v, &["status"]),
888 is_add_on_sub_item: v
889 .get("is_add_on_sub_item")
890 .and_then(|x| x.as_bool())
891 .unwrap_or(false),
892 }
893}
894
895pub fn parse_product_detail(body: &Value) -> Result<ProductDetail, TailFinError> {
896 let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
897 if err_code != 0 {
898 let msg = body
899 .get("error_msg")
900 .and_then(|v| v.as_str())
901 .unwrap_or("")
902 .to_string();
903 if err_code == 90309999 {
904 return Err(TailFinError::Api(format!(
905 "Shopee anti-bot wall (error 90309999): {msg}. \
906 The attached Chrome tab needs more browsing history / \
907 a completed order before Shopee will trust it for \
908 product detail."
909 )));
910 }
911 if err_code == 4 {
915 return Err(TailFinError::Api(format!(
916 "Shopee item not found (error 4): {msg}. \
917 The product may have been removed or the itemid/shopid \
918 pair is wrong."
919 )));
920 }
921 return Err(TailFinError::Api(format!("Shopee error {err_code}: {msg}")));
922 }
923
924 let item = unwrap_item(body).ok_or_else(|| {
925 TailFinError::Parse("missing item object in product-detail response".into())
926 })?;
927
928 let pr = body.pointer("/data/product_review");
940 let pi = body.pointer("/data/product_images");
941 let pa = body.pointer("/data/product_attributes");
942 let sd = body.pointer("/data/shop_detailed");
943
944 let (rating_star, rating_count) = read_rating(item).unwrap_or_else(|| {
947 pr.map(|r| {
948 let s = r.get("rating_star").and_then(|v| v.as_f64()).unwrap_or(0.0);
949 let c = r
950 .get("rating_count")
951 .and_then(|v| v.as_array())
952 .and_then(|a| a.first())
953 .and_then(|v| v.as_u64())
954 .unwrap_or(0);
955 (s, c)
956 })
957 .unwrap_or((0.0, 0))
958 });
959
960 let images = read_images(item)
964 .or_else(|| {
965 pi.and_then(|v| v.get("images"))
966 .and_then(read_images_from_value)
967 })
968 .unwrap_or_default();
969 let cover_image = item
970 .get("image")
971 .and_then(|v| v.as_str())
972 .map(|s| s.to_string())
973 .or_else(|| images.first().cloned());
974
975 let models: Vec<ProductModel> = item
977 .get("models")
978 .and_then(|v| v.as_array())
979 .map(|a| a.iter().map(parse_one_model).collect())
980 .or_else(|| {
981 pa.and_then(|v| v.get("models"))
982 .and_then(|v| v.as_array())
983 .map(|a| a.iter().map(parse_one_model).collect())
984 })
985 .unwrap_or_default();
986
987 Ok(ProductDetail {
988 itemid: pick_u64(item, &["item_id", "itemid"]),
991 shopid: pick_u64(item, &["shop_id", "shopid"]),
992 name: pick_str(item, &["title", "name"]).unwrap_or_default(),
994 description: pick_str(item, &["description"]),
995 price: pick_u64(item, &["price"]),
996 price_min: pick_u64(item, &["price_min"]),
997 price_max: pick_u64(item, &["price_max"]),
998 currency: pick_str(item, &["currency"]),
999 stock: pick_i64(item, &["stock"]),
1000 historical_sold: pick_i64(item, &["historical_sold"]),
1001 liked_count: pick_u64(item, &["liked_count"]),
1002 rating_star,
1003 rating_count,
1004 shop_location: pick_str(item, &["shop_location"]).or_else(|| {
1005 sd.and_then(|v| v.get("shop_location"))
1006 .and_then(|v| v.as_str())
1007 .map(|s| s.to_string())
1008 }),
1009 image: cover_image,
1010 images,
1011 models,
1012 })
1013}
1014
1015fn unwrap_item(body: &Value) -> Option<&Value> {
1020 let candidates: [Option<&Value>; 4] = [
1021 body.pointer("/data/item"),
1022 body.pointer("/data"),
1023 body.pointer("/item"),
1024 Some(body),
1025 ];
1026 for c in candidates.iter().flatten() {
1027 let has_id = c.get("item_id").and_then(|v| v.as_u64()).is_some()
1028 || c.get("itemid").and_then(|v| v.as_u64()).is_some();
1029 if has_id {
1030 return Some(c);
1031 }
1032 }
1033 None
1034}
1035
1036fn pick_u64(v: &Value, keys: &[&str]) -> u64 {
1037 for k in keys {
1038 if let Some(n) = v.get(k).and_then(|x| x.as_u64()) {
1039 return n;
1040 }
1041 }
1042 0
1043}
1044
1045fn pick_i64(v: &Value, keys: &[&str]) -> i64 {
1046 for k in keys {
1047 if let Some(n) = v.get(k).and_then(|x| x.as_i64()) {
1048 return n;
1049 }
1050 }
1051 0
1052}
1053
1054fn pick_str(v: &Value, keys: &[&str]) -> Option<String> {
1055 for k in keys {
1056 if let Some(s) = v.get(k).and_then(|x| x.as_str()) {
1057 return Some(s.to_string());
1058 }
1059 }
1060 None
1061}
1062
1063fn read_rating(item: &Value) -> Option<(f64, u64)> {
1064 let r = item.get("item_rating")?;
1065 let s = r.get("rating_star").and_then(|v| v.as_f64())?;
1066 let c = r
1067 .get("rating_count")
1068 .and_then(|v| v.as_array())
1069 .and_then(|a| a.first())
1070 .and_then(|v| v.as_u64())
1071 .unwrap_or(0);
1072 Some((s, c))
1073}
1074
1075fn read_images(item: &Value) -> Option<Vec<String>> {
1076 item.get("images").and_then(|v| v.as_array()).and_then(|a| {
1077 let v: Vec<String> = a
1081 .iter()
1082 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1083 .collect();
1084 if v.is_empty() {
1085 None
1086 } else {
1087 Some(v)
1088 }
1089 })
1090}
1091
1092fn read_images_from_value(v: &Value) -> Option<Vec<String>> {
1100 v.as_array().map(|a| {
1101 a.iter()
1102 .filter_map(|v| {
1103 v.as_str().map(|s| s.to_string()).or_else(|| {
1104 v.get("image_id")
1105 .or_else(|| v.get("image_hash"))
1106 .and_then(|x| x.as_str())
1107 .map(|s| s.to_string())
1108 })
1109 })
1110 .collect()
1111 })
1112}
1113
1114fn parse_one_model(v: &Value) -> ProductModel {
1118 let price = v
1119 .pointer("/price/single_value")
1120 .and_then(|x| x.as_u64())
1121 .or_else(|| v.get("price").and_then(|x| x.as_u64()))
1122 .unwrap_or(0);
1123 ProductModel {
1124 modelid: pick_u64(v, &["model_id", "modelid"]),
1125 name: v
1126 .get("name")
1127 .and_then(|v| v.as_str())
1128 .unwrap_or("")
1129 .to_string(),
1130 price,
1131 stock: v.get("stock").and_then(|v| v.as_i64()).unwrap_or(0),
1132 }
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137 use super::*;
1138 use serde_json::json;
1139
1140 fn fixture() -> Value {
1143 json!({
1144 "error": 0,
1145 "error_msg": null,
1146 "total_count": 8000,
1147 "items": [
1148 {
1149 "itemid": 12345,
1150 "shopid": 67890,
1151 "item_basic": {
1152 "itemid": 12345,
1153 "shopid": 67890,
1154 "name": "iPhone 15 Pro 256GB 黑色",
1155 "price": 3999000000_u64,
1156 "price_min": 3999000000_u64,
1157 "price_max": 5499000000_u64,
1158 "currency": "TWD",
1159 "stock": 99,
1160 "historical_sold": 100,
1161 "liked_count": 250,
1162 "shop_location": "新北市",
1163 "image": "abc123def456",
1164 "item_rating": {
1165 "rating_star": 4.85,
1166 "rating_count": [123, 5, 10, 20, 30, 58]
1167 }
1168 }
1169 },
1170 {
1171 "itemid": 99999,
1172 "shopid": 11111,
1173 "item_basic": {
1174 "itemid": 99999,
1175 "shopid": 11111,
1176 "name": "Stock-hidden item",
1177 "price": 50000000,
1178 "price_min": 50000000,
1179 "price_max": 50000000,
1180 "currency": "TWD",
1181 "stock": -1,
1182 "historical_sold": -1,
1183 "liked_count": 0,
1184 "shop_location": "Singapore",
1185 "image": null,
1186 "item_rating": null
1187 }
1188 }
1189 ]
1190 })
1191 }
1192
1193 #[test]
1194 fn parses_two_items_and_echoes_keyword() {
1195 let r = parse_search_items(&fixture(), "iPhone", 0).expect("parse");
1196 assert_eq!(r.keyword, "iPhone");
1197 assert_eq!(r.total_count, 8000);
1198 assert_eq!(r.items.len(), 2);
1199 }
1200
1201 #[test]
1202 fn first_item_has_full_fields_populated() {
1203 let r = parse_search_items(&fixture(), "iPhone", 0).unwrap();
1204 let it = &r.items[0];
1205 assert_eq!(it.itemid, 12345);
1206 assert_eq!(it.shopid, 67890);
1207 assert_eq!(it.name, "iPhone 15 Pro 256GB 黑色");
1208 assert_eq!(it.price, 3_999_000_000);
1209 assert_eq!(it.price_min, 3_999_000_000);
1210 assert_eq!(it.price_max, 5_499_000_000);
1211 assert_eq!(it.currency.as_deref(), Some("TWD"));
1212 assert_eq!(it.stock, 99);
1213 assert_eq!(it.historical_sold, 100);
1214 assert_eq!(it.liked_count, 250);
1215 assert!((it.rating_star - 4.85).abs() < 1e-6);
1216 assert_eq!(it.rating_count, 123);
1217 assert_eq!(it.shop_location.as_deref(), Some("新北市"));
1218 assert_eq!(it.image.as_deref(), Some("abc123def456"));
1219 }
1220
1221 #[test]
1222 fn second_item_handles_hidden_stock_and_null_rating() {
1223 let r = parse_search_items(&fixture(), "iPhone", 0).unwrap();
1224 let it = &r.items[1];
1225 assert_eq!(it.stock, -1);
1226 assert_eq!(it.historical_sold, -1);
1227 assert_eq!(it.rating_star, 0.0);
1228 assert_eq!(it.rating_count, 0);
1229 assert!(it.image.is_none());
1230 }
1231
1232 #[test]
1233 fn parse_falls_back_to_outer_when_item_basic_missing() {
1234 let body = json!({
1238 "error": 0,
1239 "items": [{
1240 "itemid": 1,
1241 "shopid": 2,
1242 "name": "flat-shape item",
1243 "price": 1000000,
1244 "price_min": 1000000,
1245 "price_max": 1000000
1246 }]
1247 });
1248 let r = parse_search_items(&body, "x", 0).unwrap();
1249 assert_eq!(r.items.len(), 1);
1250 assert_eq!(r.items[0].name, "flat-shape item");
1251 assert_eq!(r.items[0].price, 1_000_000);
1252 }
1253
1254 #[test]
1255 fn captcha_wall_surfaces_distinct_error() {
1256 let body = json!({
1257 "error": 90309999,
1258 "error_msg": "anti-bot triggered"
1259 });
1260 let err = parse_search_items(&body, "x", 0).unwrap_err();
1261 let msg = err.to_string();
1262 assert!(
1263 msg.contains("90309999"),
1264 "expected captcha-wall hint, got: {msg}"
1265 );
1266 assert!(msg.contains("trust") || msg.contains("history"));
1267 }
1268
1269 #[test]
1270 fn other_error_codes_surface_generically() {
1271 let body = json!({
1272 "error": 44,
1273 "error_msg": "must login"
1274 });
1275 let err = parse_search_items(&body, "x", 0).unwrap_err();
1276 let msg = err.to_string();
1277 assert!(msg.contains("44"));
1278 assert!(msg.contains("must login"));
1279 }
1280
1281 #[test]
1282 fn missing_items_field_is_parse_error() {
1283 let body = json!({ "error": 0 });
1284 let err = parse_search_items(&body, "x", 0).unwrap_err();
1285 assert!(err.to_string().contains("missing"));
1286 }
1287
1288 fn detail_fixture_pdp() -> Value {
1292 json!({
1293 "error": 0,
1294 "data": {
1295 "item": {
1296 "itemid": 12345,
1297 "shopid": 67890,
1298 "name": "iPhone 15 Pro 256GB 黑色",
1299 "description": "All-new iPhone 15 Pro with titanium design.",
1300 "price": 3999000000_u64,
1301 "price_min": 3999000000_u64,
1302 "price_max": 5499000000_u64,
1303 "currency": "TWD",
1304 "stock": 99,
1305 "historical_sold": 100,
1306 "liked_count": 250,
1307 "shop_location": "新北市",
1308 "image": "abc123",
1309 "images": ["abc123", "def456", "ghi789"],
1310 "item_rating": {
1311 "rating_star": 4.85,
1312 "rating_count": [123, 5, 10, 20, 30, 58]
1313 },
1314 "models": [
1315 {
1316 "modelid": 11111,
1317 "name": "黑色,256GB",
1318 "price": 3999000000_u64,
1319 "stock": 50
1320 },
1321 {
1322 "modelid": 22222,
1323 "name": "白色,512GB",
1324 "price": 4799000000_u64,
1325 "stock": -1
1326 }
1327 ]
1328 }
1329 }
1330 })
1331 }
1332
1333 fn detail_fixture_legacy() -> Value {
1335 json!({
1336 "error": 0,
1337 "item": {
1338 "itemid": 999,
1339 "shopid": 888,
1340 "name": "Old item",
1341 "price": 1000000,
1342 "price_min": 1000000,
1343 "price_max": 1000000
1344 }
1345 })
1346 }
1347
1348 #[test]
1349 fn parses_pdp_wrapper_shape() {
1350 let r = parse_product_detail(&detail_fixture_pdp()).expect("parse");
1351 assert_eq!(r.itemid, 12345);
1352 assert_eq!(r.shopid, 67890);
1353 assert_eq!(r.name, "iPhone 15 Pro 256GB 黑色");
1354 assert_eq!(
1355 r.description.as_deref(),
1356 Some("All-new iPhone 15 Pro with titanium design.")
1357 );
1358 assert_eq!(r.price, 3_999_000_000);
1359 assert_eq!(r.currency.as_deref(), Some("TWD"));
1360 assert_eq!(r.stock, 99);
1361 assert!((r.rating_star - 4.85).abs() < 1e-6);
1362 assert_eq!(r.rating_count, 123);
1363 }
1364
1365 #[test]
1366 fn parses_image_gallery() {
1367 let r = parse_product_detail(&detail_fixture_pdp()).unwrap();
1368 assert_eq!(r.images, vec!["abc123", "def456", "ghi789"]);
1369 assert_eq!(r.image.as_deref(), Some("abc123"));
1370 }
1371
1372 #[test]
1373 fn item_images_with_only_null_elements_falls_through_to_fan_out() {
1374 let body = json!({
1380 "error": 0,
1381 "data": {
1382 "item": {
1383 "itemid": 1,
1384 "shopid": 2,
1385 "name": "x",
1386 "price": 100,
1387 "price_min": 100,
1388 "price_max": 100,
1389 "images": [null, null]
1390 },
1391 "product_images": {
1392 "images": [{ "image_id": "fallback_aaa" }]
1393 }
1394 }
1395 });
1396 let r = parse_product_detail(&body).unwrap();
1397 assert_eq!(r.images, vec!["fallback_aaa"]);
1398 }
1399
1400 #[test]
1401 fn parses_variant_models() {
1402 let r = parse_product_detail(&detail_fixture_pdp()).unwrap();
1403 assert_eq!(r.models.len(), 2);
1404 assert_eq!(r.models[0].modelid, 11111);
1405 assert_eq!(r.models[0].name, "黑色,256GB");
1406 assert_eq!(r.models[0].stock, 50);
1407 assert_eq!(r.models[1].stock, -1);
1409 assert_eq!(r.models[1].price, 4_799_000_000);
1410 }
1411
1412 #[test]
1413 fn parses_legacy_wrapper_shape() {
1414 let r = parse_product_detail(&detail_fixture_legacy()).expect("parse legacy");
1415 assert_eq!(r.itemid, 999);
1416 assert_eq!(r.shopid, 888);
1417 assert_eq!(r.name, "Old item");
1418 assert!(r.description.is_none());
1420 assert!(r.images.is_empty());
1421 assert!(r.models.is_empty());
1422 }
1423
1424 #[test]
1425 fn detail_captcha_wall_surfaces_distinct_error() {
1426 let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
1427 let err = parse_product_detail(&body).unwrap_err();
1428 let msg = err.to_string();
1429 assert!(msg.contains("90309999"));
1430 assert!(msg.contains("trust") || msg.contains("history"));
1431 }
1432
1433 #[test]
1434 fn detail_item_not_found_surfaces_distinct_error() {
1435 let body = json!({ "error": 4, "error_msg": "item not found" });
1436 let err = parse_product_detail(&body).unwrap_err();
1437 let msg = err.to_string();
1438 assert!(msg.contains("not found"));
1439 assert!(msg.contains("removed") || msg.contains("itemid"));
1440 }
1441
1442 #[test]
1443 fn detail_missing_item_object_is_parse_error() {
1444 let body = json!({ "error": 0, "data": { "version": "x" } });
1446 let err = parse_product_detail(&body).unwrap_err();
1447 assert!(err.to_string().contains("missing item"));
1448 }
1449
1450 #[test]
1451 fn detail_falls_back_to_root_when_unwrapped() {
1452 let body = json!({
1456 "error": 0,
1457 "itemid": 7,
1458 "shopid": 8,
1459 "name": "root-shape",
1460 "price": 500000,
1461 "price_min": 500000,
1462 "price_max": 500000
1463 });
1464 let r = parse_product_detail(&body).unwrap();
1465 assert_eq!(r.itemid, 7);
1466 assert_eq!(r.name, "root-shape");
1467 }
1468
1469 fn detail_fixture_modern_pdp() -> Value {
1476 json!({
1477 "error": 0,
1478 "data": {
1479 "item": {
1480 "item_id": 43968123553_u64,
1481 "shop_id": 188277742,
1482 "title": "Apple iPhone 17 Pro 256GB 手機",
1483 "description": "Apple iPhone 17 Pro 256GB...",
1484 "currency": "TWD",
1485 "price": 3990000000_u64,
1486 "price_min": 3990000000_u64,
1487 "price_max": 3990000000_u64,
1488 "stock": 50,
1489 "historical_sold": 25,
1490 "liked_count": 100,
1491 "image": "img_hash_aaa",
1492 "images": ["img_hash_aaa", "img_hash_bbb"],
1493 "shop_location": "新北市",
1494 "item_rating": {
1495 "rating_star": 4.92,
1496 "rating_count": [50, 1, 0, 2, 5, 42]
1497 },
1498 "models": [
1499 {
1500 "model_id": 296491431379_u64,
1501 "name": "黑色,256GB",
1502 "price": 3990000000_u64,
1503 "stock": 50
1504 }
1505 ]
1506 }
1507 }
1508 })
1509 }
1510
1511 #[test]
1512 fn parses_modern_pdp_shape() {
1513 let r = parse_product_detail(&detail_fixture_modern_pdp()).expect("parse modern");
1514 assert_eq!(r.itemid, 43_968_123_553);
1516 assert_eq!(r.shopid, 188_277_742);
1517 assert_eq!(r.name, "Apple iPhone 17 Pro 256GB 手機");
1519 assert_eq!(r.price, 3_990_000_000);
1520 assert_eq!(r.currency.as_deref(), Some("TWD"));
1521 assert_eq!(r.stock, 50);
1522 assert_eq!(r.historical_sold, 25);
1523 assert!((r.rating_star - 4.92).abs() < 1e-6);
1524 assert_eq!(r.rating_count, 50);
1525 }
1526
1527 #[test]
1528 fn modern_pdp_image_and_models_round_trip() {
1529 let r = parse_product_detail(&detail_fixture_modern_pdp()).unwrap();
1530 assert_eq!(r.images, vec!["img_hash_aaa", "img_hash_bbb"]);
1531 assert_eq!(r.image.as_deref(), Some("img_hash_aaa"));
1532 assert_eq!(r.models.len(), 1);
1533 assert_eq!(r.models[0].modelid, 296_491_431_379);
1534 assert_eq!(r.models[0].name, "黑色,256GB");
1535 assert_eq!(r.models[0].price, 3_990_000_000);
1536 }
1537
1538 #[test]
1539 fn modern_pdp_falls_back_to_product_review_when_item_rating_absent() {
1540 let mut body = detail_fixture_modern_pdp();
1545 body["data"]["item"]
1546 .as_object_mut()
1547 .unwrap()
1548 .remove("item_rating");
1549 body["data"]["product_review"] = json!({
1550 "rating_star": 4.5,
1551 "rating_count": [10, 0, 0, 1, 2, 7]
1552 });
1553 let r = parse_product_detail(&body).unwrap();
1554 assert!((r.rating_star - 4.5).abs() < 1e-6);
1555 assert_eq!(r.rating_count, 10);
1556 }
1557
1558 #[test]
1559 fn modern_pdp_falls_back_to_product_images_with_image_id() {
1560 let mut body = detail_fixture_modern_pdp();
1564 body["data"]["item"]
1565 .as_object_mut()
1566 .unwrap()
1567 .remove("images");
1568 body["data"]["product_images"] = json!({
1569 "images": [
1570 { "image_id": "fan_aaa" },
1571 { "image_id": "fan_bbb" }
1572 ]
1573 });
1574 let r = parse_product_detail(&body).unwrap();
1575 assert_eq!(r.images, vec!["fan_aaa", "fan_bbb"]);
1576 }
1577
1578 fn recommend_pdp_fixture() -> Value {
1585 json!({
1586 "error": null,
1587 "data": {
1588 "sections": [{
1589 "key": "you_may_also_like",
1590 "total": 600,
1591 "units": [
1592 {
1593 "itemid": 28991283813_u64,
1594 "shopid": 188277742,
1595 "name": "iPhone 17 Pro 512GB",
1596 "price": 4690000000_u64,
1597 "currency": "TWD",
1598 "stock": 1,
1599 "image": "img_aaa",
1600 "shop_location": "桃園市",
1601 "item_rating": {
1602 "rating_star": 4.989,
1603 "rating_count": [553, 1, 0, 0, 2, 550]
1604 }
1605 },
1606 {
1607 "itemid": 99,
1608 "shopid": 88,
1609 "name": "iPhone 16",
1610 "price": 2990000000_u64,
1611 "stock": -1,
1612 "image": "img_bbb"
1613 }
1614 ]
1615 }]
1616 }
1617 })
1618 }
1619
1620 #[test]
1621 fn parses_recommend_pdp_units() {
1622 let r = parse_recommend_pdp(&recommend_pdp_fixture());
1623 assert_eq!(r.len(), 2);
1624 assert_eq!(r[0].itemid, 28_991_283_813);
1625 assert_eq!(r[0].shopid, 188_277_742);
1626 assert_eq!(r[0].name, "iPhone 17 Pro 512GB");
1627 assert_eq!(r[0].price, 4_690_000_000);
1628 assert_eq!(r[0].rating_count, 553);
1629 assert!((r[0].rating_star - 4.989).abs() < 1e-3);
1630 assert_eq!(r[1].stock, -1);
1632 }
1633
1634 #[test]
1635 fn empty_sections_yield_empty_recommendations() {
1636 let body = json!({ "data": { "sections": [] } });
1637 assert!(parse_recommend_pdp(&body).is_empty());
1638 let body = json!({ "data": {} });
1640 assert!(parse_recommend_pdp(&body).is_empty());
1641 }
1642
1643 fn hot_sales_fixture() -> Value {
1648 json!({
1649 "error": null,
1650 "data": {
1651 "card_set": { "card_set_name": "FTSS TPFS card" },
1652 "item_cards": [
1653 {
1654 "item_data": {
1655 "itemid": 40518550283_u64,
1656 "shopid": 109729156,
1657 "item_rating": {
1658 "rating_star": 5.0,
1659 "rating_count": [82, 0, 0, 0, 0, 82]
1660 }
1661 },
1662 "item_card_displayed_asset": {
1663 "name": "iPhone 17 Pro Max 256G 全新",
1664 "image": "tw-hot-aaa",
1665 "display_price": {
1666 "price": 4697900000_u64,
1667 "strikethrough_price": null
1668 }
1669 }
1670 }
1671 ]
1672 }
1673 })
1674 }
1675
1676 #[test]
1677 fn parses_hot_sales_split_fields() {
1678 let r = parse_hot_sales(&hot_sales_fixture());
1679 assert_eq!(r.len(), 1);
1680 let c = &r[0];
1681 assert_eq!(c.itemid, 40_518_550_283);
1683 assert_eq!(c.shopid, 109_729_156);
1684 assert_eq!(c.name, "iPhone 17 Pro Max 256G 全新");
1685 assert_eq!(c.image.as_deref(), Some("tw-hot-aaa"));
1686 assert_eq!(c.price, 4_697_900_000);
1687 assert_eq!(c.rating_count, 82);
1689 assert!((c.rating_star - 5.0).abs() < 1e-6);
1690 }
1691
1692 #[test]
1693 fn missing_item_cards_yields_empty_hot_sales() {
1694 let body = json!({ "data": {} });
1695 assert!(parse_hot_sales(&body).is_empty());
1696 }
1697
1698 #[test]
1699 fn combine_related_pairs_both_endpoints() {
1700 let r = combine_related(
1701 Some(&hot_sales_fixture()),
1702 Some(&recommend_pdp_fixture()),
1703 999,
1704 888,
1705 );
1706 assert_eq!(r.source_shopid, 999);
1707 assert_eq!(r.source_itemid, 888);
1708 assert_eq!(r.hot_sales.len(), 1);
1709 assert_eq!(r.recommended.len(), 2);
1710 }
1711
1712 #[test]
1713 fn combine_related_handles_one_side_missing() {
1714 let r = combine_related(Some(&hot_sales_fixture()), None, 1, 2);
1717 assert_eq!(r.hot_sales.len(), 1);
1718 assert!(r.recommended.is_empty());
1719 }
1720
1721 #[test]
1724 fn search_results_carry_page_index() {
1725 let r = parse_search_items(&fixture(), "iPhone", 3).unwrap();
1726 assert_eq!(r.page, 3);
1729 }
1730
1731 fn cart_fixture() -> Value {
1736 json!({
1737 "error": 0,
1738 "error_msg": null,
1739 "data": {
1740 "total_cart_item_count": 22,
1741 "unique_cart_item_count": 20,
1742 "translation_status": 0,
1743 "recent_cart_item_details": [
1744 {
1745 "itemid": 44302564613_u64,
1746 "shopid": 983412696,
1747 "modelid": 227845841315_u64,
1748 "bundle_deal_id": 0,
1749 "is_add_on_sub_item": false,
1750 "name": "COSTCO 好市多 Webber Naturals 甘胺酸鋅膠囊 240粒",
1751 "price": 81900000,
1752 "is_wholesale_price": false,
1753 "status": 1,
1754 "image": "tw-img-aaa",
1755 "promotion_type": 0
1756 },
1757 {
1758 "itemid": 12345,
1759 "shopid": 678,
1760 "modelid": 0,
1761 "is_add_on_sub_item": true,
1762 "name": "Add-on freebie",
1763 "price": 0,
1764 "status": 1,
1765 "image": null
1766 }
1767 ]
1768 }
1769 })
1770 }
1771
1772 #[test]
1773 fn parses_cart_mini_counts_and_items() {
1774 let r = parse_cart_mini(&cart_fixture()).expect("parse");
1775 assert_eq!(r.total_count, 22);
1776 assert_eq!(r.unique_count, 20);
1777 assert_eq!(r.recent_items.len(), 2);
1778 let it = &r.recent_items[0];
1779 assert_eq!(it.itemid, 44_302_564_613);
1780 assert_eq!(it.shopid, 983_412_696);
1781 assert_eq!(it.modelid, 227_845_841_315);
1782 assert_eq!(it.name, "COSTCO 好市多 Webber Naturals 甘胺酸鋅膠囊 240粒");
1783 assert_eq!(it.price, 81_900_000);
1784 assert_eq!(it.image.as_deref(), Some("tw-img-aaa"));
1785 assert_eq!(it.status, 1);
1786 assert!(!it.is_add_on_sub_item);
1787 }
1788
1789 #[test]
1790 fn parses_cart_add_on_sub_item_flag() {
1791 let r = parse_cart_mini(&cart_fixture()).unwrap();
1792 let add_on = &r.recent_items[1];
1795 assert!(add_on.is_add_on_sub_item);
1796 assert_eq!(add_on.price, 0);
1797 assert_eq!(add_on.modelid, 0);
1798 assert!(add_on.image.is_none());
1799 }
1800
1801 #[test]
1802 fn empty_cart_yields_zero_counts_and_no_items() {
1803 let body = json!({
1807 "error": 0,
1808 "data": {
1809 "total_cart_item_count": 0,
1810 "unique_cart_item_count": 0
1811 }
1812 });
1813 let r = parse_cart_mini(&body).unwrap();
1814 assert_eq!(r.total_count, 0);
1815 assert_eq!(r.unique_count, 0);
1816 assert!(r.recent_items.is_empty());
1817 }
1818
1819 #[test]
1820 fn cart_captcha_wall_surfaces_distinct_error() {
1821 let body = json!({
1822 "error": 90309999,
1823 "error_msg": "anti-bot triggered"
1824 });
1825 let err = parse_cart_mini(&body).unwrap_err();
1826 let msg = err.to_string();
1827 assert!(msg.contains("90309999"));
1828 assert!(msg.contains("trust") || msg.contains("warm"));
1829 }
1830
1831 #[test]
1832 fn cart_handles_null_error_field_as_success() {
1833 let body = json!({
1837 "error": null,
1838 "data": {
1839 "total_cart_item_count": 1,
1840 "unique_cart_item_count": 1,
1841 "recent_cart_item_details": []
1842 }
1843 });
1844 let r = parse_cart_mini(&body).unwrap();
1845 assert_eq!(r.total_count, 1);
1846 }
1847
1848 #[test]
1849 fn cart_missing_data_is_parse_error() {
1850 let body = json!({ "error": 0 });
1851 let err = parse_cart_mini(&body).unwrap_err();
1852 assert!(err.to_string().contains("missing `data`"));
1853 }
1854
1855 fn daily_discover_fixture() -> Value {
1863 json!({
1864 "error": null,
1865 "data": {
1866 "feed_total": 500,
1867 "feeds": [
1868 {
1869 "type": "centralised_item_card",
1870 "centralised_item_card": {
1871 "item_data": {
1872 "itemid": 1001_u64,
1873 "shopid": 2001_u64,
1874 "item_rating": {
1875 "rating_star": 4.7,
1876 "rating_count": [42, 0, 0, 1, 5, 36]
1877 }
1878 },
1879 "item_card_displayed_asset": {
1880 "name": "discover-feed-aaa",
1881 "image": "tw-feed-aaa",
1882 "display_price": { "price": 199000000_u64 }
1883 }
1884 }
1885 },
1886 {
1887 "type": "centralised_item_card",
1888 "centralised_item_card": {
1889 "item_data": { "itemid": 1002, "shopid": 2002 },
1890 "item_card_displayed_asset": {
1891 "name": "discover-feed-bbb",
1892 "image": "tw-feed-bbb",
1893 "display_price": { "price": 99000000_u64 }
1894 }
1895 }
1896 }
1897 ]
1898 }
1899 })
1900 }
1901
1902 #[test]
1903 fn parses_daily_discover_feeds_via_hot_sale_layout() {
1904 let (feeds, total) = parse_daily_discover(&daily_discover_fixture());
1905 assert_eq!(total, 500);
1906 assert_eq!(feeds.len(), 2);
1907 assert_eq!(feeds[0].itemid, 1001);
1908 assert_eq!(feeds[0].name, "discover-feed-aaa");
1909 assert_eq!(feeds[0].price, 199_000_000);
1910 assert!((feeds[0].rating_star - 4.7).abs() < 1e-3);
1911 assert_eq!(feeds[0].rating_count, 42);
1912 }
1913
1914 #[test]
1915 fn daily_discover_skips_non_centralised_card_feeds() {
1916 let body = json!({
1920 "data": {
1921 "feed_total": 100,
1922 "feeds": [
1923 { "type": "ads_item_card", "ads_item_card": { "id": 999 } },
1924 {
1925 "type": "centralised_item_card",
1926 "centralised_item_card": {
1927 "item_data": { "itemid": 7, "shopid": 8 },
1928 "item_card_displayed_asset": {
1929 "name": "real-item",
1930 "display_price": { "price": 50000000_u64 }
1931 }
1932 }
1933 }
1934 ]
1935 }
1936 });
1937 let (feeds, _total) = parse_daily_discover(&body);
1938 assert_eq!(feeds.len(), 1);
1939 assert_eq!(feeds[0].name, "real-item");
1940 }
1941
1942 #[test]
1943 fn daily_discover_skips_centralised_card_without_item_data() {
1944 let body = json!({
1950 "data": {
1951 "feed_total": 50,
1952 "feeds": [
1953 {
1954 "type": "centralised_item_card",
1955 "centralised_item_card": {
1956 "item_card_displayed_asset": {
1958 "name": "orphan-no-item-data",
1959 "display_price": { "price": 1000000_u64 }
1960 }
1961 }
1962 },
1963 {
1964 "type": "centralised_item_card",
1965 "centralised_item_card": {
1966 "item_data": { "itemid": 9, "shopid": 10 },
1967 "item_card_displayed_asset": {
1968 "name": "real-item",
1969 "display_price": { "price": 50000000_u64 }
1970 }
1971 }
1972 }
1973 ]
1974 }
1975 });
1976 let (feeds, _) = parse_daily_discover(&body);
1977 assert_eq!(feeds.len(), 1);
1979 assert_eq!(feeds[0].itemid, 9);
1980 assert_eq!(feeds[0].name, "real-item");
1981 }
1982
1983 fn flash_sale_fixture() -> Value {
1984 json!({
1985 "error": 0,
1986 "data": {
1987 "items": [
1988 {
1989 "itemid": 9001_u64,
1990 "shopid": 8001_u64,
1991 "name": "P&G ARIEL 4D炭酸洗衣膠球",
1992 "price": 18300000_u64,
1993 "raw_discount": 69,
1994 "end_time": 1777521600,
1995 "stock": 692,
1996 "image": "https://mms.img.susercontent.com/full-url-aaa",
1997 "promotionid": 245737892360192_u64
1998 }
1999 ]
2000 }
2001 })
2002 }
2003
2004 #[test]
2005 fn parses_flash_sale_items() {
2006 let r = parse_flash_sale_items(&flash_sale_fixture());
2007 assert_eq!(r.len(), 1);
2008 let it = &r[0];
2009 assert_eq!(it.itemid, 9001);
2010 assert_eq!(it.shopid, 8001);
2011 assert_eq!(it.price, 18_300_000);
2012 assert_eq!(it.raw_discount, 69);
2013 assert_eq!(it.end_time, 1_777_521_600);
2014 assert_eq!(it.stock, 692);
2015 assert!(it.image.as_deref().unwrap().starts_with("https://"));
2017 assert_eq!(it.promotionid, 245_737_892_360_192);
2018 }
2019
2020 fn mall_shops_fixture() -> Value {
2021 json!({
2022 "error": null,
2023 "data": {
2024 "shops": [
2025 {
2026 "shopid": 23047686,
2027 "url": "https://shopee.tw/cookingstar",
2028 "image": "tw-shop-aaa",
2029 "promo_text": "無門檻8折券"
2030 },
2031 {
2032 "shopid": 26221748,
2033 "url": "https://shopee.tw/tokuyo",
2034 "image": null,
2035 "promo_text": null
2036 }
2037 ]
2038 }
2039 })
2040 }
2041
2042 #[test]
2043 fn parses_mall_shops() {
2044 let r = parse_mall_shops(&mall_shops_fixture());
2045 assert_eq!(r.len(), 2);
2046 assert_eq!(r[0].shopid, 23_047_686);
2047 assert_eq!(r[0].url, "https://shopee.tw/cookingstar");
2048 assert_eq!(r[0].image.as_deref(), Some("tw-shop-aaa"));
2049 assert_eq!(r[0].promo_text.as_deref(), Some("無門檻8折券"));
2050 assert!(r[1].image.is_none());
2052 assert!(r[1].promo_text.is_none());
2053 }
2054
2055 #[test]
2056 fn combine_discover_pairs_all_three() {
2057 let d = combine_discover(
2058 Some(&daily_discover_fixture()),
2059 Some(&flash_sale_fixture()),
2060 Some(&mall_shops_fixture()),
2061 );
2062 assert_eq!(d.feeds.len(), 2);
2063 assert_eq!(d.feed_total, 500);
2064 assert_eq!(d.flash_sale.len(), 1);
2065 assert_eq!(d.mall_shops.len(), 2);
2066 }
2067
2068 #[test]
2069 fn combine_discover_handles_missing_endpoints() {
2070 let d = combine_discover(Some(&daily_discover_fixture()), None, None);
2074 assert_eq!(d.feeds.len(), 2);
2075 assert!(d.flash_sale.is_empty());
2076 assert!(d.mall_shops.is_empty());
2077 }
2078
2079 #[test]
2080 fn missing_top_level_keys_yield_empty_collections() {
2081 let empty = json!({});
2084 let (feeds, total) = parse_daily_discover(&empty);
2085 assert!(feeds.is_empty());
2086 assert_eq!(total, 0);
2087 assert!(parse_flash_sale_items(&empty).is_empty());
2088 assert!(parse_mall_shops(&empty).is_empty());
2089 assert!(parse_category_tree(&empty).is_empty());
2090 }
2091
2092 fn category_tree_fixture() -> Value {
2099 json!({
2100 "data": {
2101 "category_list": [
2102 {
2103 "catid": 11040766,
2104 "parent_catid": 0,
2105 "name": "Women's Apparel",
2106 "display_name": "女生衣著",
2107 "image": "17f3879a1872099681d7b85101e187db",
2108 "level": 1,
2109 "children": null
2110 },
2111 {
2112 "catid": 11041120,
2113 "parent_catid": 0,
2114 "name": "Books & Magazines",
2115 "display_name": "書籍及雜誌",
2116 "image": "abc123",
2117 "level": 1,
2118 "children": null
2119 }
2120 ]
2121 }
2122 })
2123 }
2124
2125 #[test]
2126 fn parses_category_tree_top_level() {
2127 let r = parse_category_tree(&category_tree_fixture());
2128 assert_eq!(r.len(), 2);
2129 assert_eq!(r[0].catid, 11_040_766);
2130 assert_eq!(r[0].parent_catid, 0);
2131 assert_eq!(r[0].name, "Women's Apparel");
2132 assert_eq!(r[0].display_name, "女生衣著");
2133 assert_eq!(
2134 r[0].image.as_deref(),
2135 Some("17f3879a1872099681d7b85101e187db")
2136 );
2137 assert_eq!(r[0].level, 1);
2138 assert!(r[0].children.is_empty());
2140 }
2141
2142 #[test]
2143 fn category_tree_recursive_children_are_parsed() {
2144 let body = json!({
2149 "data": {
2150 "category_list": [{
2151 "catid": 1,
2152 "parent_catid": 0,
2153 "name": "parent",
2154 "display_name": "父",
2155 "level": 1,
2156 "children": [
2157 {
2158 "catid": 11,
2159 "parent_catid": 1,
2160 "name": "child-a",
2161 "display_name": "子A",
2162 "level": 2,
2163 "children": null
2164 },
2165 {
2166 "catid": 12,
2167 "parent_catid": 1,
2168 "name": "child-b",
2169 "display_name": "子B",
2170 "level": 2,
2171 "children": [{
2172 "catid": 121,
2173 "parent_catid": 12,
2174 "name": "grandchild",
2175 "display_name": "孫",
2176 "level": 3,
2177 "children": null
2178 }]
2179 }
2180 ]
2181 }]
2182 }
2183 });
2184 let r = parse_category_tree(&body);
2185 assert_eq!(r.len(), 1);
2186 assert_eq!(r[0].children.len(), 2);
2187 assert_eq!(r[0].children[0].catid, 11);
2188 assert_eq!(r[0].children[0].level, 2);
2189 assert_eq!(r[0].children[1].children.len(), 1);
2190 assert_eq!(r[0].children[1].children[0].catid, 121);
2191 assert_eq!(r[0].children[1].children[0].level, 3);
2192 }
2193
2194 #[test]
2195 fn missing_category_list_yields_empty() {
2196 let body = json!({ "data": {} });
2197 assert!(parse_category_tree(&body).is_empty());
2198 }
2199
2200 #[test]
2201 fn category_list_with_non_array_value_degrades_gracefully() {
2202 let null_body = json!({ "data": { "category_list": null } });
2207 assert!(parse_category_tree(&null_body).is_empty());
2208
2209 let string_body = json!({ "data": { "category_list": "oops" } });
2210 assert!(parse_category_tree(&string_body).is_empty());
2211
2212 let object_body = json!({ "data": { "category_list": { "wrong": "shape" } } });
2213 assert!(parse_category_tree(&object_body).is_empty());
2214 }
2215
2216 fn fe_category_detail_subcategory_fixture() -> Value {
2224 json!({
2225 "data": {
2226 "categories": [
2227 {
2228 "catid": 11042305,
2229 "parent_cat_id": [11040766],
2230 "name": "Pants",
2231 "display_name": [
2232 { "lang": "zh-Hant", "value": "長褲", "is_default": true },
2233 { "lang": "zh-Hans", "value": "Pants", "is_default": false },
2234 { "lang": "en", "value": "Pants", "is_default": false }
2235 ],
2236 "image": "tw-11134258-7rbkc-m8ej6wv3w0yvd7",
2237 "level": 2,
2238 "block_buyer_platform": null
2239 }
2240 ]
2241 }
2242 })
2243 }
2244
2245 #[test]
2246 fn fe_category_detail_subcategory_parses() {
2247 let cat = parse_fe_category_detail(&fe_category_detail_subcategory_fixture())
2248 .expect("subcategory fixture parses to Some");
2249 assert_eq!(cat.catid, 11_042_305);
2250 assert_eq!(cat.name, "Pants");
2251 assert_eq!(cat.display_name, "長褲");
2252 assert_eq!(cat.level, 2);
2253 assert_eq!(cat.parent_cat_id, Some(11_040_766));
2254 assert_eq!(
2255 cat.image.as_deref(),
2256 Some("tw-11134258-7rbkc-m8ej6wv3w0yvd7")
2257 );
2258 }
2259
2260 #[test]
2261 fn fe_category_detail_top_level_has_no_parent() {
2262 let body = json!({
2266 "data": {
2267 "categories": [{
2268 "catid": 11040766,
2269 "parent_cat_id": null,
2270 "name": "Women's Apparel",
2271 "display_name": [
2272 { "lang": "zh-Hant", "value": "女生衣著", "is_default": true }
2273 ],
2274 "image": "17f3879a1872099681d7b85101e187db",
2275 "level": 1,
2276 "block_buyer_platform": null
2277 }]
2278 }
2279 });
2280 let cat = parse_fe_category_detail(&body).expect("top-level fixture parses");
2281 assert_eq!(cat.catid, 11_040_766);
2282 assert_eq!(cat.name, "Women's Apparel");
2283 assert_eq!(cat.display_name, "女生衣著");
2284 assert_eq!(cat.level, 1);
2285 assert!(cat.parent_cat_id.is_none(), "top-level has no parent");
2286 }
2287
2288 #[test]
2289 fn fe_category_detail_accepts_scalar_parent_cat_id() {
2290 let body = json!({
2294 "data": {
2295 "categories": [{
2296 "catid": 999,
2297 "parent_cat_id": 100,
2298 "name": "scalar",
2299 "display_name": [{ "lang": "zh-Hant", "value": "X", "is_default": true }],
2300 "level": 2
2301 }]
2302 }
2303 });
2304 let cat = parse_fe_category_detail(&body).expect("scalar parent fixture parses");
2305 assert_eq!(cat.parent_cat_id, Some(100));
2306 }
2307
2308 #[test]
2309 fn fe_category_detail_picks_first_when_no_default_locale() {
2310 let body = json!({
2313 "data": {
2314 "categories": [{
2315 "catid": 1,
2316 "parent_cat_id": null,
2317 "name": "n",
2318 "display_name": [
2319 { "lang": "en", "value": "fallback", "is_default": false }
2320 ],
2321 "level": 1
2322 }]
2323 }
2324 });
2325 let cat = parse_fe_category_detail(&body).expect("fallback fixture parses");
2326 assert_eq!(cat.display_name, "fallback");
2327 }
2328
2329 #[test]
2330 fn fe_category_detail_malformed_default_returns_none_not_other_locale() {
2331 let body = json!({
2336 "data": {
2337 "categories": [{
2338 "catid": 1,
2339 "parent_cat_id": null,
2340 "name": "n",
2341 "display_name": [
2342 { "lang": "zh-Hant", "is_default": true },
2344 { "lang": "en", "value": "WRONG_LOCALE", "is_default": false }
2345 ],
2346 "level": 1
2347 }]
2348 }
2349 });
2350 let cat = parse_fe_category_detail(&body).expect("malformed-default fixture parses");
2351 assert_eq!(cat.display_name, "");
2354 }
2355
2356 #[test]
2357 fn fe_category_detail_missing_data_yields_none() {
2358 assert!(parse_fe_category_detail(&json!({})).is_none());
2362 assert!(parse_fe_category_detail(&json!({ "data": {} })).is_none());
2363 assert!(parse_fe_category_detail(&json!({ "data": { "categories": [] } })).is_none());
2364 assert!(parse_fe_category_detail(&json!({ "data": { "categories": null } })).is_none());
2365 }
2366
2367 fn shop_info_fixture() -> Value {
2372 json!({
2373 "error": 0,
2374 "error_msg": null,
2375 "data": {
2376 "shop_id": 1530245671_u64,
2377 "user_id": 1531065367_u64,
2378 "last_active_time": 1777515787,
2379 "holiday_mode": false,
2380 "place": "700 臺南市中西區和意路68號",
2381 "is_shopee_verified": false,
2382 "is_official_shop": true,
2383 "item_count": 400,
2384 "rating_star": 4.987321002386635,
2385 "response_rate": 50,
2386 "name": "miko 米可|手機門號配件專賣",
2387 "ctime": 1745843453,
2388 "response_time": 0,
2389 "follower_count": 10539,
2390 "rating_bad": 2,
2391 "rating_good": 6964,
2392 "rating_normal": 14
2393 }
2394 })
2395 }
2396
2397 #[test]
2398 fn parses_shop_info_full_fields() {
2399 let r = parse_shop_info(&shop_info_fixture()).expect("parse");
2400 assert_eq!(r.shop_id, 1_530_245_671);
2401 assert_eq!(r.user_id, 1_531_065_367);
2402 assert_eq!(r.name, "miko 米可|手機門號配件專賣");
2403 assert_eq!(r.place.as_deref(), Some("700 臺南市中西區和意路68號"));
2404 assert!(r.is_official_shop);
2405 assert!(!r.is_shopee_verified);
2406 assert!(!r.holiday_mode);
2407 assert_eq!(r.item_count, 400);
2408 assert_eq!(r.follower_count, 10_539);
2409 assert!((r.rating_star - 4.987_321).abs() < 1e-3);
2410 assert_eq!(r.rating_good, 6964);
2411 assert_eq!(r.rating_normal, 14);
2412 assert_eq!(r.rating_bad, 2);
2413 assert_eq!(r.response_rate, 50);
2414 assert_eq!(r.ctime, 1_745_843_453);
2415 }
2416
2417 #[test]
2418 fn shop_info_captcha_wall_surfaces_distinct_error() {
2419 let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
2420 let err = parse_shop_info(&body).unwrap_err();
2421 let msg = err.to_string();
2422 assert!(msg.contains("90309999"));
2423 assert!(msg.contains("trust") || msg.contains("Profile"));
2424 }
2425
2426 #[test]
2427 fn shop_info_missing_data_is_parse_error() {
2428 let body = json!({ "error": 0 });
2429 let err = parse_shop_info(&body).unwrap_err();
2430 assert!(err.to_string().contains("missing `data`"));
2431 }
2432
2433 #[test]
2434 fn shop_info_sparse_response_defaults_cleanly() {
2435 let body = json!({
2438 "error": 0,
2439 "data": {
2440 "shop_id": 999,
2441 "name": "new-shop",
2442 "rating_star": 0.0
2443 }
2444 });
2445 let r = parse_shop_info(&body).unwrap();
2446 assert_eq!(r.shop_id, 999);
2447 assert_eq!(r.name, "new-shop");
2448 assert_eq!(r.item_count, 0);
2449 assert_eq!(r.follower_count, 0);
2450 assert!(!r.is_official_shop);
2451 assert!(r.place.is_none());
2452 }
2453
2454 fn reviews_fixture() -> Value {
2457 json!({
2458 "error": 0,
2459 "data": {
2460 "ratings": [
2461 {
2462 "cmtid": 96399040702_u64,
2463 "itemid": 42818537019_u64,
2464 "shopid": 109729156,
2465 "rating_star": 5,
2466 "comment": "",
2467 "ctime": 1776650189,
2468 "author_username": "k*****s",
2469 "anonymous": true
2470 },
2471 {
2472 "cmtid": 96399040703_u64,
2473 "itemid": 42818537019_u64,
2474 "shopid": 109729156,
2475 "rating_star": 4,
2476 "comment": "包裝完整,送達快速",
2477 "ctime": 1776650200,
2478 "author_username": "alice123",
2479 "anonymous": false,
2480 "images": ["tw-img-rev-aaa", "tw-img-rev-bbb"]
2481 }
2482 ],
2483 "item_rating_star": 4.5,
2484 "item_rating_count": 6,
2485 "has_more": true
2486 }
2487 })
2488 }
2489
2490 #[test]
2491 fn parses_reviews_with_summary_and_pagination_flag() {
2492 let r = parse_reviews(&reviews_fixture(), 109_729_156, 42_818_537_019).expect("parse");
2493 assert_eq!(r.shopid, 109_729_156);
2496 assert_eq!(r.itemid, 42_818_537_019);
2497 assert!((r.item_rating_star - 4.5).abs() < 1e-6);
2498 assert_eq!(r.item_rating_count, 6);
2499 assert!(r.has_more);
2500 assert_eq!(r.ratings.len(), 2);
2501 }
2502
2503 #[test]
2504 fn reviews_first_entry_anonymous_no_text_no_images() {
2505 let r = parse_reviews(&reviews_fixture(), 109_729_156, 42_818_537_019).unwrap();
2506 let rev = &r.ratings[0];
2507 assert_eq!(rev.cmtid, 96_399_040_702);
2508 assert_eq!(rev.rating_star, 5);
2509 assert!(rev.comment.is_empty());
2510 assert!(rev.anonymous);
2511 assert!(rev.images.is_empty());
2512 assert_eq!(rev.author_username, "k*****s");
2513 }
2514
2515 #[test]
2516 fn reviews_second_entry_with_text_and_images() {
2517 let r = parse_reviews(&reviews_fixture(), 109_729_156, 42_818_537_019).unwrap();
2518 let rev = &r.ratings[1];
2519 assert_eq!(rev.rating_star, 4);
2520 assert_eq!(rev.comment, "包裝完整,送達快速");
2521 assert!(!rev.anonymous);
2522 assert_eq!(rev.images, vec!["tw-img-rev-aaa", "tw-img-rev-bbb"]);
2523 }
2524
2525 #[test]
2526 fn reviews_captcha_wall_surfaces_distinct_error() {
2527 let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
2528 let err = parse_reviews(&body, 1, 2).unwrap_err();
2529 let msg = err.to_string();
2530 assert!(msg.contains("90309999"));
2531 }
2532
2533 #[test]
2534 fn reviews_empty_ratings_is_valid() {
2535 let body = json!({
2536 "error": 0,
2537 "data": {
2538 "ratings": [],
2539 "item_rating_star": 0.0,
2540 "item_rating_count": 0,
2541 "has_more": false
2542 }
2543 });
2544 let r = parse_reviews(&body, 1, 2).unwrap();
2545 assert!(r.ratings.is_empty());
2546 assert_eq!(r.item_rating_count, 0);
2547 assert!(!r.has_more);
2548 }
2549
2550 fn shop_items_fixture() -> Value {
2555 json!({
2556 "error": 0,
2557 "error_msg": "",
2558 "total_count": 28,
2559 "nomore": true,
2560 "centralize_item_card": {
2561 "item_cards": [
2562 {
2563 "itemid": 1449269277_u64,
2564 "shopid": 355141,
2565 "is_sold_out": false,
2566 "item_rating": {
2567 "rating_star": 4.934240362811791,
2568 "rating_count": [882, 2, 2, 12, 20, 846]
2569 },
2570 "liked_count": 999,
2571 "item_card_display_price": {
2572 "price": 103100000_u64
2573 },
2574 "item_card_displayed_asset": {
2575 "name": "Wilson 籃球 R68",
2576 "image": "tw-img-shop-aaa",
2577 "display_price": { "price": 103100000_u64 },
2578 "sold_count": { "text": "已售出 1000+" }
2579 }
2580 },
2581 {
2582 "itemid": 9999_u64,
2583 "shopid": 355141,
2584 "is_sold_out": true,
2585 "item_card_display_price": { "price": 50000000_u64 },
2586 "item_card_displayed_asset": {
2587 "name": "OOS item",
2588 "image": "tw-img-shop-bbb",
2589 "display_price": { "price": 50000000_u64 }
2590 }
2591 }
2592 ]
2593 }
2594 })
2595 }
2596
2597 #[test]
2598 fn parses_shop_items_with_pagination_metadata() {
2599 let r = parse_shop_items(&shop_items_fixture(), 355141, 0).expect("parse");
2600 assert_eq!(r.shopid, 355141);
2601 assert_eq!(r.page, 0);
2602 assert_eq!(r.total_count, 28);
2603 assert!(r.nomore);
2604 assert_eq!(r.items.len(), 2);
2605 }
2606
2607 #[test]
2608 fn shop_items_first_card_has_full_fields() {
2609 let r = parse_shop_items(&shop_items_fixture(), 355141, 0).unwrap();
2610 let it = &r.items[0];
2611 assert_eq!(it.itemid, 1_449_269_277);
2614 assert_eq!(it.shopid, 355141);
2615 assert_eq!(it.name, "Wilson 籃球 R68");
2617 assert_eq!(it.image.as_deref(), Some("tw-img-shop-aaa"));
2618 assert_eq!(it.price, 103_100_000);
2620 assert!((it.rating_star - 4.934_240).abs() < 1e-3);
2622 assert_eq!(it.rating_count, 882);
2623 assert_eq!(it.stock, -1);
2627 assert!(!it.is_sold_out);
2628 }
2629
2630 #[test]
2631 fn shop_items_sold_out_card_sets_is_sold_out_flag() {
2632 let r = parse_shop_items(&shop_items_fixture(), 355141, 0).unwrap();
2633 let oos = &r.items[1];
2634 assert!(oos.name.contains("OOS"));
2635 assert_eq!(oos.stock, -1);
2639 assert!(oos.is_sold_out);
2640 }
2641
2642 #[test]
2643 fn shop_items_page_index_round_trips() {
2644 let r = parse_shop_items(&shop_items_fixture(), 355141, 3).unwrap();
2645 assert_eq!(r.page, 3);
2646 }
2647
2648 #[test]
2649 fn shop_items_captcha_wall_surfaces_distinct_error() {
2650 let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
2651 let err = parse_shop_items(&body, 1, 0).unwrap_err();
2652 assert!(err.to_string().contains("90309999"));
2653 }
2654
2655 #[test]
2656 fn shop_items_missing_centralize_item_card_yields_empty() {
2657 let body = json!({ "error": 0, "total_count": 0, "nomore": true });
2659 let r = parse_shop_items(&body, 1, 0).unwrap();
2660 assert!(r.items.is_empty());
2661 assert!(r.nomore);
2662 }
2663
2664 fn shop_items_rcmd_fixture() -> Value {
2669 json!({
2670 "error": 0,
2671 "data": {
2672 "total": 1553,
2673 "no_more": false,
2674 "centralize_item_card": {
2675 "item_cards": [
2676 {
2677 "itemid": 71204627_u64,
2678 "shopid": 355141,
2679 "is_sold_out": false,
2680 "item_rating": {
2681 "rating_star": 4.8,
2682 "rating_count": [50, 0, 1, 2, 5, 42]
2683 },
2684 "item_card_displayed_asset": {
2685 "name": "rcmd-item-aaa",
2686 "image": "tw-img-rcmd-aaa",
2687 "display_price": { "price": 75000000_u64 }
2688 }
2689 }
2690 ]
2691 }
2692 }
2693 })
2694 }
2695
2696 #[test]
2697 fn parses_rcmd_items_wrapped_shape() {
2698 let r = parse_shop_items(&shop_items_rcmd_fixture(), 355141, 0).expect("parse rcmd");
2703 assert_eq!(r.shopid, 355141);
2704 assert_eq!(r.total_count, 1553);
2705 assert!(!r.nomore); assert_eq!(r.items.len(), 1);
2707 let it = &r.items[0];
2708 assert_eq!(it.itemid, 71_204_627);
2709 assert_eq!(it.name, "rcmd-item-aaa");
2710 assert_eq!(it.price, 75_000_000);
2711 assert_eq!(it.stock, -1);
2712 assert!(!it.is_sold_out);
2713 }
2714
2715 fn search_user_fixture() -> Value {
2721 json!({
2722 "error": null,
2723 "data": {
2724 "users": [
2725 {
2726 "shopid": 1276618414_u64,
2727 "userid": 1277122037_u64,
2728 "username": "orz_orz_orz",
2729 "shopname": " 銘鈺標識",
2730 "nickname": " 銘鈺標識",
2731 "portrait": "tw-11134216-81ztn-mgj5ztv0q51k66",
2732 "shop_rating": 4.981132075471698,
2733 "follower_count": 137,
2734 "products": 277,
2735 "is_official_shop": false,
2736 "shopee_verified_flag": 1,
2737 "response_rate": 100,
2738 "response_time": 3855,
2739 "country": "tw"
2740 }
2741 ]
2742 }
2743 })
2744 }
2745
2746 #[test]
2747 fn parses_search_user_shop_match() {
2748 let r = parse_search_user(&search_user_fixture(), "籃球").expect("parse");
2749 assert_eq!(r.keyword, "籃球");
2750 assert_eq!(r.users.len(), 1);
2751 let u = &r.users[0];
2752 assert_eq!(u.shopid, 1_276_618_414);
2753 assert_eq!(u.userid, 1_277_122_037);
2754 assert_eq!(u.username, "orz_orz_orz");
2755 assert_eq!(u.shopname, " 銘鈺標識");
2756 assert_eq!(
2757 u.portrait.as_deref(),
2758 Some("tw-11134216-81ztn-mgj5ztv0q51k66")
2759 );
2760 assert!((u.shop_rating - 4.981).abs() < 1e-3);
2761 assert_eq!(u.follower_count, 137);
2762 assert_eq!(u.products, 277);
2763 assert!(!u.is_official_shop);
2764 assert_eq!(u.shopee_verified_flag, 1);
2765 assert_eq!(u.response_rate, 100);
2766 assert_eq!(u.country, "tw");
2767 }
2768
2769 #[test]
2770 fn search_user_handles_null_error_field_as_success() {
2771 let r = parse_search_user(&search_user_fixture(), "x").unwrap();
2775 assert!(!r.users.is_empty());
2776 }
2777
2778 #[test]
2779 fn search_user_captcha_wall_surfaces_distinct_error() {
2780 let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
2781 let err = parse_search_user(&body, "x").unwrap_err();
2782 assert!(err.to_string().contains("90309999"));
2783 }
2784
2785 #[test]
2786 fn search_user_empty_users_is_valid() {
2787 let body = json!({ "error": 0, "data": { "users": [] } });
2790 let r = parse_search_user(&body, "non-existent-keyword").unwrap();
2791 assert!(r.users.is_empty());
2792 }
2793
2794 #[test]
2795 fn search_user_missing_data_yields_empty_users() {
2796 let body = json!({ "error": 0 });
2799 let r = parse_search_user(&body, "x").unwrap();
2800 assert!(r.users.is_empty());
2801 }
2802}