chore: 更新Rust构建缓存和依赖文件

This commit is contained in:
2026-03-23 15:38:55 +08:00
parent 9103da519c
commit bf77212793
812 changed files with 16380 additions and 13 deletions

348
src/config.rs Normal file
View File

@@ -0,0 +1,348 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Result, Context};
use tracing::{info, warn, error};
/// LLM API配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmApiConfig {
pub api_key: String,
pub base_url: String,
pub model: String,
pub timeout: u64,
pub retry_times: u32,
}
impl Default for LlmApiConfig {
fn default() -> Self {
Self {
api_key: String::new(),
base_url: "https://open.bigmodel.cn/api/paas/v4".to_string(),
model: "glm-4.7-flash".to_string(),
timeout: 120,
retry_times: 3,
}
}
}
/// 爬虫配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpiderConfig {
pub target_url: String,
pub xpath: String,
pub user_agent: String,
pub fetch_interval: u64,
pub retry_times: u32,
pub retry_interval: u64,
pub chrome_path: String,
}
impl Default for SpiderConfig {
fn default() -> Self {
Self {
target_url: "https://example.com".to_string(),
xpath: "//a[contains(@class, 'linkblack')]".to_string(),
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string(),
fetch_interval: 60,
retry_times: 3,
retry_interval: 5,
chrome_path: String::new(),
}
}
}
/// UI配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
pub opacity: f32,
pub is_on_top: bool,
pub thresholds: Thresholds,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thresholds {
pub cold: i32,
pub warm: i32,
}
impl Default for Thresholds {
fn default() -> Self {
Self {
cold: 30,
warm: 70,
}
}
}
impl Default for UiConfig {
fn default() -> Self {
Self {
opacity: 0.9,
is_on_top: true,
thresholds: Thresholds::default(),
}
}
}
/// 数据库配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
pub path: String,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
path: "guba.db".to_string(),
}
}
}
/// 日志配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
pub level: String,
pub path: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "INFO".to_string(),
path: "guba.log".to_string(),
}
}
}
/// 应用配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub llm_api: LlmApiConfig,
pub spider: SpiderConfig,
pub ui: UiConfig,
pub database: DatabaseConfig,
pub logging: LoggingConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
llm_api: LlmApiConfig::default(),
spider: SpiderConfig::default(),
ui: UiConfig::default(),
database: DatabaseConfig::default(),
logging: LoggingConfig::default(),
}
}
}
/// 配置管理器
pub struct ConfigManager {
config_path: PathBuf,
config: AppConfig,
}
impl ConfigManager {
/// 创建新的配置管理器
pub fn new(config_path: &str) -> Result<Self> {
let config_path = Self::get_config_path(config_path)?;
info!("配置管理器初始化,配置文件路径: {:?}", config_path);
let config = if config_path.exists() {
Self::load_from_file(&config_path)?
} else {
warn!("配置文件不存在,使用默认配置");
AppConfig::default()
};
Ok(Self {
config_path,
config,
})
}
/// 获取配置文件路径
fn get_config_path(config_path: &str) -> Result<PathBuf> {
// 检查是否是打包后的环境
let exe_path = std::env::current_exe()?;
let exe_dir = exe_path.parent()
.context("无法获取可执行文件目录")?;
// 优先使用可执行文件所在目录
let path = exe_dir.join(config_path);
Ok(path)
}
/// 从文件加载配置
fn load_from_file(path: &Path) -> Result<AppConfig> {
let content = fs::read_to_string(path)
.with_context(|| format!("读取配置文件失败: {:?}", path))?;
let loaded: serde_json::Value = serde_json::from_str(&content)
.with_context(|| "解析配置文件失败")?;
// 合并默认配置和加载的配置
let default_config = serde_json::to_value(AppConfig::default())?;
let merged = Self::merge_config(&default_config, &loaded);
let config: AppConfig = serde_json::from_value(merged)?;
info!("配置加载成功");
Ok(config)
}
/// 递归合并配置
fn merge_config(default: &serde_json::Value, loaded: &serde_json::Value) -> serde_json::Value {
match (default, loaded) {
(serde_json::Value::Object(mut default_map), serde_json::Value::Object(loaded_map)) => {
for (key, value) in loaded_map {
if let Some(default_value) = default_map.get(key) {
default_map.insert(
key.clone(),
Self::merge_config(default_value, value),
);
} else {
default_map.insert(key.clone(), value.clone());
}
}
serde_json::Value::Object(default_map)
}
_ => loaded.clone(),
}
}
/// 保存配置到文件
pub fn save(&self) -> Result<()> {
let content = serde_json::to_string_pretty(&self.config)?;
fs::write(&self.config_path, content)
.with_context(|| format!("保存配置文件失败: {:?}", self.config_path))?;
info!("配置保存成功");
Ok(())
}
/// 获取嵌套配置值
pub fn get(&self, keys: &[&str]) -> Option<serde_json::Value> {
let mut value = serde_json::to_value(&self.config).ok()?;
for key in keys {
value = value.get(key)?.clone();
}
Some(value)
}
/// 设置嵌套配置值
pub fn set(&mut self, keys: &[&str], value: serde_json::Value) -> Result<()> {
let mut config_value = serde_json::to_value(&self.config)?;
let mut current = &mut config_value;
for key in &keys[..keys.len() - 1] {
current = current
.as_object_mut()
.context("配置不是对象")?
.get_mut(*key)
.context("配置键不存在")?;
}
if let Some(last_key) = keys.last() {
current
.as_object_mut()
.context("配置不是对象")?
.insert(last_key.to_string(), value);
}
self.config = serde_json::from_value(config_value)?;
self.save()?;
Ok(())
}
// Getters
pub fn llm_api_config(&self) -> &LlmApiConfig {
&self.config.llm_api
}
pub fn spider_config(&self) -> &SpiderConfig {
&self.config.spider
}
pub fn ui_config(&self) -> &UiConfig {
&self.config.ui
}
pub fn database_config(&self) -> &DatabaseConfig {
&self.config.database
}
pub fn logging_config(&self) -> &LoggingConfig {
&self.config.logging
}
// Update methods
pub fn update_llm_api(&mut self, api_key: Option<String>, base_url: Option<String>,
model: Option<String>, timeout: Option<u64>,
retry_times: Option<u32>) -> Result<()> {
if let Some(api_key) = api_key {
self.config.llm_api.api_key = api_key;
}
if let Some(base_url) = base_url {
self.config.llm_api.base_url = base_url;
}
if let Some(model) = model {
self.config.llm_api.model = model;
}
if let Some(timeout) = timeout {
self.config.llm_api.timeout = timeout;
}
if let Some(retry_times) = retry_times {
self.config.llm_api.retry_times = retry_times;
}
info!("LLM API配置已更新");
self.save()
}
pub fn update_spider(&mut self, target_url: Option<String>, xpath: Option<String>,
user_agent: Option<String>, fetch_interval: Option<u64>,
retry_times: Option<u32>, retry_interval: Option<u64>,
chrome_path: Option<String>) -> Result<()> {
if let Some(target_url) = target_url {
self.config.spider.target_url = target_url;
}
if let Some(xpath) = xpath {
self.config.spider.xpath = xpath;
}
if let Some(user_agent) = user_agent {
self.config.spider.user_agent = user_agent;
}
if let Some(fetch_interval) = fetch_interval {
self.config.spider.fetch_interval = fetch_interval;
}
if let Some(retry_times) = retry_times {
self.config.spider.retry_times = retry_times;
}
if let Some(retry_interval) = retry_interval {
self.config.spider.retry_interval = retry_interval;
}
if let Some(chrome_path) = chrome_path {
self.config.spider.chrome_path = chrome_path;
}
info!("爬虫配置已更新");
self.save()
}
pub fn update_ui(&mut self, opacity: Option<f32>, is_on_top: Option<bool>,
cold_threshold: Option<i32>, warm_threshold: Option<i32>) -> Result<()> {
if let Some(opacity) = opacity {
self.config.ui.opacity = opacity.clamp(0.3, 1.0);
}
if let Some(is_on_top) = is_on_top {
self.config.ui.is_on_top = is_on_top;
}
if let Some(cold) = cold_threshold {
self.config.ui.thresholds.cold = cold;
}
if let Some(warm) = warm_threshold {
self.config.ui.thresholds.warm = warm;
}
info!("UI配置已更新");
self.save()
}
}

