gwelle 0.1.0

Lightweight Rust client for the Google Trends API
Documentation
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);
    }
}