use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::Result;
use tokio::sync::Mutex;
use super::bg_worker::BackgroundWorker;
use super::bulk_crawler::{BulkDomainCrawler, FollowExternalLinks, RespectRobots};
use super::crawl_graph::CrawlGraphStore;
use super::embedder::Embedder;
use super::proxy_pool::ProxyPool;
use super::seed_catalog::{CuratedDomain, Recrawl};
use crate::schema::content::StructuredContent;
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub delay_ms: u64,
pub max_pages_per_source: usize,
pub sweep_interval_secs: u64,
pub only_domains: Vec<String>,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
delay_ms: 1000,
max_pages_per_source: 50,
sweep_interval_secs: 3600,
only_domains: Vec::new(),
}
}
}
#[derive(Debug, Default)]
pub struct SweepStats {
pub sources_crawled: usize,
pub pages_fetched: usize,
pub pages_indexed: usize,
pub errors: usize,
}
pub struct CurationDaemon {
config: DaemonConfig,
graph_store: Arc<dyn CrawlGraphStore + Send + Sync>,
background_worker: Arc<BackgroundWorker>,
embedder: Option<Arc<dyn Embedder>>,
last_crawl: Mutex<std::collections::HashMap<String, Instant>>,
}
impl CurationDaemon {
pub fn new(
config: DaemonConfig,
graph_store: Arc<dyn CrawlGraphStore + Send + Sync>,
background_worker: Arc<BackgroundWorker>,
embedder: Option<Arc<dyn Embedder>>,
) -> Self {
Self {
config,
graph_store,
background_worker,
embedder,
last_crawl: Mutex::new(std::collections::HashMap::new()),
}
}
pub fn embedder(&self) -> Option<Arc<dyn Embedder>> {
self.embedder.clone()
}
pub async fn run_forever(&self) -> Result<()> {
tracing::info!(
"curation daemon started: {} domains, sweep every {}s",
self.active_domains().len(),
self.config.sweep_interval_secs
);
loop {
let stats = self.sweep().await;
tracing::info!(
"curation sweep done: {} sources, {} pages fetched, {} indexed, {} errors",
stats.sources_crawled,
stats.pages_fetched,
stats.pages_indexed,
stats.errors
);
tokio::time::sleep(Duration::from_secs(self.config.sweep_interval_secs)).await;
}
}
pub async fn sweep(&self) -> SweepStats {
let mut stats = SweepStats::default();
for domain in self.active_domains() {
for source in domain.sources {
if !self.is_due(source.url).await {
continue;
}
stats.sources_crawled += 1;
match self.crawl_source(domain, source.url).await {
Ok((fetched, indexed)) => {
stats.pages_fetched += fetched;
stats.pages_indexed += indexed;
}
Err(e) => {
tracing::warn!("daemon crawl of {} failed: {}", source.url, e);
stats.errors += 1;
}
}
self.mark_crawled(source.url).await;
}
}
stats
}
async fn crawl_source(
&self,
domain: &'static CuratedDomain,
url: &str,
) -> Result<(usize, usize)> {
let proxy_pool = ProxyPool::new();
let crawler = BulkDomainCrawler::new(
proxy_pool,
30,
30,
self.config.delay_ms.max(100),
1,
self.config.max_pages_per_source.clamp(1, 500),
FollowExternalLinks::Ignore,
0,
2,
)
.with_respect_robots(RespectRobots::Yes)
.with_graph_store(self.graph_store.clone())
.with_background_worker(self.background_worker.clone())
.with_topics(domain.topics.iter().map(|s| s.to_string()).collect());
let contents: Vec<StructuredContent> = crawler.crawl(url).await?;
let indexed = contents.iter().filter(|c| c.is_valid_content).count();
Ok((contents.len(), indexed))
}
fn active_domains(&self) -> Vec<&'static CuratedDomain> {
if self.config.only_domains.is_empty() {
return super::seed_catalog::DOMAINS.iter().collect();
}
super::seed_catalog::DOMAINS
.iter()
.filter(|d| self.config.only_domains.contains(&d.slug.to_string()))
.collect()
}
async fn is_due(&self, url: &str) -> bool {
let last = self.last_crawl.lock().await.get(url).copied();
let recrawl = super::seed_catalog::all_source_urls()
.iter()
.find(|(u, _)| *u == url)
.map(|(_, r)| *r)
.unwrap_or(Recrawl::Weekly);
match last {
None => true,
Some(t) => t.elapsed() >= recrawl.interval(),
}
}
async fn mark_crawled(&self, url: &str) {
self.last_crawl
.lock()
.await
.insert(url.to_string(), Instant::now());
}
}