tail-fin-shopee 0.7.8

Shopee adapter for tail-fin: account info, search (browser-only), product detail. Multi-region (TW/SG/MY/...).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
//! Mode A — browser-attached client for endpoints that require
//! Shopee's per-request anti-bot signature (`af-ac-enc-dat`).
//!
//! The strategy mirrors `tail-fin-spotify`'s pathfinder-capture
//! pattern: rather than computing the signature ourselves, attach
//! to the user's already-trusted Chrome via CDP, let Shopee's own
//! JS fire the request (it computes the signature naturally), and
//! capture the response body off the network layer with
//! `BrowserSession::capture_responses`.
//!
//! ## Prerequisites
//!
//! 1. Real Chrome (NOT chromium) running with:
//!    ```sh
//!    /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
//!      --remote-debugging-port=9222 \
//!      --user-data-dir="$HOME/.tail-fin/chrome-modea"
//!    ```
//! 2. That Chrome has at least one tab open at `https://shopee.tw/`
//!    (or whichever region) and is logged in.
//! 3. The profile has been "warmed up" — manually browse a few
//!    products / complete a search or two before relying on this.
//!    A brand-new profile starts with zero trust; Shopee will
//!    CAPTCHA-wall search even with valid cookies until the trust
//!    score climbs.
//!
//! See `crates/tail-fin-shopee/README.md` for the full prereq write-up.

use serde_json::{json, Value};
use tail_fin_common::{BrowserSession, TailFinError};

use crate::parsing::{
    combine_discover, combine_related, parse_cart_mini, parse_category_tree,
    parse_fe_category_detail, parse_product_detail, parse_reviews, parse_search_items,
    parse_search_user, parse_shop_info, parse_shop_items,
};
use crate::site::ShopeeRegion;
use crate::types::{
    CartPreview, Category, CategoryPage, Discover, HomepageBundle, ProductDetail, RelatedItems,
    Reviews, SearchResults, ShopInfo, ShopItems, UserSearchResults,
};

/// CDP capture filter — broad on purpose so future endpoints in the
/// same family (cart, orders, recommend) reuse one `Fetch.enable`
/// session instead of stacking filters. Widened from
/// `*/api/v4/*` to `*/api/*` so the `reviews` endpoint
/// (`/api/v2/item/get_ratings` — Shopee shipped this on v2 not
/// v4) is caught alongside the v4 endpoints. Cost: captures a
/// few more Shopee internal calls (`api/v1/rating/common_config`
/// and similar) we silently drop in user-space.
const CAPTURE_PATTERN: &str = "*shopee.*/api/*";

/// Browser-attached Shopee client. Construct via `new(session)` after
/// attaching the `BrowserSession` to a logged-in Chrome tab — see
/// the module docstring for prereqs.
pub struct ShopeeBrowserClient {
    session: BrowserSession,
    region: ShopeeRegion,
    capture_installed: tokio::sync::OnceCell<()>,
}

impl ShopeeBrowserClient {
    pub fn new(session: BrowserSession, region: ShopeeRegion) -> Self {
        Self {
            session,
            region,
            capture_installed: tokio::sync::OnceCell::const_new(),
        }
    }

    /// Region this client targets.
    pub fn region(&self) -> ShopeeRegion {
        self.region
    }

    /// Underlying browser session — exposed for callers that want to
    /// drive additional CDP ops on the same attached tab.
    pub fn session(&self) -> &BrowserSession {
        &self.session
    }

    /// Enable CDP-level response-body capture for `/api/v4/*`.
    /// Idempotent — the underlying `Fetch.enable` is global per
    /// session, so calling it twice would double-deliver events.
    ///
    /// Also disables the service worker so SW-cached responses
    /// surface at the CDP network layer. Shopee registers a worker
    /// (`pcmall_root_assets_sw.js`); without bypass, `Fetch.enable`
    /// sees zero events for `/api/v4/*` because the SW satisfies
    /// the requests internally.
    async fn ensure_capture_installed(&self) -> Result<(), TailFinError> {
        self.capture_installed
            .get_or_try_init(|| async {
                self.session.capture_responses(CAPTURE_PATTERN).await?;
                let _ = self
                    .session
                    .cdp_command("Network.setBypassServiceWorker", json!({ "bypass": true }))
                    .await;
                Ok::<(), TailFinError>(())
            })
            .await?;
        Ok(())
    }

    /// Search Shopee — first page only. Equivalent to
    /// [`Self::search_page`] with `page = 0`.
    pub async fn search(&self, keyword: &str) -> Result<SearchResults, TailFinError> {
        self.search_page(keyword, 0).await
    }

    /// Search Shopee, fetching a specific page of results.
    ///
    /// `page` is 0-indexed; each page yields up to 60 items. The
    /// underlying `/api/v4/search/search_items` call uses
    /// `newest=page*60` as its offset. Empirically (verified against
    /// shopee.tw 2026-04-30) Shopee's URL bar uses **0-indexed**
    /// `&page=` — `&page=0` triggers `newest=0` (first page),
    /// `&page=1` triggers `newest=60` (second page) — so we pass
    /// `page` through verbatim.
    pub async fn search_page(
        &self,
        keyword: &str,
        page: u32,
    ) -> Result<SearchResults, TailFinError> {
        self.ensure_capture_installed().await?;

        // urlencoding lives in the workspace via tail-fin-spotify but
        // isn't a direct dep here; do the minimal encoding inline.
        let encoded = url_encode(keyword);
        let url = format!(
            "{}/search?keyword={}&page={}",
            self.region.base_url(),
            encoded,
            page,
        );

        navigate_force(&self.session, &url).await?;

        // Search renders behind a chunked SPA load. 30s max waits
        // for the document + initial XHR storm; 3s of quiet means
        // we're past the burst.
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        // One extra second to catch the final search_items response,
        // which can land just after the network-idle window closes
        // (Shopee debounces it client-side).
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;

        // Confirm we actually landed on the search page — guards against
        // CAPTCHA challenge redirects that wouldn't fire `search_items`.
        if let Ok(current) = self.session.get_url().await {
            if !current.contains("/search") {
                return Err(TailFinError::Api(format!(
                    "navigation landed on {current} instead of /search — \
                     Shopee likely served a CAPTCHA / verify page. Solve \
                     it manually in the attached Chrome window and retry."
                )));
            }
        }

        let captured = self.session.get_captured_responses().await?;
        let mut found: Option<Value> = None;
        // Match the response carrying the offset we asked for, not
        // just any `search_items` URL. Without this, an embedded
        // recommendation rail or an auto-scroll prefetch could
        // capture a different page's response and we'd mis-attribute.
        let want_offset = format!("newest={}", page * 60);
        let mut search_urls_seen: Vec<String> = Vec::new();
        for r in &captured {
            if r.url.contains("/api/v4/search/search_items") {
                search_urls_seen.push(r.url.clone());
                if r.url.contains(&want_offset) {
                    if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                        found = Some(v);
                        // Don't break — the last response wins
                        // (Shopee may fire multiple matching
                        // search_items, want the freshest one).
                    }
                }
            }
        }
        let body = found.ok_or_else(|| {
            TailFinError::Api(format!(
                "no /api/v4/search/search_items response captured for {want_offset}. \
                 search_items URLs we DID see ({} total): {:?}",
                search_urls_seen.len(),
                search_urls_seen
                    .iter()
                    .map(|u| u.split('?').nth(1).unwrap_or(""))
                    .collect::<Vec<_>>(),
            ))
        })?;

