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_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 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
114fn 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 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 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 result = result
183 .replace("&", "&")
184 .replace("<", "<")
185 .replace(">", ">")
186 .replace(""", "\"")
187 .replace(" ", " ")
188 .replace("'", "'");
189
190 while result.contains("\n\n\n") {
192 result = result.replace("\n\n\n", "\n\n");
193 }
194
195 result.trim().to_string()
196}