334
src/database.rs Normal file
View File

@@ -0,0 +1,334 @@
use rusqlite::{Connection, params, OptionalExtension};
use chrono::Local;
use md5::{Md5, Digest};
use anyhow::{Result, Context};
use tracing::{info, debug, warn};
/// 评论数据结构
#[derive(Debug, Clone)]
pub struct Comment {
pub id: i64,
pub content: String,
pub content_hash: String,
pub url: Option<String>,
pub created_at: Option<String>,
pub fetched_at: String,
pub analyzed: bool,
pub sentiment_score: Option<f64>,
pub analyzed_at: Option<String>,
}
/// 未分析评论(用于分析)
#[derive(Debug, Clone)]
pub struct UnanalyzedComment {
pub id: i64,
pub content: String,
pub url: Option<String>,
}
/// 分析历史记录
#[derive(Debug, Clone)]
pub struct AnalysisHistory {
pub id: i64,
pub comment_id: i64,
pub sentiment_score: f64,
pub analysis_text: Option<String>,
pub created_at: String,
}
/// 数据库管理器
pub struct DatabaseManager {
db_path: String,
}
impl DatabaseManager {
/// 创建新的数据库管理器
pub fn new(db_path: &str) -> Result<Self> {
let manager = Self {
db_path: db_path.to_string(),
};
manager.init_db()?;
info!("数据库管理器初始化完成,数据库路径: {}", db_path);
Ok(manager)
}
/// 获取数据库连接
fn get_connection(&self) -> Result<Connection> {
Connection::open(&self.db_path)
.with_context(|| format!("无法打开数据库: {}", self.db_path))
}
/// 初始化数据库表
fn init_db(&self) -> Result<()> {
let conn = self.get_connection()?;
// 评论表
conn.execute(
"CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
content_hash TEXT UNIQUE NOT NULL,
url TEXT,
created_at TEXT,
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP,
analyzed INTEGER DEFAULT 0,
sentiment_score REAL,
analyzed_at TEXT
)",
[],
)?;
// 分析历史表
conn.execute(
"CREATE TABLE IF NOT EXISTS analysis_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER,
sentiment_score REAL NOT NULL,
analysis_text TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (comment_id) REFERENCES comments(id)
)",
[],
)?;
// 配置表
conn.execute(
"CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
debug!("数据库表初始化完成");
Ok(())
}
/// 计算内容哈希值用于去重
pub fn hash_content(content: &str) -> String {
let mut hasher = Md5::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
/// 检查评论是否已存在
pub fn is_comment_exists(&self, content_hash: &str) -> Result<bool> {
let conn = self.get_connection()?;
let exists: Option<i64> = conn.query_row(
"SELECT 1 FROM comments WHERE content_hash = ?1",
[content_hash],
|row| row.get(0),
).optional()?;
Ok(exists.is_some())
}
/// 添加单条评论返回评论ID
pub fn add_comment(&self, content: &str, url: Option<&str>) -> Result<Option<i64>> {
let content_hash = Self::hash_content(content);
if self.is_comment_exists(&content_hash)? {
debug!("评论已存在,跳过: {}", &content[..content.len().min(30)]);
return Ok(None);
}
let conn = self.get_connection()?;
let now = Local::now().to_rfc3339();
conn.execute(
"INSERT INTO comments (content, content_hash, url, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![content, content_hash, url, now],
)?;
let comment_id = conn.last_insert_rowid();
info!("添加新评论ID: {}", comment_id);
Ok(Some(comment_id))
}
/// 批量添加评论返回新添加的ID列表
pub fn add_comments_batch(&self, comments: &[(String, Option<String>)]) -> Result<Vec<i64>> {
let mut new_ids = Vec::new();
let conn = self.get_connection()?;
let now = Local::now().to_rfc3339();
for (content, url) in comments {
let content_hash = Self::hash_content(content);
if self.is_comment_exists(&content_hash)? {
debug!("评论已存在,跳过: {}", &content[..content.len().min(30)]);
continue;
}
match conn.execute(
"INSERT OR IGNORE INTO comments (content, content_hash, url, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![content, content_hash, url.as_deref(), now],
) {
Ok(rows) if rows > 0 => {
new_ids.push(conn.last_insert_rowid());
}
Ok(_) => {}
Err(e) => {
warn!("插入评论失败(可能已存在): {}", e);
}
}
}
info!("批量添加评论完成,新增 {} 条", new_ids.len());
Ok(new_ids)
}
/// 获取未分析的评论
pub fn get_unanalyzed_comments(&self, limit: i64) -> Result<Vec<UnanalyzedComment>> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, content, url FROM comments
WHERE analyzed = 0
ORDER BY fetched_at ASC
LIMIT ?1"
)?;
let comments = stmt.query_map([limit], |row| {
Ok(UnanalyzedComment {
id: row.get(0)?,
content: row.get(1)?,
url: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
debug!("获取到 {} 条未分析评论", comments.len());
Ok(comments)
}
/// 标记评论已分析
pub fn mark_analyzed(&self, comment_id: i64, sentiment_score: f64, analysis_text: &str) -> Result<()> {
let conn = self.get_connection()?;
let now = Local::now().to_rfc3339();
// 更新评论状态
conn.execute(
"UPDATE comments
SET analyzed = 1, sentiment_score = ?1, analyzed_at = ?2
WHERE id = ?3",
params![sentiment_score, now, comment_id],
)?;
// 添加分析历史
conn.execute(
"INSERT INTO analysis_history (comment_id, sentiment_score, analysis_text)
VALUES (?1, ?2, ?3)",
params![comment_id, sentiment_score, analysis_text],
)?;
debug!("标记评论 {} 已分析,分数: {}", comment_id, sentiment_score);
Ok(())
}
/// 获取最新的情感分数
pub fn get_latest_sentiment_score(&self) -> Result<Option<f64>> {
let conn = self.get_connection()?;
let score: Option<f64> = conn.query_row(
"SELECT sentiment_score FROM comments
WHERE analyzed = 1 AND sentiment_score IS NOT NULL
ORDER BY analyzed_at DESC
LIMIT 1",
[],
|row| row.get(0),
).optional()?;
debug!("最新情感分数: {:?}", score);
Ok(score)
}
/// 获取已分析的分数,可指定数量限制
pub fn get_all_scores(&self, limit: Option<i64>) -> Result<Vec<f64>> {
let conn = self.get_connection()?;
let scores: Vec<f64> = if let Some(limit) = limit {
let mut stmt = conn.prepare(
"SELECT sentiment_score FROM comments
WHERE analyzed = 1 AND sentiment_score IS NOT NULL
ORDER BY analyzed_at DESC
LIMIT ?1"
)?;
stmt.query_map([limit], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
} else {
let mut stmt = conn.prepare(
"SELECT sentiment_score FROM comments
WHERE analyzed = 1 AND sentiment_score IS NOT NULL
ORDER BY analyzed_at DESC"
)?;
stmt.query_map([], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
};
let valid_scores: Vec<f64> = scores.into_iter().filter(|s| !s.is_nan()).collect();
debug!("获取到 {} 个分数", valid_scores.len());
Ok(valid_scores)
}
/// 获取评论总数
pub fn get_comment_count(&self) -> Result<i64> {
let conn = self.get_connection()?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM comments",
[],
|row| row.get(0),
)?;
debug!("评论总数: {}", count);
Ok(count)
}
/// 获取已分析评论数
pub fn get_analyzed_count(&self) -> Result<i64> {
let conn = self.get_connection()?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM comments WHERE analyzed = 1",
[],
|row| row.get(0),
)?;
debug!("已分析评论数: {}", count);
Ok(count)
}
/// 获取最近的评论
pub fn get_recent_comments(&self, limit: i64) -> Result<Vec<Comment>> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, content, sentiment_score, analyzed_at
FROM comments
ORDER BY fetched_at DESC
LIMIT ?1"
)?;
let comments = stmt.query_map([limit], |row| {
let content: String = row.get(1)?;
let display_content = if content.len() > 50 {
format!("{}...", &content[..50])
} else {
content.clone()
};
Ok(Comment {
id: row.get(0)?,
content: display_content,
content_hash: String::new(),
url: None,
created_at: None,
fetched_at: String::new(),
analyzed: row.get::<_, Option<f64>>(2)?.is_some(),
sentiment_score: row.get(2)?,
analyzed_at: row.get(3)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
debug!("获取到 {} 条最近评论", comments.len());
Ok(comments)
}
}

278
src/gui.rs Normal file
View File

@@ -0,0 +1,278 @@
use eframe::egui;
use std::sync::Arc;
use parking_lot::RwLock;
use tracing::{info, debug};
use crate::config::ConfigManager;
use crate::database::DatabaseManager;
use crate::spider::SpiderManager;
use crate::llm_analyzer::LLMAnalyzer;
/// 应用状态
#[derive(Debug, Clone)]
pub struct AppState {
pub score: i32,
pub label: String,
pub status: String,
pub screenshot_path: Option<String>,
}
impl Default for AppState {
fn default() -> Self {
Self {
score: 50,
label: "中性".to_string(),
status: "等待数据...".to_string(),
screenshot_path: None,
}
}
}
/// 股吧应用主结构
pub struct GubaApp {
config: Arc<RwLock<ConfigManager>>,
db: Arc<RwLock<DatabaseManager>>,
spider: Arc<RwLock<SpiderManager>>,
analyzer: Arc<RwLock<LLMAnalyzer>>,
state: AppState,
show_config: bool,
dragging: bool,
drag_start: egui::Pos2,
window_pos: egui::Pos2,
}
impl GubaApp {
pub fn new(
config: Arc<RwLock<ConfigManager>>,
db: Arc<RwLock<DatabaseManager>>,
spider: Arc<RwLock<SpiderManager>>,
analyzer: Arc<RwLock<LLMAnalyzer>>,
) -> Self {
info!("创建GubaApp");
Self {
config,
db,
spider,
analyzer,
state: AppState::default(),
show_config: false,
dragging: false,
drag_start: egui::Pos2::ZERO,
window_pos: egui::Pos2::new(100.0, 100.0),
}
}
/// 获取分数对应的颜色
fn get_score_color(&self, score: i32) -> egui::Color32 {
if score < 30 {
egui::Color32::from_rgb(21, 101, 192) // 深蓝
} else if score < 39 {
egui::Color32::from_rgb(25, 118, 210) // 蓝色
} else if score < 45 {
egui::Color32::from_rgb(66, 165, 245) // 浅蓝
} else if score < 55 {
egui::Color32::from_rgb(102, 187, 106) // 绿色
} else if score < 65 {
egui::Color32::from_rgb(255, 167, 38) // 橙色
} else if score < 70 {
egui::Color32::from_rgb(251, 140, 0) // 深橙
} else {
egui::Color32::from_rgb(229, 57, 53) // 红色
}
}
/// 获取分数对应的描述
fn get_score_description(&self, score: i32) -> &'static str {
if score < 30 {
"极度悲观"
} else if score < 39 {
"悲观"
} else if score < 45 {
"偏悲观"
} else if score < 55 {
"中立"
} else if score < 65 {
"偏乐观"
} else if score < 70 {
"乐观"
} else {
"极度乐观"
}
}
/// 绘制情感指示灯
fn draw_indicator(&self, ui: &mut egui::Ui, score: i32) {
let size = 120.0;
let (response, painter) = ui.allocate_painter(
egui::vec2(size, size),
egui::Sense::hover(),
);
let center = response.rect.center();
let radius = size / 2.0 - 10.0;
let color = self.get_score_color(score);
// 绘制外圈
painter.circle_stroke(center, radius, egui::Stroke::new(2.0, egui::Color32::from_gray(100)));
// 绘制内圈(渐变效果)
painter.circle_filled(center, radius - 4.0, color);
// 绘制发光效果
for i in 1..=3 {
let alpha = (50 / i) as u8;
let glow_color = egui::Color32::from_rgba_premultiplied(
color.r(),
color.g(),
color.b(),
alpha,
);
painter.circle_filled(center, radius - (i as f32) * 5.0, glow_color);
}
}
/// 绘制配置对话框
fn draw_config_dialog(&mut self, ctx: &egui::Context) {
if !self.show_config {
return;
}
egui::Window::new("配置")
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
ui.label("配置功能待实现");
if ui.button("关闭").clicked() {
self.show_config = false;
}
});
}
}
impl eframe::App for GubaApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// 设置窗口样式
let opacity = self.config.read().ui_config().opacity;
egui::CentralPanel::default()
.frame(egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(255, 255, 255, (opacity * 255.0) as u8))
.rounding(12.0)
)
.show(ctx, |ui| {
// 处理拖拽
let response = ui.interact(
ui.max_rect(),
ui.id().with("drag_area"),
egui::Sense::drag(),
);
if response.drag_started() {
self.dragging = true;
self.drag_start = ctx.input(|i| i.pointer.interact_pos().unwrap_or_default());
}
if response.dragged() {
if let Some(pointer_pos) = ctx.input(|i| i.pointer.interact_pos()) {
let delta = pointer_pos - self.drag_start;
// 更新窗口位置
}
}
if response.drag_released() {
self.dragging = false;
}
// 垂直布局
ui.vertical_centered(|ui| {
ui.add_space(16.0);
// 标题
ui.label(
egui::RichText::new("上证指数 sh000001")
.size(20.0)
.strong()
.color(egui::Color32::from_gray(51))
);
ui.add_space(12.0);
// 情感指示灯
self.draw_indicator(ui, self.state.score);
ui.add_space(12.0);
// 分数显示
ui.label(
egui::RichText::new(self.state.score.to_string())
.size(32.0)
.strong()
.color(self.get_score_color(self.state.score))
);
ui.add_space(4.0);
// 情感标签
let label = if self.state.label.is_empty() {
self.get_score_description(self.state.score).to_string()
} else {
self.state.label.clone()
};
ui.label(
egui::RichText::new(label)
.size(16.0)
.color(egui::Color32::from_gray(102))
);
ui.add_space(12.0);
// 截图显示区域
ui.group(|ui| {
ui.set_min_size(egui::vec2(380.0, 180.0));
ui.set_max_height(200.0);
if let Some(ref path) = self.state.screenshot_path {
ui.label(format!("截图: {}", path));
} else {
ui.label("等待截图...");
}
});
ui.add_space(8.0);
// 状态信息
ui.label(
egui::RichText::new(&self.state.status)
.size(12.0)
.color(egui::Color32::from_gray(153))
);
ui.add_space(16.0);
// 按钮区域
ui.horizontal(|ui| {
if ui.button("刷新").clicked() {
debug!("点击刷新按钮");
// TODO: 触发刷新
}
if ui.button("配置").clicked() {
debug!("点击配置按钮");
self.show_config = true;
}
if ui.button("退出").clicked() {
debug!("点击退出按钮");
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
});
});
// 绘制配置对话框
self.draw_config_dialog(ctx);
}
}

303
src/llm_analyzer.rs Normal file
View File

@@ -0,0 +1,303 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use regex::Regex;
use anyhow::{Result, Context};
use tracing::{info, debug, warn, error};
use crate::config::LlmApiConfig;
/// 分析结果
#[derive(Debug, Clone)]
pub struct AnalysisResult {
pub score: i32,
pub label: String,
pub reasoning: Option<String>,
pub raw_response: String,
}
/// LLM请求消息
#[derive(Debug, Serialize)]
struct Message {
role: String,
content: String,
}
/// LLM请求体
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
temperature: f32,
max_tokens: i32,
}
/// LLM响应选择
#[derive(Debug, Deserialize)]
struct Choice {
message: ResponseMessage,
}
/// LLM响应消息
#[derive(Debug, Deserialize)]
struct ResponseMessage {
content: Option<String>,
#[serde(rename = "reasoning_content")]
reasoning_content: Option<String>,
}
/// LLM响应
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
/// LLM分析器
pub struct LLMAnalyzer {
config: LlmApiConfig,
client: Client,
last_result: Option<AnalysisResult>,
}
impl LLMAnalyzer {
/// 系统提示词
const SYSTEM_PROMPT: &'static str = r#"你是一个专业的情感分析助手。你的任务是分析股吧/论坛评论的情感倾向,判断投资者对该股票的态度。
评分规则:
- 0-30: 极度悲观(利空、暴跌、绝望等情绪)
- 30-39: 悲观(看空、担忧、谨慎等情绪)
- 39-45: 偏悲观(谨慎观望、保守等情绪)
- 45-55: 中立(观望、客观等情绪)
- 55-65: 偏乐观(看好、希望等情绪)
- 65-70: 乐观(看涨、信心等情绪)
- 70-100: 极度乐观(利好、暴涨、兴奋等情绪)
请直接输出一个JSON格式的结果包含两个字段
- score: 0-100的整数评分
- label: 简短的态度描述(如"极度悲观"、"悲观"、"偏悲观"、"中立"、"偏乐观"、"乐观"、"极度乐观"
注意:
1. 只返回JSON不要有其他文字
2. 如果无法判断返回50和"无法判断"
3. 分析要客观,不要被表面文字迷惑"#;
/// 创建新的LLM分析器
pub fn new(config: LlmApiConfig) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout))
.build()
.expect("创建HTTP客户端失败");
info!(
"LLM分析器配置 - base_url: {}, model: {}, timeout: {}s, retry: {}次",
config.base_url, config.model, config.timeout, config.retry_times
);
if config.api_key.is_empty() {
warn!("LLM API 未配置api_key 为空");
}
Self {
config,
client,
last_result: None,
}
}
/// 分析单条评论
pub async fn analyze(&mut self, comment: &str) -> Result<(Option<i32>, String)> {
if self.config.api_key.is_empty() {
error!("LLM客户端未初始化请检查API配置");
return Ok((None, "LLM未配置".to_string()));
}
if comment.trim().is_empty() {
warn!("评论内容为空");
return Ok((None, "评论为空".to_string()));
}
debug!("开始分析评论: {}", &comment[..comment.len().min(50)]);
debug!("使用模型: {}, 超时设置: {}秒", self.config.model, self.config.timeout);
for attempt in 0..self.config.retry_times {
info!("API调用尝试 {}/{}", attempt + 1, self.config.retry_times);
match self.call_api(comment).await {
Ok((score, label, reasoning, raw_response)) => {
// 保存最后结果
self.last_result = Some(AnalysisResult {
score: score.unwrap_or(50),
label: label.clone(),
reasoning,
raw_response,
});
if score.is_some() {
info!("分析完成: {}分 - {}", score.unwrap(), label);
return Ok((score, label));
}
}
Err(e) => {
warn!("API调用失败 (尝试 {}/{}): {}", attempt + 1, self.config.retry_times, e);
if attempt < self.config.retry_times - 1 {
let wait_time = 2u64.pow(attempt);
info!("等待 {} 秒后重试...", wait_time);
tokio::time::sleep(Duration::from_secs(wait_time)).await;
}
}
}
}
error!("所有 {} 次重试均失败", self.config.retry_times);
Ok((None, "分析失败".to_string()))
}
/// 调用LLM API
async fn call_api(&self, comment: &str) -> Result<(Option<i32>, String, Option<String>, String)> {
let url = format!("{}/chat/completions", self.config.base_url);
let request_body = ChatCompletionRequest {
model: self.config.model.clone(),
messages: vec![
Message {
role: "system".to_string(),
content: Self::SYSTEM_PROMPT.to_string(),
},
Message {
role: "user".to_string(),
content: format!("请分析以下评论的情感倾向:\n\n{}", comment),
},
],
temperature: 0.3,
max_tokens: 500,
};
debug!("发送请求到LLM API: {}", url);
let response = self.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.config.api_key))
.header("Content-Type", "application/json")
.json(&request_body)
.send()
.await
.context("发送API请求失败")?;
let response_text = response.text().await?;
debug!("API返回原始内容: {}", &response_text[..response_text.len().min(100)]);
// 解析响应
let chat_response: ChatCompletionResponse = serde_json::from_str(&response_text)
.context("解析API响应失败")?;
if let Some(choice) = chat_response.choices.first() {
let message = &choice.message;
let result_text = message.content.clone().unwrap_or_default();
let reasoning = message.reasoning_content.clone();
if let Some(ref r) = reasoning {
debug!("推理过程: {}", &r[..r.len().min(100)]);
}
let (score, label) = self.parse_response(&result_text);
return Ok((score, label, reasoning, result_text));
}
Err(anyhow::anyhow!("API响应中没有选择项"))
}
/// 解析LLM返回的结果
fn parse_response(&self, response: &str) -> (Option<i32>, String) {
// 尝试直接解析JSON
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response) {
if let Some(score) = json_value.get("score").and_then(|v| v.as_i64()) {
let score = score.clamp(0, 100) as i32;
let label = json_value
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("无法判断")
.to_string();
debug!("JSON解析成功: {} - {}", score, label);
return (Some(score), label);
}
}
debug!("JSON解析失败尝试文本提取");
// 尝试从文本中提取数字
let re = Regex::new(r"\b(\d{1,3})\b").unwrap();
if let Some(caps) = re.captures(response) {
if let Some(num_match) = caps.get(1) {
let score: i32 = num_match.as_str().parse().unwrap_or(50);
let score = score.clamp(0, 100);
// 提取标签
let label_re = Regex::new(r#""([^"]+)""#).unwrap();
let label = if let Some(label_caps) = label_re.captures(response) {
label_caps.get(1).map(|m| m.as_str().to_string())
} else {
response.lines().next().map(|l| l[..l.len().min(20)].to_string())
}.unwrap_or_else(|| "无法判断".to_string());
debug!("文本提取成功: {} - {}", score, label);
return (Some(score), label);
}
}
warn!("无法解析响应");
(None, "解析失败".to_string())
}
/// 获取最后一次分析结果
pub fn get_last_result(&self) -> Option<AnalysisResult> {
self.last_result.clone()
}
/// 批量分析评论
pub async fn analyze_batch(&mut self, comments: &[String], delay_secs: f64) -> Result<Vec<(String, Option<i32>, String)>> {
info!("开始批量分析 {} 条评论,每次间隔 {} 秒", comments.len(), delay_secs);
let mut results = Vec::new();
let mut success_count = 0;
let mut fail_count = 0;
for (i, comment) in comments.iter().enumerate() {
info!("正在分析第 {}/{} 条评论", i + 1, comments.len());
let (score, label) = self.analyze(comment).await?;
if score.is_some() {
success_count += 1;
debug!("第 {} 条评论分析成功: {}分 - {}", i + 1, score.unwrap(), label);
} else {
fail_count += 1;
warn!("第 {} 条评论分析失败: {}", i + 1, label);
}
results.push((comment.clone(), score, label));
if delay_secs > 0.0 && i < comments.len() - 1 {
debug!("等待 {} 秒后继续...", delay_secs);
tokio::time::sleep(Duration::from_secs_f64(delay_secs)).await;
}
}
info!("批量分析完成,成功 {} 条,失败 {} 条", success_count, fail_count);
Ok(results)
}
/// 检查是否已配置
pub fn is_configured(&self) -> bool {
!self.config.api_key.is_empty()
}
/// 更新配置
pub fn update_config(&mut self, config: LlmApiConfig) {
self.config = config;
// 重新创建客户端
self.client = Client::builder()
.timeout(Duration::from_secs(self.config.timeout))
.build()
.expect("创建HTTP客户端失败");
}
}