        parse_search_items(&body, keyword, page)
    }

    /// Fetch detailed product information.
    ///
    /// Navigates the attached tab to the canonical product URL
    /// (`/product/{shopid}/{itemid}`), waits for either
    /// `/api/v4/pdp/get_pc` or `/api/v4/item/get` to land in the
    /// capture buffer (Shopee has shipped both over time), then
    /// parses it.
    ///
    /// Empirically the product-detail endpoint is **lower-trust** than
    /// search — public product pages render fine on a fresh attached
    /// tab even when search is CAPTCHA-walled. Still, callers should
    /// treat `error: 90309999` as "warm the profile up" the same way
    /// `search()` does.
    pub async fn product_detail(
        &self,
        shopid: u64,
        itemid: u64,
    ) -> Result<ProductDetail, TailFinError> {
        self.ensure_capture_installed().await?;

        // Canonical product URL — shopee accepts both
        // `/product/{shopid}/{itemid}` (which we use, simplest) and
        // the SEO-slug form `/{slug}-i.{shopid}.{itemid}`.
        let url = format!("{}/product/{}/{}", self.region.base_url(), shopid, itemid);

        navigate_force(&self.session, &url).await?;

        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;

        if let Ok(current) = self.session.get_url().await {
            // Shopee may redirect canonical → SEO-slug form
            // (`/{slug}-i.{shopid}.{itemid}`), so we accept either.
            // Anchored markers — bare `"{shopid}.{itemid}"` would
            // substring-collide with arbitrary URL fragments that
            // happen to contain those digits separated by a dot
            // (analytics URLs, prefix-extended IDs, …).
            let canonical = format!("/product/{shopid}/{itemid}");
            let seo_slug = format!("-i.{shopid}.{itemid}");
            if !current.contains(&canonical) && !current.contains(&seo_slug) {
                return Err(TailFinError::Api(format!(
                    "navigation landed on {current} — expected a product \
                     page for shopid={shopid} itemid={itemid}. CAPTCHA \
                     redirect, or the item was removed."
                )));
            }
        }

        let captured = self.session.get_captured_responses().await?;
        let mut found: Option<Value> = None;
        let mut detail_url_seen = String::new();
        // Anchored ID-match strings — modern PDP uses
        // `?item_id=…&shop_id=…`, legacy `item/get` uses
        // `?itemid=…&shopid=…`. Bare `contains(itemid.to_string())`
        // would match prefix-extended IDs on embedded recommendation
        // cards (e.g. itemid 12345 colliding with itemid 123456).
        let item_modern = format!("item_id={itemid}");
        let shop_modern = format!("shop_id={shopid}");
        let item_legacy = format!("itemid={itemid}");
        let shop_legacy = format!("shopid={shopid}");
        for r in &captured {
            // Match either Shopee endpoint shipping product detail.
            // Both contain shopid/itemid in the query string for
            // disambiguation when multiple PDP fetches fire (rare,
            // but defensive).
            let is_detail_endpoint =
                r.url.contains("/api/v4/pdp/get_pc") || r.url.contains("/api/v4/item/get");
            if !is_detail_endpoint {
                continue;
            }
            // Take the response whose query string carries OUR ids,
            // not some embedded recommendation card.
            let our_ids = (r.url.contains(&item_modern) || r.url.contains(&item_legacy))
                && (r.url.contains(&shop_modern) || r.url.contains(&shop_legacy));
            if our_ids {
                if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                    found = Some(v);
                    detail_url_seen = r.url.clone();
                }
            }
        }
        let body = found.ok_or_else(|| {
            // Surface what WAS captured to help diagnose endpoint
            // changes (Shopee occasionally shifts URL paths).
            let urls: Vec<&str> = captured
                .iter()
                .filter(|r| r.url.contains("/api/v4/"))
                .map(|r| r.url.as_str())
                .collect();
            TailFinError::Api(format!(
                "no /api/v4/pdp/get_pc or /api/v4/item/get response captured \
                 for shopid={shopid} itemid={itemid}. \
                 Captured /api/v4/* URLs ({} total): {:?}",
                urls.len(),
                urls
            ))
        })?;

        // If parsing fails, surface the response shape so we can fix
        // the parser (Shopee occasionally rotates wrapper layouts).
        // Lists `data.*` keys + `data.item.*` keys — enough to
        // pinpoint schema drift without dumping the multi-MB body.
        parse_product_detail(&body).map_err(|e| {
            let data_keys: Vec<&str> = body
                .pointer("/data")
                .and_then(|v| v.as_object())
                .map(|o| o.keys().map(|s| s.as_str()).collect())
                .unwrap_or_default();
            let item_keys: Vec<&str> = body
                .pointer("/data/item")
                .and_then(|v| v.as_object())
                .map(|o| o.keys().map(|s| s.as_str()).collect())
                .unwrap_or_default();
            TailFinError::Api(format!(
                "{e} (url: {detail_url_seen})\n  \
                 data.* keys: {data_keys:?}\n  \
                 data.item.* keys: {item_keys:?}"
            ))
        })
    }

    /// Fetch related/recommended products for a given item.
    ///
    /// Drives the same PDP load as `product_detail` (Shopee's PDP
    /// fires both `pdp/hot_sales/get_item_cards` and
    /// `recommend/product_detail_page` automatically as the page
    /// renders), then drains the captures and returns both.
    ///
    /// Returns an empty `RelatedItems` (rather than erroring) when
    /// neither side captured — Shopee personalises this aggressively
    /// and a brand-new account or a low-trust profile can legitimately
    /// see zero recommendations.
    pub async fn related_products(
        &self,
        shopid: u64,
        itemid: u64,
    ) -> Result<RelatedItems, TailFinError> {
        self.ensure_capture_installed().await?;

        let url = format!("{}/product/{}/{}", self.region.base_url(), shopid, itemid);
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        // PDP recommend endpoints fire after the main PDP body
        // resolves; give them an extra second to land.
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;

        // Sanity-check URL — if Shopee redirected us to a CAPTCHA /
        // verify page, none of the recommend endpoints would fire and
        // we'd return `hot_sales=0 recommended=0` silently. Mirrors
        // the URL check in `product_detail`.
        if let Ok(current) = self.session.get_url().await {
            let canonical = format!("/product/{shopid}/{itemid}");
            let seo_slug = format!("-i.{shopid}.{itemid}");
            if !current.contains(&canonical) && !current.contains(&seo_slug) {
                return Err(TailFinError::Api(format!(
                    "navigation landed on {current} — expected a product \
                     page for shopid={shopid} itemid={itemid} during \
                     related_products. CAPTCHA redirect, or the item \
                     was removed."
                )));
            }
        }

        let captured = self.session.get_captured_responses().await?;
        let item_marker = format!("item_id={itemid}");
        let mut hot_sales_body: Option<Value> = None;
        let mut recommend_body: Option<Value> = None;
        for r in captured {
            // Match by URL substring + our itemid in the query/body
            // so an embedded card's recommend doesn't poison ours.
            // (`recommend/product_detail_page` is a POST without
            // query-string IDs — there's typically only one fired per
            // PDP load, so URL-only match is fine for it.)
            if r.url.contains("/api/v4/pdp/hot_sales/get_item_cards")
                && r.url.contains(&item_marker)
            {
                if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                    hot_sales_body = Some(v);
                }
            } else if r.url.contains("/api/v4/recommend/product_detail_page") {
                if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                    recommend_body = Some(v);
                }
            }
        }

        Ok(combine_related(
            hot_sales_body.as_ref(),
            recommend_body.as_ref(),
            shopid,
            itemid,
        ))
    }

    /// Fetch the authenticated user's cart preview.
    ///
    /// Drives a homepage navigation (`shopee.{region}/`) which
    /// always fires `cart/mini` to populate the header badge,
    /// then captures + parses that response. Returns up to 5
    /// most-recently-added items plus the total / unique counts.
    ///
    /// Trust note: cart/mini was previously 90309999-walled per
    /// `shopee_antibot_signature` memory; verified open against
    /// the warmed `chrome-modea` profile 2026-04-30. If the wall
    /// returns, the parser surfaces a distinct error pointing to
    /// "warm the profile up further".
    pub async fn cart_preview(&self) -> Result<CartPreview, TailFinError> {
        self.ensure_capture_installed().await?;

        // Navigate to homepage — every Shopee page fires cart/mini
        // in its header init, but the homepage is the most reliable
        // (PDP / search pages have heavier inflight JS that can
        // race with our capture window).
        let url = format!("{}/", self.region.base_url());
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;

        let captured = self.session.get_captured_responses().await?;
        let mut found: Option<Value> = None;
        for r in captured {
            if r.url.contains("/api/v4/cart/mini") {
                if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                    found = Some(v);
                    // Last response wins — Shopee may fire
                    // cart/mini on each header re-render.
                }
            }
        }
        let body = found.ok_or_else(|| {
            TailFinError::Api(
                "no /api/v4/cart/mini response captured. Did the tab \
                 reach the homepage? CAPTCHA / verify-page would block \
                 the header init."
                    .to_string(),
            )
        })?;

        parse_cart_mini(&body)
    }

    /// Fetch the homepage "discover" surface — personalised feed +
    /// flash-sale + mall-shop highlights, all from one navigation.
    ///
    /// Shopee fires three distinct endpoints automatically when the
    /// homepage renders:
    /// - `homepage/get_daily_discover` — personalised "for you" feed
    /// - `flash_sale/flash_sale_get_items` — limited-time deals
    /// - `homepage/mall_shops` — official-mall shop highlights
    ///
    /// We navigate to `shopee.{region}/`, drain the capture buffer
    /// once, and return all three batches combined into a single
    /// `Discover`. Saves ~60 s vs three independent calls (each
    /// would pay its own ~30 s navigate cost).
    ///
    /// Any of the three may be empty for a low-trust profile or
    /// during off-peak hours (Shopee occasionally hides the flash
    /// sale module entirely between sessions). Caller decides
    /// which batches are mandatory for their use case.
    pub async fn discover(&self) -> Result<Discover, TailFinError> {
        self.ensure_capture_installed().await?;

        let url = format!("{}/", self.region.base_url());
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        // Homepage fires its three discover endpoints sequentially
        // as the page hydrates; the daily-discover feed in
        // particular lands ~1 s after the initial XHR storm
        // settles. 2 s buffer catches it.
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;

        let captured = self.session.get_captured_responses().await?;
        let mut discover_body: Option<Value> = None;
        let mut flash_sale_body: Option<Value> = None;
        let mut mall_shops_body: Option<Value> = None;
        for r in captured {
            let parse = || serde_json::from_str::<Value>(&r.body).ok();
            if r.url.contains("/api/v4/homepage/get_daily_discover") {
                discover_body = parse();
            } else if r.url.contains("/api/v4/flash_sale/flash_sale_get_items") {
                flash_sale_body = parse();
            } else if r.url.contains("/api/v4/homepage/mall_shops") {
                mall_shops_body = parse();
            }
        }

        Ok(combine_discover(
            discover_body.as_ref(),
            flash_sale_body.as_ref(),
            mall_shops_body.as_ref(),
        ))
    }

    /// Fetch the top-level category list (`/api/v4/pages/get_homepage_category_list`).
    ///
    /// Shopee fires this on every homepage load to populate the
    /// category sidebar. Returns 24 top-level categories on
    /// shopee.tw (`level: 1`, `parent_catid: 0`); each
    /// `Category.children` is empty for this endpoint — sub-
    /// category drilling lives behind separate endpoints we
    /// haven't wrapped yet.
    ///
    /// Public-read: should work on any logged-in profile, no
    /// trust-warming required.
    pub async fn category_tree(&self) -> Result<Vec<Category>, TailFinError> {
        self.ensure_capture_installed().await?;

        let url = format!("{}/", self.region.base_url());
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;

        let captured = self.session.get_captured_responses().await?;
        let mut found: Option<Value> = None;
        for r in captured {
            if r.url.contains("/api/v4/pages/get_homepage_category_list") {
                if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                    found = Some(v);
                }
            }
        }
        let body = found.ok_or_else(|| {
            TailFinError::Api(
                "no /api/v4/pages/get_homepage_category_list response captured. \
                 Did the homepage load? CAPTCHA / verify-page would block the \
                 sidebar init."
                    .to_string(),
            )
        })?;

        Ok(parse_category_tree(&body))
    }

    /// Browse a Shopee **leaf** sub-category — items list +
    /// category metadata.
    ///
    /// **Two-arg form: parent catid + leaf catid.** Shopee's
    /// sub-category URLs encode the full ancestor chain
    /// (`cat.<parent>.<leaf>`), and the SPA refuses to render a
    /// sub-category page without it — passing just the leaf catid
    /// causes Shopee to redirect to the top-level parent's
    /// collection landing.
    ///
    /// **Top-level catids return no items.** Top-level pages
    /// (`level: 1`) render *collection landings* (popular
    /// collections + banners + official mall shops) and don't
    /// fire `search_items`. `category` is leaf-only by design —
    /// pass `<top_level_catid>` as the parent and
    /// `<sub_category_catid>` as the leaf.
    ///
    /// **Page is SSR'd; we trigger a sort-click to fire `search_items`.**
    /// Shopee bakes the first 60 items directly into the initial
    /// HTML — no XHR fires on the bare page load. Direct
    /// `fetch()` from inside the page is anti-bot-walled (the
    /// signature is on Shopee's internal HTTP client, not on
    /// `window.fetch`). The reliable trigger: programmatically
    /// click a sort tab (`button.shopee-sort-by-options__option`,
    /// stable BEM class) — Shopee's SPA re-fetches via their
    /// signed client and our capture pattern catches the
    /// response. We click "最新" (by=ctime) so items come back
    /// sorted by creation time descending; if "最新" isn't
    /// present, fallback to any inactive sort. Side effect:
    /// items in the response are sorted differently from the
    /// SSR default (relevancy / "綜合排名").
    ///
    /// **`page` parameter — v1 supports `0` only.** The `page`
    /// arg is wired through but pagination beyond page 0 is
    /// deferred to a follow-up that clicks the
    /// `.shopee-mini-page-controller__next-btn` element. Passing
    /// `page > 0` will navigate to `?page=N` but the sort-click
    /// re-fetches `newest=0` (page 1) regardless.
    ///
    /// Navigation: `<base>/category-cat.<parent>.<leaf>?page=N`
    /// — the slug is decorative; Shopee's router only parses the
    /// numeric `cat.<parent>.<leaf>` suffix. Empirically tried +
    /// ruled out:
    /// - `/search?fe_categoryids=<catid>` → fires
    ///   `scenario=PAGE_OTHERS`, NOT `PAGE_CATEGORY`.
    /// - `/-cat.<catid>` (empty slug) → 404s before SPA loads.
    /// - `/<slug>-cat.<leaf>` (no chain) → Shopee redirects to
    ///   parent's top-level landing.
    ///
    /// Captures:
    /// - `/api/v4/search/get_fe_category_detail?catids=<leaf>` —
    ///   metadata; degrades to `category: None` if not fired.
    /// - `/api/v4/search/search_items?fe_categoryids=<leaf>&by=ctime&newest=0&...` —
    ///   the items list (60 items, sorted newest-first).
    ///
    /// **Trust requirement**: same as `search` — fresh / unwarmed
    /// profiles get `error: 90309999` (Shopee's anti-bot wall).
    /// Browse a few products manually in the attached Chrome
    /// before relying on this for production data.
    pub async fn category(
        &self,
        parent_catid: u64,
        leaf_catid: u64,
        page: u32,
    ) -> Result<CategoryPage, TailFinError> {
        self.ensure_capture_installed().await?;

        // v1 only supports `page == 0`. The sort-click trigger
        // re-fetches `newest=0` regardless of URL `?page=N`, so
        // higher pages would silently land on the post-click
        // `want_offset` mismatch path and return a cryptic "no
        // search_items captured" error. Hard-fail explicitly so
        // callers learn the limitation without forensics.
        // Pagination beyond page 0 is queued for a follow-up that
        // clicks `.shopee-mini-page-controller__next-btn` after
        // the initial sort-click.
        if page != 0 {
            return Err(TailFinError::Api(format!(
                "category() v1 supports page=0 only (got page={page}); pagination \
                 beyond page 0 is queued for a follow-up. Use page=0 to fetch the \
                 first 60 items."
            )));
        }

        // `category-cat.<parent>.<leaf>` — Shopee's URL format for
        // a level-2 sub-category. The "category" prefix is a
        // decorative slug placeholder (Shopee's router parses only
        // the numeric `cat.N.M` suffix); using a literal English
        // word keeps the URL human-debuggable when pasted into a
        // browser. v1 supports level-2 leaves only — deeper
        // hierarchies (level-3+) need a longer dotted chain and
        // are deferred to a follow-up that loads `get_category_tree`
        // to walk ancestors automatically.
        let url = format!(
            "{}/category-cat.{}.{}",
            self.region.base_url(),
            parent_catid,
            leaf_catid,
        );
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        // Tail buffer for SSR'd page to fully settle before we
        // start interacting with sort buttons.
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;

        // NB: do NOT drain the pre-click capture buffer here —
        // `get_fe_category_detail` fires during the initial SSR
        // navigation, not on the sort-click. Draining would lose
        // it permanently and force `category` metadata to None.
        // The post-click `search_items` is the only XHR we expect
        // to arrive AFTER this point; the URL matcher below
        // distinguishes it from any pre-click responses.
        //
        // Empirical reality: Shopee's category page is **SSR'd** —
        // the first 60 items are baked into the initial HTML and
        // NO `search_items` XHR fires on the initial page render.
        // (Verified: `_initiator: script` for the original HAR
        // entries, all firing only after user clicked sort tabs.)
        //
        // Direct `fetch('/api/v4/.../search_items?...')` from inside
        // the SSR'd page returns 403 + `error: 90309999` — Shopee
        // adds the anti-bot signature on their internal HTTP client,
        // not on `window.fetch`, so we can't fire it ourselves.
        //
        // The reliable trigger: **click a sort tab**. Shopee's SPA
        // re-fetches via their signed client → our capture pattern
        // catches the response. The BEM-style class
        // `shopee-sort-by-options__option` is stable (part of
        // Shopee's design system) and the "最新" (newest /
        // `by=ctime`) sort tab is universal across categories.
        // Side effect: items come back sorted by creation time
        // descending rather than the SSR default "綜合排名" /
        // relevancy. Documented above.
        let click_result = self
            .session
            .eval(
                r#"
            (function(){
                const buttons = Array.from(document.querySelectorAll('button.shopee-sort-by-options__option'));
                if (buttons.length === 0) return { ok: false, reason: 'no_sort_buttons' };
                // Prefer "最新" (by=ctime) — semantically meaningful + stable across categories.
                let target = buttons.find(b => b.textContent.trim() === '最新' && b.getAttribute('aria-pressed') !== 'true');
                // Fallback: any inactive sort. Triggers the same SPA refetch even if the label drifts.
                if (!target) target = buttons.find(b => b.getAttribute('aria-pressed') !== 'true');
                if (!target) return { ok: false, reason: 'all_sorts_active', total: buttons.length };
                const text = target.textContent.trim();
                target.click();
                return { ok: true, clicked: text };
            })()
            "#,
            )
            .await
            .map_err(|e| TailFinError::Api(format!("sort-click eval failed: {e:?}")))?;
        // Robust structural check — `eval` returns the JS value
        // directly as `serde_json::Value`. Avoids substring-on-
        // stringified-JSON brittleness.
        let click_ok = click_result
            .get("ok")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        if !click_ok {
            let reason = click_result
                .get("reason")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown");
            return Err(TailFinError::Api(format!(
                "sort-click failed (reason={reason}): {click_result}. \
                 Page may have rendered without the sort tabs \
                 (different layout? CAPTCHA? warm the profile up)."
            )));
        }

        // Wait for Shopee's SPA to fire `search_items` after click.
        // Adaptive: settle when network goes quiet for 1.5s within
        // an 8s budget (XHR empirically lands within ~1.5s, but
        // network pressure can stretch it). 1s tail catches the
        // last-response-wins edge case where Shopee re-fires.
        let _ = self.session.wait_for_network_idle(8_000, 1_500).await;
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;

        // CAPTCHA / verify-page redirect guard. Shopee may rewrite
        // the empty-slug `/-cat.<id>` to a slugged form
        // (`/<slug>-cat.<id>`) — that's fine; we only care that
        // `cat.` is in the final URL.
        if let Ok(current) = self.session.get_url().await {
            if !current.contains("cat.") {
                return Err(TailFinError::Api(format!(
                    "navigation landed on {current} instead of a category page — \
                     Shopee likely served a CAPTCHA / verify page. Solve \
                     it manually in the attached Chrome window and retry."
                )));
            }
        }

        let captured = self.session.get_captured_responses().await?;
        let want_offset = format!("newest={}", page * 60);
        let mut items_body: Option<Value> = None;
        let mut detail_body: Option<Value> = None;
        let mut search_urls_seen: Vec<String> = Vec::new();
        for r in &captured {
            // Items endpoint — match the offset we asked for, same
            // discipline as search (an embedded recommend rail
            // could capture a different page's response otherwise).
            if r.url.contains("/api/v4/search/search_items") {
                search_urls_seen.push(r.url.clone());
                if r.url.contains(&want_offset) && r.url.contains("PAGE_CATEGORY") {
                    if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                        items_body = Some(v);
                        // Don't break — last response wins (matches
                        // `search_page`'s discipline: Shopee can
                        // re-fire as state settles, want freshest).
                    }
                }
            } else if r.url.contains("/api/v4/search/get_fe_category_detail")
                && r.url.contains(&format!("catids={leaf_catid}"))
            {
                if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                    detail_body = Some(v);
                }
            }
        }

        // Diagnostic prefetch: capture the landing URL up-front
        // (the closure below can't `await`). Helps debug "did the
        // page even load?" vs "loaded the wrong scenario?".
        let landing = self.session.get_url().await.unwrap_or_default();
        let items_body = items_body.ok_or_else(|| {
            // Cap the dump at 20 URLs to keep the error message
            // readable on high-cardinality failures (a stuck page
            // can rack up 200+ /api/ URLs from polling endpoints).
            const MAX_DIAG_URLS: usize = 20;
            let all_api_total = captured.iter().filter(|r| r.url.contains("/api/")).count();
            let all_api: Vec<&str> = captured
                .iter()
                .filter(|r| r.url.contains("/api/"))
                .take(MAX_DIAG_URLS)
                .map(|r| r.url.as_str())
                .collect();
            TailFinError::Api(format!(
                "no /api/v4/search/search_items?...&scenario=PAGE_CATEGORY response \
                 captured for parent={parent_catid} leaf={leaf_catid} {want_offset}.\n  \
                 Landing URL: {landing}\n  \
                 search_items URLs we DID see ({} total): {:?}\n  \
                 /api/ URLs (showing first {} of {}): {:?}",
                search_urls_seen.len(),
                search_urls_seen
                    .iter()
                    .map(|u| u.split('?').nth(1).unwrap_or(""))
                    .collect::<Vec<_>>(),
                all_api.len(),
                all_api_total,
                all_api,
            ))
        })?;

        // Reuse `parse_search_items` — the wire shape is identical
        // to keyword search, except we pass an empty keyword (the
        // result's `keyword` field is irrelevant for category
        // browsing; callers care about the items + metadata).
        let search = parse_search_items(&items_body, "", page)?;

        let category = detail_body.as_ref().and_then(|b| {
            let parsed = parse_fe_category_detail(b);
            if parsed.is_none() {
                eprintln!(
                    "[shopee] get_fe_category_detail body parsed empty for \
                     leaf_catid={leaf_catid} — degrading category metadata to None"
                );
            }
            parsed
        });

        let has_more = (u64::from(page) + 1) * 60 < search.total_count;

        Ok(CategoryPage {
            category,
            items: search.items,
            total_count: search.total_count,
            page,
            has_more,
        })
    }

    /// Fetch shop / seller info via `/api/v4/promotion/get_shop_info`.
    ///
    /// Empirically Shopee's `get_shop_info` endpoint fires when a
    /// PDP renders (as part of the seller-info card), NOT when
    /// navigating directly to `/shop/{shop_id}` (that route uses
    /// a different endpoint family). So we navigate to a product
    /// page and capture the side-effect `get_shop_info` call.
    ///
    /// `itemid` is a navigation hint — any item belonging to the
    /// target shop works. Callers typically have one already
    /// (from `search`, `cart`, `related`, etc.). The returned
    /// `ShopInfo` describes the shop, not the item.
    ///
    /// **Caveat — Shopee-direct / brand-store listings don't fire
    /// this endpoint.** Listings under Shopee's own first-party
    /// shop (`蝦皮直營`, recurring `shopid: 188_277_742` on
    /// shopee.tw) and certain brand-store listings render a
    /// different seller card layout that omits the
    /// `get_shop_info` call. If your callers expect to look up
    /// those, this method will return "no response captured".
    /// Pick a 3rd-party listing as the navigation hint instead.
    ///
    /// Public-read for verified shops; brand-new shops with zero
    /// activity may return a sparse response (most fields default).
    pub async fn shop_info(&self, shopid: u64, itemid: u64) -> Result<ShopInfo, TailFinError> {
        self.ensure_capture_installed().await?;

        // Navigate to the PDP — Shopee's seller-info card on
        // PDP pages fires `get_shop_info` automatically.
        let url = format!("{}/product/{}/{}", self.region.base_url(), shopid, itemid);
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;

        // Anchored ID match — the URL has `?shop_id={shopid}` and a
        // bare `contains(shopid.to_string())` would prefix-collide
        // with longer IDs (same defensive pattern as #193 search +
        // #191 product_detail).
        let id_marker = format!("shop_id={shopid}");
        let captured = self.session.get_captured_responses().await?;
        let mut found: Option<Value> = None;
        for r in captured {
            if r.url.contains("/api/v4/promotion/get_shop_info") && r.url.contains(&id_marker) {
                if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                    found = Some(v);
                }
            }
        }
        let body = found.ok_or_else(|| {
            TailFinError::Api(format!(
                "no /api/v4/promotion/get_shop_info response captured for {id_marker}. \
                 Did the PDP load? CAPTCHA / verify-page would block the seller card."
            ))
        })?;

        parse_shop_info(&body)
    }

    /// Fetch reviews / ratings for a product via
    /// `/api/v2/item/get_ratings` (note: `v2` not `v4`).
    ///
    /// Drives the same PDP load as `product_detail` — ratings
    /// fire automatically on PDP render. Returns up to 6 reviews
    /// per call (Shopee's default page size); `has_more` flag
    /// indicates whether more pages exist past this batch.
    /// Pagination not yet plumbed through this method.
    pub async fn reviews(&self, shopid: u64, itemid: u64) -> Result<Reviews, TailFinError> {
        self.ensure_capture_installed().await?;

        let url = format!("{}/product/{}/{}", self.region.base_url(), shopid, itemid);
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;

        // Confirm we landed on the product page — same canonical /
        // SEO-slug check as `product_detail`.
        if let Ok(current) = self.session.get_url().await {
            let canonical = format!("/product/{shopid}/{itemid}");
            let seo_slug = format!("-i.{shopid}.{itemid}");
            if !current.contains(&canonical) && !current.contains(&seo_slug) {
                return Err(TailFinError::Api(format!(
                    "navigation landed on {current} — expected a product \
                     page for shopid={shopid} itemid={itemid} during \
                     reviews. CAPTCHA redirect, or the item was removed."
                )));
            }
        }

        let item_marker = format!("itemid={itemid}");
        let shop_marker = format!("shopid={shopid}");
        let captured = self.session.get_captured_responses().await?;
        let mut found: Option<Value> = None;
        for r in captured {
            // ratings is `/api/v2/`, not v4 — `CAPTURE_PATTERN`
            // (above) was widened to `*shopee.*/api/*` to catch
            // this v2 endpoint alongside the v4 family.
            if r.url.contains("/api/v2/item/get_ratings")
                && r.url.contains(&item_marker)
                && r.url.contains(&shop_marker)
            {
                if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                    found = Some(v);
                }
            }
        }
        let body = found.ok_or_else(|| {
            TailFinError::Api(format!(
                "no /api/v2/item/get_ratings response captured for \
                 shopid={shopid} itemid={itemid}. Did the PDP load?"
            ))
        })?;

        parse_reviews(&body, shopid, itemid)
    }

    /// Browse a shop's catalogue — items the shop has listed.
    ///
    /// Captures whichever shop-listing endpoint fires when we
    /// navigate to `shopee.{region}/shop/{shopid}`:
    ///
    /// - **`shop/rcmd_items`** (POST) — fires on shop home / intro
    ///   tab. Returns recommended items (Shopee's algorithmic
    ///   ranking). This is what the bare `/shop/{shopid}` URL
    ///   triggers on shopee.tw today.
    /// - **`shop/search_items`** (GET) — fires when filtering by
    ///   collection / category (`?shopCollection=X` or
    ///   `?categoryId=Y`). Popularity-sort across full catalogue.
    ///   Not triggered by bare shop-home URL — would need extra
    ///   params we don't construct in this v0.
    ///
    /// The parser tolerates both wire shapes (the only differences
    /// are `total_count` vs `data.total` and `nomore` vs
    /// `data.no_more`); callers see one consistent `ShopItems`.
    ///
    /// `page` is 0-indexed; each page yields up to 30 items
    /// (Shopee's default `limit=30` on these endpoints, distinct
    /// from search's `limit=60`). Note that `rcmd_items` is a POST
    /// with offset in the body — the offset filter still works
    /// because Shopee echoes shopid into the URL query string.
    pub async fn shop_items(&self, shopid: u64, page: u32) -> Result<ShopItems, TailFinError> {
        self.ensure_capture_installed().await?;

        let url = format!("{}/shop/{}?page={}", self.region.base_url(), shopid, page);
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        // rcmd_items lands ~1s after the initial XHR storm; 2s
        // buffer matches `discover()` and `related_products()`.
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;

        // Single pass with explicit precedence — search_items
        // wins when both fire (more complete catalogue), rcmd_items
        // is the fallback for the bare-shop-URL case.
        let captured = self.session.get_captured_responses().await?;
        let mut search_items_body: Option<Value> = None;
        let mut rcmd_items_body: Option<Value> = None;
        for r in &captured {
            if r.url.contains("/api/v4/shop/search_items") && search_items_body.is_none() {
                search_items_body = serde_json::from_str(&r.body).ok();
            } else if r.url.contains("/api/v4/shop/rcmd_items") && rcmd_items_body.is_none() {
                rcmd_items_body = serde_json::from_str(&r.body).ok();
            }
        }
        let body = search_items_body.or(rcmd_items_body).ok_or_else(|| {
            let shop_urls: Vec<&str> = captured
                .iter()
                .filter(|r| r.url.contains("/api/v4/shop/"))
                .map(|r| r.url.as_str())
                .collect();
            TailFinError::Api(format!(
                "no shop/search_items or shop/rcmd_items response captured \
                 for shopid={shopid}. shop/* URLs we DID see ({}): {:?}",
                shop_urls.len(),
                shop_urls
            ))
        })?;

        parse_shop_items(&body, shopid, page)
    }

    /// Search for shops by keyword via
    /// `/api/v4/search/search_user`.
    ///
    /// Returns the single most-relevant shop for the keyword
    /// (typically a Shopee-direct brand store or a heavily-
    /// followed verified seller). Empirically Shopee returns
    /// `limit=1` regardless of UI surface today; the multi-shop
    /// "View all" Users tab uses a different navigation flow we
    /// haven't probed yet.
    ///
    /// Returns shop identity (shopid / username / nickname),
    /// rating, follower count, item count, and verification
    /// flags. To drill into the shop's items, chain into
    /// [`Self::shop_items`].
    pub async fn search_user(&self, keyword: &str) -> Result<UserSearchResults, TailFinError> {
        self.ensure_capture_installed().await?;

        let encoded = url_encode(keyword);
        // We add `?searchType=user` so the URL matches what a real
        // user navigating to the Users tab would see — relevant
        // for Shopee's signature-based anti-bot scoring (per
        // `shopee_antibot_signature` memory note: the wire URL
        // is part of the request fingerprint). The response is
        // the same `limit=1` as the product-search sidebar today;
        // if Shopee ever honors the param to bump the limit,
        // this code already requests it.
        let url = format!(
            "{}/search?keyword={}&searchType=user",
            self.region.base_url(),
            encoded
        );
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;

        let captured = self.session.get_captured_responses().await?;
        let mut found: Option<Value> = None;
        // Last response wins — Shopee may re-fire as the user
        // tab hydrates; we want the freshest one. Note this
        // diverges from `shop_items`'s "search_items wins
        // precedence" pattern: there we have two distinct
        // endpoints and prefer one; here the same endpoint may
        // fire twice during hydration and we just want the
        // latest (most-settled) state.
        for r in captured {
            if r.url.contains("/api/v4/search/search_user") {
                if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                    found = Some(v);
                }
            }
        }
        let body = found.ok_or_else(|| {
            TailFinError::Api(
                "no /api/v4/search/search_user response captured. \
                 Did the search page load? CAPTCHA / verify-page \
                 would block the SPA."
                    .to_string(),
            )
        })?;

        parse_search_user(&body, keyword)
    }

    /// Fetch the combined homepage state in one navigation —
    /// cart preview + discover surface (feed + flash sale + mall
    /// shops) + top-level category tree.
    ///
    /// All three components fire automatically on a single
    /// homepage navigation, so combining them avoids paying the
    /// ~30 s navigate cost three times when callers want the
    /// full homepage state. Single call ~30 s vs three sequential
    /// calls ~90 s.
    ///
    /// Each component degrades independently:
    /// - `cart` → `None` when the user isn't logged in (cart-mini
    ///   only fires for authenticated sessions).
    /// - `discover.feeds` → empty when daily-discover doesn't
    ///   fire on initial load (it's scroll-triggered on
    ///   shopee.tw); flash-sale and mall-shops still populate.
    /// - `categories` → typically 24 entries on shopee.tw; empty
    ///   only if the homepage didn't render successfully.
    ///
    /// On full failure (no homepage endpoints captured at all),
    /// returns an `Api` error pointing the caller at the likely
    /// CAPTCHA / verify-page redirect.
    pub async fn homepage_bundle(&self) -> Result<HomepageBundle, TailFinError> {
        self.ensure_capture_installed().await?;

        let url = format!("{}/", self.region.base_url());
        navigate_force(&self.session, &url).await?;
        let _ = self.session.wait_for_network_idle(30_000, 3_000).await;
        // Generous buffer — daily_discover (heaviest of the three
        // discover endpoints) lands ~1 s after the initial XHR
        // storm settles, and category_list / cart_mini sometimes
        // re-fire as the header re-renders. 2 s captures all of
        // that without dragging out the call further.
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;

        let captured = self.session.get_captured_responses().await?;
        let routed = route_homepage_responses(&captured)?;

        // cart_mini failure is non-fatal for an unauthenticated
        // browse session OR an anti-bot wall on cart specifically;
        // degrade to None but log the error so power users see why.
        let cart = routed
            .cart_body
            .as_ref()
            .and_then(|b| match parse_cart_mini(b) {
                Ok(c) => Some(c),
                Err(e) => {
                    eprintln!(
                        "[tail-fin-shopee::homepage_bundle] cart_mini parse failed, \
                     degrading cart to None: {e}"
                    );
                    None
                }
            });
        let discover = combine_discover(
            routed.discover_body.as_ref(),
            routed.flash_sale_body.as_ref(),
            routed.mall_shops_body.as_ref(),
        );
        let categories = routed
            .categories_body
            .as_ref()
            .map(parse_category_tree)
            .unwrap_or_default();

        Ok(HomepageBundle {
            cart,
            discover,
            categories,
        })
    }
}

