bbc_news_cli/
api.rs

1use anyhow::{Context, Result};
2use quick_xml::events::Event;
3use quick_xml::Reader;
4use std::time::Duration;
5
6use crate::app::NewsStory;
7use crate::cache::Cache;
8
9fn create_http_client() -> Result<reqwest::blocking::Client> {
10    reqwest::blocking::Client::builder()
11        .user_agent("Mozilla/5.0 (Linux; Android 10; SM-A307G) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36")
12        .timeout(Duration::from_secs(10))
13        .build()
14        .context("Failed to create HTTP client")
15}
16
17pub fn fetch_stories(feed_url: &str) -> Result<Vec<NewsStory>> {
18    fetch_stories_with_cache(feed_url, false)
19}
20
21pub fn fetch_stories_with_cache(feed_url: &str, force_offline: bool) -> Result<Vec<NewsStory>> {
22    let cache = Cache::new().ok();
23
24    // Try to load from cache first (if not expired)
25    if let Some(ref cache) = cache {
26        if let Some(cached_stories) = cache.load_feed(feed_url) {
27            return Ok(cached_stories);
28        }
29    }
30
31    // Try to fetch from network
32    let stories_result = if !force_offline {
33        fetch_from_network(feed_url)
34    } else {
35        Err(anyhow::anyhow!("Offline mode - skipping network fetch"))
36    };
37
38    match stories_result {
39        Ok(stories) => {
40            // Save to cache on successful fetch
41            if let Some(ref cache) = cache {
42                let _ = cache.save_feed(feed_url, &stories);
43            }
44            Ok(stories)
45        }
46        Err(e) => {
47            // Network failed, try to load from cache (even if expired)
48            if let Some(ref cache) = cache {
49                if let Some(cached_stories) = cache.load_feed_offline(feed_url) {
50                    return Ok(cached_stories);
51                }
52            }
53            Err(e)
54        }
55    }
56}
57
58fn fetch_from_network(feed_url: &str) -> Result<Vec<NewsStory>> {
59    let client = create_http_client()?;
60
61    let response = client
62        .get(feed_url)
63        .send()
64        .context("Failed to fetch BBC RSS feed")?
65        .text()
66        .context("Failed to read response text")?;
67
68    parse_rss(&response)
69}
70
71fn parse_rss(xml_content: &str) -> Result<Vec<NewsStory>> {
72    let mut reader = Reader::from_str(xml_content);
73    reader.config_mut().trim_text(true);
74
75    let mut stories = Vec::new();
76    let mut buf = Vec::new();
77
78    let mut in_item = false;
79    let mut current_story = NewsStory {
80        title: String::new(),
81        description: String::new(),
82        link: String::new(),
83        pub_date: String::new(),
84        category: String::new(),
85        image_url: None,
86    };
87
88    let mut current_tag = String::new();
89
90    loop {
91        match reader.read_event_into(&mut buf) {
92            Ok(Event::Start(e)) => {
93                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
94                if tag_name == "item" {
95                    in_item = true;
96                    current_story = NewsStory {
97                        title: String::new(),
98                        description: String::new(),
99                        link: String::new(),
100                        pub_date: String::new(),
101                        category: String::from("News"),
102                        image_url: None,
103                    };
104                } else if in_item {
105                    // Handle media:thumbnail tag to extract image URL
106                    if tag_name == "media:thumbnail" || tag_name == "media:content" {
107                        // Extract url attribute
108                        if let Some(url_attr) = e.attributes()
109                            .filter_map(|a| a.ok())
110                            .find(|attr| {
111                                let key = String::from_utf8_lossy(attr.key.as_ref());
112                                key == "url"
113                            })
114                        {
115                            if let Ok(url_value) = url_attr.unescape_value() {
116                                current_story.image_url = Some(url_value.to_string());
117                            }
118                        }
119                    }
120                    current_tag = tag_name;
121                }
122            }
123            Ok(Event::Text(e)) => {
124                if in_item {
125                    let text = e.unescape().unwrap_or_default().to_string();
126                    match current_tag.as_str() {
127                        "title" => current_story.title = text,
128                        "description" => current_story.description = text,
129                        "link" => current_story.link = text,
130                        "pubDate" => current_story.pub_date = format_date(&text),
131                        "category" => current_story.category = text,
132                        _ => {}
133                    }
134                }
135            }
136            Ok(Event::CData(e)) => {
137                if in_item {
138                    let text = String::from_utf8_lossy(&e.into_inner()).to_string();
139                    match current_tag.as_str() {
140                        "title" => current_story.title = text,
141                        "description" => current_story.description = text,
142                        "link" => current_story.link = text,
143                        "pubDate" => current_story.pub_date = format_date(&text),
144                        "category" => current_story.category = text,
145                        _ => {}
146                    }
147                }
148            }
149            Ok(Event::Empty(e)) => {
150                // Handle self-closing tags like <media:thumbnail ... />
151                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
152
153                if in_item && (tag_name == "media:thumbnail" || tag_name == "media:content") {
154                    // Extract url attribute
155                    if let Some(url_attr) = e.attributes()
156                        .filter_map(|a| a.ok())
157                        .find(|attr| {
158                            let key = String::from_utf8_lossy(attr.key.as_ref());
159                            key == "url"
160                        })
161                    {
162                        if let Ok(url_value) = url_attr.unescape_value() {
163                            current_story.image_url = Some(url_value.to_string());
164                        }
165                    }
166                }
167            }
168            Ok(Event::End(e)) => {
169                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
170                if tag_name == "item" {
171                    in_item = false;
172                    if !current_story.title.is_empty() {
173                        stories.push(current_story.clone());
174                    }
175                } else if in_item {
176                    current_tag.clear();
177                }
178            }
179            Ok(Event::Eof) => break,
180            Err(e) => return Err(anyhow::anyhow!("Error parsing XML at position {}: {:?}", reader.buffer_position(), e)),
181            _ => {}
182        }
183        buf.clear();
184    }
185
186    Ok(stories.into_iter().take(30).collect())
187}
188
189fn format_date(date_str: &str) -> String {
190    use chrono::DateTime;
191
192    // BBC RSS dates are in RFC 2822 format (e.g., "Wed, 03 Feb 2015 15:58:15 GMT")
193    // Convert to "YYYY-MM-DD HH:MM:SS" format
194    if let Ok(dt) = DateTime::parse_from_rfc2822(date_str) {
195        dt.format("%Y-%m-%d %H:%M:%S").to_string()
196    } else {
197        date_str.to_string()
198    }
199}