tail-fin-591 0.6.2

591.com.tw Taiwan rentals adapter for tail-fin: listings, communities, price history, crawl
Documentation
//! `Site` implementation for 591.com.tw (Taiwan rentals + sales).
//!
//! 591 commands are anonymous. Most (hot, community, price-history,
//! sales) go directly to `api.591.com.tw`; `search` / `crawl` use
//! the Nuxt3-rendered website at `www.591.com.tw` because the
//! listing API requires a CSRF token that's only emitted by the
//! SSR page.
//!
//! No login. Validation is network-liveness against the website
//! (which is the CSRF-token source).

use std::time::Duration;

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

pub struct FiveNineOneSite;

#[async_trait]
impl Site for FiveNineOneSite {
    fn id(&self) -> &'static str {
        "591"
    }

    fn display_name(&self) -> &'static str {
        "591 Taiwan Rentals"
    }

    fn cookie_domain_patterns(&self) -> &'static [&'static str] {
        // `.591.com.tw` covers both api.591 and www.591 subdomains —
        // the CSRF token cookie `591-csrf-token` is issued on the
        // parent domain.
        &["*.591.com.tw"]
    }

    fn refresh_url(&self) -> &'static str {
        // The SSR homepage emits the CSRF cookie the listing API
        // requires. Hitting the API root directly wouldn't refresh it.
        "https://www.591.com.tw/"
    }

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

    async fn validate(&self, session: &BrowserSession) -> Result<SessionStatus, SiteError> {
        let status = session
            .http_ping("https://www.591.com.tw/")
            .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: "591 rate limit".into(),
                retry_after: Some(Duration::from_secs(180)),
            },
            0 => SessionStatus::Unknown,
            other => SessionStatus::Degrading {
                estimated_expiry: None,
                hint: format!("unexpected HTTP {other}"),
            },
        })
    }

    fn detect_auth_failure(&self, indicators: &FailureIndicators) -> Option<AuthFailureKind> {
        // 591's listing API returns 403 with a CSRF-mismatch body when
        // the token is stale. Classify as CookieExpired — refreshing
        // (= re-hitting the SSR page) is the fix.
        match indicators.status {
            Some(401) | Some(403) => Some(AuthFailureKind::CookieExpired),
            Some(429) => Some(AuthFailureKind::RateLimited {
                retry_after: Duration::from_secs(180),
            }),
            _ => None,
        }
    }
}

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

    fn indicators(status: u16) -> FailureIndicators {
        FailureIndicators {
            status: Some(status),
            ..Default::default()
        }
    }

    #[test]
    fn identity_fields() {
        let s = FiveNineOneSite;
        assert_eq!(s.id(), "591");
        assert_eq!(s.display_name(), "591 Taiwan Rentals");
        assert_eq!(s.cookie_domain_patterns(), &["*.591.com.tw"]);
        assert_eq!(s.refresh_url(), "https://www.591.com.tw/");
    }

    #[test]
    fn detect_cookie_expired_on_401_and_403() {
        for status in [401, 403] {
            assert!(matches!(
                FiveNineOneSite.detect_auth_failure(&indicators(status)),
                Some(AuthFailureKind::CookieExpired)
            ));
        }
    }

    #[test]
    fn detect_rate_limited_on_429() {
        match FiveNineOneSite.detect_auth_failure(&indicators(429)) {
            Some(AuthFailureKind::RateLimited { retry_after }) => {
                assert_eq!(retry_after, Duration::from_secs(180));
            }
            other => panic!("expected RateLimited, got {other:?}"),
        }
    }

    #[test]
    fn detect_returns_none_for_ok_and_unknown() {
        assert!(FiveNineOneSite
            .detect_auth_failure(&indicators(200))
            .is_none());
        assert!(FiveNineOneSite
            .detect_auth_failure(&indicators(500))
            .is_none());
    }
}