use std::collections::BTreeMap;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::emcp::EpistemicTaint;
use crate::tool_executor::ToolResult;
use crate::tool_registry::{ScrapeConfig, ToolEntry};
pub const DEFAULT_BODY_LIMIT: usize = 5 * 1024 * 1024;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub const CRAWL_HARD_PAGE_CAP: usize = 10_000;
pub const CRAWL_MAX_CONCURRENCY: usize = 32;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawPage {
pub status: u16,
pub final_url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
pub body: String,
#[serde(default)]
pub from_cache: bool,
#[serde(default)]
pub truncated: bool,
#[serde(default)]
pub engine: String,
}
impl RawPage {
fn capped(mut self, limit: usize) -> Self {
if self.body.len() > limit {
let mut end = limit;
while end > 0 && !self.body.is_char_boundary(end) {
end -= 1;
}
self.body.truncate(end);
self.truncated = true;
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScrapeError {
MissingArgument(String),
InvalidUrl(String),
RobotsDenied(String),
NoBrowserSidecar,
FetchFailed(String),
Blocked(String),
MalformedPage(String),
}
impl std::fmt::Display for ScrapeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScrapeError::MissingArgument(a) => {
write!(f, "scrape: missing required argument '{a}'")
}
ScrapeError::InvalidUrl(u) => write!(f, "scrape: invalid URL '{u}' (need http/https)"),
ScrapeError::RobotsDenied(u) => {
write!(f, "scrape: robots.txt disallows '{u}' (respect_robots is on)")
}
ScrapeError::NoBrowserSidecar => write!(
f,
"scrape: engine 'browser' requested but no headless sidecar is configured — \
the OSS build has no renderer (this is not a silent empty result)"
),
ScrapeError::FetchFailed(e) => write!(f, "scrape: fetch failed: {e}"),
ScrapeError::Blocked(e) => write!(f, "scrape: request blocked by anti-bot: {e}"),
ScrapeError::MalformedPage(e) => write!(f, "scrape_dom: malformed page argument: {e}"),
}
}
}
impl std::error::Error for ScrapeError {}
#[derive(Debug, Clone)]
pub struct ScrapeOutcome {
pub result: ToolResult,
pub taint: EpistemicTaint,
}
impl ScrapeOutcome {
fn ok(tool_name: &str, output: String) -> Self {
ScrapeOutcome {
result: ToolResult {
success: true,
output,
tool_name: tool_name.to_string(),
},
taint: EpistemicTaint::Untrusted,
}
}
fn err(tool_name: &str, e: ScrapeError) -> Self {
ScrapeOutcome {
result: ToolResult {
success: false,
output: e.to_string(),
tool_name: tool_name.to_string(),
},
taint: EpistemicTaint::Untrusted,
}
}
}
#[derive(Debug, Clone)]
pub struct FetchRequest {
pub url: String,
pub engine: String,
pub impersonate: String,
pub proxy: String,
pub respect_robots: bool,
pub render_wait: String,
pub timeout: Duration,
pub body_limit: usize,
pub tenant: String,
}
pub trait ScrapeFetcher: Send + Sync {
fn fetch(&self, req: &FetchRequest) -> Result<RawPage, ScrapeError>;
fn engine_slug(&self) -> &'static str;
}
static SCRAPE_FETCHER: OnceLock<Arc<dyn ScrapeFetcher>> = OnceLock::new();
pub fn register_scrape_fetcher(fetcher: Arc<dyn ScrapeFetcher>) -> bool {
SCRAPE_FETCHER.set(fetcher).is_ok()
}
pub fn has_registered_fetcher() -> bool {
SCRAPE_FETCHER.get().is_some()
}
pub trait SelectorMemory: Send + Sync {
fn recall(&self, tenant: &str, tool: &str, field: &str, domain: &str) -> Option<String>;
fn learn(&self, tenant: &str, tool: &str, field: &str, domain: &str, selector: &str);
}
fn selector_memory_reg() -> &'static std::sync::RwLock<Option<Arc<dyn SelectorMemory>>> {
static REG: OnceLock<std::sync::RwLock<Option<Arc<dyn SelectorMemory>>>> = OnceLock::new();
REG.get_or_init(|| std::sync::RwLock::new(None))
}
pub fn register_selector_memory(memory: Arc<dyn SelectorMemory>) {
*selector_memory_reg().write().expect("selector memory poisoned") = Some(memory);
}
pub fn clear_selector_memory() {
*selector_memory_reg().write().expect("selector memory poisoned") = None;
}
#[derive(Debug, Clone)]
pub enum ScrapeAuditEvent<'a> {
RobotsDenied { url: &'a str },
Blocked {
url: &'a str,
engine: &'a str,
reason: &'a str,
},
}
pub trait ScrapeAuditSink: Send + Sync {
fn record(&self, tenant: &str, event: ScrapeAuditEvent<'_>);
}
fn scrape_audit_reg() -> &'static std::sync::RwLock<Option<Arc<dyn ScrapeAuditSink>>> {
static REG: OnceLock<std::sync::RwLock<Option<Arc<dyn ScrapeAuditSink>>>> = OnceLock::new();
REG.get_or_init(|| std::sync::RwLock::new(None))
}
pub fn register_scrape_audit_sink(sink: Arc<dyn ScrapeAuditSink>) {
*scrape_audit_reg().write().expect("scrape audit sink poisoned") = Some(sink);
}
pub fn clear_scrape_audit_sink() {
*scrape_audit_reg().write().expect("scrape audit sink poisoned") = None;
}
fn audit_scrape(tenant: &str, event: ScrapeAuditEvent<'_>) {
if tenant.is_empty() {
return;
}
if let Some(s) = scrape_audit_reg().read().expect("scrape audit sink poisoned").as_ref() {
s.record(tenant, event);
}
}
fn recall_selector(tenant: &str, tool: &str, field: &str, domain: &str) -> Option<String> {
if tenant.is_empty() {
return None;
}
selector_memory_reg()
.read()
.expect("selector memory poisoned")
.as_ref()
.and_then(|m| m.recall(tenant, tool, field, domain))
}
fn learn_selector(tenant: &str, tool: &str, field: &str, domain: &str, selector: &str) {
if tenant.is_empty() {
return;
}
if let Some(m) = selector_memory_reg().read().expect("selector memory poisoned").as_ref() {
m.learn(tenant, tool, field, domain, selector);
}
}
fn fetch_page(req: &FetchRequest) -> Result<RawPage, ScrapeError> {
let result = if let Some(f) = SCRAPE_FETCHER.get() {
f.fetch(req)
} else {
default_fetch(req)
};
match &result {
Err(ScrapeError::RobotsDenied(url)) => {
audit_scrape(&req.tenant, ScrapeAuditEvent::RobotsDenied { url });
}
Err(ScrapeError::Blocked(reason)) => {
audit_scrape(
&req.tenant,
ScrapeAuditEvent::Blocked {
url: &req.url,
engine: &req.engine,
reason,
},
);
}
_ => {}
}
result
}
pub const DEFAULT_USER_AGENT: &str = "AxonScrape/1.0 (+https://axon.dev)";
fn default_fetch(req: &FetchRequest) -> Result<RawPage, ScrapeError> {
let url = req.url.trim();
if url.is_empty() || (!url.starts_with("http://") && !url.starts_with("https://")) {
return Err(ScrapeError::InvalidUrl(url.to_string()));
}
if req.engine == "browser" {
return Err(ScrapeError::NoBrowserSidecar);
}
if req.respect_robots && !robots_allows(url, req.timeout) {
return Err(ScrapeError::RobotsDenied(url.to_string()));
}
let client = reqwest::blocking::Client::builder()
.timeout(req.timeout)
.connect_timeout(clamp_connect_timeout(req.timeout))
.user_agent(DEFAULT_USER_AGENT)
.build()
.map_err(|e| ScrapeError::FetchFailed(format!("client build: {e}")))?;
let resp = client.get(url).send().map_err(|e| {
if e.is_timeout() {
ScrapeError::FetchFailed(format!("timed out after {}s", req.timeout.as_secs()))
} else if e.is_connect() {
ScrapeError::FetchFailed(format!("connection failed to {url}"))
} else {
ScrapeError::FetchFailed(e.to_string())
}
})?;
let status = resp.status().as_u16();
let final_url = resp.url().to_string();
if status == 403 || status == 429 {
return Err(ScrapeError::Blocked(format!("HTTP {status} from {final_url}")));
}
let mut headers = BTreeMap::new();
for name in ["content-type", "content-length", "server", "last-modified"] {
if let Some(v) = resp.headers().get(name).and_then(|v| v.to_str().ok()) {
headers.insert(name.to_string(), v.to_string());
}
}
let body = resp
.text()
.map_err(|e| ScrapeError::FetchFailed(format!("read body: {e}")))?;
Ok(RawPage {
status,
final_url,
headers,
body,
from_cache: false,
truncated: false,
engine: "reqwest-fallback".to_string(),
}
.capped(req.body_limit))
}
fn clamp_connect_timeout(total: Duration) -> Duration {
total
.min(Duration::from_secs(15))
.max(Duration::from_secs(1))
}
fn robots_allows(url: &str, timeout: Duration) -> bool {
let (scheme, host, path) = match split_url(url) {
Some(t) => t,
None => return true,
};
let robots_url = format!("{scheme}://{host}/robots.txt");
let client = match reqwest::blocking::Client::builder()
.timeout(timeout.min(Duration::from_secs(10)))
.connect_timeout(clamp_connect_timeout(timeout))
.user_agent(DEFAULT_USER_AGENT)
.build()
{
Ok(c) => c,
Err(_) => return true,
};
let body = match client.get(&robots_url).send().and_then(|r| r.text()) {
Ok(b) => b,
Err(_) => return true,
};
robots_path_allowed(&body, &path)
}
pub fn robots_path_allowed(robots_txt: &str, path: &str) -> bool {
let mut in_star = false;
let mut applicable = false;
let mut best: Option<(usize, bool)> = None;
for raw in robots_txt.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let (key, val) = match line.split_once(':') {
Some((k, v)) => (k.trim().to_ascii_lowercase(), v.trim().to_string()),
None => continue,
};
match key.as_str() {
"user-agent" => {
if val == "*" {
in_star = true;
applicable = true;
} else {
in_star = false;
}
}
"disallow" | "allow" if in_star || applicable && in_star => {
if val.is_empty() {
continue;
}
if path.starts_with(&val) {
let is_allow = key == "allow";
let len = val.len();
if best.map(|(bl, _)| len > bl).unwrap_or(true) {
best = Some((len, is_allow));
}
}
}
_ => {}
}
}
match best {
Some((_, is_allow)) => is_allow,
None => true,
}
}
fn split_url(url: &str) -> Option<(String, String, String)> {
let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
("https", r)
} else if let Some(r) = url.strip_prefix("http://") {
("http", r)
} else {
return None;
};
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
let host = authority.rsplit('@').next().unwrap_or(authority);
let path = path.split('#').next().unwrap_or(path);
Some((scheme.to_string(), host.to_string(), path.to_string()))
}
pub fn dispatch_scrape(entry: &ToolEntry, argument: &str) -> ToolResult {
dispatch_scrape_outcome(entry, argument).result
}
pub fn dispatch_scrape_outcome(entry: &ToolEntry, argument: &str) -> ScrapeOutcome {
let cfg = entry.scrape.clone().unwrap_or_default();
let args = parse_args(argument);
match entry.provider.as_str() {
"scrape_http" => run_scrape_http(&entry.name, &cfg, &entry.timeout, &args),
"scrape_dom" => run_scrape_dom(&entry.name, &cfg, &args),
"scrape_crawl" => run_scrape_http(&entry.name, &cfg, &entry.timeout, &args),
other => ScrapeOutcome::err(
&entry.name,
ScrapeError::FetchFailed(format!("unknown scrape provider '{other}'")),
),
}
}
fn parse_args(argument: &str) -> serde_json::Value {
let trimmed = argument.trim_start();
if trimmed.starts_with('{') {
serde_json::from_str(argument).unwrap_or_else(|_| serde_json::json!({ "input": argument }))
} else {
serde_json::json!({ "input": argument, "url": argument, "page": argument })
}
}
fn arg_str<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> {
args.get(key).and_then(|v| v.as_str())
}
fn run_scrape_http(
name: &str,
cfg: &ScrapeConfig,
timeout: &str,
args: &serde_json::Value,
) -> ScrapeOutcome {
let url = match arg_str(args, "url") {
Some(u) if !u.is_empty() => u.to_string(),
_ => return ScrapeOutcome::err(name, ScrapeError::MissingArgument("url".into())),
};
let req = FetchRequest {
url,
engine: cfg.effective_engine().to_string(),
impersonate: cfg.impersonate.clone(),
proxy: cfg.proxy.clone(),
respect_robots: cfg.respect_robots,
render_wait: cfg.render_wait.clone(),
timeout: crate::http_tool::parse_timeout_pub(timeout).unwrap_or(DEFAULT_TIMEOUT),
body_limit: DEFAULT_BODY_LIMIT,
tenant: cfg.tenant.clone(),
};
match fetch_page(&req) {
Ok(page) => match serde_json::to_string(&page) {
Ok(json) => ScrapeOutcome::ok(name, json),
Err(e) => ScrapeOutcome::err(name, ScrapeError::FetchFailed(format!("encode: {e}"))),
},
Err(e) => ScrapeOutcome::err(name, e),
}
}
fn run_scrape_dom(name: &str, cfg: &ScrapeConfig, args: &serde_json::Value) -> ScrapeOutcome {
let domain_of = |url: &str| split_url(url).map(|(_, host, _)| host).unwrap_or_default();
let (html, domain) = match args.get("page") {
Some(serde_json::Value::String(s)) => {
match serde_json::from_str::<RawPage>(s) {
Ok(p) => {
let d = domain_of(&p.final_url);
(p.body, d)
}
Err(_) => (s.clone(), String::new()),
}
}
Some(v @ serde_json::Value::Object(_)) => match serde_json::from_value::<RawPage>(v.clone())
{
Ok(p) => {
let d = domain_of(&p.final_url);
(p.body, d)
}
Err(e) => return ScrapeOutcome::err(name, ScrapeError::MalformedPage(e.to_string())),
},
_ => return ScrapeOutcome::err(name, ScrapeError::MissingArgument("page".into())),
};
let extracted = extract_fields(
&html,
&cfg.extract,
cfg.adaptive,
cfg.similarity_floor,
&cfg.tenant,
name,
&domain,
);
match serde_json::to_string(&extracted) {
Ok(json) => ScrapeOutcome::ok(name, json),
Err(e) => ScrapeOutcome::err(name, ScrapeError::MalformedPage(format!("encode: {e}"))),
}
}
#[derive(Debug, Clone)]
struct Element {
tag: String,
id: String,
classes: Vec<String>,
text: String,
}
#[derive(Debug, Clone, Default)]
struct Selector {
tag: Option<String>,
id: Option<String>,
class: Option<String>,
}
fn parse_selector(sel: &str) -> Selector {
let sel = sel.trim();
let mut out = Selector::default();
let mut i = 0;
let bytes = sel.as_bytes();
let mut j = 0;
while j < bytes.len() && bytes[j] != b'.' && bytes[j] != b'#' {
j += 1;
}
if j > 0 {
out.tag = Some(sel[..j].to_ascii_lowercase());
}
i = j;
while i < bytes.len() {
let marker = bytes[i];
let start = i + 1;
let mut k = start;
while k < bytes.len() && bytes[k] != b'.' && bytes[k] != b'#' {
k += 1;
}
let frag = &sel[start..k];
match marker {
b'#' => out.id = Some(frag.to_string()),
b'.' => out.class = Some(frag.to_string()),
_ => {}
}
i = k;
}
out
}
fn matches(el: &Element, s: &Selector) -> bool {
if let Some(t) = &s.tag {
if &el.tag != t {
return false;
}
}
if let Some(id) = &s.id {
if &el.id != id {
return false;
}
}
if let Some(c) = &s.class {
if !el.classes.iter().any(|x| x == c) {
return false;
}
}
true
}
const SCANNED_TAGS: &[&str] = &[
"h1", "h2", "h3", "h4", "h5", "h6", "p", "a", "span", "div", "article", "section", "li", "td",
"th", "title", "strong", "em", "b", "i",
];
fn scan_elements(html: &str) -> Vec<Element> {
use regex::Regex;
static RES: OnceLock<Vec<(String, Regex)>> = OnceLock::new();
let res = RES.get_or_init(|| {
SCANNED_TAGS
.iter()
.map(|t| {
(
t.to_string(),
Regex::new(&format!(r#"(?is)<{t}\b([^>]*)>(.*?)</{t}>"#))
.expect("static per-tag scrape regex is valid"),
)
})
.collect()
});
let attr_id = attr_regex("id");
let attr_class = attr_regex("class");
let tag_strip = tag_strip_regex();
let mut out: Vec<(usize, Element)> = Vec::new();
for (tag, re) in res {
for cap in re.captures_iter(html) {
let whole = cap.get(0).unwrap();
let attrs = cap.get(1).map(|m| m.as_str()).unwrap_or("");
let inner = cap.get(2).map(|m| m.as_str()).unwrap_or("");
let id = attr_id
.captures(attrs)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.unwrap_or_default();
let classes = attr_class
.captures(attrs)
.and_then(|c| c.get(1))
.map(|m| m.as_str().split_whitespace().map(|s| s.to_string()).collect())
.unwrap_or_default();
let text = tag_strip.replace_all(inner, " ");
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
out.push((
whole.start(),
Element {
tag: tag.clone(),
id,
classes,
text,
},
));
}
}
out.sort_by_key(|(start, _)| *start);
out.into_iter().map(|(_, e)| e).collect()
}
fn attr_regex(name: &str) -> regex::Regex {
regex::Regex::new(&format!(r#"(?i)\b{name}\s*=\s*["']([^"']*)["']"#)).expect("attr regex valid")
}
fn tag_strip_regex() -> regex::Regex {
static RE: OnceLock<regex::Regex> = OnceLock::new();
RE.get_or_init(|| regex::Regex::new(r"(?s)<[^>]*>").expect("tag strip regex valid"))
.clone()
}
fn extract_fields(
html: &str,
specs: &[String],
adaptive: bool,
similarity_floor: f64,
tenant: &str,
tool: &str,
domain: &str,
) -> serde_json::Value {
let elements = scan_elements(html);
let mut obj = serde_json::Map::new();
for spec in specs {
let (field, selector_str) = match spec.split_once('=') {
Some((f, s)) => (f.trim(), s.trim()),
None => {
obj.insert(spec.clone(), serde_json::Value::Null);
continue;
}
};
if let Some(learned) = recall_selector(tenant, tool, field, domain) {
let lsel = parse_selector(&learned);
if let Some(el) = elements.iter().find(|el| matches(el, &lsel)) {
obj.insert(field.to_string(), serde_json::Value::String(el.text.clone()));
continue;
}
}
let sel = parse_selector(selector_str);
if let Some(el) = elements.iter().find(|el| matches(el, &sel)) {
obj.insert(field.to_string(), serde_json::Value::String(el.text.clone()));
continue;
}
let value = if adaptive {
match relocate(&elements, &sel, similarity_floor) {
Some((text, learned_selector)) => {
learn_selector(tenant, tool, field, domain, &learned_selector);
Some(text)
}
None => None,
}
} else {
None
};
obj.insert(
field.to_string(),
value.map(serde_json::Value::String).unwrap_or(serde_json::Value::Null),
);
}
serde_json::Value::Object(obj)
}
fn relocate(elements: &[Element], sel: &Selector, similarity_floor: f64) -> Option<(String, String)> {
if sel.tag.is_none() && sel.id.is_none() && sel.class.is_none() {
return None;
}
let floor = if similarity_floor.is_nan() {
1.0
} else {
similarity_floor.clamp(0.0, 1.0)
};
let mut best: Option<(f64, &Element)> = None;
for el in elements {
let score = selector_similarity(sel, el);
if score + f64::EPSILON < floor {
continue;
}
match best {
Some((b, _)) if b >= score => {}
_ => best = Some((score, el)),
}
}
best.map(|(_, el)| (el.text.clone(), reconstruct_selector(el)))
}
fn reconstruct_selector(el: &Element) -> String {
if !el.id.is_empty() {
format!("{}#{}", el.tag, el.id)
} else if let Some(c) = el.classes.first() {
format!("{}.{}", el.tag, c)
} else {
el.tag.clone()
}
}
fn selector_similarity(sel: &Selector, el: &Element) -> f64 {
const W_TAG: f64 = 0.5;
const W_CLASS: f64 = 0.35;
const W_ID: f64 = 0.35;
let (mut score, mut weight) = (0.0, 0.0);
if let Some(t) = &sel.tag {
weight += W_TAG;
if &el.tag == t {
score += W_TAG;
}
}
if let Some(c) = &sel.class {
weight += W_CLASS;
let a = tokenize(c);
let mut b = std::collections::HashSet::new();
for cls in &el.classes {
b.extend(tokenize(cls));
}
score += W_CLASS * jaccard(&a, &b);
}
if let Some(id) = &sel.id {
weight += W_ID;
score += W_ID * jaccard(&tokenize(id), &tokenize(&el.id));
}
if weight == 0.0 {
0.0
} else {
(score / weight).clamp(0.0, 1.0)
}
}
fn tokenize(s: &str) -> std::collections::HashSet<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(|t| t.to_ascii_lowercase())
.collect()
}
fn jaccard(
a: &std::collections::HashSet<String>,
b: &std::collections::HashSet<String>,
) -> f64 {
let union = a.union(b).count();
if union == 0 {
return 0.0;
}
a.intersection(b).count() as f64 / union as f64
}
use async_trait::async_trait;
use crate::tool_trait::{Tool, ToolChunk, ToolContext, ToolFinishReason, ToolStream};
pub struct ScrapeStreamingTool {
name: String,
cfg: ScrapeConfig,
timeout: Duration,
}
impl ScrapeStreamingTool {
pub fn from_entry(entry: &ToolEntry) -> Self {
ScrapeStreamingTool {
name: entry.name.clone(),
cfg: entry.scrape.clone().unwrap_or_default(),
timeout: crate::http_tool::parse_timeout_pub(&entry.timeout).unwrap_or(DEFAULT_TIMEOUT),
}
}
fn page_budget(&self) -> usize {
let declared = self.cfg.max_pages.max(0) as usize;
if declared == 0 {
CRAWL_HARD_PAGE_CAP
} else {
declared.min(CRAWL_HARD_PAGE_CAP)
}
}
}
fn extract_links(html: &str, base: &str) -> Vec<String> {
use regex::Regex;
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(r#"(?i)<a\b[^>]*\bhref\s*=\s*["']([^"'#]+)["']"#).expect("href regex valid")
});
let mut out = Vec::new();
for cap in re.captures_iter(html) {
if let Some(m) = cap.get(1) {
if let Some(abs) = join_url(base, m.as_str()) {
out.push(abs);
}
}
}
out
}
fn join_url(base: &str, href: &str) -> Option<String> {
if href.starts_with("http://") || href.starts_with("https://") {
return Some(href.to_string());
}
let (scheme, host, path) = split_url(base)?;
if let Some(rooted) = href.strip_prefix('/') {
return Some(format!("{scheme}://{host}/{rooted}"));
}
let dir = match path.rfind('/') {
Some(i) => &path[..=i],
None => "/",
};
Some(format!("{scheme}://{host}{dir}{href}"))
}
#[async_trait]
impl Tool for ScrapeStreamingTool {
async fn execute(&self, args: String, _ctx: ToolContext) -> ToolResult {
let entry_cfg = self.cfg.clone();
let timeout = self.timeout;
let name = self.name.clone();
let args_owned = args.clone();
match tokio::task::spawn_blocking(move || {
let parsed = parse_args(&args_owned);
let entry = ToolEntry {
name: name.clone(),
provider: "scrape_http".to_string(),
timeout: format!("{}s", timeout.as_secs()),
runtime: String::new(),
resource_ref: String::new(),
capacity: None,
sandbox: None,
max_results: None,
output_schema: String::new(),
effect_row: vec!["network".into(), "web".into()],
parameters: Vec::new(),
secret: String::new(),
secret_partition: String::new(),
source: crate::tool_registry::ToolSource::Program,
is_streaming: true,
scrape: Some(entry_cfg),
};
let seed = parsed
.get("seed")
.and_then(|v| v.as_str())
.or_else(|| parsed.get("url").and_then(|v| v.as_str()))
.unwrap_or("")
.to_string();
run_scrape_http(
&entry.name,
&entry.scrape.clone().unwrap_or_default(),
&entry.timeout,
&serde_json::json!({ "url": seed }),
)
.result
})
.await
{
Ok(r) => r,
Err(e) => ToolResult {
success: false,
output: format!("scrape_crawl '{}': join failed: {e}", self.name),
tool_name: self.name.clone(),
},
}
}
async fn stream(&self, args: String, ctx: ToolContext) -> ToolStream {
let name = self.name.clone();
let cfg = self.cfg.clone();
let timeout = self.timeout;
let budget = self.page_budget();
let max_depth = self.cfg.max_depth.max(0) as usize;
let follow = !self.cfg.follow.is_empty();
let cancel = ctx.cancel.clone();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ToolChunk>();
tokio::spawn(async move {
let send_term = |reason: ToolFinishReason| {
let _ = tx.send(ToolChunk::terminator("", reason));
};
let seed = {
let parsed = parse_args(&args);
parsed
.get("seed")
.and_then(|v| v.as_str())
.or_else(|| parsed.get("url").and_then(|v| v.as_str()))
.unwrap_or("")
.to_string()
};
if seed.is_empty() {
send_term(ToolFinishReason::Error {
message: format!("scrape_crawl '{name}': missing 'seed' argument"),
});
return;
}
let concurrency =
(cfg.concurrency.max(1) as usize).min(CRAWL_MAX_CONCURRENCY);
let mut frontier: std::collections::VecDeque<(String, usize)> =
std::collections::VecDeque::new();
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
frontier.push_back((seed, 0));
let mut succeeded = 0usize;
let mut in_flight = futures::stream::FuturesUnordered::new();
loop {
if cancel.is_cancelled() {
send_term(ToolFinishReason::Cancelled);
return;
}
while in_flight.len() < concurrency && succeeded + in_flight.len() < budget {
let Some((url, depth)) = frontier.pop_front() else {
break; };
if !visited.insert(url.clone()) {
continue; }
let req = FetchRequest {
url,
engine: cfg.effective_engine().to_string(),
impersonate: cfg.impersonate.clone(),
proxy: cfg.proxy.clone(),
respect_robots: cfg.respect_robots,
render_wait: cfg.render_wait.clone(),
timeout,
body_limit: DEFAULT_BODY_LIMIT,
tenant: cfg.tenant.clone(),
};
let handle = tokio::task::spawn_blocking(move || fetch_page(&req));
in_flight.push(async move { (depth, handle.await) });
}
if in_flight.is_empty() {
break;
}
let Some((depth, joined)) =
futures::StreamExt::next(&mut in_flight).await
else {
break;
};
match joined {
Ok(Ok(page)) => {
succeeded += 1;
if follow && depth < max_depth {
for link in extract_links(&page.body, &page.final_url) {
if !visited.contains(&link) {
frontier.push_back((link, depth + 1));
}
}
}
match serde_json::to_string(&page) {
Ok(json) => {
if tx.send(ToolChunk::intermediate(json)).is_err() {
return; }
}
Err(e) => {
let _ = tx.send(ToolChunk::intermediate(
ScrapeError::FetchFailed(format!("encode: {e}")).to_string(),
));
}
}
}
Ok(Err(e)) => {
let _ = tx.send(ToolChunk::intermediate(e.to_string()));
}
Err(e) => {
let _ = tx.send(ToolChunk::intermediate(format!("crawl join failed: {e}")));
}
}
}
send_term(ToolFinishReason::Stop);
});
Box::pin(futures::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|chunk| (chunk, rx))
}))
}
fn is_streaming(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_dom(extract: &[&str], adaptive: bool, floor: f64) -> ScrapeConfig {
ScrapeConfig {
extract: extract.iter().map(|s| s.to_string()).collect(),
adaptive,
similarity_floor: floor,
..Default::default()
}
}
fn dom_entry(name: &str, cfg: ScrapeConfig) -> ToolEntry {
ToolEntry {
name: name.to_string(),
provider: "scrape_dom".to_string(),
timeout: String::new(),
runtime: String::new(),
resource_ref: String::new(),
capacity: None,
sandbox: None,
max_results: None,
output_schema: String::new(),
effect_row: vec!["web".to_string()],
parameters: Vec::new(),
secret: String::new(),
secret_partition: String::new(),
source: crate::tool_registry::ToolSource::Program,
is_streaming: false,
scrape: Some(cfg),
}
}
#[test]
fn dom_output_is_born_untrusted() {
let html = r#"<html><body><h1>Hello</h1><p class="lead">World</p></body></html>"#;
let entry = dom_entry("Ex", cfg_dom(&["title=h1", "lead=p.lead"], false, 0.0));
let out = dispatch_scrape_outcome(&entry, &format!("{{\"page\": {:?} }}", html));
assert!(out.result.success, "output: {}", out.result.output);
assert_eq!(out.taint, EpistemicTaint::Untrusted);
let v: serde_json::Value = serde_json::from_str(&out.result.output).unwrap();
assert_eq!(v["title"], "Hello");
assert_eq!(v["lead"], "World");
}
#[test]
fn dom_selector_subset_id_class_tag() {
let html = r#"<div id="main"><span class="price">$9</span><a href="/x">buy</a></div>"#;
let out = extract_fields(
html,
&[
"p=#main".to_string(), "price=.price".to_string(),
"cta=a".to_string(),
],
false,
0.0,
"", "", "",
);
assert_eq!(out["price"], "$9");
assert_eq!(out["cta"], "buy");
}
#[test]
fn dom_miss_without_adaptive_is_null() {
let html = "<h1>Title</h1>";
let out = extract_fields(html, &["x=.nope".to_string()], false, 0.0, "", "", "");
assert_eq!(out["x"], serde_json::Value::Null);
}
#[test]
fn dom_adaptive_relocates_by_tag() {
let html = "<h1>Relocated</h1>";
let out = extract_fields(html, &["t=h1.headline".to_string()], true, 0.5, "", "", "");
assert_eq!(out["t"], "Relocated");
}
#[test]
fn dom_adaptive_respects_floor() {
let html = "<h1>X</h1>";
let out = extract_fields(html, &["t=.only-class".to_string()], true, 0.5, "", "", "");
assert_eq!(out["t"], serde_json::Value::Null);
}
#[test]
fn dom_adaptive_relocates_across_class_drift() {
let html = r#"<span class="product-price">$42</span>"#;
let out = extract_fields(html, &["p=span.price".to_string()], true, 0.6, "", "", "");
assert_eq!(out["p"], "$42");
}
#[test]
fn dom_adaptive_below_floor_is_null_not_a_wrong_field() {
let html = r#"<span class="footer-legal">unrelated</span>"#;
let out = extract_fields(html, &["p=span.price".to_string()], true, 0.75, "", "", "");
assert_eq!(
out["p"],
serde_json::Value::Null,
"a low-similarity element must not be fabricated as the field"
);
}
#[test]
fn dom_adaptive_picks_best_scoring_candidate() {
let html = r#"<span class="nav">Home</span><span class="unit-price">$9</span>"#;
let out = extract_fields(html, &["p=span.price".to_string()], true, 0.6, "", "", "");
assert_eq!(out["p"], "$9");
}
#[test]
fn dom_selector_memory_learns_then_recalls_a_drift() {
use std::collections::HashMap;
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
let _g = LOCK.lock().unwrap();
#[derive(Default)]
struct Mem {
map: Mutex<HashMap<String, String>>,
}
impl SelectorMemory for Mem {
fn recall(&self, t: &str, tool: &str, f: &str, d: &str) -> Option<String> {
self.map.lock().unwrap().get(&format!("{t}|{tool}|{f}|{d}")).cloned()
}
fn learn(&self, t: &str, tool: &str, f: &str, d: &str, sel: &str) {
self.map.lock().unwrap().insert(format!("{t}|{tool}|{f}|{d}"), sel.to_string());
}
}
let mem = Arc::new(Mem::default());
register_selector_memory(mem.clone());
let html = r#"<span class="product-price">$42</span>"#;
let out = extract_fields(
html,
&["p=span.price".to_string()],
true,
0.6,
"kivi",
"Harvest",
"shop.acme.com",
);
assert_eq!(out["p"], "$42");
assert_eq!(
mem.recall("kivi", "Harvest", "p", "shop.acme.com").as_deref(),
Some("span.product-price"),
"the recovered selector must be learned"
);
let out2 = extract_fields(
html,
&["p=span.price".to_string()],
false,
1.0,
"kivi",
"Harvest",
"shop.acme.com",
);
assert_eq!(out2["p"], "$42", "the learned selector heals the drift on recall");
assert!(mem.recall("other", "Harvest", "p", "shop.acme.com").is_none());
clear_selector_memory();
}
#[test]
fn scrape_audit_sink_records_only_for_a_stamped_tenant() {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
let _g = LOCK.lock().unwrap();
#[derive(Default)]
struct Sink {
rows: Mutex<Vec<String>>,
}
impl ScrapeAuditSink for Sink {
fn record(&self, tenant: &str, event: ScrapeAuditEvent<'_>) {
let row = match event {
ScrapeAuditEvent::RobotsDenied { url } => format!("{tenant}|robots|{url}"),
ScrapeAuditEvent::Blocked { url, engine, reason } => {
format!("{tenant}|blocked|{url}|{engine}|{reason}")
}
};
self.rows.lock().unwrap().push(row);
}
}
let sink = Arc::new(Sink::default());
register_scrape_audit_sink(sink.clone());
audit_scrape("kivi", ScrapeAuditEvent::RobotsDenied { url: "https://x.com/p" });
audit_scrape(
"kivi",
ScrapeAuditEvent::Blocked { url: "https://x.com/q", engine: "browser", reason: "turnstile" },
);
audit_scrape("", ScrapeAuditEvent::RobotsDenied { url: "https://x.com/z" });
let rows = sink.rows.lock().unwrap().clone();
assert_eq!(rows, vec![
"kivi|robots|https://x.com/p".to_string(),
"kivi|blocked|https://x.com/q|browser|turnstile".to_string(),
]);
clear_scrape_audit_sink();
}
#[test]
fn body_cap_truncates_on_char_boundary() {
let page = RawPage {
status: 200,
final_url: "https://e.x".into(),
headers: BTreeMap::new(),
body: "áéíóú".repeat(1000),
from_cache: false,
truncated: false,
engine: "test".into(),
}
.capped(10);
assert!(page.truncated);
assert!(page.body.len() <= 10);
assert!(std::str::from_utf8(page.body.as_bytes()).is_ok());
}
#[test]
fn robots_longest_match_wins() {
let robots = "User-agent: *\nDisallow: /private\nAllow: /private/public\n";
assert!(!robots_path_allowed(robots, "/private/secret"));
assert!(robots_path_allowed(robots, "/private/public/x"));
assert!(robots_path_allowed(robots, "/open"));
}
#[test]
fn robots_no_rules_allows() {
assert!(robots_path_allowed("", "/anything"));
assert!(robots_path_allowed("User-agent: Googlebot\nDisallow: /", "/x"));
}
#[test]
fn split_url_extracts_parts() {
let (s, h, p) = split_url("https://ex.com:8080/a/b?q=1#frag").unwrap();
assert_eq!(s, "https");
assert_eq!(h, "ex.com:8080");
assert_eq!(p, "/a/b?q=1");
assert!(split_url("ftp://x").is_none());
}
#[test]
fn browser_engine_without_sidecar_is_typed_refusal() {
let req = FetchRequest {
url: "https://example.com".into(),
engine: "browser".into(),
impersonate: String::new(),
proxy: String::new(),
respect_robots: false,
render_wait: String::new(),
timeout: Duration::from_secs(2),
body_limit: DEFAULT_BODY_LIMIT,
tenant: String::new(),
};
assert!(matches!(default_fetch(&req), Err(ScrapeError::NoBrowserSidecar)));
}
#[test]
fn invalid_url_is_typed_refusal() {
let req = FetchRequest {
url: "notaurl".into(),
engine: "impersonate".into(),
impersonate: String::new(),
proxy: String::new(),
respect_robots: false,
render_wait: String::new(),
timeout: Duration::from_secs(2),
body_limit: DEFAULT_BODY_LIMIT,
tenant: String::new(),
};
assert!(matches!(default_fetch(&req), Err(ScrapeError::InvalidUrl(_))));
}
#[test]
fn missing_url_argument_is_error() {
let entry = ToolEntry {
name: "F".into(),
provider: "scrape_http".into(),
timeout: String::new(),
runtime: String::new(),
resource_ref: String::new(),
capacity: None,
sandbox: None,
max_results: None,
output_schema: String::new(),
effect_row: vec!["network".into(), "web".into()],
parameters: Vec::new(),
secret: String::new(),
secret_partition: String::new(),
source: crate::tool_registry::ToolSource::Program,
is_streaming: false,
scrape: Some(ScrapeConfig::default()),
};
let out = dispatch_scrape_outcome(&entry, "{}");
assert!(!out.result.success);
assert!(out.result.output.contains("url"));
}
}