tail-fin-shopee 0.7.8

Shopee adapter for tail-fin: account info, search (browser-only), product detail. Multi-region (TW/SG/MY/...).
Documentation
//! Shopee marketplace adapter — multi-region, cookie-authenticated.
//!
//! ## Anti-bot scope
//!
//! Most of Shopee's `/api/v4/*` namespace requires per-request anti-bot
//! signatures (`af-ac-enc-dat`, `af-ac-sz`, …) that the website's JS
//! computes per call. A plain HTTP client with cookies alone gets
//! `403 Forbidden` from those endpoints.
//!
//! **Verified empirically (2026-04-29):** the SA-style trick of using
//! `wreq` with Chrome 145 emulation (which solves PerimeterX) does
//! NOT solve Shopee. Every protected endpoint returned 403 with
//! `{is_login: true, error: 90309999}` — Shopee's WAF accepts the
//! cookies and knows the user is logged in; it's blocking on the
//! missing signature, not on TLS fingerprint.
//!
//! **Fresh stealth launch + cookie inject is also only partial.**
//! Verified across 4 stealth variants (headless cold, headless +
//! warm-up, headed cold, headed + warm-up — all four identical):
//! `/search/search_items` and `/cart/mini` always return
//! `error: 90309999` with `/anti_fraud/captcha/generate` firing.
//! Lower-sensitivity endpoints (`/search/curated_search`,
//! `/account/basic/*`) pass with `error: 0`. The signal Shopee scores
//! on isn't headless-detection or behavioral telemetry — it's deeper
//! (browser-fingerprint cleanliness, device-cookie binding).
//!
//! **Realistic browser-mode paths** (each requires user
//! collaboration; no "launch-and-forget" path exists):
//!
//! 1. `--connect` to the user's existing Chrome (Grok's Mode A) —
//!    real browser has months of history; CAPTCHA much less likely.
//! 2. Persistent stealth profile + first-run manual CAPTCHA — workable
//!    but worse UX than Mode A.
//! 3. Cherry-pick the medium-trust endpoints (`curated_search`,
//!    `search_prefills`, `account/*`) that DO pass from fresh stealth.
//!
//! See the memory note `shopee_antibot_signature.md` for the full
//! empirical write-up.
//!
//! This adapter ships two modes:
//!
//! - **Cookie-HTTP** ([`ShopeeHttpClient`]) — for endpoints that
//!   don't require anti-bot signatures (currently `me()`).
//! - **Mode A** ([`ShopeeBrowserClient`]) — attaches to the user's
//!   logged-in Chrome via CDP and drives Shopee through real-browser
//!   navigation so Shopee's own JS computes the signature; we capture
//!   the response body off the network layer. See `browser.rs` for
//!   the full prereq list. Currently ships `search()` and
//!   `product_detail()`; cart and orders are queued behind it.

pub mod browser;
pub mod parsing;
pub mod site;
pub mod types;

use std::path::Path;

use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, COOKIE, REFERER, USER_AGENT};
use tail_fin_common::TailFinError;

pub use browser::ShopeeBrowserClient;
pub use site::{ShopeeRegion, ShopeeSite};
pub use types::{
    AccountInfo, CartItem, CartPreview, Category, CategoryDetail, CategoryPage, Discover,
    FlashSaleItem, HomepageBundle, MallShop, ProductDetail, ProductModel, RecommendedItem,
    RelatedItems, Review, Reviews, SearchItem, SearchResults, ShopInfo, ShopItems, UserMatch,
    UserSearchResults,
};

const UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";

/// HTTP client for Shopee using saved cookies. The base URL is
/// region-specific (e.g. `https://shopee.tw`) — pick a region via
/// [`ShopeeRegion`] when constructing.
pub struct ShopeeHttpClient {
    client: reqwest::Client,
    region: ShopeeRegion,
    csrf_token: String,
}

