Skip to main content

lc_tools/
url_fetch.rs

1// lc-tools/src/url_fetch.rs
2//! 网页抓取工具
3//!
4//! 提供网页内容抓取和解析功能。
5
6use 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
46/// Check if an IP address is private/internal (SSRF protection).
47fn 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
67/// Resolve a URL hostname and check if it points to a private IP (async).
68async 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/// URLFetch 工具输入
100#[derive(Debug, Deserialize, JsonSchema)]
101pub struct URLFetchInput {
102    /// 操作类型: "fetch", "extract_text", "extract_links", "extract_images", "metadata"
103    pub operation: String,
104
105    /// URL 地址
106    pub url: String,
107
108    /// 是否包含头部信息(用于 fetch 操作)
109    pub include_headers: Option<bool>,
110
111    /// 最大内容长度(字节)
112    pub max_length: Option<usize>,
113}
114
115/// URLFetch 工具输出
116#[derive(Debug, Serialize)]
117pub struct URLFetchOutput {
118    /// 操作结果
119    pub result: String,
120
121    /// 操作类型
122    pub operation: String,
123
124    /// URL
125    pub url: String,
126
127    /// 内容长度
128    pub content_length: usize,
129
130    /// 额外信息
131    pub details: Option<String>,
132}
133
134/// 网页抓取工具
135pub struct URLFetchTool {
136    /// HTTP 客户端
137    client: reqwest::Client,
138    /// 是否允许访问内网 IP(默认 false)
139    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    /// Allow requests to private/internal IP addresses (SSRF opt-in).
155    pub fn with_allow_private_ips(mut self, allow: bool) -> Self {
156        self.allow_private_ips = allow;
157        self
158    }
159
160    /// Check SSRF protection before making a request.
161    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    /// 抓取网页内容
176    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    /// 提取纯文本内容
234    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    /// 提取链接
263    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    /// 提取图片链接
285    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    /// 提取元数据
306    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}