feat: 新增Rust项目基础框架和核心功能模块

- 添加Cargo.toml配置文件,定义项目元信息和依赖项
- 实现配置管理模块(ConfigManager),支持JSON配置读写
- 添加爬虫模块(SpiderManager),支持网页内容抓取和解析
- 实现数据库模块(DatabaseManager),使用SQLite存储评论数据
- 添加LLM分析模块(LLMAnalyzer),支持调用AI接口进行情绪分析
- 实现UI界面模块,包含指标显示和波形图绘制功能
- 添加项目文档和截图资源
This commit is contained in:
2026-02-27 17:03:32 +08:00
parent 9096a38ad2
commit b35e235682
41 changed files with 6592 additions and 0 deletions

8
rust/.cargo/config.toml Normal file
View File

@@ -0,0 +1,8 @@
[source.crates-io]
replace-with = "ustc"
[source.ustc]
registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"
[http]
check-revoke = false

5346
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,42 @@
[package]
name = "guba"
version = "0.1.0"
edition = "2021"
authors = ["Guba Developer"]
description = "股吧人气指示器 - 基于Rust的情感分析工具"
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rusqlite = { version = "0.32", features = ["bundled"] }
log = "0.4"
env_logger = "0.11"
chrono = { version = "0.4", features = ["serde"] }
sha2 = "0.10"
hex = "0.4"
regex = "1.10"
thiserror = "2.0"
once_cell = "1.19"
parking_lot = "0.12"
dirs = "6.0"
[dependencies.egui]
version = "0.29"
features = ["default", "persistence"]
[dependencies.eframe]
version = "0.29"
default-features = false
features = ["default", "glow"]
[build-dependencies]
[features]
default = []
[profile.release]
lto = true
opt-level = "z"
strip = true

182
rust/src/analyzer.rs Normal file
View File

@@ -0,0 +1,182 @@
use crate::config::LlmApiConfig;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AnalyzerError {
#[error("Request failed: {0}")]
RequestError(#[from] reqwest::Error),
#[error("API error: {0}")]
ApiError(String),
#[error("Parse error: {0}")]
ParseError(String),
#[error("No API key configured")]
NoApiKey,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisResponse {
pub score: Option<i32>,
pub label: Option<String>,
pub reasoning: Option<String>,
}
pub struct LLMAnalyzer {
config: LlmApiConfig,
client: Client,
last_result: Option<AnalysisResponse>,
}
impl LLMAnalyzer {
pub fn new(config: LlmApiConfig) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout))
.build()
.expect("Failed to create HTTP client");
Self {
config,
client,
last_result: None,
}
}
pub fn analyze(&mut self, content: &str) -> Result<(i32, String), AnalyzerError> {
if self.config.api_key.is_empty() {
return Ok((50, "未配置API".to_string()));
}
let prompt = self.build_prompt(content);
let response = self.send_request(&prompt)?;
if let Some(score) = response.score {
let label = response.label.unwrap_or_else(|| self.get_label(score));
self.last_result = Some(response);
Ok((score, label))
} else {
Ok((50, "无法判断".to_string()))
}
}
fn build_prompt(&self, content: &str) -> String {
format!(
r#"你是一个专业的股市情绪分析师。请分析以下股评内容的情绪倾向并给出0-100分的情绪评分。
评分标准:
- 0-30分极度悲观股灾、暴跌、绝望等
- 30-39分悲观下跌、风险、不看好等
- 40-49分偏悲观谨慎、担忧等
- 50分中性观望、震荡等
- 51-60分偏乐观关注、期待等
- 60-69分乐观看好、上涨等
- 70-100分极度乐观暴涨、牛市、必涨等
请直接返回JSON格式的分析结果不要其他内容
{{"score": 评分数字, "label": "情绪标签", "reasoning": "简短分析理由20字内"}}
股评内容:{}
"#,
content
)
}
fn send_request(&self, prompt: &str) -> Result<AnalysisResponse, AnalyzerError> {
let url = format!("{}/chat/completions", self.config.base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": self.config.model,
"messages": [
{"role": "system", "content": "你是一个专业的股市情绪分析师。请严格按照JSON格式返回结果。"},
{"role": "user", "content": prompt}
],
"temperature": 0.3,
"max_tokens": 256
});
let response = self.client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", self.config.api_key))
.json(&body)
.send()?;
let status = response.status();
if !status.is_success() {
return Err(AnalyzerError::ApiError(format!("HTTP {}", status)));
}
let result: serde_json::Value = response.json()?;
if let Some(content) = result.get("choices")
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
{
self.parse_response(content)
} else {
Err(AnalyzerError::ParseError("Invalid response format".to_string()))
}
}
fn parse_response(&self, content: &str) -> Result<AnalysisResponse, AnalyzerError> {
let content = content.trim();
let json_str = if content.contains('{') && content.contains('}') {
let start = content.find('{').unwrap_or(0);
let end = content.rfind('}').map(|i| i + 1).unwrap_or(content.len());
&content[start..end]
} else {
content
};
let parsed: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| AnalyzerError::ParseError(e.to_string()))?;
let score = parsed.get("score")
.and_then(|s| s.as_i64())
.map(|s| s as i32);
let label = parsed.get("label")
.and_then(|l| l.as_str())
.map(|s| s.to_string());
let reasoning = parsed.get("reasoning")
.and_then(|r| r.as_str())
.map(|s| s.to_string());
Ok(AnalysisResponse {
score,
label,
reasoning,
})
}
fn get_label(&self, score: i32) -> String {
match score {
0..=29 => "极度悲观".to_string(),
30..=39 => "悲观".to_string(),
40..=49 => "偏悲观".to_string(),
50..=50 => "中性".to_string(),
51..=60 => "偏乐观".to_string(),
61..=69 => "乐观".to_string(),
70..=100 => "极度乐观".to_string(),
_ => "无法判断".to_string(),
}
}
pub fn get_last_result(&self) -> Option<&AnalysisResponse> {
self.last_result.as_ref()
}
pub fn update_config(&mut self, config: LlmApiConfig) {
self.config = config;
self.client = Client::builder()
.timeout(Duration::from_secs(self.config.timeout))
.build()
.expect("Failed to create HTTP client");
}
}

