1use std::{
2 env,
3 fmt::Write,
4 fs,
5 path::{Path, PathBuf},
6 process,
7};
8
9use anyhow::{Context, Result};
10use reqwest::{header::HeaderMap, redirect::Policy};
11use scraper::{Html, Selector};
12use tracing::{error, warn};
13
14pub const AOC_URL: &str = "https://adventofcode.com";
15pub const AUTH_VAR: &str = "AOC_AUTH_TOKEN";
16pub const CACHE_PATH: &str = ".cache/aoc";
17
18pub type PuzzleId = (u16, u8);
20
21pub struct Client {
23 http: reqwest::blocking::Client,
24 cache: Cache,
25}
26
27impl Client {
28 pub fn new() -> Result<Self> {
29 let token = env::var(AUTH_VAR).unwrap_or_else(|e| {
30 error!(cause = %e, AUTH_VAR);
31 process::exit(1);
32 });
33
34 let mut headers = HeaderMap::new();
35 headers.insert("cookie", format!("session={token}").parse()?);
36 Ok(Self {
37 http: reqwest::blocking::Client::builder()
38 .user_agent("libaoc.rs")
39 .default_headers(headers)
40 .redirect(Policy::none())
41 .build()?,
42 cache: Cache::new(home_dir().join(CACHE_PATH))?,
43 })
44 }
45
46 pub fn get_puzzle(&self, id: &PuzzleId) -> Result<Puzzle> {
48 if let Some(puzzle) = self.cache.get(id) {
49 return Ok(puzzle);
50 }
51 self.download_puzzle(id)
52 }
53
54 pub fn download_puzzle(&self, id: &PuzzleId) -> Result<Puzzle> {
56 let puzzle = self.scrape_puzzle(id)?;
57 self.cache.insert(id, &puzzle);
58 Ok(puzzle)
59 }
60
61 pub fn get_input(&self, id: &PuzzleId) -> Result<String> {
63 if let Some(input) = self.cache.get_input(id) {
64 return Ok(input);
65 }
66 self.download_input(id)
67 }
68
69 pub fn download_input(&self, id: &PuzzleId) -> Result<String> {
71 let input = self
72 .http
73 .get(format!("{}/input", self.mkurl(id)))
74 .send()?
75 .error_for_status()?
76 .text()?;
77 self.cache.insert_input(id, &input);
78 Ok(input)
79 }
80
81 pub fn scrape_puzzle(&self, id: &PuzzleId) -> Result<Puzzle> {
83 let html = self
84 .http
85 .get(self.mkurl(id))
86 .send()?
87 .error_for_status()?
88 .text()?;
89
90 let doc = Html::parse_document(&html);
91 let query = Selector::parse("article.day-desc").unwrap();
92 let mut questions = doc.select(&query);
93 let q1 = questions
94 .next()
95 .and_then(|el| html2text::from_read(el.inner_html().as_bytes(), 80).ok());
96 let q2 = questions
97 .next()
98 .and_then(|el| html2text::from_read(el.inner_html().as_bytes(), 80).ok());
99
100 let query = Selector::parse("article.day-desc + p code").unwrap();
101 let mut answers = doc.select(&query);
102 let a1 = answers.next().map(|el| el.text().collect::<String>());
103 let a2 = answers.next().map(|el| el.text().collect::<String>());
104
105 Ok(Puzzle {
106 id: *id,
107 q1,
108 q2,
109 a1,
110 a2,
111 })
112 }
113
114 pub fn submit(
116 &self,
117 id: &PuzzleId,
118 part: Option<u8>,
119 answer: impl AsRef<str>,
120 ) -> Result<Option<Puzzle>> {
121 let path = self.cache.mkpath(id);
124 let part = part.unwrap_or_else(|| {
125 if fs::metadata(path.join("a1")).is_ok_and(|m| m.len() > 0) {
126 2
127 } else {
128 1
129 }
130 });
131
132 let html = self
133 .http
134 .post(format!("{}/answer", self.mkurl(id)))
135 .header("content-type", "application/x-www-form-urlencoded")
136 .body(format!("level={}&answer={}", part, answer.as_ref()))
137 .send()?
138 .error_for_status()?
139 .text()?;
140
141 match self.submission_outcome(&html) {
142 Submit::Correct => {
143 println!("Correct!");
144 return Ok(Some(self.download_puzzle(id)?));
145 }
146 Submit::Incorrect => println!("Incorrect!"),
147 Submit::Wait => println!("Wait!"),
148 Submit::Error => println!("Unknown response"),
149 };
150 Ok(None)
151 }
152
153 fn submission_outcome(&self, response: &str) -> Submit {
154 if response.contains("That's the right answer") {
155 Submit::Correct
156 } else if response.contains("That's not the right answer") {
157 Submit::Incorrect
158 } else if response.contains("You gave an answer too recently") {
159 Submit::Wait
160 } else {
161 Submit::Error
162 }
163 }
164
165 fn mkurl(&self, (y, d): &PuzzleId) -> String {
166 format!("{AOC_URL}/{y}/day/{d}")
167 }
168}
169
170pub enum Submit {
172 Correct,
173 Incorrect,
174 Wait,
175 Error,
176}
177
178struct Cache {
180 path: PathBuf,
181}
182
183impl Cache {
184 pub fn new(path: impl AsRef<Path>) -> Result<Self> {
185 let path = path.as_ref();
186 if !path.exists() {
187 fs::create_dir_all(path).context("mkdir cache")?;
188 }
189 Ok(Self { path: path.into() })
190 }
191
192 pub fn get(&self, id: &PuzzleId) -> Option<Puzzle> {
193 let path = self.mkpath(id);
194 path.exists().then(|| Puzzle::read(path, id))
195 }
196
197 pub fn get_input(&self, id: &PuzzleId) -> Option<String> {
198 let path = self.mkpath(id).join("in");
199 path.exists().then(|| fs::read_to_string(path).unwrap())
200 }
201
202 #[allow(dead_code)]
203 pub fn get_answers(&self, id: &PuzzleId) -> (Option<String>, Option<String>) {
204 let path = self.mkpath(id);
205 (
206 fs::read_to_string(path.join("a1")).ok(),
207 fs::read_to_string(path.join("a2")).ok(),
208 )
209 }
210
211 pub fn insert(&self, id: &PuzzleId, puzzle: &Puzzle) {
212 puzzle
213 .write(self.mkpath(id))
214 .unwrap_or_else(|_| warn!("failed to insert puzzle"));
215 }
216
217 pub fn insert_input(&self, id: &PuzzleId, input: &str) {
218 fs::write(self.mkpath(id).join("in"), input)
219 .unwrap_or_else(|_| warn!("failed to insert input"));
220 }
221
222 #[allow(dead_code)]
223 pub fn update_answer(&self, id: &PuzzleId, part: u32, answer: &str) {
224 fs::write(self.mkpath(id).join(format!("a{part}")), answer)
225 .unwrap_or_else(|_| warn!("failed to update answer"));
226 }
227
228 fn mkpath(&self, (y, d): &PuzzleId) -> PathBuf {
229 self.path.join(format!("{y}/{d}"))
230 }
231}
232
233#[derive(Debug, Default, Clone)]
234pub struct Puzzle {
235 pub id: PuzzleId,
236 pub q1: Option<String>,
237 pub q2: Option<String>,
238 pub a1: Option<String>,
239 pub a2: Option<String>,
240}
241
242impl Puzzle {
243 pub fn read(path: impl AsRef<Path>, id: &PuzzleId) -> Puzzle {
244 let path = path.as_ref();
245 Puzzle {
246 id: *id,
247 q1: fs::read_to_string(path.join("q1")).ok(),
248 q2: fs::read_to_string(path.join("q2")).ok(),
249 a1: fs::read_to_string(path.join("a1")).ok(),
250 a2: fs::read_to_string(path.join("a2")).ok(),
251 }
252 }
253
254 pub fn write(&self, path: impl AsRef<Path>) -> Result<()> {
255 let path = path.as_ref();
256 fs::create_dir_all(path)?;
257 if let Some(q) = &self.q1 {
258 fs::write(path.join("q1"), q.as_bytes())?;
259 }
260 if let Some(q) = &self.q2 {
261 fs::write(path.join("q2"), q.as_bytes())?;
262 }
263 if let Some(a) = &self.a1 {
264 fs::write(path.join("a1"), a.as_bytes())?;
265 }
266 if let Some(a) = &self.a2 {
267 fs::write(path.join("a2"), a.as_bytes())?;
268 }
269 Ok(())
270 }
271
272 pub fn view(&self, show_answers: bool) -> String {
273 let mut buf = String::new();
274 if let Some(q1) = &self.q1 {
275 let _ = writeln!(&mut buf, "{q1}");
276 if show_answers {
277 if let Some(a1) = &self.a1 {
278 let _ = writeln!(&mut buf, "**Answer**: `{a1}`.");
279 }
280 }
281 }
282 if let Some(q2) = &self.q2 {
283 let _ = writeln!(&mut buf, "\n{q2}");
284 if show_answers {
285 if let Some(a2) = &self.a2 {
286 let _ = writeln!(&mut buf, "**Answer**: `{a2}`.");
287 }
288 }
289 }
290 buf
291 }
292
293 pub fn write_view(&self, path: impl AsRef<Path>) -> Result<()> {
294 Ok(fs::write(path, self.view(true))?)
295 }
296}
297
298fn home_dir() -> PathBuf {
299 PathBuf::from(env::var("HOME").unwrap_or_else(|e| {
300 error!(cause = %e, "HOME");
301 process::exit(1);
302 }))
303}
304
305pub fn puzzle_id_from_path(path: impl AsRef<Path>) -> Option<PuzzleId> {
307 let mut day = 0xff;
308 let mut year = 0;
309 for parent in path.as_ref().ancestors() {
310 let mut chars = parent
311 .file_name()
312 .unwrap()
313 .to_str()
314 .unwrap()
315 .chars()
316 .peekable();
317 let mut buf = String::new();
318 while let Some(c) = chars.next() {
319 if c.is_ascii_digit() {
320 buf.push(c);
321 if !chars.peek().is_some_and(|c| c.is_ascii_digit()) {
322 break;
323 }
324 }
325 }
326 if !buf.is_empty() {
327 if day == 0xff {
328 day = buf.parse().unwrap();
329 } else {
330 year = buf.parse().unwrap();
331 }
332 }
333 if year > 0 {
334 return Some((year, day));
335 }
336 }
337 None
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn from_path() {
346 let cases = vec![
347 ("/Users/j0rdi/aoc/2015/d01", Some((2015, 1))),
348 ("/home/j0rdi/aoc/2024/25", Some((2024, 25))),
349 ("/Users/j0rdi/aoc/2017/other/d8", Some((2017, 8))),
350 ("/home/j0rdi/aoc/2017/other/08/sub", Some((2017, 8))),
351 ];
352
353 for (path, expected) in cases {
354 assert_eq!(puzzle_id_from_path(path), expected)
355 }
356
357 assert_eq!(puzzle_id_from_path("/invalid/path"), None)
358 }
359}
360
361