use std::collections::hash_map::DefaultHasher;
use std::fs::{File, OpenOptions};
use std::hash::{Hash, Hasher};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ReCrawlPolicy {
Never,
FixedDays(u32),
Adaptive,
}
impl Default for ReCrawlPolicy {
fn default() -> Self {
ReCrawlPolicy::FixedDays(7)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry {
pub url: String,
pub content_hash: u64,
pub first_seen: DateTime<Utc>,
pub last_fetched: DateTime<Utc>,
pub fetch_count: u32,
pub last_status: u16,
pub title: Option<String>,
pub simhash: Option<u64>,
}
impl CacheEntry {
pub fn hash_content(text: &str) -> u64 {
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
}
pub struct CacheStore {
path: PathBuf,
entries: std::sync::Mutex<std::collections::HashMap<String, CacheEntry>>,
policy: ReCrawlPolicy,
}
impl CacheStore {
pub fn open(base: impl AsRef<Path>) -> Result<Self> {
let path = base.as_ref().join("cache").join("urls.jsonl");
std::fs::create_dir_all(
path.parent()
.ok_or_else(|| anyhow::anyhow!("cache path has no parent: {:?}", path))?,
)?;
let mut store = Self {
path,
entries: std::sync::Mutex::new(std::collections::HashMap::new()),
policy: ReCrawlPolicy::default(),
};
store.load()?;
Ok(store)
}
pub fn with_policy(mut self, policy: ReCrawlPolicy) -> Self {
self.policy = policy;
self
}
pub fn normalize_url(url: &str) -> String {
let mut u = url.trim().to_lowercase();
if let Some(pos) = u.find('#') {
u.truncate(pos);
}
u = u.trim_end_matches('/').to_string();
for scheme in &["http://", "https://"] {
if let Some(rest) = u.strip_prefix(scheme) {
let parts: Vec<&str> = rest.splitn(2, '/').collect();
let host = parts[0];
if let Some(colon) = host.find(':') {
let port = &host[colon + 1..];
let default_port = if scheme == &"http://" { "80" } else { "443" };
if port == default_port {
let new_host = &host[..colon];
let new_rest = parts.get(1).map(|s| format!("/{}", s)).unwrap_or_default();
return format!("{}{}{}", scheme, new_host, new_rest);
}
}
}
}
u
}
pub fn get(&self, url: &str) -> Option<CacheEntry> {
let key = Self::normalize_url(url);
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
.cloned()
}
pub fn insert(&self, entry: CacheEntry) -> Result<()> {
let key = Self::normalize_url(&entry.url);
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key, entry);
self.persist()
}
pub fn should_fetch(&self, url: &str) -> bool {
match self.get(url) {
None => true,
Some(entry) => match self.policy {
ReCrawlPolicy::Never => false,
ReCrawlPolicy::FixedDays(days) => {
let age = Utc::now() - entry.last_fetched;
age > chrono::Duration::days(days as i64)
}
ReCrawlPolicy::Adaptive => Self::adaptive_should_fetch(&entry),
},
}
}
fn adaptive_should_fetch(entry: &CacheEntry) -> bool {
let age = Utc::now() - entry.last_fetched;
let interval_days = if entry.fetch_count <= 1 {
1
} else {
(2_i64).pow((entry.fetch_count - 1).min(4)).min(30)
};
age > chrono::Duration::days(interval_days)
}
pub fn len(&self) -> usize {
self.entries.lock().unwrap_or_else(|e| e.into_inner()).len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn load(&mut self) -> Result<()> {
if !self.path.exists() {
return Ok(());
}
let file = File::open(&self.path)?;
let reader = BufReader::new(file);
let mut map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let entry: CacheEntry = serde_json::from_str(&line)?;
map.insert(Self::normalize_url(&entry.url), entry);
}
Ok(())
}
fn persist(&self) -> Result<()> {
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&self.path)?;
let mut writer = std::io::LineWriter::new(file);
let map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
for entry in map.values() {
let line = serde_json::to_string(entry)?;
writeln!(writer, "{}", line)?;
}
writer.flush()?;
Ok(())
}
}
pub struct SimHash;
impl SimHash {
pub const NEAR_DUP_THRESHOLD: u32 = 3;
pub fn compute(text: &str) -> u64 {
const SHINGLE_K: usize = 3;
let words: Vec<String> = text
.split_whitespace()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase())
.filter(|w| !w.is_empty())
.collect();
let mut vec = [0i64; 64];
if words.len() < SHINGLE_K {
return 0;
}
for window in words.windows(SHINGLE_K) {
let mut hasher = DefaultHasher::new();
window.hash(&mut hasher);
let h = hasher.finish();
for (i, v) in vec.iter_mut().enumerate() {
let bit = (h >> i) & 1;
if bit == 1 {
*v += 1;
} else {
*v -= 1;
}
}
}
let mut hash = 0u64;
for (i, &v) in vec.iter().enumerate() {
if v > 0 {
hash |= 1 << i;
}
}
hash
}
pub fn hamming_distance(a: u64, b: u64) -> u32 {
(a ^ b).count_ones()
}
pub fn is_near_duplicate(a: &str, b: &str) -> bool {
Self::hamming_distance(Self::compute(a), Self::compute(b)) <= Self::NEAR_DUP_THRESHOLD
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
const BASE_PROSE: &str = "the distributed search engine indexes crawled web pages across \
many hosts and stores their content in a local database so that agents can \
query it later without hitting the network again every single time which \
saves bandwidth and improves latency for repeated research tasks over time";
#[test]
fn test_normalize_url() {
assert_eq!(
CacheStore::normalize_url("https://example.com/page#section"),
"https://example.com/page"
);
assert_eq!(
CacheStore::normalize_url("https://example.com:443/page/"),
"https://example.com/page"
);
assert_eq!(
CacheStore::normalize_url("http://example.com:80/page"),
"http://example.com/page"
);
}
#[test]
fn test_cache_roundtrip() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::open(tmp.path()).unwrap();
assert!(cache.should_fetch("https://example.com/a"));
let entry = CacheEntry {
url: "https://example.com/a".to_string(),
content_hash: 123,
first_seen: Utc::now(),
last_fetched: Utc::now(),
fetch_count: 1,
last_status: 200,
title: Some("Hello".to_string()),
simhash: Some(0),
};
cache.insert(entry.clone()).unwrap();
assert_eq!(cache.len(), 1);
assert!(!cache.should_fetch("https://example.com/a"));
let cache2 = CacheStore::open(tmp.path()).unwrap();
assert_eq!(cache2.len(), 1);
assert_eq!(
cache2.get("https://example.com/a").unwrap().content_hash,
123
);
}
#[test]
fn test_fixed_policy_blocks_recent() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::open(tmp.path())
.unwrap()
.with_policy(ReCrawlPolicy::FixedDays(7));
let entry = CacheEntry {
url: "https://example.com/a".to_string(),
content_hash: 0,
first_seen: Utc::now(),
last_fetched: Utc::now(),
fetch_count: 1,
last_status: 200,
title: None,
simhash: None,
};
cache.insert(entry).unwrap();
assert!(!cache.should_fetch("https://example.com/a"));
}
#[test]
fn test_never_policy() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::open(tmp.path())
.unwrap()
.with_policy(ReCrawlPolicy::Never);
let entry = CacheEntry {
url: "https://example.com/old".to_string(),
content_hash: 0,
first_seen: Utc::now() - chrono::Duration::days(400),
last_fetched: Utc::now() - chrono::Duration::days(365),
fetch_count: 1,
last_status: 200,
title: None,
simhash: None,
};
cache.insert(entry).unwrap();
assert!(!cache.should_fetch("https://example.com/old"));
}
#[test]
fn test_simhash_near_duplicate() {
let a = format!("{} jumped", BASE_PROSE);
let b = format!("{} leapt", BASE_PROSE);
let c = "Python is a high level scripting language for web development with \
dynamic typing automatic memory management and an extensive standard \
library covering web data science and automation tasks for beginners".to_string();
let d = SimHash::hamming_distance(SimHash::compute(&a), SimHash::compute(&b));
assert!(
SimHash::is_near_duplicate(&a, &b),
"one-word edit on a real document must stay within {} bits (dist={d})",
SimHash::NEAR_DUP_THRESHOLD
);
assert!(!SimHash::is_near_duplicate(&a, &c), "distinct doc must not be a near-dup");
assert!(d <= SimHash::NEAR_DUP_THRESHOLD, "near-dup distance {d} within threshold");
}
}