1use async_trait::async_trait;
7use regex::Regex;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::net::IpAddr;
11
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 is_private_ip(ip: &IpAddr) -> bool {
48 match ip {
49 IpAddr::V4(v4) => {
50 let octets = v4.octets();
51 octets[0] == 127
52 || octets[0] == 10
53 || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31)
54 || (octets[0] == 192 && octets[1] == 168)
55 || (octets[0] == 169 && octets[1] == 254)
56 || *v4 == std::net::Ipv4Addr::UNSPECIFIED
57 }
58 IpAddr::V6(v6) => {
59 v6.is_loopback()
60 || (v6.segments()[0] & 0xfe00) == 0xfc00
61 || matches!(v6.segments(), [0xfe80, ..])
62 || *v6 == std::net::Ipv6Addr::UNSPECIFIED
63 }
64 }
65}
66
67async fn url_points_to_private_ip(url: &str) -> Result<bool, ToolError> {
69 let parsed =
70 url::Url::parse(url).map_err(|e| ToolError::InvalidInput(format!("Invalid URL: {}", e)))?;
71 let host = parsed
72 .host_str()
73 .ok_or_else(|| ToolError::InvalidInput("URL has no host".to_string()))?;
74
75 if let Ok(ip) = host.parse::<IpAddr>() {
76 return Ok(is_private_ip(&ip));
77 }
78
79 let port = parsed.port_or_known_default().unwrap_or(80);
80 let addr_str = format!("{}:{}", host, port);
81 let addrs: Vec<IpAddr> = tokio::net::lookup_host(&addr_str)
82 .await
83 .map_err(|e| {
84 ToolError::ExecutionFailed(format!("DNS resolution failed for {}: {}", host, e))
85 })?
86 .map(|sa| sa.ip())
87 .collect();
88
89 if addrs.is_empty() {
90 return Err(ToolError::ExecutionFailed(format!(
91 "DNS resolution returned no addresses for {}",
92 host
93 )));
94 }
95
96 Ok(addrs.iter().any(is_private_ip))
97}
98
99#[derive(Debug, Deserialize, JsonSchema)]
101pub struct URLFetchInput {
102 pub operation: String,
104
105 pub url: String,
107
108 pub include_headers: Option<bool>,
110
111 pub max_length: Option<usize>,
113}
114
115#[derive(Debug, Serialize)]
117pub struct URLFetchOutput {
118 pub result: String,
120
121 pub operation: String,
123
124 pub url: String,
126
127 pub content_length: usize,
129
130 pub details: Option<String>,
132}
133
134pub struct URLFetchTool {
136 client: reqwest::Client,
138 allow_private_ips: bool,
140}
141
142impl URLFetchTool {
143 pub fn new() -> Self {
144 Self {
145 client: reqwest::Client::builder()
146 .timeout(std::time::Duration::from_secs(30))
147 .user_agent("LangChainRust/0.1 (URL Fetch Tool)")
148 .build()
149 .unwrap_or_else(|_| reqwest::Client::new()),
150 allow_private_ips: false,
151 }
152 }
153
154 pub fn with_allow_private_ips(mut self, allow: bool) -> Self {
156 self.allow_private_ips = allow;
157 self
158 }
159
160 async fn check_ssrf(&self, url: &str) -> Result<(), ToolError> {
162 if self.allow_private_ips {
163 return Ok(());
164 }
165 if url_points_to_private_ip(url).await? {
166 return Err(ToolError::ExecutionFailed(
167 "Request to private/internal IP address is blocked by SSRF protection. \
168 Call .with_allow_private_ips(true) to allow."
169 .to_string(),
170 ));
171 }
172 Ok(())
173 }
174
175 async fn fetch_url(
177 &self,
178 url: &str,
179 max_length: Option<usize>,
180 ) -> Result<URLFetchOutput, ToolError> {
181 if !url.starts_with("http://") && !url.starts_with("https://") {
182 return Err(ToolError::InvalidInput(
183 "URL 必须以 http:// 或 https:// 开头".to_string(),
184 ));
185 }
186
187 self.check_ssrf(url).await?;
188
189 let response = self
190 .client
191 .get(url)
192 .send()
193 .await
194 .map_err(|e| ToolError::ExecutionFailed(format!("HTTP 请求失败: {}", e)))?;
195
196 let status = response.status();
197 if !status.is_success() {
198 return Err(ToolError::ExecutionFailed(format!(
199 "HTTP 错误: {} - {}",
200 status.as_u16(),
201 status.canonical_reason().unwrap_or("未知")
202 )));
203 }
204
205 let content = response
206 .text()
207 .await
208 .map_err(|e| ToolError::ExecutionFailed(format!("读取响应失败: {}", e)))?;
209
210 let max_len = max_length.unwrap_or(50000);
211 let content_len = content.len();
212 let truncated = content_len > max_len;
213 let result = if truncated {
214 content.chars().take(max_len).collect::<String>() + "\n... [内容已截断]"
215 } else {
216 content
217 };
218
219 Ok(URLFetchOutput {
220 result,
221 operation: "fetch".to_string(),
222 url: url.to_string(),
223 content_length: content_len,
224 details: Some(format!(
225 "状态码: {}, 内容长度: {} 字节{}",
226 status.as_u16(),
227 content_len,
228 if truncated { " (已截断)" } else { "" }
229 )),
230 })
231 }
232
233 async fn extract_text(&self, url: &str) -> Result<URLFetchOutput, ToolError> {
235 let fetch_result = self.fetch_url(url, Some(100000)).await?;
236 let html = &fetch_result.result;
237
238 let html = SCRIPT_REGEX.replace_all(html, "");
239 let html = STYLE_REGEX.replace_all(&html, "");
240
241 let text = TAG_REGEX.replace_all(&html, "");
242
243 let clean_text = WHITESPACE_REGEX.replace_all(&text, " ").trim().to_string();
244
245 let max_len = 5000;
246 let clean_len = clean_text.len();
247 let result = if clean_len > max_len {
248 clean_text.chars().take(max_len).collect::<String>() + "..."
249 } else {
250 clean_text
251 };
252
253 Ok(URLFetchOutput {
254 result,
255 operation: "extract_text".to_string(),
256 url: url.to_string(),
257 content_length: clean_len,
258 details: Some(format!("提取了 {} 字符的纯文本", clean_len)),
259 })
260 }
261
262 async fn extract_links(&self, url: &str) -> Result<URLFetchOutput, ToolError> {
264 let fetch_result = self.fetch_url(url, Some(100000)).await?;
265 let html = &fetch_result.result;
266
267 let links: Vec<String> = LINK_REGEX
268 .captures_iter(html)
269 .map(|cap| cap[1].to_string())
270 .collect();
271
272 let unique_links: Vec<String> = links.into_iter().collect();
273 let result = unique_links.join("\n");
274
275 Ok(URLFetchOutput {
276 result,
277 operation: "extract_links".to_string(),
278 url: url.to_string(),
279 content_length: unique_links.len(),
280 details: Some(format!("找到 {} 个链接", unique_links.len())),
281 })
282 }
283
284 async fn extract_images(&self, url: &str) -> Result<URLFetchOutput, ToolError> {
286 let fetch_result = self.fetch_url(url, Some(100000)).await?;
287 let html = &fetch_result.result;
288
289 let images: Vec<String> = IMG_REGEX
290 .captures_iter(html)
291 .map(|cap| cap[1].to_string())
292 .collect();
293
294 let result = images.join("\n");
295
296 Ok(URLFetchOutput {
297 result,
298 operation: "extract_images".to_string(),
299 url: url.to_string(),
300 content_length: images.len(),
301 details: Some(format!("找到 {} 张图片", images.len())),
302 })
303 }
304
305 async fn extract_metadata(&self, url: &str) -> Result<URLFetchOutput, ToolError> {
307 let fetch_result = self.fetch_url(url, Some(50000)).await?;
308 let html = &fetch_result.result;
309
310 let title = TITLE_REGEX
311 .captures(html)
312 .map(|cap| cap[1].trim().to_string())
313 .unwrap_or_default();
314
315 let description = DESC_REGEX
316 .captures(html)
317 .map(|cap| cap[1].to_string())
318 .unwrap_or_default();
319
320 let keywords = KW_REGEX
321 .captures(html)
322 .map(|cap| cap[1].to_string())
323 .unwrap_or_default();
324
325 let result = format!(
326 "标题: {}\n描述: {}\n关键词: {}",
327 title, description, keywords
328 );
329
330 Ok(URLFetchOutput {
331 result,
332 operation: "metadata".to_string(),
333 url: url.to_string(),
334 content_length: title.len() + description.len() + keywords.len(),
335 details: Some("提取了网页元数据".to_string()),
336 })
337 }
338}
339
340impl Default for URLFetchTool {
341 fn default() -> Self {
342 Self::new()
343 }
344}
345
346#[async_trait]
347impl Tool for URLFetchTool {
348 type Input = URLFetchInput;
349 type Output = URLFetchOutput;
350
351 async fn invoke(&self, input: Self::Input) -> Result<Self::Output, ToolError> {
352 match input.operation.as_str() {
353 "fetch" => self.fetch_url(&input.url, input.max_length).await,
354 "extract_text" => self.extract_text(&input.url).await,
355 "extract_links" => self.extract_links(&input.url).await,
356 "extract_images" => self.extract_images(&input.url).await,
357 "metadata" => self.extract_metadata(&input.url).await,
358 _ => Err(ToolError::InvalidInput(
359 format!("不支持的操作: {},请使用: fetch, extract_text, extract_links, extract_images, metadata", input.operation)
360 )),
361 }
362 }
363}
364
365#[async_trait]
366impl BaseTool for URLFetchTool {
367 fn name(&self) -> &str {
368 "url_fetch"
369 }
370
371 fn description(&self) -> &str {
372 "网页抓取工具。支持多种操作:
373
374操作类型:
375- fetch: 抓取完整网页内容
376- extract_text: 提取纯文本内容(去除HTML标签)
377- extract_links: 提取所有链接
378- extract_images: 提取所有图片链接
379- metadata: 提取网页元数据(标题、描述、关键词)
380
381参数:
382- url: 网页地址(必须以 http:// 或 https:// 开头)
383- max_length: 最大内容长度(可选,默认50KB)
384- include_headers: 是否包含头部信息(可选)
385
386示例:
387- 抓取网页: {\"operation\": \"fetch\", \"url\": \"https://example.com\"}
388- 提取文本: {\"operation\": \"extract_text\", \"url\": \"https://example.com\"}
389- 提取链接: {\"operation\": \"extract_links\", \"url\": \"https://example.com\"}"
390 }
391
392 async fn run(&self, input: String) -> Result<String, ToolError> {
393 let parsed: URLFetchInput = serde_json::from_str(&input)
394 .map_err(|e| ToolError::InvalidInput(format!("JSON 解析失败: {}", e)))?;
395
396 let output = self.invoke(parsed).await?;
397
398 Ok(format!(
399 "URL: {}\n操作: {}\n内容长度: {} 字节\n\n{}\n详细信息: {}",
400 output.url,
401 output.operation,
402 output.content_length,
403 output.result,
404 output.details.unwrap_or_default()
405 ))
406 }
407
408 fn args_schema(&self) -> Option<serde_json::Value> {
409 use schemars::schema_for;
410 serde_json::to_value(schema_for!(URLFetchInput)).ok()
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn test_url_validation() {
420 let valid_url = "https://example.com";
421 assert!(valid_url.starts_with("http://") || valid_url.starts_with("https://"));
422
423 let valid_url2 = "http://example.org";
424 assert!(valid_url2.starts_with("http://") || valid_url2.starts_with("https://"));
425 }
426
427 #[tokio::test]
428 async fn test_url_fetch_invalid_url() {
429 let tool = URLFetchTool::new();
430
431 let input = URLFetchInput {
432 operation: "fetch".to_string(),
433 url: "invalid-url".to_string(),
434 include_headers: None,
435 max_length: None,
436 };
437
438 let result = tool.invoke(input).await;
439 assert!(result.is_err());
440 assert!(result.unwrap_err().to_string().contains("http://"));
441 }
442
443 #[tokio::test]
444 #[ignore = "需要网络连接"]
445 async fn test_url_fetch_real() {
446 let tool = URLFetchTool::new();
447
448 let input = URLFetchInput {
449 operation: "fetch".to_string(),
450 url: "https://example.com".to_string(),
451 include_headers: None,
452 max_length: Some(5000),
453 };
454
455 let result = tool.invoke(input).await.unwrap();
456 assert!(result.result.contains("example"));
457 assert!(result.content_length > 0);
458 }
459
460 #[tokio::test]
461 #[ignore = "需要网络连接"]
462 async fn test_url_extract_text_real() {
463 let tool = URLFetchTool::new();
464
465 let input = URLFetchInput {
466 operation: "extract_text".to_string(),
467 url: "https://example.com".to_string(),
468 include_headers: None,
469 max_length: None,
470 };
471
472 let result = tool.invoke(input).await.unwrap();
473 assert!(!result.result.contains("<"));
474 }
475
476 #[tokio::test]
477 #[ignore = "需要网络连接"]
478 async fn test_url_extract_links_real() {
479 let tool = URLFetchTool::new();
480
481 let input = URLFetchInput {
482 operation: "extract_links".to_string(),
483 url: "https://example.com".to_string(),
484 include_headers: None,
485 max_length: None,
486 };
487
488 let result = tool.invoke(input).await.unwrap();
489 assert!(result.details.unwrap().contains("链接"));
490 }
491
492 #[tokio::test]
493 #[ignore = "需要网络连接"]
494 async fn test_url_extract_metadata_real() {
495 let tool = URLFetchTool::new();
496
497 let input = URLFetchInput {
498 operation: "metadata".to_string(),
499 url: "https://example.com".to_string(),
500 include_headers: None,
501 max_length: None,
502 };
503
504 let result = tool.invoke(input).await.unwrap();
505 assert!(result.result.contains("标题"));
506 }
507
508 #[test]
509 fn test_tool_properties() {
510 let tool = URLFetchTool::new();
511
512 assert_eq!(tool.name(), "url_fetch");
513 assert!(tool.description().contains("fetch"));
514 assert!(BaseTool::args_schema(&tool).is_some());
515 }
516}