207
rust/src/config.rs Normal file
View File

@@ -0,0 +1,207 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use parking_lot::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmApiConfig {
pub base_url: String,
pub api_key: String,
pub model: String,
pub timeout: u64,
#[serde(default = "default_retry_times")]
pub retry_times: u32,
}
fn default_retry_times() -> u32 { 3 }
impl Default for LlmApiConfig {
fn default() -> Self {
Self {
base_url: "https://integrate.api.nvidia.com/v1".to_string(),
api_key: "".to_string(),
model: "deepseek-ai/deepseek-r1".to_string(),
timeout: 120,
retry_times: 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpiderConfig {
pub target_url: String,
pub xpath: String,
#[serde(default = "default_user_agent")]
pub user_agent: String,
#[serde(default = "default_fetch_interval")]
pub fetch_interval: u64,
#[serde(default = "default_retry_times")]
pub retry_times: u32,
#[serde(default = "default_retry_interval")]
pub retry_interval: u64,
}
fn default_user_agent() -> String {
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string()
}
fn default_fetch_interval() -> u64 { 15 }
fn default_retry_interval() -> u64 { 5 }
impl Default for SpiderConfig {
fn default() -> Self {
Self {
target_url: "".to_string(),
xpath: "".to_string(),
user_agent: default_user_agent(),
fetch_interval: 15,
retry_times: 3,
retry_interval: 5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
#[serde(default = "default_opacity")]
pub opacity: f32,
#[serde(default = "default_is_on_top")]
pub is_on_top: bool,
pub thresholds: ThresholdConfig,
}
fn default_opacity() -> f32 { 0.9 }
fn default_is_on_top() -> bool { true }
impl Default for UiConfig {
fn default() -> Self {
Self {
opacity: 0.9,
is_on_top: true,
thresholds: ThresholdConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThresholdConfig {
#[serde(default = "default_cold")]
pub cold: i32,
#[serde(default = "default_warm")]
pub warm: i32,
}
fn default_cold() -> i32 { 30 }
fn default_warm() -> i32 { 70 }
impl Default for ThresholdConfig {
fn default() -> Self {
Self {
cold: 30,
warm: 70,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
#[serde(default = "default_db_path")]
pub path: String,
}
fn default_db_path() -> String { "guba.db".to_string() }
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
path: "guba.db".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_log_path")]
pub path: String,
}
fn default_log_level() -> String { "INFO".to_string() }
fn default_log_path() -> String { "guba.log".to_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 Config {
pub llm_api: LlmApiConfig,
pub spider: SpiderConfig,
pub ui: UiConfig,
pub database: DatabaseConfig,
pub logging: LoggingConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
llm_api: LlmApiConfig::default(),
spider: SpiderConfig::default(),
ui: UiConfig::default(),
database: DatabaseConfig::default(),
logging: LoggingConfig::default(),
}
}
}
pub struct ConfigManager {
config: Arc<RwLock<Config>>,
config_path: PathBuf,
}
impl ConfigManager {
pub fn new(config_file: &str) -> Self {
let config_path = PathBuf::from(config_file);
let config = if config_path.exists() {
match fs::read_to_string(&config_path) {
Ok(content) => {
serde_json::from_str(&content).unwrap_or_default()
}
Err(_) => Config::default(),
}
} else {
Config::default()
};
Self {
config: Arc::new(RwLock::new(config)),
config_path,
}
}
pub fn get(&self) -> Config {
self.config.read().clone()
}
pub fn save(&self) -> Result<(), String> {
let config = self.config.read();
let content = serde_json::to_string_pretty(&*config)
.map_err(|e| e.to_string())?;
fs::write(&self.config_path, content).map_err(|e| e.to_string())
}
pub fn update<F>(&self, f: F)
where
F: FnOnce(&mut Config),
{
let mut config = self.config.write();
f(&mut config);
}
}

223
rust/src/database.rs Normal file
View File

@@ -0,0 +1,223 @@
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection, Result as SqlResult};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::Path;
use std::sync::Arc;
use parking_lot::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: i64,
pub content: String,
pub hash: String,
pub created_at: String,
pub analyzed: bool,
pub score: Option<i32>,
pub label: Option<String>,
pub analyzed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisResult {
pub id: i64,
pub comment_id: i64,
pub score: i32,
pub label: String,
pub analyzed_at: String,
}
pub struct DatabaseManager {
conn: Arc<Mutex<Connection>>,
}
impl DatabaseManager {
pub fn new(db_path: &str) -> SqlResult<Self> {
let conn = Connection::open(Path::new(db_path))?;
let manager = Self {
conn: Arc::new(Mutex::new(conn)),
};
manager.init_tables()?;
Ok(manager)
}
fn init_tables(&self) -> SqlResult<()> {
let conn = self.conn.lock();
conn.execute(
"CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
analyzed INTEGER DEFAULT 0,
score INTEGER,
label TEXT,
analyzed_at TEXT
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_comments_hash ON comments(hash)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_comments_analyzed ON comments(analyzed)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS analysis_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER NOT NULL,
score INTEGER NOT NULL,
label TEXT NOT NULL,
analyzed_at TEXT NOT NULL,
FOREIGN KEY (comment_id) REFERENCES comments(id)
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_analysis_comment ON analysis_history(comment_id)",
[],
)?;
Ok(())
}
pub fn add_comment(&self, content: &str) -> SqlResult<Option<i64>> {
let hash = self.compute_hash(content);
let created_at = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let conn = self.conn.lock();
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM comments WHERE hash = ?",
[&hash],
|row| row.get(0),
)
.ok();
if existing.is_some() {
return Ok(None);
}
conn.execute(
"INSERT INTO comments (content, hash, created_at) VALUES (?, ?, ?)",
params![content, hash, created_at],
)?;
let id = conn.last_insert_rowid();
Ok(Some(id))
}
pub fn add_comments_batch(&self, comments: &[String]) -> SqlResult<Vec<i64>> {
let mut new_ids = Vec::new();
for content in comments {
if let Some(id) = self.add_comment(content)? {
new_ids.push(id);
}
}
Ok(new_ids)
}
pub fn get_unanalyzed_comments(&self, limit: usize) -> SqlResult<Vec<Comment>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT id, content, hash, created_at, analyzed, score, label, analyzed_at
FROM comments WHERE analyzed = 0 LIMIT ?",
)?;
let comments = stmt
.query_map([limit], |row| {
Ok(Comment {
id: row.get(0)?,
content: row.get(1)?,
hash: row.get(2)?,
created_at: row.get(3)?,
analyzed: row.get::<_, i32>(4)? != 0,
score: row.get(5)?,
label: row.get(6)?,
analyzed_at: row.get(7)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(comments)
}
pub fn mark_analyzed(&self, comment_id: i64, score: i32, label: &str) -> SqlResult<()> {
let analyzed_at = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let conn = self.conn.lock();
conn.execute(
"UPDATE comments SET analyzed = 1, score = ?, label = ?, analyzed_at = ? WHERE id = ?",
params![score, label, analyzed_at, comment_id],
)?;
conn.execute(
"INSERT INTO analysis_history (comment_id, score, label, analyzed_at) VALUES (?, ?, ?, ?)",
params![comment_id, score, label, analyzed_at],
)?;
Ok(())
}
pub fn get_all_scores(&self, limit: usize) -> SqlResult<Vec<i32>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT score FROM comments WHERE score IS NOT NULL ORDER BY analyzed_at DESC LIMIT ?",
)?;
let scores = stmt
.query_map([limit], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(scores)
}
pub fn get_statistics(&self) -> SqlResult<Statistics> {
let conn = self.conn.lock();
let total: i64 = conn.query_row(
"SELECT COUNT(*) FROM comments",
[],
|row| row.get(0),
)?;
let analyzed: i64 = conn.query_row(
"SELECT COUNT(*) FROM comments WHERE analyzed = 1",
[],
|row| row.get(0),
)?;
let avg_score: Option<f64> = conn.query_row(
"SELECT AVG(score) FROM comments WHERE score IS NOT NULL",
[],
|row| row.get(0),
).ok();
Ok(Statistics {
total_comments: total as usize,
analyzed_comments: analyzed as usize,
average_score: avg_score.map(|s| s as i32),
})
}
fn compute_hash(&self, content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Statistics {
pub total_comments: usize,
pub analyzed_comments: usize,
pub average_score: Option<i32>,
}

9
rust/src/lib.rs Normal file
View File

@@ -0,0 +1,9 @@
pub mod config;
pub mod database;
pub mod spider;
pub mod analyzer;
pub use config::ConfigManager;
pub use database::DatabaseManager;
pub use spider::SpiderManager;
pub use analyzer::LLMAnalyzer;

276
rust/src/main.rs Normal file
View File

@@ -0,0 +1,276 @@
mod config;
mod database;
mod spider;
mod analyzer;
mod ui;
use config::{Config, ConfigManager};
use database::DatabaseManager;
use spider::SpiderManager;
use analyzer::LLMAnalyzer;
use ui::{AppState, draw_indicator, draw_waveform, get_score_label};
use eframe::egui;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
struct GubaApp {
config_manager: ConfigManager,
db: DatabaseManager,
spider: SpiderManager,
analyzer: LLMAnalyzer,
state: AppState,
stock_data: Arc<parking_lot::Mutex<Vec<(String, f64)>>>,
config_open: bool,
}
impl GubaApp {
fn new(cc: &eframe::CreationContext<'_>, config_manager: ConfigManager) -> Self {
let config = config_manager.get();
let db = DatabaseManager::new(&config.database.path)
.expect("Failed to initialize database");
let spider = SpiderManager::new(config.spider.clone());
let mut analyzer = LLMAnalyzer::new(config.llm_api.clone());
let state = AppState::new();
let stock_data = Arc::new(parking_lot::Mutex::new(Vec::new()));
let db_clone = Arc::new(database::DatabaseManager::new(&config.database.path)
.expect("Failed to create database clone"));
let spider_clone = Arc::new(spider::SpiderManager::new(config.spider.clone()));
let mut analyzer_clone = analyzer::LLMAnalyzer::new(config.llm_api.clone());
let state_clone = Arc::new(ui::AppState::new());
let stock_data_clone = stock_data.clone();
thread::spawn(move || {
run_background_task(db_clone, spider_clone, &mut analyzer_clone, state_clone, stock_data_clone);
});
Self {
config_manager,
db,
spider,
analyzer,
state,
stock_data,
config_open: false,
}
}
}
fn run_background_task(
db: Arc<DatabaseManager>,
spider: Arc<SpiderManager>,
analyzer: &mut LLMAnalyzer,
state: Arc<ui::AppState>,
stock_data: Arc<parking_lot::Mutex<Vec<(String, f64)>>>,
) {
let mut no_content_count = 0i32;
let mut fetch_interval = 15u64;
loop {
if !state.running.load(Ordering::SeqCst) {
thread::sleep(Duration::from_secs(1));
continue;
}
{
let mut status = state.status_text.lock();
*status = "正在爬取评论...".to_string();
}
let comments = match spider.fetch() {
Ok(c) => c,
Err(e) => {
let mut status = state.status_text.lock();
*status = format!("爬取失败: {}", e);
thread::sleep(Duration::from_secs(5));
continue;
}
};
if comments.is_empty() {
no_content_count += 1;
let interval = fetch_interval * (1 + no_content_count.min(4) as u64);
let mut status = state.status_text.lock();
*status = format!("无新内容,{}秒后重试", interval);
thread::sleep(Duration::from_secs(interval));
continue;
}
no_content_count = 0;
state.fetch_count.fetch_add(1, Ordering::SeqCst);
{
let mut status = state.status_text.lock();
*status = format!("获取到 {} 条评论", comments.len());
}
match db.add_comments_batch(&comments) {
Ok(new_ids) if !new_ids.is_empty() => {
let thresholds = {
let config = ConfigManager::new("config.json");
config.get().ui.thresholds
};
for id in &new_ids {
if let Ok(unanalyzed) = db.get_unanalyzed_comments(1) {
if let Some(comment) = unanalyzed.first() {
match analyzer.analyze(&comment.content) {
Ok((score, label)) => {
let _ = db.mark_analyzed(comment.id, score, &label);
state.analysis_count.fetch_add(1, Ordering::SeqCst);
let scores = db.get_all_scores(100).unwrap_or_default();
if !scores.is_empty() {
let avg: i32 = scores.iter().sum::<i32>() / scores.len() as i32;
state.current_score.store(avg, Ordering::SeqCst);
}
let mut status = state.status_text.lock();
*status = format!("分析完成: {}分 - {}", score, label);
}
Err(e) => {
let _ = db.mark_analyzed(comment.id, 50, "分析异常");
let mut status = state.status_text.lock();
*status = format!("分析失败: {}", e);
}
}
thread::sleep(Duration::from_secs(1));
}
}
}
}
_ => {}
}
if let Ok(data) = spider.fetch_sse_stock_data() {
if data.value > 0.0 {
let mut stocks = stock_data.lock();
stocks.push((data.time.clone(), data.value));
if stocks.len() > 100 {
stocks.remove(0);
}
}
}
let interval = fetch_interval * (1 + no_content_count.min(4) as u64);
thread::sleep(Duration::from_secs(interval));
}
}
impl eframe::App for GubaApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("股吧人气指示器");
ui.horizontal(|ui| {
if ui.button(if self.state.running.load(Ordering::SeqCst) { "停止" } else { "开始" }).clicked() {
let running = self.state.running.load(Ordering::SeqCst);
self.state.running.store(!running, Ordering::SeqCst);
}
if ui.button("刷新").clicked() {
self.state.no_content_count.store(0, Ordering::SeqCst);
}
if ui.button("配置").clicked() {
self.config_open = true;
}
});
ui.separator();
let config = self.config_manager.get();
let score = self.state.current_score.load(Ordering::SeqCst);
let cold = config.ui.thresholds.cold;
let warm = config.ui.thresholds.warm;
ui.horizontal(|ui| {
ui.label("当前情绪:");
draw_indicator(ui, score, cold, warm);
});
ui.separator();
ui.label("上证指数走势:");
let stock_data = self.stock_data.lock();
let data: Vec<(String, f64)> = stock_data.clone();
drop(stock_data);
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
draw_waveform(ui, &data, ui.available_width(), 150.0);
});
ui.separator();
ui.horizontal(|ui| {
ui.label("状态: ");
let status = self.state.status_text.lock();
ui.label(status.clone());
});
ui.horizontal(|ui| {
ui.label(format!("爬取次数: {}", self.state.fetch_count.load(Ordering::SeqCst)));
ui.label(format!("分析次数: {}", self.state.analysis_count.load(Ordering::SeqCst)));
});
});
if self.config_open {
egui::Window::new("配置").open(&mut self.config_open).show(ctx, |ui| {
let config = self.config_manager.get();
ui.group(|ui| {
ui.label("API配置:");
ui.text_edit_singleline(&mut self.config_manager.get().llm_api.api_key.clone());
});
ui.group(|ui| {
ui.label("爬虫配置:");
ui.text_edit_singleline(&mut self.config_manager.get().spider.target_url.clone());
});
ui.group(|ui| {
ui.label("阈值设置:");
ui.horizontal(|ui| {
ui.label("冷阈值:");
ui.add(egui::DragValue::new(&mut self.config_manager.get().ui.thresholds.cold).range(0..=100));
ui.label("热阈值:");
ui.add(egui::DragValue::new(&mut self.config_manager.get().ui.thresholds.warm).range(0..=100));
});
});
if ui.button("保存").clicked() {
let _ = self.config_manager.save();
}
});
}
}
}
fn main() -> eframe::Result<()> {
let config_manager = ConfigManager::new("config.json");
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.init();
log::info!("股吧人气指示器启动");
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 600.0])
.with_min_inner_size([300.0, 400.0])
.with_resizable(true),
..Default::default()
};
eframe::run_native(
"股吧人气指示器",
options,
Box::new(|cc| Ok(Box::new(GubaApp::new(cc, config_manager)))),
)
}

