#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
use url::Url;
pub mod advanced_features;
pub mod ai_analyzers;
pub mod ai_bots;
pub mod analyzers;
pub mod audit;
pub mod backlink_adapters;
pub mod backlinks;
pub mod backpressure;
pub mod circuit_breaker;
pub mod compare;
pub mod determinism;
pub mod dns;
pub mod encryption;
pub mod enterprise;
pub mod export;
pub mod feature_flags;
pub mod http;
pub mod js_render_decision;
pub mod observability;
pub mod playwright;
pub mod plugin;
pub use plugin::{PluginError, PluginManifest, PluginMetadata, PluginRegistry, WasmPlugin};
pub mod queue;
pub mod ratelimit;
pub mod resource_monitor;
pub mod robots;
pub mod rum;
pub mod sitemap;
pub mod storage;
pub mod wasm_analyzers;
pub use ai_analyzers::{
AiAnswerBoxAnalyzer, AiCitationEligibilityAnalyzer, AiContentStructureAnalyzer,
AiCrawlerAccessibilityAnalyzer,
};
pub use ai_bots::{AiBot, AiBotRegistry};
pub use analyzers::{
AccessibilityAnalyzer, AnalysisContext, Analyzer, AnalyzerRegistry, CanonicalUrlValidator,
ContentQualityAnalyzer, EcommerceSignalsAnalyzer, EnhancedReadabilityAnalyzer, EntityAnalyzer,
Finding, HeadingHierarchyAnalyzer, HreflangValidator, HttpStatusAnalyzer, ImageAnalyzer,
ImageInfo, InternationalSeoAnalyzer, KeywordAnalyzer, LinkAnalyzer, LinkInfo, MetaTagAnalyzer,
MobileFriendlinessChecker, RedirectChainAnalyzer, RobotsRule, RobotsTxtAnalyzer,
SecurityHeaderAnalyzer, SitemapAnalyzer, SitemapEntry, SocialMediaAnalyzer, SslCertificateInfo,
SslCertificateValidator, StructuredDataValidator, WordCountAnalyzer,
};
pub use audit::{AuditEvent, AuditEventType, AuditTrail};
pub use backlink_adapters::{
AdapterError, AhrefsAdapter, BacklinkAdapter, BacklinkAdapterRegistry, ExternalBacklink,
GscAdapter, MajesticAdapter,
};
pub use backlinks::{Backlink, BacklinkAnalyzer, BacklinkReport, BacklinkSummary, PageScore};
pub use backpressure::{BackpressureController, BackpressureError, BoundedPipeline};
pub use circuit_breaker::{
CircuitBreaker, CircuitBreakerConfig, CircuitBreakerRegistry, CircuitState,
};
pub use determinism::DeterminismController;
pub use dns::{DnsCache, DnsError, DnsPrefetcher};
pub use encryption::{EncryptionConfig, EncryptionError, EncryptionManager};
pub use feature_flags::{
FeatureFlags, SharedFeatureFlags, FLAG_AI_ANALYZERS, FLAG_JS_RENDERING, FLAG_WASM_ANALYZERS,
};
pub use http::{FetchStreamReader, HttpClient, HttpClientConfig};
pub use js_render_decision::{JsRenderDecision, JsRenderDecisionEngine, SpaIndicators};
pub use observability::{Metrics, MetricsSnapshot, SharedMetrics};
pub use playwright::{
BrowserContext, BrowserType, ConsoleMessage, NetworkRequest, PlaywrightConfig,
PlaywrightDetector, PlaywrightError, PlaywrightRenderer, RenderedPage,
WasmError as PlaywrightWasmError,
};
pub use resource_monitor::{ResourceLimits, ResourceMonitor, ResourceUsage};
pub use robots::RobotsTxtCache;
pub use rum::{
CruxAdapter, CruxData, FieldMetrics, GoogleAnalyticsAdapter, LabMetrics, MergedMetrics,
MetricDeltas, RumDataPoint, RumError,
};
pub use sitemap::SitemapCache;
pub use storage::{
CacheStats, CrawlStats, Issue, IssueCategory, IssueFilter, Severity, StorageError,
};
pub use wasm_analyzers::{WasmPatternAnalyzer, WasmPerformanceAnalyzer, WasmRuntimeAnalyzer};
pub mod meta;
pub mod parser;
pub use meta::{HreflangTag, MetaTags, OpenGraphTags, TwitterTags};
pub use parser::{
ExtractedForm, ExtractedImage, ExtractedInput, ExtractedLink, Heading, HtmlParser, ParseError,
ParsedPage, ScriptInfo, StructuredData, StyleInfo,
};
mod duration_ms {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
duration.as_millis().serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let ms = u64::deserialize(deserializer)?;
Ok(Duration::from_millis(ms))
}
}
mod opt_duration_ms {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
duration.map(|d| d.as_millis()).serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
where
D: Deserializer<'de>,
{
let ms = Option::<u64>::deserialize(deserializer)?;
Ok(ms.map(Duration::from_millis))
}
}
#[derive(Debug, Error)]
pub enum CrawlError {
#[error("invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("too many redirects ({0})")]
TooManyRedirects(usize),
#[error("blocked by robots.txt: {0}")]
BlockedByRobotsTxt(String),
#[error("out of scope: {0}")]
OutOfScope(String),
#[error("storage error: {0}")]
Storage(String),
#[error("max retries exceeded after {0} attempts")]
MaxRetriesExceeded(usize),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrawlConfig {
pub start_url: Url,
pub max_pages: usize,
#[serde(default, with = "opt_duration_ms")]
pub max_time: Option<Duration>,
pub max_depth: Option<usize>,
#[serde(with = "duration_ms")]
pub request_delay: Duration,
pub concurrency: usize,
#[serde(with = "duration_ms")]
pub request_timeout: Duration,
pub user_agent: String,
pub max_redirects: usize,
pub respect_robots_txt: bool,
pub allowed_patterns: Vec<String>,
pub disallowed_patterns: Vec<String>,
}
impl Default for CrawlConfig {
fn default() -> Self {
Self {
start_url: Url::parse("https://example.com")
.unwrap_or_else(|_| unreachable!("static URL string is always valid")),
max_pages: 100,
max_time: None,
max_depth: None,
request_delay: Duration::from_millis(500),
concurrency: 4,
request_timeout: Duration::from_secs(30),
user_agent: format!("crawlkit/{}", env!("CARGO_PKG_VERSION")),
max_redirects: 20,
respect_robots_txt: true,
allowed_patterns: Vec::new(),
disallowed_patterns: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlEntry {
pub url: Url,
pub canonical_url: Url,
pub referrer: Option<Url>,
pub depth: usize,
pub discovered_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FetchResult {
pub final_url: Url,
pub status_code: u16,
pub headers: Vec<(String, String)>,
pub body: String,
#[serde(with = "duration_ms")]
pub response_time: Duration,
pub body_size: usize,
pub fetched_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedirectHop {
pub from: Url,
pub to: Url,
pub status_code: u16,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_crawl_config_default() {
let config = CrawlConfig::default();
assert_eq!(config.max_pages, 100);
assert_eq!(config.concurrency, 4);
assert_eq!(config.max_redirects, 20);
assert!(config.respect_robots_txt);
}
#[test]
fn test_crawl_config_serialization() {
let config = CrawlConfig::default();
let json = serde_json::to_string(&config).unwrap();
let deserialized: CrawlConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.max_pages, deserialized.max_pages);
assert_eq!(config.request_delay, deserialized.request_delay);
assert_eq!(config.request_timeout, deserialized.request_timeout);
}
#[test]
fn test_url_entry_serialization() {
let entry = UrlEntry {
url: Url::parse("https://example.com").unwrap(),
canonical_url: Url::parse("https://example.com").unwrap(),
referrer: None,
depth: 0,
discovered_at: Utc::now(),
};
let json = serde_json::to_string(&entry).unwrap();
let deserialized: UrlEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry.url, deserialized.url);
assert_eq!(entry.depth, deserialized.depth);
}
#[test]
fn test_redirect_hops() {
let hops = [
RedirectHop {
from: Url::parse("https://example.com/old").unwrap(),
to: Url::parse("https://example.com/mid").unwrap(),
status_code: 301,
},
RedirectHop {
from: Url::parse("https://example.com/mid").unwrap(),
to: Url::parse("https://example.com/new").unwrap(),
status_code: 302,
},
];
assert_eq!(hops.len(), 2);
assert_eq!(hops[0].status_code, 301);
assert_eq!(hops[1].status_code, 302);
}
#[test]
fn test_fetch_result_serialization() {
let result = FetchResult {
final_url: Url::parse("https://example.com").unwrap(),
status_code: 200,
headers: vec![("content-type".into(), "text/html".into())],
body: "<html></html>".into(),
response_time: Duration::from_millis(123),
body_size: 14,
fetched_at: Utc::now(),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("123"));
let deserialized: FetchResult = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.response_time, Duration::from_millis(123));
}
}