Skip to main content

pagesift_store/
cache.rs

1//! 抓取结果缓存(F-8.2)。
2//!
3//! 参数语义以 Firecrawl v2 文档为准(2026-10 查阅):
4//! - <https://docs.firecrawl.dev/api-reference/endpoint/scrape>
5//! - <https://docs.firecrawl.dev/features/fast-scraping>
6//!
7//! - `maxAge`(毫秒,默认 172800000 即 2 天):缓存比这个年轻就直接返回,否则重新抓取;
8//!   `maxAge: 0` 表示总是重新抓取。
9//! - `minAge`(毫秒,无默认值):设置后只查缓存、永不触发抓取;缓存至少要这么老才返回,
10//!   否则返回 404 `SCRAPE_NO_CACHED_DATA`;设为 1 表示接受任何缓存。
11//! - `storeInCache`(默认 true):为 false 时结果不写入缓存。带 `headers`、`actions` 等参数的
12//!   请求 Firecrawl 会强制不写缓存,这由调用方决定。
13//!
14//! 缓存键由调用方规范化:URL 加上影响结果的参数(Firecrawl 要求 url、mobile、location、
15//! waitFor、blockAds、screenshot、代理模式完全一致才算命中)。
16
17use std::time::{Duration, SystemTime};
18
19use crate::engine::ns;
20use crate::{Result, Store, from_ms, to_ms};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CacheEntry {
24    pub value: Vec<u8>,
25    pub stored_at: SystemTime,
26}
27
28impl CacheEntry {
29    /// 相对 `now` 的年龄;写入时间在未来(时钟回拨)时算 0。
30    pub fn age_at(&self, now: SystemTime) -> Duration {
31        now.duration_since(self.stored_at).unwrap_or_default()
32    }
33}
34
35/// 一次请求的缓存参数。
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct CachePolicy {
38    pub max_age: Duration,
39    pub min_age: Option<Duration>,
40    pub store_in_cache: bool,
41}
42
43impl Default for CachePolicy {
44    fn default() -> Self {
45        Self {
46            max_age: Duration::from_millis(172_800_000),
47            min_age: None,
48            store_in_cache: true,
49        }
50    }
51}
52
53impl CachePolicy {
54    /// 直接用 Firecrawl 请求里的字段构造,缺省值和 Firecrawl 相同。
55    pub fn from_firecrawl(
56        max_age_ms: Option<u64>,
57        min_age_ms: Option<u64>,
58        store_in_cache: Option<bool>,
59    ) -> Self {
60        let d = Self::default();
61        Self {
62            max_age: max_age_ms.map_or(d.max_age, Duration::from_millis),
63            min_age: min_age_ms.map(Duration::from_millis),
64            store_in_cache: store_in_cache.unwrap_or(d.store_in_cache),
65        }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum CacheLookup {
71    /// 命中,直接返回(响应里 `metadata.cacheState = "hit"`)。
72    Hit(CacheEntry),
73    /// 未命中,需要抓取;抓取后按 `store_in_cache` 决定是否写入。
74    Miss,
75    /// 设置了 `minAge` 但没有合格的缓存:返回 404 `SCRAPE_NO_CACHED_DATA`,不要抓取。
76    NoCachedData,
77}
78
79pub struct Cache<'a> {
80    store: &'a Store,
81}
82
83impl<'a> Cache<'a> {
84    pub(crate) fn new(store: &'a Store) -> Self {
85        Self { store }
86    }
87
88    /// 读出缓存条目,不看年龄。
89    pub fn entry(&self, key: impl AsRef<[u8]>) -> Result<Option<CacheEntry>> {
90        Ok(self
91            .store
92            .get(ns::CACHE, key.as_ref())?
93            .map(|(ts, value)| CacheEntry {
94                value,
95                stored_at: from_ms(ts),
96            }))
97    }
98
99    /// `maxAge` 语义:年龄小于 `max_age` 才返回;`max_age` 为 0 时总是 None。
100    pub fn get(&self, key: impl AsRef<[u8]>, max_age: Duration) -> Result<Option<CacheEntry>> {
101        self.get_at(key, max_age, SystemTime::now())
102    }
103
104    pub fn get_at(
105        &self,
106        key: impl AsRef<[u8]>,
107        max_age: Duration,
108        now: SystemTime,
109    ) -> Result<Option<CacheEntry>> {
110        if max_age.is_zero() {
111            return Ok(None);
112        }
113        Ok(self.entry(key)?.filter(|e| e.age_at(now) < max_age))
114    }
115
116    /// `minAge` 语义:年龄不小于 `min_age` 才返回。
117    pub fn get_min_age(
118        &self,
119        key: impl AsRef<[u8]>,
120        min_age: Duration,
121        now: SystemTime,
122    ) -> Result<Option<CacheEntry>> {
123        Ok(self.entry(key)?.filter(|e| e.age_at(now) >= min_age))
124    }
125
126    /// 按一次请求的参数查缓存。设置了 `min_age` 时忽略 `max_age`(Firecrawl 文档里
127    /// minAge 模式只查缓存,没有说明和 maxAge 的组合)。
128    pub fn lookup(&self, key: impl AsRef<[u8]>, policy: &CachePolicy) -> Result<CacheLookup> {
129        let now = SystemTime::now();
130        if let Some(min) = policy.min_age {
131            return Ok(match self.get_min_age(key, min, now)? {
132                Some(e) => CacheLookup::Hit(e),
133                None => CacheLookup::NoCachedData,
134            });
135        }
136        Ok(match self.get_at(key, policy.max_age, now)? {
137            Some(e) => CacheLookup::Hit(e),
138            None => CacheLookup::Miss,
139        })
140    }
141
142    pub fn put(&self, key: impl AsRef<[u8]>, value: &[u8]) -> Result<()> {
143        self.put_at(key, value, SystemTime::now())
144    }
145
146    /// 指定写入时间(导入旧数据、测试)。
147    pub fn put_at(&self, key: impl AsRef<[u8]>, value: &[u8], stored_at: SystemTime) -> Result<()> {
148        self.store
149            .put(ns::CACHE, key.as_ref(), value, to_ms(stored_at))
150            .map(drop)
151    }
152
153    /// 按 `store_in_cache` 决定是否写入,返回是否写了。
154    pub fn store(&self, key: impl AsRef<[u8]>, value: &[u8], policy: &CachePolicy) -> Result<bool> {
155        if policy.store_in_cache {
156            self.put(key, value)?;
157        }
158        Ok(policy.store_in_cache)
159    }
160
161    pub fn remove(&self, key: impl AsRef<[u8]>) -> Result<bool> {
162        self.store.delete(ns::CACHE, key.as_ref())
163    }
164}