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, ¶ms).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"));
}
}