Skip to main content

pagesift_engine/engine/
planner.rs

1//! 渲染决策与候选合并的引擎接线:学习表和族的键在 `pagesift_plan::render`,这里把它接到请求路径上
2//! (渲不渲染、换不换内核、保留哪个结果、页面数据岛),并负责把学习表存进存储。
3
4pub use pagesift_plan::render::*;
5
6use pagesift_api::types::PlanStep;
7use pagesift_plan::merge::{Candidate, Quality, merge};
8use pagesift_stat::Delta;
9
10/// 存储里的键。
11const STORE_KEY: &[u8] = b"plan/render";
12
13/// 学习表写进存储的 KV。
14pub fn save(planner: &RenderPlanner, store: &pagesift_store::Store) -> pagesift_store::Result<()> {
15    store.tasks().kv_put(STORE_KEY, &planner.encode())
16}
17
18/// 从存储读回学习表;没有时保持空表(冷启动)。
19pub fn load(planner: &RenderPlanner, store: &pagesift_store::Store) {
20    if let Ok(Some(buf)) = store.tasks().kv_get(STORE_KEY) {
21        planner.decode_merge(&buf);
22    }
23}
24
25/// `embedded-json` 候选:页面数据岛(`__NEXT_DATA__`、ld+json 等)里的正文与 HTTP 结果按块合并,
26/// 合并结果补进了数据岛独有的块、且质量分高于 HTTP 结果时,把 `doc.markdown` 换成合并结果。
27/// 没有数据岛或质量模型时返回 `None`,否则返回这一步的依据。
28pub fn embedded_json(doc: &mut pagesift_content::Document, html: &str) -> Option<PlanStep> {
29    let blocks = pagesift_plan::embedded_json::extract(html, Delta::DEFAULT);
30    if blocks.is_empty() {
31        return None;
32    }
33    let qh = quality(doc, html.len())?;
34    // 换进候选 Markdown 算质量分,再换回来:不复制整个文档
35    let http_md = std::mem::replace(&mut doc.markdown, pagesift_plan::block::join(&blocks));
36    let qj = quality(doc, html.len());
37    let json_md = std::mem::replace(&mut doc.markdown, http_md);
38    let qj = qj?;
39    let (md, from) = merge_markdown(&doc.markdown, qh, &json_md, qj);
40    let mut step = PlanStep {
41        action: "embedded-json".into(),
42        learned: true,
43        blocks: Some(from),
44        quality: Some(qh),
45        ..PlanStep::default()
46    };
47    // from[2]:只来自数据岛的块数
48    if from[2] == 0 {
49        return Some(step);
50    }
51    let http_md = std::mem::replace(&mut doc.markdown, md);
52    let qm = quality(doc, html.len()).unwrap_or(qh);
53    step.value = Some(qm - qh);
54    if qm > qh {
55        step.chosen = true;
56        step.quality = Some(qm);
57        doc.plain_text = None;
58    } else {
59        doc.markdown = http_md;
60    }
61    Some(step)
62}
63
64/// HTTP 与渲染两个候选按块合并。返回合并后的 Markdown 和各块来源计数(两边都有、只来自 HTTP、只来自渲染)。
65/// [推导] 计数数组长度 3 = 来源种类数(`pagesift_plan::merge::From` 的变体数)
66/// 质量区间暂取点估计(模型还没有给出单页区间),所以"区间不劣于"退化为"不低于"。
67pub fn merge_markdown(
68    http: &str,
69    http_quality: f64,
70    rendered: &str,
71    rendered_quality: f64,
72) -> (String, [usize; 3]) {
73    let point = |q: f64| Quality {
74        mean: q,
75        lo: q,
76        hi: q,
77    };
78    let m = merge(
79        &Candidate {
80            blocks: pagesift_plan::block::split(http),
81            quality: point(http_quality),
82        },
83        &Candidate {
84            blocks: pagesift_plan::block::split(rendered),
85            quality: point(rendered_quality),
86        },
87        Delta::default(),
88    );
89    let mut from = [0; 3]; // [推导] 来源种类数,同上
90    for f in &m.from {
91        from[match f {
92            pagesift_plan::merge::From::Both => 0,
93            pagesift_plan::merge::From::Left => 1,
94            pagesift_plan::merge::From::Right => 2,
95        }] += 1;
96    }
97    (m.markdown(), from)
98}
99
100impl super::Scraper {
101    /// HTTP 结果已经出来、0.81 规则认为值得渲染时,按学到的后验决定是否真的渲染。
102    /// 浏览器的影子价格暂取 0(空闲时的值):浏览器池的利用率接口在 0.86 改完后接上。
103    /// 返回决定和它的依据(`metadata.pagesift.plan` 的一步)。
104    pub(super) fn plan_render(
105        &self,
106        url: &str,
107        http: &pagesift_content::Document,
108        html_bytes: usize,
109        hints: &super::RenderHints,
110        opts: &pagesift_api::types::ScrapeOptions,
111        started: std::time::Instant,
112    ) -> (bool, PlanStep) {
113        let q = quality(http, html_bytes);
114        let fallback = self.render_stats.default_timeout();
115        let remaining = super::render::request_deadline(opts, started).map_or(fallback, |d| {
116            d.saturating_duration_since(std::time::Instant::now())
117        });
118        let render_secs = self.render_stats.budget().unwrap_or(fallback).as_secs_f64();
119        let family = render_family(url, hints.client_code);
120        let (go, why) =
121            self.planner
122                .decide(&family, true, render_secs, remaining.as_secs_f64(), 0.0);
123        let step = PlanStep {
124            action: "render".into(),
125            chosen: go,
126            learned: why.learned,
127            p: Some(why.p),
128            value: Some(why.value),
129            charge: Some(why.charge),
130            quality: q,
131            blocks: None,
132        };
133        (go, step)
134    }
135
136    /// 当前内核渲染出的正文太少(0.81 的规则,`thin`)时,是否换下一个内核再试:
137    /// 换 ⇔ `P(下一个内核更好 | 族) > E[渲染秒] · (λ_browser + 1/剩余期限)`(同 `RenderPlanner::decide`),
138    /// 族里还没有数据时照 0.81 的规则换。结果出来后用 [`Self::record_fallback`] 记下有没有变好。
139    pub(super) fn plan_fallback(
140        &self,
141        family: &str,
142        thin: bool,
143        current_quality: Option<f64>,
144        opts: &pagesift_api::types::ScrapeOptions,
145        started: std::time::Instant,
146    ) -> (bool, PlanStep) {
147        if !thin {
148            return (
149                false,
150                PlanStep {
151                    action: "fallback".into(),
152                    quality: current_quality,
153                    ..PlanStep::default()
154                },
155            );
156        }
157        let fallback = self.render_stats.default_timeout();
158        let remaining = super::render::request_deadline(opts, started).map_or(fallback, |d| {
159            d.saturating_duration_since(std::time::Instant::now())
160        });
161        let render_secs = self.render_stats.budget().unwrap_or(fallback).as_secs_f64();
162        let (go, why) =
163            self.planner
164                .decide(family, true, render_secs, remaining.as_secs_f64(), 0.0);
165        let step = PlanStep {
166            action: "fallback".into(),
167            chosen: go,
168            learned: why.learned,
169            p: Some(why.p),
170            value: Some(why.value),
171            charge: Some(why.charge),
172            quality: current_quality,
173            blocks: None,
174        };
175        (go, step)
176    }
177
178    /// 换内核之后记下有没有变好:下一个内核的结果字更多,或者质量分更高(同 [`Self::adopt_render`]:
179    /// 质量分不看篇幅,只能多算增益)。`before`、`after` 是 (质量分, 加权字数)。
180    pub(super) fn record_fallback(
181        &self,
182        family: &str,
183        before: (Option<f64>, usize),
184        after: (Option<f64>, usize),
185    ) {
186        let better_quality = matches!((before.0, after.0), (Some(b), Some(a)) if a > b);
187        self.planner
188            .record(family, after.1 > before.1 || better_quality);
189    }
190
191    /// 渲染回来后决定用哪个结果,并把"渲染有没有增益"记进 planner。
192    /// 三个候选:HTTP、渲染、两者按块合并;合并结果的质量分不低于渲染结果、且确实补进了 HTTP 独有的块时用合并结果。
193    /// 返回 `None` 表示留 HTTP 的结果。质量分算不出来(内置模型没加载)时退回 0.81 的"渲染出的字更多"。
194    /// 第二项是这一步的依据(`adopt`:是否采用渲染或合并的结果;`blocks` 是合并时各块的来源)。
195    pub(super) fn adopt_render(
196        &self,
197        url: &str,
198        http: &pagesift_content::Document,
199        html_bytes: usize,
200        client_code: bool,
201        http_chars: usize,
202        mut rendered: pagesift_api::types::Document,
203    ) -> (Option<pagesift_api::types::Document>, PlanStep) {
204        let mut step = PlanStep {
205            action: "adopt".into(),
206            ..PlanStep::default()
207        };
208        let (Some(qh), Some(qr)) = (quality(http, html_bytes), rendered.quality) else {
209            let more =
210                super::text_weight(rendered.markdown.as_deref().unwrap_or_default()) > http_chars;
211            step.chosen = more;
212            return (more.then_some(rendered), step);
213        };
214        let mut best = qr;
215        let mut merged_md = None;
216        if let Some(rmd) = rendered.markdown.as_deref() {
217            let (md, from) = merge_markdown(&http.markdown, qh, rmd, qr);
218            step.blocks = Some(from);
219            // from[1]:只来自 HTTP 的块数
220            if from[1] > 0 {
221                let mut m = http.clone();
222                m.markdown = md;
223                m.plain_text = None;
224                if let Some(qm) = quality(&m, html_bytes)
225                    && qm >= qr
226                {
227                    best = qm;
228                    merged_md = Some(m.markdown);
229                }
230            }
231        }
232        // 质量分只能增加采用、不能否决 0.81 会采用的结果(渲染出的字更多):质量分不看篇幅,
233        // HTTP 只拿到一小段时分数也可能不低
234        let more =
235            super::text_weight(rendered.markdown.as_deref().unwrap_or_default()) > http_chars;
236        let gain = more || best > qh;
237        self.planner.record(&render_family(url, client_code), gain);
238        step.chosen = gain;
239        step.learned = true;
240        step.value = Some(best - qh);
241        step.quality = Some(if gain { best.max(qh) } else { qh });
242        if !gain {
243            return (None, step);
244        }
245        if let Some(md) = merged_md {
246            rendered.markdown = Some(md);
247            step.action = "merge".into();
248        }
249        (Some(rendered), step)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn cold_start_follows_the_heuristic_then_learns() {
259        let p = RenderPlanner::default();
260        let url = &family("https://example.com/docs/a");
261        assert!(p.decide(url, true, 1.0, 30.0, 0.0).0);
262        assert!(!p.decide(url, false, 1.0, 30.0, 0.0).0);
263        // 同族多次渲染都没有增益之后,即使启发式说要渲染,也基本不再渲染
264        for _ in 0..200 {
265            p.record(&family("https://example.com/docs/b"), false);
266        }
267        let renders = (0..100)
268            .filter(|_| p.decide(url, true, 1.0, 30.0, 0.0).0)
269            .count();
270        assert!(renders < 10, "{renders}");
271        // 别的族不受影响
272        assert!(
273            p.decide(&family("https://example.com/app/x"), true, 1.0, 30.0, 0.0)
274                .0
275        );
276        assert_eq!(
277            family("https://example.com/docs/a?x=1"),
278            "https://example.com/docs"
279        );
280    }
281
282    /// 0.90 用 `p·(1 − Q_http)` 比较代价:HTTP 结果质量分 0.97(基本率 0.951 附近的常见值)时
283    /// 即使这一族每次渲染都有增益也不再渲染。现在只看 `p` 与代价。
284    #[test]
285    fn a_family_where_rendering_always_helps_keeps_rendering() {
286        let p = RenderPlanner::default();
287        let url = &family("https://example.com/spa/a");
288        for _ in 0..50 {
289            p.record(url, true);
290        }
291        let renders = (0..100)
292            .filter(|_| p.decide(url, true, 1.0, 30.0, 0.0).0)
293            .count();
294        assert!(renders > 90, "{renders}");
295        let (_, why) = p.decide(url, true, 1.0, 30.0, 0.0);
296        assert!(why.learned);
297        assert_eq!(why.value, 1.0);
298    }
299
300    #[test]
301    fn merging_fills_in_blocks_the_http_result_missed() {
302        let http = "# T\n\nIntro paragraph here.\n";
303        let rendered = "# T\n\nIntro paragraph here.\n\nLoaded by script.\n";
304        let (md, from) = merge_markdown(http, 0.4, rendered, 0.8);
305        assert_eq!(md, rendered);
306        assert_eq!(from, [2, 0, 1]);
307    }
308
309    #[test]
310    fn embedded_json_fills_in_a_script_rendered_article() {
311        let body = "The council approved the new transit plan on Tuesday after a long debate. \
312                    Supporters said the plan would cut commute times across the city by a third. \
313                    Opponents argued the budget estimates were optimistic and asked for an audit. \
314                    The first new lines are expected to open in two years, officials said.";
315        let html = format!(
316            "<html><head><script type=\"application/ld+json\">{{\"@type\":\"NewsArticle\",\
317             \"headline\":\"Transit plan\",\"articleBody\":\"{body}\"}}</script></head>\
318             <body><div id=app></div><script src=app.js></script></body></html>"
319        );
320        let opts = pagesift_content::Options::default();
321        let mut doc = pagesift_content::process(&html, &opts);
322        let before = doc.markdown.clone();
323        // 内置质量模型随二进制发布(`pagesift_plan::quality` 的测试保证它能解码),这里必须出候选:
324        // 0.90 的这个测试在取不到候选时直接 return,掩盖了小数据岛总是取空的问题
325        assert!(pagesift_plan::quality::QualityModel::builtin().is_some());
326        let step = embedded_json(&mut doc, &html).expect("a candidate from the JSON-LD island");
327        assert_eq!(step.action, "embedded-json");
328        // HTTP 只拿到空壳,数据岛补进整篇正文:质量分应当更高
329        assert!(step.chosen, "{step:?}");
330        assert!(doc.markdown.contains("transit plan"), "{}", doc.markdown);
331        assert_ne!(doc.markdown, before);
332        // 没有数据岛的页面不出候选
333        let mut plain = pagesift_content::process("<p>hello</p>", &opts);
334        assert!(embedded_json(&mut plain, "<p>hello</p>").is_none());
335    }
336}