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) = 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 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 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 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 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 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 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 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; };
325 assert_eq!(step.action, "embedded-json");
326 assert!(step.chosen, "{step:?}");
328 assert!(doc.markdown.contains("transit plan"), "{}", doc.markdown);
329 assert_ne!(doc.markdown, before);
330 let mut plain = pagesift_content::process("<p>hello</p>", &opts);
332 assert!(embedded_json(&mut plain, "<p>hello</p>").is_none());
333 }
334}