Skip to main content

oratos_html/
load.rs

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    /// When set on URL targets, crawl same-origin HTML pages up to limits.
17    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}