use std::path::Path;
use std::sync::OnceLock;
use std::time::Duration;
use openpaw_core::{Result, ToolCallOutcome, ToolCategory, ToolConcurrency, ToolSpec};
use crate::web::web_fetch::{check_dns_rebinding, loopback_test_allowance, validate_url_ssrf};
const DEFAULT_MAX_CHARS: usize = 30_000;
const MAX_CHARS_CAP: usize = 1_000_000;
const MAX_BODY_BYTES: usize = 5 * 1024 * 1024;
const MAX_REDIRECTS: usize = 5;
const USER_AGENT: &str = "openpaw/0.1";
pub fn spec() -> ToolSpec {
ToolSpec {
name: "web_extract".into(),
description: "Fetch a URL and extract structured content in one step. Provides multiple \
output formats: readable text (strips scripts/styles), markdown (preserves \
structure), links (all hyperlinks), or a comprehensive summary combining content and \
links. Use this for research, data collection, and content analysis tasks."
.into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch and extract content from"
},
"format": {
"type": "string",
"enum": ["text", "markdown", "links", "summary"],
"description": "Extraction format: 'text' for readable text only, 'markdown' \
for structured markdown, 'links' for all hyperlinks, 'summary' for \
markdown + links combined. Default: summary.",
"default": "summary"
},
"max_chars": {
"type": "integer",
"description": "Maximum characters to return. Default: 30000.",
"default": 30000
}
},
"required": ["url"]
}),
category: Some(ToolCategory::Network),
concurrency: Some(ToolConcurrency::ParallelSafe),
is_destructive: Some(false),
..Default::default()
}
}
pub async fn run(_workspace: &Path, args: serde_json::Value) -> Result<ToolCallOutcome> {
let Some(url_str) = args.get("url").and_then(serde_json::Value::as_str) else {
return Ok(ToolCallOutcome::error(
"web_extract: missing or non-string 'url'",
));
};
let format = args
.get("format")
.and_then(serde_json::Value::as_str)
.unwrap_or("summary")
.to_string();
if !matches!(format.as_str(), "text" | "markdown" | "links" | "summary") {
return Ok(ToolCallOutcome::error(format!(
"web_extract: invalid format '{format}' (expected one of: text, markdown, links, summary)"
)));
}
let max_chars = match args.get("max_chars") {
None | Some(serde_json::Value::Null) => DEFAULT_MAX_CHARS,
Some(v) => match v.as_u64() {
Some(n) => (n as usize).min(MAX_CHARS_CAP),
None => {
return Ok(ToolCallOutcome::error(
"web_extract: 'max_chars' must be a non-negative integer",
))
}
},
};
let mut current_url = match url::Url::parse(url_str) {
Ok(u) => u,
Err(e) => {
return Ok(ToolCallOutcome::error(format!(
"web_extract: invalid URL: {e}"
)))
}
};
let allow_loopback = loopback_test_allowance();
if let Err(msg) = validate_url_ssrf(¤t_url, allow_loopback) {
return Ok(ToolCallOutcome::error(format!("web_extract: {msg}")));
}
if let Err(msg) = check_dns_rebinding(¤t_url, allow_loopback).await {
return Ok(ToolCallOutcome::error(format!("web_extract: {msg}")));
}
let client = http_client();
let mut resp = match client
.get(current_url.as_str())
.header("User-Agent", USER_AGENT)
.send()
.await
{
Ok(r) => r,
Err(e) => return Ok(ToolCallOutcome::error(format!("web_extract: {e}"))),
};
for _ in 0..MAX_REDIRECTS {
if !resp.status().is_redirection() {
break;
}
let Some(location) = resp.headers().get("location") else {
break;
};
let location_str = location.to_str().unwrap_or("");
let redirect_url = match current_url.join(location_str) {
Ok(u) => u,
Err(e) => {
return Ok(ToolCallOutcome::error(format!(
"web_extract: bad redirect URL: {e}"
)))
}
};
if let Err(msg) = validate_url_ssrf(&redirect_url, allow_loopback) {
return Ok(ToolCallOutcome::error(format!(
"web_extract: redirect blocked: {msg}"
)));
}
if let Err(msg) = check_dns_rebinding(&redirect_url, allow_loopback).await {
return Ok(ToolCallOutcome::error(format!(
"web_extract: redirect blocked: {msg}"
)));
}
current_url = redirect_url;
resp = match client
.get(current_url.as_str())
.header("User-Agent", USER_AGENT)
.send()
.await
{
Ok(r) => r,
Err(e) => {
return Ok(ToolCallOutcome::error(format!(
"web_extract: redirect failed: {e}"
)))
}
};
}
if !resp.status().is_success() {
return Ok(ToolCallOutcome::error(format!(
"web_extract: HTTP {}",
resp.status()
)));
}
if let Some(len) = resp.content_length() {
if len > MAX_BODY_BYTES as u64 {
return Ok(ToolCallOutcome::error(format!(
"web_extract: response too large: Content-Length {len} bytes (max {MAX_BODY_BYTES})"
)));
}
}
let body = match resp.bytes().await {
Ok(bytes) => {
if bytes.len() > MAX_BODY_BYTES {
return Ok(ToolCallOutcome::error(format!(
"web_extract: response too large: {} bytes (max {MAX_BODY_BYTES})",
bytes.len()
)));
}
String::from_utf8_lossy(&bytes).into_owned()
}
Err(e) => {
return Ok(ToolCallOutcome::error(format!(
"web_extract: read body: {e}"
)))
}
};
let links = extract_links(&body, ¤t_url);
let content = match format.as_str() {
"links" => format!(
"=== Links from {current_url} ===\n{}",
format_link_list(&links)
),
"summary" => {
let mut out = crate::web::html_to_md::html_to_markdown(&body);
out.push_str("\n\n## Links\n");
out.push_str(&format_link_list(&links));
out
}
_ => crate::web::html_to_md::html_to_markdown(&body),
};
Ok(ToolCallOutcome::ok(truncate_output(content, max_chars)))
}
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build shared reqwest client for web_extract")
})
}
#[derive(Debug)]
struct ExtractedLink {
url: String,
text: String,
}
fn extract_links(html: &str, base: &url::Url) -> Vec<ExtractedLink> {
let re = anchor_regex();
let mut out: Vec<ExtractedLink> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for cap in re.captures_iter(html) {
let href_raw = cap
.get(1)
.or_else(|| cap.get(2))
.or_else(|| cap.get(3))
.map(|m| m.as_str())
.unwrap_or("")
.trim();
let href = decode_html_entities(href_raw);
if href.is_empty() || href.to_ascii_lowercase().starts_with("javascript:") {
continue;
}
let absolute = match base.join(&href) {
Ok(u) => u,
Err(_) => continue,
};
let url_str = absolute.to_string();
if !seen.insert(url_str.clone()) {
continue;
}
let text = plain_text(cap.get(4).map(|m| m.as_str()).unwrap_or(""));
out.push(ExtractedLink { url: url_str, text });
}
out
}
fn format_link_list(links: &[ExtractedLink]) -> String {
if links.is_empty() {
return "(no links found)".to_string();
}
let mut out = String::new();
for l in links {
if l.text.is_empty() {
out.push_str(&format!("- {}\n", l.url));
} else {
out.push_str(&format!("- [{}]({})\n", l.text, l.url));
}
}
out
}
fn anchor_regex() -> &'static regex::Regex {
static RE: OnceLock<regex::Regex> = OnceLock::new();
RE.get_or_init(|| {
regex::Regex::new(
r#"(?is)<a\b[^>]*?\shref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>(.*?)</a\s*>"#,
)
.expect("valid anchor regex")
})
}
fn plain_text(html: &str) -> String {
static TAG_RE: OnceLock<regex::Regex> = OnceLock::new();
let tag_re = TAG_RE.get_or_init(|| regex::Regex::new(r"<[^>]*>").expect("valid tag regex"));
let decoded = decode_html_entities(&tag_re.replace_all(html, " "));
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn decode_html_entities(text: &str) -> String {
text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace(" ", " ")
}
fn truncate_output(out: String, max_chars: usize) -> String {
if out.chars().count() <= max_chars {
return out;
}
let truncated: String = out.chars().take(max_chars).collect();
format!("{truncated}\n... (output truncated at {max_chars} characters)")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssrf_guard_rejects_bad_targets() {
for bad in [
"http://localhost/",
"http://127.0.0.1/",
"file:///etc/passwd",
] {
let url = url::Url::parse(bad).unwrap();
assert!(
validate_url_ssrf(&url, false).is_err(),
"should reject {bad}"
);
}
}
#[test]
fn ssrf_guard_allows_public_http() {
let url = url::Url::parse("https://example.com/page").unwrap();
assert!(validate_url_ssrf(&url, false).is_ok());
}
#[test]
fn extract_links_absolutizes_and_dedupes() {
let base = url::Url::parse("http://example.com/dir/page").unwrap();
let html = r#"<a href="/a">First</a><a href="/a">Second</a> \
<a href="https://x.y/">X</a><a href="javascript:void(0)">js</a>"#;
let links = extract_links(html, &base);
assert_eq!(links.len(), 2, "dedupe + javascript skip: {links:?}");
assert_eq!(links[0].url, "http://example.com/a");
assert_eq!(links[0].text, "First", "first anchor text wins");
assert_eq!(links[1].url, "https://x.y/");
}
#[test]
fn truncate_output_appends_notice() {
let out = truncate_output("abcdefghij".to_string(), 4);
assert!(out.starts_with("abcd"));
assert!(out.contains("truncated at 4 characters"));
}
}