use std::error::Error;
use std::fmt::{self, Write as _};
use std::sync::OnceLock;
use crate::engine::FormalAiEngine;
use crate::seed::parser::{parse_lino, LinoNode};
use crate::seed::supported_languages;
pub const GOOGLE_TRENDS_TOP_LIMIT: usize = 10;
const GOOGLE_TRENDS_SNAPSHOT_LINO: &str = include_str!("../data/seed/google-trends-snapshot.lino");
const GOOGLE_TRENDS_PROMPTS_LINO: &str = include_str!("../data/seed/google-trends-prompts.lino");
#[derive(Debug, Clone, PartialEq)]
pub struct GoogleTrendsCatalog {
pub source: String,
pub source_url: String,
pub geo: String,
pub locale: String,
pub collected_at: String,
pub topics: Vec<GoogleTrendTopic>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GoogleTrendTopic {
pub rank: usize,
pub query: String,
pub approx_traffic: Option<String>,
pub pub_date: Option<String>,
pub news_items: Vec<GoogleTrendNewsItem>,
pub prompts: Vec<GoogleTrendPromptVariant>,
pub answered: Vec<GoogleTrendPromptAnswer>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GoogleTrendNewsItem {
pub title: String,
pub source: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GoogleTrendPromptVariant {
pub language: String,
pub variation_key: String,
pub prompt: String,
}
impl GoogleTrendPromptVariant {
#[must_use]
pub fn is_trends_context_request(&self) -> bool {
self.variation_key == "trends_context"
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct GoogleTrendPromptAnswer {
pub language: String,
pub variation_key: String,
pub prompt: String,
pub intent: String,
pub confidence: f32,
pub answer: String,
pub evidence_links: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GoogleTrendsParseError {
NoTopics,
}
impl fmt::Display for GoogleTrendsParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoTopics => formatter.write_str("Google Trends RSS feed contained no topics"),
}
}
}
impl Error for GoogleTrendsParseError {}
pub fn parse_google_trends_rss(
rss_xml: &str,
geo: &str,
locale: &str,
) -> Result<GoogleTrendsCatalog, GoogleTrendsParseError> {
let mut topics = Vec::new();
for block in extract_blocks(rss_xml, "item") {
let Some(query) = extract_text(block, "title") else {
continue;
};
if query.trim().is_empty() {
continue;
}
let news_items = extract_blocks(block, "ht:news_item")
.into_iter()
.filter_map(parse_news_item)
.collect();
topics.push(GoogleTrendTopic {
rank: topics.len() + 1,
query,
approx_traffic: extract_text(block, "ht:approx_traffic"),
pub_date: extract_text(block, "pubDate"),
news_items,
prompts: Vec::new(),
answered: Vec::new(),
});
}
if topics.is_empty() {
return Err(GoogleTrendsParseError::NoTopics);
}
Ok(GoogleTrendsCatalog {
source: "google_trends_rss".to_string(),
source_url: format!("https://trends.google.com/trending/rss?geo={geo}"),
geo: geo.to_string(),
locale: locale.to_string(),
collected_at: String::new(),
topics,
})
}
#[must_use]
pub fn google_trends_catalog() -> GoogleTrendsCatalog {
cached_catalog().clone()
}
#[must_use]
pub fn render_google_trends_snapshot_lino(snapshot: &GoogleTrendsCatalog) -> String {
let mut out = String::from("google_trends_snapshot\n");
out.push_str(" record_type \"google_trends_snapshot\"\n");
field(&mut out, 2, "source", &snapshot.source);
field(&mut out, 2, "source_url", &snapshot.source_url);
field(&mut out, 2, "geo", &snapshot.geo);
field(&mut out, 2, "locale", &snapshot.locale);
if !snapshot.collected_at.trim().is_empty() {
field(&mut out, 2, "collected_at", &snapshot.collected_at);
}
for topic in snapshot.topics.iter().take(GOOGLE_TRENDS_TOP_LIMIT) {
out.push_str("google_trend_topic\n");
out.push_str(" record_type \"google_trend_topic\"\n");
let _ = writeln!(out, " rank \"{}\"", topic.rank);
field(&mut out, 2, "query", &topic.query);
if let Some(traffic) = &topic.approx_traffic {
field(&mut out, 2, "approx_traffic", traffic);
}
if let Some(pub_date) = &topic.pub_date {
field(&mut out, 2, "pub_date", pub_date);
}
for item in &topic.news_items {
out.push_str(" news_item\n");
out.push_str(" record_type \"google_trend_news_item\"\n");
field(&mut out, 4, "title", &item.title);
field(&mut out, 4, "source", &item.source);
field(&mut out, 4, "url", &item.url);
}
}
format!("{}\n", out.trim_end())
}
fn cached_catalog() -> &'static GoogleTrendsCatalog {
static CATALOG: OnceLock<GoogleTrendsCatalog> = OnceLock::new();
CATALOG.get_or_init(build_catalog)
}
fn build_catalog() -> GoogleTrendsCatalog {
let mut catalog = load_seed_snapshot();
catalog.topics.sort_by_key(|topic| topic.rank);
catalog.topics.truncate(GOOGLE_TRENDS_TOP_LIMIT);
let engine = FormalAiEngine;
for topic in &mut catalog.topics {
topic.prompts = prompt_variants_for_topic(&topic.query);
topic.answered = topic
.prompts
.iter()
.map(|prompt| {
let answer = engine.answer(&prompt.prompt);
GoogleTrendPromptAnswer {
language: prompt.language.clone(),
variation_key: prompt.variation_key.clone(),
prompt: prompt.prompt.clone(),
intent: answer.intent,
confidence: answer.confidence,
answer: collapse_whitespace(&answer.answer),
evidence_links: answer.evidence_links,
}
})
.collect();
}
catalog
}
fn load_seed_snapshot() -> GoogleTrendsCatalog {
let tree = parse_lino(GOOGLE_TRENDS_SNAPSHOT_LINO);
let root = tree
.children
.iter()
.find(|record| record.find_child_value("record_type") == "google_trends_snapshot");
let mut catalog = GoogleTrendsCatalog {
source: child_value(root, "source")
.unwrap_or("google_trends_rss")
.to_string(),
source_url: child_value(root, "source_url")
.unwrap_or("https://trends.google.com/trending/rss?geo=US")
.to_string(),
geo: child_value(root, "geo").unwrap_or("US").to_string(),
locale: child_value(root, "locale").unwrap_or("ru").to_string(),
collected_at: child_value(root, "collected_at")
.unwrap_or_default()
.to_string(),
topics: Vec::new(),
};
for record in tree
.children
.iter()
.filter(|record| record.find_child_value("record_type") == "google_trend_topic")
{
let rank = record
.find_child_value("rank")
.parse::<usize>()
.unwrap_or(catalog.topics.len() + 1);
let query = record.find_child_value("query").trim();
if query.is_empty() {
continue;
}
let news_items = record
.children
.iter()
.filter(|child| child.find_child_value("record_type") == "google_trend_news_item")
.map(|child| GoogleTrendNewsItem {
title: child.find_child_value("title").to_string(),
source: child.find_child_value("source").to_string(),
url: child.find_child_value("url").to_string(),
})
.filter(|item| !item.title.trim().is_empty())
.collect();
catalog.topics.push(GoogleTrendTopic {
rank,
query: query.to_string(),
approx_traffic: optional_child(record.find_child_value("approx_traffic")),
pub_date: optional_child(record.find_child_value("pub_date")),
news_items,
prompts: Vec::new(),
answered: Vec::new(),
});
}
catalog
}
fn child_value<'a>(root: Option<&'a LinoNode>, name: &str) -> Option<&'a str> {
root.map(|node| node.find_child_value(name))
.filter(|value| !value.trim().is_empty())
}
fn optional_child(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GoogleTrendPromptTemplate {
language: String,
variation_key: String,
text: String,
}
const QUERY_PLACEHOLDER: &str = "{query}";
fn prompt_variants_for_topic(search: &str) -> Vec<GoogleTrendPromptVariant> {
let templates = prompt_templates();
supported_languages()
.iter()
.flat_map(|language| {
templates
.iter()
.filter(|template| template.language == *language)
.map(|template| GoogleTrendPromptVariant {
language: template.language.clone(),
variation_key: template.variation_key.clone(),
prompt: template.text.replace(QUERY_PLACEHOLDER, search),
})
.collect::<Vec<_>>()
})
.collect()
}
fn prompt_templates() -> &'static [GoogleTrendPromptTemplate] {
static TEMPLATES: OnceLock<Vec<GoogleTrendPromptTemplate>> = OnceLock::new();
TEMPLATES.get_or_init(load_prompt_templates)
}
fn load_prompt_templates() -> Vec<GoogleTrendPromptTemplate> {
let tree = parse_lino(GOOGLE_TRENDS_PROMPTS_LINO);
tree.children
.iter()
.filter(|record| record.find_child_value("record_type") == "google_trend_prompt_template")
.filter_map(|record| {
let language = record.find_child_value("language").trim();
let variation_key = record.find_child_value("variation").trim();
let text = record.find_child_value("text");
if language.is_empty() || variation_key.is_empty() || text.trim().is_empty() {
return None;
}
Some(GoogleTrendPromptTemplate {
language: language.to_string(),
variation_key: variation_key.to_string(),
text: text.to_string(),
})
})
.collect()
}
fn parse_news_item(block: &str) -> Option<GoogleTrendNewsItem> {
let title = extract_text(block, "ht:news_item_title")?;
let source = extract_text(block, "ht:news_item_source").unwrap_or_default();
let url = extract_text(block, "ht:news_item_url").unwrap_or_default();
Some(GoogleTrendNewsItem { title, source, url })
}
fn extract_blocks<'a>(text: &'a str, tag: &str) -> Vec<&'a str> {
let start_tag = format!("<{tag}>");
let end_tag = format!("</{tag}>");
let mut blocks = Vec::new();
let mut offset = 0usize;
while let Some(start) = text[offset..].find(&start_tag) {
let content_start = offset + start + start_tag.len();
let Some(end) = text[content_start..].find(&end_tag) else {
break;
};
let content_end = content_start + end;
blocks.push(&text[content_start..content_end]);
offset = content_end + end_tag.len();
}
blocks
}
fn extract_text(text: &str, tag: &str) -> Option<String> {
let start_tag = format!("<{tag}>");
let end_tag = format!("</{tag}>");
let content_start = text.find(&start_tag)? + start_tag.len();
let content_end = content_start + text[content_start..].find(&end_tag)?;
let raw = text[content_start..content_end].trim();
if raw.is_empty() {
return None;
}
Some(collapse_whitespace(&decode_xml_text(raw)))
}
fn decode_xml_text(raw: &str) -> String {
let cdata = raw
.strip_prefix("<![CDATA[")
.and_then(|value| value.strip_suffix("]]>"))
.unwrap_or(raw);
cdata
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
}
fn field(out: &mut String, indent: usize, name: &str, value: &str) {
let spaces = " ".repeat(indent);
let _ = writeln!(out, "{spaces}{name} \"{}\"", escape_lino_value(value));
}
fn escape_lino_value(value: &str) -> String {
collapse_whitespace(value).replace('"', "'")
}
fn collapse_whitespace(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}