1pub use pagesift_plan::render::*;
5
6use pagesift_api::types::PlanStep;
7use pagesift_plan::merge::{Candidate, Quality, merge};
8use pagesift_stat::Delta;
9
10const STORE_KEY: &[u8] = b"plan/render";
12
13pub fn save(planner: &RenderPlanner, store: &pagesift_store::Store) -> pagesift_store::Result<()> {
15 store.tasks().kv_put(STORE_KEY, &planner.encode())
16}
17
18pub 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
25pub 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 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 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
64pub 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]; 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 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 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 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 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 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 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 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 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 #[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 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 assert!(step.chosen, "{step:?}");
330 assert!(doc.markdown.contains("transit plan"), "{}", doc.markdown);
331 assert_ne!(doc.markdown, before);
332 let mut plain = pagesift_content::process("<p>hello</p>", &opts);
334 assert!(embedded_json(&mut plain, "<p>hello</p>").is_none());
335 }
336}