pub use pagesift_plan::render::*;
use pagesift_api::types::PlanStep;
use pagesift_plan::merge::{Candidate, Quality, merge};
use pagesift_stat::Delta;
const STORE_KEY: &[u8] = b"plan/render";
pub fn save(planner: &RenderPlanner, store: &pagesift_store::Store) -> pagesift_store::Result<()> {
store.tasks().kv_put(STORE_KEY, &planner.encode())
}
pub fn load(planner: &RenderPlanner, store: &pagesift_store::Store) {
if let Ok(Some(buf)) = store.tasks().kv_get(STORE_KEY) {
planner.decode_merge(&buf);
}
}
pub fn embedded_json(doc: &mut pagesift_content::Document, html: &str) -> Option<PlanStep> {
let blocks = pagesift_plan::embedded_json::extract(html, Delta::DEFAULT);
if blocks.is_empty() {
return None;
}
let qh = quality(doc, html.len())?;
let http_md = std::mem::replace(&mut doc.markdown, pagesift_plan::block::join(&blocks));
let qj = quality(doc, html.len());
let json_md = std::mem::replace(&mut doc.markdown, http_md);
let qj = qj?;
let (md, from) = merge_markdown(&doc.markdown, qh, &json_md, qj);
let mut step = PlanStep {
action: "embedded-json".into(),
learned: true,
blocks: Some(from),
quality: Some(qh),
..PlanStep::default()
};
if from[2] == 0 {
return Some(step);
}
let http_md = std::mem::replace(&mut doc.markdown, md);
let qm = quality(doc, html.len()).unwrap_or(qh);
step.value = Some(qm - qh);
if qm > qh {
step.chosen = true;
step.quality = Some(qm);
doc.plain_text = None;
} else {
doc.markdown = http_md;
}
Some(step)
}
pub fn merge_markdown(
http: &str,
http_quality: f64,
rendered: &str,
rendered_quality: f64,
) -> (String, [usize; 3]) {
let point = |q: f64| Quality {
mean: q,
lo: q,
hi: q,
};
let m = merge(
&Candidate {
blocks: pagesift_plan::block::split(http),
quality: point(http_quality),
},
&Candidate {
blocks: pagesift_plan::block::split(rendered),
quality: point(rendered_quality),
},
Delta::default(),
);
let mut from = [0; 3]; for f in &m.from {
from[match f {
pagesift_plan::merge::From::Both => 0,
pagesift_plan::merge::From::Left => 1,
pagesift_plan::merge::From::Right => 2,
}] += 1;
}
(m.markdown(), from)
}
impl super::Scraper {
pub(super) fn plan_render(
&self,
url: &str,
http: &pagesift_content::Document,
html_bytes: usize,
hints: &super::RenderHints,
opts: &pagesift_api::types::ScrapeOptions,
started: std::time::Instant,
) -> (bool, PlanStep) {
let q = quality(http, html_bytes);
let fallback = self.render_stats.default_timeout();
let remaining = super::render::request_deadline(opts, started).map_or(fallback, |d| {
d.saturating_duration_since(std::time::Instant::now())
});
let render_secs = self.render_stats.budget().unwrap_or(fallback).as_secs_f64();
let family = render_family(url, hints.client_code);
let (go, why) =
self.planner
.decide(&family, true, render_secs, remaining.as_secs_f64(), 0.0);
let step = PlanStep {
action: "render".into(),
chosen: go,
learned: why.learned,
p: Some(why.p),
value: Some(why.value),
charge: Some(why.charge),
quality: q,
blocks: None,
};
(go, step)
}
pub(super) fn plan_fallback(
&self,
family: &str,
thin: bool,
current_quality: Option<f64>,
opts: &pagesift_api::types::ScrapeOptions,
started: std::time::Instant,
) -> (bool, PlanStep) {
if !thin {
return (
false,
PlanStep {
action: "fallback".into(),
quality: current_quality,
..PlanStep::default()
},
);
}
let fallback = self.render_stats.default_timeout();
let remaining = super::render::request_deadline(opts, started).map_or(fallback, |d| {
d.saturating_duration_since(std::time::Instant::now())
});
let render_secs = self.render_stats.budget().unwrap_or(fallback).as_secs_f64();
let (go, why) =
self.planner
.decide(family, true, render_secs, remaining.as_secs_f64(), 0.0);
let step = PlanStep {
action: "fallback".into(),
chosen: go,
learned: why.learned,
p: Some(why.p),
value: Some(why.value),
charge: Some(why.charge),
quality: current_quality,
blocks: None,
};
(go, step)
}
pub(super) fn record_fallback(
&self,
family: &str,
before: (Option<f64>, usize),
after: (Option<f64>, usize),
) {
let better_quality = matches!((before.0, after.0), (Some(b), Some(a)) if a > b);
self.planner
.record(family, after.1 > before.1 || better_quality);
}
pub(super) fn adopt_render(
&self,
url: &str,
http: &pagesift_content::Document,
html_bytes: usize,
client_code: bool,
http_chars: usize,
mut rendered: pagesift_api::types::Document,
) -> (Option<pagesift_api::types::Document>, PlanStep) {
let mut step = PlanStep {
action: "adopt".into(),
..PlanStep::default()
};
let (Some(qh), Some(qr)) = (quality(http, html_bytes), rendered.quality) else {
let more =
super::text_weight(rendered.markdown.as_deref().unwrap_or_default()) > http_chars;
step.chosen = more;
return (more.then_some(rendered), step);
};
let mut best = qr;
let mut merged_md = None;
if let Some(rmd) = rendered.markdown.as_deref() {
let (md, from) = merge_markdown(&http.markdown, qh, rmd, qr);
step.blocks = Some(from);
if from[1] > 0 {
let mut m = http.clone();
m.markdown = md;
m.plain_text = None;
if let Some(qm) = quality(&m, html_bytes)
&& qm >= qr
{
best = qm;
merged_md = Some(m.markdown);
}
}
}
let more =
super::text_weight(rendered.markdown.as_deref().unwrap_or_default()) > http_chars;
let gain = more || best > qh;
self.planner.record(&render_family(url, client_code), gain);
step.chosen = gain;
step.learned = true;
step.value = Some(best - qh);
step.quality = Some(if gain { best.max(qh) } else { qh });
if !gain {
return (None, step);
}
if let Some(md) = merged_md {
rendered.markdown = Some(md);
step.action = "merge".into();
}
(Some(rendered), step)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cold_start_follows_the_heuristic_then_learns() {
let p = RenderPlanner::default();
let url = &family("https://example.com/docs/a");
assert!(p.decide(url, true, 1.0, 30.0, 0.0).0);
assert!(!p.decide(url, false, 1.0, 30.0, 0.0).0);
for _ in 0..200 {
p.record(&family("https://example.com/docs/b"), false);
}
let renders = (0..100)
.filter(|_| p.decide(url, true, 1.0, 30.0, 0.0).0)
.count();
assert!(renders < 10, "{renders}");
assert!(
p.decide(&family("https://example.com/app/x"), true, 1.0, 30.0, 0.0)
.0
);
assert_eq!(
family("https://example.com/docs/a?x=1"),
"https://example.com/docs"
);
}
#[test]
fn a_family_where_rendering_always_helps_keeps_rendering() {
let p = RenderPlanner::default();
let url = &family("https://example.com/spa/a");
for _ in 0..50 {
p.record(url, true);
}
let renders = (0..100)
.filter(|_| p.decide(url, true, 1.0, 30.0, 0.0).0)
.count();
assert!(renders > 90, "{renders}");
let (_, why) = p.decide(url, true, 1.0, 30.0, 0.0);
assert!(why.learned);
assert_eq!(why.value, 1.0);
}
#[test]
fn merging_fills_in_blocks_the_http_result_missed() {
let http = "# T\n\nIntro paragraph here.\n";
let rendered = "# T\n\nIntro paragraph here.\n\nLoaded by script.\n";
let (md, from) = merge_markdown(http, 0.4, rendered, 0.8);
assert_eq!(md, rendered);
assert_eq!(from, [2, 0, 1]);
}
#[test]
fn embedded_json_fills_in_a_script_rendered_article() {
let body = "The council approved the new transit plan on Tuesday after a long debate. \
Supporters said the plan would cut commute times across the city by a third. \
Opponents argued the budget estimates were optimistic and asked for an audit. \
The first new lines are expected to open in two years, officials said.";
let html = format!(
"<html><head><script type=\"application/ld+json\">{{\"@type\":\"NewsArticle\",\
\"headline\":\"Transit plan\",\"articleBody\":\"{body}\"}}</script></head>\
<body><div id=app></div><script src=app.js></script></body></html>"
);
let opts = pagesift_content::Options::default();
let mut doc = pagesift_content::process(&html, &opts);
let before = doc.markdown.clone();
assert!(pagesift_plan::quality::QualityModel::builtin().is_some());
let step = embedded_json(&mut doc, &html).expect("a candidate from the JSON-LD island");
assert_eq!(step.action, "embedded-json");
assert!(step.chosen, "{step:?}");
assert!(doc.markdown.contains("transit plan"), "{}", doc.markdown);
assert_ne!(doc.markdown, before);
let mut plain = pagesift_content::process("<p>hello</p>", &opts);
assert!(embedded_json(&mut plain, "<p>hello</p>").is_none());
}
}