120
rust/src/spider.rs Normal file
View File

@@ -0,0 +1,120 @@
use crate::config::SpiderConfig;
use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SpiderError {
#[error("Request failed: {0}")]
RequestError(#[from] reqwest::Error),
#[error("Parse error: {0}")]
ParseError(String),
#[error("No comments found")]
NoComments,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
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()
.user_agent(&config.user_agent)
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
Self { config, client }
}
pub fn fetch(&self) -> Result<Vec<String>, SpiderError> {
if self.config.target_url.is_empty() {
return Ok(Vec::new());
}
let response = self.client.get(&self.config.target_url).send()?;
let html = response.text()?;
self.parse_comments(&html)
}
fn parse_comments(&self, html: &str) -> Result<Vec<String>, SpiderError> {
if self.config.xpath.is_empty() {
return Err(SpiderError::ParseError("XPath is empty".to_string()));
}
let mut comments = Vec::new();
if let Ok(re) = Regex::new(r#"<[^>]*>([^<]+)</[^>]*>"#) {
for cap in re.captures_iter(html) {
if let Some(content) = cap.get(1) {
let text = content.as_str().trim().to_string();
if !text.is_empty() && text.len() > 5 {
comments.push(text);
}
}
}
}
if comments.is_empty() {
if let Ok(re) = Regex::new(r#"[\u4e00-\u9fa5]{4,}"#) {
for mat in re.find_iter(html) {
let text = mat.as_str().to_string();
if !comments.contains(&text) {
comments.push(text);
}
}
}
}
Ok(comments)
}
pub fn fetch_sse_stock_data(&self) -> Result<StockData, SpiderError> {
let url = "https://hq.sinajs.cn/list=s_sh000001";
let response = self.client.get(url)
.header("Referer", "https://finance.sina.com.cn/")
.send()?;
let text = response.text()?;
if let Ok(re) = Regex::new(r#"="([^"]+)""#) {
if let Some(cap) = re.captures(&text) {
if let Some(data) = cap.get(1) {
let parts: Vec<&str> = data.as_str().split(',').collect();
if parts.len() >= 32 {
let value: f64 = parts[1].parse().unwrap_or(0.0);
let time = parts[31].to_string();
return Ok(StockData { time, value });
}
}
}
}
let now = chrono::Local::now();
Ok(StockData {
time: now.format("%H:%M:%S").to_string(),
value: 0.0,
})
}
pub fn update_config(&mut self, config: SpiderConfig) {
self.config = config;
self.client = Client::builder()
.user_agent(&self.config.user_agent)
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
}
}

