Skip to main content

cosh_tools/web/
fetch.rs

1//! URL content extraction via Exa AI and/or local extraction.
2//!
3//! The `EXA_FIRST` constant controls extraction priority:
4//! - `true` (default): tries Exa Contents API / MCP `web_fetch_exa` first,
5//!   falls back to `rs_trafilatura`.
6//! - `false`: tries `rs_trafilatura` first, falls back to Exa.
7use 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
19/// Fetch a URL, returning clean markdown for LLM context.
20///
21/// Content is extracted via Exa Contents API / MCP `web_fetch_exa`,
22/// falling back to `rs_trafilatura` (order controlled by `EXA_FIRST`).
23///
24/// # Errors
25///
26/// Returns `Err` if the fetch fails or all fallback methods are exhausted.
27pub 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        // The Exa MCP `web_fetch_exa` tool expects `urls` as an array,
39        // not a single `url` string. Match the format used by `rest_contents`.
40        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        // rs_trafilatura spams stderr with debug messages that corrupt the TUI.
58        // Redirect stderr to the OS null device during extraction.
59        #[allow(unused_unsafe)]
60        let extract_result = {
61            // Open null device to suppress stderr (NUL on Windows, /dev/null on Unix)
62            #[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                // Fallback: no suppression
69                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                // Restore stderr
78                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        // rs_trafilatura's extraction_quality heuristic (0.0–1.0) tells us
88        // whether the content was successfully extracted or fell back to the
89        // raw body (e.g. for JSON API responses). Low quality means we
90        // should fall through to the Exa backend.
91        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}