1use async_trait::async_trait;
7use regex::Regex;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::ssrf::guarded_get;
12use lc_core::tools::{BaseTool, Tool, ToolError};
13
14static SCRIPT_REGEX: std::sync::LazyLock<Regex> =
15 std::sync::LazyLock::new(|| Regex::new(r"<script[^>]*>.*?</script>").unwrap());
16
17static STYLE_REGEX: std::sync::LazyLock<Regex> =
18 std::sync::LazyLock::new(|| Regex::new(r"<style[^>]*>.*?</style>").unwrap());
19
20static TAG_REGEX: std::sync::LazyLock<Regex> =
21 std::sync::LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
22
23static WHITESPACE_REGEX: std::sync::LazyLock<Regex> =
24 std::sync::LazyLock::new(|| Regex::new(r"\s+").unwrap());
25
26static LINK_REGEX: std::sync::LazyLock<Regex> =
27 std::sync::LazyLock::new(|| Regex::new(r#"<a[^>]+href\s*=\s*['"]([^'"]+)['"][^>]*>"#).unwrap());
28
29static IMG_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
30 Regex::new(r#"<img[^>]+src\s*=\s*['"]([^'"]+)['"][^>]*>"#).unwrap()
31});
32
33static TITLE_REGEX: std::sync::LazyLock<Regex> =
34 std::sync::LazyLock::new(|| Regex::new(r"<title[^>]*>(.*?)</title>").unwrap());
35
36static DESC_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
37 Regex::new(r#"<meta[^>]+name\s*=\s*['"]description['"][^>]+content\s*=\s*['"]([^'"]+)['"]"#)
38 .unwrap()
39});
40
41static KW_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
42 Regex::new(r#"<meta[^>]+name\s*=\s*['"]keywords['"][^>]+content\s*=\s*['"]([^'"]+)['"]"#)
43 .unwrap()
44});
45
46fn extract_unique_links(html: &str) -> (Vec<String>, usize) {
52 let raw: Vec<String> = LINK_REGEX
53 .captures_iter(html)
54 .map(|cap| cap[1].to_string())
55 .collect();
56 let raw_count = raw.len();
57 let mut seen = std::collections::HashSet::new();
58 let unique: Vec<String> = raw
59 .into_iter()
60 .filter(|link| seen.insert(link.clone()))
61 .collect();
62 (unique, raw_count)
63}
64
65#[derive(Debug, Deserialize, JsonSchema)]
67pub struct URLFetchInput {
68 pub operation: String,
70
71 pub url: String,
73
74 pub include_headers: Option<bool>,
76
77 pub max_length: Option<usize>,
79}
80
81#[derive(Debug, Serialize)]
83pub struct URLFetchOutput {
84 pub result: String,
86
87 pub operation: String,
89
90 pub url: String,
92
93 pub content_length: usize,
95
96 pub details: Option<String>,
98}
99
100pub struct URLFetchTool {
102 allow_private_ips: bool,
104}
105
106impl URLFetchTool {
107 pub fn new() -> Self {
112 Self {
113 allow_private_ips: false,
114 }
115 }
116
117 pub fn with_allow_private_ips(mut self, allow: bool) -> Self {
119 self.allow_private_ips = allow;
120 self
121 }
122
123 async fn fetch_url(
125 &self,
126 url: &str,
127 max_length: Option<usize>,
128 include_headers: Option<bool>,
129 ) -> Result<URLFetchOutput, ToolError> {
130 if !url.starts_with("http://") && !url.starts_with("https://") {
131 return Err(ToolError::InvalidInput(
132 "URL must start with http:// or https://".to_string(),
133 ));
134 }
135
136 let response = guarded_get(
140 url,
141 !self.allow_private_ips,
142 Some(std::time::Duration::from_secs(30)),
143 )
144 .await?;
145
146 let status = response.status();
147 if !status.is_success() {
148 return Err(ToolError::ExecutionFailed(format!(
149 "HTTP error: {} - {}",
150 status.as_u16(),
151 status.canonical_reason().unwrap_or("unknown")
152 )));
153 }
154
155 let header_block: String = if include_headers.unwrap_or(false) {
159 let mut lines: Vec<String> = response
160 .headers()
161 .iter()
162 .map(|(name, value)| format!("{}: {}", name, value.to_str().unwrap_or("<非UTF-8>")))
163 .collect();
164 lines.sort();
165 let mut block = String::from("响应头:\n");
166 for line in lines {
167 block.push_str(&line);
168 block.push('\n');
169 }
170 block
171 } else {
172 String::new()
173 };
174
175 let content = response
176 .text()
177 .await
178 .map_err(|e| ToolError::ExecutionFailed(format!("failed to read response: {}", e)))?;
179
180 let max_len = max_length.unwrap_or(50000);
181 let content_len = content.len();
182 let truncated = content_len > max_len;
183 let result = if truncated {
184 content.chars().take(max_len).collect::<String>() + "\n... [内容已截断]"
185 } else {
186 content
187 };
188
189 let mut details = format!(
190 "状态码: {}, 内容长度: {} 字节{}",
191 status.as_u16(),
192 content_len,
193 if truncated { " (已截断)" } else { "" }
194 );
195 if !header_block.is_empty() {
196 details.push('\n');
197 details.push_str(&header_block);
198 }
199
200 Ok(URLFetchOutput {
201 result,
202 operation: "fetch".to_string(),
203 url: url.to_string(),
204 content_length: content_len,
205 details: Some(details),
206 })
207 }
208
209 async fn extract_text(&self, url: &str) -> Result<URLFetchOutput, ToolError> {
211 let fetch_result = self.fetch_url(url, Some(100000), None).await?;
212 let html = &fetch_result.result;
213
214 let html = SCRIPT_REGEX.replace_all(html, "");
215 let html = STYLE_REGEX.replace_all(&html, "");
216
217 let text = TAG_REGEX.replace_all(&html, "");
218
219 let clean_text = WHITESPACE_REGEX.replace_all(&text, " ").trim().to_string();
220
221 let max_len = 5000;
222 let clean_len = clean_text.len();
223 let result = if clean_len > max_len {
224 clean_text.chars().take(max_len).collect::<String>() + "..."
225 } else {
226 clean_text
227 };
228
229 Ok(URLFetchOutput {
230 result,
231 operation: "extract_text".to_string(),
232 url: url.to_string(),
233 content_length: clean_len,
234 details: Some(format!("提取了 {} 字符的纯文本", clean_len)),
235 })
236 }
237
238 async fn extract_links(&self, url: &str) -> Result<URLFetchOutput, ToolError> {
240 let fetch_result = self.fetch_url(url, Some(100000), None).await?;
241 let html = &fetch_result.result;
242
243 let (unique_links, raw_count) = extract_unique_links(html);
244 let result = unique_links.join("\n");
245
246 Ok(URLFetchOutput {
247 result,
248 operation: "extract_links".to_string(),
249 url: url.to_string(),
250 content_length: html.len(), details: Some(format!(
252 "找到 {} 个唯一链接(原始 {} 个)",
253 unique_links.len(),
254 raw_count
255 )),
256 })
257 }
258
259 async fn extract_images(&self, url: &str) -> Result<URLFetchOutput, ToolError> {
261 let fetch_result = self.fetch_url(url, Some(100000), None).await?;
262 let html = &fetch_result.result;
263
264 let images: Vec<String> = IMG_REGEX
265 .captures_iter(html)
266 .map(|cap| cap[1].to_string())
267 .collect();
268
269 let result = images.join("\n");
270
271 Ok(URLFetchOutput {
272 result,
273 operation: "extract_images".to_string(),
274 url: url.to_string(),
275 content_length: images.len(),
276 details: Some(format!("找到 {} 张图片", images.len())),
277 })
278 }
279
280 async fn extract_metadata(&self, url: &str) -> Result<URLFetchOutput, ToolError> {
282 let fetch_result = self.fetch_url(url, Some(50000), None).await?;
283 let html = &fetch_result.result;
284
285 let title = TITLE_REGEX
286 .captures(html)
287 .map(|cap| cap[1].trim().to_string())
288 .unwrap_or_default();
289
290 let description = DESC_REGEX
291 .captures(html)
292 .map(|cap| cap[1].to_string())
293 .unwrap_or_default();
294
295 let keywords = KW_REGEX
296 .captures(html)
297 .map(|cap| cap[1].to_string())
298 .unwrap_or_default();
299
300 let result = format!(
301 "标题: {}\n描述: {}\n关键词: {}",
302 title, description, keywords
303 );
304
305 Ok(URLFetchOutput {
306 result,
307 operation: "metadata".to_string(),
308 url: url.to_string(),
309 content_length: title.len() + description.len() + keywords.len(),
310 details: Some("提取了网页元数据".to_string()),
311 })
312 }
313}
314
315impl Default for URLFetchTool {
316 fn default() -> Self {
317 Self::new()
318 }
319}
320
321#[async_trait]
322impl Tool for URLFetchTool {
323 type Input = URLFetchInput;
324 type Output = URLFetchOutput;
325
326 async fn invoke(&self, input: Self::Input) -> Result<Self::Output, ToolError> {
327 match input.operation.as_str() {
328 "fetch" => {
329 self.fetch_url(&input.url, input.max_length, input.include_headers)
330 .await
331 }
332 "extract_text" => self.extract_text(&input.url).await,
333 "extract_links" => self.extract_links(&input.url).await,
334 "extract_images" => self.extract_images(&input.url).await,
335 "metadata" => self.extract_metadata(&input.url).await,
336 _ => Err(ToolError::InvalidInput(
337 format!("unsupported operation: {}, use: fetch, extract_text, extract_links, extract_images, metadata", input.operation)
338 )),
339 }
340 }
341}
342
343#[async_trait]
344impl BaseTool for URLFetchTool {
345 fn name(&self) -> &str {
346 "url_fetch"
347 }
348
349 fn description(&self) -> &str {
350 "网页抓取工具。支持多种操作:
351
352操作类型:
353- fetch: 抓取完整网页内容
354- extract_text: 提取纯文本内容(去除HTML标签)
355- extract_links: 提取所有链接
356- extract_images: 提取所有图片链接
357- metadata: 提取网页元数据(标题、描述、关键词)
358
359参数:
360- url: 网页地址(必须以 http:// 或 https:// 开头)
361- max_length: 最大内容长度(可选,默认50KB)
362- include_headers: 是否包含头部信息(可选)
363
364示例:
365- 抓取网页: {\"operation\": \"fetch\", \"url\": \"https://example.com\"}
366- 提取文本: {\"operation\": \"extract_text\", \"url\": \"https://example.com\"}
367- 提取链接: {\"operation\": \"extract_links\", \"url\": \"https://example.com\"}"
368 }
369
370 async fn run(&self, input: String) -> Result<String, ToolError> {
371 let parsed: URLFetchInput = serde_json::from_str(&input)
372 .map_err(|e| ToolError::InvalidInput(format!("JSON parse failed: {}", e)))?;
373
374 let output = self.invoke(parsed).await?;
375
376 Ok(format!(
377 "URL: {}\n操作: {}\n内容长度: {} 字节\n\n{}\n详细信息: {}",
378 output.url,
379 output.operation,
380 output.content_length,
381 output.result,
382 output.details.unwrap_or_default()
383 ))
384 }
385
386 fn args_schema(&self) -> Option<serde_json::Value> {
387 use schemars::schema_for;
388 serde_json::to_value(schema_for!(URLFetchInput)).ok()
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn test_url_validation() {
398 let valid_url = "https://example.com";
399 assert!(valid_url.starts_with("http://") || valid_url.starts_with("https://"));
400
401 let valid_url2 = "http://example.org";
402 assert!(valid_url2.starts_with("http://") || valid_url2.starts_with("https://"));
403 }
404
405 #[tokio::test]
406 async fn test_url_fetch_invalid_url() {
407 let tool = URLFetchTool::new();
408
409 let input = URLFetchInput {
410 operation: "fetch".to_string(),
411 url: "invalid-url".to_string(),
412 include_headers: None,
413 max_length: None,
414 };
415
416 let result = tool.invoke(input).await;
417 assert!(result.is_err());
418 assert!(result.unwrap_err().to_string().contains("http://"));
419 }
420
421 #[test]
423 fn test_extract_unique_links_dedups() {
424 let html = r#"
425 <a href="https://a.com/1">first</a>
426 <a href="https://a.com/1">dup</a>
427 <a href="https://a.com/2">second</a>
428 <a href="https://a.com/1">dup2</a>
429 "#;
430 let (unique, raw) = extract_unique_links(html);
431 assert_eq!(raw, 4, "原始链接数应为 4");
432 assert_eq!(
433 unique,
434 vec!["https://a.com/1".to_string(), "https://a.com/2".to_string()],
435 "应去重且保持首次出现顺序"
436 );
437 }
438
439 #[tokio::test]
441 async fn test_url_fetch_blocks_localhost_by_default() {
442 let tool = URLFetchTool::new();
443 let input = URLFetchInput {
444 operation: "fetch".to_string(),
445 url: "http://127.0.0.1:6379/".to_string(),
446 include_headers: None,
447 max_length: None,
448 };
449 let result = tool.invoke(input).await;
450 assert!(result.is_err());
451 let err = result.unwrap_err().to_string();
452 assert!(err.contains("SSRF"), "expected SSRF error, got: {}", err);
453 }
454
455 #[tokio::test]
457 async fn test_fetch_include_headers() {
458 use tokio::io::{AsyncReadExt, AsyncWriteExt};
459 use tokio::net::TcpListener;
460
461 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
462 let addr = listener.local_addr().unwrap();
463
464 let server = tokio::spawn(async move {
465 let (mut socket, _) = listener.accept().await.unwrap();
466 let mut buf = [0u8; 4096];
467 let _ = socket.read(&mut buf).await;
468 let body = "hello world";
469 let resp = format!(
470 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nX-Test-Header: hello\r\n\r\n{}",
471 body.len(),
472 body
473 );
474 socket.write_all(resp.as_bytes()).await.unwrap();
475 drop(socket);
476 });
477
478 let tool = URLFetchTool::new().with_allow_private_ips(true);
479 let input = URLFetchInput {
480 operation: "fetch".to_string(),
481 url: format!("http://{}/", addr),
482 include_headers: Some(true),
483 max_length: None,
484 };
485
486 let output = tool.invoke(input).await.unwrap();
487 let details = output.details.unwrap();
488 assert!(details.contains("响应头:"), "details: {}", details);
489 assert!(
491 details.contains("x-test-header: hello"),
492 "details: {}",
493 details
494 );
495 assert!(output.result.contains("hello world"));
496 server.await.unwrap();
497 }
498
499 #[tokio::test]
501 async fn test_fetch_without_include_headers() {
502 use tokio::io::{AsyncReadExt, AsyncWriteExt};
503 use tokio::net::TcpListener;
504
505 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
506 let addr = listener.local_addr().unwrap();
507
508 let server = tokio::spawn(async move {
509 let (mut socket, _) = listener.accept().await.unwrap();
510 let mut buf = [0u8; 4096];
511 let _ = socket.read(&mut buf).await;
512 let body = "hello world";
513 let resp = format!(
514 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nX-Test-Header: hello\r\n\r\n{}",
515 body.len(),
516 body
517 );
518 socket.write_all(resp.as_bytes()).await.unwrap();
519 drop(socket);
520 });
521
522 let tool = URLFetchTool::new().with_allow_private_ips(true);
523 let input = URLFetchInput {
524 operation: "fetch".to_string(),
525 url: format!("http://{}/", addr),
526 include_headers: Some(false),
527 max_length: None,
528 };
529
530 let output = tool.invoke(input).await.unwrap();
531 let details = output.details.unwrap();
532 assert!(
533 !details.contains("X-Test-Header"),
534 "不应包含响应头, got: {}",
535 details
536 );
537 server.await.unwrap();
538 }
539
540 #[test]
541 fn test_tool_properties() {
542 let tool = URLFetchTool::new();
543
544 assert_eq!(tool.name(), "url_fetch");
545 assert!(tool.description().contains("fetch"));
546 assert!(BaseTool::args_schema(&tool).is_some());
547 }
548}