79
src/main.rs Normal file
View File

@@ -0,0 +1,79 @@
mod config;
mod database;
mod spider;
mod llm_analyzer;
mod gui;
mod waveform;
mod screenshot;
mod worker;
use std::sync::Arc;
use parking_lot::RwLock;
use tracing::{info, error};
use anyhow::Result;
use config::ConfigManager;
use database::DatabaseManager;
use spider::SpiderManager;
use llm_analyzer::LLMAnalyzer;
use worker::BackendWorker;
#[tokio::main]
async fn main() -> Result<()> {
// 初始化日志
tracing_subscriber::fmt()
.with_env_filter("info")
.with_target(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.init();
info!("=== 股吧人气指示器启动 ===");
// 加载配置
info!("加载配置文件...");
let config = Arc::new(RwLock::new(ConfigManager::new("config.json")?));
// 初始化数据库
info!("初始化数据库...");
let db_path = config.read().database_config().path.clone();
let db = Arc::new(RwLock::new(DatabaseManager::new(&db_path)?));
// 初始化爬虫
info!("初始化爬虫...");
let spider_config = config.read().spider_config().clone();
let spider = Arc::new(RwLock::new(SpiderManager::new(spider_config)));
// 初始化LLM分析器
info!("初始化LLM分析器...");
let llm_config = config.read().llm_api_config().clone();
let analyzer = Arc::new(RwLock::new(LLMAnalyzer::new(llm_config)));
// 启动GUI
info!("启动GUI...");
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 600.0])
.with_always_on_top()
.with_decorations(false)
.with_transparent(true),
..Default::default()
};
let app = gui::GubaApp::new(
config.clone(),
db.clone(),
spider.clone(),
analyzer.clone(),
);
eframe::run_native(
"股吧人气指示器",
options,
Box::new(|_cc| Ok(Box::new(app))),
)?;
info!("=== 股吧人气指示器关闭 ===");
Ok(())
}

