gwelle 0.1.0

Lightweight Rust client for the Google Trends API
Documentation
use crate::client::{format_req_param, TrendsClient};
use crate::models::{ExploreRequest, ExploreTokens, WidgetToken};
use crate::{Result, TrendsError};
use serde_json::json;

fn validate_explore_request(req: &ExploreRequest) -> Result<()> {
    const MAX_KEYWORDS: usize = 5;
    const MAX_KEYWORD_LEN: usize = 100;
    const MAX_GEO_LEN: usize = 16;
    const MAX_TIMEFRAME_LEN: usize = 64;
    const MAX_PROPERTY_LEN: usize = 16;

    if req.keywords.is_empty() {
        return Err(TrendsError::InvalidInput(
            "At least one keyword is required".to_string(),
        ));
    }
    if req.keywords.len() > MAX_KEYWORDS {
        return Err(TrendsError::InvalidInput(
            "Google Trends supports at most 5 keywords".to_string(),
        ));
    }
    if req.keywords.iter().any(|kw| kw.trim().is_empty()) {
        return Err(TrendsError::InvalidInput(
            "Keywords cannot be empty strings".to_string(),
        ));
    }
    if req
        .keywords
        .iter()
        .any(|kw| kw.trim().len() > MAX_KEYWORD_LEN)
    {
        return Err(TrendsError::InvalidInput(format!(
            "Keywords must be <= {} characters",
            MAX_KEYWORD_LEN
        )));
    }
    if req.geo.trim().len() > MAX_GEO_LEN {
        return Err(TrendsError::InvalidInput(format!(
            "Geo must be <= {} characters",
            MAX_GEO_LEN
        )));
    }
    if req.timeframe.trim().is_empty() || req.timeframe.trim().len() > MAX_TIMEFRAME_LEN {
        return Err(TrendsError::InvalidInput(format!(
            "Timeframe must be 1..={} characters",
            MAX_TIMEFRAME_LEN
        )));
    }
    if req.property.trim().len() > MAX_PROPERTY_LEN {
        return Err(TrendsError::InvalidInput(format!(
            "Property must be <= {} characters",
            MAX_PROPERTY_LEN
        )));
    }

    Ok(())
}

impl TrendsClient {
    pub async fn explore(&self, req: &ExploreRequest) -> Result<ExploreTokens> {
        validate_explore_request(req)?;

        let url = "https://trends.google.com/trends/api/explore";
        let geo = req.geo.trim();
        let timeframe = req.timeframe.trim();
        let property = req.property.trim();

        let comparison_items: Vec<_> = req
            .keywords
            .iter()
            .map(|kw| {
                json!({
                    "keyword": kw.trim(),
                    "geo": geo,
                    "time": timeframe,
                })
            })
            .collect();

        let req_json = json!({
            "comparisonItem": comparison_items,
            "category": req.category,
            "property": property,
        });

        let req_str = format_req_param(&req_json)?;
        let tz_str = self.tz.to_string();

        let params = vec![
            ("hl", self.hl.as_str()),
            ("tz", tz_str.as_str()),
            ("req", req_str.as_str()),
            ("cts", "1"),
        ];

        let result = self.get_json(url, &params).await?;

        let widgets = result
            .get("widgets")
            .and_then(|w| w.as_array())
            .ok_or_else(|| {
                TrendsError::TokenNotFound("No widgets array in explore response".to_string())
            })?;

        let mut tokens = ExploreTokens {
            interest_over_time: None,
            interest_by_region: None,
            related_topics: None,
            related_queries: None,
        };

        for widget in widgets {
            if let (Some(id), Some(token), Some(request)) = (
                widget.get("id").and_then(|i| i.as_str()),
                widget.get("token").and_then(|t| t.as_str()),
                widget.get("request"),
            ) {
                let w_token = WidgetToken {
                    token: token.to_string(),
                    request: request.clone(),
                };

                match id {
                    "TIMESERIES" => tokens.interest_over_time = Some(w_token),
                    "GEO_MAP" => tokens.interest_by_region = Some(w_token),
                    "RELATED_TOPICS" => tokens.related_topics = Some(w_token),
                    "RELATED_QUERIES" => tokens.related_queries = Some(w_token),
                    _ => {}
                }
            }
        }

        Ok(tokens)
    }
}

#[cfg(test)]
mod tests {
    use super::validate_explore_request;
    use crate::models::ExploreRequest;

    #[test]
    fn rejects_empty_keyword_list() {
        let req = ExploreRequest::default();
        let err = validate_explore_request(&req).expect_err("expected invalid input");
        assert!(err.to_string().contains("At least one keyword"));
    }

    #[test]
    fn rejects_more_than_five_keywords() {
        let req = ExploreRequest {
            keywords: vec![
                "a".into(),
                "b".into(),
                "c".into(),
                "d".into(),
                "e".into(),
                "f".into(),
            ],
            geo: "US".into(),
            timeframe: "today 12-m".into(),
            category: 0,
            property: "".into(),
        };
        let err = validate_explore_request(&req).expect_err("expected invalid input");
        assert!(err.to_string().contains("at most 5"));
    }
}