use crate::parser::Selector;
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::{CrawlRule, ParseItemFn};
use async_trait::async_trait;
use std::collections::HashSet;
use url::Url;
#[derive(Debug, Default, PartialEq)]
pub struct SitemapResult {
pub urls: Vec<String>,
pub sitemaps: Vec<String>,
}
pub struct SitemapSpider {
name: String,
sitemap_urls: Vec<String>,
rules: Vec<CrawlRule>,
sitemap_follow: Option<LinkExtractor>,
sitemap_alternate_links: bool,
parse_item: Option<ParseItemFn>,
allowed_domains: HashSet<String>,
concurrent_requests: u32,
development_mode: bool,
robots_txt_obey: bool,
}
impl SitemapSpider {
pub fn builder(name: &str) -> SitemapSpiderBuilder {
SitemapSpiderBuilder::new(name)
}
fn robots_sitemaps(body: &str) -> Vec<String> {
body.lines()
.filter_map(|line| {
let line = line.split('#').next().unwrap_or("").trim();
let rest = line
.get(..8)
.filter(|p| p.eq_ignore_ascii_case("sitemap:"))
.map(|_| line[8..].trim())?;
(!rest.is_empty()).then(|| rest.to_string())
})
.collect()
}
pub fn parse_sitemap_body(&self, body: &str) -> SitemapResult {
let root = Selector::from_html(body);
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut index_locs: Vec<String> = Vec::new();
for el in root.css("sitemapindex loc") {
let text = el.text().to_string().trim().to_string();
if !text.is_empty() && seen.insert(text.clone()) {
index_locs.push(text);
}
}
if !index_locs.is_empty() {
return SitemapResult {
urls: Vec::new(),
sitemaps: index_locs,
};
}
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut urls: Vec<String> = Vec::new();
for el in root.css("urlset url *") {
let tag = el.tag().to_string();
if tag == "loc" {
let text = el.text().to_string().trim().to_string();
if !text.is_empty() && seen.insert(text.clone()) {
urls.push(text);
}
} else if self.sitemap_alternate_links && (tag == "link" || tag.ends_with(":link")) {
if let Some(href) = el.get_attribute("href") {
let href = href.to_string().trim().to_string();
if !href.is_empty() && seen.insert(href.clone()) {
urls.push(href);
}
}
}
}
if urls.is_empty() && root.css("urlset").into_iter().next().is_none() {
log::warn!("Response does not look like a sitemap (no <urlset> or <sitemapindex>)");
}
SitemapResult {
urls,
sitemaps: Vec::new(),
}
}
fn dispatch(&self, url: &str, response: &SpiderResponse) -> Option<SpiderRequest> {
if self.rules.is_empty() {
return Some(SpiderRequest::new(url));
}
self.rules
.iter()
.find(|rule| rule.link_extractor.matches(url))
.and_then(|rule| rule.build_request(url, response))
}
fn is_robots_txt(url: &str) -> bool {
Url::parse(url)
.map(|u| u.path().ends_with("/robots.txt"))
.unwrap_or(false)
}
fn looks_like_sitemap(body: &str) -> bool {
let mut rest = body.trim_start_matches('\u{feff}').trim_start();
loop {
if let Some(after) = rest.strip_prefix("<?") {
match after.find("?>") {
Some(i) => rest = after[i + 2..].trim_start(),
None => return false,
}
} else if let Some(after) = rest.strip_prefix("<!--") {
match after.find("-->") {
Some(i) => rest = after[i + 3..].trim_start(),
None => return false,
}
} else {
break;
}
}
let head: String = rest.chars().take(16).collect::<String>().to_lowercase();
head.starts_with("<urlset") || head.starts_with("<sitemapindex")
}
fn absolutize(base: &str, candidate: &str) -> Option<String> {
match Url::parse(candidate) {
Ok(u) => Some(u.to_string()),
Err(_) => match Url::parse(base).ok()?.join(candidate) {
Ok(u) => Some(u.to_string()),
Err(_) => {
log::debug!("Skipping unresolvable sitemap URL {candidate:?}");
None
}
},
}
}
}
#[async_trait]
impl Spider for SitemapSpider {
fn name(&self) -> &str {
&self.name
}
fn start_urls(&self) -> Vec<String> {
self.sitemap_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::is_robots_txt(response.url()) {
let sitemaps = Self::robots_sitemaps(response.text());
if sitemaps.is_empty() {
log::warn!("No Sitemaps found in {}", response.url());
}
let requests = sitemaps
.iter()
.filter_map(|u| Self::absolutize(response.url(), u))
.map(|u| SpiderRequest::new(&u))
.collect();
return (Vec::new(), requests);
}
if Self::looks_like_sitemap(response.text()) {
let result = self.parse_sitemap_body(response.text());
let mut requests: Vec<SpiderRequest> = Vec::new();
for child in &result.sitemaps {
let Some(child) = Self::absolutize(response.url(), child) else {
continue;
};
if let Some(filter) = &self.sitemap_follow {
if !filter.matches(&child) {
continue;
}
}
requests.push(SpiderRequest::new(&child));
}
for url in &result.urls {
let Some(url) = Self::absolutize(response.url(), url) else {
continue;
};
if let Some(req) = self.dispatch(&url, &response) {
requests.push(req);
}
}
return (Vec::new(), requests);
}
let items = self
.parse_item
.as_ref()
.map(|f| f(&response))
.unwrap_or_default();
(items, Vec::new())
}
}
#[must_use = "builders do nothing until `.build()` is called"]
pub struct SitemapSpiderBuilder {
spider: SitemapSpider,
}
impl SitemapSpiderBuilder {
fn new(name: &str) -> Self {
Self {
spider: SitemapSpider {
name: name.to_string(),
sitemap_urls: Vec::new(),
rules: Vec::new(),
sitemap_follow: None,
sitemap_alternate_links: false,
parse_item: None,
allowed_domains: HashSet::new(),
concurrent_requests: 4,
development_mode: false,
robots_txt_obey: false,
},
}
}
pub fn sitemap_urls<I, S>(mut self, urls: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.spider
.sitemap_urls
.extend(urls.into_iter().map(|u| u.as_ref().to_string()));
self
}
pub fn rule(mut self, rule: CrawlRule) -> Self {
self.spider.rules.push(rule);
self
}
pub fn rules<I>(mut self, rules: I) -> Self
where
I: IntoIterator<Item = CrawlRule>,
{
self.spider.rules.extend(rules);
self
}
pub fn sitemap_follow(mut self, extractor: LinkExtractor) -> Self {
self.spider.sitemap_follow = Some(extractor);
self
}
pub fn sitemap_alternate_links(mut self, on: bool) -> Self {
self.spider.sitemap_alternate_links = on;
self
}
pub fn parse_item(mut self, f: ParseItemFn) -> Self {
self.spider.parse_item = Some(f);
self
}
pub fn allowed_domains<I, S>(mut self, domains: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.spider
.allowed_domains
.extend(domains.into_iter().map(|d| d.as_ref().to_string()));
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
}
#[must_use]
pub fn build(self) -> SitemapSpider {
self.spider
}
}