125
src/screenshot.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::path::{Path, PathBuf};
use std::fs;
use chrono::Local;
use anyhow::{Result, Context};
use tracing::{info, debug, warn, error};
/// 截图管理器
pub struct ScreenshotManager {
screenshot_dir: PathBuf,
target_url: String,
}
impl ScreenshotManager {
/// 创建新的截图管理器
pub fn new(screenshot_dir: &str) -> Result<Self> {
// 确定截图目录的正确路径
let exe_path = std::env::current_exe()?;
let exe_dir = exe_path.parent()
.context("无法获取可执行文件目录")?;
let screenshot_dir = exe_dir.join(screenshot_dir);
// 创建截图目录
fs::create_dir_all(&screenshot_dir)?;
info!("截图管理器初始化完成,截图目录: {:?}", screenshot_dir);
Ok(Self {
screenshot_dir,
target_url: "https://www.sse.com.cn/".to_string(),
})
}
/// 获取最新的截图文件路径
pub fn get_latest_screenshot(&self) -> Option<PathBuf> {
if !self.screenshot_dir.exists() {
return None;
}
let mut screenshot_files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
if let Ok(entries) = fs::read_dir(&self.screenshot_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(filename) = path.file_name() {
let filename = filename.to_string_lossy();
if filename.starts_with("sse_chart_") && filename.ends_with(".png") {
if let Ok(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
screenshot_files.push((path, modified));
}
}
}
}
}
}
if screenshot_files.is_empty() {
return None;
}
// 按修改时间排序,获取最新的文件
screenshot_files.sort_by(|a, b| b.1.cmp(&a.1));
Some(screenshot_files[0].0.clone())
}
/// 清理旧的截图文件
pub fn cleanup_old_screenshots(&self, keep_count: usize) -> Result<()> {
if !self.screenshot_dir.exists() {
return Ok(());
}
let mut screenshot_files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
if let Ok(entries) = fs::read_dir(&self.screenshot_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(filename) = path.file_name() {
let filename = filename.to_string_lossy();
if filename.starts_with("sse_chart_") && filename.ends_with(".png") {
if let Ok(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
screenshot_files.push((path, modified));
}
}
}
}
}
}
if screenshot_files.len() <= keep_count {
return Ok(());
}
// 按修改时间排序,删除旧的文件
screenshot_files.sort_by(|a, b| a.1.cmp(&b.1));
for (path, _) in screenshot_files.iter().take(screenshot_files.len() - keep_count) {
if let Err(e) = fs::remove_file(path) {
error!("删除截图文件失败 {:?}: {}", path, e);
} else {
info!("删除旧截图: {:?}", path);
}
}
Ok(())
}
/// 获取截图目录路径
pub fn get_screenshot_dir(&self) -> &Path {
&self.screenshot_dir
}
/// 生成截图文件名
pub fn generate_screenshot_path(&self) -> PathBuf {
let timestamp = Local::now().format("%Y%m%d_%H%M%S");
self.screenshot_dir.join(format!("sse_chart_{}.png", timestamp))
}
}
impl Default for ScreenshotManager {
fn default() -> Self {
Self::new("screenshots").expect("创建截图管理器失败")
}
}

