Skip to main content

apollo/tools/
web_fetch.rs

1//! Web fetch tool — download and extract readable content from URLs.
2
3use async_trait::async_trait;
4use serde::Deserialize;
5
6use super::network::validate_public_http_url;
7use super::traits::*;
8use crate::text::truncate_chars;
9
10pub struct WebFetchTool {
11    client: reqwest::Client,
12}
13
14impl WebFetchTool {
15    pub fn new() -> Self {
16        Self {
17            client: reqwest::Client::builder()
18                .timeout(std::time::Duration::from_secs(30))
19                .user_agent("apollo/0.1")
20                .redirect(reqwest::redirect::Policy::none())
21                .build()
22                .expect("Failed to create HTTP client"),
23        }
24    }
25}
26
27impl Default for WebFetchTool {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33#[derive(Deserialize)]
34struct FetchArgs {
35    url: String,
36    #[serde(default = "default_max_chars")]
37    max_chars: usize,
38}
39
40fn default_max_chars() -> usize {
41    50_000
42}
43
44#[async_trait]
45impl Tool for WebFetchTool {
46    fn name(&self) -> &str {
47        "web_fetch"
48    }
49
50    fn spec(&self) -> ToolSpec {
51        ToolSpec {
52            name: "web_fetch".to_string(),
53            description: "Fetch and extract readable content from a URL (HTML → text). Use for reading web pages.".to_string(),
54            parameters: serde_json::json!({
55                "type": "object",
56                "properties": {
57                    "url": {
58                        "type": "string",
59                        "description": "HTTP or HTTPS URL to fetch"
60                    },
61                    "max_chars": {
62                        "type": "integer",
63                        "description": "Maximum characters to return (default 50000)"
64                    }
65                },
66                "required": ["url"]
67            }),
68        }
69    }
70
71    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
72        let args: FetchArgs = serde_json::from_str(arguments)?;
73
74        let _ = validate_public_http_url(&args.url, &[]).await?;
75
76        let resp = match self.client.get(&args.url).send().await {
77            Ok(r) => r,
78            Err(e) => return Ok(ToolResult::error(format!("Fetch error: {}", e))),
79        };
80
81        if !resp.status().is_success() {
82            return Ok(ToolResult::error(format!("HTTP {}", resp.status())));
83        }
84
85        const MAX_BYTES: usize = 1_048_576;
86        let bytes = match resp.bytes().await {
87            Ok(bytes) => bytes,
88            Err(e) => return Ok(ToolResult::error(format!("Fetch error: {}", e))),
89        };
90        if bytes.len() > MAX_BYTES {
91            return Ok(ToolResult::error(format!(
92                "Response too large: {} bytes (max {})",
93                bytes.len(),
94                MAX_BYTES
95            )));
96        }
97
98        let text = String::from_utf8_lossy(&bytes);
99
100        // Simple HTML stripping (remove tags, decode entities)
101        let cleaned = strip_html(&text);
102
103        let truncated = if cleaned.len() > args.max_chars {
104            format!(
105                "{}...\n[truncated at {} chars]",
106                truncate_chars(&cleaned, args.max_chars),
107                args.max_chars
108            )
109        } else {
110            cleaned
111        };
112
113        Ok(ToolResult::success(truncated))
114    }
115}
116
117/// Basic HTML tag stripping
118fn strip_html(html: &str) -> String {
119    let mut result = String::with_capacity(html.len());
120    let mut in_tag = false;
121    let mut in_script = false;
122    let mut in_style = false;
123
124    let lower = html.to_lowercase();
125    let chars: Vec<char> = html.chars().collect();
126    let lower_chars: Vec<char> = lower.chars().collect();
127
128    let mut i = 0;
129    while i < chars.len() {
130        // Detect script/style blocks
131        if i + 7 < lower_chars.len() {
132            let slice: String = lower_chars[i..i + 7].iter().collect();
133            if slice == "<script" {
134                in_script = true;
135            }
136            if slice == "<style "
137                || (i + 6 < lower_chars.len()
138                    && lower_chars[i..i + 6].iter().collect::<String>() == "<style")
139            {
140                in_style = true;
141            }
142        }
143        if i + 8 < lower_chars.len() {
144            let slice: String = lower_chars[i..i + 9.min(lower_chars.len())]
145                .iter()
146                .collect();
147            if slice.starts_with("</script") {
148                in_script = false;
149            }
150        }
151        if i + 7 < lower_chars.len() {
152            let slice: String = lower_chars[i..i + 8.min(lower_chars.len())]
153                .iter()
154                .collect();
155            if slice.starts_with("</style") {
156                in_style = false;
157            }
158        }
159
160        if chars[i] == '<' {
161            in_tag = true;
162            // Add newline for block elements
163            if i + 3 < chars.len() {
164                let tag: String = lower_chars[i + 1..i + 3.min(lower_chars.len())]
165                    .iter()
166                    .collect();
167                if tag.starts_with('p')
168                    || tag.starts_with('h')
169                    || tag.starts_with("br")
170                    || tag.starts_with("di")
171                    || tag.starts_with("li")
172                {
173                    result.push('\n');
174                }
175            }
176        } else if chars[i] == '>' {
177            in_tag = false;
178        } else if !in_tag && !in_script && !in_style {
179            result.push(chars[i]);
180        }
181        i += 1;
182    }
183
184    // Decode common entities
185    result = result
186        .replace("&amp;", "&")
187        .replace("&lt;", "<")
188        .replace("&gt;", ">")
189        .replace("&quot;", "\"")
190        .replace("&nbsp;", " ")
191        .replace("&#39;", "'");
192
193    // Collapse multiple newlines
194    while result.contains("\n\n\n") {
195        result = result.replace("\n\n\n", "\n\n");
196    }
197
198    result.trim().to_string()
199}