use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use md5::{Digest, Md5};
use regex::Regex;
use crate::content_processor::manager::ContentProcessorManager;
use crate::engine::parsed_url::ParsedUrl;
use crate::error::{CrawlerError, CrawlerResult};
use crate::export::exporter::Exporter;
use crate::export::utils::offline_url_converter::OfflineUrlConverter;
use crate::export::utils::target_domain_relation::TargetDomainRelation;
use crate::output::output::Output;
use crate::result::status::Status;
use crate::result::visited_url::VisitedUrl;
use crate::types::ContentTypeId;
use crate::utils;
const CONTENT_TYPES_REQUIRING_CHANGES: &[ContentTypeId] = &[
ContentTypeId::Html,
ContentTypeId::Script,
ContentTypeId::Stylesheet,
ContentTypeId::Redirect,
];
pub struct OfflineWebsiteExporter {
offline_export_directory: Option<String>,
offline_export_store_only_url_regex: Vec<String>,
offline_export_remove_unwanted_code: bool,
offline_export_no_auto_redirect_html: bool,
offline_export_preserve_url_structure: bool,
offline_export_lowercase: bool,
ignore_store_file_error: bool,
replace_content: Vec<String>,
replace_query_string: Vec<String>,
initial_parsed_url: Option<ParsedUrl>,
content_processor_manager: Option<Arc<Mutex<ContentProcessorManager>>>,
#[allow(clippy::type_complexity)]
is_domain_allowed_for_static_files: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
#[allow(clippy::type_complexity)]
is_external_domain_allowed_for_crawling: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
exported_file_paths: HashMap<String, String>,
}
impl Default for OfflineWebsiteExporter {
fn default() -> Self {
Self::new()
}
}
impl OfflineWebsiteExporter {
pub fn new() -> Self {
Self {
offline_export_directory: None,
offline_export_store_only_url_regex: Vec::new(),
offline_export_remove_unwanted_code: false,
offline_export_no_auto_redirect_html: false,
offline_export_preserve_url_structure: false,
offline_export_lowercase: false,
ignore_store_file_error: false,
replace_content: Vec::new(),
replace_query_string: Vec::new(),
initial_parsed_url: None,
content_processor_manager: None,
is_domain_allowed_for_static_files: None,
is_external_domain_allowed_for_crawling: None,
exported_file_paths: HashMap::new(),
}
}
pub fn set_offline_export_directory(&mut self, dir: Option<String>) {
self.offline_export_directory = dir.map(|d| d.trim_end_matches('/').to_string());
}
pub fn set_offline_export_store_only_url_regex(&mut self, regexes: Vec<String>) {
self.offline_export_store_only_url_regex = regexes;
}
pub fn set_offline_export_remove_unwanted_code(&mut self, remove: bool) {
self.offline_export_remove_unwanted_code = remove;
}
pub fn set_offline_export_no_auto_redirect_html(&mut self, disable: bool) {
self.offline_export_no_auto_redirect_html = disable;
}
pub fn set_offline_export_preserve_url_structure(&mut self, preserve: bool) {
self.offline_export_preserve_url_structure = preserve;
}
pub fn set_offline_export_lowercase(&mut self, lowercase: bool) {
self.offline_export_lowercase = lowercase;
}
pub fn set_ignore_store_file_error(&mut self, ignore: bool) {
self.ignore_store_file_error = ignore;
}
pub fn set_replace_content(&mut self, replacements: Vec<String>) {
self.replace_content = replacements;
}
pub fn set_replace_query_string(&mut self, replacements: Vec<String>) {
self.replace_query_string = replacements;
}
pub fn set_initial_parsed_url(&mut self, url: ParsedUrl) {
self.initial_parsed_url = Some(url);
}
pub fn set_content_processor_manager(&mut self, cpm: Arc<Mutex<ContentProcessorManager>>) {
self.content_processor_manager = Some(cpm);
}
pub fn set_domain_callbacks(
&mut self,
static_files: Box<dyn Fn(&str) -> bool + Send + Sync>,
crawling: Box<dyn Fn(&str) -> bool + Send + Sync>,
) {
self.is_domain_allowed_for_static_files = Some(static_files);
self.is_external_domain_allowed_for_crawling = Some(crawling);
}
pub fn get_exported_file_paths(&self) -> &HashMap<String, String> {
&self.exported_file_paths
}
fn store_file(&mut self, visited_url: &VisitedUrl, status: &Status, _output: &dyn Output) -> CrawlerResult<()> {
let export_dir = self
.offline_export_directory
.as_ref()
.ok_or_else(|| CrawlerError::Export("Offline export directory not set".to_string()))?;
let body_bytes = status.get_url_body(&visited_url.uq_id).unwrap_or_default();
let final_bytes =
if !body_bytes.is_empty() && CONTENT_TYPES_REQUIRING_CHANGES.contains(&visited_url.content_type) {
let mut content = String::from_utf8_lossy(&body_bytes).into_owned();
if let Some(ref cpm) = self.content_processor_manager {
let parsed_url = ParsedUrl::parse(&visited_url.url, None);
if let Ok(mut manager) = cpm.lock() {
let original_content = content.clone();
let content_loader = |url_str: &str| -> Option<String> {
let parsed = ParsedUrl::parse(url_str, None);
let full_url = parsed.get_full_url(true, false);
let mut hasher = Md5::new();
hasher.update(full_url.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let uq_id = hash[..8].to_string();
status.get_url_body_text(&uq_id)
};
manager.apply_content_changes_for_offline_version_with_loader(
&mut content,
visited_url.content_type,
&parsed_url,
self.offline_export_remove_unwanted_code,
&content_loader,
);
if content.is_empty() {
content = original_content;
}
}
}
if !self.replace_content.is_empty() {
for replace in &self.replace_content {
let parts: Vec<&str> = replace.splitn(2, "->").collect();
let replace_from = parts[0].trim();
let replace_to = if parts.len() > 1 { parts[1].trim() } else { "" };
let is_regex = crate::utils::is_regex_pattern(replace_from);
if is_regex {
if let Some(pattern) = extract_regex_pattern(replace_from)
&& let Ok(re) = Regex::new(&pattern)
{
content = re.replace_all(&content, replace_to).to_string();
}
} else {
content = content.replace(replace_from, replace_to);
}
}
}
content.into_bytes()
} else {
body_bytes
};
let relative_path = self.get_relative_file_path_for_file_by_url(visited_url, status);
let sanitized_path = OfflineUrlConverter::sanitize_file_path(&relative_path, false);
let sanitized_path = sanitized_path.replace("../", "").replace("..\\", "");
let store_file_path = format!("{}/{}", export_dir, sanitized_path);
let dir_path = Path::new(&store_file_path).parent().ok_or_else(|| {
CrawlerError::Export(format!("Cannot determine parent directory for '{}'", store_file_path))
})?;
if !dir_path.exists() {
fs::create_dir_all(dir_path).map_err(|e| {
CrawlerError::Export(format!("Cannot create directory '{}': {}", dir_path.display(), e))
})?;
}
let mut save_file = true;
if Path::new(&store_file_path).exists()
&& let Some(ref initial_url) = self.initial_parsed_url
&& !visited_url.is_https()
&& initial_url.is_https()
{
save_file = false;
let message = format!(
"File '{}' already exists and will not be overwritten because initial request was HTTPS and this request is HTTP: {}",
store_file_path, visited_url.url
);
status.add_notice_to_summary("offline-exporter-store-file-ignored", &message);
}
if save_file {
match fs::write(&store_file_path, &final_bytes) {
Ok(()) => {
self.exported_file_paths
.insert(visited_url.url.clone(), sanitized_path.clone());
}
Err(e) => {
let has_extension = Regex::new(r"(?i)\.[a-z0-9\-]{1,15}$")
.map(|re| re.is_match(&store_file_path))
.unwrap_or(false);
if has_extension && !self.ignore_store_file_error {
return Err(CrawlerError::Export(format!(
"Cannot store file '{}': {}",
store_file_path, e
)));
} else {
let message = format!(
"Cannot store file '{}' (undefined extension). Original URL: {}",
store_file_path, visited_url.url
);
status.add_notice_to_summary("offline-exporter-store-file-error", &message);
}
}
}
}
Ok(())
}
fn should_be_url_stored(&self, visited_url: &VisitedUrl) -> bool {
let mut result = false;
if !self.offline_export_store_only_url_regex.is_empty() {
for regex_str in &self.offline_export_store_only_url_regex {
let pattern = crate::utils::extract_pcre_regex_pattern(regex_str);
if let Ok(re) = Regex::new(&pattern)
&& re.is_match(&visited_url.url)
{
result = true;
break;
}
}
} else {
result = true;
}
if result && visited_url.is_external {
let parsed_url = ParsedUrl::parse(&visited_url.url, None);
if let Some(ref host) = parsed_url.host
&& let Some(ref cb) = self.is_external_domain_allowed_for_crawling
{
if cb(host) {
result = true;
} else if visited_url.is_static_file() || parsed_url.is_static_file() {
if let Some(ref static_cb) = self.is_domain_allowed_for_static_files {
result = static_cb(host);
} else {
result = false;
}
} else {
result = false;
}
}
}
result
}
fn get_relative_file_path_for_file_by_url(&self, visited_url: &VisitedUrl, status: &Status) -> String {
let initial_url = self
.initial_parsed_url
.clone()
.unwrap_or_else(|| ParsedUrl::parse(&visited_url.url, None));
let source_url = if !visited_url.source_uq_id.is_empty() {
status
.get_url_by_uq_id(&visited_url.source_uq_id)
.unwrap_or_else(|| visited_url.url.clone())
} else {
visited_url.url.clone()
};
let base_url = ParsedUrl::parse(&source_url, None);
let target_url = ParsedUrl::parse(&visited_url.url, None);
let attribute = if visited_url.content_type == ContentTypeId::Image {
"src"
} else {
"href"
};
let mut converter = OfflineUrlConverter::new(initial_url, base_url, target_url, None, None, Some(attribute));
converter.set_preserve_url_structure(self.offline_export_preserve_url_structure);
let relative_url = converter.convert_url_to_relative(false);
let relative_target_url = converter.get_relative_target_url();
let target_domain_relation = converter.get_target_domain_relation();
match target_domain_relation {
TargetDomainRelation::InitialDifferentBaseSame | TargetDomainRelation::InitialDifferentBaseDifferent => {
let relative_path = relative_url
.replace("../", "")
.trim_start_matches(['/', ' '])
.to_string();
let host = relative_target_url.host.as_deref().unwrap_or("");
if !relative_path.starts_with(&format!("_{}", host)) {
format!("_{}/{}", host, relative_path)
} else {
relative_path
}
}
TargetDomainRelation::InitialSameBaseSame | TargetDomainRelation::InitialSameBaseDifferent => relative_url
.replace("../", "")
.trim_start_matches(['/', ' '])
.to_string(),
}
}
fn is_valid_url(url: &str) -> bool {
if url::Url::parse(url).is_ok() {
return true;
}
let encoded: String = url
.chars()
.map(|c| {
if c.is_ascii() && c as u32 >= 0x20 && (c as u32) <= 0x7E {
c.to_string()
} else {
percent_encoding::utf8_percent_encode(&c.to_string(), percent_encoding::NON_ALPHANUMERIC)
.to_string()
}
})
.collect();
url::Url::parse(&encoded).is_ok()
}
fn add_redirect_html_to_subfolders(dir: &str) -> CrawlerResult<()> {
let dir_path = Path::new(dir);
if !dir_path.is_dir() {
return Ok(());
}
let entries = fs::read_dir(dir_path)
.map_err(|e| CrawlerError::Export(format!("Cannot read directory '{}': {}", dir, e)))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let index_html_path = path.join("index.html");
if index_html_path.exists() {
let redirect_html_path = format!("{}.html", path.display());
if !Path::new(&redirect_html_path).exists() {
let redirect_content = format!(
"<!DOCTYPE html><meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">",
dir_name
);
let _ = fs::write(&redirect_html_path, redirect_content);
}
}
Self::add_redirect_html_to_subfolders(&path.to_string_lossy())?;
}
}
Ok(())
}
}
impl Exporter for OfflineWebsiteExporter {
fn get_name(&self) -> &str {
"OfflineWebsiteExporter"
}
fn should_be_activated(&self) -> bool {
self.offline_export_directory.is_some()
}
fn export(&mut self, status: &Status, output: &dyn Output) -> CrawlerResult<()> {
let start_time = Instant::now();
let export_dir = match self.offline_export_directory {
Some(ref dir) => dir.clone(),
None => return Ok(()),
};
OfflineUrlConverter::set_replace_query_string(self.replace_query_string.clone());
OfflineUrlConverter::set_lowercase(self.offline_export_lowercase);
let visited_urls = status.get_visited_urls();
let exported_urls: Vec<&VisitedUrl> = visited_urls
.iter()
.filter(|u| matches!(u.status_code, 200 | 201 | 301 | 302 | 303 | 308))
.collect();
for exported_url in &exported_urls {
if Self::is_valid_url(&exported_url.url) && self.should_be_url_stored(exported_url) {
self.store_file(exported_url, status, output)?;
}
}
if !self.offline_export_no_auto_redirect_html {
let _ = Self::add_redirect_html_to_subfolders(&export_dir);
}
let duration = start_time.elapsed().as_secs_f64();
let formatted_path = utils::get_output_formatted_path(&export_dir);
let formatted_duration = utils::get_formatted_duration(duration);
status.add_info_to_summary(
"offline-website-generated",
&format!(
"Offline website generated to '{}' and took {}",
formatted_path, formatted_duration
),
);
Ok(())
}
}
fn extract_regex_pattern(input: &str) -> Option<String> {
if input.len() < 2 {
return None;
}
let delimiter = input.chars().next()?;
let rest = &input[1..];
if let Some(end_pos) = rest.rfind(delimiter) {
let pattern = &rest[..end_pos];
let flags = &rest[end_pos + 1..];
let mut regex_pattern = String::new();
if flags.contains('i') {
regex_pattern.push_str("(?i)");
}
regex_pattern.push_str(pattern);
Some(regex_pattern)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_valid_url() {
assert!(OfflineWebsiteExporter::is_valid_url("https://example.com/"));
assert!(OfflineWebsiteExporter::is_valid_url("https://example.com/path/page"));
assert!(!OfflineWebsiteExporter::is_valid_url("not-a-url"));
}
#[test]
fn test_should_be_activated() {
let mut exporter = OfflineWebsiteExporter::new();
assert!(!exporter.should_be_activated());
exporter.set_offline_export_directory(Some("/tmp/offline".to_string()));
assert!(exporter.should_be_activated());
}
}