wip: checkpoint 2026-03-29 runtime work
This commit is contained in:
382
src/compat/screen_html_export_tool.rs
Normal file
382
src/compat/screen_html_export_tool.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
generated_at_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
rows: Option<Vec<Vec<Value>>>,
|
||||
#[serde(default)]
|
||||
table: Option<Vec<ScreenTableRow>>,
|
||||
#[serde(default)]
|
||||
categories: Option<Vec<ScreenCategory>>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
}
|
||||
|
||||
#[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<ScreenCategory>,
|
||||
table: Vec<ScreenTableRow>,
|
||||
}
|
||||
|
||||
#[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<ToolResult> {
|
||||
let parsed = match serde_json::from_value::<ScreenHtmlExportArgs>(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<Value>]) -> anyhow::Result<Vec<ScreenTableRow>> {
|
||||
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<ScreenCategory> {
|
||||
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::<String>();
|
||||
let base = number_part.parse::<f64>().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<u64> {
|
||||
match value {
|
||||
Value::Number(number) => number.as_u64(),
|
||||
Value::String(text) => text.trim().parse::<u64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_template(payload: &ScreenPayload) -> anyhow::Result<String> {
|
||||
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::<Vec<_>>()
|
||||
.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()))
|
||||
}
|
||||
Reference in New Issue
Block a user