impl ShopeeHttpClient {
    /// Build a client from a Netscape cookie file. The cookies must
    /// include `csrftoken` plus one of the auth-bearing cookies
    /// (`SPC_ST`, `SPC_EC`, `SPC_U`, …). The client retains the
    /// cookie header verbatim and re-sends it on every request — no
    /// jar, no refresh.
    pub fn from_cookie_file(path: &Path, region: ShopeeRegion) -> Result<Self, TailFinError> {
        let cookies = tail_fin_common::cookies::load_netscape_file(path)?;
        if cookies.is_empty() {
            return Err(TailFinError::AuthRequired);
        }
        let csrf_token = cookies
            .iter()
            .find(|(n, _)| n == "csrftoken")
            .map(|(_, v)| v.clone())
            .unwrap_or_default();
        let cookie_header = tail_fin_common::cookies::build_cookie_header(&cookies);

        let mut headers = HeaderMap::new();
        headers.insert(USER_AGENT, HeaderValue::from_static(UA));
        headers.insert(
            ACCEPT,
            HeaderValue::from_static("application/json, text/plain, */*"),
        );
        // Shopee blocks requests without a same-origin Referer.
        let referer = format!("{}/", region.base_url());
        headers.insert(
            REFERER,
            HeaderValue::from_str(&referer).map_err(|e| {
                TailFinError::Api(format!("invalid referer for region {region:?}: {e}"))
            })?,
        );
        headers.insert(
            COOKIE,
            HeaderValue::from_str(&cookie_header)
                .map_err(|e| TailFinError::Api(format!("cookie header: {e}")))?,
        );
        if !csrf_token.is_empty() {
            // Shopee accepts X-Csrftoken with the value of the
            // `csrftoken` cookie. Some endpoints additionally accept
            // mixed-case `X-CSRFToken`; HTTP headers are case-
            // insensitive so this works either way.
            headers.insert(
                "x-csrftoken",
                HeaderValue::from_str(&csrf_token)
                    .map_err(|e| TailFinError::Api(format!("csrftoken header: {e}")))?,
            );
        }
        // Localised content + Shopee's required language header.
        headers.insert("x-shopee-language", HeaderValue::from_static("zh-Hant"));

        let client = reqwest::Client::builder()
            .default_headers(headers)
            .build()
            .map_err(|e| TailFinError::Api(format!("HTTP client error: {e}")))?;

        Ok(Self {
            client,
            region,
            csrf_token,
        })
    }

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

    /// CSRF token (from the `csrftoken` cookie). Empty when missing.
    pub fn csrf_token(&self) -> &str {
        &self.csrf_token
    }

    /// Fetch the authenticated user's account info via
    /// `/api/v4/account/basic/get_account_info`.
    ///
    /// **Auth required** — returns [`TailFinError::AuthRequired`] if
    /// Shopee responds with a non-zero `error` field (the typical
    /// "session expired" signal).
    pub async fn me(&self) -> Result<AccountInfo, TailFinError> {
        let url = format!(
            "{}/api/v4/account/basic/get_account_info",
            self.region.base_url()
        );
        let resp = self
            .client
            .get(&url)
            .send()
            .await
            .map_err(|e| TailFinError::Api(format!("request: {e}")))?;
        if !resp.status().is_success() {
            return Err(TailFinError::Api(format!(
                "Shopee {} HTTP {}",
                self.region.id(),
                resp.status()
            )));
        }
        let body: serde_json::Value = resp
            .json()
            .await
            .map_err(|e| TailFinError::Parse(format!("body: {e}")))?;

        // Shopee's wrapper: `{ error: 0, error_msg: "", data: {...} }`.
        let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
        if err_code != 0 {
            let msg = body
                .get("error_msg")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            // Shopee uses error=44 for "auth required" / "session expired".
            if err_code == 44 || msg.contains("must login") || msg.contains("login") {
                return Err(TailFinError::AuthRequired);
            }
            return Err(TailFinError::Api(format!("Shopee error {err_code}: {msg}")));
        }
        let data = body
            .get("data")
            .ok_or_else(|| TailFinError::Parse("missing `data` in account_info response".into()))?
            .clone();
        let mut info: AccountInfo = serde_json::from_value(data.clone())
            .map_err(|e| TailFinError::Parse(format!("AccountInfo: {e}")))?;
        info.raw = Some(data);
        Ok(info)
    }
}