cp_cli_platform_project_euler/
client.rs1use 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 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 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 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 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("&", "&")
363 .replace("<", "<")
364 .replace(">", ">")
365 .replace(""", "\"")
366 .replace("'", "'")
367 .replace("'", "'");
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}