1use std::collections::{HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use url::Url;
6use walkdir::WalkDir;
7
8use crate::extract::parse_html;
9use crate::page::HtmlPage;
10use crate::robots::{disallowed_paths, is_disallowed};
11use crate::sitemap::urls_from_sitemap_xml;
12
13#[derive(Debug, Clone, Default)]
14pub struct LoadOptions {
15 pub base_url: Option<String>,
16 pub crawl: Option<CrawlOptions>,
18}
19
20#[derive(Debug, Clone)]
21pub struct CrawlOptions {
22 pub max_pages: usize,
23 pub max_depth: usize,
24 pub respect_robots: bool,
25 pub use_sitemap: bool,
26}
27
28pub async fn load_pages(target: &str, options: &LoadOptions) -> Result<Vec<HtmlPage>> {
29 if target.starts_with("http://") || target.starts_with("https://") {
30 if let Some(crawl) = &options.crawl {
31 return load_url_crawl(target, crawl).await;
32 }
33 load_url(target).await.map(|p| vec![p])
34 } else {
35 let path = Path::new(target);
36 if path.is_file() {
37 load_file(path).map(|p| vec![p])
38 } else if path.is_dir() {
39 load_directory(path, options)
40 } else {
41 anyhow::bail!("target not found: {target}");
42 }
43 }
44}
45
46fn load_file(path: &Path) -> Result<HtmlPage> {
47 let source = std::fs::read_to_string(path)
48 .with_context(|| format!("failed to read {}", path.display()))?;
49 let url_or_path = path.to_string_lossy().to_string();
50 Ok(parse_html(&url_or_path, &source, true))
51}
52
53fn load_directory(dir: &Path, options: &LoadOptions) -> Result<Vec<HtmlPage>> {
54 let mut pages = Vec::new();
55 for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
56 let path = entry.path();
57 if path.is_file() && is_html_file(path) {
58 let mut page = load_file(path)?;
59 if let Some(base) = &options.base_url {
60 page.url_or_path = file_to_url(path, dir, base);
61 }
62 pages.push(page);
63 }
64 }
65 pages.sort_by(|a, b| a.url_or_path.cmp(&b.url_or_path));
66 Ok(pages)
67}
68
69async fn load_url(url: &str) -> Result<HtmlPage> {
70 let (source, normalized) = fetch_url(url).await?;
71 Ok(parse_html(&normalized, &source, false))
72}
73
74async fn fetch_url(url: &str) -> Result<(String, String)> {
75 let normalized_url = normalize_url(url).unwrap_or_else(|_| url.to_string());
76 let parsed = Url::parse(&normalized_url).context("invalid URL")?;
77 let client = http_client()?;
78 let response = client
79 .get(parsed.clone())
80 .send()
81 .await
82 .context("HTTP request failed")?;
83 let source = response
84 .text()
85 .await
86 .context("failed to read response body")?;
87 Ok((source, normalized_url))
88}
89
90async fn load_url_crawl(seed: &str, crawl: &CrawlOptions) -> Result<Vec<HtmlPage>> {
91 let seed_url = normalize_url(seed).unwrap_or_else(|_| seed.to_string());
92 let base = Url::parse(&seed_url).context("invalid seed URL")?;
93 let client = http_client()?;
94
95 let mut disallowed = Vec::new();
96 if crawl.respect_robots {
97 let robots_url = base.join("/robots.txt").unwrap_or_else(|_| base.clone());
98 if let Ok(resp) = client.get(robots_url.clone()).send().await {
99 if let Ok(body) = resp.text().await {
100 disallowed = disallowed_paths(&body);
101 }
102 }
103 }
104
105 let mut queue: VecDeque<(String, u32)> = VecDeque::new();
106 let mut seeds = vec![seed_url.clone()];
107
108 if crawl.use_sitemap {
109 let sitemap_url = base.join("/sitemap.xml").unwrap_or_else(|_| base.clone());
110 if let Ok(resp) = client.get(sitemap_url).send().await {
111 if let Ok(xml) = resp.text().await {
112 if let Ok(mut from_map) = urls_from_sitemap_xml(&xml, &base) {
113 seeds.append(&mut from_map);
114 }
115 }
116 }
117 }
118
119 for s in seeds {
120 queue.push_back((s, 0));
121 }
122
123 let mut seen = HashSet::new();
124 let mut pages = Vec::new();
125
126 while let Some((url, depth)) = queue.pop_front() {
127 if pages.len() >= crawl.max_pages {
128 break;
129 }
130 if !seen.insert(url.clone()) {
131 continue;
132 }
133 let parsed = match Url::parse(&url) {
134 Ok(u) => u,
135 Err(_) => continue,
136 };
137 if parsed.origin() != base.origin() {
138 continue;
139 }
140 let path = parsed.path();
141 if crawl.respect_robots && is_disallowed(path, &disallowed) {
142 continue;
143 }
144
145 let (source, normalized) = match fetch_url(&url).await {
146 Ok(v) => v,
147 Err(_) => continue,
148 };
149 let page = parse_html(&normalized, &source, false);
150 if depth < crawl.max_depth as u32 {
151 for link in &page.links {
152 if link.is_internal || same_origin(&base, &link.href) {
153 if let Some(next) = resolve_crawl_link(&base, &url, &link.href) {
154 queue.push_back((next, depth + 1));
155 }
156 }
157 }
158 }
159 pages.push(page);
160 }
161
162 pages.sort_by(|a, b| a.url_or_path.cmp(&b.url_or_path));
163 Ok(pages)
164}
165
166fn same_origin(base: &Url, href: &str) -> bool {
167 if href.starts_with("http://") || href.starts_with("https://") {
168 Url::parse(href)
169 .map(|u| u.origin() == base.origin())
170 .unwrap_or(false)
171 } else {
172 !href.starts_with('#') && !href.starts_with("mailto:") && !href.starts_with("tel:")
173 }
174}
175
176fn resolve_crawl_link(base: &Url, current: &str, href: &str) -> Option<String> {
177 if href.starts_with('#') || href.is_empty() {
178 return None;
179 }
180 let current_url = Url::parse(current).ok()?;
181 let joined = current_url.join(href).ok()?;
182 if joined.origin() != base.origin() {
183 return None;
184 }
185 let path = joined.path().to_lowercase();
186 if path.ends_with(".html")
187 || path.ends_with(".htm")
188 || path.ends_with('/')
189 || !path.contains('.')
190 {
191 Some(joined.to_string())
192 } else {
193 None
194 }
195}
196
197fn http_client() -> Result<reqwest::Client> {
198 reqwest::Client::builder()
199 .user_agent("oratos/0.2.0 (+https://github.com/latentmeta/oratos)")
200 .build()
201 .context("failed to build HTTP client")
202}
203
204fn is_html_file(path: &Path) -> bool {
205 path.extension()
206 .and_then(|e| e.to_str())
207 .is_some_and(|ext| matches!(ext.to_lowercase().as_str(), "html" | "htm"))
208}
209
210fn file_to_url(path: &Path, base_dir: &Path, base_url: &str) -> String {
211 let relative = path
212 .strip_prefix(base_dir)
213 .unwrap_or(path)
214 .to_string_lossy()
215 .replace('\\', "/");
216 let base = base_url.trim_end_matches('/');
217 format!("{base}/{relative}")
218}
219
220pub fn resolve_internal_path(page_dir: &Path, href: &str) -> Option<PathBuf> {
221 if href.starts_with("http://") || href.starts_with("https://") || href.starts_with('#') {
222 return None;
223 }
224 let joined = page_dir.join(href.trim_start_matches('/'));
225 if joined.exists() {
226 Some(joined.canonicalize().unwrap_or(joined))
227 } else {
228 None
229 }
230}
231
232pub fn normalize_url(url: &str) -> Result<String> {
233 let parsed = Url::parse(url)?;
234 let mut normalized = parsed.clone();
235 normalized.set_fragment(None);
236 Ok(normalized.to_string())
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn normalizes_url_fragments() {
245 let n = normalize_url("https://example.com/page#section").unwrap();
246 assert_eq!(n, "https://example.com/page");
247 }
248}