fix: stabilize zhihu export and dashboard flow
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {";
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user