Skip to main content

cp_cli_platform_project_euler/
client.rs

1use std::time::Duration;
2
3use reqwest::{Client as HttpClient, ClientBuilder, redirect::Policy};
4
5use crate::{
6    ArchivePage, Difficulty, Error, PROBLEM_CONTENT_ATTRIBUTION, PROBLEM_CONTENT_LICENSE,
7    PROBLEM_CONTENT_LICENSE_URL, Problem, ProblemCatalog, ProblemSummary, RecentProblems,
8};
9
10pub(crate) const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
11const MAX_STATEMENT_BYTES: usize = 512 * 1024;
12const MAX_ARCHIVE_PAGE: u8 = 20;
13const ARCHIVE_PAGE_SIZE: usize = 50;
14const RECENT_PAGE_SIZE: usize = 10;
15const MAX_CATALOG_PROBLEMS: usize = 4_096;
16const CATALOG_HEADER: &str = "ID##Title##Published##Solved By";
17const MAX_TITLE_BYTES: usize = 256;
18const MAX_METADATA_BYTES: usize = 128;
19const NOT_FOUND_SENTINEL: &str = "Data for that problem cannot be found";
20
21pub struct Client {
22    pub(crate) http: HttpClient,
23    pub(crate) endpoint: Box<str>,
24}
25
26impl Client {
27    pub fn new() -> Result<Self, Error> {
28        Ok(Self {
29            http: http_builder().build()?,
30            endpoint: "https://projecteuler.net/".into(),
31        })
32    }
33
34    /// List one bounded page of fifty public archive problems
35    pub async fn archive(
36        &self,
37        page: u8,
38        progress: impl FnMut(usize, Option<u64>),
39    ) -> Result<ArchivePage, Error> {
40        if !(1..=MAX_ARCHIVE_PAGE).contains(&page) {
41            return Err(Error::InvalidArchivePage);
42        }
43        let body = self
44            .get_html(&format!("archives;page={page}"), progress)
45            .await?;
46        let problems = parse_problem_rows(&body, ARCHIVE_PAGE_SIZE)?;
47        Ok(ArchivePage { page, problems })
48    }
49
50    /// List the ten most recently published public problems
51    pub async fn recent(
52        &self,
53        progress: impl FnMut(usize, Option<u64>),
54    ) -> Result<RecentProblems, Error> {
55        let body = self.get_html("recent", progress).await?;
56        let problems = parse_problem_rows(&body, RECENT_PAGE_SIZE)?;
57        Ok(RecentProblems { problems })
58    }
59
60    /// List every public problem from Project Euler's compact catalogue
61    pub async fn catalog(
62        &self,
63        progress: impl FnMut(usize, Option<u64>),
64    ) -> Result<ProblemCatalog, Error> {
65        let body = self.get_html("minimal=problems", progress).await?;
66        Ok(ProblemCatalog {
67            problems: parse_catalog(&body)?,
68        })
69    }
70
71    /// Fetch the public statement and metadata for a Project Euler problem
72    pub async fn problem(
73        &self,
74        number: u16,
75        mut progress: impl FnMut(usize, Option<u64>),
76    ) -> Result<Problem, Error> {
77        if number == 0 {
78            return Err(Error::InvalidProblemNumber);
79        }
80        let page = self
81            .get_html(&format!("problem={number}"), |received, total| {
82                progress(received, total)
83            })
84            .await?;
85        let (summary, difficulty) = parse_problem_page(&page, number)?;
86        let statement_html = self
87            .get_html(&format!("minimal={number}"), |received, total| {
88                progress(received, total)
89            })
90            .await?;
91        if statement_html.len() > MAX_STATEMENT_BYTES || !valid_html(&statement_html) {
92            return Err(Error::InvalidResponse);
93        }
94        Ok(Problem {
95            summary,
96            difficulty,
97            statement_html,
98            attribution: PROBLEM_CONTENT_ATTRIBUTION,
99            license: PROBLEM_CONTENT_LICENSE,
100            license_url: PROBLEM_CONTENT_LICENSE_URL,
101        })
102    }
103
104    async fn get_html(
105        &self,
106        path: &str,
107        progress: impl FnMut(usize, Option<u64>),
108    ) -> Result<Box<str>, Error> {
109        let response = self.http.get(self.endpoint_path(path)?).send().await?;
110        if response.status() == reqwest::StatusCode::NOT_FOUND {
111            return Err(Error::NotFound);
112        }
113        if !response.status().is_success() {
114            return Err(Error::Status(response.status()));
115        }
116        let body = read_response(response, progress).await?;
117        if body.contains(NOT_FOUND_SENTINEL) {
118            return Err(Error::NotFound);
119        }
120        Ok(body)
121    }
122
123    fn endpoint_path(&self, path: &str) -> Result<reqwest::Url, Error> {
124        let mut url = reqwest::Url::parse(&self.endpoint).map_err(|_| Error::InvalidResponse)?;
125        url.set_path(path);
126        url.set_query(None);
127        Ok(url)
128    }
129}
130
131pub(crate) fn http_builder() -> ClientBuilder {
132    HttpClient::builder()
133        .https_only(true)
134        .connect_timeout(Duration::from_secs(10))
135        .read_timeout(Duration::from_secs(20))
136        .timeout(Duration::from_secs(30))
137        .redirect(Policy::none())
138        .retry(reqwest::retry::never())
139        .no_gzip()
140        .no_brotli()
141        .no_deflate()
142        .no_zstd()
143        .pool_max_idle_per_host(2)
144        .user_agent(concat!(
145            env!("CARGO_PKG_NAME"),
146            "/",
147            env!("CARGO_PKG_VERSION")
148        ))
149}
150
151async fn read_response(
152    mut response: reqwest::Response,
153    mut progress: impl FnMut(usize, Option<u64>),
154) -> Result<Box<str>, Error> {
155    if response
156        .content_length()
157        .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
158    {
159        return Err(Error::ResponseTooLarge {
160            limit: MAX_RESPONSE_BYTES,
161        });
162    }
163    let total = response.content_length();
164    let mut body = Vec::with_capacity(8 * 1024);
165    progress(0, total);
166    while let Some(chunk) = response.chunk().await? {
167        if chunk.len() > MAX_RESPONSE_BYTES - body.len() {
168            return Err(Error::ResponseTooLarge {
169                limit: MAX_RESPONSE_BYTES,
170            });
171        }
172        body.extend_from_slice(&chunk);
173        progress(body.len(), total);
174    }
175    let body = String::from_utf8(body).map_err(|_| Error::InvalidResponse)?;
176    Ok(body.into())
177}
178
179fn parse_problem_rows(body: &str, expected_count: usize) -> Result<Vec<ProblemSummary>, Error> {
180    let table =
181        between(body, "<table id=\"problems_table\"", "</table>").ok_or(Error::InvalidResponse)?;
182    let problems = table
183        .split("<tr>")
184        .skip(1)
185        .filter_map(|row| row.split_once("</tr>").map(|(row, _)| row))
186        .filter(|row| row.contains("<td class=\"id_column\">"))
187        .map(parse_archive_row)
188        .collect::<Result<Vec<_>, _>>()?;
189    if problems.len() != expected_count {
190        return Err(Error::InvalidResponse);
191    }
192    Ok(problems)
193}
194
195fn parse_catalog(body: &str) -> Result<Vec<ProblemSummary>, Error> {
196    let mut lines = body.lines();
197    if lines.next() != Some(CATALOG_HEADER) {
198        return Err(Error::InvalidResponse);
199    }
200    let mut previous = 0;
201    let mut problems = Vec::with_capacity(1_024);
202    for line in lines {
203        if problems.len() == MAX_CATALOG_PROBLEMS {
204            return Err(Error::InvalidResponse);
205        }
206        let mut fields = line.split("##");
207        let number = fields
208            .next()
209            .and_then(|value| value.parse::<u16>().ok())
210            .filter(|number| *number > previous)
211            .ok_or(Error::InvalidResponse)?;
212        let title = fields
213            .next()
214            .and_then(decode_text)
215            .filter(|title| valid_text(title, MAX_TITLE_BYTES))
216            .ok_or(Error::InvalidResponse)?;
217        let published_at = fields
218            .next()
219            .and_then(decode_text)
220            .filter(|published| valid_text(published, MAX_METADATA_BYTES))
221            .ok_or(Error::InvalidResponse)?;
222        let solved_count = fields
223            .next()
224            .and_then(|value| value.parse::<u32>().ok())
225            .ok_or(Error::InvalidResponse)?;
226        if fields.next().is_some() {
227            return Err(Error::InvalidResponse);
228        }
229        previous = number;
230        problems.push(ProblemSummary {
231            number,
232            title: title.into(),
233            solved_count: Some(solved_count),
234            published_at: Some(published_at.into()),
235            canonical_url: canonical_url(number),
236        });
237    }
238    (!problems.is_empty())
239        .then_some(problems)
240        .ok_or(Error::InvalidResponse)
241}
242
243fn parse_archive_row(row: &str) -> Result<ProblemSummary, Error> {
244    let number = between(row, "<td class=\"id_column\">", "</td>")
245        .and_then(|number| number.parse::<u16>().ok())
246        .filter(|number| *number > 0)
247        .ok_or(Error::InvalidResponse)?;
248    let anchor_start = row
249        .find("<a href=\"problem=")
250        .ok_or(Error::InvalidResponse)?;
251    let anchor = &row[anchor_start..];
252    let href_start = "<a href=\"".len();
253    let href_end = anchor
254        .get(href_start..)
255        .and_then(|value| value.find('\"').map(|end| href_start + end))
256        .ok_or(Error::InvalidResponse)?;
257    let href = anchor
258        .get(href_start..href_end)
259        .ok_or(Error::InvalidResponse)?;
260    if href != format!("problem={number}") {
261        return Err(Error::InvalidResponse);
262    }
263    let tag_end = anchor.find('>').ok_or(Error::InvalidResponse)?;
264    let tag = &anchor[..tag_end];
265    let title = anchor
266        .get(tag_end + 1..)
267        .and_then(|text| text.split_once("</a>").map(|(title, _)| title))
268        .and_then(decode_text)
269        .filter(|title| valid_text(title, MAX_TITLE_BYTES))
270        .ok_or(Error::InvalidResponse)?;
271    let published_at = attribute(tag, "title")
272        .and_then(|published| published.strip_prefix("Published on "))
273        .and_then(decode_text)
274        .filter(|published| valid_text(published, MAX_METADATA_BYTES));
275    let solved_count = between(anchor, "<div class=\"center\">", "</div>")
276        .and_then(|count| count.parse::<u32>().ok());
277    Ok(ProblemSummary {
278        number,
279        title: title.into(),
280        solved_count,
281        published_at: published_at.map(Into::into),
282        canonical_url: canonical_url(number),
283    })
284}
285
286fn parse_problem_page(
287    body: &str,
288    number: u16,
289) -> Result<(ProblemSummary, Option<Difficulty>), Error> {
290    let title = between(body, "<div id=\"content\">", "</h2>")
291        .and_then(|content| content.rsplit_once("<h2>").map(|(_, title)| title))
292        .and_then(decode_text)
293        .filter(|title| valid_text(title, MAX_TITLE_BYTES))
294        .ok_or(Error::InvalidResponse)?;
295    if !body.contains(&format!("<h3>Problem {number}</h3>")) {
296        return Err(Error::InvalidResponse);
297    }
298    let tooltip = between(body, "tooltiptext_right\">", "</span>");
299    let status = tooltip.and_then(|value| value.split_once("<br>").map(|(status, _)| status));
300    let published_at = status
301        .and_then(|value| value.strip_prefix("Published on "))
302        .map(|value| {
303            value
304                .split_once(" and solved by ")
305                .map_or(value, |(date, _)| date)
306        })
307        .and_then(decode_text)
308        .filter(|date| valid_text(date, MAX_METADATA_BYTES));
309    let solved_count = status
310        .and_then(|value| value.split_once("solved by ").map(|(_, count)| count))
311        .and_then(|count| {
312            count
313                .split_once('<')
314                .map(|(count, _)| count)
315                .or(Some(count))
316        })
317        .and_then(|count| count.trim().parse::<u32>().ok());
318    let difficulty = tooltip.and_then(parse_difficulty);
319    Ok((
320        ProblemSummary {
321            number,
322            title: title.into(),
323            solved_count,
324            published_at: published_at.map(Into::into),
325            canonical_url: canonical_url(number),
326        },
327        difficulty,
328    ))
329}
330
331fn parse_difficulty(tooltip: &str) -> Option<Difficulty> {
332    let value = tooltip.split_once("Difficulty: Level ")?.1;
333    let (level, percentage) = value.split_once(" [")?;
334    let percentage = percentage.strip_suffix("%]")?;
335    Some(Difficulty {
336        level: level.parse().ok()?,
337        percentage: percentage.parse().ok()?,
338    })
339}
340
341fn between<'a>(value: &'a str, start: &str, end: &str) -> Option<&'a str> {
342    let value = value.split_once(start)?.1;
343    value.split_once(end).map(|(value, _)| value)
344}
345
346fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
347    let prefix = format!("{name}=\"");
348    let value = tag.split_once(&prefix)?.1;
349    value.split_once('\"').map(|(value, _)| value)
350}
351
352fn decode_text(value: &str) -> Option<String> {
353    let value = value
354        .replace("<sup>", "")
355        .replace("</sup>", "")
356        .replace("<sub>", "")
357        .replace("</sub>", "");
358    if value.contains('<') || value.len() > MAX_TITLE_BYTES {
359        return None;
360    }
361    let value = value
362        .replace("&amp;", "&")
363        .replace("&lt;", "<")
364        .replace("&gt;", ">")
365        .replace("&quot;", "\"")
366        .replace("&#39;", "'")
367        .replace("&apos;", "'");
368    (!value.is_empty() && !value.as_bytes().contains(&0)).then_some(value)
369}
370
371fn canonical_url(number: u16) -> Box<str> {
372    format!("https://projecteuler.net/problem={number}").into()
373}
374
375fn valid_text(value: &str, limit: usize) -> bool {
376    !value.is_empty() && value.len() <= limit && !value.as_bytes().contains(&0)
377}
378
379fn valid_html(value: &str) -> bool {
380    !value.trim().is_empty() && !value.as_bytes().contains(&0)
381}