chore: 更新Rust构建缓存和依赖文件
This commit is contained in:
348
src/config.rs
Normal file
348
src/config.rs
Normal 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
334
src/database.rs
Normal 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
278
src/gui.rs
Normal 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
303
src/llm_analyzer.rs
Normal 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
79
src/main.rs
Normal 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
125
src/screenshot.rs
Normal 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
296
src/spider.rs
Normal 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
272
src/waveform.rs
Normal 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
262
src/worker.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user