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) = self.planner.decide(
121            &family,
122            true,
123            q.unwrap_or(0.0),
124            render_secs,
125            remaining.as_secs_f64(),
126            0.0,
127        );
128        let step = PlanStep {
129            action: "render".into(),
130            chosen: go,
131            learned: why.learned,
132            p: Some(why.p),
133            value: Some(why.value),
134            charge: Some(why.charge),
135            quality: q,
136            blocks: None,
137        };
138        (go, step)
139    }
140
141    /// 当前内核渲染出的正文太少(0.81 的规则,`thin`)时,是否换下一个内核再试:
142    /// 换 ⇔ `P(下一个内核更好 | 族) · (1 − Q_当前) > E[渲染秒] · (λ_browser + 1/剩余期限)`,
143    /// 族里还没有数据时照 0.81 的规则换。结果出来后用 [`Self::record_fallback`] 记下有没有变好。
144    pub(super) fn plan_fallback(
145        &self,
146        family: &str,
147        thin: bool,
148        current_quality: Option<f64>,
149        opts: &pagesift_api::types::ScrapeOptions,
150        started: std::time::Instant,
151    ) -> (bool, PlanStep) {
152        if !thin {
153            return (
154                false,
155                PlanStep {
156                    action: "fallback".into(),
157                    quality: current_quality,
158                    ..PlanStep::default()
159                },
160            );
161        }
162        let fallback = self.render_stats.default_timeout();
163        let remaining = super::render::request_deadline(opts, started).map_or(fallback, |d| {
164            d.saturating_duration_since(std::time::Instant::now())
165        });
166        let render_secs = self.render_stats.budget().unwrap_or(fallback).as_secs_f64();
167        let (go, why) = self.planner.decide(
168            family,
169            true,
170            current_quality.unwrap_or(0.0),
171            render_secs,
172            remaining.as_secs_f64(),
173            0.0,
174        );
175        let step = PlanStep {
176            action: "fallback".into(),
177            chosen: go,
178            learned: why.learned,
179            p: Some(why.p),
180            value: Some(why.value),
181            charge: Some(why.charge),
182            quality: current_quality,
183            blocks: None,
184        };
185        (go, step)
186    }
187
188    /// 换内核之后记下有没有变好:下一个内核的结果字更多,或者质量分更高(同 [`Self::adopt_render`]:
189    /// 质量分不看篇幅,只能多算增益)。`before`、`after` 是 (质量分, 加权字数)。
190    pub(super) fn record_fallback(
191        &self,
192        family: &str,
193        before: (Option<f64>, usize),
194        after: (Option<f64>, usize),
195    ) {
196        let better_quality = matches!((before.0, after.0), (Some(b), Some(a)) if a > b);
197        self.planner
198            .record(family, after.1 > before.1 || better_quality);
199    }
200
201    /// 渲染回来后决定用哪个结果,并把"渲染有没有增益"记进 planner。
202    /// 三个候选:HTTP、渲染、两者按块合并;合并结果的质量分不低于渲染结果、且确实补进了 HTTP 独有的块时用合并结果。
203    /// 返回 `None` 表示留 HTTP 的结果。质量分算不出来(内置模型没加载)时退回 0.81 的"渲染出的字更多"。
204    /// 第二项是这一步的依据(`adopt`:是否采用渲染或合并的结果;`blocks` 是合并时各块的来源)。
205    pub(super) fn adopt_render(
206        &self,
207        url: &str,
208        http: &pagesift_content::Document,
209        html_bytes: usize,
210        client_code: bool,
211        http_chars: usize,
212        mut rendered: pagesift_api::types::Document,
213    ) -> (Option<pagesift_api::types::Document>, PlanStep) {
214        let mut step = PlanStep {
215            action: "adopt".into(),
216            ..PlanStep::default()
217        };
218        let (Some(qh), Some(qr)) = (quality(http, html_bytes), rendered.quality) else {
219            let more =
220                super::text_weight(rendered.markdown.as_deref().unwrap_or_default()) > http_chars;
221            step.chosen = more;
222            return (more.then_some(rendered), step);
223        };
224        let mut best = qr;
225        let mut merged_md = None;
226        if let Some(rmd) = rendered.markdown.as_deref() {
227            let (md, from) = merge_markdown(&http.markdown, qh, rmd, qr);
228            step.blocks = Some(from);
229            // from[1]:只来自 HTTP 的块数
230            if from[1] > 0 {
231                let mut m = http.clone();
232                m.markdown = md;
233                m.plain_text = None;
234                if let Some(qm) = quality(&m, html_bytes)
235                    && qm >= qr
236                {
237                    best = qm;
238                    merged_md = Some(m.markdown);
239                }
240            }
241        }
242        // 质量分只能增加采用、不能否决 0.81 会采用的结果(渲染出的字更多):质量分不看篇幅,
243        // HTTP 只拿到一小段时分数也可能不低
244        let more =
245            super::text_weight(rendered.markdown.as_deref().unwrap_or_default()) > http_chars;
246        let gain = more || best > qh;
247        self.planner.record(&render_family(url, client_code), gain);
248        step.chosen = gain;
249        step.learned = true;
250        step.value = Some(best - qh);
251        step.quality = Some(if gain { best.max(qh) } else { qh });
252        if !gain {
253            return (None, step);
254        }
255        if let Some(md) = merged_md {
256            rendered.markdown = Some(md);
257            step.action = "merge".into();
258        }
259        (Some(rendered), step)
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn cold_start_follows_the_heuristic_then_learns() {
269        let p = RenderPlanner::default();
270        let url = &family("https://example.com/docs/a");
271        assert!(p.decide(url, true, 0.5, 1.0, 30.0, 0.0).0);
272        assert!(!p.decide(url, false, 0.5, 1.0, 30.0, 0.0).0);
273        // 同族多次渲染都没有增益之后,即使启发式说要渲染,也基本不再渲染
274        for _ in 0..200 {
275            p.record(&family("https://example.com/docs/b"), false);
276        }
277        let renders = (0..100)
278            .filter(|_| p.decide(url, true, 0.5, 1.0, 30.0, 0.0).0)
279            .count();
280        assert!(renders < 10, "{renders}");
281        // 别的族不受影响
282        assert!(
283            p.decide(
284                &family("https://example.com/app/x"),
285                true,
286                0.5,
287                1.0,
288                30.0,
289                0.0
290            )
291            .0
292        );
293        assert_eq!(
294            family("https://example.com/docs/a?x=1"),
295            "https://example.com/docs"
296        );
297    }
298
299    #[test]
300    fn merging_fills_in_blocks_the_http_result_missed() {
301        let http = "# T\n\nIntro paragraph here.\n";
302        let rendered = "# T\n\nIntro paragraph here.\n\nLoaded by script.\n";
303        let (md, from) = merge_markdown(http, 0.4, rendered, 0.8);
304        assert_eq!(md, rendered);
305        assert_eq!(from, [2, 0, 1]);
306    }
307
308    #[test]
309    fn embedded_json_fills_in_a_script_rendered_article() {
310        let body = "The council approved the new transit plan on Tuesday after a long debate. \
311                    Supporters said the plan would cut commute times across the city by a third. \
312                    Opponents argued the budget estimates were optimistic and asked for an audit. \
313                    The first new lines are expected to open in two years, officials said.";
314        let html = format!(
315            "<html><head><script type=\"application/ld+json\">{{\"@type\":\"NewsArticle\",\
316             \"headline\":\"Transit plan\",\"articleBody\":\"{body}\"}}</script></head>\
317             <body><div id=app></div><script src=app.js></script></body></html>"
318        );
319        let opts = pagesift_content::Options::default();
320        let mut doc = pagesift_content::process(&html, &opts);
321        let before = doc.markdown.clone();
322        let Some(step) = embedded_json(&mut doc, &html) else {
323            return; // 内置质量模型没加载时不做
324        };
325        assert_eq!(step.action, "embedded-json");
326        // HTTP 只拿到空壳,数据岛补进整篇正文:质量分应当更高
327        assert!(step.chosen, "{step:?}");
328        assert!(doc.markdown.contains("transit plan"), "{}", doc.markdown);
329        assert_ne!(doc.markdown, before);
330        // 没有数据岛的页面不出候选
331        let mut plain = pagesift_content::process("<p>hello</p>", &opts);
332        assert!(embedded_json(&mut plain, "<p>hello</p>").is_none());
333    }
334}