125
rust/src/ui.rs Normal file
View File

@@ -0,0 +1,125 @@
use egui::{Color32, RichText, Stroke, Vec2};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Arc;
pub struct AppState {
pub running: Arc<AtomicBool>,
pub current_score: Arc<AtomicI32>,
pub status_text: Arc<parking_lot::Mutex<String>>,
pub fetch_count: Arc<AtomicI32>,
pub analysis_count: Arc<AtomicI32>,
}
impl AppState {
pub fn new() -> Self {
Self {
running: Arc::new(AtomicBool::new(false)),
current_score: Arc::new(AtomicI32::new(50)),
status_text: Arc::new(parking_lot::Mutex::new("就绪".to_string())),
fetch_count: Arc::new(AtomicI32::new(0)),
analysis_count: Arc::new(AtomicI32::new(0)),
}
}
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
pub fn get_score_color(score: i32) -> Color32 {
match score {
0..=30 => Color32::from_rgb(0, 100, 200),
31..=50 => Color32::from_rgb(100, 200, 100),
51..=70 => Color32::from_rgb(255, 200, 0),
71..=100 => Color32::from_rgb(255, 50, 50),
_ => Color32::GRAY,
}
}
pub fn get_score_label(score: i32, cold: i32, warm: i32) -> &'static str {
if score < cold {
"看跌"
} else if score > warm {
"看涨"
} else {
"中性"
}
}
pub fn draw_indicator(ui: &mut egui::Ui, score: i32, cold: i32, warm: i32) {
let color = get_score_color(score);
let label = get_score_label(score, cold, warm);
let size = Vec2::new(120.0, 120.0);
let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover());
let painter = ui.painter();
painter.circle_filled(rect.center(), 55.0, Color32::from_rgba_unmultiplied(30, 30, 30, 200));
painter.circle_stroke(rect.center(), 55.0, Stroke::new(3.0, color));
let inner_radius = 40.0;
let angle = (score as f32 / 100.0) * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
let indicator_pos = rect.center() + Vec2::new(
angle.cos() * inner_radius,
angle.sin() * inner_radius,
);
painter.circle_filled(indicator_pos, 8.0, color);
let text = RichText::new(format!("{}", score))
.heading()
.size(36.0)
.color(color);
painter.text(
rect.center() - Vec2::new(0.0, 10.0),
egui::Align2::CENTER_CENTER,
text,
);
let label_text = RichText::new(label).size(16.0).color(Color32::WHITE);
painter.text(
rect.center() + Vec2::new(0.0, 25.0),
egui::Align2::CENTER_CENTER,
label_text,
);
}
pub fn draw_waveform(ui: &mut egui::Ui, data: &[(String, f64)], width: f32, height: f32) {
let rect = ui.available_rect_before_wrap();
let painter = ui.painter();
painter.rect_filled(rect, 0.0, Color32::from_rgba_unmultiplied(20, 20, 30, 180));
painter.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::GRAY));
if data.is_empty() {
let text = RichText::new("暂无数据").small().color(Color32::GRAY);
painter.text(rect.center(), egui::Align2::CENTER_CENTER, text);
return;
}
let values: Vec<f64> = data.iter().map(|(_, v)| *v).collect();
let min_val = values.iter().cloned().fold(f64::INFINITY, f64::min);
let max_val = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let range = if (max_val - min_val).abs() < 0.01 { 1.0 } else { max_val - min_val };
let padding = 10.0;
let draw_width = rect.width() - padding * 2.0;
let draw_height = rect.height() - padding * 2.0;
let step_x = if data.len() > 1 { draw_width / (data.len() - 1) as f32 } else { 0.0 };
for i in 0..data.len().saturating_sub(1) {
let x1 = rect.min.x + padding + (i as f32) * step_x;
let x2 = rect.min.x + padding + ((i + 1) as f32) * step_x;
let y1 = rect.max.y - padding - ((values[i] - min_val) / range * draw_height as f64) as f32;
let y2 = rect.max.y - padding - ((values[i + 1] - min_val) / range * draw_height as f64) as f32;
painter.line_segment(
[egui::pos2(x1, y1), egui::pos2(x2, y2)],
Stroke::new(2.0, Color32::from_rgb(0, 150, 255)),
);
}
}

