Skip to main content

aacode_rs/tools/
web.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Web tools — search_web, fetch_url, search_code.
5//!
6//! Ported from Python `tools/web_tools.py`. Multi-backend search with
7//! automatic fallback: SearXNG → Brave → Google CSE → Bing → SerpAPI →
8//! HTML scrape (Bing + Sogou, raced in parallel).
9//!
10//! Robustness features (mobile-first):
11//!   * Separate connect timeout (2s) so a dead/unreachable SearXNG host is
12//!     detected quickly instead of eating the whole request timeout.
13//!   * Per-engine circuit breaker: after a transport failure the engine is
14//!     skipped for a cool-down window instead of being retried on every call.
15//!   * A global time budget (deadline) shared by all engines, so the total
16//!     latency is bounded no matter how many backends are configured.
17//!   * The HTML fallback scrapers run in parallel; first non-empty wins.
18
19use 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
29/// Max time (secs) to establish a TCP+TLS connection before declaring the
30/// engine unreachable. Keeps dead LAN SearXNG hosts from stalling searches.
31const CONNECT_TIMEOUT_SECS: u64 = 2;
32/// Cap for any single engine attempt, regardless of the overall budget.
33const PER_ENGINE_CAP_SECS: f64 = 4.0;
34/// How long a failing engine's circuit stays open (attempts skipped).
35const CIRCUIT_OPEN_SECS: f64 = 120.0;
36/// Consecutive transport failures before the circuit opens.
37const FAILURES_TO_OPEN: u32 = 1;
38/// Minimum window always granted to the final HTML-scrape fallback.
39const SCRAPE_MIN_SECS: f64 = 3.0;
40
41/// Realistic desktop Chrome UA for plain HTTP fetches (fetch_url / scrape).
42/// The webview (browser tools) uses the platform browser UA instead.
43const 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
45/// Process-wide HTTP client. Carries a persistent cookie jar and realistic
46/// browser default headers so plain fetches look like a normal browser request
47/// (and keep cookies across calls). `Accept-Encoding` is left to reqwest.
48static 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// ──────────────────────────── search_web ────────────────────────────────
73
74/// Mutable health/rate state for one engine (guarded by the global Mutex).
75#[derive(Default, Clone)]
76struct EngineHealth {
77    /// Consecutive transport failures.
78    failures: u32,
79    /// Engine is skipped until this epoch-seconds timestamp.
80    open_until: f64,
81    /// Last request timestamp (rate limiting).
82    last_call: f64,
83}
84
85/// Process-wide engine health map, shared by ALL SearchWebTool instances.
86/// With concurrent agent tasks (multi-session), one task discovering that
87/// SearXNG is down immediately benefits every other task.
88static 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
100/// Outcome of a single engine attempt (for diagnostics + breaker updates).
101enum 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    /// Pick the best available engine from config.
114    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    /// True if the engine's circuit is currently open (recently failed).
132    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    /// Record the outcome of an engine attempt for the circuit breaker.
141    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    /// Rate limit: sleep (bounded by the remaining budget) until the engine's
156    /// minimum interval has elapsed since its previous call.
157    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    /// Try all configured backends in priority order, with fallback.
188    /// Returns (success, engine, results, engines_tried diagnostics).
189    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"; // default if nothing configured
202        }
203
204        // Engine priority: detected engine first, then the others.
205        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); // reachable, just no hits
231                }
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        // HTML fallback scrape (Bing + Sogou raced in parallel). Always grant
243        // it a minimum window even if the engines used up the budget.
244        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        // Unconfigured engines are skipped outright (no key / no URL).
266        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
342/// Shorten a transport error message for the diagnostics list.
343fn 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
397// ──────────────────── Engine-specific search helpers ────────────────────
398
399async 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    // Bing v7 API requires Ocp-Apim-Subscription-Key.
493    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
553/// Extract {title, url, content} from a JSON search response's results array.
554fn 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
581// ──────────────────────── HTML fallback scrape ──────────────────────────
582
583/// Bing RSS search (format=rss). Much cleaner and more query-accurate than
584/// scraping the HTML result page, and stable across the `www` / `cn` hosts.
585/// Races both endpoints and returns the first non-empty set.
586async 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
629/// Parse a Bing RSS document into result objects (pure — unit-testable).
630fn 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: &regex::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    // 1) Prefer Bing RSS: clean XML, query-accurate, and endpoint-agnostic.
684    //    Global (`www`) and China (`cn`) endpoints are raced — both are valid
685    //    for a globally distributed app (Google Play / global App Store).
686    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    // 2) Parallel HTML scrape race; first NON-EMPTY (after quality filtering) wins.
690    // (name, url, result_regex, clean_regex, user_agent)
691    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            // Anchor on the <h2> title link — the first <a> inside b_algo is
703            // often the cite/breadcrumb link, not the result title.
704            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; // all run in parallel with (nearly) full budget
734        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
816/// DuckDuckGo html results use redirect links like
817/// `//duckduckgo.com/l/?uddg=<percent-encoded-url>&rut=...` — unwrap them.
818fn normalize_result_url(url: &str) -> String {
819    // Feeds HTML-escape `&` as `&amp;` inside hrefs/links.
820    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                // Entity names/refs are short; ignore absurd runs.
858                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
874/// Decode a single HTML entity body (the text between `&` and `;`): numeric
875/// (`#123`, `#x1F`) or a small set of common named entities.
876fn 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
933/// Strip an optional `<![CDATA[ ... ]]>` wrapper (Bing RSS descriptions).
934fn 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
942/// Drop junk entries: breadcrumb/cite pseudo-titles ("site.com › path"),
943/// empty titles, ad/redirect links.
944fn 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    // A "title" that is just a URL/domain (no spaces, looks like a host).
952    if !title.contains(' ')
953        && (title.contains("http") || title.contains(".com") || title.contains(".org"))
954    {
955        return false;
956    }
957    // Search engines / ad redirects / SERP landing pages are not content.
958    if looks_like_search_url(url) {
959        return false;
960    }
961    // Titles that are obviously a search box / SERP.
962    let t = title.trim();
963    if t.contains("百度一下") || t.contains("搜索结果") {
964        return false;
965    }
966    true
967}
968
969/// Whether a URL is a search-engine / ad-redirect / SERP page rather than a
970/// content page. Kept deliberately high-precision to avoid dropping legitimate
971/// results: only known search hosts, or a search *path* combined with a search
972/// query param (a bare `?word=` on e.g. a dictionary entry is NOT filtered).
973fn 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    // Search *path* + a search query param, e.g. `/s?q=`, `/search?query=`.
994    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
1010// ─────────────────────────── detect engine type ─────────────────────────
1011
1012fn 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
1035// ─────────────────────────── fetch_url ──────────────────────────────────
1036
1037/// Decode an HTTP body honoring its charset. reqwest is built with
1038/// `default-features = false` (no `charset` feature), so `resp.text()` assumes
1039/// UTF-8 and mangles GBK/GB2312 pages. Prefer the Content-Type charset, then a
1040/// `<meta charset>` / `http-equiv` sniff in the first 4 KB, else UTF-8 lossy.
1041fn 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    // ASCII lowercasing preserves byte offsets, so `i` is valid in `ct`.
1059    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        // Use the shared client's browser UA / Accept-Language. Do NOT override
1141        // the UA with a bot string: many sites (Baidu, 知乎, …) answer a bot UA
1142        // with 403 / an anti-bot page, so a plain fetch looked "blocked".
1143        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
1210/// Heuristically decide whether a fetch was blocked by anti-bot / a JS
1211/// challenge. Primary signal is the HTTP status; for 2xx responses we look for
1212/// a script-heavy shell with almost no readable text plus generic challenge
1213/// wording (English + Chinese). Deliberately small/generic rather than matching
1214/// any single site.
1215pub(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    // A near-empty document that is mostly <script> → likely a JS challenge.
1246    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    // Unique filename so concurrent tasks (multi-session) don't clobber each
1256    // other's extracts.
1257    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    // Keep only the most recent 20 extracts to bound disk usage.
1264    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
1279// ─────────────────────────── HTML cleaning ──────────────────────────────
1280
1281/// Strip HTML tags + script/style and collapse whitespace.
1282pub 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
1330/// Structure-aware HTML → text: drops `<script>/<style>/<noscript>` and comments,
1331/// turns block tags into line breaks, strips the remaining tags, decodes common
1332/// entities, then collapses whitespace and duplicate lines.
1333///
1334/// More readable (and usually smaller) than [`clean_html`], which flattens the
1335/// whole document to a single line. Used to compress browser page dumps.
1336pub 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
1372/// Remove `<script>/<style>/<noscript>` blocks and `<!-- -->` comments.
1373fn 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
1407/// Whether a (lowercased, `/`-or-attribute-suffixed) tag name is block-level.
1408fn 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("&nbsp;", " ")
1449        .replace("&amp;", "&")
1450        .replace("&lt;", "<")
1451        .replace("&gt;", ">")
1452        .replace("&quot;", "\"")
1453        .replace("&#39;", "'")
1454        .replace("&apos;", "'")
1455        .replace("&mdash;", "—")
1456        .replace("&ndash;", "–")
1457        .replace("&hellip;", "…")
1458}
1459
1460// ─────────────────────────── search_code ────────────────────────────────
1461
1462pub 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        // Prefer SearXNG with IT categories (fast connect timeout so a dead
1490        // host degrades to the GitHub fallback quickly).
1491        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        // Fallback: GitHub repository search (no key needed).
1517        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&nbsp;<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&nbsp;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        // Falls back to scrape or reports no results.
1608        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        // Use a unique key: the breaker map is process-wide.
1668        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        // Success resets the breaker.
1673        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        // Process-wide sharing: instance B sees the circuit opened by A.
1680        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        // With nothing configured, every API engine must be skipped without
1694        // network I/O; only the scrape fallback may take time.
1695        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    /// Serializes tests that touch the process-wide "searxng" breaker entry.
1712    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        // Connection refused on localhost is immediate; the second call must
1718        // then be skipped by the circuit breaker.
1719        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); // reset shared breaker state
1725        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); // clean up for other tests
1731    }
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); // clean up shared breaker state
1755    }
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        // Plain URLs pass through.
1787        assert_eq!(normalize_result_url("https://a.com/x"), "https://a.com/x");
1788        // Protocol-relative URLs get https.
1789        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        // Breadcrumb pseudo-titles from cite links.
1802        assert!(!is_quality_result(
1803            "https://a.com",
1804            "rust-lang.orghttps://rust-lang.org › zh-CN"
1805        ));
1806        // Domain-only titles.
1807        assert!(!is_quality_result("https://a.com", "rust-lang.org"));
1808        // Ads/redirects.
1809        assert!(!is_quality_result(
1810            "https://www.bing.com/aclick?x=1",
1811            "Real Title"
1812        ));
1813        // Good results pass.
1814        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 &amp; b&#39;s &lt;tag&gt;"),
1824            "a & b's <tag>"
1825        );
1826    }
1827
1828    #[test]
1829    fn decode_body_honors_charset() {
1830        // "中文" encoded as GBK.
1831        let gbk = [0xD6u8, 0xD0, 0xCE, 0xC4];
1832        assert_eq!(decode_body(&gbk, "text/html; charset=gbk"), "中文");
1833        // Header charset wins.
1834        assert_eq!(
1835            decode_body("中文".as_bytes(), "text/html; charset=utf-8"),
1836            "中文"
1837        );
1838        // No header charset → sniff <meta charset>.
1839        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        // Plain UTF-8 fallback.
1843        assert_eq!(decode_body("中文".as_bytes(), "text/html"), "中文");
1844        // Charset parsing helpers.
1845        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        // Extra parameters after charset are ignored.
1855        assert_eq!(
1856            charset_from_content_type("text/html; charset=gbk; x=1"),
1857            Some("gbk".to_string())
1858        );
1859        // No space, uppercase name/value.
1860        assert_eq!(
1861            charset_from_content_type("CHARSET=UTF-8"),
1862            Some("utf-8".to_string())
1863        );
1864        // Empty value yields None.
1865        assert_eq!(charset_from_content_type("text/html; charset="), None);
1866        // No charset at all.
1867        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&ensp;b&#0183;c&#176;d&mdash;e"),
1885            "a b·c°d—e"
1886        );
1887        assert_eq!(html_unescape("&lt;x&gt;&amp;&#x27;"), "<x>&'");
1888        assert_eq!(html_unescape("&unknown; stays"), "&unknown; stays");
1889    }
1890
1891    #[test]
1892    fn detect_block_classifies() {
1893        // Non-2xx is always blocked.
1894        assert_eq!(detect_block(403, "百度安全验证", 6), Some("http_status"));
1895        assert_eq!(detect_block(404, "not found", 9), Some("http_status"));
1896        // Healthy page.
1897        assert_eq!(
1898            detect_block(200, "<html><body>hello</body></html>", 500),
1899            None
1900        );
1901        // 2xx challenge page.
1902        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>天气查询 &amp; 数据</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        // Search / SERP / ad landing pages are not content.
1932        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        // A bare `?q=` on a non-search path is NOT treated as a SERP (kept).
1941        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        // Search path + param (generic SERP) is dropped.
1947        assert!(!is_quality_result(
1948            "https://shop.example.com/s?q=shoes",
1949            "shoes"
1950        ));
1951        // Real content pages survive (no false positives).
1952        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        // A bare `?word=` on a dictionary entry (content, not a SERP) survives.
1961        assert!(is_quality_result(
1962            "https://dict.youdao.com/result?word=apple",
1963            "apple - 有道词典"
1964        ));
1965        // A content page that merely has a `&q=` tracking param survives.
1966        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&amp;b=2"),
1976            "https://a.com/x?a=1&b=2"
1977        );
1978    }
1979}