use crate::spiders::links::LinkExtractor;
use crate::spiders::request::SpiderRequest;
use crate::spiders::response::SpiderResponse;
use crate::spiders::spider::Spider;
use crate::spiders::templates::crawler::{follow_rules, CrawlRule};
use async_trait::async_trait;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
pub struct SiteToMarkdownSpider {
name: String,
start_urls: Vec<String>,
rules: Vec<CrawlRule>,
css_selector: Option<String>,
main_content_only: bool,
output_dir: Option<PathBuf>,
max_pages: usize,
allowed_domains: HashSet<String>,
concurrent_requests: u32,
development_mode: bool,
robots_txt_obey: bool,
page_count: AtomicUsize,
used_names: Mutex<HashSet<String>>,
}
impl SiteToMarkdownSpider {
pub fn builder(name: &str) -> SiteToMarkdownSpiderBuilder {
SiteToMarkdownSpiderBuilder::new(name)
}
fn filename_for(&self, url: &str) -> String {
let base = match url::Url::parse(url) {
Ok(u) => format!("{}{}", u.host_str().unwrap_or_default(), u.path()),
Err(_) => url.to_string(),
};
let mut name = String::new();
let mut last_dash = true; for c in base.trim_matches('/').chars() {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
name.push(c);
last_dash = false;
} else if !last_dash {
name.push('-');
last_dash = true;
}
}
let mut name = name.trim_matches('-').to_string();
name.truncate(150);
if name.is_empty() {
name = "index".to_string();
}
let mut used = self.used_names.lock().expect("filename lock poisoned");
if used.contains(&name) {
use sha2::{Digest, Sha256};
let digest = crate::core::hash::hex(&Sha256::digest(url.as_bytes()));
name = format!("{name}-{}", &digest[..8]);
}
used.insert(name.clone());
name
}
}
#[async_trait]
impl Spider for SiteToMarkdownSpider {
fn name(&self) -> &str {
&self.name
}
fn start_urls(&self) -> Vec<String> {
self.start_urls.clone()
}
fn allowed_domains(&self) -> HashSet<String> {
self.allowed_domains.clone()
}
fn concurrent_requests(&self) -> u32 {
self.concurrent_requests
}
fn development_mode(&self) -> bool {
self.development_mode
}
fn robots_txt_obey(&self) -> bool {
self.robots_txt_obey
}
async fn parse(
&self,
response: SpiderResponse,
) -> (Vec<serde_json::Value>, Vec<SpiderRequest>) {
if self.max_pages > 0 {
let mut count = self.page_count.load(Ordering::SeqCst);
loop {
if count >= self.max_pages {
return (vec![], vec![]);
}
match self.page_count.compare_exchange(
count,
count + 1,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => break,
Err(actual) => count = actual,
}
}
} else {
self.page_count.fetch_add(1, Ordering::SeqCst);
}
let title = response
.css_get("title::text")
.map(|t| t.as_str().trim().to_string())
.unwrap_or_default();
let markdown = response.markdown(self.css_selector.as_deref(), self.main_content_only);
if let Some(dir) = &self.output_dir {
let file = dir.join(format!("{}.md", self.filename_for(response.url())));
let write = async {
tokio::fs::create_dir_all(dir).await?;
tokio::fs::write(&file, &markdown).await
};
if let Err(e) = write.await {
log::warn!("Failed to write Markdown file {}: {e}", file.display());
}
}
let item = serde_json::json!({
"url": response.url(),
"title": title,
"markdown": markdown,
});
let under_cap =
self.max_pages == 0 || self.page_count.load(Ordering::SeqCst) < self.max_pages;
let requests = if under_cap {
follow_rules(&self.rules, &response)
} else {
vec![]
};
(vec![item], requests)
}
}
#[must_use = "a builder does nothing until `.build()` is called"]
pub struct SiteToMarkdownSpiderBuilder {
spider: SiteToMarkdownSpider,
rules_set: bool,
}
impl SiteToMarkdownSpiderBuilder {
fn new(name: &str) -> Self {
Self {
spider: SiteToMarkdownSpider {
name: name.to_string(),
start_urls: Vec::new(),
rules: vec![CrawlRule::new(LinkExtractor::new())],
css_selector: None,
main_content_only: true,
output_dir: None,
max_pages: 0,
allowed_domains: HashSet::new(),
concurrent_requests: 4,
development_mode: false,
robots_txt_obey: false,
page_count: AtomicUsize::new(0),
used_names: Mutex::new(HashSet::new()),
},
rules_set: false,
}
}
pub fn start_url(mut self, url: &str) -> Self {
self.spider.start_urls.push(url.to_string());
self
}
pub fn start_urls(mut self, urls: impl IntoIterator<Item = String>) -> Self {
self.spider.start_urls.extend(urls);
self
}
pub fn allowed_domains(mut self, domains: impl IntoIterator<Item = String>) -> Self {
self.spider.allowed_domains.extend(domains);
self
}
pub fn rules(mut self, rules: impl IntoIterator<Item = CrawlRule>) -> Self {
if !self.rules_set {
self.spider.rules.clear();
self.rules_set = true;
}
self.spider.rules.extend(rules);
self
}
pub fn css_selector(mut self, selector: &str) -> Self {
self.spider.css_selector = Some(selector.to_string());
self
}
pub fn main_content_only(mut self, on: bool) -> Self {
self.spider.main_content_only = on;
self
}
pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.spider.output_dir = Some(dir.into());
self
}
pub fn max_pages(mut self, n: usize) -> Self {
self.spider.max_pages = n;
self
}
pub fn concurrent_requests(mut self, n: u32) -> Self {
self.spider.concurrent_requests = n;
self
}
pub fn development_mode(mut self, on: bool) -> Self {
self.spider.development_mode = on;
self
}
pub fn robots_txt_obey(mut self, on: bool) -> Self {
self.spider.robots_txt_obey = on;
self
}
pub fn build(self) -> SiteToMarkdownSpider {
assert!(
!self.spider.allowed_domains.is_empty(),
"SiteToMarkdownSpider requires `allowed_domains` to bound the crawl to the target website(s)"
);
self.spider
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spider(max_pages: usize) -> SiteToMarkdownSpider {
SiteToMarkdownSpider::builder("test")
.start_url("https://example.com/")
.allowed_domains(["example.com".to_string()])
.max_pages(max_pages)
.build()
}
#[test]
fn filenames_are_sanitized_unique_and_bounded() {
let s = spider(0);
assert_eq!(
s.filename_for("https://example.com/docs/intro?x=1"),
"example.com-docs-intro"
);
let second = s.filename_for("https://example.com/docs/intro/");
assert!(second.starts_with("example.com-docs-intro-"));
assert_eq!(second.len(), "example.com-docs-intro".len() + 9);
assert_eq!(s.filename_for("https://example.com/"), "example.com");
let long = format!("https://example.com/{}", "a".repeat(400));
assert!(s.filename_for(&long).len() <= 150);
}
#[test]
#[should_panic(expected = "allowed_domains")]
fn build_without_allowed_domains_panics() {
let _ = SiteToMarkdownSpider::builder("bad")
.start_url("https://example.com/")
.build();
}
}