use crate::session::SessionCookies;
use crate::{Result, TrendsError};
use reqwest::header::{
HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE, COOKIE, REFERER, USER_AGENT,
};
use reqwest_cookie_store::{CookieStore, CookieStoreMutex};
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
use url::Url;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub const TZ_UTC: i32 = 0;
pub const TZ_BERLIN: i32 = -60;
#[derive(Clone)]
pub struct TrendsClient {
pub(crate) inner: reqwest::Client,
pub hl: String,
pub tz: i32,
}
impl TrendsClient {
pub fn new(session: &SessionCookies, hl: impl Into<String>, tz: i32) -> Result<Self> {
let trends_url = Url::parse("https://trends.google.com")?;
let mut store = CookieStore::default();
for part in session.cookie_header.split(';') {
let part = part.trim();
if !part.is_empty() {
let _ = store.parse(part, &trends_url);
}
}
let cookies = Arc::new(CookieStoreMutex::new(store));
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_static(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
),
);
headers.insert(
ACCEPT,
HeaderValue::from_static("application/json, text/javascript, */*; q=0.01"),
);
headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9"));
headers.insert(
REFERER,
HeaderValue::from_static("https://trends.google.com/"),
);
if let Ok(cookie_val) = HeaderValue::from_str(&session.cookie_header) {
headers.insert(COOKIE, cookie_val);
}
let inner = reqwest::Client::builder()
.cookie_provider(cookies)
.default_headers(headers)
.connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.pool_idle_timeout(Duration::from_secs(90))
.build()?;
Ok(Self {
inner,
hl: hl.into(),
tz,
})
}
pub fn new_with_defaults(hl: impl Into<String>, tz: i32) -> Result<Self> {
let store = CookieStore::default();
let cookies = Arc::new(CookieStoreMutex::new(store));
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_static(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/121.0.0.0 Safari/537.36",
),
);
let inner = reqwest::Client::builder()
.cookie_provider(cookies)
.default_headers(headers)
.connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.pool_idle_timeout(Duration::from_secs(90))
.build()?;
Ok(Self {
inner,
hl: hl.into(),
tz,
})
}
pub(crate) async fn get_json(&self, url: &str, params: &[(&str, &str)]) -> Result<Value> {
let resp = self.inner.get(url).query(params).send().await?;
Self::process_response(resp).await
}
pub(crate) async fn get_json_with_params(
&self,
url: &str,
params: &[(&str, &str)],
) -> Result<Value> {
self.get_json(url, params).await
}
async fn process_response(resp: reqwest::Response) -> Result<Value> {
let status = resp.status();
if status == reqwest::StatusCode::BAD_REQUEST
|| status == reqwest::StatusCode::TOO_MANY_REQUESTS
{
return Err(TrendsError::RateLimited);
}
resp.error_for_status_ref()?;
let body = resp.text().await?;
parse_xssi_json(&body)
}
}
pub(crate) fn format_req_param(value: &Value) -> Result<String> {
let raw_json = serde_json::to_string(value)?;
Ok(insert_structural_spaces(&raw_json))
}
fn insert_structural_spaces(input: &str) -> String {
let mut out = String::with_capacity(input.len() + input.len() / 8);
let mut in_string = false;
let mut escaped = false;
for ch in input.chars() {
if in_string {
out.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => {
in_string = true;
out.push(ch);
}
':' | ',' => {
out.push(ch);
out.push(' ');
}
_ => out.push(ch),
}
}
out
}
fn parse_xssi_json(body: &str) -> Result<Value> {
let body = body.trim_start_matches('\u{feff}').trim_start();
if body.is_empty() {
return Err(TrendsError::XssiPrefixMissing);
}
if let Ok(value) = serde_json::from_str::<Value>(body) {
return Ok(value);
}
let mut parse_error = None;
for (idx, ch) in body.char_indices() {
if ch != '{' && ch != '[' {
continue;
}
let candidate = &body[idx..];
match serde_json::from_str::<Value>(candidate) {
Ok(value) => return Ok(value),
Err(err) => parse_error = Some(err),
}
}
if let Some(err) = parse_error {
return Err(err.into());
}
Err(TrendsError::XssiPrefixMissing)
}
#[cfg(test)]
mod tests {
use super::{format_req_param, parse_xssi_json};
use serde_json::json;
#[test]
fn preserves_string_content_when_formatting_req() {
let req = json!({
"keyword": "foo:bar,baz",
"nested": { "value": "a,b:c" },
});
let formatted = format_req_param(&req).expect("format req");
assert!(formatted.contains("\"foo:bar,baz\""));
assert!(formatted.contains("\"a,b:c\""));
}
#[test]
fn parses_xssi_prefixed_payload() {
let payload = ")]}',\n{\"default\": {\"ok\": true}}";
let parsed = parse_xssi_json(payload).expect("parse xssi");
assert_eq!(parsed["default"]["ok"], true);
}
}