296
src/spider.rs Normal file
View File

@@ -0,0 +1,296 @@
use reqwest::{Client, header};
use scraper::{Html, Selector};
use regex::Regex;
use std::time::Duration;
use anyhow::{Result, Context};
use tracing::{info, debug, warn, error};
use crate::config::SpiderConfig;
/// 评论数据结构
#[derive(Debug, Clone)]
pub struct Comment {
pub content: String,
pub url: String,
}
/// 股票数据结构
#[derive(Debug, Clone)]
pub struct StockData {
pub time: String,
pub value: f64,
}
/// 爬虫管理器
pub struct SpiderManager {
config: SpiderConfig,
client: Client,
}
impl SpiderManager {
/// 创建新的爬虫管理器
pub fn new(config: SpiderConfig) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(&config.user_agent)
.build()
.expect("创建HTTP客户端失败");
info!("爬虫管理器初始化完成目标URL: {}", config.target_url);
Self {
config,
client,
}
}
/// 抓取网页评论
pub async fn fetch(&self, url: Option<&str>, xpath: Option<&str>) -> Result<Vec<Comment>> {
let target_url = url.unwrap_or(&self.config.target_url);
let target_xpath = xpath.unwrap_or(&self.config.xpath);
if target_url.is_empty() {
warn!("未设置目标URL");
return Ok(Vec::new());
}
info!("开始抓取: {}", target_url);
let html = self.fetch_with_retry(target_url).await?;
if html.is_empty() {
warn!("网页获取失败");
return Ok(Vec::new());
}
let comments = self.parse_comments(&html, target_xpath, target_url).await?;
info!("解析完成,获取到 {} 条评论", comments.len());
Ok(comments)
}
/// 带重试的网页获取
async fn fetch_with_retry(&self, url: &str) -> Result<String> {
let max_retries = self.config.retry_times as usize;
for attempt in 0..max_retries {
debug!("尝试 {}/{} 获取网页", attempt + 1, max_retries);
match self.client.get(url).send().await {
Ok(response) => {
match response.text().await {
Ok(text) => {
debug!("网页获取成功,大小: {} 字节", text.len());
return Ok(text);
}
Err(e) => {
warn!("读取响应失败 (尝试 {}/{}): {}", attempt + 1, max_retries, e);
}
}
}
Err(e) => {
warn!("请求失败 (尝试 {}/{}): {}", attempt + 1, max_retries, e);
}
}
if attempt < max_retries - 1 {
let wait_secs = self.config.retry_interval + (rand::random::<f64>() * 2.0) as u64;
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
}
}
error!("所有重试均失败: {}", url);
Ok(String::new())
}
/// 获取页面标题
pub async fn get_page_title(&self, url: Option<&str>) -> Result<String> {
let target_url = url.unwrap_or(&self.config.target_url);
if target_url.is_empty() {
warn!("未设置目标URL");
return Ok(String::new());
}
info!("获取页面标题: {}", target_url);
let html = self.fetch_with_retry(target_url).await?;
if html.is_empty() {
warn!("网页获取失败");
return Ok(String::new());
}
// 使用正则表达式提取标题
let re = Regex::new(r"<title[^>]*>([^<]*)</title>").unwrap();
if let Some(caps) = re.captures(&html) {
let title = caps.get(1).map(|m| m.as_str().trim().to_string())
.unwrap_or_default();
info!("获取到页面标题: {}", title);
return Ok(title);
}
warn!("未找到页面标题");
Ok(String::new())
}
/// 解析评论
async fn parse_comments(&self, html: &str, xpath: &str, base_url: &str) -> Result<Vec<Comment>> {
let mut comments = Vec::new();
// 使用 scraper 解析 HTML
let document = Html::parse_document(html);
// 将 XPath 转换为 CSS 选择器(简化处理)
// 注意:这里使用简化的 XPath 处理,实际项目中可能需要更复杂的转换
let selector_str = if xpath.contains("contains(@class") {
// 处理类似 //a[contains(@class, 'linkblack')] 的 XPath
let re = Regex::new(r"contains\(@class,\s*['\"]([^'\"]+)['\"]\)").unwrap();
if let Some(caps) = re.captures(xpath) {
let class_name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
format!(".{}", class_name)
} else {
"a".to_string()
}
} else {
"a".to_string()
};
let selector = Selector::parse(&selector_str)
.map_err(|e| anyhow::anyhow!("无效的选择器: {:?}", e))?;
for element in document.select(&selector) {
let text = element.text().collect::<String>().trim().to_string();
if !text.is_empty() {
let href = element.value().attr("href").unwrap_or(base_url);
let full_url = if href.starts_with("http") {
href.to_string()
} else {
format!("{}{}", base_url.trim_end_matches('/'), href)
};
comments.push(Comment {
content: text,
url: full_url,
});
}
}
// 如果 XPath 解析失败,使用备选解析方法
if comments.is_empty() {
comments = self.fallback_parse(html, base_url).await?;
}
Ok(comments)
}
/// 备选解析方法
async fn fallback_parse(&self, html: &str, base_url: &str) -> Result<Vec<Comment>> {
let mut comments = Vec::new();
debug!("使用备选解析方法");
let document = Html::parse_document(html);
// 尝试查找常见的评论元素
let selectors = [
"a.linkblack",
"div.content",
"p.content",
"span.content",
".comment",
".post",
];
for selector_str in &selectors {
if let Ok(selector) = Selector::parse(selector_str) {
for element in document.select(&selector).take(50) {
let text = element.text().collect::<String>().trim().to_string();
if text.len() > 5 {
comments.push(Comment {
content: text,
url: base_url.to_string(),
});
}
}
if !comments.is_empty() {
debug!("备选解析获取到 {} 条评论", comments.len());
break;
}
}
}
Ok(comments)
}
/// 获取上证指数数据通过新浪财经JS接口
pub async fn fetch_sse_stock_data(&self) -> Result<StockData> {
let sse_url = "https://hq.sinajs.cn/list=sh000001";
let headers = {
let mut headers = header::HeaderMap::new();
headers.insert(
header::REFERER,
header::HeaderValue::from_static("https://finance.sina.com.cn/"),
);
headers
};
info!("通过新浪JS接口获取上证指数数据: {}", sse_url);
let response = self.client
.get(sse_url)
.headers(headers)
.send()
.await
.context("请求新浪JS接口失败")?;
let content = response.text().await?;
debug!("获取到响应: {}", content);
// 解析响应
let re = Regex::new(r#"var hq_str_sh000001="([^"]+)""#).unwrap();
let caps = re.captures(&content)
.context("未能解析新浪JS接口返回数据")?;
let data_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let data_fields: Vec<&str> = data_str.split(',').collect();
if data_fields.len() < 32 {
return Err(anyhow::anyhow!("数据字段不足: {}", data_fields.len()));
}
let stock_name = data_fields[0];
let current_price: f64 = data_fields[3].parse()
.context("解析当前价格失败")?;
let now = chrono::Local::now();
let current_time = now.format("%H:%M").to_string();
info!("成功获取 {}: {} (时间: {})", stock_name, current_price, current_time);
Ok(StockData {
time: current_time,
value: current_price,
})
}
/// 更新User-Agent
pub fn set_user_agent(&mut self, user_agent: &str) {
self.config.user_agent = user_agent.to_string();
// 重新创建客户端
self.client = Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(user_agent)
.build()
.expect("创建HTTP客户端失败");
}
/// 更新配置
pub fn update_config(&mut self, config: SpiderConfig) {
if !config.user_agent.is_empty() && config.user_agent != self.config.user_agent {
self.set_user_agent(&config.user_agent);
}
self.config = config;
}
/// 获取爬取间隔
pub fn get_fetch_interval(&self) -> u64 {
self.config.fetch_interval
}
}

