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