fix: stabilize zhihu export and dashboard flow

This commit is contained in:
zhaoyilun
2026-04-10 17:09:19 +08:00
parent 4becf81066
commit 34035cdc9c
10 changed files with 955 additions and 276 deletions

View File

@@ -4,10 +4,13 @@ use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use zeroclaw::tools::{Tool, ToolResult};
use zip::write::FileOptions;
use zip::{CompressionMethod, ZipWriter};
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
const DEFAULT_SHEET_NAME: &str = "知乎热榜";
@@ -280,13 +283,8 @@ fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
.parent()
.map(|path| path.join("openxml_cli").join("Cargo.toml"))
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli manifest path"))?;
let binary_path = manifest_path
.parent()
.map(|path| path.join("target").join("debug").join("openxml-cli"))
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli binary path"))?;
let output = if binary_path.exists() {
Command::new(&binary_path)
let output = if let Some(binary_path) = resolve_openxml_cli_binary(&manifest_path) {
Command::new(binary_path)
.args([
"template",
"render",
@@ -325,6 +323,34 @@ fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
Ok(serde_json::from_str(&stdout)?)
}
fn resolve_openxml_cli_binary(manifest_path: &Path) -> Option<PathBuf> {
let cli_dir = manifest_path.parent()?;
openxml_cli_candidate_paths(cli_dir)
.into_iter()
.find(|path| path.exists())
}
fn openxml_cli_candidate_paths(cli_dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
for profile in ["release", "debug"] {
paths.push(
cli_dir
.join("target")
.join(profile)
.join(openxml_cli_binary_name()),
);
}
paths
}
fn openxml_cli_binary_name() -> &'static str {
if cfg!(windows) {
"openxml-cli.exe"
} else {
"openxml-cli"
}
}
fn value_to_string(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
@@ -363,22 +389,81 @@ fn write_hotlist_template(path: &Path, row_count: usize) -> anyhow::Result<()> {
fs::remove_file(path)?;
}
let zip = Command::new("zip")
.current_dir(&build_root)
.args(["-q", "-r", path.to_string_lossy().as_ref(), "."])
.output()?;
if !zip.status.success() {
let stderr = String::from_utf8_lossy(&zip.stderr);
return Err(anyhow::anyhow!(format!(
"failed to create xlsx template: {}",
stderr.trim()
)));
}
zip_directory(&build_root, path)?;
let _ = fs::remove_dir_all(&build_root);
Ok(())
}
#[cfg(test)]
mod tests {
use super::{openxml_cli_binary_name, openxml_cli_candidate_paths, zip_entry_name};
use std::path::Path;
#[test]
fn openxml_cli_candidates_prefer_release_before_debug() {
let paths = openxml_cli_candidate_paths(Path::new("E:\\coding\\codex\\openxml_cli"));
assert_eq!(paths.len(), 2);
assert_eq!(
paths[0],
Path::new("E:\\coding\\codex\\openxml_cli")
.join("target")
.join("release")
.join(openxml_cli_binary_name())
);
assert_eq!(
paths[1],
Path::new("E:\\coding\\codex\\openxml_cli")
.join("target")
.join("debug")
.join(openxml_cli_binary_name())
);
}
#[test]
fn zip_entry_name_normalizes_windows_separators() {
let rel = Path::new("xl\\worksheets\\sheet1.xml");
assert_eq!(zip_entry_name(rel), "xl/worksheets/sheet1.xml");
}
}
fn zip_directory(source_root: &Path, zip_path: &Path) -> anyhow::Result<()> {
let file = fs::File::create(zip_path)?;
let mut writer = ZipWriter::new(file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
add_directory_to_zip(&mut writer, source_root, source_root, options)?;
writer.finish()?;
Ok(())
}
fn add_directory_to_zip<W: Write + std::io::Seek>(
writer: &mut ZipWriter<W>,
source_root: &Path,
current_dir: &Path,
options: FileOptions,
) -> anyhow::Result<()> {
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
add_directory_to_zip(writer, source_root, &path, options)?;
continue;
}
let relative_path = path.strip_prefix(source_root)?;
writer.start_file(zip_entry_name(relative_path), options)?;
let mut input = fs::File::open(&path)?;
let mut buffer = Vec::new();
input.read_to_end(&mut buffer)?;
writer.write_all(&buffer)?;
}
Ok(())
}
fn zip_entry_name(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn worksheet_xml(row_count: usize) -> String {
let mut rows = Vec::new();
rows.push(

View File

@@ -1,5 +1,6 @@
use std::path::Path;
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
use crate::compat::runtime::CompatTaskContext;
use crate::config::SgClawSettings;
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
@@ -34,6 +35,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
workspace_root: &Path,
settings: &SgClawSettings,
) -> Result<String, PipeError> {
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
let route = crate::compat::workflow_executor::detect_route(
instruction,
task_context.page_url.as_deref(),
@@ -45,6 +47,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
transport,
&browser_tool,
workspace_root,
&skills_dir,
instruction,
task_context,
route,
@@ -70,6 +73,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
transport,
&browser_tool,
workspace_root,
&skills_dir,
instruction,
task_context,
route,
@@ -80,6 +84,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
transport,
&browser_tool,
workspace_root,
&skills_dir,
instruction,
task_context,
route,

View File

@@ -12,7 +12,7 @@ const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
const DEFAULT_SCREEN_TITLE: &str = "知乎热榜主题分类分析大屏";
const TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../skill_lib/skills/zhihu-hotlist-screen/assets/zhihu-hotlist-echarts.html"
"/resources/zhihu-hotlist-echarts.html"
));
const PAYLOAD_START_MARKER: &str = " const defaultPayload = ";
const PAYLOAD_END_MARKER: &str = "\n\n const themeMeta = {";

View File

@@ -117,6 +117,7 @@ pub fn execute_route<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
workspace_root: &Path,
skills_dir: &Path,
instruction: &str,
task_context: &CompatTaskContext,
route: WorkflowRoute,
@@ -124,7 +125,13 @@ pub fn execute_route<T: Transport + 'static>(
match route {
WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => {
let top_n = extract_top_n(instruction);
let items = collect_hotlist_items(transport, browser_tool, top_n, task_context)?;
let items = collect_hotlist_items(
transport,
browser_tool,
skills_dir,
top_n,
task_context,
)?;
if items.is_empty() {
return Err(PipeError::Protocol(
"知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(),
@@ -141,13 +148,27 @@ pub fn execute_route<T: Transport + 'static>(
}
}
WorkflowRoute::ZhihuArticleEntry => {
execute_zhihu_article_entry_route(transport, browser_tool)
execute_zhihu_article_entry_route(transport, browser_tool, skills_dir)
}
WorkflowRoute::ZhihuArticleDraft => {
execute_zhihu_article_route(transport, browser_tool, instruction, task_context, false)
execute_zhihu_article_route(
transport,
browser_tool,
skills_dir,
instruction,
task_context,
false,
)
}
WorkflowRoute::ZhihuArticlePublish => {
execute_zhihu_article_route(transport, browser_tool, instruction, task_context, true)
execute_zhihu_article_route(
transport,
browser_tool,
skills_dir,
instruction,
task_context,
true,
)
}
}
}
@@ -155,10 +176,13 @@ pub fn execute_route<T: Transport + 'static>(
fn collect_hotlist_items<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
skills_dir: &Path,
top_n: usize,
task_context: &CompatTaskContext,
) -> Result<Vec<HotlistItem>, PipeError> {
if let Some(items) = ensure_hotlist_page_ready(transport, browser_tool, top_n, task_context)? {
if let Some(items) =
ensure_hotlist_page_ready(transport, browser_tool, skills_dir, top_n, task_context)?
{
return Ok(items);
}
transport.send(&AgentMessage::LogEntry {
@@ -167,7 +191,7 @@ fn collect_hotlist_items<T: Transport + 'static>(
})?;
let response = browser_tool.invoke(
Action::Eval,
json!({ "script": load_hotlist_extractor_script(top_n)? }),
json!({ "script": load_hotlist_extractor_script(skills_dir, top_n)? }),
ZHIHU_DOMAIN,
)?;
if !response.success {
@@ -188,6 +212,7 @@ fn collect_hotlist_items<T: Transport + 'static>(
fn ensure_hotlist_page_ready<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
skills_dir: &Path,
top_n: usize,
task_context: &CompatTaskContext,
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
@@ -204,7 +229,7 @@ fn ensure_hotlist_page_ready<T: Transport + 'static>(
return Ok(None);
}
if starts_on_hotlist {
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? {
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, skills_dir, top_n)? {
return Ok(Some(items));
}
}
@@ -215,7 +240,7 @@ fn ensure_hotlist_page_ready<T: Transport + 'static>(
if poll_for_hotlist_readiness(browser_tool)? {
return Ok(None);
}
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? {
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, skills_dir, top_n)? {
return Ok(Some(items));
}
last_error = Some(PipeError::Protocol(format!(
@@ -230,6 +255,7 @@ fn ensure_hotlist_page_ready<T: Transport + 'static>(
fn probe_hotlist_extractor<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
skills_dir: &Path,
top_n: usize,
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
transport.send(&AgentMessage::LogEntry {
@@ -238,7 +264,7 @@ fn probe_hotlist_extractor<T: Transport + 'static>(
})?;
let response = browser_tool.invoke(
Action::Eval,
json!({ "script": load_hotlist_extractor_script(top_n)? }),
json!({ "script": load_hotlist_extractor_script(skills_dir, top_n)? }),
ZHIHU_DOMAIN,
)?;
if !response.success {
@@ -379,6 +405,7 @@ fn export_screen<T: Transport>(
fn execute_zhihu_article_route<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
skills_dir: &Path,
instruction: &str,
task_context: &CompatTaskContext,
publish_mode: bool,
@@ -401,6 +428,7 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
})?;
let creator_state = execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-navigate",
"open_creator_entry.js",
json!({ "desired_target": "article_editor" }),
@@ -424,6 +452,7 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
})?;
let editor_state = execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-write",
"prepare_article_editor.js",
json!({ "desired_mode": if publish_mode { "publish" } else { "draft" } }),
@@ -446,6 +475,7 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
})?;
let fill_result = execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-write",
"fill_article_draft.js",
json!({
@@ -482,6 +512,7 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
fn execute_zhihu_article_entry_route<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
skills_dir: &Path,
) -> Result<String, PipeError> {
navigate_zhihu_page(transport, browser_tool, ZHIHU_CREATOR_URL)?;
transport.send(&AgentMessage::LogEntry {
@@ -490,6 +521,7 @@ fn execute_zhihu_article_entry_route<T: Transport + 'static>(
})?;
let creator_state = execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-navigate",
"open_creator_entry.js",
json!({ "desired_target": "article_editor" }),
@@ -513,6 +545,7 @@ fn execute_zhihu_article_entry_route<T: Transport + 'static>(
})?;
let editor_state = execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-write",
"prepare_article_editor.js",
json!({ "desired_mode": "draft" }),
@@ -532,8 +565,9 @@ fn execute_zhihu_article_entry_route<T: Transport + 'static>(
)))
}
fn load_hotlist_extractor_script(top_n: usize) -> Result<String, PipeError> {
fn load_hotlist_extractor_script(skills_dir: &Path, top_n: usize) -> Result<String, PipeError> {
load_browser_skill_script(
skills_dir,
"zhihu-hotlist",
"extract_hotlist.js",
json!({ "top_n": top_n.to_string() }),
@@ -618,12 +652,14 @@ fn navigate_zhihu_page<T: Transport + 'static>(
fn execute_browser_skill_script<T: Transport + 'static>(
browser_tool: &BrowserPipeTool<T>,
skills_dir: &Path,
skill_name: &str,
script_name: &str,
args: Value,
expected_domain: &str,
) -> Result<Value, PipeError> {
let wrapped_script = load_browser_skill_script(skill_name, script_name, args)?;
let wrapped_script =
load_browser_skill_script(skills_dir, skill_name, script_name, args)?;
let response = browser_tool.invoke(
Action::Eval,
json!({ "script": wrapped_script }),
@@ -977,15 +1013,12 @@ mod tests {
}
fn load_browser_skill_script(
skills_dir: &Path,
skill_name: &str,
script_name: &str,
args: Value,
) -> Result<String, PipeError> {
let script_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")))
.join("skill_lib")
.join("skills")
let script_path = skills_dir
.join(skill_name)
.join("scripts")
.join(script_name);