1use super::registry::Tool;
20use super::schema::{ParamType, ToolParameter, ToolSchema};
21use crate::config::SearchConfig;
22use crate::error::{AacodeError, Result};
23use serde_json::{json, Value};
24use std::path::PathBuf;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{LazyLock, Mutex};
27use std::time::Duration;
28
29const CONNECT_TIMEOUT_SECS: u64 = 2;
32const PER_ENGINE_CAP_SECS: f64 = 4.0;
34const CIRCUIT_OPEN_SECS: f64 = 120.0;
36const FAILURES_TO_OPEN: u32 = 1;
38const SCRAPE_MIN_SECS: f64 = 3.0;
40
41const DESKTOP_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
44
45static HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
49 use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE, USER_AGENT};
50 let mut headers = HeaderMap::new();
51 headers.insert(USER_AGENT, HeaderValue::from_static(DESKTOP_UA));
52 headers.insert(
53 ACCEPT,
54 HeaderValue::from_static("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
55 );
56 headers.insert(
57 ACCEPT_LANGUAGE,
58 HeaderValue::from_static("zh-CN,zh;q=0.9,en;q=0.8"),
59 );
60 headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
61 headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
62 headers.insert("Sec-Fetch-Site", HeaderValue::from_static("none"));
63 headers.insert("Upgrade-Insecure-Requests", HeaderValue::from_static("1"));
64 reqwest::Client::builder()
65 .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
66 .default_headers(headers)
67 .cookie_store(true)
68 .build()
69 .expect("reqwest client")
70});
71
72#[derive(Default, Clone)]
76struct EngineHealth {
77 failures: u32,
79 open_until: f64,
81 last_call: f64,
83}
84
85static ENGINE_HEALTH: std::sync::OnceLock<Mutex<std::collections::HashMap<String, EngineHealth>>> =
89 std::sync::OnceLock::new();
90
91fn engine_health() -> &'static Mutex<std::collections::HashMap<String, EngineHealth>> {
92 ENGINE_HEALTH.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
93}
94
95pub struct SearchWebTool {
96 pub cfg: SearchConfig,
97 pub timeout_secs: u64,
98}
99
100enum Attempt {
102 Ok(Vec<Value>),
103 Empty,
104 TransportError(String),
105 Skipped(&'static str),
106}
107
108impl SearchWebTool {
109 pub fn new(cfg: SearchConfig, timeout_secs: u64) -> Self {
110 SearchWebTool { cfg, timeout_secs }
111 }
112
113 fn choose_best_engine(&self) -> &'static str {
115 let det = detect_engine_type(self.cfg.searxng_url.as_deref().unwrap_or(""));
116 match det {
117 "brave" if self.cfg.brave_api_key.is_some() => "brave",
118 "google_cse" if self.cfg.google_cse_key.is_some() => "google_cse",
119 "bing" if self.cfg.bing_api_key.is_some() => "bing",
120 "serpapi" if self.cfg.serpapi_key.is_some() => "serpapi",
121 _ => {
122 if self.cfg.searxng_url.is_some() {
123 "searxng"
124 } else {
125 ""
126 }
127 }
128 }
129 }
130
131 fn circuit_open(&self, engine: &str) -> bool {
133 let guard = engine_health().lock().unwrap_or_else(|e| e.into_inner());
134 guard
135 .get(engine)
136 .map(|h| current_time() < h.open_until)
137 .unwrap_or(false)
138 }
139
140 fn record_outcome(&self, engine: &str, ok: bool) {
142 let mut guard = engine_health().lock().unwrap_or_else(|e| e.into_inner());
143 let h = guard.entry(engine.to_string()).or_default();
144 if ok {
145 h.failures = 0;
146 h.open_until = 0.0;
147 } else {
148 h.failures += 1;
149 if h.failures >= FAILURES_TO_OPEN {
150 h.open_until = current_time() + CIRCUIT_OPEN_SECS;
151 }
152 }
153 }
154
155 async fn enforce_rate_limit(&self, engine: &str, remaining: f64) {
158 let do_wait = {
159 let mut guard = engine_health().lock().unwrap_or_else(|e| e.into_inner());
160 let rate = match engine {
161 "searxng" => 0.5,
162 _ => 1.0,
163 };
164 let now = current_time();
165 let h = guard.entry(engine.to_string()).or_default();
166 if h.last_call > 0.0 {
167 let wait = rate - (now - h.last_call);
168 if wait > 0.0 {
169 let wait = wait.min(remaining.max(0.0));
170 Some(wait)
171 } else {
172 h.last_call = current_time();
173 None
174 }
175 } else {
176 h.last_call = current_time();
177 None
178 }
179 };
180 if let Some(wait) = do_wait {
181 tokio::time::sleep(Duration::from_secs_f64(wait)).await;
182 let mut guard = engine_health().lock().unwrap_or_else(|e| e.into_inner());
183 guard.entry(engine.to_string()).or_default().last_call = current_time();
184 }
185 }
186
187 async fn search_with_fallback(
190 &self,
191 query: &str,
192 max_results: usize,
193 timeout: u64,
194 cancel: &AtomicBool,
195 ) -> (bool, String, Vec<Value>, Vec<String>) {
196 let deadline = current_time() + timeout as f64;
197 let mut tried: Vec<String> = Vec::new();
198
199 let mut engine = self.choose_best_engine();
200 if engine.is_empty() {
201 engine = "searxng"; }
203
204 let mut order: Vec<&str> = vec![engine];
206 for fb in ["searxng", "brave", "google_cse", "bing", "serpapi"] {
207 if fb != engine {
208 order.push(fb);
209 }
210 }
211
212 for eng in order {
213 if cancel.load(Ordering::Relaxed) {
214 tried.push(format!("{eng}:cancelled"));
215 return (false, eng.to_string(), vec![], tried);
216 }
217 let remaining = deadline - current_time();
218 if remaining <= 0.5 {
219 tried.push(format!("{eng}:skipped(budget)"));
220 continue;
221 }
222 match self.try_engine(eng, query, max_results, remaining).await {
223 Attempt::Ok(results) => {
224 tried.push(format!("{eng}:ok"));
225 self.record_outcome(eng, true);
226 return (true, eng.to_string(), results, tried);
227 }
228 Attempt::Empty => {
229 tried.push(format!("{eng}:empty"));
230 self.record_outcome(eng, true); }
232 Attempt::TransportError(e) => {
233 tried.push(format!("{eng}:error({})", brief_err(&e)));
234 self.record_outcome(eng, false);
235 }
236 Attempt::Skipped(why) => {
237 tried.push(format!("{eng}:skipped({why})"));
238 }
239 }
240 }
241
242 if !cancel.load(Ordering::Relaxed) {
245 let remaining = (deadline - current_time()).max(SCRAPE_MIN_SECS);
246 if let Some((name, results)) = fallback_scrape(query, max_results, remaining).await {
247 if !results.is_empty() {
248 tried.push(format!("{name}:ok"));
249 return (true, name, results, tried);
250 }
251 }
252 tried.push("fallback_scrape:empty".to_string());
253 }
254
255 (false, engine.to_string(), vec![], tried)
256 }
257
258 async fn try_engine(
259 &self,
260 engine: &str,
261 query: &str,
262 max_results: usize,
263 remaining: f64,
264 ) -> Attempt {
265 let configured = match engine {
267 "searxng" => self.cfg.searxng_url.is_some(),
268 "brave" => self.cfg.brave_api_key.is_some(),
269 "google_cse" => self.cfg.google_cse_key.is_some() && self.cfg.google_cse_cx.is_some(),
270 "bing" => self.cfg.bing_api_key.is_some(),
271 "serpapi" => self.cfg.serpapi_key.is_some(),
272 _ => false,
273 };
274 if !configured {
275 return Attempt::Skipped("not-configured");
276 }
277 if self.circuit_open(engine) {
278 return Attempt::Skipped("circuit-open");
279 }
280 self.enforce_rate_limit(engine, remaining).await;
281 let budget = remaining.min(PER_ENGINE_CAP_SECS);
282 let r = match engine {
283 "searxng" => {
284 searxng_search(
285 self.cfg
286 .searxng_url
287 .as_deref()
288 .unwrap_or("http://localhost:8080"),
289 query,
290 max_results,
291 budget,
292 )
293 .await
294 }
295 "brave" => {
296 brave_search(
297 self.cfg.brave_api_key.as_deref().unwrap_or(""),
298 query,
299 max_results,
300 budget,
301 )
302 .await
303 }
304 "google_cse" => {
305 google_cse_search(
306 self.cfg.google_cse_key.as_deref().unwrap_or(""),
307 self.cfg.google_cse_cx.as_deref().unwrap_or(""),
308 query,
309 max_results,
310 budget,
311 )
312 .await
313 }
314 "bing" => {
315 bing_search(
316 self.cfg.bing_api_key.as_deref().unwrap_or(""),
317 query,
318 max_results,
319 budget,
320 )
321 .await
322 }
323 "serpapi" => {
324 serpapi_search(
325 self.cfg.serpapi_key.as_deref().unwrap_or(""),
326 query,
327 max_results,
328 budget,
329 )
330 .await
331 }
332 _ => return Attempt::Skipped("unknown-engine"),
333 };
334 match r {
335 Ok(v) if !v.is_empty() => Attempt::Ok(v),
336 Ok(_) => Attempt::Empty,
337 Err(e) => Attempt::TransportError(e.to_string()),
338 }
339 }
340}
341
342fn brief_err(e: &str) -> String {
344 let first = e.lines().next().unwrap_or(e);
345 let mut s: String = first.chars().take(80).collect();
346 if first.chars().count() > 80 {
347 s.push('…');
348 }
349 s
350}
351
352#[async_trait::async_trait]
353impl Tool for SearchWebTool {
354 fn schema(&self) -> ToolSchema {
355 ToolSchema::new(
356 "search_web",
357 "Search the web. Auto-detects backend from configured URL/key. Supports SearXNG, Brave, Google CSE, Bing, SerpAPI. Falls back to HTML scraping (Bing/Sogou) when all backends fail.",
358 vec![
359 ToolParameter::new("query", ParamType::String, true, "Search keywords", &["search", "keyword", "q", "term"]),
360 ToolParameter::new("max_results", ParamType::Integer, false, "Max results (default 5)", &["limit", "count", "num", "num_results"]),
361 ToolParameter::new("timeout", ParamType::Integer, false, "Timeout seconds (default 8)", &["timeout_seconds", "time_limit"]),
362 ],
363 )
364 }
365
366 async fn call(&self, args: &Value, cancel: &AtomicBool) -> Result<String> {
367 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
368 let max_results = args
369 .get("max_results")
370 .and_then(|v| v.as_u64())
371 .unwrap_or(5) as usize;
372 let timeout = args
373 .get("timeout")
374 .and_then(|v| v.as_u64())
375 .unwrap_or(self.timeout_secs);
376
377 if query.is_empty() {
378 return Ok(json!({"success": false, "error": "empty query"}).to_string());
379 }
380
381 let (success, engine, results, tried) = self
382 .search_with_fallback(query, max_results, timeout, cancel)
383 .await;
384
385 Ok(json!({
386 "success": success,
387 "query": query,
388 "engine": engine,
389 "results": results,
390 "total_results": results.len(),
391 "engines_tried": tried,
392 })
393 .to_string())
394 }
395}
396
397async fn searxng_search(
400 base: &str,
401 query: &str,
402 max_results: usize,
403 budget: f64,
404) -> Result<Vec<Value>> {
405 let url = format!("{}/search", base.trim_end_matches('/'));
406 let resp = HTTP_CLIENT
407 .get(&url)
408 .query(&[("q", query), ("format", "json")])
409 .timeout(Duration::from_secs_f64(budget))
410 .send()
411 .await
412 .map_err(|e| AacodeError::Network(e.to_string()))?;
413 let body = resp
414 .text()
415 .await
416 .map_err(|e| AacodeError::Network(e.to_string()))?;
417 let v: Value = serde_json::from_str(&body)?;
418 Ok(extract_results(&v, max_results, "title", "url", "content"))
419}
420
421async fn brave_search(
422 api_key: &str,
423 query: &str,
424 max_results: usize,
425 budget: f64,
426) -> Result<Vec<Value>> {
427 let resp = HTTP_CLIENT
428 .get("https://api.search.brave.com/res/v1/web/search")
429 .header("Accept", "application/json")
430 .header("Accept-Encoding", "gzip")
431 .header("X-Subscription-Token", api_key)
432 .query(&[("q", query), ("count", &max_results.to_string())])
433 .timeout(Duration::from_secs_f64(budget))
434 .send()
435 .await
436 .map_err(|e| AacodeError::Network(e.to_string()))?;
437 let body = resp
438 .text()
439 .await
440 .map_err(|e| AacodeError::Network(e.to_string()))?;
441 let v: Value = serde_json::from_str(&body)?;
442 let mut out = Vec::new();
443 if let Some(arr) = v
444 .get("web")
445 .and_then(|r| r.get("results"))
446 .and_then(|r| r.as_array())
447 {
448 for item in arr.iter().take(max_results) {
449 out.push(json!({
450 "title": item.get("title").and_then(|x| x.as_str()).unwrap_or(""),
451 "url": item.get("url").and_then(|x| x.as_str()).unwrap_or(""),
452 "content": item.get("description").and_then(|x| x.as_str()).unwrap_or(""),
453 }));
454 }
455 }
456 Ok(out)
457}
458
459async fn google_cse_search(
460 api_key: &str,
461 cx: &str,
462 query: &str,
463 max_results: usize,
464 budget: f64,
465) -> Result<Vec<Value>> {
466 let resp = HTTP_CLIENT
467 .get("https://www.googleapis.com/customsearch/v1")
468 .query(&[
469 ("key", api_key),
470 ("cx", cx),
471 ("q", query),
472 ("num", &max_results.to_string()),
473 ])
474 .timeout(Duration::from_secs_f64(budget))
475 .send()
476 .await
477 .map_err(|e| AacodeError::Network(e.to_string()))?;
478 let body = resp
479 .text()
480 .await
481 .map_err(|e| AacodeError::Network(e.to_string()))?;
482 let v: Value = serde_json::from_str(&body)?;
483 Ok(extract_results(&v, max_results, "title", "link", "snippet"))
484}
485
486async fn bing_search(
487 _api_key: &str,
488 query: &str,
489 max_results: usize,
490 budget: f64,
491) -> Result<Vec<Value>> {
492 let resp = HTTP_CLIENT
494 .get("https://api.bing.microsoft.com/v7.0/search")
495 .header("Ocp-Apim-Subscription-Key", _api_key)
496 .query(&[
497 ("q", query),
498 ("count", &max_results.to_string()),
499 ("mkt", "en-US"),
500 ])
501 .timeout(Duration::from_secs_f64(budget))
502 .send()
503 .await
504 .map_err(|e| AacodeError::Network(e.to_string()))?;
505 let body = resp
506 .text()
507 .await
508 .map_err(|e| AacodeError::Network(e.to_string()))?;
509 let v: Value = serde_json::from_str(&body)?;
510 let mut out = Vec::new();
511 if let Some(arr) = v
512 .get("webPages")
513 .and_then(|r| r.get("value"))
514 .and_then(|r| r.as_array())
515 {
516 for item in arr.iter().take(max_results) {
517 out.push(json!({
518 "title": item.get("name").and_then(|x| x.as_str()).unwrap_or(""),
519 "url": item.get("url").and_then(|x| x.as_str()).unwrap_or(""),
520 "content": item.get("snippet").and_then(|x| x.as_str()).unwrap_or(""),
521 }));
522 }
523 }
524 Ok(out)
525}
526
527async fn serpapi_search(
528 api_key: &str,
529 query: &str,
530 max_results: usize,
531 budget: f64,
532) -> Result<Vec<Value>> {
533 let resp = HTTP_CLIENT
534 .get("https://serpapi.com/search")
535 .query(&[
536 ("api_key", api_key),
537 ("q", query),
538 ("engine", "google"),
539 ("num", &max_results.to_string()),
540 ])
541 .timeout(Duration::from_secs_f64(budget))
542 .send()
543 .await
544 .map_err(|e| AacodeError::Network(e.to_string()))?;
545 let body = resp
546 .text()
547 .await
548 .map_err(|e| AacodeError::Network(e.to_string()))?;
549 let v: Value = serde_json::from_str(&body)?;
550 Ok(extract_results(&v, max_results, "title", "link", "snippet"))
551}
552
553fn extract_results(
555 v: &Value,
556 max_results: usize,
557 title_k: &str,
558 url_k: &str,
559 snippet_k: &str,
560) -> Vec<Value> {
561 let arr = match v
562 .get("results")
563 .or_else(|| v.get("items"))
564 .and_then(|r| r.as_array())
565 {
566 Some(a) => a,
567 None => return Vec::new(),
568 };
569 arr.iter()
570 .take(max_results)
571 .map(|item| {
572 json!({
573 "title": item.get(title_k).and_then(|x| x.as_str()).unwrap_or(""),
574 "url": item.get(url_k).and_then(|x| x.as_str()).unwrap_or(""),
575 "content": item.get(snippet_k).and_then(|x| x.as_str()).unwrap_or(""),
576 })
577 })
578 .collect()
579}
580
581async fn bing_rss_scrape(query: &str, max_results: usize, budget: f64) -> Option<Vec<Value>> {
587 let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<Value>>(2);
588 for base in ["https://www.bing.com/search", "https://cn.bing.com/search"] {
589 let q = query.to_string();
590 let bu = base.to_string();
591 let tx = tx.clone();
592 tokio::spawn(async move {
593 if let Some(v) = scrape_bing_rss(&bu, &q, max_results, budget).await {
594 let _ = tx.send(v).await;
595 }
596 });
597 }
598 drop(tx);
599 tokio::time::timeout(Duration::from_secs_f64(budget), rx.recv())
600 .await
601 .ok()
602 .flatten()
603}
604
605async fn scrape_bing_rss(
606 base_url: &str,
607 query: &str,
608 max_results: usize,
609 budget: f64,
610) -> Option<Vec<Value>> {
611 let resp = HTTP_CLIENT
612 .get(base_url)
613 .header("User-Agent", DESKTOP_UA)
614 .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
615 .query(&[("q", query), ("format", "rss")])
616 .timeout(Duration::from_secs_f64(budget))
617 .send()
618 .await
619 .ok()?;
620 let xml = resp.text().await.ok()?;
621 let results = parse_bing_rss(&xml, max_results);
622 if results.is_empty() {
623 None
624 } else {
625 Some(results)
626 }
627}
628
629fn parse_bing_rss(xml: &str, max_results: usize) -> Vec<Value> {
631 let Ok(item_re) = regex::Regex::new(r"(?s)<item>(.*?)</item>") else {
632 return vec![];
633 };
634 let Ok(title_re) = regex::Regex::new(r"(?s)<title>(.*?)</title>") else {
635 return vec![];
636 };
637 let Ok(link_re) = regex::Regex::new(r"(?s)<link>(.*?)</link>") else {
638 return vec![];
639 };
640 let Ok(desc_re) = regex::Regex::new(r"(?s)<description>(.*?)</description>") else {
641 return vec![];
642 };
643 let Ok(clean_re) = regex::Regex::new(r#"<[^>]+>"#) else {
644 return vec![];
645 };
646 let pick = |re: ®ex::Regex, it: &str| -> String {
647 re.captures(it)
648 .map(|c| strip_cdata(&c[1]))
649 .unwrap_or_default()
650 };
651 let mut results = Vec::new();
652 let mut seen = std::collections::HashSet::new();
653 for cap in item_re.captures_iter(xml) {
654 let it = &cap[1];
655 let title = html_unescape(&pick(&title_re, it)).trim().to_string();
656 let url = normalize_result_url(&pick(&link_re, it));
657 let snippet = html_unescape(clean_re.replace_all(&pick(&desc_re, it), "").trim());
658 if !is_quality_result(&url, &title) || seen.contains(&url) {
659 continue;
660 }
661 seen.insert(url.clone());
662 results.push(json!({
663 "title": title,
664 "url": url,
665 "content": snippet,
666 "engine": "bing_rss",
667 }));
668 if results.len() >= max_results {
669 break;
670 }
671 }
672 results
673}
674
675async fn fallback_scrape(
676 query: &str,
677 max_results: usize,
678 budget: f64,
679) -> Option<(String, Vec<Value>)> {
680 if budget <= 0.5 {
681 return None;
682 }
683 if let Some(results) = bing_rss_scrape(query, max_results, (budget * 0.6).max(2.0)).await {
687 return Some(("bing_rss".to_string(), results));
688 }
689 let scrapers: [(&str, &str, &str, &str, &str); 4] = [
692 (
693 "ddg_scrape",
694 "https://html.duckduckgo.com/html/",
695 r#"(?s)<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>.*?class="result__snippet"[^>]*>(.*?)</"#,
696 r#"<[^>]+>"#,
697 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
698 ),
699 (
700 "bing_scrape",
701 "https://www.bing.com/search",
702 r#"(?s)<li\s+class="b_algo"[^>]*>.*?<h2[^>]*>\s*<a[^>]*href="(https?://[^"]+)"[^>]*>(.*?)</a>.*?<p[^>]*>(.*?)</p>"#,
705 r#"<[^>]+>"#,
706 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
707 ),
708 (
709 "bing_scrape_cn",
710 "https://cn.bing.com/search",
711 r#"(?s)<li\s+class="b_algo"[^>]*>.*?<h2[^>]*>\s*<a[^>]*href="(https?://[^"]+)"[^>]*>(.*?)</a>.*?<p[^>]*>(.*?)</p>"#,
712 r#"<[^>]+>"#,
713 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
714 ),
715 (
716 "sogou_scrape",
717 "https://www.sogou.com/web",
718 r#"(?s)<div[^>]*class="[^"]*vrwrap[^"]*"[^>]*>.*?<a[^>]*href="(https?://[^"]+)"[^>]*>\s*(.*?)\s*</a>.*?<p[^>]*>(.*?)</p>"#,
719 r#"<[^>]+>"#,
720 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
721 ),
722 ];
723
724 let (tx, mut rx) = tokio::sync::mpsc::channel::<(String, Vec<Value>)>(3);
725 for (name, base_url, result_re, clean_re, ua) in &scrapers {
726 let q = query.to_string();
727 let n = name.to_string();
728 let bu = base_url.to_string();
729 let re = result_re.to_string();
730 let cr = clean_re.to_string();
731 let ua = ua.to_string();
732 let tx = tx.clone();
733 let budget = budget * 0.9; tokio::spawn(async move {
735 let scraped = scrape_one(&n, &bu, &q, max_results, budget, &re, &cr, &ua).await;
736 if let Some(v) = scraped {
737 let _ = tx.send((n, v)).await;
738 }
739 });
740 }
741 drop(tx);
742
743 tokio::time::timeout(Duration::from_secs_f64(budget), rx.recv())
744 .await
745 .ok()
746 .flatten()
747}
748
749async fn scrape_one(
750 name: &str,
751 base_url: &str,
752 query: &str,
753 max_results: usize,
754 budget: f64,
755 result_re: &str,
756 clean_re: &str,
757 ua: &str,
758) -> Option<Vec<Value>> {
759 let resp = HTTP_CLIENT
760 .get(base_url)
761 .header("User-Agent", ua)
762 .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
763 .query(&[("q", query)])
764 .timeout(Duration::from_secs_f64(budget))
765 .send()
766 .await;
767 let resp = match resp {
768 Ok(r) => r,
769 Err(_) => return None,
770 };
771 let html = match resp.text().await {
772 Ok(s) => s,
773 Err(_) => return None,
774 };
775 let re = regex::Regex::new(result_re).ok()?;
776 let clean = regex::Regex::new(clean_re).ok()?;
777 let mut results = Vec::new();
778 let mut seen = std::collections::HashSet::new();
779 for caps in re.captures_iter(&html) {
780 let raw_url = caps
781 .get(1)
782 .map(|m| m.as_str().trim().to_string())
783 .unwrap_or_default();
784 let url = normalize_result_url(&raw_url);
785 let title = html_unescape(
786 clean
787 .replace_all(caps.get(2).map(|m| m.as_str()).unwrap_or(""), "")
788 .trim(),
789 );
790 let snippet = html_unescape(
791 clean
792 .replace_all(caps.get(3).map(|m| m.as_str()).unwrap_or(""), "")
793 .trim(),
794 );
795 if !is_quality_result(&url, &title) || seen.contains(&url) {
796 continue;
797 }
798 seen.insert(url.clone());
799 results.push(json!({
800 "title": title,
801 "url": url,
802 "content": snippet,
803 "engine": name,
804 }));
805 if results.len() >= max_results {
806 break;
807 }
808 }
809 if results.is_empty() {
810 None
811 } else {
812 Some(results)
813 }
814}
815
816fn normalize_result_url(url: &str) -> String {
819 let url = html_unescape(url);
821 if let Some(pos) = url.find("uddg=") {
822 let rest = &url[pos + 5..];
823 let enc = rest.split('&').next().unwrap_or(rest);
824 return percent_decode(enc);
825 }
826 if let Some(stripped) = url.strip_prefix("//") {
827 return format!("https://{stripped}");
828 }
829 url
830}
831
832fn percent_decode(s: &str) -> String {
833 let bytes = s.as_bytes();
834 let mut out = Vec::with_capacity(bytes.len());
835 let mut i = 0;
836 while i < bytes.len() {
837 if bytes[i] == b'%' && i + 2 < bytes.len() {
838 if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
839 out.push(b);
840 i += 3;
841 continue;
842 }
843 }
844 out.push(bytes[i]);
845 i += 1;
846 }
847 String::from_utf8_lossy(&out).to_string()
848}
849
850fn html_unescape(s: &str) -> String {
851 let mut out = String::with_capacity(s.len());
852 let mut i = 0usize;
853 while i < s.len() {
854 if s.as_bytes()[i] == b'&' {
855 if let Some(rel) = s[i..].find(';') {
856 let semi = i + rel;
857 if semi - i <= 12 {
859 if let Some(ch) = decode_entity(&s[i + 1..semi]) {
860 out.push(ch);
861 i = semi + 1;
862 continue;
863 }
864 }
865 }
866 }
867 let ch = s[i..].chars().next().unwrap();
868 out.push(ch);
869 i += ch.len_utf8();
870 }
871 out
872}
873
874fn decode_entity(ent: &str) -> Option<char> {
877 if let Some(hex) = ent.strip_prefix("#x").or_else(|| ent.strip_prefix("#X")) {
878 return u32::from_str_radix(hex, 16).ok().and_then(char::from_u32);
879 }
880 if let Some(dec) = ent.strip_prefix('#') {
881 return dec.parse::<u32>().ok().and_then(char::from_u32);
882 }
883 Some(match ent {
884 "amp" => '&',
885 "lt" => '<',
886 "gt" => '>',
887 "quot" => '"',
888 "apos" => '\'',
889 "nbsp" | "ensp" | "emsp" | "thinsp" | "zwnj" | "zwj" => ' ',
890 "middot" => '·',
891 "copy" => '©',
892 "reg" => '®',
893 "trade" => '™',
894 "hellip" => '…',
895 "mdash" => '—',
896 "ndash" | "minus" => '–',
897 "laquo" => '«',
898 "raquo" => '»',
899 "ldquo" => '“',
900 "rdquo" => '”',
901 "lsquo" => '‘',
902 "rsquo" => '’',
903 "times" => '×',
904 "divide" => '÷',
905 "deg" => '°',
906 "plusmn" => '±',
907 "sup2" => '²',
908 "sup3" => '³',
909 "frac12" => '½',
910 "frac14" => '¼',
911 "euro" => '€',
912 "pound" => '£',
913 "yen" => '¥',
914 "cent" => '¢',
915 "sect" => '§',
916 "para" => '¶',
917 "bull" => '•',
918 "dagger" => '†',
919 "prime" => '′',
920 "Prime" => '″',
921 "lsaquo" => '‹',
922 "rsaquo" => '›',
923 "larr" => '←',
924 "rarr" => '→',
925 "harr" => '↔',
926 "ne" => '≠',
927 "le" => '≤',
928 "ge" => '≥',
929 _ => return None,
930 })
931}
932
933fn strip_cdata(s: &str) -> String {
935 let t = s.trim();
936 t.strip_prefix("<![CDATA[")
937 .and_then(|x| x.strip_suffix("]]>"))
938 .map(|x| x.to_string())
939 .unwrap_or_else(|| t.to_string())
940}
941
942fn is_quality_result(url: &str, title: &str) -> bool {
945 if url.is_empty() || !url.starts_with("http") {
946 return false;
947 }
948 if title.is_empty() || title.contains('›') {
949 return false;
950 }
951 if !title.contains(' ')
953 && (title.contains("http") || title.contains(".com") || title.contains(".org"))
954 {
955 return false;
956 }
957 if looks_like_search_url(url) {
959 return false;
960 }
961 let t = title.trim();
963 if t.contains("百度一下") || t.contains("搜索结果") {
964 return false;
965 }
966 true
967}
968
969fn looks_like_search_url(url: &str) -> bool {
974 let u = url.to_ascii_lowercase();
975 const HOSTS: &[&str] = &[
976 "bing.com/aclick",
977 "bing.com/search",
978 "duckduckgo.com/y.js",
979 "mc.baidu.com",
980 "baidu.com/s?",
981 "m.baidu.com/from=",
982 "so.com/s?",
983 "sogou.com/web",
984 "google.com/search",
985 "google.com/url?",
986 "search.yahoo.com",
987 "yandex.com/search",
988 "sm.cn/s?",
989 ];
990 if HOSTS.iter().any(|h| u.contains(h)) {
991 return true;
992 }
993 let (path, query) = match u.split_once('?') {
995 Some((p, q)) => (p, q),
996 None => return false,
997 };
998 let search_path = ["/s", "/search", "/web", "/results", "/find", "/query"]
999 .iter()
1000 .any(|s| path.ends_with(s));
1001 if !search_path {
1002 return false;
1003 }
1004 const PARAMS: &[&str] = &["q=", "word=", "query=", "wd=", "keyword=", "kw="];
1005 PARAMS
1006 .iter()
1007 .any(|p| query.starts_with(p) || query.contains(&format!("&{p}")))
1008}
1009
1010fn detect_engine_type(url: &str) -> &'static str {
1013 let lower = url.to_lowercase();
1014 if lower.contains("brave.com") {
1015 "brave"
1016 } else if lower.contains("googleapis.com/customsearch") {
1017 "google_cse"
1018 } else if lower.contains("bing.microsoft.com") {
1019 "bing"
1020 } else if lower.contains("serpapi.com") {
1021 "serpapi"
1022 } else {
1023 "searxng"
1024 }
1025}
1026
1027fn current_time() -> f64 {
1028 use std::time::{SystemTime, UNIX_EPOCH};
1029 SystemTime::now()
1030 .duration_since(UNIX_EPOCH)
1031 .map(|d| d.as_secs_f64())
1032 .unwrap_or(0.0)
1033}
1034
1035fn decode_body(bytes: &[u8], content_type: &str) -> String {
1042 let label = charset_from_content_type(content_type).or_else(|| sniff_meta_charset(bytes));
1043 match label
1044 .as_deref()
1045 .and_then(|l| encoding_rs::Encoding::for_label(l.as_bytes()))
1046 {
1047 Some(enc) if enc != encoding_rs::UTF_8 => {
1048 let (s, _, _) = enc.decode(bytes);
1049 s.into_owned()
1050 }
1051 _ => String::from_utf8_lossy(bytes).into_owned(),
1052 }
1053}
1054
1055fn charset_from_content_type(ct: &str) -> Option<String> {
1056 let lower = ct.to_ascii_lowercase();
1057 let i = lower.find("charset=")?;
1058 let rest = &ct[i + "charset=".len()..];
1060 let val: String = rest
1061 .trim_start_matches(['"', '\'', ' '])
1062 .chars()
1063 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
1064 .collect();
1065 if val.is_empty() {
1066 None
1067 } else {
1068 Some(val.to_ascii_lowercase())
1069 }
1070}
1071
1072fn sniff_meta_charset(bytes: &[u8]) -> Option<String> {
1073 let head = &bytes[..bytes.len().min(4096)];
1074 let s = String::from_utf8_lossy(head).to_ascii_lowercase();
1075 let i = s.find("charset=")?;
1076 let rest = &s[i + "charset=".len()..];
1077 let val: String = rest
1078 .trim_start_matches(['"', '\'', ' '])
1079 .chars()
1080 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
1081 .collect();
1082 if val.is_empty() {
1083 None
1084 } else {
1085 Some(val)
1086 }
1087}
1088
1089pub struct FetchUrlTool {
1090 pub project_path: PathBuf,
1091 pub timeout_secs: u64,
1092}
1093
1094#[async_trait::async_trait]
1095impl Tool for FetchUrlTool {
1096 fn schema(&self) -> ToolSchema {
1097 ToolSchema::new(
1098 "fetch_url",
1099 "Fetch the content of a URL (cleaned text). Also available via run_shell + curl.",
1100 vec![
1101 ToolParameter::new(
1102 "url",
1103 ParamType::String,
1104 true,
1105 "URL to fetch",
1106 &["link", "uri", "address"],
1107 ),
1108 ToolParameter::new(
1109 "timeout",
1110 ParamType::Integer,
1111 false,
1112 "Timeout seconds",
1113 &["time_limit", "max_time", "wait"],
1114 ),
1115 ToolParameter::new(
1116 "max_content_length",
1117 ParamType::Integer,
1118 false,
1119 "Max cleaned chars",
1120 &["max_length", "max_chars"],
1121 ),
1122 ],
1123 )
1124 }
1125
1126 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
1127 let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("");
1128 if url.is_empty() {
1129 return Ok(json!({"success": false, "error": "empty url"}).to_string());
1130 }
1131 let timeout = args
1132 .get("timeout")
1133 .and_then(|v| v.as_u64())
1134 .unwrap_or(self.timeout_secs);
1135 let max_len = args
1136 .get("max_content_length")
1137 .and_then(|v| v.as_u64())
1138 .unwrap_or(5000) as usize;
1139
1140 let resp = HTTP_CLIENT
1144 .get(url)
1145 .timeout(Duration::from_secs_f64(timeout as f64))
1146 .send()
1147 .await;
1148 let (status, body) = match resp {
1149 Ok(r) => {
1150 let status = r.status().as_u16();
1151 let ctype = r
1152 .headers()
1153 .get(reqwest::header::CONTENT_TYPE)
1154 .and_then(|v| v.to_str().ok())
1155 .unwrap_or("")
1156 .to_string();
1157 let bytes = r.bytes().await.unwrap_or_default();
1158 (status, decode_body(&bytes, &ctype))
1159 }
1160 Err(e) => {
1161 return Ok(
1162 json!({"success": false, "url": url, "error": e.to_string()}).to_string(),
1163 );
1164 }
1165 };
1166
1167 let raw_len = body.len();
1168 let cleaned = clean_html(&body);
1169 let cleaned_len = cleaned.chars().count();
1170 let content: String = cleaned.chars().take(max_len).collect();
1171 let saved = save_extract(&self.project_path, &cleaned);
1172
1173 let block = detect_block(status, &body, cleaned_len);
1174 let ok = (200..300).contains(&status) && block.is_none();
1175
1176 #[allow(unused_mut)]
1177 let mut out = json!({
1178 "success": ok,
1179 "url": url,
1180 "status_code": status,
1181 "raw_length": raw_len,
1182 "content_length": cleaned_len,
1183 "content": content,
1184 "extract_file": saved,
1185 });
1186 if let Some(reason) = block {
1187 out["blocked"] = json!(true);
1188 out["block_reason"] = json!(reason);
1189 out["hint"] = json!(
1190 "Blocked by the site's anti-bot / JS challenge — a plain HTTP fetch cannot pass it. \
1191 Use `browser_tools` + `browser_call`: `navigate` to the URL, then `get_page_text` (or `extract_text`). \
1192 For sites with a mobile/AMP version or a public API, those often bypass the challenge."
1193 );
1194 } else if !ok {
1195 out["hint"] = json!(format!(
1196 "HTTP {status} — the server returned an error page; verify the URL or try a web search."
1197 ));
1198 } else if cleaned_len < 200 {
1199 #[cfg(feature = "browser")]
1200 {
1201 out["hint"] = json!(
1202 "little text extracted — this page may need JavaScript; try fetch_rendered, or browser_tools + browser_call (navigate → get_page_text)"
1203 );
1204 }
1205 }
1206 Ok(out.to_string())
1207 }
1208}
1209
1210pub(crate) fn detect_block(status: u16, body: &str, cleaned_len: usize) -> Option<&'static str> {
1216 if status >= 400 {
1217 return Some("http_status");
1218 }
1219 if status == 0 {
1220 return Some("no_response");
1221 }
1222 if cleaned_len >= 120 {
1223 return None;
1224 }
1225 let lower = body.to_ascii_lowercase();
1226 const CHALLENGE_WORDS: &[&str] = &[
1227 "captcha",
1228 "challenge",
1229 "are you a robot",
1230 "verify you are human",
1231 "access denied",
1232 "just a moment",
1233 "attention required",
1234 "checking your browser",
1235 "enable javascript",
1236 "安全验证",
1237 "验证码",
1238 "人机验证",
1239 "访问验证",
1240 "滑动验证",
1241 ];
1242 if CHALLENGE_WORDS.iter().any(|w| lower.contains(w)) {
1243 return Some("anti_bot");
1244 }
1245 if lower.matches("<script").count() >= 2 && cleaned_len < 40 {
1247 return Some("js_challenge");
1248 }
1249 None
1250}
1251
1252fn save_extract(project_path: &std::path::Path, content: &str) -> Option<String> {
1253 let dir = project_path.join(".aacode").join("context");
1254 std::fs::create_dir_all(&dir).ok()?;
1255 let stamp = std::time::SystemTime::now()
1258 .duration_since(std::time::UNIX_EPOCH)
1259 .map(|d| d.as_millis())
1260 .unwrap_or(0);
1261 let path = dir.join(format!("web_fetch_{stamp}_{}.txt", std::process::id()));
1262 std::fs::write(&path, content).ok()?;
1263 if let Ok(entries) = std::fs::read_dir(&dir) {
1265 let mut files: Vec<_> = entries
1266 .flatten()
1267 .filter(|e| e.file_name().to_string_lossy().starts_with("web_fetch_"))
1268 .collect();
1269 if files.len() > 20 {
1270 files.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
1271 for old in files.iter().take(files.len() - 20) {
1272 let _ = std::fs::remove_file(old.path());
1273 }
1274 }
1275 }
1276 Some(path.to_string_lossy().to_string())
1277}
1278
1279pub fn clean_html(html: &str) -> String {
1283 let stripped = remove_blocks(html);
1284 let mut out = String::with_capacity(stripped.len() / 2);
1285 let mut in_tag = false;
1286 for c in stripped.chars() {
1287 match c {
1288 '<' => in_tag = true,
1289 '>' => {
1290 in_tag = false;
1291 out.push(' ');
1292 }
1293 _ if !in_tag => out.push(c),
1294 _ => {}
1295 }
1296 }
1297 let decoded = html_unescape(&out);
1298 decoded.split_whitespace().collect::<Vec<_>>().join(" ")
1299}
1300
1301fn remove_blocks(html: &str) -> String {
1302 let lower = html.to_lowercase();
1303 let chars: Vec<char> = html.chars().collect();
1304 let low: Vec<char> = lower.chars().collect();
1305 let mut out = String::with_capacity(html.len());
1306 let mut i = 0;
1307 let n = chars.len();
1308 while i < n {
1309 let rest: String = low[i..].iter().take(8).collect();
1310 if rest.starts_with("<script") || rest.starts_with("<style") {
1311 let close = if rest.starts_with("<script") {
1312 "</script>"
1313 } else {
1314 "</style>"
1315 };
1316 let low_rest: String = low[i..].iter().collect();
1317 if let Some(pos) = low_rest.find(close) {
1318 i += pos + close.len();
1319 continue;
1320 } else {
1321 break;
1322 }
1323 }
1324 out.push(chars[i]);
1325 i += 1;
1326 }
1327 out
1328}
1329
1330pub fn html_to_text(html: &str) -> String {
1337 let stripped = remove_tag_blocks(html);
1338 let mut out = String::with_capacity(stripped.len() / 2);
1339 let mut in_tag = false;
1340 let mut tag = String::new();
1341 for c in stripped.chars() {
1342 match c {
1343 '<' => {
1344 in_tag = true;
1345 tag.clear();
1346 }
1347 '>' => {
1348 in_tag = false;
1349 out.push(if is_block_tag(&tag) { '\n' } else { ' ' });
1350 }
1351 _ if in_tag => tag.push(c.to_ascii_lowercase()),
1352 _ => out.push(c),
1353 }
1354 }
1355 let decoded = decode_entities(&out);
1356 let mut clean = String::with_capacity(decoded.len());
1357 let mut prev: Option<String> = None;
1358 for raw in decoded.lines() {
1359 let line = raw.split_whitespace().collect::<Vec<_>>().join(" ");
1360 if line.is_empty() || prev.as_deref() == Some(line.as_str()) {
1361 continue;
1362 }
1363 if !clean.is_empty() {
1364 clean.push('\n');
1365 }
1366 clean.push_str(&line);
1367 prev = Some(line);
1368 }
1369 clean
1370}
1371
1372fn remove_tag_blocks(html: &str) -> String {
1374 let lower = html.to_ascii_lowercase();
1375 let mut out = String::with_capacity(html.len());
1376 let mut i = 0usize;
1377 let n = html.len();
1378 while i < n {
1379 let rest = &lower[i..];
1380 let close = if rest.starts_with("<script") {
1381 Some("</script>")
1382 } else if rest.starts_with("<style") {
1383 Some("</style>")
1384 } else if rest.starts_with("<noscript") {
1385 Some("</noscript>")
1386 } else if rest.starts_with("<!--") {
1387 Some("-->")
1388 } else {
1389 None
1390 };
1391 if let Some(c) = close {
1392 match rest.find(c) {
1393 Some(p) => {
1394 i += p + c.len();
1395 continue;
1396 }
1397 None => break,
1398 }
1399 }
1400 let ch = html[i..].chars().next().unwrap();
1401 out.push(ch);
1402 i += ch.len_utf8();
1403 }
1404 out
1405}
1406
1407fn is_block_tag(tag: &str) -> bool {
1409 let t = tag
1410 .trim_start_matches('/')
1411 .split(|c: char| c.is_whitespace() || c == '/')
1412 .next()
1413 .unwrap_or("");
1414 matches!(
1415 t,
1416 "p" | "div"
1417 | "br"
1418 | "li"
1419 | "ul"
1420 | "ol"
1421 | "tr"
1422 | "td"
1423 | "th"
1424 | "table"
1425 | "h1"
1426 | "h2"
1427 | "h3"
1428 | "h4"
1429 | "h5"
1430 | "h6"
1431 | "section"
1432 | "article"
1433 | "header"
1434 | "footer"
1435 | "nav"
1436 | "aside"
1437 | "blockquote"
1438 | "pre"
1439 | "hr"
1440 | "form"
1441 | "figure"
1442 | "figcaption"
1443 | "main"
1444 )
1445}
1446
1447fn decode_entities(s: &str) -> String {
1448 s.replace(" ", " ")
1449 .replace("&", "&")
1450 .replace("<", "<")
1451 .replace(">", ">")
1452 .replace(""", "\"")
1453 .replace("'", "'")
1454 .replace("'", "'")
1455 .replace("—", "—")
1456 .replace("–", "–")
1457 .replace("…", "…")
1458}
1459
1460pub struct SearchCodeTool {
1463 pub cfg: SearchConfig,
1464 pub timeout_secs: u64,
1465}
1466
1467#[async_trait::async_trait]
1468impl Tool for SearchCodeTool {
1469 fn schema(&self) -> ToolSchema {
1470 ToolSchema::new(
1471 "search_code",
1472 "Search code examples. If SearXNG is configured, uses it with categories=it to find code. Falls back to GitHub repository search.",
1473 vec![
1474 ToolParameter::new("query", ParamType::String, true, "Search keywords", &["q", "keyword", "search"]),
1475 ToolParameter::new("max_results", ParamType::Integer, false, "Max results", &["limit", "count"]),
1476 ],
1477 )
1478 }
1479 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
1480 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
1481 if query.is_empty() {
1482 return Ok(json!({"success": false, "error": "empty query"}).to_string());
1483 }
1484 let max = args
1485 .get("max_results")
1486 .and_then(|v| v.as_u64())
1487 .unwrap_or(5) as usize;
1488
1489 if let Some(base) = &self.cfg.searxng_url {
1492 let url = format!("{}/search", base.trim_end_matches('/'));
1493 match HTTP_CLIENT
1494 .get(&url)
1495 .query(&[("q", query), ("format", "json"), ("categories", "it")])
1496 .timeout(Duration::from_secs_f64(PER_ENGINE_CAP_SECS))
1497 .send()
1498 .await
1499 {
1500 Ok(r) => {
1501 let body = r.text().await.unwrap_or_default();
1502 if let Ok(v) = serde_json::from_str::<Value>(&body) {
1503 let results = extract_results(&v, max, "title", "url", "content");
1504 if !results.is_empty() {
1505 return Ok(
1506 json!({"success": true, "query": query, "results": results})
1507 .to_string(),
1508 );
1509 }
1510 }
1511 }
1512 Err(_) => {}
1513 }
1514 }
1515
1516 let url = format!(
1518 "https://api.github.com/search/repositories?q={}&per_page={}",
1519 urlencode(query),
1520 max
1521 );
1522 let resp = HTTP_CLIENT
1523 .get(&url)
1524 .header("User-Agent", "aacode-rs")
1525 .header("Accept", "application/vnd.github+json")
1526 .timeout(Duration::from_secs_f64(self.timeout_secs as f64))
1527 .send()
1528 .await;
1529 match resp {
1530 Ok(r) => {
1531 let body = r.text().await.unwrap_or_default();
1532 let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));
1533 let mut items = Vec::new();
1534 if let Some(arr) = v.get("items").and_then(|x| x.as_array()) {
1535 for it in arr.iter().take(max) {
1536 items.push(json!({
1537 "name": it.get("full_name").and_then(|x| x.as_str()).unwrap_or(""),
1538 "url": it.get("html_url").and_then(|x| x.as_str()).unwrap_or(""),
1539 "description": it.get("description").and_then(|x| x.as_str()).unwrap_or(""),
1540 "stars": it.get("stargazers_count").and_then(|x| x.as_u64()).unwrap_or(0),
1541 }));
1542 }
1543 }
1544 Ok(json!({"success": true, "query": query, "results": items}).to_string())
1545 }
1546 Err(e) => Ok(json!({"success": false, "error": e.to_string()}).to_string()),
1547 }
1548 }
1549}
1550
1551fn urlencode(s: &str) -> String {
1552 let mut out = String::new();
1553 for b in s.bytes() {
1554 match b {
1555 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1556 out.push(b as char)
1557 }
1558 b' ' => out.push_str("%20"),
1559 _ => out.push_str(&format!("%{b:02X}")),
1560 }
1561 }
1562 out
1563}
1564
1565#[cfg(test)]
1566mod tests {
1567 use super::*;
1568
1569 #[test]
1570 fn clean_html_strips_tags_and_scripts() {
1571 let html = "<html><head><style>.a{color:red}</style><script>var x=1;</script></head><body><p>Hello <b>World</b></p></body></html>";
1572 let cleaned = clean_html(html);
1573 assert!(cleaned.contains("Hello"));
1574 assert!(cleaned.contains("World"));
1575 assert!(!cleaned.contains("color:red"));
1576 assert!(!cleaned.contains("var x"));
1577 }
1578
1579 #[test]
1580 fn html_to_text_keeps_block_lines_and_drops_scripts() {
1581 let html = "<h1>Title</h1><script>var x=1;</script><p>Hello world</p>\
1582 <!-- c --><div>a</div><div>a</div><div>b</div>";
1583 let text = html_to_text(html);
1584 assert_eq!(text, "Title\nHello world\na\nb");
1585 assert!(!text.contains('<'));
1586 }
1587
1588 #[test]
1589 fn urlencode_works() {
1590 assert_eq!(urlencode("a b"), "a%20b");
1591 assert_eq!(urlencode("rust lang"), "rust%20lang");
1592 }
1593
1594 #[test]
1595 fn detect_engine() {
1596 assert_eq!(detect_engine_type("https://api.search.brave.com"), "brave");
1597 assert_eq!(detect_engine_type("https://api.bing.microsoft.com"), "bing");
1598 assert_eq!(detect_engine_type("https://myserver.com"), "searxng");
1599 }
1600
1601 #[tokio::test]
1602 async fn search_web_no_backend() {
1603 let t = SearchWebTool::new(SearchConfig::default(), 5);
1604 let cancel = AtomicBool::new(false);
1605 let out = t.call(&json!({"query": "rust"}), &cancel).await.unwrap();
1606 let v: Value = serde_json::from_str(&out).unwrap();
1607 let _ = v["success"].as_bool();
1609 }
1610
1611 #[tokio::test]
1612 async fn fetch_url_empty() {
1613 let t = FetchUrlTool {
1614 project_path: std::env::temp_dir(),
1615 timeout_secs: 5,
1616 };
1617 let cancel = AtomicBool::new(false);
1618 let out = t.call(&json!({"url": ""}), &cancel).await.unwrap();
1619 assert_eq!(
1620 serde_json::from_str::<Value>(&out).unwrap()["success"],
1621 false
1622 );
1623 }
1624
1625 #[tokio::test]
1626 async fn fetch_url_hints_when_content_short() {
1627 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1628 let port = server.server_addr().to_ip().unwrap().port();
1629 std::thread::spawn(move || {
1630 if let Ok(req) = server.recv() {
1631 let _ = req.respond(tiny_http::Response::from_string(
1632 "<html><body>hi</body></html>",
1633 ));
1634 }
1635 });
1636 let t = FetchUrlTool {
1637 project_path: std::env::temp_dir(),
1638 timeout_secs: 5,
1639 };
1640 let cancel = AtomicBool::new(false);
1641 let out = t
1642 .call(
1643 &json!({"url": format!("http://127.0.0.1:{port}/")}),
1644 &cancel,
1645 )
1646 .await
1647 .unwrap();
1648 let v: Value = serde_json::from_str(&out).unwrap();
1649 assert_eq!(v["success"], true);
1650 #[cfg(feature = "browser")]
1651 assert!(
1652 v.get("hint").is_some(),
1653 "short content should hint fetch_rendered: {v}"
1654 );
1655 #[cfg(not(feature = "browser"))]
1656 assert!(v.get("hint").is_none());
1657 }
1658
1659 #[test]
1660 fn schema_defined() {
1661 let s = SearchWebTool::new(SearchConfig::default(), 5).schema();
1662 assert_eq!(s.name, "search_web");
1663 }
1664
1665 #[test]
1666 fn circuit_breaker_opens_after_failure() {
1667 let t = SearchWebTool::new(SearchConfig::default(), 5);
1669 assert!(!t.circuit_open("test_engine_cb"));
1670 t.record_outcome("test_engine_cb", false);
1671 assert!(t.circuit_open("test_engine_cb"));
1672 t.record_outcome("test_engine_cb", true);
1674 assert!(!t.circuit_open("test_engine_cb"));
1675 }
1676
1677 #[test]
1678 fn circuit_breaker_shared_across_instances() {
1679 let a = SearchWebTool::new(SearchConfig::default(), 5);
1681 let b = SearchWebTool::new(SearchConfig::default(), 5);
1682 a.record_outcome("test_engine_shared", false);
1683 assert!(
1684 b.circuit_open("test_engine_shared"),
1685 "breaker must be shared"
1686 );
1687 b.record_outcome("test_engine_shared", true);
1688 assert!(!a.circuit_open("test_engine_shared"));
1689 }
1690
1691 #[tokio::test]
1692 async fn unconfigured_engines_are_skipped_fast() {
1693 let t = SearchWebTool::new(SearchConfig::default(), 5);
1696 let start = std::time::Instant::now();
1697 let a = t.try_engine("brave", "q", 3, 5.0).await;
1698 assert!(matches!(a, Attempt::Skipped("not-configured")));
1699 let a = t.try_engine("google_cse", "q", 3, 5.0).await;
1700 assert!(matches!(a, Attempt::Skipped("not-configured")));
1701 let a = t.try_engine("serpapi", "q", 3, 5.0).await;
1702 assert!(matches!(a, Attempt::Skipped("not-configured")));
1703 let a = t.try_engine("searxng", "q", 3, 5.0).await;
1704 assert!(matches!(a, Attempt::Skipped("not-configured")));
1705 assert!(
1706 start.elapsed().as_millis() < 200,
1707 "skips must not hit the network"
1708 );
1709 }
1710
1711 static SEARXNG_TEST_LOCK: Mutex<()> = Mutex::new(());
1713
1714 #[tokio::test]
1715 async fn refused_searxng_fails_fast_and_opens_circuit() {
1716 let _l = SEARXNG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1717 let cfg = SearchConfig {
1720 searxng_url: Some("http://127.0.0.1:59999".into()),
1721 ..Default::default()
1722 };
1723 let t = SearchWebTool::new(cfg, 5);
1724 t.record_outcome("searxng", true); let a = t.try_engine("searxng", "q", 3, 5.0).await;
1726 assert!(matches!(a, Attempt::TransportError(_)));
1727 t.record_outcome("searxng", false);
1728 let a = t.try_engine("searxng", "q", 3, 5.0).await;
1729 assert!(matches!(a, Attempt::Skipped("circuit-open")));
1730 t.record_outcome("searxng", true); }
1732
1733 #[tokio::test]
1734 async fn search_reports_engines_tried() {
1735 let _l = SEARXNG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1736 let cfg = SearchConfig {
1737 searxng_url: Some("http://127.0.0.1:59999".into()),
1738 ..Default::default()
1739 };
1740 let t = SearchWebTool::new(cfg, 1);
1741 let cancel = AtomicBool::new(false);
1742 let out = t
1743 .call(&json!({"query": "rust", "timeout": 1}), &cancel)
1744 .await
1745 .unwrap();
1746 let v: Value = serde_json::from_str(&out).unwrap();
1747 let tried = v["engines_tried"].as_array().unwrap();
1748 assert!(!tried.is_empty());
1749 let first = tried[0].as_str().unwrap();
1750 assert!(
1751 first.starts_with("searxng:"),
1752 "first tried should be searxng, got {first}"
1753 );
1754 t.record_outcome("searxng", true); }
1756
1757 #[tokio::test]
1758 async fn cancelled_search_returns_immediately() {
1759 let cfg = SearchConfig {
1760 searxng_url: Some("http://127.0.0.1:59999".into()),
1761 ..Default::default()
1762 };
1763 let t = SearchWebTool::new(cfg, 8);
1764 let cancel = AtomicBool::new(true);
1765 let start = std::time::Instant::now();
1766 let out = t.call(&json!({"query": "rust"}), &cancel).await.unwrap();
1767 let v: Value = serde_json::from_str(&out).unwrap();
1768 assert_eq!(v["success"], false);
1769 assert!(start.elapsed().as_secs() < 2);
1770 }
1771
1772 #[test]
1773 fn brief_err_truncates() {
1774 let long = "x".repeat(300);
1775 assert!(brief_err(&long).chars().count() <= 81);
1776 assert_eq!(brief_err("short"), "short");
1777 }
1778
1779 #[test]
1780 fn ddg_redirect_url_unwrapped() {
1781 let u = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fblog.rust%2Dlang.org%2F2024%2F07%2F25%2FRust%2D1.80.0.html&rut=abc";
1782 assert_eq!(
1783 normalize_result_url(u),
1784 "https://blog.rust-lang.org/2024/07/25/Rust-1.80.0.html"
1785 );
1786 assert_eq!(normalize_result_url("https://a.com/x"), "https://a.com/x");
1788 assert_eq!(normalize_result_url("//a.com/x"), "https://a.com/x");
1790 }
1791
1792 #[test]
1793 fn percent_decode_works() {
1794 assert_eq!(percent_decode("a%20b"), "a b");
1795 assert_eq!(percent_decode("100%"), "100%");
1796 assert_eq!(percent_decode("%2Fpath"), "/path");
1797 }
1798
1799 #[test]
1800 fn quality_filter_drops_junk() {
1801 assert!(!is_quality_result(
1803 "https://a.com",
1804 "rust-lang.orghttps://rust-lang.org › zh-CN"
1805 ));
1806 assert!(!is_quality_result("https://a.com", "rust-lang.org"));
1808 assert!(!is_quality_result(
1810 "https://www.bing.com/aclick?x=1",
1811 "Real Title"
1812 ));
1813 assert!(is_quality_result(
1815 "https://blog.rust-lang.org/2024/07/25/Rust-1.80.0.html",
1816 "Announcing Rust 1.80.0"
1817 ));
1818 }
1819
1820 #[test]
1821 fn html_unescape_entities() {
1822 assert_eq!(
1823 html_unescape("a & b's <tag>"),
1824 "a & b's <tag>"
1825 );
1826 }
1827
1828 #[test]
1829 fn decode_body_honors_charset() {
1830 let gbk = [0xD6u8, 0xD0, 0xCE, 0xC4];
1832 assert_eq!(decode_body(&gbk, "text/html; charset=gbk"), "中文");
1833 assert_eq!(
1835 decode_body("中文".as_bytes(), "text/html; charset=utf-8"),
1836 "中文"
1837 );
1838 let mut html = b"<html><head><meta charset=\"gb18030\"></head><body>".to_vec();
1840 html.extend_from_slice(&gbk);
1841 assert!(decode_body(&html, "text/html").contains("中文"));
1842 assert_eq!(decode_body("中文".as_bytes(), "text/html"), "中文");
1844 assert_eq!(
1846 charset_from_content_type("text/html; Charset=\"GB2312\""),
1847 Some("gb2312".to_string())
1848 );
1849 assert_eq!(charset_from_content_type("text/html"), None);
1850 }
1851
1852 #[test]
1853 fn charset_from_content_type_edge_cases() {
1854 assert_eq!(
1856 charset_from_content_type("text/html; charset=gbk; x=1"),
1857 Some("gbk".to_string())
1858 );
1859 assert_eq!(
1861 charset_from_content_type("CHARSET=UTF-8"),
1862 Some("utf-8".to_string())
1863 );
1864 assert_eq!(charset_from_content_type("text/html; charset="), None);
1866 assert_eq!(charset_from_content_type("application/json"), None);
1868 }
1869
1870 #[test]
1871 fn sniff_meta_charset_variants() {
1872 assert_eq!(
1873 sniff_meta_charset(
1874 b"<meta http-equiv=\"Content-Type\" content=\"text/html; charset=gb2312\">"
1875 ),
1876 Some("gb2312".to_string())
1877 );
1878 assert_eq!(sniff_meta_charset(b"<html>no meta</html>"), None);
1879 }
1880
1881 #[test]
1882 fn html_unescape_extended_entities() {
1883 assert_eq!(
1884 html_unescape("a b·c°d—e"),
1885 "a b·c°d—e"
1886 );
1887 assert_eq!(html_unescape("<x>&'"), "<x>&'");
1888 assert_eq!(html_unescape("&unknown; stays"), "&unknown; stays");
1889 }
1890
1891 #[test]
1892 fn detect_block_classifies() {
1893 assert_eq!(detect_block(403, "百度安全验证", 6), Some("http_status"));
1895 assert_eq!(detect_block(404, "not found", 9), Some("http_status"));
1896 assert_eq!(
1898 detect_block(200, "<html><body>hello</body></html>", 500),
1899 None
1900 );
1901 assert_eq!(
1903 detect_block(200, "<html><body>安全验证</body></html>", 4),
1904 Some("anti_bot")
1905 );
1906 assert_eq!(
1907 detect_block(200, "<html><script>a</script><script>b</script></html>", 0),
1908 Some("js_challenge")
1909 );
1910 }
1911
1912 #[test]
1913 fn parse_bing_rss_items() {
1914 let xml = r#"<rss><channel>
1915 <item><title><![CDATA[西安市10月份气温查询]]></title><link>https://t.com/x</link><description>历史每年西安10月的气温</description></item>
1916 <item><title>西安2025年10月份历史天气</title><link>https://ip.cn/a</link><description>天气查询 & 数据</description></item>
1917 </channel></rss>"#;
1918 let v = parse_bing_rss(xml, 5);
1919 assert_eq!(v.len(), 2);
1920 assert_eq!(v[0]["title"], "西安市10月份气温查询");
1921 assert_eq!(v[0]["url"], "https://t.com/x");
1922 assert_eq!(v[0]["engine"], "bing_rss");
1923 assert!(v[1]["content"]
1924 .as_str()
1925 .unwrap()
1926 .contains("天气查询 & 数据"));
1927 }
1928
1929 #[test]
1930 fn quality_filter_rejects_search_landing() {
1931 assert!(!is_quality_result(
1933 "https://mc.baidu.com/s?word=%E7%99%BE%E5%BA%A6",
1934 "百度官方网站入口 - 百度"
1935 ));
1936 assert!(!is_quality_result(
1937 "https://www.bing.com/search?q=x",
1938 "x - Bing"
1939 ));
1940 assert!(is_quality_result(
1942 "https://example.com/?q=weather",
1943 "Weather"
1944 ));
1945 assert!(!is_quality_result("https://baidu.com/s?wd=x", "百度一下"));
1946 assert!(!is_quality_result(
1948 "https://shop.example.com/s?q=shoes",
1949 "shoes"
1950 ));
1951 assert!(is_quality_result(
1953 "https://baike.baidu.com/item/%E8%9C%98%E8%9B%9B",
1954 "蜘蛛_百度百科"
1955 ));
1956 assert!(is_quality_result(
1957 "https://www.tianqi24.com/xian/history10.html",
1958 "西安市10月份气温查询"
1959 ));
1960 assert!(is_quality_result(
1962 "https://dict.youdao.com/result?word=apple",
1963 "apple - 有道词典"
1964 ));
1965 assert!(is_quality_result(
1967 "https://example.com/article?id=1&q=foo",
1968 "An article"
1969 ));
1970 }
1971
1972 #[test]
1973 fn normalize_url_unescapes_amp() {
1974 assert_eq!(
1975 normalize_result_url("https://a.com/x?a=1&b=2"),
1976 "https://a.com/x?a=1&b=2"
1977 );
1978 }
1979}