tail-fin-shopee 0.7.8

Shopee adapter for tail-fin: account info, search (browser-only), product detail. Multi-region (TW/SG/MY/...).
Documentation
//! `Site` impl + region enum for Shopee.
//!
//! Shopee operates regional sites (Taiwan, Singapore, Malaysia, …)
//! that share an API surface but use different domains. We follow the
//! workspace convention from `docs/site-adapter-template.md` and
//! expose a region-qualified `Site::id()` (`"shopee-tw"`, etc).

use std::time::Duration;

use async_trait::async_trait;
use tail_fin_core::{
    AuthFailureKind, BrowserSession, FailureIndicators, SessionStatus, Site, SiteError,
};

/// Shopee regional sites. Each region has its own login session,
/// cookie file, and base URL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShopeeRegion {
    /// 台灣 — `shopee.tw`
    Tw,
    /// Singapore — `shopee.sg`
    Sg,
    /// Malaysia — `shopee.com.my`
    My,
    /// Indonesia — `shopee.co.id`
    Id,
    /// Vietnam — `shopee.vn`
    Vn,
    /// Philippines — `shopee.ph`
    Ph,
    /// Thailand — `shopee.co.th`
    Th,
    /// Brazil — `shopee.com.br`
    Br,
}

impl ShopeeRegion {
    /// Region code as it appears in `Site::id()` and cookie filenames
    /// (`tw`, `sg`, …). Used to derive `~/.tail-fin/shopee-{region}-cookies.txt`.
    pub fn region_code(&self) -> &'static str {
        match self {
            Self::Tw => "tw",
            Self::Sg => "sg",
            Self::My => "my",
            Self::Id => "id",
            Self::Vn => "vn",
            Self::Ph => "ph",
            Self::Th => "th",
            Self::Br => "br",
        }
    }

    /// Site identifier (`"shopee-tw"`, …). Matches `Site::id()`.
    pub fn id(&self) -> &'static str {
        match self {
            Self::Tw => "shopee-tw",
            Self::Sg => "shopee-sg",
            Self::My => "shopee-my",
            Self::Id => "shopee-id",
            Self::Vn => "shopee-vn",
            Self::Ph => "shopee-ph",
            Self::Th => "shopee-th",
            Self::Br => "shopee-br",
        }
    }

    /// Base URL — including scheme, no trailing slash.
    pub fn base_url(&self) -> &'static str {
        match self {
            Self::Tw => "https://shopee.tw",
            Self::Sg => "https://shopee.sg",
            Self::My => "https://shopee.com.my",
            Self::Id => "https://shopee.co.id",
            Self::Vn => "https://shopee.vn",
            Self::Ph => "https://shopee.ph",
            Self::Th => "https://shopee.co.th",
            Self::Br => "https://shopee.com.br",
        }
    }

    /// Cookie domain pattern fed to `Site::cookie_domain_patterns()`.
    pub fn cookie_domain_pattern(&self) -> &'static str {
        match self {
            Self::Tw => ".shopee.tw",
            Self::Sg => ".shopee.sg",
            Self::My => ".shopee.com.my",
            Self::Id => ".shopee.co.id",
            Self::Vn => ".shopee.vn",
            Self::Ph => ".shopee.ph",
            Self::Th => ".shopee.co.th",
            Self::Br => ".shopee.com.br",
        }
    }

    /// Parse from an `id`-form string (`"shopee-tw"`, …) or a bare
    /// region code (`"tw"`, …). Returns `None` for unknown values.
    pub fn parse(s: &str) -> Option<Self> {
        let code = s.strip_prefix("shopee-").unwrap_or(s);
        match code {
            "tw" => Some(Self::Tw),
            "sg" => Some(Self::Sg),
            "my" => Some(Self::My),
            "id" => Some(Self::Id),
            "vn" => Some(Self::Vn),
            "ph" => Some(Self::Ph),
            "th" => Some(Self::Th),
            "br" => Some(Self::Br),
            _ => None,
        }
    }
}

/// Marker `Site` impl. One instance per region — the daemon registers
/// each region separately so a session map can keep them isolated.
pub struct ShopeeSite(pub ShopeeRegion);

