Skip to main content

synaptic_tools/
jina_reader.rs

1//! Jina Reader tool — converts any URL to clean Markdown for LLM consumption.
2
3use async_trait::async_trait;
4use serde_json::{json, Value};
5use synaptic_core::{SynapticError, Tool};
6
7/// Jina Reader tool — converts any URL to clean Markdown for LLM consumption.
8///
9/// Uses the free Jina Reader API (no API key required). Removes ads, navigation,
10/// and boilerplate from web pages, returning clean Markdown text.
11///
12/// # Example
13///
14/// ```rust,ignore
15/// use synaptic_tools::JinaReaderTool;
16/// use synaptic_core::Tool;
17///
18/// let tool = JinaReaderTool::new();
19/// let result = tool.call(serde_json::json!({"url": "https://example.com"})).await?;
20/// println!("{}", result["content"].as_str().unwrap());
21/// ```
22pub struct JinaReaderTool {
23    client: reqwest::Client,
24}
25
26impl JinaReaderTool {
27    /// Create a new `JinaReaderTool`. No API key required.
28    pub fn new() -> Self {
29        Self {
30            client: reqwest::Client::new(),
31        }
32    }
33}
34
35impl Default for JinaReaderTool {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41#[async_trait]
42impl Tool for JinaReaderTool {
43    fn name(&self) -> &'static str {
44        "jina_reader"
45    }
46
47    fn description(&self) -> &'static str {
48        "Convert any web page URL to clean Markdown for LLM consumption. Removes ads, navigation, and boilerplate. Free to use, no API key required."
49    }
50
51    fn parameters(&self) -> Option<Value> {
52        Some(json!({
53            "type": "object",
54            "properties": {
55                "url": {
56                    "type": "string",
57                    "description": "The URL to fetch and convert to Markdown"
58                }
59            },
60            "required": ["url"]
61        }))
62    }
63
64    async fn call(&self, args: Value) -> Result<Value, SynapticError> {
65        let url = args["url"]
66            .as_str()
67            .ok_or_else(|| SynapticError::Tool("missing 'url' parameter".to_string()))?;
68
69        let reader_url = format!("https://r.jina.ai/{}", url);
70        let resp = self
71            .client
72            .get(&reader_url)
73            .header("Accept", "text/markdown")
74            .header("X-Return-Format", "markdown")
75            .send()
76            .await
77            .map_err(|e| SynapticError::Tool(format!("Jina Reader request: {e}")))?;
78
79        let status = resp.status().as_u16();
80        let content = resp
81            .text()
82            .await
83            .map_err(|e| SynapticError::Tool(format!("Jina Reader parse: {e}")))?;
84
85        if status != 200 {
86            return Err(SynapticError::Tool(format!(
87                "Jina Reader error ({})",
88                status
89            )));
90        }
91
92        Ok(json!({
93            "url": url,
94            "content": content,
95        }))
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn tool_metadata() {
105        let tool = JinaReaderTool::new();
106        assert_eq!(tool.name(), "jina_reader");
107        assert!(!tool.description().is_empty());
108    }
109
110    #[test]
111    fn tool_schema() {
112        let tool = JinaReaderTool::new();
113        let schema = tool.parameters().unwrap();
114        assert_eq!(schema["type"], "object");
115        assert!(schema["properties"]["url"].is_object());
116    }
117
118    #[test]
119    fn default_impl() {
120        let _tool = JinaReaderTool::default();
121    }
122
123    #[tokio::test]
124    async fn missing_url_returns_error() {
125        let tool = JinaReaderTool::new();
126        let result = tool.call(json!({})).await;
127        assert!(result.is_err());
128        assert!(result.unwrap_err().to_string().contains("url"));
129    }
130}