refactor: worker进程 → tokio task 架构 + 多渠道消息队列 + JSON工具系统
核心变更: - daemon: 废弃 worker 子进程模型,替换为 LLM/Tool/Send 三消费者 tokio task - queue: 新增公平轮转 MessageQueue + QueueRunner 路由,严格 ChannelId 渠道隔离 - channel: ChannelId(platform + user_id) 统一渠道标识,替代散列 user_id+platform - tools: 全部工具统一经由 execute_tool() → Tool Consumer 单一执行路径 - llm: chat_with_tools 返回 ChatResult,支持异步工具调用 → 保留/恢复 session - tools: 新增 JSON 工具定义系统 (ToolDef) 及 SubprocessRunner (HTTP 模板引擎) - bin: ias-auth 独立 JWT 生成器,ias-fetch headless Chrome 网页抓取 - 所有出站消息通过 Send Consumer 队列转发,无直接 send_text 调用 - Session TTL 清理 (5分钟),QueueRunner 通道容错 - cargo check 零警告,cargo test 23/23 通过
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
//! ias-fetch — 通过 headless Chrome 提取网页正文
|
||||
//!
|
||||
//! 启动无头 Chrome 渲染页面 JS,取渲染后的纯文本输出到 stdout。
|
||||
//! 专供 JSON 工具 `${cmd:ias-fetch {{url}}}` 调用。
|
||||
//!
|
||||
//! 用法:
|
||||
//! ias-fetch <url> # 默认:取 body.innerText
|
||||
//! ias-fetch --raw <url> # 返回完整 HTML
|
||||
//! ias-fetch --timeout 15 <url> # 自定义超时(默认 15s)
|
||||
|
||||
use futures::StreamExt;
|
||||
use std::time::Duration;
|
||||
|
||||
use chromiumoxide::{Browser, BrowserConfig, Page};
|
||||
|
||||
const DEFAULT_TIMEOUT: u64 = 15;
|
||||
const MAX_OUTPUT_CHARS: usize = 10_000;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
let mut raw_mode = false;
|
||||
let mut timeout_secs = DEFAULT_TIMEOUT;
|
||||
let mut url: Option<String> = None;
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--raw" => raw_mode = true,
|
||||
"--timeout" => {
|
||||
i += 1;
|
||||
timeout_secs = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(DEFAULT_TIMEOUT);
|
||||
}
|
||||
s if s.starts_with("http://") || s.starts_with("https://") => {
|
||||
url = Some(s.to_string());
|
||||
}
|
||||
s if !s.starts_with('-') => url = Some(s.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let url = match url {
|
||||
Some(u) => {
|
||||
if u.starts_with("http://") || u.starts_with("https://") {
|
||||
u
|
||||
} else {
|
||||
format!("https://{}", u)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
eprintln!("用法: ias-fetch [--raw] [--timeout N] <url>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
match tokio::time::timeout(timeout, fetch_page(&url, raw_mode)).await {
|
||||
Ok(Ok(output)) => {
|
||||
if output.is_empty() {
|
||||
eprintln!("页面无内容");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("{}", output);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("超时 ({}s): {}", timeout_secs, url);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_page(url: &str, raw_mode: bool) -> Result<String, String> {
|
||||
let (mut browser, mut handler) = Browser::launch(
|
||||
BrowserConfig::builder()
|
||||
.build()
|
||||
.map_err(|e| format!("Chrome 配置失败: {}", e))?,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("启动 Chrome 失败: {}\n请确保已安装 chromium 或 google-chrome", e))?;
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Some(_event) = handler.next().await {
|
||||
// 持续消费 handler 事件
|
||||
}
|
||||
});
|
||||
|
||||
// new_page 接受 URL,自动导航并等待加载
|
||||
let page = browser
|
||||
.new_page(url)
|
||||
.await
|
||||
.map_err(|e| format!("导航到 {} 失败: {}", url, e))?;
|
||||
|
||||
// 额外等待 JS 渲染
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// 提取内容
|
||||
let content = if raw_mode {
|
||||
page.content().await.map_err(|e| format!("获取 HTML 失败: {}", e))?
|
||||
} else {
|
||||
get_inner_text(&page).await?
|
||||
};
|
||||
|
||||
// 清理
|
||||
browser.close().await.ok();
|
||||
handle.await.ok();
|
||||
|
||||
// 截断
|
||||
if content.chars().count() > MAX_OUTPUT_CHARS {
|
||||
Ok(format!(
|
||||
"{}…(已截断,完整内容 {} 字符)",
|
||||
content.chars().take(MAX_OUTPUT_CHARS).collect::<String>(),
|
||||
content.chars().count()
|
||||
))
|
||||
} else {
|
||||
Ok(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 evaluate JS 获取 body.innerText
|
||||
async fn get_inner_text(page: &Page) -> Result<String, String> {
|
||||
let result = page
|
||||
.evaluate("document.body?.innerText || document.documentElement?.innerText || ''")
|
||||
.await
|
||||
.map_err(|e| format!("提取正文失败: {}", e))?;
|
||||
|
||||
let value: serde_json::Value = result
|
||||
.into_value()
|
||||
.map_err(|e| format!("解析 evaluate 结果失败: {}", e))?;
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
let cleaned: Vec<&str> = s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
|
||||
Ok(cleaned.join("\n"))
|
||||
}
|
||||
_ => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user