272
src/waveform.rs Normal file
View File

@@ -0,0 +1,272 @@
use eframe::egui;
use chrono::{NaiveTime, Local, Timelike};
use tracing::{info, debug, error};
/// 波形图数据点
#[derive(Debug, Clone)]
pub struct DataPoint {
pub time: String,
pub value: f64,
}
/// 波形图组件
pub struct WaveformWidget {
data_points: Vec<DataPoint>,
base_value: f64,
}
impl WaveformWidget {
/// 创建新的波形图组件
pub fn new() -> Self {
info!("WaveformWidget 初始化完成");
Self {
data_points: Vec::new(),
base_value: 0.0,
}
}
/// 添加数据点
pub fn add_data_point(&mut self, time_str: &str, value: f64) {
// 如果是第一个数据点,设置基准值
if self.data_points.is_empty() {
self.base_value = value;
info!("设置基准值: {}", self.base_value);
}
self.data_points.push(DataPoint {
time: time_str.to_string(),
value,
});
info!("添加数据点: 时间={}, 值={}", time_str, value);
// 限制数据点数量,避免内存过大
if self.data_points.len() > 100 {
self.data_points = self.data_points.split_off(self.data_points.len() - 100);
}
}
/// 清除所有数据
pub fn clear_data(&mut self) {
self.data_points.clear();
self.base_value = 0.0;
info!("波形图数据已清除");
}
/// 将时间转换为X轴位置
/// 时间折算关系:
/// - 9:30 -> 最左侧 (x=0)
/// - 11:30 -> 中间 (x=total_width/2)
/// - 13:00 -> 中间 (x=total_width/2)
/// - 15:00 -> 最右侧 (x=total_width)
fn time_to_x_position(&self, time_str: &str, total_width: f32) -> f32 {
let time = match NaiveTime::parse_from_str(time_str, "%H:%M") {
Ok(t) => t,
Err(e) => {
error!("时间转换错误: {}, 错误: {}", time_str, e);
return total_width / 2.0;
}
};
// 定义关键时间点
let market_start = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
let market_mid1 = NaiveTime::from_hms_opt(11, 30, 0).unwrap();
let market_mid2 = NaiveTime::from_hms_opt(13, 0, 0).unwrap();
let market_end = NaiveTime::from_hms_opt(15, 0, 0).unwrap();
// 计算总交易时间(分钟)
let morning_duration = (market_mid1.hour() - market_start.hour()) as i64 * 60
+ (market_mid1.minute() - market_start.minute()) as i64;
let afternoon_duration = (market_end.hour() - market_mid2.hour()) as i64 * 60
+ (market_end.minute() - market_mid2.minute()) as i64;
// 计算当前时间相对于开盘时间的分钟数
let x_ratio = if time <= market_mid1 {
// 上午交易时段
let minutes_from_start = (time.hour() - market_start.hour()) as i64 * 60
+ (time.minute() - market_start.minute()) as i64;
// 上午时段占一半宽度
minutes_from_start as f32 / morning_duration as f32 * 0.5
} else if time >= market_mid2 {
// 下午交易时段
let minutes_from_start = (time.hour() - market_mid2.hour()) as i64 * 60
+ (time.minute() - market_mid2.minute()) as i64;
// 下午时段占一半宽度,从中间开始
0.5 + minutes_from_start as f32 / afternoon_duration as f32 * 0.5
} else {
// 午休时间,统一放在中间
0.5
};
x_ratio * total_width
}
/// 绘制波形图
pub fn draw(&self, ui: &mut egui::Ui) {
let available_size = ui.available_size();
let (response, painter) = ui.allocate_painter(
available_size,
egui::Sense::hover(),
);
let rect = response.rect;
let width = rect.width();
let height = rect.height();
// 绘制背景
painter.rect_filled(rect, 0.0, egui::Color32::from_rgb(30, 30, 30));
// 如果没有数据点,显示提示信息
if self.data_points.is_empty() {
self.draw_no_data_message(&painter, rect);
return;
}
// 绘制网格和坐标轴
self.draw_grid(&painter, rect);
// 绘制波形线
self.draw_waveform(&painter, rect);
// 绘制数据点
self.draw_data_points(&painter, rect);
}
/// 绘制网格和坐标轴
fn draw_grid(&self, painter: &egui::Painter, rect: egui::Rect) {
let width = rect.width();
let height = rect.height();
// 设置网格颜色
let grid_color = egui::Color32::from_rgb(100, 100, 100);
// 绘制水平网格线
for i in 1..5 {
let y = height * i as f32 / 5.0;
painter.line_segment(
[egui::pos2(rect.left(), rect.top() + y), egui::pos2(rect.right(), rect.top() + y)],
egui::Stroke::new(1.0, grid_color),
);
}
// 绘制垂直网格线(时间刻度)
let time_points = ["9:30", "10:30", "11:30", "13:00", "14:00", "15:00"];
for time_str in &time_points {
let x = self.time_to_x_position(time_str, width);
painter.line_segment(
[egui::pos2(rect.left() + x, rect.top()), egui::pos2(rect.left() + x, rect.bottom())],
egui::Stroke::new(1.0, grid_color),
);
// 绘制时间标签
painter.text(
egui::pos2(rect.left() + x - 20.0, rect.bottom() - 5.0),
egui::Align2::LEFT_BOTTOM,
*time_str,
egui::FontId::proportional(10.0),
egui::Color32::from_rgb(150, 150, 150),
);
}
// 绘制坐标轴
let axis_color = egui::Color32::from_rgb(200, 200, 200);
painter.line_segment(
[egui::pos2(rect.left(), rect.top() + height / 2.0), egui::pos2(rect.right(), rect.top() + height / 2.0)],
egui::Stroke::new(2.0, axis_color),
);
painter.line_segment(
[egui::pos2(rect.left(), rect.top()), egui::pos2(rect.left(), rect.bottom())],
egui::Stroke::new(2.0, axis_color),
);
}
/// 绘制波形线
fn draw_waveform(&self, painter: &egui::Painter, rect: egui::Rect) {
if self.data_points.len() < 2 {
return;
}
let width = rect.width();
let height = rect.height();
// 设置波形线样式
let waveform_color = egui::Color32::from_rgb(0, 200, 255);
// 计算Y轴范围基准值±100点
let y_min = self.base_value - 100.0;
let y_max = self.base_value + 100.0;
let y_range = y_max - y_min;
let mut points = Vec::new();
for point in &self.data_points {
let x = rect.left() + self.time_to_x_position(&point.time, width);
// 计算Y坐标从底部到顶部
let y_ratio = (point.value - y_min) / y_range;
let y = rect.bottom() - (y_ratio as f32 * height);
points.push(egui::pos2(x, y));
}
// 绘制折线
for i in 0..points.len() - 1 {
painter.line_segment(
[points[i], points[i + 1]],
egui::Stroke::new(3.0, waveform_color),
);
}
}
/// 绘制数据点
fn draw_data_points(&self, painter: &egui::Painter, rect: egui::Rect) {
let width = rect.width();
let height = rect.height();
// 设置数据点颜色
let point_color = egui::Color32::from_rgb(255, 100, 100);
// 计算Y轴范围
let y_min = self.base_value - 100.0;
let y_max = self.base_value + 100.0;
let y_range = y_max - y_min;
for point in &self.data_points {
let x = rect.left() + self.time_to_x_position(&point.time, width);
let y_ratio = (point.value - y_min) / y_range;
let y = rect.bottom() - (y_ratio as f32 * height);
// 绘制数据点圆圈
painter.circle_filled(egui::pos2(x, y), 3.0, point_color);
// 显示数值标签
painter.text(
egui::pos2(x + 5.0, y - 5.0),
egui::Align2::LEFT_BOTTOM,
format!("{:.2}", point.value),
egui::FontId::proportional(10.0),
egui::Color32::from_rgb(200, 200, 200),
);
}
}
/// 绘制无数据提示信息
fn draw_no_data_message(&self, painter: &egui::Painter, rect: egui::Rect) {
let center = rect.center();
painter.text(
center,
egui::Align2::CENTER_CENTER,
"等待数据...",
egui::FontId::proportional(20.0),
egui::Color32::from_rgb(150, 150, 150),
);
}
}
impl Default for WaveformWidget {
fn default() -> Self {
Self::new()
}
}

