crawlkit_engine/lib.rs
1//! # crawlkit-core
2//!
3//! Core library for crawlkit — a high-performance Rust web crawler for SEO analysis.
4//!
5//! ## Overview
6//!
7//! This crate provides the foundational types, HTTP fetching, HTML parsing,
8//! SEO analyzers, crawl queue, storage, and observability primitives used by
9//! the crawlkit CLI and API server.
10//!
11//! ## Features
12//!
13//! - **28 SEO analyzers** covering meta tags, content quality, security, accessibility
14//! - **Async HTTP/2** fetching with retry, redirect tracking, rate limiting
15//! - **HTML parsing** with link, heading, image, and structured data extraction
16//! - **SQLite storage** with WAL mode and batch operations
17//! - **Observability** with atomic metrics and OpenTelemetry support
18//! - **Plugin system** with WASM sandboxing for third-party extensions
19//! - **Encryption at rest** with AES-256-GCM
20//!
21//! ## Quick Start
22//!
23//! ```rust,no_run
24//! use crawlkit_engine::{CrawlConfig, HttpClient, HtmlParser};
25//! use crawlkit_engine::analyzers::AnalyzerRegistry;
26//!
27//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
28//! let config = CrawlConfig::default();
29//! let client = HttpClient::from_crawl_config(&config)?;
30//! let registry = AnalyzerRegistry::new(&config);
31//!
32//! let url = url::Url::parse("https://example.com")?;
33//! let result = client.fetch(&url).await?;
34//! let parsed = HtmlParser::parse(&result.body, &url)?;
35//! # Ok(())
36//! # }
37//! ```
38#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
39
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42use std::time::Duration;
43use thiserror::Error;
44use url::Url;
45
46/// Advanced crawl features such as JavaScript rendering and WASM analysis.
47///
48/// Provides [`AlertManager`](advanced_features::AlertManager) for monitoring
49/// crawl metrics and triggering notifications on threshold breaches.
50pub mod advanced_features;
51/// AI-powered page content analyzers for answer boxes, citations, and crawler accessibility.
52///
53/// These analyzers detect AI-specific SEO opportunities and issues, such as
54/// whether AI crawlers can access the site and whether content is structured
55/// for AI extraction.
56pub mod ai_analyzers;
57/// Registry of known AI bot user-agents and crawler identification.
58///
59/// Contains a static list of AI crawlers (GPTBot, ClaudeBot, etc.) with metadata
60/// for robots.txt analysis and AI accessibility scoring.
61pub mod ai_bots;
62/// SEO analysis engine with pluggable analyzers (title, meta, links, etc.).
63///
64/// Defines the [`Analyzer`](analyzers::Analyzer) trait and provides 28+ built-in analyzers
65/// covering HTTP status, redirects, canonical URLs, meta tags, headings, links,
66/// images, structured data, security, accessibility, and more.
67pub mod analyzers;
68/// Audit trail logging for crawl operations and configuration changes.
69///
70/// Provides a tamper-evident append-only log with SHA-256 chaining for
71/// compliance and security auditing.
72pub mod audit;
73/// Adapters for third-party backlink data sources (Ahrefs, GSC, Majestic).
74///
75/// Defines the [`BacklinkAdapter`](backlink_adapters::BacklinkAdapter) trait for
76/// integrating external backlink data into crawl analysis.
77pub mod backlink_adapters;
78/// Backlink analysis, scoring, and reporting.
79///
80/// Computes PageRank-like scores from internal link graphs and produces
81/// per-page backlink reports and site-wide summaries.
82pub mod backlinks;
83/// Backpressure controller to bound in-flight work and prevent memory blowouts.
84///
85/// Uses tokio semaphores and bounded channels to limit concurrent tasks,
86/// ensuring the crawler stays within resource budgets.
87pub mod backpressure;
88/// Circuit breaker for failing HTTP endpoints to avoid cascading failures.
89///
90/// Per-domain circuit breakers track consecutive failures and automatically
91/// stop requests to failing domains until they recover.
92pub mod circuit_breaker;
93/// Diff-based comparison of two crawl results.
94///
95/// Detects added/removed pages, status changes, title changes, content
96/// changes, and Core Web Vitals regressions between crawls.
97pub mod compare;
98/// Deterministic replay controller for reproducible crawl runs.
99///
100/// Seed-based PRNG ensures that given the same input and configuration,
101/// the crawler produces identical output for testing and auditing.
102pub mod determinism;
103/// DNS resolution cache and prefetching.
104///
105/// Concurrent DNS cache with configurable TTL and background prefetching
106/// to reduce DNS lookup latency during high-throughput crawling.
107pub mod dns;
108/// TLS and encryption configuration for HTTPS requests.
109///
110/// Provides AES-256-GCM encryption at rest for sensitive crawl data,
111/// with key management via files, environment variables, or system keyrings.
112pub mod encryption;
113/// Enterprise feature gating and licensing utilities.
114pub mod enterprise;
115/// Export of crawl data to JSON, CSV, HTML, and Markdown formats.
116///
117/// Configurable column selection and formatting for CSV export,
118/// with streaming support for large datasets.
119pub mod export;
120/// Feature flag system for toggling capabilities at runtime.
121///
122/// Flags are immutable per-crawl session once set. Supports TOML
123/// configuration and programmatic access via [`SharedFeatureFlags`](feature_flags::SharedFeatureFlags).
124pub mod feature_flags;
125/// HTTP client with retry, redirect following, and rate limiting.
126///
127/// Provides [`HttpClient`](http::HttpClient) with exponential backoff retry,
128/// manual redirect tracking, user-agent rotation, and streaming responses.
129pub mod http;
130/// Decision engine for determining whether a page requires JavaScript rendering.
131///
132/// Detects SPA frameworks (Next.js, Nuxt, SvelteKit, Angular) via HTML hints
133/// and URL patterns to decide when to invoke Playwright.
134pub mod js_render_decision;
135
136/// Metrics collection and observability hooks.
137///
138/// Provides atomic [`Metrics`](observability::Metrics) for tracking pages crawled,
139/// bytes fetched, timing, and circuit breaker events with zero-allocation hot paths.
140pub mod observability;
141/// Playwright-based headless browser integration for JS-rendered pages.
142///
143/// Renders JavaScript-heavy SPAs via Playwright CLI subprocess with
144/// browser context isolation, resource limits, and console/network capture.
145pub mod playwright;
146/// Plugin system for extending the crawler with custom analyzers.
147///
148/// WASM-based plugins are sandboxed via wasmtime with a well-defined
149/// ABI (`crawlkit_plugin_init`, `crawlkit_plugin_analyze`, `crawlkit_plugin_alloc/free`).
150pub mod plugin;
151
152pub use plugin::{PluginError, PluginManifest, PluginMetadata, PluginRegistry, WasmPlugin};
153/// Priority URL queue with depth and scope filtering.
154///
155/// Binary heap-based priority queue with deduplication, domain tracking,
156/// and configurable scope control (allowed/blocked domains and paths).
157pub mod queue;
158/// Per-domain rate limiting to respect politeness constraints.
159///
160/// Token-bucket rate limiter with per-domain and global buckets,
161/// supporting crawl-delay from robots.txt and concurrency limiting.
162pub mod ratelimit;
163/// Runtime resource monitoring and limit enforcement.
164///
165/// Tracks memory, CPU, disk, and page counts against configurable limits,
166/// providing early termination when budgets are exceeded.
167pub mod resource_monitor;
168/// robots.txt parsing, caching, and compliance checking.
169pub mod robots;
170/// Real User Metrics (CrUX, GA) integration for performance data.
171///
172/// Fetches Core Web Vitals from Chrome UX Report API and Google Analytics,
173/// merging lab and field data for comprehensive performance analysis.
174pub mod rum;
175/// Sitemap.xml parsing and URL discovery.
176pub mod sitemap;
177/// SQLite-backed persistent storage for crawl results and issues.
178///
179/// WAL-mode SQLite with LRU page cache, batch insert operations,
180/// and memory usage tracking. Supports pages, links, issues, images,
181/// structured data, and CrUX metrics.
182pub mod storage;
183/// WASM-based analyzers for advanced code and performance analysis.
184///
185/// Static pattern analysis, runtime performance analysis, and
186/// Playwright-powered rendering analysis for WebAssembly content.
187pub mod wasm_analyzers;
188
189pub use ai_analyzers::{
190 AiAnswerBoxAnalyzer, AiCitationEligibilityAnalyzer, AiContentStructureAnalyzer,
191 AiCrawlerAccessibilityAnalyzer,
192};
193pub use ai_bots::{AiBot, AiBotRegistry};
194pub use analyzers::{
195 AccessibilityAnalyzer, AnalysisContext, Analyzer, AnalyzerRegistry, CanonicalUrlValidator,
196 ContentQualityAnalyzer, EcommerceSignalsAnalyzer, EnhancedReadabilityAnalyzer, EntityAnalyzer,
197 Finding, HeadingHierarchyAnalyzer, HreflangValidator, HttpStatusAnalyzer, ImageAnalyzer,
198 ImageInfo, InternationalSeoAnalyzer, KeywordAnalyzer, LinkAnalyzer, LinkInfo, MetaTagAnalyzer,
199 MobileFriendlinessChecker, RedirectChainAnalyzer, RobotsRule, RobotsTxtAnalyzer,
200 SecurityHeaderAnalyzer, SitemapAnalyzer, SitemapEntry, SocialMediaAnalyzer, SslCertificateInfo,
201 SslCertificateValidator, StructuredDataValidator, WordCountAnalyzer,
202};
203pub use audit::{AuditEvent, AuditEventType, AuditTrail};
204pub use backlink_adapters::{
205 AdapterError, AhrefsAdapter, BacklinkAdapter, BacklinkAdapterRegistry, ExternalBacklink,
206 GscAdapter, MajesticAdapter,
207};
208pub use backlinks::{Backlink, BacklinkAnalyzer, BacklinkReport, BacklinkSummary, PageScore};
209pub use backpressure::{BackpressureController, BackpressureError, BoundedPipeline};
210pub use circuit_breaker::{
211 CircuitBreaker, CircuitBreakerConfig, CircuitBreakerRegistry, CircuitState,
212};
213pub use determinism::DeterminismController;
214pub use dns::{DnsCache, DnsError, DnsPrefetcher};
215pub use encryption::{EncryptionConfig, EncryptionError, EncryptionManager};
216pub use feature_flags::{
217 FeatureFlags, SharedFeatureFlags, FLAG_AI_ANALYZERS, FLAG_JS_RENDERING, FLAG_WASM_ANALYZERS,
218};
219pub use http::{FetchStreamReader, HttpClient, HttpClientConfig};
220pub use js_render_decision::{JsRenderDecision, JsRenderDecisionEngine, SpaIndicators};
221
222pub use observability::{Metrics, MetricsSnapshot, SharedMetrics};
223pub use playwright::{
224 BrowserContext, BrowserType, ConsoleMessage, NetworkRequest, PlaywrightConfig,
225 PlaywrightDetector, PlaywrightError, PlaywrightRenderer, RenderedPage,
226 WasmError as PlaywrightWasmError,
227};
228pub use resource_monitor::{ResourceLimits, ResourceMonitor, ResourceUsage};
229pub use robots::RobotsTxtCache;
230pub use rum::{
231 CruxAdapter, CruxData, FieldMetrics, GoogleAnalyticsAdapter, LabMetrics, MergedMetrics,
232 MetricDeltas, RumDataPoint, RumError,
233};
234pub use sitemap::SitemapCache;
235pub use storage::{
236 CacheStats, CrawlStats, Issue, IssueCategory, IssueFilter, Severity, StorageError,
237};
238pub use wasm_analyzers::{WasmPatternAnalyzer, WasmPerformanceAnalyzer, WasmRuntimeAnalyzer};
239
240/// HTML meta tag extraction (title, description, OG, Twitter Cards, hreflang).
241///
242/// Provides [`MetaTags`](meta::MetaTags) with helper methods for checking
243/// `noindex`/`nofollow` directives and measuring tag lengths.
244pub mod meta;
245/// HTML parser that extracts links, headings, images, forms, and structured data.
246///
247/// [`HtmlParser::parse`] produces a [`ParsedPage`] with all SEO-relevant data
248/// extracted from raw HTML, including accessibility landmarks and social metadata.
249pub mod parser;
250
251pub use meta::{HreflangTag, MetaTags, OpenGraphTags, TwitterTags};
252pub use parser::{
253 ExtractedForm, ExtractedImage, ExtractedInput, ExtractedLink, Heading, HtmlParser, ParseError,
254 ParsedPage, ScriptInfo, StreamingHtmlParser, StructuredData, StyleInfo,
255};
256
257mod duration_ms {
258 use serde::{Deserialize, Deserializer, Serialize, Serializer};
259 use std::time::Duration;
260
261 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
262 where
263 S: Serializer,
264 {
265 duration.as_millis().serialize(serializer)
266 }
267
268 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
269 where
270 D: Deserializer<'de>,
271 {
272 let ms = u64::deserialize(deserializer)?;
273 Ok(Duration::from_millis(ms))
274 }
275}
276
277mod opt_duration_ms {
278 use serde::{Deserialize, Deserializer, Serialize, Serializer};
279 use std::time::Duration;
280
281 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
282 where
283 S: Serializer,
284 {
285 duration.map(|d| d.as_millis()).serialize(serializer)
286 }
287
288 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
289 where
290 D: Deserializer<'de>,
291 {
292 let ms = Option::<u64>::deserialize(deserializer)?;
293 Ok(ms.map(Duration::from_millis))
294 }
295}
296
297/// Errors that can occur during crawl operations.
298///
299/// This is the primary error type for the crawlkit crate. It wraps
300/// lower-level errors from URL parsing, HTTP requests, robots.txt,
301/// and storage operations.
302///
303/// # Examples
304///
305/// ```rust
306/// use crawlkit_engine::CrawlError;
307///
308/// let err = CrawlError::TooManyRedirects(20);
309/// assert!(err.to_string().contains("20"));
310/// ```
311#[derive(Debug, Error)]
312pub enum CrawlError {
313 /// The URL could not be parsed.
314 #[error("invalid URL: {0}")]
315 InvalidUrl(#[from] url::ParseError),
316
317 /// The HTTP request failed.
318 #[error("request failed: {0}")]
319 RequestFailed(#[from] reqwest::Error),
320
321 /// The URL exceeded the maximum redirect limit.
322 #[error("too many redirects ({0})")]
323 TooManyRedirects(usize),
324
325 /// The URL is excluded by robots.txt disallow rules.
326 #[error("blocked by robots.txt: {0}")]
327 BlockedByRobotsTxt(String),
328
329 /// The URL is outside the allowed domain scope.
330 #[error("out of scope: {0}")]
331 OutOfScope(String),
332
333 /// A storage/database error occurred.
334 #[error("storage error: {0}")]
335 Storage(String),
336
337 /// All retry attempts were exhausted.
338 #[error("max retries exceeded after {0} attempts")]
339 MaxRetriesExceeded(usize),
340}
341
342/// Configuration for a crawl session.
343///
344/// Controls the starting URL, crawl limits, politeness settings, and
345/// URL filtering patterns. Implements `Default` with sensible values
346/// for most use cases.
347///
348/// # Examples
349///
350/// ```rust
351/// use crawlkit_engine::CrawlConfig;
352/// use std::time::Duration;
353///
354/// let config = CrawlConfig {
355/// max_pages: 500,
356/// request_delay: Duration::from_millis(200),
357/// concurrency: 8,
358/// ..Default::default()
359/// };
360/// assert_eq!(config.max_pages, 500);
361/// ```
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct CrawlConfig {
364 /// The starting URL to crawl.
365 pub start_url: Url,
366
367 /// Maximum number of pages to crawl.
368 pub max_pages: usize,
369
370 /// Maximum crawl duration. `None` means no time limit.
371 #[serde(default, with = "opt_duration_ms")]
372 pub max_time: Option<Duration>,
373
374 /// Maximum crawl depth from the starting URL. `None` means no depth limit.
375 pub max_depth: Option<usize>,
376
377 /// Delay between requests to the same domain.
378 #[serde(with = "duration_ms")]
379 pub request_delay: Duration,
380
381 /// Number of concurrent fetchers per domain.
382 pub concurrency: usize,
383
384 /// HTTP request timeout.
385 #[serde(with = "duration_ms")]
386 pub request_timeout: Duration,
387
388 /// User-Agent string to send with requests.
389 pub user_agent: String,
390
391 /// Maximum number of redirects to follow.
392 pub max_redirects: usize,
393
394 /// Whether to respect robots.txt directives.
395 pub respect_robots_txt: bool,
396
397 /// Allowed URL patterns (glob-style).
398 pub allowed_patterns: Vec<String>,
399
400 /// Disallowed URL patterns (glob-style).
401 pub disallowed_patterns: Vec<String>,
402}
403
404impl Default for CrawlConfig {
405 fn default() -> Self {
406 Self {
407 start_url: Url::parse("https://example.com")
408 .unwrap_or_else(|_| unreachable!("static URL string is always valid")),
409 max_pages: 100,
410 max_time: None,
411 max_depth: None,
412 request_delay: Duration::from_millis(500),
413 concurrency: 4,
414 request_timeout: Duration::from_secs(30),
415 user_agent: format!("crawlkit/{}", env!("CARGO_PKG_VERSION")),
416 max_redirects: 20,
417 respect_robots_txt: true,
418 allowed_patterns: Vec::new(),
419 disallowed_patterns: Vec::new(),
420 }
421 }
422}
423
424/// Represents a single URL discovered during crawling.
425///
426/// Tracks the original URL, its canonical form, the referring page,
427/// crawl depth, and discovery timestamp. Used by the crawl queue
428/// and storage layers.
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct UrlEntry {
431 /// The original URL as discovered.
432 pub url: Url,
433
434 /// The canonical URL after normalization.
435 pub canonical_url: Url,
436
437 /// The page that linked to this URL.
438 pub referrer: Option<Url>,
439
440 /// The depth from the starting URL (0 = start).
441 pub depth: usize,
442
443 /// When this URL was discovered.
444 pub discovered_at: DateTime<Utc>,
445}
446
447/// The result of fetching a URL.
448///
449/// Contains the final URL (after redirects), HTTP status, response headers,
450/// body content, timing, and size information. This is the primary output
451/// of [`HttpClient::fetch`](http::HttpClient::fetch).
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct FetchResult {
454 /// The final URL after following redirects.
455 pub final_url: Url,
456
457 /// HTTP status code.
458 pub status_code: u16,
459
460 /// Response headers.
461 pub headers: Vec<(String, String)>,
462
463 /// The response body as a UTF-8 string.
464 pub body: String,
465
466 /// Time taken for the request.
467 #[serde(with = "duration_ms")]
468 pub response_time: Duration,
469
470 /// Size of the response body in bytes.
471 pub body_size: usize,
472
473 /// When the request was made.
474 pub fetched_at: DateTime<Utc>,
475}
476
477/// A single hop in a redirect chain.
478///
479/// Records the source and destination URLs along with the HTTP status code
480/// (301, 302, 307, 308) for each redirect. Multiple hops form a
481/// [`RedirectChainAnalyzer`](analyzers::RedirectChainAnalyzer) input.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct RedirectHop {
484 /// The URL that redirected.
485 pub from: Url,
486
487 /// The URL redirected to.
488 pub to: Url,
489
490 /// The HTTP status code (301, 302, 307, 308).
491 pub status_code: u16,
492}
493
494#[cfg(test)]
495#[allow(clippy::unwrap_used)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn test_crawl_config_default() {
501 let config = CrawlConfig::default();
502 assert_eq!(config.max_pages, 100);
503 assert_eq!(config.concurrency, 4);
504 assert_eq!(config.max_redirects, 20);
505 assert!(config.respect_robots_txt);
506 }
507
508 #[test]
509 fn test_crawl_config_serialization() {
510 let config = CrawlConfig::default();
511 let json = serde_json::to_string(&config).unwrap();
512 let deserialized: CrawlConfig = serde_json::from_str(&json).unwrap();
513 assert_eq!(config.max_pages, deserialized.max_pages);
514 assert_eq!(config.request_delay, deserialized.request_delay);
515 assert_eq!(config.request_timeout, deserialized.request_timeout);
516 }
517
518 #[test]
519 fn test_url_entry_serialization() {
520 let entry = UrlEntry {
521 url: Url::parse("https://example.com").unwrap(),
522 canonical_url: Url::parse("https://example.com").unwrap(),
523 referrer: None,
524 depth: 0,
525 discovered_at: Utc::now(),
526 };
527
528 let json = serde_json::to_string(&entry).unwrap();
529 let deserialized: UrlEntry = serde_json::from_str(&json).unwrap();
530 assert_eq!(entry.url, deserialized.url);
531 assert_eq!(entry.depth, deserialized.depth);
532 }
533
534 #[test]
535 fn test_redirect_hops() {
536 let hops = [
537 RedirectHop {
538 from: Url::parse("https://example.com/old").unwrap(),
539 to: Url::parse("https://example.com/mid").unwrap(),
540 status_code: 301,
541 },
542 RedirectHop {
543 from: Url::parse("https://example.com/mid").unwrap(),
544 to: Url::parse("https://example.com/new").unwrap(),
545 status_code: 302,
546 },
547 ];
548
549 assert_eq!(hops.len(), 2);
550 assert_eq!(hops[0].status_code, 301);
551 assert_eq!(hops[1].status_code, 302);
552 }
553
554 #[test]
555 fn test_fetch_result_serialization() {
556 let result = FetchResult {
557 final_url: Url::parse("https://example.com").unwrap(),
558 status_code: 200,
559 headers: vec![("content-type".into(), "text/html".into())],
560 body: "<html></html>".into(),
561 response_time: Duration::from_millis(123),
562 body_size: 14,
563 fetched_at: Utc::now(),
564 };
565
566 let json = serde_json::to_string(&result).unwrap();
567 assert!(json.contains("123"));
568 let deserialized: FetchResult = serde_json::from_str(&json).unwrap();
569 assert_eq!(deserialized.response_time, Duration::from_millis(123));
570 }
571}