royal_api/
lib.rs

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            // idk wtf a comment is supposed to mean
151            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}