/// Bodies routed from a homepage capture buffer, one slot per
/// endpoint. Pure helper — extracted for unit-testability.
#[derive(Debug)]
struct RoutedHomepage {
    cart_body: Option<Value>,
    categories_body: Option<Value>,
    discover_body: Option<Value>,
    flash_sale_body: Option<Value>,
    mall_shops_body: Option<Value>,
}

/// Route a capture buffer into per-endpoint slots. Last-wins per
/// endpoint, but a malformed body is silently dropped — a
/// previously-good body in the same stream wins over a corrupt
/// follow-up. Hard-fails when no homepage endpoint matched at all
/// (CAPTCHA / verify-page case).
fn route_homepage_responses(
    captured: &[tail_fin_common::CapturedResponse],
) -> Result<RoutedHomepage, TailFinError> {
    let mut cart_body: Option<Value> = None;
    let mut categories_body: Option<Value> = None;
    let mut discover_body: Option<Value> = None;
    let mut flash_sale_body: Option<Value> = None;
    let mut mall_shops_body: Option<Value> = None;

    for r in captured {
        // Defensive last-wins — only OVERWRITE if the new body
        // parses. A corrupt body mid-stream shouldn't drop a
        // previously-good response.
        let maybe_set = |slot: &mut Option<Value>| {
            if let Ok(v) = serde_json::from_str::<Value>(&r.body) {
                *slot = Some(v);
            }
        };
        if r.url.contains("/api/v4/cart/mini") {
            maybe_set(&mut cart_body);
        } else if r.url.contains("/api/v4/pages/get_homepage_category_list") {
            maybe_set(&mut categories_body);
        } else if r.url.contains("/api/v4/homepage/get_daily_discover") {
            maybe_set(&mut discover_body);
        } else if r.url.contains("/api/v4/flash_sale/flash_sale_get_items") {
            maybe_set(&mut flash_sale_body);
        } else if r.url.contains("/api/v4/homepage/mall_shops") {
            maybe_set(&mut mall_shops_body);
        }
    }

    let nothing_captured = cart_body.is_none()
        && categories_body.is_none()
        && discover_body.is_none()
        && flash_sale_body.is_none()
        && mall_shops_body.is_none();
    if nothing_captured {
        return Err(TailFinError::Api(
            "no homepage endpoints captured — CAPTCHA / verify-page \
             likely blocked the homepage render."
                .to_string(),
        ));
    }

    Ok(RoutedHomepage {
        cart_body,
        categories_body,
        discover_body,
        flash_sale_body,
        mall_shops_body,
    })
}

