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)]
14pub struct GoogleReader {
18 username: String,
19 password: String,
20 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)]
28pub struct Link {
30 pub href: String,
31}
32
33#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
34pub struct Summary {
36 pub content: Option<String>,
37 pub author: Option<String>,
38}
39
40#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
41pub 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)]
59pub struct Response {
61 pub id: String,
62 pub items: Vec<Item>,
63 pub updated: usize,
64 pub continuation: Option<String>,
65}
66
67impl GoogleReader {
69 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 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(¶ms)
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 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 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 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 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 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 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(¶ms)
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 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}