apollo/tools/
web_fetch.rs1use 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 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
117fn 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 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 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 result = result
186 .replace("&", "&")
187 .replace("<", "<")
188 .replace(">", ">")
189 .replace(""", "\"")
190 .replace(" ", " ")
191 .replace("'", "'");
192
193 while result.contains("\n\n\n") {
195 result = result.replace("\n\n\n", "\n\n");
196 }
197
198 result.trim().to_string()
199}