Skip to main content

pagesift_api/monitor/
schedule.rs

1//! `schedule` 的周期:Firecrawl 的 cron(常见的等间隔写法)和自然语言文本,换算成秒。
2//!
3//! 给了 cron 或文本时,监控按这个周期检查(最短、最长间隔都等于它,和 Firecrawl 一样);
4//! pagesift 扩展 `minIntervalMs`、`maxIntervalMs`(毫秒)给出时,在这个区间里按变化率的后验自适应。
5
6use crate::error::ApiError;
7
8/// [协议] Firecrawl 文档:“The minimum interval is 5 minutes.”
9pub const MIN_INTERVAL_SECS: u64 = 300;
10/// [策略] 最长间隔一年:再长的监控没有意义,算到期时间也不会溢出
11pub const MAX_INTERVAL_SECS: u64 = 366 * DAY;
12
13const MINUTE: u64 = 60;
14const HOUR: u64 = 3600;
15const DAY: u64 = 86400;
16/// [协议] 一周 7 天。
17const WEEK: u64 = 7 * DAY;
18
19/// 等间隔的 cron 写法:`*/N * * * *`、`M * * * *`、`M */N * * *`、`M H * * *`、`M H * * D`。
20/// 其他写法(非等间隔,如 `0 9,17 * * *`)报错,不悄悄近似。
21pub fn cron_period(cron: &str) -> Result<u64, ApiError> {
22    let f: Vec<&str> = cron.split_whitespace().collect();
23    let bad = || {
24        ApiError::invalid(format!(
25            "schedule.cron {cron:?}: only evenly spaced schedules are supported (*/N * * * *, M * * * *, M */N * * *, M H * * *, M H * * D)"
26        ))
27    };
28    // [协议] cron 的五个字段:分、时、日、月、星期
29    if f.len() != 5 {
30        return Err(bad());
31    }
32    let num = |s: &str| s.parse::<u64>().ok();
33    let every = |s: &str| s.strip_prefix("*/").and_then(num);
34    let rest_any = f[2] == "*" && f[3] == "*";
35    match (f[0], f[1], rest_any, f[4]) {
36        (m, "*", true, "*") if every(m).is_some() => {
37            Ok(every(m).unwrap_or(1).saturating_mul(MINUTE))
38        }
39        ("*", "*", true, "*") => Ok(MINUTE),
40        (m, "*", true, "*") if num(m).is_some() => Ok(HOUR),
41        (m, h, true, "*") if num(m).is_some() && every(h).is_some() => {
42            Ok(every(h).unwrap_or(1).saturating_mul(HOUR))
43        }
44        (m, h, true, "*") if num(m).is_some() && num(h).is_some() => Ok(DAY),
45        (m, h, true, d) if num(m).is_some() && num(h).is_some() && num(d).is_some() => Ok(WEEK),
46        _ => Err(bad()),
47    }
48}
49
50/// Firecrawl 文档列出的文本写法:`every N minutes`、`hourly`、`every N hours`、`daily`、
51/// `daily at …`、`weekly`(`starting at :07` 之类的相位不影响周期)。
52pub fn text_period(text: &str) -> Result<u64, ApiError> {
53    let t = text.trim().to_ascii_lowercase();
54    let words: Vec<&str> = t.split_whitespace().collect();
55    let n = |i: usize| words.get(i).and_then(|w| w.parse::<u64>().ok());
56    let period = match words.as_slice() {
57        ["hourly", ..] => Some(HOUR),
58        ["daily", ..] => Some(DAY),
59        ["weekly", ..] => Some(WEEK),
60        ["every", "minute", ..] => Some(MINUTE),
61        ["every", "hour", ..] => Some(HOUR),
62        ["every", "day", ..] => Some(DAY),
63        ["every", "week", ..] => Some(WEEK),
64        ["every", _, unit, ..] => n(1).and_then(|k| {
65            let u = unit.trim_end_matches('s');
66            match u {
67                "minute" | "min" => Some(k.saturating_mul(MINUTE)),
68                "hour" => Some(k.saturating_mul(HOUR)),
69                "day" => Some(k.saturating_mul(DAY)),
70                "week" => Some(k.saturating_mul(WEEK)),
71                _ => None,
72            }
73        }),
74        _ => None,
75    };
76    period.ok_or_else(|| {
77        ApiError::invalid(format!(
78            "schedule.text {text:?}: use every N minutes, hourly, every N hours, daily, daily at …, or weekly"
79        ))
80    })
81}
82
83/// 只取了周期、没有实现的部分(时刻、时区),如实写成警告。
84pub fn unsupported(s: &super::Schedule, period: Option<u64>) -> Vec<String> {
85    let mut out = Vec::new();
86    let Some(period) = period else {
87        return out;
88    };
89    let normalized = normalized_cron(period);
90    if let Some(c) = &s.cron
91        && c.split_whitespace().collect::<Vec<_>>().join(" ") != normalized
92    {
93        out.push(format!(
94            "monitor_time_ignored: cron {c:?} runs every {period} s from the moment the monitor was created; the time of day and weekday are not applied"
95        ));
96    }
97    // 文本写法里周期后面还带时刻(daily at 9am、starting at :07)
98    if let Some(t) = s.text.as_deref().filter(|_| s.cron.is_none())
99        && t.to_ascii_lowercase().contains(" at ")
100    {
101        out.push(format!(
102            "monitor_time_ignored: {t:?} runs every {period} s from the moment the monitor was created; the time is not applied"
103        ));
104    }
105    if let Some(z) = s
106        .timezone
107        .as_deref()
108        .filter(|z| !z.eq_ignore_ascii_case("UTC"))
109    {
110        out.push(format!(
111            "monitor_timezone_ignored: timezone {z:?} is not applied; schedules are computed in UTC"
112        ));
113    }
114    out
115}
116
117/// 归一化的 cron(响应里回给调用方,和 Firecrawl 一样总是给 cron)。
118pub fn normalized_cron(period: u64) -> String {
119    match period {
120        p if p % WEEK == 0 => "0 0 * * 0".into(),
121        p if p % DAY == 0 => "0 0 * * *".into(),
122        p if p % HOUR == 0 && p / HOUR > 1 => format!("0 */{} * * *", p / HOUR),
123        p if p == HOUR => "0 * * * *".into(),
124        p => format!("*/{} * * * *", (p / MINUTE).max(1)),
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn periods() {
134        assert_eq!(cron_period("*/30 * * * *").unwrap(), 1800);
135        assert_eq!(cron_period("7 * * * *").unwrap(), 3600);
136        assert_eq!(cron_period("0 */2 * * *").unwrap(), 7200);
137        assert_eq!(cron_period("30 9 * * *").unwrap(), 86400);
138        assert!(cron_period("0 9,17 * * *").is_err());
139        assert_eq!(
140            text_period("every 15 minutes starting at :07").unwrap(),
141            900
142        );
143        assert_eq!(text_period("daily at 9am").unwrap(), 86400);
144        assert_eq!(text_period("every 2 hours").unwrap(), 7200);
145        assert!(text_period("sometimes").is_err());
146        assert_eq!(normalized_cron(1800), "*/30 * * * *");
147        assert_eq!(normalized_cron(7200), "0 */2 * * *");
148    }
149}