//! fetch_search AI 工具 — 搜索引擎查询(DuckDuckGo HTML 免 key,GET → 解析结果列表) //! //! 设计目标:让 LLM 拿到「搜索」能力。用户给"调研 X"时,AI 需先搜索找文档入口, //! 再 fetch_url 抓。当前无搜索工具,LLM 只能瞎猜 URL。本工具 GET DuckDuckGo HTML 端点 //! (免 API key),解析结果列表(标题+URL+摘要),让 AI 据此挑入口用 fetch_url 深读。 //! //! ## 与 fetch_url / http_request 的分工 //! //! - `http_request`:结构化 API 调用(POST/鉴权/JSON),拿原始响应 body。 //! - `fetch_url`:**已知 URL** → markdown 文档嗅探,LLM 拿到正文。 //! - `fetch_search`:**未知 URL** → 搜索引擎查询,LLM 拿到候选 URL 列表 + 摘要, //! 再据结果挑入口用 fetch_url 深读。补「搜索」缺口。 //! //! ## HTML 解析方式:手写字符串扫描(非 scraper/html5ever) //! //! DDG HTML 端点结构稳定且单一来源,引入 scraper(html5ever + selectors + cssparser, //! 重依赖 ~5MB 编译产物)不值。对齐 fetch_url::extract_title 的简单字符串扫描风格: //! 在 HTML 中找 `class="result__a"` 锚点 → 取 href + 文本;找 `result__snippet` → 取摘要。 //! 解析纯函数化(extract_results),零网络依赖,单测用 fixture HTML 字符串验证。 //! //! ## DDG 重定向 URL 解包 //! //! DDG 把结果 URL 包成 `https://duckduckgo.com/l/?uddg=&rut=...` //! 重定向跳板(统计/反爬)。本工具解析 `uddg=` 查询参数,percent-decode 还原真实 URL, //! LLM 拿到的就是可直接 fetch_url 的目标地址。解析失败(无 uddg 参数 / 非 DDG 跳板 URL) //! 保留原 href 兜底(可能是 //duckduckgo.com/... 内部页,LLM 至少能看到)。 //! //! ## 安全(SSRF 防护 — 与 http_request / fetch_url 共享同一套) //! //! 复用 `http.rs` 的 `validate_url` / `resolve_and_check_host` / `build_client` / //! `execute_with_redirects`:① 协议白名单(仅 http/https)② 私网 IP 黑名单 ③ DNS resolve //! 后校验 IP(防 rebinding)④ 重定向 ≤3 跳每跳重校验。fetch_search 只发 GET 到固定 DDG 端点。 //! //! ## 反爬评估 //! //! DDG HTML 端点(html.duckduckgo.com/html/)对带浏览器 UA 的请求通常友好(无 key/无 token)。 //! 本工具默认带主流浏览器 UA(与 fetch_url 一致)。若反爬升级(403/空结果),不硬上 —— //! handler 把 HTTP 状态/空结果如实返回,LLM 据此判断(不改用代理/绕过,避免对抗反爬)。 //! 间歇限流:单次工具调用是单 GET,无高频请求,不内置 rate limit(并发由 LLM loop 控制, //! 搜索是低频操作,不至触发频控)。 use std::collections::HashMap; use std::time::{Duration, Instant}; use serde_json::{json, Value}; /// DuckDuckGo HTML 搜索端点(免 API key,GET 返回结果列表 HTML)。 const DDG_HTML_ENDPOINT: &str = "https://html.duckduckgo.com/html/"; /// 默认返回结果数。LLM 单次搜索 5 条足以挑入口(太多撑爆 context)。 const DEFAULT_MAX_RESULTS: usize = 5; /// 返回结果数下限。防 LLM 传极小值拿到无意义单条。 const MIN_MAX_RESULTS: usize = 1; /// 返回结果数上限。防 LLM 传极大值仍撑爆 context(10 条覆盖多数场景)。 const MAX_MAX_RESULTS: usize = 10; /// 默认请求超时(秒)。与 http_request / fetch_url 一致。 const DEFAULT_TIMEOUT_SECS: u64 = 30; /// 超时硬上限(秒)。与 http_request / fetch_url 一致。 const MAX_TIMEOUT_SECS: u64 = 60; /// 响应 body 字节上限。DDG HTML 结果页通常 <500KB,2MB 足够且防 OOM(对齐 fetch_url)。 const MAX_HTML_BYTES: usize = 2 * 1024 * 1024; /// fetch_search 工具 handler 入口(供 tools/fetch_search.rs register 调用)。 /// /// 参数: /// - query: 必填,搜索关键词 /// - max_results: 可选,返回结果数(默认 5,clamp [1, 10]) /// /// 返回 {query, results:[{title, url, snippet}], count} pub(crate) async fn execute_fetch_search(args: Value) -> anyhow::Result { // ── 参数解析 ── let query = args.get("query").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("缺少 query 参数"))? .trim() .to_string(); if query.is_empty() { anyhow::bail!("query 不能为空"); } let max_results = (args.get("max_results").and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_MAX_RESULTS as u64) as usize) .clamp(MIN_MAX_RESULTS, MAX_MAX_RESULTS); let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_TIMEOUT_SECS) .min(MAX_TIMEOUT_SECS) .max(1); // ── 构造请求 URL:percent-encode query 拼 DDG 端点 ?q= ── // DDG HTML 端点接受 GET ?q=。encode 严格 RFC 3986(query 段)。 let encoded_query = percent_encode_query(&query); let search_url = format!("{}?q={}", DDG_HTML_ENDPOINT, encoded_query); // ── SSRF 校验(对固定 DDG 端点也走标准防护:协议/host/DNS resolve IP) ── let (_scheme, host, port) = crate::commands::ai::http::validate_url(&search_url)?; crate::commands::ai::http::resolve_and_check_host(&host, port).await?; // ── 构建限制 client + 执行 GET(复用 http.rs 重定向循环,带浏览器 UA) ── // DDG HTML 端点对非浏览器 UA 可能返回简化页/拒,带主流 UA 拿完整结果列表(对齐 fetch_url)。 let mut headers: HashMap = HashMap::new(); headers.insert( "User-Agent".to_string(), "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36".to_string(), ); // Accept-Language:让 DDG 返回匹配用户语言偏好的结果(中英文兼顾)。 headers.insert( "Accept-Language".to_string(), "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7".to_string(), ); let client = crate::commands::ai::http::build_client(Duration::from_secs(timeout_secs))?; let started = Instant::now(); let resp = crate::commands::ai::http::execute_with_redirects( &client, reqwest::Method::GET, search_url.clone(), &headers, &None, crate::commands::ai::http::MAX_REDIRECTS, ) .await?; let elapsed_ms = started.elapsed().as_millis() as u64; let final_url = resp.url().to_string(); let status = resp.status().as_u16(); if !resp.status().is_success() { anyhow::bail!( "搜索失败:HTTP {} {}({})。DDG 可能限流/反爬,稍后重试或换关键词", status, resp.status().canonical_reason().unwrap_or(""), final_url ); } // ── 读取 body + 大小挡板 ── let bytes = resp.bytes().await .map_err(|e| anyhow::anyhow!("读取响应 body 失败: {}", e))?; let total_bytes = bytes.len(); if total_bytes > MAX_HTML_BYTES { anyhow::bail!( "响应过大:{} 字节超过 {} 上限,fetch_search 拿到异常大响应", total_bytes, MAX_HTML_BYTES ); } let html = String::from_utf8_lossy(&bytes).into_owned(); // ── 解析结果列表(纯函数,单测用 fixture HTML 验证) ── let mut results = extract_results(&html); // 截断到 max_results(解析可能拿到更多,DDG 单页通常 ~30 条) results.truncate(max_results); let count = results.len(); tracing::info!( "fetch_search: query={:?} 拿到 {} 条结果(HTML {} 字节,{}ms)", query, count, total_bytes, elapsed_ms ); Ok(json!({ "query": query, "results": results, "count": count, "status": status, "elapsed_ms": elapsed_ms, })) } /// 从 DDG HTML 结果页解析结果列表(标题+URL+摘要)。 /// /// DDG HTML 每条结果结构(简化): /// ```text ///
///

