rucora_tools/web/
fetch.rs1use async_trait::async_trait;
6use rucora_core::{
7 error::ToolError,
8 tool::{Tool, ToolCategory},
9};
10use serde_json::{Value, json};
11use std::time::Duration;
12
13use super::security::validate_public_http_url;
14
15const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
17
18pub struct WebFetchTool;
31
32impl WebFetchTool {
33 pub fn new() -> Self {
35 Self
36 }
37}
38
39impl Default for WebFetchTool {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45#[async_trait]
46impl Tool for WebFetchTool {
47 fn name(&self) -> &str {
49 "web_fetch"
50 }
51
52 fn description(&self) -> Option<&str> {
54 Some("获取网页的 HTML 内容")
55 }
56
57 fn categories(&self) -> &'static [ToolCategory] {
59 &[ToolCategory::Network]
60 }
61
62 fn input_schema(&self) -> Value {
64 json!({
65 "type": "object",
66 "properties": {
67 "url": {
68 "type": "string",
69 "description": "网页 URL"
70 },
71 "timeout": {
72 "type": "integer",
73 "description": "超时时间(秒),默认 30 秒",
74 "default": 30
75 }
76 },
77 "required": ["url"]
78 })
79 }
80
81 async fn call(&self, input: Value) -> Result<Value, ToolError> {
83 let url = input
84 .get("url")
85 .and_then(|v| v.as_str())
86 .ok_or_else(|| ToolError::Message("缺少必需的 'url' 字段".to_string()))?;
87
88 let timeout_secs = input.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30);
89
90 validate_public_http_url(url, None, None).await?;
91
92 let client = reqwest::Client::builder()
94 .timeout(Duration::from_secs(timeout_secs))
95 .redirect(reqwest::redirect::Policy::none())
96 .user_agent("Mozilla/5.0 (compatible; rucora/0.1)")
97 .build()
98 .map_err(|e| ToolError::Message(format!("HTTP 客户端创建失败: {e}")))?;
99
100 let response = client
102 .get(url)
103 .send()
104 .await
105 .map_err(|e| ToolError::Message(format!("获取网页失败: {e}")))?;
106
107 let status = response.status().as_u16();
108
109 let body_bytes = response
111 .bytes()
112 .await
113 .map_err(|e| ToolError::Message(format!("读取响应体失败: {e}")))?;
114 if body_bytes.len() > MAX_RESPONSE_SIZE {
115 return Err(ToolError::Message(format!(
116 "响应体过大({} 字节),超过限制({} 字节)",
117 body_bytes.len(),
118 MAX_RESPONSE_SIZE
119 )));
120 }
121 let body = String::from_utf8_lossy(&body_bytes).to_string();
122
123 Ok(json!({
124 "url": url,
125 "status": status,
126 "html": body,
127 "success": (200..300).contains(&status)
128 }))
129 }
130}