browser_automation_cli/
robots.rs1use std::sync::OnceLock;
15use std::time::Duration;
16
17use robotstxt::DefaultMatcher;
18use url::Url;
19
20use crate::error::{CliError, ErrorKind};
21
22const DEFAULT_HTTP_UA: &str = concat!(
24 "browser-automation-cli/",
25 env!("CARGO_PKG_VERSION"),
26 " (+https://github.com/danilo-aguiar-br/browser-automation-cli; local-scrape)"
27);
28
29pub fn shared_http_client() -> Result<&'static reqwest::Client, CliError> {
36 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
37 if let Some(c) = CLIENT.get() {
38 return Ok(c);
39 }
40 let built = reqwest::Client::builder()
41 .user_agent(DEFAULT_HTTP_UA)
42 .timeout(Duration::from_secs(30))
43 .redirect(reqwest::redirect::Policy::limited(10))
44 .pool_max_idle_per_host(4)
45 .tcp_nodelay(true)
49 .build()
50 .map_err(|e| CliError::new(ErrorKind::Software, format!("http client: {e}")))?;
51 Ok(CLIENT.get_or_init(|| built))
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum RobotsPolicy {
57 Honor,
59 Ignore,
61}
62
63impl RobotsPolicy {
64 pub fn as_str(self) -> &'static str {
66 match self {
67 Self::Honor => "honor",
68 Self::Ignore => "ignore",
69 }
70 }
71
72 pub fn from_flags(ignore_robots: bool, accept_risk: bool) -> Result<Self, CliError> {
74 match (ignore_robots, accept_risk) {
75 (false, false) => Ok(Self::Honor),
76 (true, true) => Ok(Self::Ignore),
77 (true, false) => Err(CliError::with_suggestion(
78 ErrorKind::Usage,
79 "--ignore-robots requires --i-accept-robots-risk",
81 "Pass both flags together when you intentionally skip robots.txt",
82 )),
83 (false, true) => Err(CliError::with_suggestion(
84 ErrorKind::Usage,
85 "--i-accept-robots-risk requires --ignore-robots",
86 "Pass both flags together when you intentionally skip robots.txt",
87 )),
88 }
89 }
90}
91
92pub fn path_allowed(path: &str, disallows: &[&str]) -> bool {
95 if disallows.is_empty() {
96 return true;
97 }
98 let path = if path.is_empty() { "/" } else { path };
99 !disallows.iter().any(|d| {
100 if d.is_empty() {
101 return false;
102 }
103 path.starts_with(d)
104 })
105}
106
107pub fn scheme_skips_robots(url: &str) -> bool {
109 let lower = url.trim().to_ascii_lowercase();
110 lower.starts_with("about:")
111 || lower.starts_with("file:")
112 || lower.starts_with("data:")
113 || lower.starts_with("blob:")
114 || lower == "about:blank"
115}
116
117pub fn url_allowed_by_robots_body(robots_body: &str, user_agent: &str, url: &str) -> bool {
119 let mut matcher = DefaultMatcher::default();
120 matcher.one_agent_allowed_by_robots(robots_body, user_agent, url)
121}
122
123pub async fn enforce_robots(
125 url: &str,
126 policy: RobotsPolicy,
127 user_agent: &str,
128) -> Result<(), CliError> {
129 if matches!(policy, RobotsPolicy::Ignore) || scheme_skips_robots(url) {
130 return Ok(());
131 }
132
133 let parsed = Url::parse(url).map_err(|e| {
134 CliError::with_suggestion(
135 ErrorKind::Usage,
136 format!("invalid URL for robots check: {e}"),
137 "Pass an absolute http(s) URL or about:blank/file://",
138 )
139 })?;
140
141 if parsed.scheme() != "http" && parsed.scheme() != "https" {
142 return Ok(());
143 }
144
145 let origin = parsed.origin().ascii_serialization();
146 let robots_url = format!("{origin}/robots.txt");
147
148 let client = shared_http_client()?;
150 let resp = match client
151 .get(&robots_url)
152 .header(reqwest::header::USER_AGENT, user_agent)
153 .timeout(Duration::from_secs(5))
154 .send()
155 .await
156 {
157 Ok(r) => r,
158 Err(e) => {
159 tracing::warn!(
161 target: "browser_automation_cli::robots",
162 robots_url = %robots_url,
163 error = %e,
164 "robots fetch failed; treating as allow"
165 );
166 return Ok(());
167 }
168 };
169
170 if !resp.status().is_success() {
171 return Ok(());
173 }
174
175 let body = resp
176 .text()
177 .await
178 .map_err(|e| CliError::new(ErrorKind::Io, format!("robots body: {e}")))?;
179
180 if url_allowed_by_robots_body(&body, user_agent, url) {
181 return Ok(());
182 }
183
184 Err(CliError::with_suggestion(
185 ErrorKind::Data,
186 format!("robots.txt disallows URL: {url}"),
187 "Use --ignore-robots --i-accept-robots-risk only when you accept the risk",
188 ))
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn honor_disallow_prefix() {
197 assert!(!path_allowed("/private/x", &["/private"]));
198 assert!(path_allowed("/public/x", &["/private"]));
199 assert!(path_allowed("/any", &[]));
200 }
201
202 #[test]
203 fn scheme_skips_local() {
204 assert!(scheme_skips_robots("about:blank"));
205 assert!(scheme_skips_robots("file:///tmp/x.html"));
206 assert!(!scheme_skips_robots("https://example.com/"));
207 }
208
209 #[test]
210 fn policy_requires_both_flags() {
211 assert!(matches!(
212 RobotsPolicy::from_flags(false, false).unwrap(),
213 RobotsPolicy::Honor
214 ));
215 assert!(matches!(
216 RobotsPolicy::from_flags(true, true).unwrap(),
217 RobotsPolicy::Ignore
218 ));
219 assert!(RobotsPolicy::from_flags(true, false).is_err());
220 assert!(RobotsPolicy::from_flags(false, true).is_err());
221 }
222
223 #[test]
224 fn default_matcher_blocks_disallow_all() {
225 let body = "user-agent: *\ndisallow: /\n";
226 assert!(!url_allowed_by_robots_body(
227 body,
228 "browser-automation-cli",
229 "https://example.com/secret"
230 ));
231 }
232
233 #[test]
234 fn default_matcher_allows_empty() {
235 assert!(url_allowed_by_robots_body(
236 "",
237 "browser-automation-cli",
238 "https://example.com/"
239 ));
240 }
241}