Files
zesdex/apps/infrastructure/src/tools/web_search.rs
T
asepharyana 7b0b53671f style: format seluruh workspace dengan cargo fmt
Menyeragamkan format kode sesuai rustfmt (126 file). Sebelumnya
lefthook pre-commit 'cargo fmt --check' akan gagal pada commit apa pun.
2026-08-27 22:10:28 +07:00

277 lines
9.2 KiB
Rust

//! Web search & documentation scraper tool — search the web for docs, APIs,
//! and troubleshooting information using a SearXNG instance.
//!
//! Flow: take a search query → call SearXNG JSON API → parse results →
//! optionally fetch full page content → return formatted markdown.
use anyhow::Result;
use serde_json::{json, Value};
use tracing::{debug, info, instrument, warn};
use crate::tools::{arg_str, Tool, ToolCtx};
/// Default SearXNG instance URL.
const DEFAULT_SEARXNG_URL: &str = "https://searxng.imrnes.team";
/// Search the web for documentation, APIs, and technical information.
///
/// Flow: build query → call SearXNG JSON endpoint → parse results →
/// optionally scrape full page content → return formatted result.
pub struct WebSearch;
impl Tool for WebSearch {
fn name(&self) -> &'static str {
"web_search"
}
fn description(&self) -> &'static str {
"Search the web for documentation, APIs, and technical information. Uses SearXNG instance."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (e.g. 'Next.js 15 app router documentation')"
},
"max_results": {
"type": "integer",
"description": "Maximum number of results to return (default 5, max 10)",
"default": 5
},
"fetch_content": {
"type": "boolean",
"description": "Whether to fetch full page content from each result (default false)",
"default": false
},
"categories": {
"type": "string",
"description": "Search categories (e.g. 'general', 'science', 'it', 'news'). Default: general",
"default": "general"
}
},
"required": ["query"]
})
}
#[instrument(skip(self, _ctx, args))]
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let query = arg_str(args, "query")?;
let max_results = args
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(5)
.min(10) as usize;
let fetch_content = args
.get("fetch_content")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let categories = args
.get("categories")
.and_then(|v| v.as_str())
.unwrap_or("general");
info!(query = %query, max_results, fetch_content, categories = %categories, "web search starting");
let searxng_url =
std::env::var("SEARXNG_URL").unwrap_or_else(|_| DEFAULT_SEARXNG_URL.to_string());
// Build the SearXNG JSON search URL
let search_url = format!(
"{}/search?format=json&q={}&categories={}&language=en-US",
searxng_url,
urlencoding(&query),
categories
);
debug!(search_url = %search_url, "calling SearXNG API");
// Make the HTTP request
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.user_agent("Zesdex/1.0 (AI Coding Agent)")
.build()
.map_err(|e| anyhow::anyhow!("failed to create HTTP client: {e}"))?;
let response = client
.get(&search_url)
.send()
.map_err(|e| anyhow::anyhow!("search request failed: {e}"))?;
if !response.status().is_success() {
warn!(status = %response.status(), "SearXNG returned non-success status");
anyhow::bail!("SearXNG returned HTTP {}", response.status());
}
let body: Value = response
.json()
.map_err(|e| anyhow::anyhow!("failed to parse SearXNG JSON response: {e}"))?;
let results = body
.get("results")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter(|r| {
r.get("url").and_then(|u| u.as_str()).is_some()
&& r.get("title").and_then(|t| t.as_str()).is_some()
})
.take(max_results)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let result_count = results.len();
if results.is_empty() {
info!("no search results found");
return Ok(format!("No search results found for '{query}'."));
}
info!(result_count, "web search completed");
let mut output = format!("## Web Search Results for: {query}\n\n");
for (i, result) in results.iter().enumerate() {
let title = result
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled");
let url = result.get("url").and_then(|v| v.as_str()).unwrap_or("");
let snippet = result
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
output.push_str(&format!("### {}. [{title}]({url})\n\n", i + 1));
if !snippet.is_empty() {
output.push_str(&format!("> {snippet}\n\n"));
}
// Optionally fetch full page content
if fetch_content && !url.is_empty() {
match fetch_page_content(url, &client) {
Ok(content) => {
let truncated = limit_lines(&content, 80);
output.push_str(&format!("**Content:**\n```\n{truncated}\n```\n\n"));
}
Err(e) => {
debug!(url = %url, error = %e, "failed to fetch page content");
output.push_str(&format!("*(Content fetch failed: {e})*\n\n"));
}
}
}
// Show engine info if available
if let Some(engine) = result.get("engine").and_then(|v| v.as_str()) {
output.push_str(&format!("*Source: {engine}*\n\n"));
}
}
// Add suggestion for more specific queries if very few results
if result_count < 3 {
output.push_str(
"---\n*Few results. Try a more specific query or different categories.*\n",
);
}
Ok(output)
}
}
/// URL-encode a string for use in query parameters.
fn urlencoding(input: &str) -> String {
input
.chars()
.map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
' ' => '+'.to_string(),
_ => {
let bytes = c.to_string().into_bytes();
bytes
.iter()
.map(|&b| format!("%{:02X}", b))
.collect::<String>()
}
})
.collect()
}
/// Fetch and extract readable text content from a URL.
fn fetch_page_content(url: &str, client: &reqwest::blocking::Client) -> Result<String> {
let resp = client
.get(url)
.timeout(std::time::Duration::from_secs(15))
.send()
.map_err(|e| anyhow::anyhow!("HTTP request failed: {e}"))?;
let html = resp
.text()
.map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
// Parse HTML and extract text
let document = scraper::Html::parse_document(&html);
// Remove script and style elements
let selector = scraper::Selector::parse("script, style, nav, footer, header")
.map_err(|e| anyhow::anyhow!("invalid selector: {e}"))?;
let cleaned = document.clone();
for element in cleaned.select(&selector) {
let inner = element.inner_html();
let _ = inner;
}
// Extract text from main content areas
let content_selectors = [
"article",
"main",
".content",
".documentation",
".doc-content",
".post-content",
".entry-content",
"#content",
"body",
];
let mut text = String::new();
for sel_str in &content_selectors {
if let Ok(sel) = scraper::Selector::parse(sel_str) {
if let Some(element) = cleaned.select(&sel).next() {
text = element.text().collect::<Vec<_>>().join(" ");
if text.len() > 100 {
break;
}
}
}
}
if text.is_empty() {
// Fallback: just get all body text
if let Ok(body_sel) = scraper::Selector::parse("body") {
text = cleaned
.select(&body_sel)
.next()
.map(|e| e.text().collect::<Vec<_>>().join(" "))
.unwrap_or_default();
}
}
// Clean up whitespace
let cleaned_text = text.split_whitespace().collect::<Vec<_>>().join(" ");
if cleaned_text.is_empty() {
anyhow::bail!("no readable content found on page");
}
Ok(cleaned_text)
}
/// Limit a string to at most `max_lines` lines.
fn limit_lines(s: &str, max_lines: usize) -> String {
s.lines().take(max_lines).collect::<Vec<_>>().join("\n")
}