use scraper::{Html, Selector};
use crate::result::status::Status;
#[derive(Debug, Clone)]
pub struct PageContext {
pub url: String,
pub title: String,
pub meta_description: String,
pub meta_keywords: String,
pub h1: String,
pub headings: String,
pub content_markdown: String,
pub lang: String,
pub canonical: String,
pub robots: String,
pub og_present: bool,
pub browser_diagnostics: Option<String>,
}
impl PageContext {
pub fn build(
status: &Status,
uq_id: &str,
url: &str,
options: &crate::options::core_options::CoreOptions,
) -> Option<PageContext> {
let html = status.get_url_body_text(uq_id)?;
let document = Html::parse_document(&html);
let title = select_text(&document, "title").unwrap_or_default();
let meta_description = select_meta(&document, "description");
let meta_keywords = select_meta(&document, "keywords");
let h1 = select_text(&document, "h1").unwrap_or_default();
let lang = select_html_lang(&document);
let headings = extract_heading_outline(&document);
let canonical = select_link_href(&document, "canonical");
let robots = select_meta(&document, "robots");
let og_present = has_opengraph(&document);
let browser_diagnostics = status.get_browser_diagnostics(uq_id).map(|d| {
d.to_ai_payload(
options.console_max_messages.max(0) as usize,
options.console_msg_max_chars.max(0) as usize,
options.console_total_max_kb.max(0) as usize,
)
});
let content_markdown = crate::export::markdown_exporter::convert_html_string_to_markdown(
&html,
ai_content_exclude_selectors(),
true, false, true, );
Some(PageContext {
url: url.to_string(),
title,
meta_description,
meta_keywords,
h1,
headings,
content_markdown,
lang,
canonical,
robots,
og_present,
browser_diagnostics,
})
}
}
fn select_text(document: &Html, selector: &str) -> Option<String> {
let sel = Selector::parse(selector).ok()?;
document
.select(&sel)
.next()
.map(|el| el.text().collect::<String>().trim().to_string())
.filter(|s| !s.is_empty())
}
fn select_meta(document: &Html, name: &str) -> String {
let selector = format!(r#"meta[name="{}"]"#, name);
if let Ok(sel) = Selector::parse(&selector)
&& let Some(el) = document.select(&sel).next()
&& let Some(content) = el.value().attr("content")
{
return content.trim().to_string();
}
String::new()
}
fn ai_content_exclude_selectors() -> Vec<String> {
[
"nav",
"header",
"footer",
"aside",
"[role=\"navigation\"]",
"[role=\"banner\"]",
"[role=\"contentinfo\"]",
"[role=\"complementary\"]",
"[role=\"search\"]",
".skip-link",
".skip-to-content",
".sr-only",
".visually-hidden",
".screen-reader-text",
"script",
"style",
"noscript",
"svg",
]
.iter()
.map(|s| s.to_string())
.collect()
}
fn select_link_href(document: &Html, rel: &str) -> String {
let selector = format!(r#"link[rel="{}"]"#, rel);
if let Ok(sel) = Selector::parse(&selector)
&& let Some(el) = document.select(&sel).next()
&& let Some(href) = el.value().attr("href")
{
return href.trim().to_string();
}
String::new()
}
fn has_opengraph(document: &Html) -> bool {
if let Ok(sel) = Selector::parse(r#"meta[property^="og:"], meta[name^="twitter:"]"#) {
return document.select(&sel).next().is_some();
}
false
}
fn select_html_lang(document: &Html) -> String {
if let Ok(sel) = Selector::parse("html")
&& let Some(el) = document.select(&sel).next()
&& let Some(lang) = el.value().attr("lang")
{
return lang.trim().to_string();
}
String::new()
}
fn extract_heading_outline(document: &Html) -> String {
let sel = match Selector::parse("h1, h2, h3, h4") {
Ok(s) => s,
Err(_) => return String::new(),
};
let mut lines = Vec::new();
for el in document.select(&sel) {
let tag = el.value().name();
let level: usize = tag.trim_start_matches('h').parse().unwrap_or(1);
let text = el.text().collect::<String>().trim().to_string();
if text.is_empty() {
continue;
}
let indent = " ".repeat(level.saturating_sub(1));
lines.push(format!("{}{}: {}", indent, tag.to_uppercase(), text));
if lines.len() >= 60 {
break;
}
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_canonical_robots_and_og() {
let html = r#"<html lang="en"><head>
<link rel="canonical" href="https://x.test/page">
<meta name="robots" content="noindex, nofollow">
<meta property="og:title" content="Hi">
</head><body><h1>H</h1></body></html>"#;
let doc = Html::parse_document(html);
assert_eq!(select_link_href(&doc, "canonical"), "https://x.test/page");
assert_eq!(select_meta(&doc, "robots"), "noindex, nofollow");
assert!(has_opengraph(&doc));
}
#[test]
fn no_og_when_absent() {
let doc = Html::parse_document("<html><head></head><body></body></html>");
assert!(!has_opengraph(&doc));
assert_eq!(select_link_href(&doc, "canonical"), "");
}
#[test]
fn detects_twitter_card_as_og() {
let doc = Html::parse_document(r#"<html><head><meta name="twitter:card" content="summary"></head></html>"#);
assert!(has_opengraph(&doc));
}
#[test]
fn content_markdown_strips_chrome_boilerplate() {
let html = r##"<html><body>
<header><a href="#main">Skip to content</a>
<nav><a href="/a">Products</a> <a href="/b">Pricing</a></nav>
</header>
<main><h1>Real Title</h1><p>The actual content paragraph that matters.</p></main>
<footer><p>Copyright 2026 Example Bank</p></footer>
</body></html>"##;
let md = crate::export::markdown_exporter::convert_html_string_to_markdown(
html,
ai_content_exclude_selectors(),
true,
false,
true,
);
assert!(md.contains("The actual content paragraph that matters"));
assert!(!md.contains("Skip to content"));
assert!(!md.contains("Copyright 2026"));
}
}