docbox_web_scraper/
lib.rs1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4use document::{determine_best_favicon, get_website_metadata};
19use download_image::{download_image_href, resolve_full_url};
20use mime::Mime;
21use reqwest::{Proxy, redirect::Policy};
22use serde::{Deserialize, Serialize};
23use std::{str::FromStr, time::Duration};
24use thiserror::Error;
25use url_validation::TokioDomainResolver;
26
27mod data_uri;
28mod document;
29mod download_image;
30mod request;
31mod url_validation;
32
33pub use document::Favicon;
34pub use reqwest::Url;
35
36use crate::{document::is_allowed_robots_txt, download_image::ImageStream};
37
38#[derive(Debug, Deserialize, Serialize)]
40#[serde(default)]
41pub struct WebsiteMetaServiceConfig {
42 pub http_proxy: Option<String>,
44 pub https_proxy: Option<String>,
46 pub metadata_connect_timeout: Duration,
52 pub metadata_read_timeout: Duration,
58}
59
60#[derive(Debug, Error)]
62pub enum WebsiteMetaServiceConfigError {
63 #[error("DOCBOX_WEB_SCRAPE_METADATA_CONNECT_TIMEOUT must be a number in seconds: {0}")]
65 InvalidMetadataConnectTimeout(<u64 as FromStr>::Err),
66 #[error("DOCBOX_WEB_SCRAPE_METADATA_READ_TIMEOUT must be a number in seconds")]
68 InvalidMetadataReadTimeout(<u64 as FromStr>::Err),
69}
70
71impl Default for WebsiteMetaServiceConfig {
72 fn default() -> Self {
73 Self {
74 http_proxy: None,
75 https_proxy: None,
76 metadata_connect_timeout: Duration::from_secs(5),
77 metadata_read_timeout: Duration::from_secs(10),
78 }
79 }
80}
81
82impl WebsiteMetaServiceConfig {
83 pub fn from_env() -> Result<WebsiteMetaServiceConfig, WebsiteMetaServiceConfigError> {
85 let mut config = WebsiteMetaServiceConfig {
86 http_proxy: std::env::var("DOCBOX_WEB_SCRAPE_HTTP_PROXY").ok(),
87 https_proxy: std::env::var("DOCBOX_WEB_SCRAPE_HTTPS_PROXY").ok(),
88 ..Default::default()
89 };
90
91 if let Ok(metadata_connect_timeout) =
92 std::env::var("DOCBOX_WEB_SCRAPE_METADATA_CONNECT_TIMEOUT")
93 {
94 let metadata_connect_timeout = metadata_connect_timeout
95 .parse::<u64>()
96 .map_err(WebsiteMetaServiceConfigError::InvalidMetadataConnectTimeout)?;
97
98 config.metadata_connect_timeout = Duration::from_secs(metadata_connect_timeout);
99 }
100
101 if let Ok(metadata_read_timeout) = std::env::var("DOCBOX_WEB_SCRAPE_METADATA_READ_TIMEOUT")
102 {
103 let metadata_read_timeout = metadata_read_timeout
104 .parse::<u64>()
105 .map_err(WebsiteMetaServiceConfigError::InvalidMetadataReadTimeout)?;
106
107 config.metadata_read_timeout = Duration::from_secs(metadata_read_timeout);
108 }
109
110 Ok(config)
111 }
112}
113
114pub struct WebsiteMetaService {
116 client: reqwest::Client,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ResolvedWebsiteMetadata {
122 pub title: Option<String>,
124
125 pub og_title: Option<String>,
127
128 pub og_description: Option<String>,
130
131 #[serde(skip)]
133 pub og_image: Option<String>,
134
135 #[serde(skip)]
137 pub best_favicon: Option<String>,
138}
139
140#[derive(Debug)]
143pub struct ResolvedImage {
144 pub content_type: Mime,
146 pub stream: ImageStream,
148}
149
150impl WebsiteMetaService {
151 pub fn new() -> reqwest::Result<Self> {
154 Self::from_config(Default::default())
155 }
156
157 #[deprecated]
164 pub fn from_client(client: reqwest::Client) -> Self {
165 Self { client }
166 }
167
168 pub fn from_config(config: WebsiteMetaServiceConfig) -> reqwest::Result<Self> {
170 let mut builder = reqwest::Client::builder();
171
172 if let Some(http_proxy) = config.http_proxy.clone() {
173 builder = builder.proxy(Proxy::http(http_proxy)?);
174 }
175
176 if let Some(https_proxy) = config.https_proxy.clone() {
177 builder = builder.proxy(Proxy::https(https_proxy)?);
178 }
179
180 let client = builder
181 .user_agent("DocboxLinkBot")
182 .connect_timeout(config.metadata_connect_timeout)
183 .read_timeout(config.metadata_read_timeout)
184 .redirect(Policy::none())
186 .dns_resolver(TokioDomainResolver)
189 .build()?;
190
191 Ok(Self { client })
192 }
193
194 pub async fn resolve_website(&self, url: &Url) -> Option<ResolvedWebsiteMetadata> {
196 let is_allowed_scraping = is_allowed_robots_txt::<TokioDomainResolver>(&self.client, url)
198 .await
199 .unwrap_or(false);
200
201 if !is_allowed_scraping {
202 return None;
203 }
204
205 let res = match get_website_metadata::<TokioDomainResolver>(&self.client, url).await {
207 Ok(value) => value,
208 Err(error) => {
209 tracing::error!(?error, "failed to get website metadata");
210 return None;
211 }
212 };
213
214 let best_favicon = determine_best_favicon(&res.favicons).cloned();
215
216 Some(ResolvedWebsiteMetadata {
217 title: res.title,
218 og_title: res.og_title,
219 og_description: res.og_description,
220 og_image: res.og_image,
221 best_favicon: best_favicon.map(|value| value.href),
222 })
223 }
224
225 pub async fn resolve_website_favicon(&self, url: &Url) -> Option<ResolvedImage> {
227 let website = self.resolve_website(url).await?;
228
229 self.resolve_favicon(url, website.best_favicon).await
230 }
231
232 pub async fn resolve_website_image(&self, url: &Url) -> Option<ResolvedImage> {
234 let website = self.resolve_website(url).await?;
235 let og_image = website.og_image?;
236
237 self.resolve_image(url, &og_image).await
238 }
239
240 pub async fn resolve_favicon(
244 &self,
245 url: &Url,
246 best_favicon: Option<String>,
247 ) -> Option<ResolvedImage> {
248 let favicon = match best_favicon {
249 Some(best) => best,
250
251 None => {
253 let mut url = url.clone();
254 url.set_path("/favicon.ico");
255 url.to_string()
256 }
257 };
258
259 self.resolve_image(url, &favicon).await
260 }
261
262 pub async fn resolve_image(&self, url: &Url, image: &str) -> Option<ResolvedImage> {
264 let image_url = resolve_full_url(url, image).ok()?;
265
266 let (stream, content_type) =
267 download_image_href::<TokioDomainResolver>(&self.client, image_url)
268 .await
269 .ok()?;
270
271 Some(ResolvedImage {
272 content_type,
273 stream,
274 })
275 }
276}