use async_trait::async_trait; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use zeroclaw::tools::{Tool, ToolResult}; 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" )); const PAYLOAD_START_MARKER: &str = " const defaultPayload = "; const PAYLOAD_END_MARKER: &str = "\n\n const themeMeta = {"; pub struct ScreenHtmlExportTool { workspace_root: PathBuf, } impl ScreenHtmlExportTool { pub fn new(workspace_root: PathBuf) -> Self { Self { workspace_root } } } #[derive(Debug, Deserialize)] struct ScreenHtmlExportArgs { #[serde(default)] snapshot_id: Option, #[serde(default)] generated_at_ms: Option, #[serde(default)] rows: Option>>, #[serde(default)] table: Option>, #[serde(default)] categories: Option>, #[serde(default)] output_path: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] struct ScreenCategory { category_code: String, category_label: String, item_count: u64, total_heat: u64, avg_heat: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] struct ScreenTableRow { rank: u64, title: String, url: String, category_code: String, category_label: String, heat_text: String, heat_value: u64, reply_count: u64, upvote_count: u64, favorite_count: u64, heart_count: u64, } #[derive(Debug, Serialize)] struct ScreenPayload { snapshot_id: String, generated_at_ms: u64, categories: Vec, table: Vec, } #[async_trait] impl Tool for ScreenHtmlExportTool { fn name(&self) -> &str { SCREEN_HTML_EXPORT_TOOL_NAME } fn description(&self) -> &str { "Render a local Zhihu hotlist ECharts dashboard HTML for leadership demos and new-tab presentation." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { "snapshot_id": { "type": "string" }, "generated_at_ms": { "type": "integer" }, "rows": { "type": "array", "items": { "type": "array", "items": {} } }, "table": { "type": "array", "items": { "type": "object" } }, "categories": { "type": "array", "items": { "type": "object" } }, "output_path": { "type": "string" } } }) } async fn execute(&self, args: Value) -> anyhow::Result { let parsed = match serde_json::from_value::(args) { Ok(value) => value, Err(err) => return Ok(failed_tool_result(format!("invalid tool arguments: {err}"))), }; let table = match parsed.table { Some(table) if !table.is_empty() => table, Some(_) => return Ok(failed_tool_result("table must not be empty".to_string())), None => match parsed.rows { Some(rows) => build_table_from_rows(&rows)?, None => { return Ok(failed_tool_result( "rows or table is required for screen_html_export".to_string(), )) } }, }; if table.is_empty() { return Ok(failed_tool_result("table must not be empty".to_string())); } let categories = parsed .categories .filter(|items| !items.is_empty()) .unwrap_or_else(|| derive_categories(&table)); let payload = ScreenPayload { snapshot_id: parsed .snapshot_id .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(default_snapshot_id), generated_at_ms: parsed.generated_at_ms.unwrap_or_else(now_ms), categories, table, }; let rendered = render_template(&payload)?; let output_path = parsed .output_path .as_deref() .map(PathBuf::from) .unwrap_or_else(|| default_output_path(&self.workspace_root)); write_output_html(&output_path, &rendered)?; let presentation_url = file_url_for_path(&output_path); Ok(ToolResult { success: true, output: json!({ "title": DEFAULT_SCREEN_TITLE, "output_path": output_path, "renderer": SCREEN_HTML_EXPORT_TOOL_NAME, "row_count": payload.table.len(), "snapshot_id": payload.snapshot_id, "presentation": { "mode": "new_tab", "title": DEFAULT_SCREEN_TITLE, "url": presentation_url, "open_in_new_tab": true } }) .to_string(), error: None, }) } } fn failed_tool_result(error: String) -> ToolResult { ToolResult { success: false, output: String::new(), error: Some(error), } } fn build_table_from_rows(rows: &[Vec]) -> anyhow::Result> { if rows.is_empty() { return Err(anyhow::anyhow!("rows must not be empty")); } rows.iter() .enumerate() .map(|(index, row)| { if row.len() != 3 { return Err(anyhow::anyhow!( "each row must contain exactly 3 values: rank, title, heat" )); } let rank = value_to_rank(&row[0]).unwrap_or((index + 1) as u64); let title = value_to_string(&row[1]); if title.trim().is_empty() { return Err(anyhow::anyhow!("title must not be empty")); } let heat_text = value_to_string(&row[2]); let heat_value = parse_heat_value(&heat_text); let (category_code, category_label) = classify_title(&title); Ok(ScreenTableRow { rank, title, url: format!("https://www.zhihu.com/question/hotlist-{rank}"), category_code: category_code.to_string(), category_label: category_label.to_string(), heat_text, heat_value, reply_count: 0, upvote_count: 0, favorite_count: 0, heart_count: 0, }) }) .collect() } fn derive_categories(table: &[ScreenTableRow]) -> Vec { let mut grouped: BTreeMap<(String, String), (u64, u64)> = BTreeMap::new(); for row in table { let key = (row.category_code.clone(), row.category_label.clone()); let entry = grouped.entry(key).or_insert((0, 0)); entry.0 += 1; entry.1 += row.heat_value; } grouped .into_iter() .map(|((category_code, category_label), (item_count, total_heat))| ScreenCategory { category_code, category_label, item_count, total_heat, avg_heat: if item_count == 0 { 0 } else { total_heat / item_count }, }) .collect() } fn classify_title(title: &str) -> (&'static str, &'static str) { let normalized = title.to_ascii_lowercase(); if contains_any(&normalized, &["ai", "芯片", "科技", "算法", "机器人", "无人机"]) { return ("technology", "科技"); } if contains_any(&normalized, &["电影", "综艺", "明星", "周杰伦", "短剧", "娱乐"]) { return ("entertainment", "娱乐"); } if contains_any(&normalized, &["足球", "比赛", "联赛", "国足", "体育", "冠军"]) { return ("sports", "体育"); } if contains_any(&normalized, &["航母", "作战", "军", "军事", "演训"]) { return ("military", "军事"); } if contains_any(&normalized, &["出口", "经济", "市场", "财经", "消费", "股"]) { return ("finance", "财经"); } ("society", "社会") } fn contains_any(haystack: &str, needles: &[&str]) -> bool { needles.iter().any(|needle| haystack.contains(needle)) } fn parse_heat_value(heat_text: &str) -> u64 { let compact = heat_text.trim().replace(',', ""); if compact.is_empty() { return 0; } let number_part = compact .chars() .filter(|ch| ch.is_ascii_digit() || *ch == '.') .collect::(); let base = number_part.parse::().unwrap_or(0.0); let multiplier = if compact.contains('亿') { 100_000_000.0 } else if compact.contains('万') { 10_000.0 } else { 1.0 }; (base * multiplier).round() as u64 } fn value_to_string(value: &Value) -> String { match value { Value::String(text) => text.clone(), Value::Number(number) => number.to_string(), Value::Bool(flag) => flag.to_string(), Value::Null => String::new(), other => other.to_string(), } } fn value_to_rank(value: &Value) -> Option { match value { Value::Number(number) => number.as_u64(), Value::String(text) => text.trim().parse::().ok(), _ => None, } } fn render_template(payload: &ScreenPayload) -> anyhow::Result { let payload_json = serde_json::to_string_pretty(payload)?; let payload_start = TEMPLATE .find(PAYLOAD_START_MARKER) .ok_or_else(|| anyhow::anyhow!("default payload start marker missing"))?; let payload_end = TEMPLATE .find(PAYLOAD_END_MARKER) .ok_or_else(|| anyhow::anyhow!("default payload end marker missing"))?; let replacement = format!( "{PAYLOAD_START_MARKER}{}\n", indent_block(&payload_json, " ") ); Ok(format!( "{}{}{}", &TEMPLATE[..payload_start], replacement, &TEMPLATE[payload_end..], )) } fn indent_block(value: &str, indent: &str) -> String { value .lines() .map(|line| format!("{indent}{line}")) .collect::>() .join("\n") } fn write_output_html(path: &Path, rendered: &str) -> anyhow::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(path, rendered)?; Ok(()) } fn default_output_path(workspace_root: &Path) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_nanos()) .unwrap_or_default(); workspace_root .join("out") .join(format!("zhihu-hotlist-screen-{nanos}.html")) } fn default_snapshot_id() -> String { format!("zhihu-hotlist-screen-{}", now_ms()) } fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_millis() as u64) .unwrap_or_default() } fn file_url_for_path(path: &Path) -> String { Url::from_file_path(path) .map(|url| url.to_string()) .unwrap_or_else(|_| format!("file://{}", path.display())) }