//! fetch_url AI 工具 — URL → markdown 文档嗅探(GET HTML → markdown,去噪音 + 截断) //! //! 设计目标:让 LLM 高效理解网页文档(API 文档/博客/技术资料),替代 `http_request` 拿原始 HTML。 //! 原始 HTML 充满 nav/footer/script/style/广告等噪声,直接灌进 prompt 既爆 token 又稀释信号; //! 本工具 GET HTML 后用 htmd(turndown.js 移植)转 markdown,剥离非内容节点 + 按 char 截断, //! 让 LLM 拿到的是干净文本主体。 //! //! ## 与 http_request 的分工 //! //! - `http_request`:结构化 API 调用(GET/POST/鉴权/JSON),拿原始响应 body。写方法有副作用=High。 //! - `fetch_url`:**只读 GET** 网页文档嗅探,输出已清洗的 markdown。Low risk(只读,无副作用)。 //! //! ## 安全(SSRF 防护 — 与 http_request 共享同一套) //! //! 复用 `http.rs` 的 `validate_url` / `resolve_and_check_host` / `build_client` / `execute_with_redirects`: //! ① 协议白名单(仅 http/https)② 私网 IP 黑名单(RFC1918 + 链路本地 + 环回 + 元数据)③ DNS resolve 后 //! 校验 IP(防 rebinding)④ 重定向 ≤3 跳每跳重校验。fetch_url 只发 GET,无 body/写副作用。 //! //! ## 去噪音策略 //! //! htmd 本身会把 script/style/noscript 等非可见节点忽略(转空),但转换后可能残留少量空白行、 //! 重复 nav 链接文本。本工具追加一道轻量清理:折叠 ≥3 连续空行为 2 行、trim 行尾空白。 //! 不做激进的 DOM 删节点(易误伤正文,且 htmd 已处理主要噪声)——稳健优先。 //! //! ## 截断 //! //! 按 char 截断(非字节,避免切多字节 UTF-8 中间):默认 max_length=8000 chars,clamp [500, 50000]。 //! 截断尾部加 `... [markdown 已截断]` 标记,LLM 可知内容不完整。 //! //! ## render 模式(obscura 引导,JS 渲染 SPA/反爬文档) //! //! `render=false`(默认)走 reqwest + htmd 静态管线,所有用户可用。 //! `render=true` 时检测本地 `obscura`(用户自带 Rust 无头浏览器,h4ckf0r0day/obscura,自带 V8): //! ① 已装 → spawn `obscura fetch --dump markdown ` → 拿 stdout → 截断 → 返回(同静态格式)。 //! ② 未装 / 启动失败 / 超时 → 回退静态管线 + `hint` 字段提示装 obscura 可增强 JS 渲染。 //! DevFlow 不打包 obscura(按需引导),保证 render=true 在任何环境都不致工具失败。 use std::collections::HashMap; use std::time::{Duration, Instant}; use serde_json::{json, Value}; /// 默认 markdown 截断长度(chars)。LLM 单次 fetch 应足以覆盖一篇中等文档的主体。 const DEFAULT_MAX_LENGTH: usize = 8000; /// markdown 截断下限(chars)。防 LLM 传极小值拿到无意义片段。 const MIN_MAX_LENGTH: usize = 500; /// markdown 截断上限(chars)。防 LLM 传极大值仍撑爆 context(50K chars ≈ 12-15K tokens)。 const MAX_MAX_LENGTH: usize = 50_000; /// 默认请求超时(秒)。与 http_request 一致。 const DEFAULT_TIMEOUT_SECS: u64 = 30; /// 超时硬上限(秒)。与 http_request 一致。 const MAX_TIMEOUT_SECS: u64 = 60; /// 响应 body 字节上限。HTML 转 markdown 前先挡超大原始响应,防 OOM(50KB markdown 足够, /// 但 HTML 可能远大于其 markdown,故放宽到 2MB 原始 HTML 上限)。 const MAX_HTML_BYTES: usize = 2 * 1024 * 1024; /// obscura 子进程超时(秒)。obscura 默认 page timeout=30s,外加 V8 启动/JIT 余量给到 45s。 /// 超时即杀子进程并回退静态管线(render=true 兜底,不让 SPA 拖垮工具)。 const OBSCURA_TIMEOUT_SECS: u64 = 45; /// obscura 未安装时的引导提示。回退静态管线后通过返回 JSON 的 `hint` 字段透传给 LLM/用户。 const OBSCURA_HINT: &str = "obscura 未安装,已用静态模式。装 obscura(Rust 无头浏览器,自带 V8)可增强 JS 渲染文档(SPA/反爬):见 github.com/h4ckf0r0day/obscura"; /// 二进制响应(含 \0)拒绝错误消息。引导式:明示改用 download_file 工具下载文件, /// 让 AI 看到错误即知换工具(治会话 01f05167:fetch_url 拒二进制 + run_command curl 失败 → 卡死)。 const BINARY_REJECT_MSG: &str = "该 URL 返回二进制内容(图片/压缩包/文件等),fetch_url 只读 HTML 文档;\ 如需下载文件请改用 download_file 工具(支持任意文件类型,含图片/二进制/文档)"; /// fetch_url 工具 handler 入口(供 tools/fetch_url.rs register 调用)。 /// /// 参数: /// - url: 必填,http/https /// - max_length: 可选,markdown 截断长度 chars(默认 8000,clamp [500, 50000]) /// - render: 可选,bool,默认 false。true 时用 obscura(JS 渲染 SPA/反爬),未装/失败回退静态。 /// /// 返回 {url, title, markdown, length, truncated, render_mode, ?hint} pub(crate) async fn execute_fetch_url(args: Value) -> anyhow::Result { // ── 参数解析 ── let url_raw = args.get("url").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("缺少 url 参数"))? .trim() .to_string(); if url_raw.is_empty() { anyhow::bail!("url 不能为空"); } let max_length = (args.get("max_length").and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_MAX_LENGTH as u64) as usize) .clamp(MIN_MAX_LENGTH, MAX_MAX_LENGTH); let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_TIMEOUT_SECS) .min(MAX_TIMEOUT_SECS) .max(1); let render = args.get("render").and_then(|v| v.as_bool()).unwrap_or(false); // SSRF 校验对所有模式都必做。校验放入口而非 fetch_static 内,确保 render 分支与静态分支 // 都先过 SSRF(原 fetch_static 内的校验保留作第二道,双重校验对安全是好事,无害)。 let (_, host, port) = crate::commands::ai::http::validate_url(&url_raw)?; crate::commands::ai::http::resolve_and_check_host(&host, port).await?; // ── render 分支 ── if render { match fetch_with_obscura(&url_raw, max_length).await { Ok(value) => { if value.get("render_mode").and_then(|v| v.as_str()) == Some("static_fallback") { tracing::info!("obscura 回退静态模式:hint={:?}", value.get("hint").and_then(|v| v.as_str()).unwrap_or("")); } return Ok(value); } Err(e) => { // 不该发生(fetch_with_obscura 内部已把所有错误转成回退+hint),但兜底再保险一道。 tracing::warn!("obscura 分支异常({}),回退静态", e); } } } // ── 静态管线(reqwest + htmd,默认/回退共用) ── let mut result = fetch_static(&url_raw, max_length, timeout_secs, &args).await?; if render { // render=true 但走到了静态管线(未装/失败/异常)→ 标记并塞 hint。 result["render_mode"] = json!("static_fallback"); result["hint"] = json!(OBSCURA_HINT); } else { result["render_mode"] = json!("static"); } Ok(result) } /// 静态管线:reqwest GET + htmd 转 markdown + 去噪音 + 截断。 /// /// 从原 execute_fetch_url 抽出,供默认/回退共用。SSRF 校验在 execute_fetch_url 入口已做, /// 这里保留第二道校验(execute_fetch_url 改动前即有,移除会破坏单元测试触发的早期拒绝路径)。 async fn fetch_static( url_raw: &str, max_length: usize, timeout_secs: u64, args: &Value, ) -> anyhow::Result { // headers: 容许 LLM 传自定义头(如 User-Agent,部分站点拒默认 UA),复用 http_request 同款解析。 let headers: HashMap = match args.get("headers") { Some(Value::Object(m)) => m.iter() .filter_map(|(k, v)| { let s = match v { Value::String(s) => s.clone(), other => other.to_string(), }; Some((k.clone(), s)) }) .collect(), _ => { // 默认带一个浏览器 UA:不少站点(如 MDN/GitHub)对非浏览器 UA 返回简化/拒绝页面, // 给一个主流 UA 拿到完整渲染 HTML,提升 markdown 质量。LLM 显式传 headers 则不覆盖。 let mut h = HashMap::new(); h.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(), ); h } }; let body: Option = None; // fetch_url 只 GET,无 body // ── SSRF 校验(第二道,见函数注释) ── let (scheme, host, port) = crate::commands::ai::http::validate_url(url_raw)?; crate::commands::ai::http::resolve_and_check_host(&host, port).await?; // ── 构建限制 client + 执行(GET,手动重定向循环复用 http.rs) ── 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, url_raw.to_string(), &headers, &body, 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!( "fetch 失败:HTTP {} {}({})", 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_url 不适合超大原始 HTML(改用 http_request 分段)", total_bytes, MAX_HTML_BYTES ); } // 含 \0 → 二进制(非 HTML),直接拒(PDF/图片等走专用工具,fetch_url 只处理文本网页)。 // 错误文案引导式:明示改用 download_file(支持任意文件类型,图片/压缩包/二进制均可下载), // 让 AI 看到错误即知换工具,避免卡死在 fetch_url / run_command curl 死循环(实测会话 01f05167)。 // 错误消息抽为 const 便于单元测试断言(无需真实网络/含 \0 响应即可验证引导文案)。 if bytes.contains(&0u8) { anyhow::bail!("{}", BINARY_REJECT_MSG); } let html = String::from_utf8_lossy(&bytes).into_owned(); // ── 提取 title(...,正则避免引 HTML 解析重依赖) ── let title = extract_title(&html); // ── HTML → markdown(htmd:turndown.js 移植,自动忽略 script/style) ── // htmd::convert 返回 Result(解析可能失败,如畸形 HTML)。失败时不阻塞 —— 降级用原文 lossy 文本, // LLM 仍可读到内容(质量差于 markdown 但不致工具整体失败)。 let mut markdown = match htmd::convert(&html) { Ok(md) => md, Err(e) => { tracing::warn!("htmd 转 markdown 失败({}),降级用原始 HTML 文本", e); html.clone() } }; // ── 去噪音:折叠 ≥3 连续空行 → 2 行、trim 行尾空白 ── markdown = cleanup_markdown(&markdown); // ── 截断(按 char 边界) ── let (markdown, truncated) = truncate_chars(&markdown, max_length); Ok(json!({ "url": final_url, "scheme": scheme, "status": status, "title": title, "markdown": markdown, "length": markdown.chars().count(), "html_bytes": total_bytes, "truncated": truncated, "elapsed_ms": elapsed_ms, })) } /// 用 obscura(JS 渲染)抓取 URL 并转 markdown。 /// /// 流程:检测 obscura 在 PATH/已知路径 → spawn `obscura fetch --dump markdown `(带超时) /// → 取 stdout → 截断 → 返回与静态管线同构的 JSON(render_mode=obscura)。 /// /// ## 两次尝试的鲁棒性(缺陷 3+4) /// /// - **缺陷 3**:obscura 非零退出码时不直接回退——先看 stdout 是否仍有实质内容(V8 watchdog /// kill 时 exit≠0 但 stdout 可能已加载大部分内容),有则用 partial 标记,无才回退。 /// - **缺陷 4**:`fetch --dump markdown` 对 JS 重的 SPA(如 Next.js hydration)可能输出空壳 /// (watchdog kill 在 dump 前)。此时自动 fallback 再试 `scrape --eval "document.body.innerText"` /// ——后者走不同管线(innerText 纯文本,不等 JS 全执行完),对 SPA 更鲁棒。 /// 两次都空壳/无实质内容才回退静态管线。 /// /// 任何环节最终失败(未装 / spawn 失败 / 两次都超时 / 都空壳)都不向上抛,而是回退静态管线: /// 内部调 fetch_static 拿结果,塞 hint 字段提示装 obscura。这样 render=true 在任何环境 /// 都不会因 obscura 缺失/故障而让工具整体失败(引导式安装的核心保证)。 async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result { let started = Instant::now(); // ── 检测 obscura ── let obscura_path = match find_obscura() { Some(p) => p, None => { // 未装:回退静态 + hint。 let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?; fallback["render_mode"] = json!("obscura_not_installed"); fallback["hint"] = json!(OBSCURA_HINT); fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); return Ok(fallback); } }; // ── 第 1 次尝试:fetch --dump markdown(输出结构 markdown,优先) ── // obscura 内部管 page load timeout(默认 30s),我们外加 tokio timeout 兜底 V8 启动/JIT // 卡死等 obscura 自身超时管不到的场景。 match run_obscura(&obscura_path, &["fetch", "--dump", "markdown", url_raw]).await { ObscuraRunResult::Markdown(md) => { return Ok(build_obscura_json(url_raw, &md, max_length, started, "obscura", None)); } ObscuraRunResult::Partial(md) => { // 缺陷 3:非零退出码但 stdout 有实质内容(watchdog 截断)→ 用 partial 结果,不回退。 return Ok(build_obscura_json( url_raw, &md, max_length, started, "obscura_partial", Some("obscura 非零退出(可能 watchdog 截断),已用部分渲染结果"), )); } ObscuraRunResult::SpawnFailed(e) => { tracing::warn!("obscura spawn 失败({}),回退静态", e); let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?; fallback["render_mode"] = json!("obscura_failed"); fallback["hint"] = json!(format!("obscura 启动失败({}),已用静态模式。{}", e, OBSCURA_HINT)); fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); return Ok(fallback); } ObscuraRunResult::Timeout => { tracing::warn!("obscura fetch 超时({}s),改试 scrape --eval", OBSCURA_TIMEOUT_SECS); // 超时也走 scrape --eval 兜底(scraper 管线不同,可能更快返回)。 } ObscuraRunResult::EmptyShell => { // 缺陷 4:fetch markdown 空壳 → fallback scrape --eval。 tracing::info!("obscura fetch --dump markdown 空壳/无实质内容,改用 scrape --eval(innerText)"); } } // ── 第 2 次尝试:scrape --eval(输出 innerText 纯文本,对 SPA 鲁棒,缺陷 4) ── match run_obscura( &obscura_path, &["scrape", "--eval", "document.body.innerText", url_raw], ) .await { ObscuraRunResult::Markdown(md) | ObscuraRunResult::Partial(md) => { // scrape --eval 拿到内容:innerText 纯文本无 markdown 结构,但能拿到正文。 return Ok(build_obscura_json( url_raw, &md, max_length, started, "obscura_scrape_eval", Some("obscura fetch markdown 空壳,改用 innerText 纯文本"), )); } ObscuraRunResult::SpawnFailed(e) => { tracing::warn!("obscura scrape spawn 失败({}),回退静态", e); let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?; fallback["render_mode"] = json!("obscura_failed"); fallback["hint"] = json!(format!("obscura 启动失败({}),已用静态模式。{}", e, OBSCURA_HINT)); fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); return Ok(fallback); } ObscuraRunResult::Timeout => { tracing::warn!("obscura scrape --eval 也超时({}s),回退静态", OBSCURA_TIMEOUT_SECS); } ObscuraRunResult::EmptyShell => { tracing::warn!("obscura scrape --eval 也无实质内容,回退静态"); } } // ── 两次都失败/空壳:回退静态管线 ── let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?; fallback["render_mode"] = json!("static_fallback"); fallback["hint"] = json!(format!( "obscura 渲染失败(fetch markdown 与 scrape --eval 均无实质内容),已用静态模式。{}", OBSCURA_HINT )); fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); Ok(fallback) } /// obscura 单次执行结果(供 fetch_with_obscura 判定 partial/空壳/失败)。 /// /// - `Markdown`:退出码 0 且 stdout 有实质内容(正常成功)。 /// - `Partial`:非零退出码但 stdout 仍有实质内容(缺陷 3:watchdog kill 截断,内容已部分加载)。 /// - `EmptyShell`:stdout 无实质内容(空 / 纯框架标签 / 极短)。caller 应尝试别的管线或回退。 /// - `Timeout`:超过 OBSCURA_TIMEOUT_SECS 被杀。 /// - `SpawnFailed`:spawn 本身失败(obscura 二进制损坏等)。 enum ObscuraRunResult { Markdown(String), Partial(String), EmptyShell, Timeout, SpawnFailed(String), } /// spawn obscura 带指定子命令参数,带进程级超时,返回 stdout 内容分类。 /// /// fetch --dump markdown 与 scrape --eval 共用此 helper(同样的 spawn 装配 + 超时 + 实质内容判定), /// 保证两条管线鲁棒性一致、且都带 CREATE_NO_WINDOW(防弹窗)。 async fn run_obscura(obscura_path: &str, args: &[&str]) -> ObscuraRunResult { let mut cmd = tokio::process::Command::new(obscura_path); cmd.args(args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); // CREATE_NO_WINDOW:防 obscura spawn 弹控制台窗口闪烁(对齐 shell.rs build_command)。 #[cfg(windows)] { cmd.creation_flags(0x0800_0000); } let output = match tokio::time::timeout( Duration::from_secs(OBSCURA_TIMEOUT_SECS), cmd.output(), ) .await { Ok(Ok(o)) => o, Ok(Err(e)) => return ObscuraRunResult::SpawnFailed(format!("{}", e)), Err(_) => return ObscuraRunResult::Timeout, }; let success = output.status.success(); let stderr = String::from_utf8_lossy(&output.stderr); if !success { // 保留原 :297 的 stderr warn 日志(诊断 obscura 非零退出原因)。 tracing::warn!("obscura 非零退出({}),stderr: {}", output.status, stderr.trim()); } // obscura 偶发在 stdout 前后带空白/日志行,trim 首尾;内部仍走 cleanup 折叠空行。 let markdown = cleanup_markdown(String::from_utf8_lossy(&output.stdout).trim()); if has_substantial_content(&markdown) { if success { ObscuraRunResult::Markdown(markdown) } else { // 缺陷 3:非零退出码但有实质内容(watchdog 截断)。 ObscuraRunResult::Partial(markdown) } } else { ObscuraRunResult::EmptyShell } } /// 判定 obscura stdout 是否有实质内容(缺陷 3+4 共用)。 /// /// 用于区分:watchdog kill 时 stdout 已加载大部分内容(可用 partial)vs SPA 空壳(无可读正文)。 /// 判定规则(层层剔除空壳形态): /// 1. trim 后 <500 chars → 无实质内容(空/极短)。 /// 2. 从原文移除常见空壳骨架(`
` 等 hydration 占位标签)。 /// 3. 从剩余文本移除占位词(loading / please enable javascript / 404 / not found 等)。 /// 4. 剔除后剩余「非空白字符」<200 → 判空壳(原文虽长但全是占位骨架/占位词)。 /// 真实正文即使在 watchdog 截断后,也会保留大量非占位正文 char → 通过。 fn has_substantial_content(md: &str) -> bool { let trimmed = md.trim(); if trimmed.chars().count() < 500 { return false; } let lower = trimmed.to_lowercase(); // 第 2 步:移除空壳骨架标签(hydration 占位)。 let shell_markers = [ "
", "
", "
", "
", "", ]; let mut stripped: String = lower.clone(); for marker in shell_markers { stripped = stripped.replace(marker, ""); } // 第 3 步:移除占位词(空壳站点常常只有这类文案)。 for placeholder in [ "loading", "please wait", "please enable javascript", "enable javascript", "enable js", "javascript is required", "404", "not found", "page not found", ] { stripped = stripped.replace(placeholder, ""); } // 第 4 步:剩余「有效字符」(非空白且非纯标点 .,!?:; 等)<200 → 判空壳。 // 标点不计入有效内容:Loading... 去掉 loading 后剩 ... 是空壳残留标点,非正文。 let non_ws_chars: usize = stripped .chars() .filter(|c| !c.is_whitespace() && !matches!(c, '.' | ',' | '!' | '?' | ':' | ';' | '-' | '_' | '|' | '*' | '#' | '<' | '>' | '/' | '=' | '"' | '\'')) .count(); non_ws_chars >= 200 } /// 组装 obscura 成功路径 JSON(三种 render_mode:obscura / obscura_partial / obscura_scrape_eval)。 /// /// 抽出共用:三种成功路径(正常/partial/scrape_eval)的 JSON 结构除 render_mode 与 hint /// 外完全一致(同样从 markdown 启发式取 title、截断、算 length/html_bytes_est)。 fn build_obscura_json( url_raw: &str, markdown_raw: &str, max_length: usize, started: Instant, render_mode: &str, hint: Option<&str>, ) -> Value { let html_bytes_est = markdown_raw.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段) // 截断(按 char 边界,与静态一致) let (markdown, truncated) = truncate_chars(markdown_raw, max_length); let elapsed_ms = started.elapsed().as_millis() as u64; let title = extract_title_from_markdown(&markdown); // scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。 let scheme = crate::commands::ai::http::validate_url(url_raw) .map(|(s, _, _)| s) .unwrap_or_else(|_| "https".to_string()); let mut v = json!({ "url": url_raw, "scheme": scheme, "status": 200u16, // obscura 成功路径不暴露 HTTP status(已渲染),用 200 占位 "title": title, "markdown": markdown, "length": markdown.chars().count(), "html_bytes": html_bytes_est, "truncated": truncated, "elapsed_ms": elapsed_ms, "render_mode": render_mode, }); if let Some(h) = hint { v["hint"] = json!(h); } v } /// 检测 obscura 是否已安装且可执行。优先级:PATH > 已知 npm-global 路径。 /// /// 用 `obscura --version` 探活(比 `which` 跨平台:Windows 无 which,且 --version 能确认 /// 二进制可跑而非仅存在)。同步探活带 15s 超时防卡(对齐 module.rs::run_command 的 thread+channel 风格)。 /// 探活结果全程 tracing 日志(debug:每个 candidate 尝试/exit/超时;warn:全部失败列出试过的路径), /// 让 Tauri 进程 PATH 与 PowerShell 不同导致的间歇性失败可诊断。 fn find_obscura() -> Option { let candidates: &[&str] = &[ "obscura", // PATH 优先(用户 npm i -g 后即在 PATH) "/d/NodeJS/npm-global/obscura", // 已知安装路径(用户环境,POSIX 形式 git-bash 友好) "/d/NodeJS/npm-global/obscura.exe", "D:\\NodeJS\\npm-global\\obscura.exe", // Windows 原生路径(tauri 在 Windows 跑) ]; let mut tried: Vec<&str> = Vec::with_capacity(candidates.len()); for cand in candidates { tracing::debug!("obscura 探测尝试 candidate: {}", cand); let (tx, rx) = std::sync::mpsc::channel(); let cand_owned = cand.to_string(); std::thread::spawn(move || { let mut c = std::process::Command::new(&cand_owned); c.arg("--version") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); // CREATE_NO_WINDOW:防 obscura --version 探测弹控制台(每次 fetch_url render=true 都探测,不弹窗)。 #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } let out = c.output(); let _ = tx.send(out); }); match rx.recv_timeout(Duration::from_secs(15)) { Ok(Ok(o)) if o.status.success() => { tracing::debug!("obscura 探测命中 candidate: {}", cand); return Some(cand.to_string()); } Ok(Ok(o)) => { tracing::debug!("obscura 探测失败 candidate={}, exit={}", cand, o.status); tried.push(cand); } Ok(Err(e)) => { tracing::debug!("obscura 探测 spawn 失败 candidate={}, err={}", cand, e); tried.push(cand); } Err(_) => { tracing::debug!("obscura 探测超时(15s) candidate={}", cand); tried.push(cand); } } } tracing::warn!("obscura 所有 candidate 探测失败,试过: {:?}", tried); None } /// 从 markdown 启发式提取 title:首个 `# 一级标题` 或首个非空文本行(≤120 chars)。 /// obscura dump markdown 无独立 title 字段,此为最佳近似(静态管线有 ,此函数仅 render 分支用)。 fn extract_title_from_markdown(md: &str) -> Option<String> { // 代码块围栏状态:true=当前在 ``` 代码块内,块内行全部跳过(不当作标题)。 // 修复:原实现只跳过 ``` 围栏行本身,块内容行(如 "code block")会被误当标题。 let mut in_code_block = false; for line in md.lines() { let t = line.trim(); if t.starts_with("```") { in_code_block = !in_code_block; // 切换围栏状态(``` 开或闭) continue; } if in_code_block { continue; // 代码块内:跳过 } if t.is_empty() { continue; } // 首个 H1 标题 if let Some(rest) = t.strip_prefix("# ") { let title = rest.trim(); if !title.is_empty() { return Some(title.to_string()); } } // 否则取首个非空、非表格/代码块的行(去掉 markdown 强调前缀) if !t.starts_with('|') { let cleaned: String = t.trim_start_matches(|c: char| c == '*' || c == '-').trim().to_string(); // 超长行(>120)截断到 120 作标题(innerText 纯文本无结构时首行即正文, // 原实现整行 >120 直接跳过 → title 为 None;截断保留可读标题)。 if !cleaned.is_empty() { let sliced: String = cleaned.chars().take(120).collect(); return Some(sliced); } } } None } /// 提取 HTML <title> 内容。case-insensitive,取首个,trim 空白。 /// 用简单字符串扫描而非 regex/HTML 解析:title 标签结构固定且少,正则/解析引依赖不值。 fn extract_title(html: &str) -> Option<String> { let lower = html.to_lowercase(); let open = lower.find("<title")?; // 跳过 `<title ...>` 到 '>'(可能含属性,但 <title> 几乎不带属性) let after_open_tag = &html[open..]; let gt = after_open_tag.find('>')?; let content_start = open + gt + 1; let rest = &html[content_start..]; // 找 (case-insensitive) let rest_lower = &lower[content_start..]; let close = rest_lower.find("")?; let title = &rest[..close]; let trimmed = title.trim(); if trimmed.is_empty() { None } else { // 去换行/多空格压缩,防 title 跨行带大量空白 let squashed: String = trimmed.split_whitespace().collect::>().join(" "); Some(squashed) } } /// 折叠 ≥3 连续空行 → 恰好 2 行,trim 每行尾空白。htmd 输出常有连续空行(nav 块转空)。 fn cleanup_markdown(md: &str) -> String { let mut out = String::with_capacity(md.len()); let mut blank_run = 0usize; for line in md.split_inclusive('\n') { // split_inclusive 保留 '\n';末行可能无 '\n' let content = line.trim_end_matches('\n'); let is_blank = content.trim().is_empty(); if is_blank { blank_run += 1; if blank_run <= 2 { out.push('\n'); } // blank_run > 2:丢弃(折叠) } else { blank_run = 0; // trim 行尾空白,保留行首缩进(markdown 列表/代码块缩进有意义) out.push_str(content.trim_end()); out.push('\n'); } } out } /// 按 char 边界截断 markdown。返回 (截断后 String, 是否截断)。 /// 不切多字节 UTF-8 中间(char_indices 保证落在 char 边界)。 fn truncate_chars(md: &str, max: usize) -> (String, bool) { let char_count = md.chars().count(); if char_count <= max { return (md.to_string(), false); } // 找到第 max 个 char 的字节位置 let byte_cutoff = md.char_indices() .nth(max) .map(|(idx, _)| idx) .unwrap_or(md.len()); let head = &md[..byte_cutoff]; let total = char_count; ( format!( "{}\n\n... [markdown 已截断,原文 {} chars,仅保留前 {} chars]", head.trim_end(), total, max ), true, ) } // ============================================================ // 单元测试 // // 覆盖纯函数(extract_title / cleanup_markdown / truncate_chars),确定性零网络。 // handler 集成层走真实网络 #[ignore](对齐 http.rs 测试策略)。 // ============================================================ #[cfg(test)] mod tests { use super::*; // ── 二进制拒绝错误文案(引导式:含 download_file 提示) ── // 治会话 01f05167:fetch_url 拒二进制后 AI 不知换工具,卡死在 run_command curl。 // 错误消息必须明示 download_file,让 AI 看到错误即换工具。纯 const 断言零网络零依赖。 #[test] fn test_binary_reject_msg_guides_to_download_file() { // 错误消息必须含 download_file 工具名(引导换工具的核心) assert!(BINARY_REJECT_MSG.contains("download_file")); // 必须明示 fetch_url 只读 HTML(让 AI 理解为何被拒) assert!(BINARY_REJECT_MSG.contains("HTML")); // 必须提及图片/二进制等关键词(匹配 AI 触发场景:用户给 .png URL) assert!(BINARY_REJECT_MSG.contains("二进制")); assert!(BINARY_REJECT_MSG.contains("图片")); } // ── extract_title ── #[test] fn test_extract_title_basic() { let html = "Hello Worldx"; assert_eq!(extract_title(html).as_deref(), Some("Hello World")); } #[test] fn test_extract_title_case_insensitive() { let html = "Case Test"; assert_eq!(extract_title(html).as_deref(), Some("Case Test")); } #[test] fn test_extract_title_multiline_squashed() { let html = "\n Multi\n Line Title \n"; assert_eq!(extract_title(html).as_deref(), Some("Multi Line Title")); } #[test] fn test_extract_title_missing_returns_none() { let html = "no title here"; assert_eq!(extract_title(html), None); } #[test] fn test_extract_title_empty_returns_none() { let html = " "; assert_eq!(extract_title(html), None); } // ── cleanup_markdown ── #[test] fn test_cleanup_collapses_many_blank_lines() { let md = "para1\n\n\n\n\npara2"; let out = cleanup_markdown(md); // 4 连续空行折叠为 2 assert_eq!(out, "para1\n\n\npara2\n"); } #[test] fn test_cleanup_trims_trailing_whitespace() { let md = "line with trailing spaces \nnext"; let out = cleanup_markdown(md); assert_eq!(out, "line with trailing spaces\nnext\n"); } #[test] fn test_cleanup_preserves_leading_indent() { // markdown 代码块/列表缩进有意义,不能 trim 行首 let md = " code line\n- list item"; let out = cleanup_markdown(md); assert_eq!(out, " code line\n- list item\n"); } #[test] fn test_cleanup_keeps_two_blank_lines() { let md = "a\n\n\nb"; let out = cleanup_markdown(md); assert_eq!(out, "a\n\n\nb\n"); } // ── extract_title_from_markdown(仅 render=obscura 分支用) ── #[test] fn test_md_title_from_h1() { let md = "# Rust 异步编程指南\n\n正文内容"; assert_eq!(extract_title_from_markdown(md).as_deref(), Some("Rust 异步编程指南")); } #[test] fn test_md_title_fallback_first_line() { // 无 H1,取首个非空行(去掉强调前缀) let md = "**Welcome** to my site\n\n更多内容"; assert_eq!(extract_title_from_markdown(md).as_deref(), Some("Welcome** to my site")); } #[test] fn test_md_title_skips_blank_and_code() { let md = "\n\n```\ncode block\n```\n# Real Title\nbody"; assert_eq!(extract_title_from_markdown(md).as_deref(), Some("Real Title")); } #[test] fn test_md_title_empty_returns_none() { assert_eq!(extract_title_from_markdown(""), None); assert_eq!(extract_title_from_markdown(" \n\n "), None); } // ── truncate_chars ── #[test] fn test_truncate_under_limit_unchanged() { let md = "short content"; let (out, trunc) = truncate_chars(md, 1000); assert!(!trunc); assert_eq!(out, "short content"); } #[test] fn test_truncate_over_limit_marks_truncated() { let md = "abcdefghij".repeat(1000); // 10000 chars let (out, trunc) = truncate_chars(&md, 100); assert!(trunc); assert!(out.contains("[markdown 已截断")); assert!(out.contains("原文 10000 chars")); assert!(out.contains("仅保留前 100 chars")); } #[test] fn test_truncate_respects_char_boundary_unicode() { // 中文每字 3 字节,截 5 char 应得 15 字节(不在中间切) let md = "你好世界测试内容"; // 8 chars,每字 3 字节 let (out, trunc) = truncate_chars(md, 5); assert!(trunc); // 截断点应在 "你好世界测" 后(5 chars = 15 bytes),无半个字 assert!(out.starts_with("你好世界测\n\n")); // 不应出现 U+FFFD(乱码替换符) assert!(!out.contains('\u{FFFD}')); } // ── has_substantial_content(缺陷 3+4 共用判定) ── #[test] fn test_substantial_content_real_markdown_passes() { // 真实正文:>500 chars(达到 has_substantial_content 的 500 字符下限阈值), // 无空壳标记,应判有实质内容。原测试 md 仅 494 chars(<500)被前置阈值拦截误判空壳。 let md = "# 异步编程指南\n\n这是一篇关于 Rust 异步编程的详细指南。异步与同步的区别在于并发模型。\n".repeat(15); assert!(has_substantial_content(&md)); } #[test] fn test_substantial_content_short_returns_false() { // <500 chars → 无实质内容 assert!(!has_substantial_content("short content only")); assert!(!has_substantial_content("")); assert!(!has_substantial_content(" \n\n ")); } #[test] fn test_substantial_content_empty_shell_root_div_rejected() { // SPA hydration 空壳典型:仅
+ 大量空白填充到 >500 chars let padding = " ".repeat(600); let shell = format!("
\n{}", padding); assert!(!has_substantial_content(&shell)); } #[test] fn test_substantial_content_empty_shell_app_div_rejected() { // 仅
空壳 let padding = "\n".repeat(600); let shell = format!("
{}", padding); assert!(!has_substantial_content(&shell)); } #[test] fn test_substantial_content_loading_placeholder_rejected() { // 纯 "Loading..." 占位符重复(去掉 "loading" 后仅剩标点/空白)→ 空壳 let placeholder = "Loading...\n".repeat(120); assert!(!has_substantial_content(&placeholder)); } #[test] fn test_substantial_content_enable_js_rejected() { // 纯 "Please enable JavaScript" 占位(去掉占位词后仅剩空白)→ 空壳 let placeholder = "Please enable JavaScript\n".repeat(40); assert!(!has_substantial_content(&placeholder)); } #[test] fn test_substantial_content_mixed_real_content_passes() { // 含真实正文(loading 标题但有正文)→ 应判有实质内容 let md = "# Loading data\n\n".to_string() + &"这是真实的正文内容,描述了一篇技术文章的核心要点。".repeat(20); assert!(has_substantial_content(&md)); } // ── ObscuraRunResult 分类逻辑(缺陷 3:partial 分支) ── // // 注:run_obscura 依赖真实 obscura 二进制 + 网络,此处不直接测 spawn,而是间接验证 // has_substantial_content 的判定正确性(它是 run_obscura 内 partial vs emptyshell 的核心)。 // 上面 has_substantial_content 测试已覆盖:有实质内容 → Markdown/Partial 分支可命中; // 空壳 → EmptyShell 分支可命中。 #[test] fn test_obscura_partial_classification_via_substantial_check() { // 模拟 watchdog 截断:obscura 非零退出但 stdout 已加载部分真实内容(>500 chars 正文)。 // run_obscura 会判 success=false + has_substantial_content=true → ObscuraRunResult::Partial。 let partial_stdout = "# 文章标题\n\n".to_string() + &"这是 watchdog 截断前已加载的部分正文内容,包含真实信息。".repeat(20); // has_substantial_content 是 Partial 分支的唯一判据 assert!(has_substantial_content(&partial_stdout)); } #[test] fn test_obscura_emptyshell_classification_via_substantial_check() { // 模拟 SPA 空壳:obscura 输出仅 hydration 占位 → EmptyShell → 触发 scrape --eval fallback let empty_stdout = format!("
\n{}", " ".repeat(600)); assert!(!has_substantial_content(&empty_stdout)); } // ── build_obscura_json(render_mode 三态 + hint) ── #[test] fn test_build_obscura_json_normal_mode_no_hint() { let md = "# 标题\n\n正文内容".to_string(); let started = Instant::now(); let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None); assert_eq!(v["render_mode"].as_str().unwrap(), "obscura"); assert_eq!(v["status"].as_u64().unwrap(), 200); assert_eq!(v["url"].as_str().unwrap(), "https://example.com/"); assert_eq!(v["title"].as_str().unwrap(), "标题"); assert_eq!(v["scheme"].as_str().unwrap(), "https"); // hint 字段不存在 assert!(v.get("hint").is_none()); } #[test] fn test_build_obscura_json_partial_mode_with_hint() { // 缺陷 3:obscura_partial render_mode + hint 说明 watchdog 截断 let md = "# 截断的标题\n\n".to_string() + &"部分正文。".repeat(50); let started = Instant::now(); let v = build_obscura_json( "https://example.com/", &md, 10000, started, "obscura_partial", Some("obscura 非零退出(可能 watchdog 截断),已用部分渲染结果"), ); assert_eq!(v["render_mode"].as_str().unwrap(), "obscura_partial"); let hint = v["hint"].as_str().unwrap(); assert!(hint.contains("watchdog")); assert!(hint.contains("部分渲染")); } #[test] fn test_build_obscura_json_scrape_eval_mode() { // 缺陷 4:obscura_scrape_eval render_mode + hint 说明改用 innerText let md = "这是 innerText 纯文本正文,无 markdown 结构。".repeat(20); let started = Instant::now(); let v = build_obscura_json( "https://example.com/", &md, 10000, started, "obscura_scrape_eval", Some("obscura fetch markdown 空壳,改用 innerText 纯文本"), ); assert_eq!(v["render_mode"].as_str().unwrap(), "obscura_scrape_eval"); let hint = v["hint"].as_str().unwrap(); assert!(hint.contains("innerText")); // innerText 无 markdown 结构 → extract_title_from_markdown 走首行 fallback assert!(v["title"].as_str().is_some()); } #[test] fn test_build_obscura_json_truncation_applies() { // build_obscura_json 内部应走 truncate_chars:超长 markdown 截断标记 let md: String = "abcdefghij".repeat(2000); // 20000 chars let started = Instant::now(); let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None); assert_eq!(v["truncated"], true); assert!(v["markdown"].as_str().unwrap().contains("[markdown 已截断")); } // ── handler 参数边界 ── #[tokio::test] async fn test_handler_missing_url_errors() { let args = json!({ "max_length": 1000 }); let err = execute_fetch_url(args).await.unwrap_err(); assert!(format!("{}", err).contains("缺少 url")); } #[tokio::test] async fn test_handler_empty_url_errors() { let args = json!({ "url": " " }); let err = execute_fetch_url(args).await.unwrap_err(); assert!(format!("{}", err).contains("url 不能为空")); } #[tokio::test] async fn test_handler_rejects_localhost() { let args = json!({ "url": "http://localhost:8080/" }); let err = execute_fetch_url(args).await.unwrap_err(); assert!(format!("{}", err).contains("localhost") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_private_ip() { let args = json!({ "url": "http://169.254.169.254/latest/" }); let err = execute_fetch_url(args).await.unwrap_err(); assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_non_http_scheme() { let args = json!({ "url": "file:///etc/passwd" }); let err = execute_fetch_url(args).await.unwrap_err(); assert!(format!("{}", err).contains("协议")); } #[tokio::test] async fn test_handler_max_length_clamped_low() { // max_length=1 应 clamp 到 500,不发请求只验证参数解析不 panic(用 localhost 触发 SSRF 早期拒绝) let args = json!({ "url": "http://localhost/", "max_length": 1 }); let _ = execute_fetch_url(args).await; // 不 panic 即通过 } #[tokio::test] async fn test_handler_max_length_clamped_high() { let args = json!({ "url": "http://localhost/", "max_length": 999999 }); let _ = execute_fetch_url(args).await; // 不 panic 即通过 } // ── 真实网络集成(#[ignore]:CI 无网时跳过) ── #[tokio::test] #[ignore = "需真实网络(example.com),CI 无网时跳过:cargo test -- --ignored"] async fn integration_fetch_example_com_markdown() { let args = json!({ "url": "https://example.com/" }); let result = execute_fetch_url(args).await.expect("fetch example.com 应成功"); assert_eq!(result["status"].as_u64().unwrap(), 200); assert_eq!(result["title"].as_str().unwrap_or(""), "Example Domain"); let md = result["markdown"].as_str().unwrap_or(""); assert!(md.contains("Example Domain")); assert!(!md.contains("