#[async_trait]
impl Site for ShopeeSite {
    fn id(&self) -> &'static str {
        self.0.id()
    }

    fn display_name(&self) -> &'static str {
        match self.0 {
            ShopeeRegion::Tw => "Shopee Taiwan",
            ShopeeRegion::Sg => "Shopee Singapore",
            ShopeeRegion::My => "Shopee Malaysia",
            ShopeeRegion::Id => "Shopee Indonesia",
            ShopeeRegion::Vn => "Shopee Vietnam",
            ShopeeRegion::Ph => "Shopee Philippines",
            ShopeeRegion::Th => "Shopee Thailand",
            ShopeeRegion::Br => "Shopee Brazil",
        }
    }

    fn cookie_domain_patterns(&self) -> &'static [&'static str] {
        match self.0 {
            ShopeeRegion::Tw => &[".shopee.tw"],
            ShopeeRegion::Sg => &[".shopee.sg"],
            ShopeeRegion::My => &[".shopee.com.my"],
            ShopeeRegion::Id => &[".shopee.co.id"],
            ShopeeRegion::Vn => &[".shopee.vn"],
            ShopeeRegion::Ph => &[".shopee.ph"],
            ShopeeRegion::Th => &[".shopee.co.th"],
            ShopeeRegion::Br => &[".shopee.com.br"],
        }
    }

    fn refresh_url(&self) -> &'static str {
        match self.0 {
            ShopeeRegion::Tw => "https://shopee.tw/",
            ShopeeRegion::Sg => "https://shopee.sg/",
            ShopeeRegion::My => "https://shopee.com.my/",
            ShopeeRegion::Id => "https://shopee.co.id/",
            ShopeeRegion::Vn => "https://shopee.vn/",
            ShopeeRegion::Ph => "https://shopee.ph/",
            ShopeeRegion::Th => "https://shopee.co.th/",
            ShopeeRegion::Br => "https://shopee.com.br/",
        }
    }

    fn refresh_interval_min(&self) -> Duration {
        Duration::from_secs(180)
    }

    async fn validate(&self, session: &BrowserSession) -> Result<SessionStatus, SiteError> {
        let status = session.http_ping(self.refresh_url()).await.map_err(|e| {
            SiteError::ValidationFailed {
                site: self.id(),
                reason: format!("homepage ping: {e}"),
            }
        })?;
        Ok(match status {
            200 => SessionStatus::Valid,
            401 | 403 => SessionStatus::Expired,
            429 => SessionStatus::Blocked {
                reason: "rate limited".into(),
                retry_after: Some(Duration::from_secs(300)),
            },
            0 => SessionStatus::Unknown,
            other => SessionStatus::Degrading {
                estimated_expiry: None,
                hint: format!("unexpected HTTP {other}"),
            },
        })
    }

    fn detect_auth_failure(&self, indicators: &FailureIndicators) -> Option<AuthFailureKind> {
        // Shopee returns HTTP 200 with `error: 44` body on auth
        // failure (the AccountInfo client maps that to `AuthRequired`).
        // At the network level, 401/403 are the canonical signals,
        // and 429 is the rate-limit class.
        match indicators.status {
            Some(401) | Some(403) => Some(AuthFailureKind::CookieExpired),
            Some(429) => Some(AuthFailureKind::RateLimited {
                retry_after: Duration::from_secs(300),
            }),
            _ => None,
        }
    }
}

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

    #[test]
    fn region_parse_round_trips() {
        for r in [
            ShopeeRegion::Tw,
            ShopeeRegion::Sg,
            ShopeeRegion::My,
            ShopeeRegion::Id,
            ShopeeRegion::Vn,
            ShopeeRegion::Ph,
            ShopeeRegion::Th,
            ShopeeRegion::Br,
        ] {
            assert_eq!(ShopeeRegion::parse(r.id()), Some(r));
            assert_eq!(ShopeeRegion::parse(r.region_code()), Some(r));
            // base_url contains the cookie domain pattern (drop leading dot)
            let cookie_host = r.cookie_domain_pattern().trim_start_matches('.');
            assert!(
                r.base_url().contains(cookie_host),
                "base_url {} should contain cookie host {}",
                r.base_url(),
                cookie_host
            );
        }
    }

    #[test]
    fn region_parse_rejects_unknown() {
        assert!(ShopeeRegion::parse("us").is_none());
        assert!(ShopeeRegion::parse("shopee-us").is_none());
        assert!(ShopeeRegion::parse("").is_none());
    }

    #[test]
    fn region_id_is_kebab_case() {
        for r in [ShopeeRegion::Tw, ShopeeRegion::Br] {
            assert!(r.id().starts_with("shopee-"));
            assert!(!r.id().contains('_'));
        }
    }

    #[test]
    fn site_id_matches_region() {
        let s = ShopeeSite(ShopeeRegion::Tw);
        assert_eq!(s.id(), "shopee-tw");
        assert_eq!(s.cookie_domain_patterns(), &[".shopee.tw"]);
        assert_eq!(s.refresh_url(), "https://shopee.tw/");
    }
}