View File

@@ -0,0 +1 @@
{"rustc_fingerprint":15274735761647741548,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\dxzq\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: x86_64-pc-windows-msvc\nrelease: 1.93.1\nLLVM version: 21.1.8\n","stderr":""}},"successes":{}}

3
rust/target/CACHEDIR.TAG Normal file
View File

@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/

View File

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
b60a5906b2002723

View File

@@ -0,0 +1 @@
{"rustc":8323788817864214825,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":3660878992414372870,"path":5157919149803396374,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\cfg-if-d2d7e488c9dc3d69\\dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1,5 @@
{"$message_type":"diagnostic","message":"linker `link.exe` not found","code":null,"level":"error","spans":[],"children":[{"message":"program not found","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m\u001b[97m: linker `link.exe` not found\u001b[0m\n \u001b[1m\u001b[96m|\u001b[0m\n \u001b[1m\u001b[96m= \u001b[0m\u001b[1m\u001b[97mnote\u001b[0m: program not found\n\n"}
{"$message_type":"diagnostic","message":"the msvc targets depend on the msvc linker but `link.exe` was not found","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: the msvc targets depend on the msvc linker but `link.exe` was not found\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"please ensure that Visual Studio 2017 or later, or Build Tools for Visual Studio were installed with the Visual C++ option.","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: please ensure that Visual Studio 2017 or later, or Build Tools for Visual Studio were installed with the Visual C++ option.\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"VS Code is a different product, and is not sufficient.","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: VS Code is a different product, and is not sufficient.\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m\u001b[97m: aborting due to 1 previous error\u001b[0m\n\n"}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1,5 @@
{"$message_type":"diagnostic","message":"linker `link.exe` not found","code":null,"level":"error","spans":[],"children":[{"message":"program not found","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m\u001b[97m: linker `link.exe` not found\u001b[0m\n \u001b[1m\u001b[96m|\u001b[0m\n \u001b[1m\u001b[96m= \u001b[0m\u001b[1m\u001b[97mnote\u001b[0m: program not found\n\n"}
{"$message_type":"diagnostic","message":"the msvc targets depend on the msvc linker but `link.exe` was not found","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: the msvc targets depend on the msvc linker but `link.exe` was not found\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"please ensure that Visual Studio 2017 or later, or Build Tools for Visual Studio were installed with the Visual C++ option.","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: please ensure that Visual Studio 2017 or later, or Build Tools for Visual Studio were installed with the Visual C++ option.\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"VS Code is a different product, and is not sufficient.","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: VS Code is a different product, and is not sufficient.\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m\u001b[97m: aborting due to 1 previous error\u001b[0m\n\n"}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1,5 @@
{"$message_type":"diagnostic","message":"linker `link.exe` not found","code":null,"level":"error","spans":[],"children":[{"message":"program not found","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m\u001b[97m: linker `link.exe` not found\u001b[0m\n \u001b[1m\u001b[96m|\u001b[0m\n \u001b[1m\u001b[96m= \u001b[0m\u001b[1m\u001b[97mnote\u001b[0m: program not found\n\n"}
{"$message_type":"diagnostic","message":"the msvc targets depend on the msvc linker but `link.exe` was not found","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: the msvc targets depend on the msvc linker but `link.exe` was not found\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"please ensure that Visual Studio 2017 or later, or Build Tools for Visual Studio were installed with the Visual C++ option.","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: please ensure that Visual Studio 2017 or later, or Build Tools for Visual Studio were installed with the Visual C++ option.\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"VS Code is a different product, and is not sufficient.","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: VS Code is a different product, and is not sufficient.\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m\u001b[97m: aborting due to 1 previous error\u001b[0m\n\n"}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
ca12b79f6a1a1d70

View File

@@ -0,0 +1 @@
{"rustc":8323788817864214825,"features":"[]","declared_features":"[]","target":14045917370260632744,"profile":17984201634715228204,"path":9962931808668317007,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\unicode-ident-0082d2f4b99fbc4a\\dep-lib-unicode_ident","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
f059b888e2d2ac91

View File

@@ -0,0 +1 @@
{"rustc":8323788817864214825,"features":"[]","declared_features":"[]","target":2558631941022679061,"profile":310928410307151240,"path":3878061000663426425,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\windows-link-6e2139e2f73745b5\\dep-lib-windows_link","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1,7 @@
H:\学习资料\自用的小工具\guba\rust\target\release\deps\cfg_if-d2d7e488c9dc3d69.d: C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\cfg-if-1.0.4\src\lib.rs
H:\学习资料\自用的小工具\guba\rust\target\release\deps\libcfg_if-d2d7e488c9dc3d69.rlib: C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\cfg-if-1.0.4\src\lib.rs
H:\学习资料\自用的小工具\guba\rust\target\release\deps\libcfg_if-d2d7e488c9dc3d69.rmeta: C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\cfg-if-1.0.4\src\lib.rs
C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\cfg-if-1.0.4\src\lib.rs:

View File

@@ -0,0 +1,8 @@
H:\学习资料\自用的小工具\guba\rust\target\release\deps\unicode_ident-0082d2f4b99fbc4a.d: C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\unicode-ident-1.0.24\src\lib.rs C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\unicode-ident-1.0.24\src\tables.rs
H:\学习资料\自用的小工具\guba\rust\target\release\deps\libunicode_ident-0082d2f4b99fbc4a.rlib: C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\unicode-ident-1.0.24\src\lib.rs C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\unicode-ident-1.0.24\src\tables.rs
H:\学习资料\自用的小工具\guba\rust\target\release\deps\libunicode_ident-0082d2f4b99fbc4a.rmeta: C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\unicode-ident-1.0.24\src\lib.rs C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\unicode-ident-1.0.24\src\tables.rs
C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\unicode-ident-1.0.24\src\lib.rs:
C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\unicode-ident-1.0.24\src\tables.rs:

View File

@@ -0,0 +1,8 @@
H:\学习资料\自用的小工具\guba\rust\target\release\deps\windows_link-6e2139e2f73745b5.d: C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\windows-link-0.2.1\src\lib.rs C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\windows-link-0.2.1\src\../readme.md
H:\学习资料\自用的小工具\guba\rust\target\release\deps\libwindows_link-6e2139e2f73745b5.rlib: C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\windows-link-0.2.1\src\lib.rs C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\windows-link-0.2.1\src\../readme.md
H:\学习资料\自用的小工具\guba\rust\target\release\deps\libwindows_link-6e2139e2f73745b5.rmeta: C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\windows-link-0.2.1\src\lib.rs C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\windows-link-0.2.1\src\../readme.md
C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\windows-link-0.2.1\src\lib.rs:
C:\Users\dxzq\.cargo\registry\src\mirrors.ustc.edu.cn-38d0e5eb5da2abae\windows-link-0.2.1\src\../readme.md:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 39 KiB