use pagesift_api::types::ScrapeOptions;
use pagesift_api::{ApiError, ErrorCode};
use pagesift_html::{Html, NodeRef};
use pagesift_llm::{ChatClient, ChatMessage, ChatRequest, ResponseFormat, Role};
use serde_json::{Value, json};
use super::extract::llm_error;
pub(super) fn per_node_chars(budget_tokens: usize) -> usize {
(budget_tokens / 64).max(80) }
pub fn snapshot_budget(context_tokens: usize, prompt_tokens: usize, max_output: usize) -> usize {
context_tokens.saturating_sub(prompt_tokens + max_output)
}
fn role(tag: &str) -> &'static str {
match tag {
"a" => "link",
"button" => "button",
"input" | "textarea" => "textbox",
"select" => "combobox",
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "heading",
"img" => "img",
"li" => "listitem",
"ul" | "ol" => "list",
"table" => "table",
"tr" => "row",
"td" | "th" => "cell",
"nav" => "navigation",
"main" => "main",
"article" => "article",
"form" => "form",
"p" => "paragraph",
_ => "generic",
}
}
const KEY_ATTRS: &[&str] = &[
"id",
"class",
"href",
"src",
"alt",
"name",
"type",
"aria-label",
"title",
"itemprop",
"data-testid",
"role",
];
pub fn snapshot<'a>(doc: &'a Html<'a>, budget: usize) -> (String, Vec<NodeRef<'a>>) {
let cap = per_node_chars(budget);
let mut out = String::new();
let mut nodes = Vec::new();
let mut used = 0usize;
for n in doc.root().descendants() {
let Some(el) = n.as_element() else { continue };
if matches!(
el.name(),
"script" | "style" | "noscript" | "template" | "head" | "svg"
) {
continue;
}
let own: String = n
.children()
.filter_map(|c| c.as_text())
.collect::<Vec<_>>()
.join(" ");
let text: String = own.split_whitespace().collect::<Vec<_>>().join(" ");
let attrs: Vec<String> = KEY_ATTRS
.iter()
.filter_map(|k| el.attr(k).map(|v| format!("{k}={}", truncate(v, cap / 2))))
.collect();
if text.is_empty() && attrs.is_empty() {
continue;
}
let line = format!(
"[{}] {} <{}> {:?} {}\n",
nodes.len(),
role(el.name()),
el.name(),
truncate(&text, cap),
attrs.join(" ")
);
let t = model_tokens(&line);
if used + t > budget {
break;
}
used += t;
out.push_str(&line);
nodes.push(n);
}
(out, nodes)
}
pub fn model_tokens(s: &str) -> usize {
s.len().div_ceil(3) }
fn truncate(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
pub const SCHEMA_SYSTEM_TEXT: &str = SCHEMA_SYSTEM;
const SCHEMA_SYSTEM: &str = "You write CSS extraction schemas for pagesift. Reply with one JSON object only: \
{\"baseSelector\": \"<CSS selector of each repeated item, or of the single item>\", \"fields\": [{\"name\": \"<field>\", \
\"selector\": \"<CSS selector relative to the item>\", \"type\": \"text\" | \"attribute\" | \"html\", \"attribute\": \"<attr, when type is attribute>\"}]}. \
Use selectors that exist in the page snapshot (tags, ids, classes, attributes shown there). Prefer stable attributes and \
semantic class names over positions. Do not invent fields that are not on the page.";
#[derive(Debug, Default, Clone, Copy)]
pub struct LlmUse {
pub calls: usize,
pub tokens: usize,
}
pub(super) async fn ask(
llm: &ChatClient,
system: &str,
user: String,
max_out: usize,
used: &mut LlmUse,
) -> Result<Value, ApiError> {
let max_out = match llm.model_limits().await {
Some((_, Some(limit))) => max_out.min(limit),
_ => max_out,
};
let req = ChatRequest {
messages: vec![
ChatMessage::new(Role::System, system),
ChatMessage::new(Role::User, user.clone()),
],
temperature: Some(0.0),
max_tokens: Some(max_out.min(u32::MAX as usize) as u32),
response_format: Some(ResponseFormat::JsonObject),
};
used.calls += 1;
let resp = llm.chat(&req).await.map_err(llm_error)?;
used.tokens += pagesift_extract::TokenIndex::new(system).len()
+ pagesift_extract::TokenIndex::new(&user).len()
+ pagesift_extract::TokenIndex::new(&resp.content).len();
let body = resp.content.trim();
let body = body
.strip_prefix("```json")
.or_else(|| body.strip_prefix("```"))
.map(|b| b.trim_end_matches("```").trim())
.unwrap_or(body);
serde_json::from_str(body).map_err(|e| {
ApiError::new(
ErrorCode::UpstreamError,
format!("the LLM did not return JSON: {e}"),
)
})
}
pub fn validate_on(doc: &Html<'_>, schema: &Value) -> Result<Vec<Value>, String> {
let s = pagesift_extract::Schema::from_json(schema).map_err(|e| e.to_string())?;
let items = pagesift_extract::JsonCssExtractor::new(&s)
.map_err(|e| e.to_string())?
.extract_document(doc);
if items.is_empty() {
return Err(format!(
"the schema extracted nothing: baseSelector {:?} matched no element with any field",
s.base_selector
));
}
Ok(items)
}
pub async fn generate(
llm: &ChatClient,
intent: &str,
url: &str,
html: &str,
budget: usize,
used: &mut LlmUse,
) -> Result<Value, ApiError> {
let doc = Html::parse(html);
let (snap, _) = snapshot(&doc, budget);
let prompt = format!("URL: {url}\nWhat to extract: {intent}\n\nPage snapshot:\n{snap}");
let first = ask(llm, SCHEMA_SYSTEM, prompt.clone(), budget, used).await?;
match validate_on(&doc, &first) {
Ok(_) => Ok(first),
Err(why) => {
let retry = format!(
"{prompt}\n\nYour previous schema was:\n{first}\nIt failed on this page: {why}\nReturn a corrected schema."
);
let second = ask(llm, SCHEMA_SYSTEM, retry, budget, used).await?;
validate_on(&doc, &second).map_err(|why| {
ApiError::new(
ErrorCode::UpstreamError,
format!(
"the generated schema does not work on the page after one correction: {why}"
),
)
})?;
Ok(second)
}
}
}
const FIELD_SYSTEM: &str = "You find CSS selectors for pagesift. Reply with one JSON object only: \
{\"selector\": \"<CSS selector relative to the item element>\", \"type\": \"text\" | \"attribute\" | \"html\", \"attribute\": \"<attr or null>\"}. \
Use only tags, classes and attributes that appear in the snapshot of the item.";
pub async fn resolve_field(
llm: &ChatClient,
describe: &str,
item_html: &str,
budget: usize,
used: &mut LlmUse,
) -> Result<Value, ApiError> {
let doc = Html::parse(item_html);
let (snap, _) = snapshot(&doc, budget);
ask(
llm,
FIELD_SYSTEM,
format!("Field: {describe}\n\nItem snapshot:\n{snap}"),
budget,
used,
)
.await
}
const BASE_SYSTEM: &str = "You find CSS selectors for pagesift. Reply with one JSON object only: \
{\"selector\": \"<CSS selector matching every repeated item that holds these fields, or the single item>\"}. \
Use only tags, classes and attributes that appear in the page snapshot.";
pub async fn resolve_base(
llm: &ChatClient,
fields: &str,
html: &str,
budget: usize,
used: &mut LlmUse,
) -> Result<Value, ApiError> {
let doc = Html::parse(html);
let (snap, _) = snapshot(&doc, budget);
ask(
llm,
BASE_SYSTEM,
format!("Fields of each item: {fields}\n\nPage snapshot:\n{snap}"),
budget,
used,
)
.await
}
pub async fn observe(
llm: &ChatClient,
instruction: &str,
html: &str,
budget: usize,
used: &mut LlmUse,
) -> Result<Vec<Value>, ApiError> {
let doc = Html::parse(html);
let (snap, nodes) = snapshot(&doc, budget);
let v = ask(
llm,
"You pick page elements for an action. Reply with JSON {\"ids\": [<snapshot ids, best first>]}.",
format!("Instruction: {instruction}\n\nPage snapshot:\n{snap}"),
budget,
used,
)
.await?;
let mut out = Vec::new();
for id in v["ids"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
{
let Some(n) = nodes.get(id as usize) else {
continue;
};
let Some(sel) = pagesift_extract::locate::robust_selector(*n, doc.root()) else {
continue;
};
let el = n.as_element();
let name = el
.and_then(|e| e.attr("aria-label").or(e.attr("title")))
.map(str::to_string)
.unwrap_or_else(|| n.text().split_whitespace().collect::<Vec<_>>().join(" "));
out.push(
json!({ "selector": sel, "role": role(el.map_or("", |e| e.name())), "name": name }),
);
}
Ok(out)
}
#[derive(Debug, Clone, Default)]
pub struct ActionSecret {
pub value: String,
pub hosts: Vec<String>,
}
impl ActionSecret {
fn allows(&self, host: &str) -> bool {
self.hosts.iter().any(|h| {
let h = h.trim_end_matches('.');
host.eq_ignore_ascii_case(h)
|| host.len().checked_sub(h.len() + 1).is_some_and(|i| {
host.as_bytes()[i] == b'.' && host[i + 1..].eq_ignore_ascii_case(h)
})
})
}
}
pub fn fill_secrets(
actions: &mut [Value],
credentials: &std::collections::BTreeMap<String, ActionSecret>,
page_url: &str,
) -> Result<(), ApiError> {
let parsed = url::Url::parse(page_url).ok();
let host = parsed
.as_ref()
.and_then(url::Url::host_str)
.unwrap_or_default();
let host = host.trim_end_matches('.');
for a in actions {
let Some(text) = a.get("text").and_then(Value::as_str) else {
continue;
};
let mut out = String::new();
let mut rest = text;
while let Some(i) = rest.find("{{secret:") {
out.push_str(&rest[..i]);
let after = &rest[i + "{{secret:".len()..];
let Some(j) = after.find("}}") else {
return Err(ApiError::invalid("unterminated {{secret:...}} placeholder"));
};
let name = &after[..j];
let v = credentials
.get(name)
.filter(|c| c.allows(host))
.ok_or_else(|| {
ApiError::invalid(format!("secret {name:?} is not available on this page"))
})?;
out.push_str(&v.value);
rest = &after[j + 2..];
}
out.push_str(rest);
a["text"] = json!(out);
}
Ok(())
}
pub fn cache_key(url: &str, intent: &str) -> Option<String> {
keyed("autoschema", url, intent)
}
pub fn keyed(kind: &str, url: &str, intent: &str) -> Option<String> {
let u = url::Url::parse(url).ok()?;
let site = pagesift_content::site_key(u.host_str()?);
let shape = pagesift_frontier::focus::url_shape(&u);
let h = blake3::hash(intent.trim().to_lowercase().as_bytes()).to_hex();
Some(format!("{kind}/{site}{shape}#{}", &h[..16]))
}
pub fn intent_of(opts: &ScrapeOptions) -> Option<String> {
let f = opts.formats.iter().find(|f| f.kind == "extract")?;
if f.options.contains_key("schema") {
return None;
}
f.options.get("intent")?.as_str().map(str::to_string)
}
impl super::Scraper {
pub async fn observe(
&self,
url: &str,
instruction: &str,
) -> Result<(Vec<Value>, LlmUse), ApiError> {
let llm = self.llm.as_ref().ok_or_else(|| {
ApiError::unsupported("observe needs an LLM: set [llm] in pagesift.toml")
})?;
let opts: ScrapeOptions = serde_json::from_value(json!({ "formats": ["rawHtml"] }))
.map_err(|e| ApiError::invalid(e.to_string()))?;
let doc = self.scrape(url, &opts).await?;
let html = doc.raw_html.unwrap_or_default();
let mut used = LlmUse::default();
let budget = self.snapshot_budget_pub().await;
let c = observe(llm, instruction, &html, budget, &mut used).await?;
Ok((c, used))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_respects_budget_and_secrets_stay_local() {
let html = "<ul>".to_string()
+ &(0..200)
.map(|i| format!("<li class='item'><a href='/p/{i}'>Product {i}</a></li>"))
.collect::<String>()
+ "</ul>";
let doc = Html::parse(&html);
let (snap, nodes) = snapshot(&doc, 300);
assert!(model_tokens(&snap) <= 300);
assert!(!nodes.is_empty() && nodes.len() < 200);
assert!(snap.contains("link <a> \"Product 0\" href=/p/0"), "{snap}");
let mut acts = vec![json!({"type": "write", "text": "user {{secret:PW}}!"})];
let creds = [(
"PW".to_string(),
ActionSecret {
value: "hunter2".to_string(),
hosts: vec!["example.com".to_string()],
},
)]
.into();
fill_secrets(&mut acts, &creds, "https://login.example.com/").unwrap();
assert_eq!(acts[0]["text"], "user hunter2!");
let mut bad = vec![json!({"type": "write", "text": "{{secret:NOPE}}"})];
let unknown = fill_secrets(&mut bad, &creds, "https://example.com/").unwrap_err();
for page in ["https://evil.test/", "https://notexample.com/", "not a url"] {
let mut acts = vec![json!({"type": "write", "text": "{{secret:PW}}"})];
let refused = fill_secrets(&mut acts, &creds, page).unwrap_err();
assert_eq!(acts[0]["text"], "{{secret:PW}}", "{page}");
assert!(
refused.message.contains("not available on this page"),
"{page}"
);
assert!(unknown.message.contains("not available on this page"));
}
let a = cache_key("https://www.shop.example.co.uk/item/123?ref=x", "Prices").unwrap();
let b = cache_key("https://a.example.co.uk/item/9?ref=1", " prices ").unwrap();
assert_eq!(a, b);
assert!(
a.starts_with("autoschema/example.co.uk/item/{n}?ref#"),
"{a}"
);
}
}