/// Title ///

/// Snippet text... ///
/// ``` /// /// 解析策略:扫描所有 `class="result__a"` 锚点 → 取 href + 内联文本;同序找 /// `class="result__snippet"` → 取摘要。两者各扫一遍再按出现顺序对齐(DDG 标题与摘要 /// 在每条结果内同序出现,故按位置一一对应;若数量不等则按较少者对齐)。 /// /// URL 解包:DDG 把真实 URL 包在 `uddg=` 查询参数(跳板重定向), /// 此处还原(percent-decode)让 LLM 拿到可直 fetch_url 的目标 URL。 fn extract_results(html: &str) -> Vec { let titles_with_url: Vec<(String, String)> = extract_result_anchors(html); let snippets: Vec = extract_snippets(html); let n = titles_with_url.len().max(snippets.len()); let mut results: Vec = Vec::with_capacity(n); for i in 0..n { let title_url = titles_with_url.get(i); let snippet = snippets.get(i); match (title_url, snippet) { (Some((title, href)), snip) => { let url = unwrap_ddg_redirect(href); results.push(json!({ "title": title, "url": url, "snippet": snip.cloned().unwrap_or_default(), })); } (None, Some(snip)) => { // 残留 snippet 无对应标题(罕见,DDG 结构异常时兜底):跳过,无标题无 URL 无意义。 // 不构造半残条目,保持每条都有 title+url(让 LLM 能 fetch_url)。 } (None, None) => break, } } results } /// 提取所有 `class="result__a"` 锚点的 (内联文本, href),保持文档顺序。 /// /// 用字符串扫描(对齐 fetch_url::extract_title 风格),不引 scraper 重依赖。 /// class 属性顺序不固定(DDG 偶有 `` 或 /// ``),扫 `class="result__a"` 子串命中锚点起点,再从该点向后 /// 找最近的 `` 起标签尾(`>`),取 href 值 + 标签内文本(到 ``)。 fn extract_result_anchors(html: &str) -> Vec<(String, String)> { // MARKER 用 `result__a`(裸 class 名),命中后检查后续字符做边界判定: // 必须后跟非标识符字符(" 或空格,即 class 值结束或多个 class 间的分隔),避免 `result__a` // 子串误命中 `result__article` / `result__a_extra` 等同类 class(DDG 有 result__* 系列)。 const MARKER: &str = "result__a"; let marker_end_byte = MARKER.len(); let mut out: Vec<(String, String)> = Vec::new(); let mut search_from = 0usize; while search_from < html.len() { // 找下一个 "result__a" 出现位置(锚点 class 标记) let Some(rel_class) = html[search_from..].find(MARKER) else { break }; let class_pos = search_from + rel_class; let after_marker = class_pos + marker_end_byte; // 边界检查:MARKER 后的字符必须是非标识符字符(" 或空格 或 >,即 class 值结束), // 否则可能是 `result__article` 这类以 `result__a` 为前缀的 class,跳过。 let next_char = html[after_marker..].chars().next(); let is_boundary = match next_char { Some(c) => !(c.is_ascii_alphanumeric() || c == '_' || c == '-'), None => true, // 字符串尾(罕见,视为边界) }; if !is_boundary { // 非边界(如 result__article):跳过本命中继续找 search_from = after_marker; continue; } // 从 class 位置向前找最近的 ` 标签内) // 倒扫到 class_pos 之前的最后一个 `` let a_open_end_rel = html[a_open_rel..].find('>'); let Some(a_open_end_offset) = a_open_end_rel else { search_from = after_marker; continue; }; let a_open_end = a_open_rel + a_open_end_offset; // `>` 位置 let a_open_tag = &html[a_open_rel..=a_open_end]; // 含 `` // 提取 href 值(href="..." 或 href='...') let href = extract_attr(a_open_tag, "href").unwrap_or_default(); // 取标签内文本(从 `>` 后到 ``) let text_start = a_open_end + 1; let text_end = html[text_start..] .find("") .map(|p| text_start + p) .unwrap_or(html.len()); let raw_text = &html[text_start..text_end]; let title = clean_html_text(raw_text); if !title.is_empty() { out.push((title, href.to_string())); } // 推进搜索位置到本锚点结束之后,避免重复命中同一锚点的 class search_from = text_end + "".len(); } out } /// 提取所有 `class="result__snippet"` 元素的内联文本,保持文档顺序。 /// /// DDG snippet 用 `text`(同时也是链接)。 /// 扫 `result__snippet` 子串 → 向前找 `` → 取到 `` 间文本。 /// 与 extract_result_anchors 同款逻辑,仅 marker 不同。 fn extract_snippets(html: &str) -> Vec { const MARKER: &str = "result__snippet"; let mut out: Vec = Vec::new(); let mut search_from = 0usize; while search_from < html.len() { let Some(rel) = html[search_from..].find(MARKER) else { break }; let class_pos = search_from + rel; let head = &html[..class_pos]; let Some(a_open_rel) = head.rfind("') { Some(o) => o, None => { search_from = class_pos + MARKER.len(); continue; } }; let a_open_end = a_open_rel + a_open_end_offset; let text_start = a_open_end + 1; let text_end = html[text_start..] .find("") .map(|p| text_start + p) .unwrap_or(html.len()); let raw_text = &html[text_start..text_end]; let snippet = clean_html_text(raw_text); if !snippet.is_empty() { out.push(snippet); } search_from = text_end + "".len(); } out } /// 从 `` 等开标签中提取指定属性值。 /// /// 支持 `attr="..."` / `attr='...'`(单双引号),不支持无引号属性值(DDG href 总是带引号)。 /// 值内的 HTML entity 不解码(简单场景 DDG href 不含 entity;若需可后续加 decode_html_entities)。 /// /// 已知局限:不做属性单词边界检查,故 `data-href="..."` 会被 `attr="href"` 匹配命中 /// (因 "href=" 是 "data-href=" 的子串)。真实 DDG `` 不同时带 data-href 与 href, /// 实务无影响;若未来需精确边界,改用前一个字符非字母数字的边界检查。 fn extract_attr(open_tag: &str, attr: &str) -> Option { let lower = open_tag.to_lowercase(); let dq_pattern = format!("{}=\"", attr); let sq_pattern = format!("{}='", attr); // 优先双引号形式,其次单引号。matched_quote 记录命中模式对应的引号字符, // 避免 backtrack 推断(原 starts_with('"') 路径在 attr 出现在开标签首时 saturating_sub 边界不稳)。 let (value_start_rel, quote_char): (usize, char) = lower .find(&dq_pattern) .map(|p| (p + dq_pattern.len(), '"')) .or_else(|| lower.find(&sq_pattern).map(|p| (p + sq_pattern.len(), '\'')))?; let value_end_rel = open_tag[value_start_rel..].find(quote_char)?; Some(open_tag[value_start_rel..value_start_rel + value_end_rel].to_string()) } /// 清理 HTML 内联文本:剥离嵌套标签(如 `highlight`)+ 折叠空白。 /// /// DDG 标题/snippet 内常有 `` 高亮匹配关键词,需剥离得到纯文本。 /// 不解码 entity(& 等),简单场景够用;后续如见 entity 可扩展。 fn clean_html_text(raw: &str) -> String { // 剥离所有 `<...>` 子串(嵌套标签如 word → word) let mut stripped = String::with_capacity(raw.len()); let mut in_tag = false; for c in raw.chars() { match c { '<' => in_tag = true, '>' => in_tag = false, _ if !in_tag => stripped.push(c), _ => {} } } // 折叠连续空白(含 \n\t)为单空格,trim 首尾 let cleaned: String = stripped.split_whitespace().collect::>().join(" "); cleaned } /// 解包 DDG 重定向跳板 URL 还原真实目标 URL。 /// /// DDG 把结果 URL 包成 `https://duckduckgo.com/l/?uddg=&rut=...`。 /// 提取 `uddg` 查询参数值并 percent-decode → 真实 URL(可直接 fetch_url)。 /// /// 非 DDG 跳板 URL(无 `uddg=` 参数 / 解码失败)→ 原样返回 href 兜底。 /// DDG 偶尔返回 `//duckduckgo.com/...` 协议相对内部页(无 uddg),保留原样让 LLM 至少看到。 fn unwrap_ddg_redirect(href: &str) -> String { // 在 href 中找 `uddg=` 参数(可能在 ? 或 & 后) let Some(uddg_pos) = href.find("uddg=") else { return href.to_string(); }; let value_start = uddg_pos + "uddg=".len(); let rest = &href[value_start..]; // 取到下一个 `&` 或字符串尾 let value_end = rest.find('&').unwrap_or(rest.len()); let encoded = &rest[..value_end]; // percent-decode(query 段,将 + 转空格 DDG 不用,uddg 值内真实 URL 的空格已 %20 编码) use percent_encoding::percent_decode_str; let decoded = percent_decode_str(encoded).decode_utf8_lossy().into_owned(); if decoded.is_empty() { href.to_string() } else { decoded } } /// percent-encode 搜索关键词(query 段 RFC 3986)。 /// /// 不引 urlencoding crate(http.rs 测试用同款手写),覆盖 query 段需转义字符: /// 空格 → %20、&=?# 等保留字符转义。字母/数字/常见安全符号(-._~)保留。 fn percent_encode_query(s: &str) -> String { let mut out = String::with_capacity(s.len()); for &b in s.as_bytes() { // RFC 3986 unreserved + 几个 query-safe 字符保留 match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char), _ => out.push_str(&format!("%{:02X}", b)), } } out } // ============================================================ // 单元测试 // // 覆盖纯函数(extract_results / extract_result_anchors / extract_snippets / // extract_attr / clean_html_text / unwrap_ddg_redirect / percent_encode_query), // 确定性零网络。handler 集成层走真实网络 #[ignore](对齐 http.rs / fetch_url 测试策略)。 // // fixture HTML:基于 DDG HTML 端点真实结构简化(保留 result__a / result__snippet / // uddg 重定向跳板等关键标记),让解析逻辑可零网络验证。 // ============================================================ #[cfg(test)] mod tests { use super::*; /// 真实 DDG HTML 结构简化 fixture(含 2 条结果,标题/snippet/uddg 重定向齐全)。 /// 用于 extract_results 端到端解析验证(零网络)。 const FIXTURE_DDG_HTML: &str = r#" "#; // ── extract_results:端到端解析(标题 + uddg 解包 URL + 摘要) ── #[test] fn test_extract_results_end_to_end_two_results() { let results = extract_results(FIXTURE_DDG_HTML); assert_eq!(results.len(), 2, "应解析 2 条结果"); // 第 1 条 let r0 = &results[0]; assert_eq!(r0["title"].as_str().unwrap(), "The Rust Programming Language book"); // uddg 解包后应是真实 URL,非 DDG 跳板 assert_eq!(r0["url"].as_str().unwrap(), "https://doc.rust-lang.org/book/"); assert!(r0["snippet"].as_str().unwrap().contains("introductory")); } #[test] fn test_extract_results_second_result() { let results = extract_results(FIXTURE_DDG_HTML); let r1 = &results[1]; assert_eq!(r1["title"].as_str().unwrap(), "Tokio Tutorial - Asynchronous Rust"); assert_eq!(r1["url"].as_str().unwrap(), "https://tokio.rs/tutorial/"); assert!(r1["snippet"].as_str().unwrap().contains("Tokio runtime")); } #[test] fn test_extract_results_strips_b_tags_in_title() { // DDG 标题内常有 高亮匹配关键词,应剥离为纯文本 let html = r#"Hello World"#; let anchors = extract_result_anchors(html); assert_eq!(anchors.len(), 1); assert_eq!(anchors[0].0, "Hello World"); } // ── 截断 ── #[test] fn test_execute_search_truncates_results() { // 不发网络:仅验证 extract_results + truncate 的截断逻辑(纯函数路径) // 构造 3 条 fixture,模拟 max_results=2 截断 let html = r#" A snip A B snip B C snip C "#; let mut results = extract_results(html); results.truncate(2); assert_eq!(results.len(), 2, "应截断到 2 条"); assert_eq!(results[0]["title"].as_str().unwrap(), "A"); assert_eq!(results[1]["title"].as_str().unwrap(), "B"); } // ── extract_attr:开标签属性提取 ── #[test] fn test_extract_attr_double_quote() { let tag = r#""#; assert_eq!(extract_attr(tag, "href").as_deref(), Some("https://example.com/")); } #[test] fn test_extract_attr_single_quote() { let tag = r#""#; assert_eq!(extract_attr(tag, "href").as_deref(), Some("https://example.com/")); } #[test] fn test_extract_attr_missing_returns_none() { let tag = r#""#; assert_eq!(extract_attr(tag, "href"), None); } #[test] fn test_extract_attr_known_limitation_data_href() { // 已知局限记录:extract_attr 不做单词边界检查,"data-href=" 含 "href=" 子串会被命中。 // 真实 DDG `` 不同时带 data-href 与 href,实务无影响。本测试锁定当前行为供回归守护: // 若未来加边界检查改为不命中,此测试会红,提醒同步更新文档注释。 let tag = r#""#; assert_eq!( extract_attr(tag, "href").as_deref(), Some("https://hidden.com/"), "已知局限:data-href 含 href= 子串会被命中(无单词边界检查)" ); } // ── clean_html_text:剥离嵌套标签 + 折叠空白 ── #[test] fn test_clean_html_text_strips_tags() { assert_eq!(clean_html_text("Hello World!"), "Hello World!"); } #[test] fn test_clean_html_text_collapses_whitespace() { assert_eq!(clean_html_text(" multiple\n spaces\t here "), "multiple spaces here"); } #[test] fn test_clean_html_text_empty_after_strip() { assert_eq!(clean_html_text(""), ""); assert_eq!(clean_html_text(" "), ""); } #[test] fn test_clean_html_text_no_tags_passthrough() { assert_eq!(clean_html_text("plain text"), "plain text"); } // ── unwrap_ddg_redirect:uddg 参数解包 ── #[test] fn test_unwrap_ddg_redirect_decodes_uddg_param() { let href = "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2F&rut=abc"; assert_eq!( unwrap_ddg_redirect(href), "https://doc.rust-lang.org/" ); } #[test] fn test_unwrap_ddg_redirect_no_uddg_returns_original() { // 非 DDG 跳板(无 uddg 参数)→ 原样返回 let href = "https://example.com/direct"; assert_eq!(unwrap_ddg_redirect(href), "https://example.com/direct"); } #[test] fn test_unwrap_ddg_redirect_uddg_last_param_no_amp() { // uddg 是最后一个参数(无尾随 &)→ 取到字符串尾 let href = "https://duckduckgo.com/l/?uddg=https%3A%2F%2Ftokio.rs"; assert_eq!(unwrap_ddg_redirect(href), "https://tokio.rs"); } #[test] fn test_unwrap_ddg_redirect_chinese_url_decoded() { // 中文 URL 经 percent-encode,decode 后应还原 let href = "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fzh.wikipedia.org%2Fwiki%2FRust&x=1"; assert_eq!( unwrap_ddg_redirect(href), "https://zh.wikipedia.org/wiki/Rust" ); } #[test] fn test_unwrap_ddg_redirect_empty_encoded_returns_original() { // uddg= 空 → 解码空 → 返回原 href 兜底 let href = "https://duckduckgo.com/l/?uddg=&rut=x"; assert_eq!(unwrap_ddg_redirect(href), href); } // ── percent_encode_query:搜索关键词编码 ── #[test] fn test_percent_encode_query_space() { assert_eq!(percent_encode_query("rust async"), "rust%20async"); } #[test] fn test_percent_encode_query_chinese() { // 中文每字节转 %XX let encoded = percent_encode_query("Rust 语言"); // "Rust " 保留 + "语"(3 字节 UTF-8)+ "言"(3 字节) assert!(encoded.starts_with("Rust%20")); assert!(encoded.contains("%E8%AF%AD")); // "语" 的 UTF-8 首三字节之一 } #[test] fn test_percent_encode_query_safe_chars_preserved() { // unreserved 字母数字 -._~ 保留 assert_eq!(percent_encode_query("abc-123_test.~"), "abc-123_test.~"); } #[test] fn test_percent_encode_query_special_chars_escaped() { // 保留字符 & = ? # 转义(防注入 query 解析) let encoded = percent_encode_query("a&b=c?d"); assert_eq!(encoded, "a%26b%3Dc%3Fd"); } // ── extract_result_anchors:多结果顺序 + 跳过空标题 ── #[test] fn test_extract_result_anchors_preserves_order() { let html = r#" First Second Third "#; let anchors = extract_result_anchors(html); assert_eq!(anchors.len(), 3); assert_eq!(anchors[0].0, "First"); assert_eq!(anchors[1].0, "Second"); assert_eq!(anchors[2].0, "Third"); } #[test] fn test_extract_result_anchors_skips_empty_title() { // 空标题(标签内仅空白)跳过,不构造半残条目 let html = r#" Valid "#; let anchors = extract_result_anchors(html); assert_eq!(anchors.len(), 1, "空标题应跳过"); assert_eq!(anchors[0].0, "Valid"); } #[test] fn test_extract_result_anchors_handles_rel_nofollow_first() { // DDG 真实顺序:``(rel 在 class 前) let html = r#"Title"#; let anchors = extract_result_anchors(html); assert_eq!(anchors.len(), 1); assert_eq!(anchors[0].1, "https://x.com/"); } #[test] fn test_extract_result_anchors_no_results_returns_empty() { // 无 result__a 标记 → 空结果(无 panic) let html = "no results here"; let anchors = extract_result_anchors(html); assert!(anchors.is_empty()); } #[test] fn test_extract_result_anchors_boundary_check_rejects_result_article() { // 边界检查:`result__article`(以 result__a 为前缀)不应被误命中为 result__a。 // 防止 DDG 其他 result__* class(如 result__article)污染标题列表。 let html = r#" Should Not Match Right Title "#; let anchors = extract_result_anchors(html); assert_eq!(anchors.len(), 1, "result__article 不应被误命中"); assert_eq!(anchors[0].0, "Right Title"); assert_eq!(anchors[0].1, "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fright.com%2F"); } #[test] fn test_extract_result_anchors_handles_multi_class_value() { // class 列表含多个 class(`class="result__a result__url"`)仍应命中(result__a 后是空格) let html = r#"Multi Class Title"#; let anchors = extract_result_anchors(html); assert_eq!(anchors.len(), 1); assert_eq!(anchors[0].0, "Multi Class Title"); } // ── extract_snippets ── #[test] fn test_extract_snippets_basic() { let html = r#" First snippet Second snippet "#; let snippets = extract_snippets(html); assert_eq!(snippets.len(), 2); assert_eq!(snippets[0], "First snippet"); assert_eq!(snippets[1], "Second snippet"); } #[test] fn test_extract_snippets_strips_b_tags() { let html = r#"Text with highlight word"#; let snippets = extract_snippets(html); assert_eq!(snippets.len(), 1); assert_eq!(snippets[0], "Text with highlight word"); } // ── extract_results:无 snippet 时空字符串 ── #[test] fn test_extract_results_snippet_missing_defaults_empty() { // 标题有但 snippet 缺(解析时 snippet.get(i) 返 None → 空字符串) let html = r#" Title Only "#; let results = extract_results(html); assert_eq!(results.len(), 1); assert_eq!(results[0]["title"].as_str().unwrap(), "Title Only"); assert_eq!(results[0]["snippet"].as_str().unwrap(), "", "无 snippet 应默认空字符串"); } // ── handler 参数边界(不发网络,触发早期拒绝) ── #[tokio::test] async fn test_handler_missing_query_errors() { let args = json!({ "max_results": 5 }); let err = execute_fetch_search(args).await.unwrap_err(); assert!(format!("{}", err).contains("缺少 query")); } #[tokio::test] async fn test_handler_empty_query_errors() { let args = json!({ "query": " " }); let err = execute_fetch_search(args).await.unwrap_err(); assert!(format!("{}", err).contains("query 不能为空")); } #[tokio::test] async fn test_handler_max_results_clamped_low() { // max_results=0 应 clamp 到 1(用空 query 触发早期拒绝,验证 clamp 不 panic) let args = json!({ "query": " ", "max_results": 0 }); let _ = execute_fetch_search(args).await; // 不 panic 即通过 } #[tokio::test] async fn test_handler_max_results_clamped_high() { let args = json!({ "query": " ", "max_results": 999 }); let _ = execute_fetch_search(args).await; // 不 panic 即通过 } // ── 真实网络集成(#[ignore]:CI 无网时跳过,本地手跑) ── #[tokio::test] #[ignore = "需真实网络(DDG),CI 无网时跳过:cargo test -- --ignored"] async fn integration_search_rust_async_returns_results() { let args = json!({ "query": "rust async programming", "max_results": 3 }); let result = execute_fetch_search(args).await.expect("搜索应成功"); let count = result["count"].as_u64().unwrap_or(0); assert!(count >= 1, "至少应返回 1 条结果,实际 {}", count); let results = result["results"].as_array().expect("results 应为数组"); // 每条应有非空 title + url for r in results { assert!(!r["title"].as_str().unwrap_or("").is_empty(), "title 不应为空"); let url = r["url"].as_str().unwrap_or(""); assert!(!url.is_empty(), "url 不应为空"); // uddg 应已解包,非 DDG 跳板 URL(除非 DDG 返回内部页) // 不强制断言非 duckduckgo.com(偶尔 DDG 返回内部结果),但应能 fetch_url } // query 应回显 assert_eq!(result["query"].as_str().unwrap(), "rust async programming"); } }