google_reader/
lib.rs

1use std::collections::HashMap;
2
3use anyhow::Context;
4use log::{debug, trace};
5use reqwest::header::HeaderMap;
6use reqwest::Client;
7use serde::Deserialize;
8use url::Url;
9
10#[cfg(test)]
11mod test;
12
13#[derive(Debug)]
14/// A Google Reader client
15///
16/// This should be instantiated with `GoogleReader::try_new()`, as a `mut` variable because login sets the authtoken.
17pub struct GoogleReader {
18    username: String,
19    password: String,
20    /// The server URL, e.g. `https://example.com/api/greader.php` for FreshRSS
21    server_url: Url,
22    authtoken: Option<String>,
23    write_token: Option<String>,
24    client: Option<Client>,
25}
26
27#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
28/// A link to a resource
29pub struct Link {
30    pub href: String,
31}
32
33#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
34/// Item Summary
35pub struct Summary {
36    pub content: Option<String>,
37    pub author: Option<String>,
38}
39
40#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
41/// Feed Item
42pub struct Item {
43    pub id: String,
44    #[serde(alias = "crawlTimeMsec")]
45    pub crawl_time_msec: Option<String>,
46    #[serde(alias = "timestampUsec")]
47    pub timestamp_usec: Option<String>,
48    pub updated: Option<usize>,
49    pub published: Option<usize>,
50    pub title: String,
51    pub canonical: Vec<Link>,
52    pub alternate: Vec<Link>,
53    pub categories: Vec<String>,
54    pub origin: HashMap<String, String>,
55    pub summary: Summary,
56}
57
58#[derive(Debug, Deserialize)]
59/// Response from the API
60pub struct Response {
61    pub id: String,
62    pub items: Vec<Item>,
63    pub updated: usize,
64    pub continuation: Option<String>,
65}
66
67/// Does all the things.
68impl GoogleReader {
69    /// The server URL is something like `https://example.com/api/greader.php` for FreshRSS
70    pub fn try_new(
71        username: impl ToString,
72        password: impl ToString,
73        server_url: impl ToString,
74    ) -> anyhow::Result<Self> {
75        let server_url = match server_url.to_string().ends_with('/') {
76            true => server_url
77                .to_string()
78                .strip_suffix('/')
79                .unwrap()
80                .to_string(),
81            false => server_url.to_string(),
82        };
83
84        let server_url = Url::parse(&server_url).with_context(|| "Failed to parse server URL")?;
85        Ok(GoogleReader {
86            username: username.to_string(),
87            password: password.to_string(),
88            server_url,
89            authtoken: None,
90            write_token: None,
91            client: None,
92        })
93    }
94
95    /// Do the login dance and cache the auth token.
96    pub async fn login(&mut self) -> anyhow::Result<()> {
97        let mut url = self.server_url.clone();
98        url.path_segments_mut()
99            .unwrap()
100            .push("accounts")
101            .push("ClientLogin");
102
103        debug!("Login URL: {}", url);
104
105        let params = [("Email", &self.username), ("Passwd", &self.password)];
106        if self.client.is_none() {
107            self.client = Some(reqwest::Client::new());
108        }
109        let res = self
110            .client
111            .as_ref()
112            .unwrap()
113            .post(url)
114            .form(&params)
115            .send()
116            .await
117            .with_context(|| "Failed to send login request")?;
118
119        let auth_parser = regex::Regex::new(r#"Auth=(?P<authtoken>\S+)"#)
120            .with_context(|| "Failed to generate auth parser regex")?;
121
122        let body = res
123            .text()
124            .await
125            .with_context(|| "Failed to get login response body")?;
126        trace!("Login response: {}", body);
127
128        let caps = auth_parser
129            .captures(&body)
130            .with_context(|| "Failed to parse login response")?;
131        if let Some(authtoken) = caps.name("authtoken") {
132            trace!("Got authtoken: {}", authtoken.as_str());
133            self.authtoken = Some(authtoken.as_str().to_string());
134        }
135
136        Ok(())
137    }
138
139    /// Get a "write" token.
140    pub async fn get_write_token(&mut self) -> anyhow::Result<String> {
141        if self.authtoken.is_none() {
142            self.login().await.with_context(|| "Failed to login")?;
143        }
144        let mut url = self.server_url.clone();
145        url.path_segments_mut()
146            .unwrap()
147            .push("reader")
148            .push("api")
149            .push("0")
150            .push("token");
151        trace!("get_write_token url: {}", url);
152        let res = self
153            .client
154            .as_ref()
155            .unwrap()
156            .get(url)
157            .headers(self.get_auth_headers())
158            .send()
159            .await
160            .with_context(|| "Failed to get write token")?;
161
162        let mut body = res
163            .text()
164            .await
165            .with_context(|| "Failed to get write token response body")?;
166
167        if body.ends_with('\n') {
168            body = body.strip_suffix('\n').unwrap().to_string();
169        }
170
171        self.write_token = Some(body.to_owned());
172
173        Ok(body)
174    }
175
176    /// Returns a list of unread item IDs.
177    pub async fn get_unread_items(
178        &mut self,
179        continuation: Option<String>,
180    ) -> anyhow::Result<Response> {
181        if self.authtoken.is_none() {
182            self.login().await.with_context(|| "Failed to login")?;
183        }
184
185        // https://your-freshrss-instance-url/api/greader.php/reader/api/0/stream/contents/user/-/state/com.google/reading-list?ot=0&n=1000&r=n&xt=user/-/state/com.google/read
186
187        let mut url = self.server_url.clone();
188        url.path_segments_mut()
189            .unwrap()
190            .push("reader")
191            .push("api")
192            .push("0")
193            .push("stream")
194            .push("contents")
195            .push("user")
196            .push("-")
197            .push("state")
198            .push("com.google")
199            .push("reading-list");
200        /*
201        ot=0: This is the "start time" for the request. Setting it to 0 means that you want to fetch all unread items since the beginning.
202        n=1000: This parameter specifies the maximum number of items to fetch. You can adjust this value to the desired number of items.
203        r=n: This parameter specifies the order in which items are returned. "n" stands for "newest first."
204        xt=user/-/state/com.google/read: This parameter specifies that you want to exclude items that are already marked as read.
205        */
206        url.set_query(Some("r=n&xt=user/-/state/com.google/read"));
207        if let Some(continuation) = continuation {
208            url.set_query(Some(
209                format!("c={}&{}", continuation, url.query().unwrap_or("")).as_str(),
210            ))
211        };
212        trace!("url: {}", url);
213        let res = self
214            .client
215            .as_ref()
216            .unwrap()
217            .get(url)
218            .headers(self.get_auth_headers())
219            .send()
220            .await
221            .with_context(|| "Failed to send unread-items request")?;
222
223        let body = res
224            .text()
225            .await
226            .with_context(|| "Failed to parse unread items response body")?;
227        #[cfg(debug_assertions)]
228        trace!("Response body:\n{}", body);
229        let response: Response = serde_json::from_str(&body)
230            .with_context(|| "Failed to parse unread items response body")?;
231        debug!("response: {:#?}", response);
232
233        Ok(response)
234    }
235
236    pub async fn get_item(&self, _item_id: usize) {}
237
238    /// Returns the auth headers for use with the API.
239    fn get_auth_headers(&self) -> HeaderMap {
240        let mut headers = HeaderMap::new();
241        headers.append(
242            "Authorization",
243            format!("GoogleLogin auth={}", self.authtoken.clone().unwrap())
244                .parse()
245                .unwrap(),
246        );
247        #[cfg(debug_assertions)]
248        trace!("Auth headers: {:?}", headers);
249        headers
250    }
251
252    /// Mark an item as read
253    pub async fn mark_item_read(&mut self, item_id: impl ToString) -> anyhow::Result<String> {
254        if self.authtoken.is_none() {
255            self.login().await.with_context(|| "Failed to login")?;
256        }
257
258        let write_token = match &self.write_token {
259            Some(val) => val.to_owned(),
260            None => self
261                .get_write_token()
262                .await
263                .with_context(|| "Failed to get write token")?,
264        };
265
266        let params = [
267            ("a", "user/-/state/com.google/read"),
268            ("T", &write_token),
269            ("i", &item_id.to_string()),
270        ];
271
272        let mut url = self.server_url.clone();
273        url.path_segments_mut()
274            .unwrap()
275            .push("reader")
276            .push("api")
277            .push("0")
278            .push("edit-tag");
279        trace!("edit-tag url: {}", url);
280        let res = self
281            .client
282            .as_ref()
283            .unwrap()
284            .post(url)
285            .form(&params)
286            .headers(self.get_auth_headers())
287            .send()
288            .await
289            .with_context(|| "Failed to get write token")?;
290
291        let body = res
292            .text()
293            .await
294            .with_context(|| "Failed to get write token response body")?;
295
296        Ok(body)
297    }
298
299    /// Returns the number of unread items, does'nt work for FreshRSS.
300    pub async fn unread_count(&mut self) -> anyhow::Result<usize> {
301        if self.authtoken.is_none() {
302            self.login().await.with_context(|| "Failed to login")?;
303        }
304
305        let mut url = self.server_url.clone();
306        url.path_segments_mut()
307            .unwrap()
308            .push("reader")
309            .push("api")
310            .push("0")
311            .push("unread-count");
312        #[cfg(debug_assertions)]
313        trace!("url: {}", url);
314        let res = self
315            .client
316            .as_ref()
317            .unwrap()
318            .get(url)
319            .headers(self.get_auth_headers())
320            .send()
321            .await
322            .with_context(|| "Failed to send unread-items request")?;
323
324        let body = res
325            .text()
326            .await
327            .with_context(|| "Failed to get unread count response body")?;
328
329        let response_usize = body
330            .parse::<usize>()
331            .with_context(|| "Failed to parse unread count response")?;
332        Ok(response_usize)
333    }
334}