1use chrono::NaiveDateTime;
2use reqwest::blocking::{Client, Response};
3use select::{
4 document::Document,
5 node::{Data, Node},
6 predicate::{Child, Class, Name},
7};
8use serde::{Deserialize, Serialize};
9use std::fs::{create_dir_all, read_to_string, File};
10use std::io::Write;
11use std::path::Path;
12
13#[derive(Serialize, Deserialize, Debug)]
14pub struct Fiction {
15 pub title: String,
16 pub id: usize,
17 pub chapters: Vec<ChapterReference>,
18}
19
20#[derive(Serialize, Deserialize, Debug, Default, Clone)]
21pub struct ChapterReference {
22 pub path: String,
23 pub title: String,
24 pub time: u64,
25}
26
27#[derive(Debug, Default)]
28pub struct Chapter {
29 pub name: String,
30 pub path: String,
31 pub content: Vec<String>,
32 pub published: u64,
33 pub edited: u64,
34}
35
36impl Fiction {
37 pub fn write_to_file(path: &str, fictions: &Vec<Fiction>) -> std::io::Result<()> {
38 if let Some(parent) = Path::new(path).parent() {
39 create_dir_all(parent)?;
40 }
41 let mut file = File::options()
42 .create(true)
43 .write(true)
44 .truncate(true)
45 .open(path)?;
46 file.write_all(
47 fictions
48 .iter()
49 .map(|f| f.id.to_string())
50 .collect::<Vec<_>>()
51 .join("\n")
52 .as_bytes(),
53 )?;
54 Ok(())
55 }
56
57 pub fn from_file(client: &RoyalClient, path: &str) -> std::io::Result<Vec<Fiction>> {
58 Ok(read_to_string(path)?
59 .split('\n')
60 .filter_map(|s| client.get_fiction(s.parse::<usize>().ok()?))
61 .collect())
62 }
63}
64
65fn traverse<'a, 'b>(n: &'a Node, v: &'b Vec<usize>) -> Option<Node<'a>> {
66 let mut v = v.iter();
67 let mut cur: Node = n.children().nth(*v.next()?)?;
68 for i in v {
69 cur = cur.children().nth(*i)?;
70 }
71 Some(cur)
72}
73
74#[derive(Serialize, Deserialize, Debug, Default, Clone)]
75struct OfficialChapterReference {
76 id: usize,
77 volumeId: Option<String>,
78 title: String,
79 slug: String,
80 date: String,
81 order: usize,
82 visible: usize,
83 subscriptionTiers: Option<String>,
84 doesNotRollOver: bool,
85 isUnlocked: bool,
86 url: String,
87}
88
89impl From<OfficialChapterReference> for ChapterReference {
90 fn from(value: OfficialChapterReference) -> Self {
91 Self {
92 path: value.url,
93 title: value.title,
94 time: NaiveDateTime::parse_from_str(&value.date, "%Y-%m-%dT%H:%M:%SZ")
95 .unwrap()
96 .and_utc()
97 .timestamp() as u64,
98 }
99 }
100}
101
102impl Chapter {
103 pub fn from_reference(reference: &ChapterReference, client: &RoyalClient) -> Option<Chapter> {
104 let result = client.get(&reference.path).ok()?;
105 let document = Document::from_read(result.text().ok()?.as_bytes()).ok()?;
106 let profile_info: Node = document.find(Class("profile-info")).next().unwrap();
107 let published = traverse(&profile_info, &vec![3, 1, 3])?
108 .attr("unixtime")?
109 .parse::<u64>()
110 .ok()?;
111 let edited = match traverse(&profile_info, &vec![3, 3, 3]) {
112 Some(x) => x.attr("unixtime")?.parse::<u64>().ok()?,
113 None => published,
114 };
115 let mut content = Vec::new();
116 Self::join_content(
117 document.find(Class("chapter-content")).next()?,
118 &mut content,
119 );
120 let chapter = Chapter {
121 name: reference.title.to_string(),
122 path: reference.path.to_string(),
123 content,
124 published,
125 edited,
126 };
127 Some(chapter)
128 }
129
130 fn join_content(node: Node, content: &mut Vec<String>) {
131 match node.data() {
132 Data::Element(..) => {
133 for child in node.children() {
134 Self::join_content(child, content);
135 }
136 }
137 Data::Text(..) => {
138 if node.text().as_str() == "\n"
139 && !content.is_empty()
140 && content.last().unwrap().len() != 0
141 {
142 content.push(String::new());
143 } else if node.text() != "\n" {
144 if content.is_empty() {
145 content.push(String::new());
146 }
147 content.last_mut().unwrap().push_str(&node.text())
148 }
149 }
150 Data::Comment(..) => {}
152 }
153 }
154}
155
156pub struct RoyalClient {
157 client: Client,
158}
159
160impl RoyalClient {
161 pub fn new() -> RoyalClient {
162 RoyalClient {
163 client: Client::new(),
164 }
165 }
166
167 pub fn get_fiction(&self, id: usize) -> Option<Fiction> {
168 let full_path = format!("/fiction/{}", id);
169 let result = self.get(&full_path).ok()?;
170 let document = Document::from_read(result.text().ok()?.as_bytes()).ok()?;
171 let title = document.find(Name("h1")).into_iter().next().unwrap().text();
172
173 let possible_chap_lists = document
174 .find(Child(Class("page-container-bg-solid"), Name("script")))
175 .into_iter()
176 .collect::<Vec<_>>();
177
178 let text = possible_chap_lists[possible_chap_lists.len() - 3]
179 .children()
180 .next()
181 .unwrap()
182 .text();
183
184 let start_index = text
185 .find("window.chapters = ")
186 .expect("failed to find chapters")
187 + "window.chapters = ".len();
188 let skip_initial = &text.as_bytes()[start_index..];
189
190 let mut chapters_json = "";
191 let mut stack = 0;
192 for (i, byte) in skip_initial.iter().enumerate() {
193 if *byte == b'[' {
194 stack += 1;
195 } else if *byte == b']' {
196 stack -= 1;
197 }
198 if stack == 0 {
199 chapters_json = std::str::from_utf8(&skip_initial[0..=i])
200 .expect("failed to convert from json to &str");
201 break;
202 }
203 }
204
205 let chapters = serde_json::from_str::<Vec<OfficialChapterReference>>(chapters_json)
206 .expect("failed to parse chapters json")
207 .into_iter()
208 .map(ChapterReference::from)
209 .collect::<Vec<ChapterReference>>();
210
211 Some(Fiction {
212 id,
213 title,
214 chapters,
215 })
216 }
217
218 pub fn get(&self, path: &str) -> Result<Response, reqwest::Error> {
219 self.client
220 .get(format!("https://royalroad.com{}", path))
221 .send()
222 }
223}