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 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 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 if let Some(ref cache) = cache {
42 let _ = cache.save_feed(feed_url, &stories);
43 }
44 Ok(stories)
45 }
46 Err(e) => {
47 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 if tag_name == "media:thumbnail" || tag_name == "media:content" {
107 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 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 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 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}