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_counted;
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 = match truncate_chars_counted(&cleaned, args.max_chars) {
104            Some((head, dropped)) => {
105                format!("{head}...\n[truncated: {dropped} chars dropped]")
106            }
107            None => cleaned,
108        };
109
110        Ok(ToolResult::success(truncated))
111    }
112}
113
114/// Basic HTML tag stripping
115fn strip_html(html: &str) -> String {
116    let mut result = String::with_capacity(html.len());
117    let mut in_tag = false;
118    let mut in_script = false;
119    let mut in_style = false;
120
121    let lower = html.to_lowercase();
122    let chars: Vec<char> = html.chars().collect();
123    let lower_chars: Vec<char> = lower.chars().collect();
124
125    let mut i = 0;
126    while i < chars.len() {
127        // Detect script/style blocks
128        if i + 7 < lower_chars.len() {
129            let slice: String = lower_chars[i..i + 7].iter().collect();
130            if slice == "<script" {
131                in_script = true;
132            }
133            if slice == "<style "
134                || (i + 6 < lower_chars.len()
135                    && lower_chars[i..i + 6].iter().collect::<String>() == "<style")
136            {
137                in_style = true;
138            }
139        }
140        if i + 8 < lower_chars.len() {
141            let slice: String = lower_chars[i..i + 9.min(lower_chars.len())]
142                .iter()
143                .collect();
144            if slice.starts_with("</script") {
145                in_script = false;
146            }
147        }
148        if i + 7 < lower_chars.len() {
149            let slice: String = lower_chars[i..i + 8.min(lower_chars.len())]
150                .iter()
151                .collect();
152            if slice.starts_with("</style") {
153                in_style = false;
154            }
155        }
156
157        if chars[i] == '<' {
158            in_tag = true;
159            // Add newline for block elements
160            if i + 3 < chars.len() {
161                let tag: String = lower_chars[i + 1..i + 3.min(lower_chars.len())]
162                    .iter()
163                    .collect();
164                if tag.starts_with('p')
165                    || tag.starts_with('h')
166                    || tag.starts_with("br")
167                    || tag.starts_with("di")
168                    || tag.starts_with("li")
169                {
170                    result.push('\n');
171                }
172            }
173        } else if chars[i] == '>' {
174            in_tag = false;
175        } else if !in_tag && !in_script && !in_style {
176            result.push(chars[i]);
177        }
178        i += 1;
179    }
180
181    // Decode common entities
182    result = result
183        .replace("&amp;", "&")
184        .replace("&lt;", "<")
185        .replace("&gt;", ">")
186        .replace("&quot;", "\"")
187        .replace("&nbsp;", " ")
188        .replace("&#39;", "'");
189
190    // Collapse multiple newlines
191    while result.contains("\n\n\n") {
192        result = result.replace("\n\n\n", "\n\n");
193    }
194
195    result.trim().to_string()
196}