/// Force a fresh navigation to `url` from a Mode-A attached tab.
///
/// Why not just `session.navigate(url)`? Four failure modes we
/// need to defend against, all observed live on shopee.tw:
///
///   1. **30s `REQUEST_TIMEOUT` on `Page.navigate` ack.** chaser-
///      oxide's `Page::goto` blocks on the navigate command's CDP
///      ack with a hardcoded 30s timeout. With `Fetch.enable` on,
///      the command channel is busy continuing intercepted
///      responses and the ack frequently misses that window —
///      yet the browser still navigates successfully.
///   2. **Same-URL short-circuit.** If the tab is already at
///      `url`, `Page.navigate` is a no-op and existing resources
///      are served from cache without going through the Fetch
///      interception layer (we'd capture nothing).
///   3. **Cross-section navigations stalling.** Empirically,
///      navigating from `/product/...` to `/search/...` via
///      `Page.navigate` followed by `reload` would sometimes
///      leave the tab on the original `/product/...` URL.
///   4. **SPA self-correction races.** When the previous page
///      still has inflight JS (e.g. search results paginating
///      via `history.replaceState` to canonicalise `&page=0`),
///      that JS can run AFTER our `location.assign` and revert
///      the URL to the previous page. We need to confirm the
///      navigation actually committed before returning, and
///      retry if it bounced back.
///
/// The strategy: drive navigation through JS (`location.assign`),
/// then poll `get_url` for up to ~6s; if URL hasn't moved off the
/// pre-flight URL by then, fire one more `location.assign` (gives
/// any inflight SPA work a chance to settle in between).
async fn navigate_force(session: &BrowserSession, url: &str) -> Result<(), TailFinError> {
    // JSON-encode the URL — costs nothing, protects future callers
    // who might pass URLs containing special chars.
    let url_lit = serde_json::to_string(url)
        .map_err(|e| TailFinError::Api(format!("failed to encode navigation URL: {e}")))?;
    let pre = session.get_url().await.unwrap_or_default();

    // First assign attempt.
    let _ = session.eval(&format!("location.assign({url_lit})")).await;

    // Poll for URL change off `pre`. We only need to confirm the
    // tab moved AWAY from the previous URL — the wait_for_network_idle
    // call after `navigate_force` returns will block on the new
    // page actually loading. POLL_INTERVAL_MS × ATTEMPTS = 6s budget.
    const POLL_INTERVAL_MS: u64 = 200;
    const ATTEMPTS: u32 = 30;
    // Halfway through, retry the assign — if the SPA on the old page
    // swallowed our first assign, this catches it. Computed instead
    // of literal so the retry point auto-tracks ATTEMPTS.
    const RETRY_AT: u32 = ATTEMPTS / 2;
    for attempt in 0..ATTEMPTS {
        tokio::time::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS)).await;
        if let Ok(now) = session.get_url().await {
            if now != pre {
                return Ok(());
            }
        }
        if attempt == RETRY_AT {
            let _ = session.eval(&format!("location.assign({url_lit})")).await;
        }
    }

    // 6s elapsed and URL still equals `pre`. Either the page is
    // genuinely stuck OR Shopee redirected us back. Caller's URL
    // sanity-check will surface the user-facing error; surfacing
    // it here would double up.
    Ok(())
}

