1use serde::{Deserialize, Serialize};
8
9const EXA_CONTENTS: &str = "https://api.exa.ai/contents";
10const EXA_MCP: &str = "https://mcp.exa.ai/mcp";
11
12const EXA_FIRST: bool = false;
13
14#[derive(Debug, Clone, Default, Deserialize)]
15pub struct WebFetch {
16 pub url: String,
17}
18
19pub async fn fetch(fetch: &WebFetch) -> Result<String, String> {
28 fetch_url(&fetch.url).await
29}
30
31async fn fetch_url(url: &str) -> Result<String, String> {
32 let try_exa = || async {
33 if let Ok(key) = std::env::var("EXA_API_KEY")
34 && !key.trim().is_empty()
35 {
36 return rest_contents(url, &key).await;
37 }
38 mcp_call("web_fetch_exa", serde_json::json!({ "urls": [url] })).await
41 };
42
43 let try_local = || async {
44 let client = reqwest::Client::builder()
45 .user_agent("Cosh/0.1")
46 .build()
47 .map_err(|e| e.to_string())?;
48 let html = client
49 .get(url)
50 .send()
51 .await
52 .map_err(|e| format!("fetch: {e}"))?
53 .text()
54 .await
55 .map_err(|e| format!("read: {e}"))?;
56
57 #[allow(unused_unsafe)]
60 let extract_result = {
61 #[cfg(windows)]
63 let null_cstr = c"NUL";
64 #[cfg(unix)]
65 let null_cstr = c"/dev/null";
66 let null_fd: i32 = unsafe { libc::open(null_cstr.as_ptr(), libc::O_WRONLY) };
67 if null_fd < 0 {
68 rs_trafilatura::extract(&html)
70 } else {
71 let saved_stderr = unsafe { libc::dup(2) };
72 unsafe { libc::dup2(null_fd, 2) };
73 unsafe { libc::close(null_fd) };
74
75 let result = rs_trafilatura::extract(&html);
76
77 unsafe { libc::dup2(saved_stderr, 2) };
79 unsafe { libc::close(saved_stderr) };
80
81 result
82 }
83 };
84
85 let extracted = extract_result.map_err(|e| format!("extract: {e}"))?;
86
87 if extracted.extraction_quality < 0.75_f64 {
92 return Err("low extraction quality".to_string());
93 }
94
95 Ok(extracted.content_text)
96 };
97
98 if EXA_FIRST {
99 match try_exa().await {
100 Ok(r) => Ok(r),
101 Err(_) => try_local().await,
102 }
103 } else {
104 match try_local().await {
105 Ok(r) => Ok(r),
106 Err(_) => try_exa().await,
107 }
108 }
109}
110
111#[derive(Serialize)]
112struct ContentsReq {
113 urls: Vec<String>,
114 text: bool,
115}
116
117#[derive(Deserialize)]
118struct ContentsRes {
119 results: Vec<ContentsResult>,
120}
121
122#[derive(Deserialize)]
123struct ContentsResult {
124 #[serde(default)]
125 text: String,
126}
127
128async fn rest_contents(url: &str, key: &str) -> Result<String, String> {
129 let body = ContentsReq {
130 urls: vec![url.to_string()],
131 text: true,
132 };
133
134 let resp = reqwest::Client::new()
135 .post(EXA_CONTENTS)
136 .header("Content-Type", "application/json")
137 .header("x-api-key", key)
138 .json(&body)
139 .send()
140 .await
141 .map_err(|e| format!("contents: {e}"))?;
142
143 let status = resp.status();
144 if !status.is_success() {
145 let text = resp.text().await.unwrap_or_default();
146 return Err(format!("contents {status}: {text}"));
147 }
148
149 let data: ContentsRes = resp.json().await.map_err(|e| format!("parse: {e}"))?;
150
151 data.results
152 .into_iter()
153 .next()
154 .map(|r| r.text)
155 .ok_or_else(|| "no content".into())
156}
157
158#[derive(Serialize)]
159struct McpReq {
160 jsonrpc: &'static str,
161 id: u32,
162 method: &'static str,
163 params: McpParams,
164}
165
166#[derive(Serialize)]
167struct McpParams {
168 name: String,
169 arguments: serde_json::Value,
170}
171
172#[derive(Deserialize)]
173struct McpRes {
174 result: Option<McpResult>,
175 error: Option<McpErr>,
176}
177
178#[derive(Deserialize)]
179struct McpResult {
180 content: Vec<McpBlock>,
181}
182
183#[derive(Deserialize)]
184struct McpBlock {
185 #[serde(rename = "type")]
186 kind: String,
187 text: String,
188}
189
190#[derive(Deserialize)]
191struct McpErr {
192 message: String,
193}
194
195pub(crate) async fn mcp_call(tool: &str, args: serde_json::Value) -> Result<String, String> {
196 let body = McpReq {
197 jsonrpc: "2.0",
198 id: 1,
199 method: "tools/call",
200 params: McpParams {
201 name: tool.to_string(),
202 arguments: args,
203 },
204 };
205
206 let raw = reqwest::Client::new()
207 .post(EXA_MCP)
208 .header("Accept", "application/json, text/event-stream")
209 .json(&body)
210 .send()
211 .await
212 .map_err(|e| format!("mcp: {e}"))?
213 .text()
214 .await
215 .map_err(|e| format!("mcp body: {e}"))?;
216
217 let line = raw
218 .lines()
219 .find(|l| l.starts_with("data: "))
220 .map(|l| &l["data: ".len()..])
221 .ok_or("no sse data")?;
222
223 let res: McpRes = serde_json::from_str(line).map_err(|e| format!("bad json: {e}"))?;
224
225 if let Some(e) = res.error {
226 return Err(format!("mcp err: {}", e.message));
227 }
228
229 Ok(res
230 .result
231 .ok_or("empty mcp result")?
232 .content
233 .into_iter()
234 .filter(|b| b.kind == "text")
235 .map(|b| b.text)
236 .collect::<Vec<_>>()
237 .join("\n\n"))
238}
239
240pub(crate) fn strip_na(s: &str) -> String {
241 s.lines()
242 .filter(|l| !matches!(l.trim(), "Published: N/A" | "Author: N/A" | "Highlights:"))
243 .collect::<Vec<_>>()
244 .join("\n")
245}