262
src/worker.rs Normal file
View File

@@ -0,0 +1,262 @@
use std::sync::Arc;
use parking_lot::RwLock;
use tokio::time::{interval, Duration, Interval};
use tracing::{info, debug, warn, error};
use anyhow::Result;
use crate::config::ConfigManager;
use crate::database::DatabaseManager;
use crate::spider::{SpiderManager, Comment};
use crate::llm_analyzer::LLMAnalyzer;
/// 后台工作器 - 处理爬取和分析任务
pub struct BackendWorker {
config: Arc<RwLock<ConfigManager>>,
db: Arc<RwLock<DatabaseManager>>,
spider: Arc<RwLock<SpiderManager>>,
analyzer: Arc<RwLock<LLMAnalyzer>>,
running: bool,
last_fetch_time: std::time::Instant,
fetch_interval: u64,
no_new_content_count: u32,
is_running_cycle: bool,
fetch_count: u64,
analysis_count: u64,
}
impl BackendWorker {
/// 创建新的后台工作器
pub fn new(
config: Arc<RwLock<ConfigManager>>,
db: Arc<RwLock<DatabaseManager>>,
spider: Arc<RwLock<SpiderManager>>,
analyzer: Arc<RwLock<LLMAnalyzer>>,
) -> Self {
let fetch_interval = config.read().spider_config().fetch_interval;
info!("BackendWorker 初始化完成");
Self {
config,
db,
spider,
analyzer,
running: false,
last_fetch_time: std::time::Instant::now(),
fetch_interval,
no_new_content_count: 0,
is_running_cycle: false,
fetch_count: 0,
analysis_count: 0,
}
}
/// 启动后台任务
pub async fn start(&mut self) {
self.running = true;
info!("后台任务已启动");
// 延迟1秒后执行第一次任务
tokio::time::sleep(Duration::from_secs(1)).await;
self.run_cycle().await;
}
/// 停止后台任务
pub fn stop(&mut self) {
self.running = false;
info!("后台任务已停止");
// 输出统计信息
info!("=== 程序运行统计 ===");
info!("爬取网站次数: {}", self.fetch_count);
info!("提交API分析次数: {}", self.analysis_count);
info!("=== 统计结束 ===");
}
/// 运行一个周期
async fn run_cycle(&mut self) {
if self.is_running_cycle {
debug!("上一个周期仍在执行,跳过本次");
return;
}
self.is_running_cycle = true;
if !self.running {
self.is_running_cycle = false;
return;
}
if let Err(e) = self.execute_cycle().await {
error!("运行错误: {}", e);
}
// 安排下一次执行
if self.running {
let interval_secs = self.fetch_interval * (1 + std::cmp::min(self.no_new_content_count, 4) as u64);
debug!("下次执行将在 {} 秒后", interval_secs);
tokio::time::sleep(Duration::from_secs(interval_secs)).await;
self.is_running_cycle = false;
self.run_cycle().await;
} else {
self.is_running_cycle = false;
}
}
/// 执行周期任务
async fn execute_cycle(&mut self) -> Result<()> {
// 1. 爬取评论
info!("开始爬取评论");
self.fetch_count += 1;
let comments = {
let spider = self.spider.read();
spider.fetch(None, None).await?
};
if comments.is_empty() {
// 爬取失败,使用固定间隔重试
info!("未获取到新评论15秒后重试");
return Ok(());
}
// 2. 写入数据库
info!("获取到 {} 条评论", comments.len());
let comments_for_db: Vec<(String, Option<String>)> = comments
.iter()
.map(|c| (c.content.clone(), Some(c.url.clone())))
.collect();
let new_ids = {
let db = self.db.read();
db.add_comments_batch(&comments_for_db)?
};
if !new_ids.is_empty() {
self.no_new_content_count = 0;
info!("新增 {} 条评论到数据库", new_ids.len());
// 3. 获取未分析评论并分析
let unanalyzed = {
let db = self.db.read();
db.get_unanalyzed_comments(10)?
};
info!("获取到 {} 条未分析评论", unanalyzed.len());
if !unanalyzed.is_empty() {
self.analyze_comments(&unanalyzed).await?;
}
} else {
self.no_new_content_count += 1;
info!("评论已存在,未新增");
}
// 4. 更新指示器
self.update_indicator().await?;
Ok(())
}
/// 分析评论
async fn analyze_comments(&mut self, comments: &[crate::database::UnanalyzedComment]) -> Result<()> {
info!("开始分析 {} 条评论", comments.len());
for (i, comment) in comments.iter().enumerate() {
if !self.running {
warn!("分析被中断");
break;
}
debug!("分析第 {} 条评论: {}...", i + 1, &comment.content[..comment.content.len().min(50)]);
self.analysis_count += 1;
let (score, label) = {
let mut analyzer = self.analyzer.write();
analyzer.analyze(&comment.content).await?
};
if let Some(score) = score {
let db = self.db.read();
db.mark_analyzed(comment.id, score as f64, &label)?;
info!("评论 {} 分析完成: {}分 - {}", comment.id, score, label);
// 每条评论分析完成后立即更新指示器
self.update_indicator().await?;
// 延迟避免API限流
tokio::time::sleep(Duration::from_secs_f64(1.0)).await;
} else {
let db = self.db.read();
db.mark_analyzed(comment.id, 50.0, "无法判断")?;
warn!("评论 {} 无法判断", comment.id);
}
}
Ok(())
}
/// 更新指示器显示
async fn update_indicator(&self) -> Result<()> {
// 获取最新的100条评论的分数
let scores = {
let db = self.db.read();
db.get_all_scores(Some(100))?
};
if scores.is_empty() {
debug!("暂无分析分数");
return Ok(());
}
// 计算平均分
let avg_score: f64 = scores.iter().sum::<f64>() / scores.len() as f64;
info!("当前平均分: {:.2} (基于最新的 {} 条评论)", avg_score, scores.len());
// 根据阈值确定标签
let thresholds = self.config.read().ui_config().thresholds.clone();
let label = if avg_score < thresholds.cold as f64 {
"看跌"
} else if avg_score > thresholds.warm as f64 {
"看涨"
} else {
"中性"
};
info!("情感倾向: {}", label);
// TODO: 发送信号到GUI更新显示
Ok(())
}
/// 手动刷新
pub async fn manual_refresh(&mut self) {
info!("用户手动刷新");
self.no_new_content_count = 0;
if !self.is_running_cycle {
self.run_cycle().await;
} else {
info!("上一个周期仍在执行,跳过手动刷新");
}
}
/// 更新爬取间隔
pub fn update_fetch_interval(&mut self, interval: u64) {
info!("更新爬取间隔: {} 秒", interval);
self.fetch_interval = interval;
}
/// 获取爬取次数
pub fn get_fetch_count(&self) -> u64 {
self.fetch_count
}
/// 获取分析次数
pub fn get_analysis_count(&self) -> u64 {
self.analysis_count
}
}