/// Minimal RFC 3986 query-string encoder for the `keyword=` value.
/// Avoids pulling in the `urlencoding` crate just for this one call;
/// matches the subset Shopee accepts in `?keyword=`.
fn url_encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.as_bytes() {
        match *b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(*b as char);
            }
            b' ' => out.push('+'),
            other => out.push_str(&format!("%{other:02X}")),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn url_encode_handles_ascii_and_unicode() {
        assert_eq!(url_encode("iPhone 15 Pro"), "iPhone+15+Pro");
        assert_eq!(url_encode("hello"), "hello");
        // Chinese characters → percent-encoded UTF-8 bytes.
        assert_eq!(url_encode("手機"), "%E6%89%8B%E6%A9%9F");
    }

    /// Build a `CapturedResponse` via JSON round-trip — the type
    /// is `#[non_exhaustive]` upstream so we can't use struct
    /// literal syntax across crates. `body` is JSON we want
    /// `route_homepage_responses` to surface; `url` decides
    /// which slot it lands in.
    fn captured(url: &str, body: &str) -> tail_fin_common::CapturedResponse {
        serde_json::from_value(json!({
            "url": url,
            "status": 200,
            "headers": [],
            "body": body,
            "is_binary": false
        }))
        .expect("CapturedResponse fixture")
    }

    #[test]
    fn route_homepage_empty_buffer_hard_fails() {
        let err = route_homepage_responses(&[]).unwrap_err();
        assert!(
            err.to_string().contains("no homepage endpoints captured"),
            "expected hard-fail message, got: {err}"
        );
    }

    #[test]
    fn route_homepage_categories_only_succeeds() {
        // Single endpoint captured (categories) — bundle should
        // succeed with cart=None and discover empty.
        let captured = vec![captured(
            "https://shopee.tw/api/v4/pages/get_homepage_category_list",
            r#"{"data":{"category_list":[]}}"#,
        )];
        let routed = route_homepage_responses(&captured).expect("partial capture should succeed");
        assert!(routed.cart_body.is_none());
        assert!(routed.categories_body.is_some());
        assert!(routed.discover_body.is_none());
        assert!(routed.flash_sale_body.is_none());
        assert!(routed.mall_shops_body.is_none());
    }

    #[test]
    fn route_homepage_routes_each_endpoint_to_its_slot() {
        let captured = vec![
            captured(
                "https://shopee.tw/api/v4/cart/mini",
                r#"{"error":0,"data":{"total_cart_item_count":1,"unique_cart_item_count":1}}"#,
            ),
            captured(
                "https://shopee.tw/api/v4/pages/get_homepage_category_list",
                r#"{"data":{"category_list":[]}}"#,
            ),
            captured(
                "https://shopee.tw/api/v4/homepage/get_daily_discover",
                r#"{"data":{"feeds":[],"feed_total":0}}"#,
            ),
            captured(
                "https://shopee.tw/api/v4/flash_sale/flash_sale_get_items",
                r#"{"error":0,"data":{"items":[]}}"#,
            ),
            captured(
                "https://shopee.tw/api/v4/homepage/mall_shops",
                r#"{"data":{"shops":[]}}"#,
            ),
        ];
        let routed = route_homepage_responses(&captured).expect("full capture");
        assert!(routed.cart_body.is_some());
        assert!(routed.categories_body.is_some());
        assert!(routed.discover_body.is_some());
        assert!(routed.flash_sale_body.is_some());
        assert!(routed.mall_shops_body.is_some());
    }

    #[test]
    fn route_homepage_corrupt_body_doesnt_drop_earlier_good_one() {
        // Defensive last-wins: a previously-good cart_mini body
        // should NOT be overwritten by a follow-up corrupt body
        // (Shopee can re-fire on header re-render).
        let captured = vec![
            captured(
                "https://shopee.tw/api/v4/cart/mini",
                r#"{"error":0,"data":{"total_cart_item_count":5}}"#,
            ),
            captured("https://shopee.tw/api/v4/cart/mini", "<<not json>>"),
        ];
        let routed = route_homepage_responses(&captured).expect("partial capture");
        let cart = routed.cart_body.expect("first good body should survive");
        assert_eq!(
            cart.pointer("/data/total_cart_item_count")
                .and_then(|v| v.as_u64()),
            Some(5)
        );
    }

    #[test]
    fn route_homepage_ignores_unrelated_urls() {
        // URLs outside the 5 homepage endpoints should be no-ops,
        // not slot occupants.
        let captured = vec![
            captured(
                "https://shopee.tw/api/v4/search/search_items?keyword=iPhone",
                r#"{"items":[],"total_count":0}"#,
            ),
            captured(
                "https://shopee.tw/api/v4/pages/get_homepage_category_list",
                r#"{"data":{"category_list":[]}}"#,
            ),
        ];
        let routed = route_homepage_responses(&captured).expect("partial capture");
        // Only categories landed; search_items was ignored.
        assert!(routed.categories_body.is_some());
        assert!(routed.cart_body.is_none());
        assert!(routed.discover_body.is_none());
    }
}