Compare commits
17 Commits
f23feaf140
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ae0eaa9c1 | |||
| e065c41d6b | |||
| bf77212793 | |||
| 9103da519c | |||
| b35e235682 | |||
| 9096a38ad2 | |||
| 421fea062b | |||
| eb8de0808e | |||
| de9edf255d | |||
| ee721e9abe | |||
| f256bd0852 | |||
| e8210b4d88 | |||
| df9348ca95 | |||
| 5d79cd9e8f | |||
| 10ce2ba17b | |||
| 4b406a3727 | |||
| 346d4a7c99 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -52,3 +52,12 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.bak
|
||||
test*.*
|
||||
|
||||
# 其它
|
||||
main.exe
|
||||
sse_screen*.png
|
||||
|
||||
# Rust
|
||||
target/
|
||||
rust/target/
|
||||
Cargo.lock
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
name: pyinstaller-one
|
||||
description: 用pyinstaller打包,执行这个技能。
|
||||
description: 用pyinstaller打包时,执行这个技能。
|
||||
---
|
||||
|
||||
## 元数据
|
||||
name: pyinstaller个性化打包
|
||||
description: 打包的时候,要求生成为一个exe文件,使用ico等等
|
||||
|
||||
## 概述
|
||||
此 Skill 用于给有GUI界面的python代码,打包的时候,生成一个统一的要求:生成一个exe文件,去掉控制台窗口,使用本目录下的ico文件作为程序的图标。打包时,如果之前有打包过的文件(dist/build 文件夹),自动覆盖旧文件,不用手动确认,一键打包到底。
|
||||
|
||||
## 打包命令示例
|
||||
pyinstaller --onefile --noconsole --icon=图标文件名.ico --distpath=. --hidden-import=PySide6.Qt6Compat python程序名.py
|
||||
|
||||
## 清除多余文件
|
||||
在windows环境下,执行clean.py
|
||||
## 元数据
|
||||
name: pyinstaller个性化打包
|
||||
description: 打包的时候,要求生成为一个exe文件,使用ico等等
|
||||
|
||||
## 概述
|
||||
此 Skill 用于给有GUI界面的python代码,打包的时候,生成一个统一的要求:生成一个exe文件,去掉控制台窗口,使用本目录下的ico文件作为程序的图标。打包时,如果之前有打包过的文件(dist/build 文件夹),自动覆盖旧文件,不用手动确认,一键打包到底。
|
||||
|
||||
## 打包命令示例
|
||||
pyinstaller --onefile --noconsole --icon=图标文件名.ico --distpath=. --hidden-import=PySide6.Qt6Compat python程序名.py
|
||||
|
||||
## 清除多余文件
|
||||
在windows环境下,执行clean.py。
|
||||
5729
Cargo.lock
generated
Normal file
5729
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
Cargo.toml
Normal file
63
Cargo.toml
Normal file
@@ -0,0 +1,63 @@
|
||||
[package]
|
||||
name = "guba-sentiment-indicator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Your Name"]
|
||||
description = "股吧人气指示器 - Rust版本"
|
||||
|
||||
[dependencies]
|
||||
# 异步运行时
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# HTTP客户端
|
||||
reqwest = { version = "0.12", features = ["json", "cookies"] }
|
||||
|
||||
# HTML解析
|
||||
scraper = "0.22"
|
||||
|
||||
# JSON处理
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# 数据库
|
||||
rusqlite = { version = "0.33", features = ["bundled", "chrono"] }
|
||||
|
||||
# 日志
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
# 时间处理
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# 错误处理
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
|
||||
# 配置管理
|
||||
dirs = "6.0"
|
||||
|
||||
# 哈希
|
||||
md-5 = "0.10"
|
||||
|
||||
# GUI - 使用egui
|
||||
eframe = { version = "0.31", features = ["default"] }
|
||||
egui = "0.31"
|
||||
|
||||
# 图像处理
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
# 正则表达式
|
||||
regex = "1.11"
|
||||
|
||||
# 并发
|
||||
parking_lot = "0.12"
|
||||
|
||||
# 工具库
|
||||
once_cell = "1.21"
|
||||
|
||||
# 随机数
|
||||
rand = "0.8"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
216
README.md
216
README.md
@@ -1,6 +1,6 @@
|
||||
# 股吧人气指示器 (Guba Sentiment Indicator)
|
||||
|
||||
一个基于 Python + PySide6 的桌面悬浮小工具,专为股票投资者设计,通过爬取股吧或论坛评论,使用大语言模型分析投资者情绪倾向,并以直观的可视化方式展示市场人气冷暖程度。
|
||||
一个基于 **Rust + eframe** 的桌面悬浮小工具,专为股票投资者设计,通过爬取股吧或论坛评论,使用大语言模型分析投资者情绪倾向,并以直观的可视化方式展示市场人气冷暖程度。
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
- **自定义爬取规则**: 支持通过XPath表达式自定义爬取目标,可灵活配置目标URL和内容选择器
|
||||
- **智能去重机制**: 使用内容哈希算法自动识别和过滤重复评论,避免重复分析
|
||||
- **自适应刷新**: 根据爬取结果动态调整刷新频率,无新内容时自动延长刷新间隔
|
||||
- **多重解析策略**: 采用 lxml + BeautifulSoup 双重解析机制,提高爬取成功率
|
||||
- **多重解析策略**: 采用正则表达式多重解析机制,提高爬取成功率
|
||||
- **用户代理配置**: 支持自定义User-Agent,增强反爬虫适应性
|
||||
|
||||
### 2. 大语言模型情感分析
|
||||
- **多模型支持**: 支持OpenAI兼容API,包括NVIDIA API等主流大模型平台
|
||||
- **精准情感评分**: 为每条评论生成0-100分的情感倾向评分(0为极度悲观,100为极度乐观)
|
||||
- **智能标签生成**: 自动为分析结果生成情感标签(极度悲观、悲观、中立、乐观、极度乐观等)
|
||||
- **重试机制**: 集成API调用重试机制,应对网络波动和API限流
|
||||
- **API测试功能**: 配置界面提供API连接测试,确保配置正确
|
||||
- **分析历史记录**: 完整保存每次分析的结果和时间戳
|
||||
|
||||
### 3. 可视化情感指示器
|
||||
@@ -27,31 +27,25 @@
|
||||
- **精确数值显示**: 实时显示当前平均情感分数和整体情绪标签
|
||||
- **发光特效**: 采用发光效果增强视觉体验
|
||||
|
||||
### 3. 股票数据实时监控
|
||||
- **上证指数监控**: 通过新浪财经API实时获取上证指数数据
|
||||
- **波形图展示**: 以时间轴形式展示股票价格走势
|
||||
- **数据点标记**: 在波形图上精确标记每个数据点的时间和价格
|
||||
### 4. 股票数据可视化
|
||||
- **上证指数实时数据**: 自动获取上证指数数据并绘制波形图
|
||||
- **历史数据追踪**: 保留最近100个数据点,展示价格变化趋势
|
||||
- **图表自动更新**: 与评论分析同步更新
|
||||
|
||||
### 4. 上证所网页截图功能
|
||||
- **自动化截图**: 使用Playwright自动截取上证所网页指定区域
|
||||
- **元素定位**: 通过XPath精确定位图表元素进行截图
|
||||
- **定时更新**: 每5分钟自动更新一次截图
|
||||
- **历史管理**: 自动管理截图文件,保留最新记录
|
||||
|
||||
### 6. 桌面客户端界面
|
||||
- **悬浮窗口**: 无边框设计,支持窗口拖拽和置顶显示
|
||||
- **系统托盘**: 集成系统托盘功能,支持最小化到托盘
|
||||
- **透明度调节**: 支持0.3-1.0范围内的透明度调节
|
||||
### 5. 桌面客户端界面
|
||||
- **悬浮窗口**: 现代化无边框设计,支持窗口拖拽和置顶显示
|
||||
- **白色主题**: 简洁美观的白色主题界面,提高可读性
|
||||
- **中文字体支持**: 自动加载系统中文字体,确保界面显示正常
|
||||
- **配置对话框**: 图形化配置界面,支持实时修改各项参数
|
||||
- **状态显示**: 实时显示当前运行状态和处理进度
|
||||
|
||||
### 7. 数据管理与持久化
|
||||
### 6. 数据管理与持久化
|
||||
- **SQLite数据库**: 使用SQLite存储所有评论、分析结果和历史数据
|
||||
- **结构化存储**: 评论表、分析历史表、配置表分离存储
|
||||
- **结构化存储**: 评论表、分析历史表分离存储
|
||||
- **数据统计**: 实时统计爬取次数、分析次数等运行指标
|
||||
- **自动清理**: 支持历史数据管理和自动清理
|
||||
- **自动清理**: 支持历史数据管理
|
||||
|
||||
### 8. 高级配置选项
|
||||
### 7. 高级配置选项
|
||||
- **API配置**: 支持自定义API Base URL、API Key、模型选择和超时设置
|
||||
- **爬虫配置**: 可调整目标URL、XPath表达式、刷新间隔等参数
|
||||
- **UI配置**: 支持透明度、置顶状态、情感阈值等界面设置
|
||||
@@ -59,51 +53,53 @@
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 核心技术
|
||||
### Rust版本(当前版本)
|
||||
- **Rust 1.70+**: 主要开发语言
|
||||
- **eframe/egui**: 现代化跨平台GUI框架
|
||||
- **SQLite**: 本地数据存储(通过rusqlite)
|
||||
- **reqwest**: HTTP请求处理
|
||||
- **serde**: 序列化/反序列化
|
||||
- **tokio**: 异步运行时
|
||||
- **regex**: 正则表达式解析
|
||||
- **chrono**: 时间处理
|
||||
- **parking_lot**: 线程安全锁
|
||||
|
||||
### Python版本(旧版本)
|
||||
- **Python 3.10+**: 主要开发语言
|
||||
- **PySide6**: 桌面GUI界面开发
|
||||
- **SQLite**: 本地数据存储
|
||||
- **Requests**: HTTP请求处理
|
||||
- **LXML**: HTML解析和XPath支持
|
||||
- **BeautifulSoup4**: 备选HTML解析器
|
||||
- **Selenium**: Web自动化(备用方案)
|
||||
- **Playwright**: 高级网页自动化和截图
|
||||
|
||||
### AI技术
|
||||
- **OpenAI API**: 大语言模型接口
|
||||
- **JSON解析**: 结构化响应处理
|
||||
- **情感分析算法**: 0-100分评分系统
|
||||
|
||||
### 开发工具
|
||||
- **Loguru**: 高级日志管理
|
||||
- **Playwright**: 自动化浏览器控制
|
||||
- **PyInstaller**: 应用程序打包(通过build.spec)
|
||||
|
||||
## 安装与配置
|
||||
|
||||
### 环境要求
|
||||
- Python 3.10 或更高版本
|
||||
- Windows/macOS/Linux 操作系统
|
||||
### 环境要求(Rust版本)
|
||||
- **Windows 10/11**: 64位系统
|
||||
- **MSYS2 + MinGW**: 用于编译Rust代码
|
||||
- **Rust 1.70+**: 安装rustup后自动管理
|
||||
|
||||
### 安装步骤
|
||||
### 安装步骤(Rust版本)
|
||||
|
||||
1. **克隆或下载项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
git clone http://124.223.26.33:3000/xiaji/guba-indicator.git
|
||||
cd guba
|
||||
```
|
||||
|
||||
2. **安装Python依赖**
|
||||
2. **编译项目**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
# 进入rust目录
|
||||
cd rust
|
||||
|
||||
# 编译release版本(无控制台窗口)
|
||||
cargo build --release
|
||||
|
||||
# 生成的可执行文件在 target/release/guba.exe
|
||||
```
|
||||
|
||||
3. **安装Playwright浏览器驱动**
|
||||
```bash
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
4. **配置API密钥**
|
||||
3. **配置API密钥**
|
||||
编辑 `config.json` 文件,填入您的大语言模型API配置:
|
||||
```json
|
||||
{
|
||||
@@ -116,7 +112,7 @@ playwright install chromium
|
||||
}
|
||||
```
|
||||
|
||||
5. **配置爬取参数**
|
||||
4. **配置爬取参数**
|
||||
在配置中设置目标URL和XPath表达式:
|
||||
```json
|
||||
{
|
||||
@@ -129,9 +125,9 @@ playwright install chromium
|
||||
```
|
||||
|
||||
### 运行程序
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
- **直接运行**: 双击 `guba-rust.exe` 文件
|
||||
- **无控制台窗口**: 程序以GUI模式运行,不会显示命令行窗口
|
||||
- **图标支持**: 程序使用 `guba.ico` 作为应用图标
|
||||
|
||||
## 配置详解
|
||||
|
||||
@@ -140,15 +136,12 @@ python main.py
|
||||
- `api_key`: API访问密钥
|
||||
- `model`: 使用的模型名称
|
||||
- `timeout`: API请求超时时间(秒)
|
||||
- `retry_times`: API调用失败时的重试次数
|
||||
|
||||
### 爬虫配置
|
||||
- `target_url`: 目标网站URL
|
||||
- `xpath`: 用于提取评论的XPath表达式
|
||||
- `user_agent`: HTTP请求的User-Agent字符串
|
||||
- `fetch_interval`: 爬取间隔时间(秒)
|
||||
- `retry_times`: 爬取失败重试次数
|
||||
- `retry_interval`: 爬取重试间隔(秒)
|
||||
|
||||
### UI配置
|
||||
- `opacity`: 窗口透明度(0.3-1.0)
|
||||
@@ -160,48 +153,48 @@ python main.py
|
||||
### 数据库配置
|
||||
- `path`: SQLite数据库文件路径
|
||||
|
||||
### 日志配置
|
||||
- `level`: 日志级别(INFO/DEBUG/ERROR等)
|
||||
- `path`: 日志文件路径
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 基本操作
|
||||
1. 启动程序后,主界面将显示情感指示器
|
||||
2. 程序自动开始爬取评论并分析情感
|
||||
3. 指示器颜色和数值实时反映市场情绪
|
||||
4. 波形图显示股票价格走势
|
||||
4. 波形图显示上证指数走势
|
||||
|
||||
### 按钮功能
|
||||
- **开始/停止**: 控制爬取和分析过程
|
||||
- **刷新**: 手动触发一次评论爬取和分析
|
||||
- **配置**: 打开配置对话框修改各项参数
|
||||
- **退出**: 关闭应用程序
|
||||
|
||||
### 系统托盘菜单
|
||||
- **显示**: 显示主窗口
|
||||
- **隐藏**: 隐藏到系统托盘
|
||||
- **退出**: 完全退出程序
|
||||
### 配置界面
|
||||
- **API配置**: 设置大语言模型API参数,包含测试按钮验证配置
|
||||
- **爬虫配置**: 设置目标网站和爬取参数
|
||||
- **阈值设置**: 调整情感分析的阈值
|
||||
- **界面设置**: 调整窗口透明度和置顶状态
|
||||
|
||||
## 高级功能
|
||||
|
||||
### 批量分析模式
|
||||
支持一次性分析多条评论,适用于历史数据分析场景。
|
||||
### API测试功能
|
||||
在配置界面中点击"测试API"按钮,程序会:
|
||||
- 检查API连接是否正常
|
||||
- 验证API Key是否有效
|
||||
- 获取并显示可用的模型列表
|
||||
- 显示测试结果和状态
|
||||
|
||||
### 实时数据统计
|
||||
程序会统计以下运行指标:
|
||||
- 爬取网站次数
|
||||
- 提交API分析次数
|
||||
- 评论总数和已分析数
|
||||
- 平均情感分数趋势
|
||||
|
||||
### 自定义情感标签
|
||||
系统根据以下规则自动分类情感:
|
||||
- 0-30分: 极度悲观
|
||||
- 0-29分: 极度悲观
|
||||
- 30-39分: 悲观
|
||||
- 39-45分: 偏悲观
|
||||
- 40-44分: 偏悲观
|
||||
- 45-55分: 中立
|
||||
- 55-65分: 偏乐观
|
||||
- 65-70分: 乐观
|
||||
- 56-64分: 偏乐观
|
||||
- 65-69分: 乐观
|
||||
- 70-100分: 极度乐观
|
||||
|
||||
## 故障排除
|
||||
@@ -219,29 +212,68 @@ python main.py
|
||||
- 查看网络连接和防火墙设置
|
||||
|
||||
3. **界面显示异常**
|
||||
- 检查PySide6是否正确安装
|
||||
- 检查系统中是否安装了中文字体
|
||||
- 尝试调整透明度设置
|
||||
|
||||
### 日志文件
|
||||
运行日志保存在 `guba.log` 文件中,记录所有操作和错误信息。
|
||||
|
||||
### 统计信息
|
||||
程序退出时会生成运行统计信息,保存在 `statistics.txt` 文件中。
|
||||
|
||||
## 打包发布
|
||||
|
||||
项目提供PyInstaller打包配置(build.spec),可将应用打包为独立可执行文件:
|
||||
|
||||
运行日志输出到控制台,可通过命令行启动查看详细信息:
|
||||
```bash
|
||||
# 安装PyInstaller
|
||||
pip install pyinstaller
|
||||
|
||||
# 打包应用
|
||||
pyinstaller build.spec
|
||||
|
||||
# 执行文件将在dist目录下生成
|
||||
# 查看日志
|
||||
.uba-rust.exe > log.txt 2>&1
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
### Rust版本
|
||||
- `rust/src/main.rs`: 主程序入口
|
||||
- `rust/src/config.rs`: 配置管理
|
||||
- `rust/src/database.rs`: 数据库操作
|
||||
- `rust/src/spider.rs`: 网页爬取
|
||||
- `rust/src/analyzer.rs`: 情感分析
|
||||
- `rust/src/ui.rs`: 界面组件
|
||||
- `rust/Cargo.toml`: 项目依赖配置
|
||||
- `rust/build.rs`: Windows资源配置
|
||||
|
||||
### Python版本(保留)
|
||||
- `main.py`: 主程序入口
|
||||
- `main_window.py`: GUI界面
|
||||
- `config_manager.py`: 配置管理
|
||||
- `database.py`: 数据库操作
|
||||
- `spider.py`: 网页爬取
|
||||
- `llm_analyzer.py`: 情感分析
|
||||
- `waveform_widget.py`: 波形图绘制
|
||||
|
||||
## 编译说明
|
||||
|
||||
### MSYS2 + MinGW编译
|
||||
项目使用MSYS2的MinGW工具链编译:
|
||||
|
||||
1. **安装MSYS2**
|
||||
- 下载并安装 [MSYS2](https://www.msys2.org/)
|
||||
- 安装MinGW工具链:`pacman -S mingw-w64-x86_64-toolchain`
|
||||
|
||||
2. **设置Rust工具链**
|
||||
```bash
|
||||
rustup default stable-x86_64-pc-windows-gnu
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
3. **编译命令**
|
||||
```bash
|
||||
# 进入rust目录
|
||||
cd rust
|
||||
|
||||
# 清理之前的构建
|
||||
cargo clean
|
||||
|
||||
# 编译release版本
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
4. **输出文件**
|
||||
- 编译后的可执行文件:`target/release/guba.exe`
|
||||
- 复制到项目根目录:`guba-rust.exe`
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API费用**: 使用大语言模型API可能产生费用,请注意控制调用频率
|
||||
@@ -252,16 +284,6 @@ pyinstaller build.spec
|
||||
## 开发扩展
|
||||
|
||||
### 模块结构
|
||||
- `main.py`: 主程序入口,负责组件初始化和信号连接
|
||||
- `main_window.py`: GUI界面和用户交互处理
|
||||
- `config_manager.py`: 配置文件管理和持久化
|
||||
- `database.py`: SQLite数据库操作和数据管理
|
||||
- `spider.py`: 网页爬取和数据提取
|
||||
- `llm_analyzer.py`: 大语言模型情感分析
|
||||
- `waveform_widget.py`: 股票数据波形图绘制
|
||||
- `screenshot_manager.py`: 网页截图功能
|
||||
|
||||
### 二次开发
|
||||
项目采用模块化设计,易于扩展新功能:
|
||||
- 可添加新的爬取目标和解析规则
|
||||
- 支持接入其他大模型API
|
||||
|
||||
25
build.bat
25
build.bat
@@ -1,25 +0,0 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo 股吧人气指示器 - 打包工具
|
||||
echo ========================================
|
||||
|
||||
REM 检查 pyinstaller 是否安装
|
||||
pip show pyinstaller >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo 正在安装 pyinstaller...
|
||||
pip install pyinstaller
|
||||
)
|
||||
|
||||
echo 开始打包...
|
||||
pyinstaller build.spec
|
||||
|
||||
if exist "dist\guba-indicator.exe" (
|
||||
echo ========================================
|
||||
echo 打包成功!
|
||||
echo 可执行文件位置: dist\guba-indicator.exe
|
||||
echo ========================================
|
||||
) else (
|
||||
echo 打包失败,请检查错误信息
|
||||
)
|
||||
|
||||
pause
|
||||
58
build.spec
58
build.spec
@@ -1,58 +0,0 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
import sys
|
||||
import os
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# 添加项目路径
|
||||
project_path = os.path.abspath('.')
|
||||
if project_path not in sys.path:
|
||||
sys.path.insert(0, project_path)
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=['.', project_path],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=['tkinter', 'IPython', 'pytest', 'matplotlib', 'pandas', 'scipy', 'numpy'],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='guba-indicator',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon='indicator.ico',
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='guba-indicator',
|
||||
)
|
||||
@@ -13,9 +13,9 @@ class ConfigManager:
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"llm_api": {
|
||||
"base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"api_key": "",
|
||||
"model": "deepseek-ai/deepseek-r1",
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"model": "glm-4.7-flash",
|
||||
"timeout": 120,
|
||||
"retry_times": 3
|
||||
},
|
||||
@@ -25,7 +25,8 @@ class ConfigManager:
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"fetch_interval": 60,
|
||||
"retry_times": 3,
|
||||
"retry_interval": 5
|
||||
"retry_interval": 5,
|
||||
"chrome_path": ""
|
||||
},
|
||||
"ui": {
|
||||
"opacity": 0.9,
|
||||
@@ -45,9 +46,17 @@ class ConfigManager:
|
||||
}
|
||||
|
||||
def __init__(self, config_path: str = "config.json"):
|
||||
self.config_path = Path(config_path)
|
||||
import sys
|
||||
# 确定配置文件的正确路径
|
||||
if getattr(sys, 'frozen', False):
|
||||
# 打包后的环境
|
||||
current_dir = Path(sys.executable).parent
|
||||
self.config_path = current_dir / config_path
|
||||
else:
|
||||
# 开发环境
|
||||
self.config_path = Path(config_path)
|
||||
self.config = self._load_config()
|
||||
logger.info(f"配置管理器初始化完成,配置文件: {config_path}")
|
||||
logger.info(f"配置管理器初始化完成,配置文件: {self.config_path}")
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""加载配置文件"""
|
||||
@@ -58,13 +67,14 @@ class ConfigManager:
|
||||
loaded_config = json.load(f)
|
||||
# 合并默认配置,确保所有键都存在
|
||||
merged = self._merge_config(self.DEFAULT_CONFIG, loaded_config)
|
||||
logger.info("配置加载成功")
|
||||
logger.info(f"配置加载成功,目标URL: {merged.get('spider', {}).get('target_url', '未设置')}")
|
||||
return merged
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.error(f"配置文件加载失败,使用默认配置: {e}")
|
||||
return self.DEFAULT_CONFIG.copy()
|
||||
else:
|
||||
logger.warning(f"配置文件不存在: {self.config_path},使用默认配置")
|
||||
logger.warning(f"默认配置目标URL: {self.DEFAULT_CONFIG.get('spider', {}).get('target_url', '未设置')}")
|
||||
return self.DEFAULT_CONFIG.copy()
|
||||
|
||||
def _merge_config(self, default: Dict, loaded: Dict) -> Dict:
|
||||
@@ -113,13 +123,13 @@ class ConfigManager:
|
||||
current[keys[-1]] = value
|
||||
return self.save_config()
|
||||
|
||||
def update_llm_api(self, base_url: str = None, api_key: str = None,
|
||||
def update_llm_api(self, api_key: str = None, base_url: str = None,
|
||||
model: str = None, timeout: int = None, retry_times: int = None):
|
||||
"""更新LLM API配置"""
|
||||
if base_url:
|
||||
self.config["llm_api"]["base_url"] = base_url
|
||||
if api_key:
|
||||
self.config["llm_api"]["api_key"] = api_key
|
||||
if base_url:
|
||||
self.config["llm_api"]["base_url"] = base_url
|
||||
if model:
|
||||
self.config["llm_api"]["model"] = model
|
||||
if timeout:
|
||||
@@ -131,7 +141,8 @@ class ConfigManager:
|
||||
|
||||
def update_spider(self, target_url: str = None, xpath: str = None,
|
||||
user_agent: str = None, fetch_interval: int = None,
|
||||
retry_times: int = None, retry_interval: int = None):
|
||||
retry_times: int = None, retry_interval: int = None,
|
||||
chrome_path: str = None):
|
||||
"""更新爬虫配置"""
|
||||
if target_url:
|
||||
self.config["spider"]["target_url"] = target_url
|
||||
@@ -145,6 +156,8 @@ class ConfigManager:
|
||||
self.config["spider"]["retry_times"] = retry_times
|
||||
if retry_interval:
|
||||
self.config["spider"]["retry_interval"] = retry_interval
|
||||
if chrome_path:
|
||||
self.config["spider"]["chrome_path"] = chrome_path
|
||||
logger.info("爬虫配置已更新")
|
||||
self.save_config()
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
大模型分析模块 - 调用LLM API分析评论情感
|
||||
支持 OpenAI 兼容 API,包括 NVIDIA API
|
||||
支持智谱AI API
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import re
|
||||
from typing import Dict, Optional, Tuple, Any
|
||||
from openai import OpenAI
|
||||
from zai import ZhipuAiClient
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ class LLMAnalyzer:
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config
|
||||
self.base_url = config.get('base_url', '')
|
||||
self.api_key = config.get('api_key', '')
|
||||
self.base_url = config.get('base_url', '')
|
||||
self.model = config.get('model', '')
|
||||
self.timeout = config.get('timeout', 120)
|
||||
self.retry_times = config.get('retry_times', 3)
|
||||
@@ -47,34 +47,33 @@ class LLMAnalyzer:
|
||||
|
||||
logger.info(f"LLM分析器配置 - base_url: {self.base_url}, model: {self.model}, timeout: {self.timeout}s, retry: {self.retry_times}次")
|
||||
|
||||
if self.base_url and self.api_key:
|
||||
if self.api_key:
|
||||
self._init_client()
|
||||
else:
|
||||
logger.warning("LLM API 未配置,base_url 或 api_key 为空")
|
||||
logger.warning("LLM API 未配置,api_key 为空")
|
||||
|
||||
def _init_client(self):
|
||||
"""初始化OpenAI客户端"""
|
||||
"""初始化智谱AI客户端"""
|
||||
try:
|
||||
logger.info(f"初始化LLM客户端: {self.base_url}")
|
||||
self.client = OpenAI(
|
||||
logger.info(f"初始化智谱AI客户端: {self.base_url}")
|
||||
self.client = ZhipuAiClient(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
timeout=self.timeout
|
||||
base_url=self.base_url
|
||||
)
|
||||
logger.info("LLM客户端初始化成功")
|
||||
logger.info("智谱AI客户端初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"初始化LLM客户端失败: {e}")
|
||||
logger.error(f"初始化智谱AI客户端失败: {e}")
|
||||
|
||||
def update_config(self, config: Dict):
|
||||
"""更新配置"""
|
||||
self.config.update(config)
|
||||
self.base_url = config.get('base_url', self.base_url)
|
||||
self.api_key = config.get('api_key', self.api_key)
|
||||
self.base_url = config.get('base_url', self.base_url)
|
||||
self.model = config.get('model', self.model)
|
||||
self.timeout = config.get('timeout', self.timeout)
|
||||
self.retry_times = config.get('retry_times', self.retry_times)
|
||||
|
||||
if self.base_url and self.api_key:
|
||||
if self.api_key:
|
||||
self._init_client()
|
||||
|
||||
def analyze(self, comment: str) -> Tuple[Optional[int], Optional[str]]:
|
||||
@@ -97,16 +96,18 @@ class LLMAnalyzer:
|
||||
try:
|
||||
logger.info(f"API调用尝试 {attempt + 1}/{self.retry_times}")
|
||||
|
||||
logger.debug(f"发送请求到 {self.base_url}")
|
||||
logger.debug("发送请求到智谱AI API")
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
model="glm-4.7-flash",
|
||||
messages=[
|
||||
{"role": "system", "content": self.SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"请分析以下评论的情感倾向:\n\n{comment}"}
|
||||
],
|
||||
thinking={
|
||||
"type": "disabled", # 禁用深度思考模式
|
||||
},
|
||||
temperature=0.3,
|
||||
max_tokens=500,
|
||||
timeout=self.timeout
|
||||
max_tokens=500
|
||||
)
|
||||
|
||||
# 处理 deepseek-r1 的特殊结构(可能有 reasoning_content)
|
||||
|
||||
281
main_window.py
281
main_window.py
@@ -4,7 +4,8 @@ PySide6 GUI界面模块
|
||||
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QSlider, QDialog, QFormLayout,
|
||||
QLineEdit, QSpinBox, QMessageBox, QSystemTrayIcon,
|
||||
QMenu, QTextEdit, QGroupBox, QDialogButtonBox, QCheckBox, QScrollArea)
|
||||
QMenu, QTextEdit, QGroupBox, QDialogButtonBox, QCheckBox, QScrollArea, QFileDialog,
|
||||
QTabWidget)
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, QPoint
|
||||
from PySide6.QtGui import QFont, QColor, QPainter, QBrush, QPen, QIcon, QAction, QPixmap
|
||||
from typing import Callable, Optional
|
||||
@@ -120,71 +121,182 @@ class ConfigDialog(QDialog):
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
layout = QFormLayout(self)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# 创建标签页
|
||||
tab_widget = QTabWidget()
|
||||
tab_widget.setStyleSheet(
|
||||
"QTabWidget::pane { border: none; }"
|
||||
"QTabBar::tab { padding: 12px 20px; font-size: 13px; }"
|
||||
"QTabBar::tab:selected { background-color: #2196F3; color: white; }"
|
||||
)
|
||||
|
||||
# API配置标签页
|
||||
api_tab = QWidget()
|
||||
api_layout = QFormLayout(api_tab)
|
||||
api_layout.setContentsMargins(20, 20, 20, 20)
|
||||
api_layout.setSpacing(12)
|
||||
|
||||
# LLM API 配置
|
||||
llm_config = self.config_manager.llm_api_config
|
||||
|
||||
self.base_url_edit = QLineEdit(llm_config.get('base_url', ''))
|
||||
self.base_url_edit.setPlaceholderText("https://api.openai.com/v1")
|
||||
self.api_key_edit = QLineEdit(llm_config.get('api_key', ''))
|
||||
self.api_key_edit.setEchoMode(QLineEdit.Password)
|
||||
self.model_edit = QLineEdit(llm_config.get('model', ''))
|
||||
self.model_edit.setPlaceholderText("gpt-3.5-turbo")
|
||||
self.timeout_spin = QSpinBox()
|
||||
self.timeout_spin.setRange(10, 300)
|
||||
self.timeout_spin.setValue(llm_config.get('timeout', 30))
|
||||
self.timeout_spin.setSuffix(" 秒")
|
||||
|
||||
layout.addRow("API Base URL:", self.base_url_edit)
|
||||
layout.addRow("API Key:", self.api_key_edit)
|
||||
layout.addRow("Model:", self.model_edit)
|
||||
layout.addRow("Timeout (s):", self.timeout_spin)
|
||||
api_layout.addRow("API Base URL:", self.base_url_edit)
|
||||
api_layout.addRow("API Key:", self.api_key_edit)
|
||||
api_layout.addRow("Model:", self.model_edit)
|
||||
api_layout.addRow("超时时间:", self.timeout_spin)
|
||||
|
||||
# 爬虫配置标签页
|
||||
spider_tab = QWidget()
|
||||
spider_layout = QFormLayout(spider_tab)
|
||||
spider_layout.setContentsMargins(20, 20, 20, 20)
|
||||
spider_layout.setSpacing(12)
|
||||
|
||||
# 爬虫配置
|
||||
spider_config = self.config_manager.spider_config
|
||||
|
||||
self.url_edit = QLineEdit(spider_config.get('target_url', ''))
|
||||
self.url_edit.setPlaceholderText("https://example.com")
|
||||
self.xpath_edit = QLineEdit(spider_config.get('xpath', ''))
|
||||
self.xpath_edit.setPlaceholderText("//div[@class='content']")
|
||||
self.user_agent_edit = QLineEdit(spider_config.get('user_agent', ''))
|
||||
self.user_agent_edit.setPlaceholderText("Mozilla/5.0...")
|
||||
self.interval_spin = QSpinBox()
|
||||
self.interval_spin.setRange(10, 3600)
|
||||
self.interval_spin.setValue(spider_config.get('fetch_interval', 15))
|
||||
self.interval_spin.setSuffix(" 秒")
|
||||
|
||||
layout.addRow("目标URL:", self.url_edit)
|
||||
layout.addRow("XPath:", self.xpath_edit)
|
||||
layout.addRow("User Agent:", self.user_agent_edit)
|
||||
layout.addRow("刷新间隔(s):", self.interval_spin)
|
||||
spider_layout.addRow("目标 URL:", self.url_edit)
|
||||
spider_layout.addRow("XPath 表达式:", self.xpath_edit)
|
||||
spider_layout.addRow("User Agent:", self.user_agent_edit)
|
||||
spider_layout.addRow("刷新间隔:", self.interval_spin)
|
||||
|
||||
# Chrome浏览器路径
|
||||
chrome_path_layout = QHBoxLayout()
|
||||
chrome_path_layout.setSpacing(8)
|
||||
self.chrome_path_edit = QLineEdit(spider_config.get('chrome_path', ''))
|
||||
self.chrome_path_edit.setPlaceholderText("留空则自动查找Chrome浏览器")
|
||||
self.chrome_browse_btn = QPushButton("浏览...")
|
||||
self.chrome_browse_btn.setFixedWidth(60)
|
||||
self.chrome_browse_btn.clicked.connect(self._browse_chrome_path)
|
||||
chrome_path_layout.addWidget(self.chrome_path_edit)
|
||||
chrome_path_layout.addWidget(self.chrome_browse_btn)
|
||||
spider_layout.addRow("Chrome 路径:", chrome_path_layout)
|
||||
|
||||
# 界面配置标签页
|
||||
ui_tab = QWidget()
|
||||
ui_layout = QFormLayout(ui_tab)
|
||||
ui_layout.setContentsMargins(20, 20, 20, 20)
|
||||
ui_layout.setSpacing(12)
|
||||
|
||||
# UI 配置
|
||||
ui_config = self.config_manager.ui_config
|
||||
|
||||
# 透明度
|
||||
opacity_layout = QHBoxLayout()
|
||||
opacity_layout.setSpacing(8)
|
||||
self.opacity_slider = QSlider(Qt.Horizontal)
|
||||
self.opacity_slider.setRange(30, 100)
|
||||
self.opacity_slider.setValue(int(ui_config.get('opacity', 0.9) * 100))
|
||||
self.ontop_check = QCheckBox() if hasattr(self, 'QCheckBox') else None
|
||||
# 使用 QPushButton 替代 QCheckBox
|
||||
self.ontop_btn = QPushButton("置顶")
|
||||
self.ontop_btn.setCheckable(True)
|
||||
self.ontop_btn.setChecked(ui_config.get('is_on_top', True))
|
||||
self.opacity_slider.setFixedWidth(120)
|
||||
self.opacity_label = QLabel(f"{int(ui_config.get('opacity', 0.9) * 100)}%")
|
||||
self.opacity_label.setFixedWidth(40)
|
||||
self.opacity_slider.valueChanged.connect(
|
||||
lambda v: self.opacity_label.setText(f"{v}%")
|
||||
)
|
||||
opacity_layout.addWidget(self.opacity_slider)
|
||||
opacity_layout.addWidget(self.opacity_label)
|
||||
opacity_layout.addStretch()
|
||||
|
||||
layout.addRow("透明度:", self.opacity_slider)
|
||||
layout.addRow("窗口置顶:", self.ontop_btn)
|
||||
# 窗口置顶
|
||||
self.ontop_btn = QPushButton("窗口置顶")
|
||||
self.ontop_btn.setCheckable(True)
|
||||
self.ontop_btn.setFixedWidth(100)
|
||||
self.ontop_btn.setChecked(ui_config.get('is_on_top', True))
|
||||
self.ontop_btn.setStyleSheet(
|
||||
"QPushButton { background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; padding: 6px; }"
|
||||
"QPushButton:checked { background-color: #2196F3; color: white; }"
|
||||
)
|
||||
|
||||
ui_layout.addRow("透明度:", opacity_layout)
|
||||
ui_layout.addRow("窗口行为:", self.ontop_btn)
|
||||
|
||||
# 阈值配置
|
||||
thresholds = ui_config.get('thresholds', {})
|
||||
|
||||
threshold_layout = QHBoxLayout()
|
||||
threshold_layout.setSpacing(12)
|
||||
|
||||
self.cold_spin = QSpinBox()
|
||||
self.cold_spin.setRange(0, 50)
|
||||
self.cold_spin.setValue(thresholds.get('cold', 30))
|
||||
self.cold_spin.setSuffix(" 分")
|
||||
self.cold_spin.setFixedWidth(80)
|
||||
|
||||
self.warm_spin = QSpinBox()
|
||||
self.warm_spin.setRange(50, 100)
|
||||
self.warm_spin.setValue(thresholds.get('warm', 70))
|
||||
self.warm_spin.setSuffix(" 分")
|
||||
self.warm_spin.setFixedWidth(80)
|
||||
|
||||
threshold_layout.addWidget(QLabel("寒冷:"))
|
||||
threshold_layout.addWidget(self.cold_spin)
|
||||
threshold_layout.addWidget(QLabel("温暖:"))
|
||||
threshold_layout.addWidget(self.warm_spin)
|
||||
threshold_layout.addStretch()
|
||||
|
||||
ui_layout.addRow("阈值设置:", threshold_layout)
|
||||
|
||||
layout.addRow("寒冷阈值:", self.cold_spin)
|
||||
layout.addRow("温暖阈值:", self.warm_spin)
|
||||
# 添加标签页
|
||||
tab_widget.addTab(api_tab, "API 配置")
|
||||
tab_widget.addTab(spider_tab, "爬虫配置")
|
||||
tab_widget.addTab(ui_tab, "界面设置")
|
||||
|
||||
# 按钮
|
||||
layout.addWidget(tab_widget)
|
||||
|
||||
# 底部按钮
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setContentsMargins(20, 0, 20, 20)
|
||||
button_layout.setSpacing(8)
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self._save_config)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addRow(button_box)
|
||||
|
||||
# 设置按钮样式
|
||||
ok_btn = button_box.button(QDialogButtonBox.Ok)
|
||||
cancel_btn = button_box.button(QDialogButtonBox.Cancel)
|
||||
ok_btn.setStyleSheet(
|
||||
"QPushButton { background-color: #2196F3; color: white; border: none; border-radius: 4px; padding: 8px 16px; }"
|
||||
"QPushButton:hover { background-color: #1976D2; }"
|
||||
)
|
||||
cancel_btn.setStyleSheet(
|
||||
"QPushButton { background-color: #f5f5f5; color: #333; border: 1px solid #ddd; border-radius: 4px; padding: 8px 16px; }"
|
||||
"QPushButton:hover { background-color: #e0e0e0; }"
|
||||
)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(button_box)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _browse_chrome_path(self):
|
||||
"""浏览Chrome路径"""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"选择Chrome浏览器可执行文件",
|
||||
"",
|
||||
"Chrome浏览器 (*.exe);;所有文件 (*.*)"
|
||||
)
|
||||
if file_path:
|
||||
self.chrome_path_edit.setText(file_path)
|
||||
|
||||
def _save_config(self):
|
||||
"""保存配置"""
|
||||
@@ -201,7 +313,8 @@ class ConfigDialog(QDialog):
|
||||
target_url=self.url_edit.text(),
|
||||
xpath=self.xpath_edit.text(),
|
||||
user_agent=self.user_agent_edit.text(),
|
||||
fetch_interval=self.interval_spin.value()
|
||||
fetch_interval=self.interval_spin.value(),
|
||||
chrome_path=self.chrome_path_edit.text()
|
||||
)
|
||||
|
||||
# UI
|
||||
@@ -239,65 +352,75 @@ class MainWindow(QWidget):
|
||||
def _init_ui(self):
|
||||
"""初始化UI"""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# 标题
|
||||
self.title_label = QLabel("上证指数sh000001")
|
||||
self.title_label = QLabel("上证指数 sh000001")
|
||||
self.title_label.setAlignment(Qt.AlignCenter)
|
||||
title_font = QFont()
|
||||
title_font.setPointSize(14)
|
||||
title_font.setPointSize(16)
|
||||
title_font.setBold(True)
|
||||
self.title_label.setFont(title_font)
|
||||
self.title_label.setStyleSheet("color: #333;")
|
||||
|
||||
# 指示灯
|
||||
self.indicator = SentimentIndicator()
|
||||
self.score_label = QLabel("50 - 中性")
|
||||
self.indicator.setMinimumSize(120, 120)
|
||||
|
||||
# 分数和标签
|
||||
self.score_label = QLabel("50")
|
||||
self.score_label.setAlignment(Qt.AlignCenter)
|
||||
score_font = QFont()
|
||||
score_font.setPointSize(24)
|
||||
score_font.setBold(True)
|
||||
self.score_label.setFont(score_font)
|
||||
self.score_label.setStyleSheet("color: #2196F3;")
|
||||
|
||||
self.sentiment_label = QLabel("中性")
|
||||
self.sentiment_label.setAlignment(Qt.AlignCenter)
|
||||
sentiment_font = QFont()
|
||||
sentiment_font.setPointSize(12)
|
||||
self.sentiment_label.setFont(sentiment_font)
|
||||
self.sentiment_label.setStyleSheet("color: #666;")
|
||||
|
||||
# 状态信息
|
||||
self.status_label = QLabel("等待数据...")
|
||||
self.status_label.setAlignment(Qt.AlignCenter)
|
||||
status_font = QFont()
|
||||
status_font.setPointSize(10)
|
||||
self.status_label.setFont(status_font)
|
||||
self.status_label.setStyleSheet("color: #999; font-size: 11px;")
|
||||
|
||||
# 上证所截图显示
|
||||
screenshot_group = QGroupBox("上证所行情")
|
||||
screenshot_layout = QVBoxLayout(screenshot_group)
|
||||
|
||||
self.screenshot_label = QLabel("等待截图...")
|
||||
self.screenshot_label.setAlignment(Qt.AlignCenter)
|
||||
self.screenshot_label.setMinimumSize(400, 200)
|
||||
self.screenshot_label.setStyleSheet("border: 1px solid #ccc; background-color: #f0f0f0;")
|
||||
|
||||
screenshot_scroll = QScrollArea()
|
||||
screenshot_scroll.setWidget(self.screenshot_label)
|
||||
screenshot_scroll.setWidgetResizable(True)
|
||||
screenshot_scroll.setMinimumHeight(150)
|
||||
|
||||
screenshot_layout.addWidget(screenshot_scroll)
|
||||
self.screenshot_label.setMinimumSize(380, 180)
|
||||
self.screenshot_label.setMaximumHeight(200)
|
||||
self.screenshot_label.setStyleSheet(
|
||||
"QLabel {"
|
||||
" border: 1px solid #e0e0e0;"
|
||||
" border-radius: 8px;"
|
||||
" background-color: #f5f5f5;"
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
# 按钮
|
||||
btn_layout = QHBoxLayout()
|
||||
self.refresh_btn = QPushButton("刷新")
|
||||
self.config_btn = QPushButton("配置")
|
||||
self.quit_btn = QPushButton("退出")
|
||||
self.quit_btn.clicked.connect(self.quit_app)
|
||||
btn_layout.addWidget(self.refresh_btn)
|
||||
btn_layout.addWidget(self.config_btn)
|
||||
btn_layout.addWidget(self.quit_btn)
|
||||
|
||||
# 添加到主布局
|
||||
layout.addWidget(self.title_label)
|
||||
layout.addWidget(self.indicator)
|
||||
layout.addWidget(self.indicator, alignment=Qt.AlignCenter)
|
||||
layout.addWidget(self.score_label)
|
||||
layout.addWidget(self.sentiment_label)
|
||||
layout.addWidget(self.screenshot_label)
|
||||
layout.addWidget(self.status_label)
|
||||
layout.addWidget(screenshot_group)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# 设置窗口标志(无边框、可拖拽)
|
||||
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
self.setStyleSheet(
|
||||
"QWidget {"
|
||||
" background-color: rgba(255, 255, 255, 0.95);"
|
||||
" border-radius: 12px;"
|
||||
"}"
|
||||
)
|
||||
|
||||
def _set_window_title(self):
|
||||
"""设置窗口标题"""
|
||||
@@ -390,14 +513,17 @@ class MainWindow(QWidget):
|
||||
def contextMenuEvent(self, event):
|
||||
"""右键菜单"""
|
||||
context_menu = QMenu(self)
|
||||
refresh_action = QAction("刷新", self)
|
||||
config_action = QAction("配置", self)
|
||||
opacity_action = QAction("透明度", self)
|
||||
quit_action = QAction("退出", self)
|
||||
|
||||
refresh_action.triggered.connect(self._on_refresh)
|
||||
config_action.triggered.connect(self.show_config)
|
||||
quit_action.triggered.connect(self.quit_app)
|
||||
|
||||
context_menu.addAction(refresh_action)
|
||||
context_menu.addAction(config_action)
|
||||
context_menu.addSeparator()
|
||||
context_menu.addAction(quit_action)
|
||||
context_menu.exec(event.globalPos())
|
||||
|
||||
@@ -407,28 +533,55 @@ class MainWindow(QWidget):
|
||||
if dialog.exec() == QDialog.Accepted:
|
||||
self._apply_config()
|
||||
|
||||
def _on_refresh(self):
|
||||
"""刷新回调"""
|
||||
if hasattr(self, '_refresh_callback') and self._refresh_callback:
|
||||
self._refresh_callback()
|
||||
logger.info("执行刷新操作")
|
||||
|
||||
def update_indicator(self, score: int, label: str = None):
|
||||
"""更新指示灯"""
|
||||
if label is None:
|
||||
label = self.indicator.get_description(score)
|
||||
self.indicator.set_value(score, label)
|
||||
self.score_label.setText(f"{score} - {label}")
|
||||
self.score_label.setText(str(score))
|
||||
self.sentiment_label.setText(label)
|
||||
|
||||
color = self._get_score_color(score)
|
||||
self.score_label.setStyleSheet(f"color: {color}; font-size: 24px; font-weight: bold;")
|
||||
|
||||
logger.debug(f"更新指示灯: {score}分 - {label}")
|
||||
|
||||
def _get_score_color(self, score: int) -> str:
|
||||
"""根据分数获取颜色值"""
|
||||
if score < 30:
|
||||
return "#1565C0"
|
||||
elif score < 39:
|
||||
return "#1976D2"
|
||||
elif score < 45:
|
||||
return "#42A5F5"
|
||||
elif score < 55:
|
||||
return "#66BB6A"
|
||||
elif score < 65:
|
||||
return "#FFA726"
|
||||
elif score < 70:
|
||||
return "#FB8C00"
|
||||
else:
|
||||
return "#E53935"
|
||||
|
||||
def update_status(self, text: str):
|
||||
"""更新状态"""
|
||||
self.status_label.setText(text)
|
||||
logger.debug(f"更新状态: {text}")
|
||||
|
||||
def set_refresh_callback(self, callback: Callable):
|
||||
"""设置刷新按钮回调"""
|
||||
self.refresh_btn.clicked.connect(callback)
|
||||
logger.debug("设置刷新按钮回调")
|
||||
"""设置刷新回调"""
|
||||
self._refresh_callback = callback
|
||||
logger.debug("设置刷新回调")
|
||||
|
||||
def set_config_callback(self, callback: Callable):
|
||||
"""设置配置按钮回调"""
|
||||
self.config_btn.clicked.connect(callback)
|
||||
logger.debug("设置配置按钮回调")
|
||||
"""设置配置回调(已废弃,配置直接通过右键菜单调用)"""
|
||||
logger.debug("配置回调已废弃,配置直接通过右键菜单调用")
|
||||
|
||||
def show_message(self, title: str, message: str):
|
||||
"""显示消息"""
|
||||
|
||||
11
rust/.cargo/config.toml
Normal file
11
rust/.cargo/config.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[source.crates-io]
|
||||
replace-with = "ustc"
|
||||
|
||||
[source.ustc]
|
||||
registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"
|
||||
|
||||
[http]
|
||||
check-revoke = false
|
||||
|
||||
[build]
|
||||
target-dir = "target"
|
||||
5346
rust/Cargo.lock
generated
Normal file
5346
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
rust/Cargo.toml
Normal file
52
rust/Cargo.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
[package]
|
||||
name = "guba"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Guba Developer"]
|
||||
description = "股吧人气指示器 - 基于Rust的情感分析工具"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
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]
|
||||
winres = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
|
||||
# 链接标志设置
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
rustflags = ["-C", "link-arg=-mwindows"]
|
||||
|
||||
[[bin]]
|
||||
name = "guba"
|
||||
path = "src/main.rs"
|
||||
5
rust/build.bat
Normal file
5
rust/build.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
set PATH=%USERPROFILE%\.cargo\bin;%ProgramFiles%\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\Hostx64\x64;%PATH%
|
||||
cd /d "%~dp0"
|
||||
cargo build --release
|
||||
pause
|
||||
3
rust/build.ps1
Normal file
3
rust/build.ps1
Normal file
@@ -0,0 +1,3 @@
|
||||
$env:PATH = "C:\Users\dxzq\.cargo\bin;C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\Hostx64\x64;" + $env:PATH
|
||||
cd "h:\学习资料\自用的小工具\guba\rust"
|
||||
cargo build --release
|
||||
35
rust/build.rs
Normal file
35
rust/build.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use std::io;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() -> io::Result<()> {
|
||||
// 设置Windows子系统为windows(不显示控制台)
|
||||
let mut res = winres::WindowsResource::new();
|
||||
|
||||
// 设置应用程序图标 - 使用项目根目录的图标
|
||||
let icon_path = std::path::Path::new("../guba.ico");
|
||||
if icon_path.exists() {
|
||||
res.set_icon("../guba.ico");
|
||||
println!("cargo:rerun-if-changed=../guba.ico");
|
||||
} else {
|
||||
println!("cargo:warning=图标文件未找到: ../guba.ico");
|
||||
}
|
||||
|
||||
// 设置文件属性
|
||||
res.set_language(0x0804); // 中文(简体)
|
||||
res.set("FileDescription", "股吧人气指示器");
|
||||
res.set("ProductName", "股吧人气指示器");
|
||||
res.set("OriginalFilename", "guba.exe");
|
||||
res.set("InternalName", "guba");
|
||||
res.set("CompanyName", "Guba Developer");
|
||||
res.set("LegalCopyright", "Copyright (C) 2024");
|
||||
|
||||
// 关键:设置Windows子系统为windows(GUI程序,不显示控制台)
|
||||
res.set("Subsystem", "windows");
|
||||
|
||||
res.compile()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
// 非Windows平台不需要特殊处理
|
||||
}
|
||||
1
rust/run_build.ps1
Normal file
1
rust/run_build.ps1
Normal file
@@ -0,0 +1 @@
|
||||
& "C:\Users\dxzq\.cargo\bin\rustup.exe" run stable cargo build --release
|
||||
265
rust/src/analyzer.rs
Normal file
265
rust/src/analyzer.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use crate::config::LlmApiConfig;
|
||||
use reqwest::blocking::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.clone().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");
|
||||
}
|
||||
|
||||
/// 测试API配置是否可用
|
||||
pub fn test_connection(&self) -> Result<String, AnalyzerError> {
|
||||
if self.config.api_key.is_empty() {
|
||||
return Err(AnalyzerError::NoApiKey);
|
||||
}
|
||||
|
||||
let url = format!("{}/models", self.config.base_url.trim_end_matches('/'));
|
||||
|
||||
let response = self.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.config.api_key))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().unwrap_or_default();
|
||||
return Err(AnalyzerError::ApiError(format!("HTTP {}: {}", status, body)));
|
||||
}
|
||||
|
||||
// 尝试解析响应获取可用模型列表
|
||||
let result: serde_json::Value = response.json()?;
|
||||
|
||||
if let Some(models) = result.get("data").and_then(|d| d.as_array()) {
|
||||
let model_count = models.len();
|
||||
let model_names: Vec<String> = models.iter()
|
||||
.filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()))
|
||||
.take(5)
|
||||
.collect();
|
||||
|
||||
if model_names.is_empty() {
|
||||
Ok(format!("连接成功!找到 {} 个模型", model_count))
|
||||
} else {
|
||||
Ok(format!("连接成功!找到 {} 个模型,包括: {}", model_count, model_names.join(", ")))
|
||||
}
|
||||
} else {
|
||||
Ok("连接成功!API配置有效".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用临时配置测试API(用于配置窗口)
|
||||
pub fn test_config(config: &LlmApiConfig) -> Result<String, AnalyzerError> {
|
||||
if config.api_key.is_empty() {
|
||||
return Err(AnalyzerError::NoApiKey);
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
let url = format!("{}/models", config.base_url.trim_end_matches('/'));
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_key))
|
||||
.send()?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().unwrap_or_default();
|
||||
return Err(AnalyzerError::ApiError(format!("HTTP {}: {}", status, body)));
|
||||
}
|
||||
|
||||
let result: serde_json::Value = response.json()?;
|
||||
|
||||
if let Some(models) = result.get("data").and_then(|d| d.as_array()) {
|
||||
let model_count = models.len();
|
||||
let model_names: Vec<String> = models.iter()
|
||||
.filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()))
|
||||
.take(5)
|
||||
.collect();
|
||||
|
||||
if model_names.is_empty() {
|
||||
Ok(format!("连接成功!找到 {} 个模型", model_count))
|
||||
} else {
|
||||
Ok(format!("连接成功!找到 {} 个模型,包括: {}", model_count, model_names.join(", ")))
|
||||
}
|
||||
} else {
|
||||
Ok("连接成功!API配置有效".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
207
rust/src/config.rs
Normal file
207
rust/src/config.rs
Normal 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
223
rust/src/database.rs
Normal 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
9
rust/src/lib.rs
Normal 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;
|
||||
477
rust/src/main.rs
Normal file
477
rust/src/main.rs
Normal file
@@ -0,0 +1,477 @@
|
||||
mod config;
|
||||
mod database;
|
||||
mod spider;
|
||||
mod analyzer;
|
||||
mod ui;
|
||||
|
||||
use config::ConfigManager;
|
||||
use database::DatabaseManager;
|
||||
use spider::SpiderManager;
|
||||
use analyzer::LLMAnalyzer;
|
||||
use ui::{AppState, draw_indicator, draw_waveform};
|
||||
|
||||
use eframe::egui;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
struct GubaApp {
|
||||
config_manager: ConfigManager,
|
||||
db: Arc<DatabaseManager>,
|
||||
spider: Arc<SpiderManager>,
|
||||
analyzer: Arc<Mutex<LLMAnalyzer>>,
|
||||
state: Arc<AppState>,
|
||||
stock_data: Arc<Mutex<Vec<(String, f64)>>>,
|
||||
config_open: bool,
|
||||
temp_config: Option<config::Config>,
|
||||
test_status: Arc<Mutex<String>>,
|
||||
is_testing: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl GubaApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, config_manager: ConfigManager) -> Self {
|
||||
// 设置中文字体
|
||||
setup_chinese_fonts(&cc.egui_ctx);
|
||||
|
||||
let config = config_manager.get();
|
||||
|
||||
let db = Arc::new(DatabaseManager::new(&config.database.path)
|
||||
.expect("Failed to initialize database"));
|
||||
|
||||
let spider = Arc::new(SpiderManager::new(config.spider.clone()));
|
||||
|
||||
let analyzer = Arc::new(Mutex::new(LLMAnalyzer::new(config.llm_api.clone())));
|
||||
|
||||
let state = Arc::new(AppState::new());
|
||||
let stock_data = Arc::new(Mutex::new(Vec::new()));
|
||||
let test_status = Arc::new(Mutex::new("点击测试按钮验证API配置".to_string()));
|
||||
let is_testing = Arc::new(Mutex::new(false));
|
||||
|
||||
// 启动后台线程
|
||||
let db_clone = db.clone();
|
||||
let spider_clone = spider.clone();
|
||||
let analyzer_clone = analyzer.clone();
|
||||
let state_clone = state.clone();
|
||||
let stock_data_clone = stock_data.clone();
|
||||
let config_manager_clone = ConfigManager::new("config.json");
|
||||
|
||||
thread::spawn(move || {
|
||||
run_background_task(db_clone, spider_clone, analyzer_clone, state_clone, stock_data_clone, config_manager_clone);
|
||||
});
|
||||
|
||||
// 启动时自动开始运行
|
||||
state.running.store(true, Ordering::SeqCst);
|
||||
|
||||
Self {
|
||||
config_manager,
|
||||
db,
|
||||
spider,
|
||||
analyzer,
|
||||
state,
|
||||
stock_data,
|
||||
config_open: false,
|
||||
temp_config: None,
|
||||
test_status,
|
||||
is_testing,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_config(&mut self) {
|
||||
if let Some(ref temp_config) = self.temp_config {
|
||||
self.config_manager.update(|config| {
|
||||
*config = temp_config.clone();
|
||||
});
|
||||
let _ = self.config_manager.save();
|
||||
}
|
||||
self.config_open = false;
|
||||
self.temp_config = None;
|
||||
}
|
||||
|
||||
fn cancel_config(&mut self) {
|
||||
self.config_open = false;
|
||||
self.temp_config = None;
|
||||
}
|
||||
|
||||
fn test_api_config(&self, temp_config: &config::Config) {
|
||||
let test_status = self.test_status.clone();
|
||||
let is_testing = self.is_testing.clone();
|
||||
let config = temp_config.llm_api.clone();
|
||||
|
||||
*is_testing.lock() = true;
|
||||
*test_status.lock() = "正在测试API连接...".to_string();
|
||||
|
||||
thread::spawn(move || {
|
||||
match LLMAnalyzer::test_config(&config) {
|
||||
Ok(msg) => {
|
||||
*test_status.lock() = format!("✅ {}", msg);
|
||||
}
|
||||
Err(e) => {
|
||||
*test_status.lock() = format!("❌ 测试失败: {}", e);
|
||||
}
|
||||
}
|
||||
*is_testing.lock() = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_chinese_fonts(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
|
||||
// 尝试加载系统字体 - 微软雅黑
|
||||
let font_paths = [
|
||||
("C:/Windows/Fonts/msyh.ttc", "微软雅黑"),
|
||||
("C:/Windows/Fonts/msyhbd.ttc", "微软雅黑粗体"),
|
||||
("C:/Windows/Fonts/simhei.ttf", "黑体"),
|
||||
("C:/Windows/Fonts/simsun.ttc", "宋体"),
|
||||
("C:/Windows/Fonts/arialuni.ttf", "Arial Unicode"),
|
||||
];
|
||||
|
||||
for (idx, (path, _name)) in font_paths.iter().enumerate() {
|
||||
if std::path::Path::new(path).exists() {
|
||||
match std::fs::read(path) {
|
||||
Ok(font_data) => {
|
||||
let font_name = format!("chinese_font_{}", idx);
|
||||
fonts.font_data.insert(
|
||||
font_name.clone(),
|
||||
egui::FontData::from_owned(font_data),
|
||||
);
|
||||
|
||||
// 将中文字体添加到所有字体族的最前面
|
||||
fonts.families
|
||||
.entry(egui::FontFamily::Proportional)
|
||||
.or_default()
|
||||
.insert(0, font_name.clone());
|
||||
fonts.families
|
||||
.entry(egui::FontFamily::Monospace)
|
||||
.or_default()
|
||||
.insert(0, font_name);
|
||||
|
||||
log::info!("成功加载字体: {}", path);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("无法加载字体 {}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
fn run_background_task(
|
||||
db: Arc<DatabaseManager>,
|
||||
spider: Arc<SpiderManager>,
|
||||
analyzer: Arc<Mutex<LLMAnalyzer>>,
|
||||
state: Arc<AppState>,
|
||||
stock_data: Arc<Mutex<Vec<(String, f64)>>>,
|
||||
_config_manager: ConfigManager,
|
||||
) {
|
||||
let mut no_content_count = 0i32;
|
||||
let 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() => {
|
||||
for _id in &new_ids {
|
||||
if let Ok(unanalyzed) = db.get_unanalyzed_comments(1) {
|
||||
if let Some(comment) = unanalyzed.first() {
|
||||
let result = {
|
||||
let mut analyzer_guard = analyzer.lock();
|
||||
analyzer_guard.analyze(&comment.content)
|
||||
};
|
||||
|
||||
match result {
|
||||
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) {
|
||||
// 白色主题配色
|
||||
let panel_frame = egui::Frame::central_panel(&ctx.style())
|
||||
.fill(egui::Color32::from_rgb(250, 250, 252)) // 浅白背景
|
||||
.rounding(10.0);
|
||||
|
||||
egui::CentralPanel::default().frame(panel_frame).show(ctx, |ui| {
|
||||
// 标题使用深色
|
||||
ui.heading(egui::RichText::new("股吧人气指示器").color(egui::Color32::from_rgb(33, 37, 41)).size(24.0));
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let running = self.state.running.load(Ordering::SeqCst);
|
||||
let btn_text = if running { "停止" } else { "开始" };
|
||||
if ui.button(egui::RichText::new(btn_text).color(egui::Color32::WHITE)).clicked() {
|
||||
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;
|
||||
self.temp_config = Some(self.config_manager.get());
|
||||
// 重置测试状态
|
||||
*self.test_status.lock() = "点击测试按钮验证API配置".to_string();
|
||||
}
|
||||
});
|
||||
|
||||
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(egui::RichText::new("当前情绪:").color(egui::Color32::from_rgb(73, 80, 87)).size(16.0));
|
||||
draw_indicator(ui, score, cold, warm);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.label(egui::RichText::new("上证指数走势:").color(egui::Color32::from_rgb(73, 80, 87)).size(16.0));
|
||||
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(egui::RichText::new("状态: ").color(egui::Color32::from_rgb(108, 117, 125)));
|
||||
let status = self.state.status_text.lock();
|
||||
ui.label(egui::RichText::new(status.clone()).color(egui::Color32::from_rgb(73, 80, 87)));
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(format!("爬取次数: {}", self.state.fetch_count.load(Ordering::SeqCst)))
|
||||
.color(egui::Color32::from_rgb(108, 117, 125)));
|
||||
ui.label(egui::RichText::new(format!("分析次数: {}", self.state.analysis_count.load(Ordering::SeqCst)))
|
||||
.color(egui::Color32::from_rgb(108, 117, 125)));
|
||||
});
|
||||
});
|
||||
|
||||
// 配置窗口
|
||||
let mut config_open = self.config_open;
|
||||
let mut should_save = false;
|
||||
let mut should_cancel = false;
|
||||
let mut should_test = false;
|
||||
|
||||
if config_open {
|
||||
if let Some(ref mut temp_config) = self.temp_config {
|
||||
egui::Window::new("配置")
|
||||
.open(&mut config_open)
|
||||
.show(ctx, |ui| {
|
||||
ui.group(|ui| {
|
||||
ui.label(egui::RichText::new("API配置:").color(egui::Color32::from_rgb(33, 37, 41)).size(16.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Base URL:");
|
||||
ui.text_edit_singleline(&mut temp_config.llm_api.base_url);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("API Key:");
|
||||
ui.add(egui::TextEdit::singleline(&mut temp_config.llm_api.api_key).password(true));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Model:");
|
||||
ui.text_edit_singleline(&mut temp_config.llm_api.model);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("超时时间:");
|
||||
ui.add(egui::DragValue::new(&mut temp_config.llm_api.timeout).range(10..=300));
|
||||
ui.label("秒");
|
||||
});
|
||||
|
||||
// 测试按钮和状态显示
|
||||
ui.horizontal(|ui| {
|
||||
let is_testing = *self.is_testing.lock();
|
||||
if ui.button(if is_testing { "测试中..." } else { "测试API" }).clicked() && !is_testing {
|
||||
should_test = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示测试状态
|
||||
let test_status = self.test_status.lock();
|
||||
let status_color = if test_status.starts_with("✅") {
|
||||
egui::Color32::from_rgb(40, 167, 69) // 绿色
|
||||
} else if test_status.starts_with("❌") {
|
||||
egui::Color32::from_rgb(220, 53, 69) // 红色
|
||||
} else {
|
||||
egui::Color32::from_rgb(108, 117, 125) // 灰色
|
||||
};
|
||||
ui.label(egui::RichText::new(test_status.clone()).color(status_color).size(12.0));
|
||||
});
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.label(egui::RichText::new("爬虫配置:").color(egui::Color32::from_rgb(33, 37, 41)).size(16.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("目标URL:");
|
||||
ui.text_edit_singleline(&mut temp_config.spider.target_url);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("XPath:");
|
||||
ui.text_edit_singleline(&mut temp_config.spider.xpath);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("刷新间隔:");
|
||||
ui.add(egui::DragValue::new(&mut temp_config.spider.fetch_interval).range(10..=3600));
|
||||
ui.label("秒");
|
||||
});
|
||||
});
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.label(egui::RichText::new("阈值设置:").color(egui::Color32::from_rgb(33, 37, 41)).size(16.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("冷阈值:");
|
||||
ui.add(egui::DragValue::new(&mut temp_config.ui.thresholds.cold).range(0..=50));
|
||||
ui.label("分");
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("热阈值:");
|
||||
ui.add(egui::DragValue::new(&mut temp_config.ui.thresholds.warm).range(50..=100));
|
||||
ui.label("分");
|
||||
});
|
||||
});
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.label(egui::RichText::new("界面设置:").color(egui::Color32::from_rgb(33, 37, 41)).size(16.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("透明度:");
|
||||
ui.add(egui::Slider::new(&mut temp_config.ui.opacity, 0.3..=1.0));
|
||||
});
|
||||
ui.checkbox(&mut temp_config.ui.is_on_top, "窗口置顶");
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("保存").clicked() {
|
||||
should_save = true;
|
||||
}
|
||||
|
||||
if ui.button("取消").clicked() {
|
||||
should_cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if should_test {
|
||||
if let Some(ref temp_config) = self.temp_config {
|
||||
self.test_api_config(temp_config);
|
||||
}
|
||||
} else if should_save {
|
||||
self.save_config();
|
||||
} else if should_cancel {
|
||||
self.cancel_config();
|
||||
} else {
|
||||
self.config_open = config_open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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([420.0, 650.0])
|
||||
.with_min_inner_size([350.0, 500.0])
|
||||
.with_resizable(true)
|
||||
.with_always_on_top()
|
||||
.with_transparent(false) // 白色背景不需要透明
|
||||
.with_decorations(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
120
rust/src/spider.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
183
rust/src/ui.rs
Normal file
183
rust/src/ui.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use egui::{Color32, FontId, Stroke, Vec2};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32};
|
||||
use std::sync::Arc;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
pub struct AppState {
|
||||
pub running: Arc<AtomicBool>,
|
||||
pub current_score: Arc<AtomicI32>,
|
||||
pub status_text: Arc<Mutex<String>>,
|
||||
pub fetch_count: Arc<AtomicI32>,
|
||||
pub analysis_count: Arc<AtomicI32>,
|
||||
pub no_content_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(Mutex::new("就绪".to_string())),
|
||||
fetch_count: Arc::new(AtomicI32::new(0)),
|
||||
analysis_count: Arc::new(AtomicI32::new(0)),
|
||||
no_content_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(21, 101, 192), // 深蓝
|
||||
31..=38 => Color32::from_rgb(25, 118, 210), // 蓝色
|
||||
39..=44 => Color32::from_rgb(66, 165, 245), // 浅蓝
|
||||
45..=55 => Color32::from_rgb(102, 187, 106), // 绿色
|
||||
56..=64 => Color32::from_rgb(255, 167, 38), // 橙色
|
||||
65..=69 => Color32::from_rgb(251, 140, 0), // 深橙
|
||||
70..=100 => Color32::from_rgb(229, 57, 53), // 红色
|
||||
_ => 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 get_score_description(score: i32) -> &'static str {
|
||||
match score {
|
||||
0..=29 => "极度悲观",
|
||||
30..=38 => "悲观",
|
||||
39..=44 => "偏悲观",
|
||||
45..=55 => "中立",
|
||||
56..=64 => "偏乐观",
|
||||
65..=69 => "乐观",
|
||||
70..=100 => "极度乐观",
|
||||
_ => "无法判断",
|
||||
}
|
||||
}
|
||||
|
||||
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 description = get_score_description(score);
|
||||
let size = Vec2::new(140.0, 160.0);
|
||||
|
||||
let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover());
|
||||
|
||||
let painter = ui.painter();
|
||||
let center = rect.center();
|
||||
|
||||
// 外圈背景 - 白色主题
|
||||
painter.circle_filled(center, 65.0, Color32::from_rgb(240, 240, 245));
|
||||
painter.circle_stroke(center, 65.0, Stroke::new(3.0, Color32::from_rgb(200, 200, 210)));
|
||||
|
||||
// 内圈颜色
|
||||
painter.circle_filled(center, 58.0, color);
|
||||
|
||||
// 发光效果
|
||||
for i in 1..=3 {
|
||||
let alpha = (60 / i) as u8;
|
||||
let glow_color = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
|
||||
painter.circle_filled(center, 58.0 - (i as f32) * 5.0, glow_color);
|
||||
}
|
||||
|
||||
// 中心白色背景
|
||||
painter.circle_filled(center, 45.0, Color32::from_rgb(255, 255, 255));
|
||||
|
||||
// 分数文本 - 深色
|
||||
let score_text = format!("{}", score);
|
||||
painter.text(
|
||||
center - Vec2::new(0.0, 5.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
score_text,
|
||||
FontId::proportional(42.0),
|
||||
color,
|
||||
);
|
||||
|
||||
// 标签文本 - 深色
|
||||
painter.text(
|
||||
center + Vec2::new(0.0, 28.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
label,
|
||||
FontId::proportional(16.0),
|
||||
Color32::from_rgb(50, 50, 55),
|
||||
);
|
||||
|
||||
// 描述文本(在指示灯下方)
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new(description).size(14.0).color(Color32::from_rgb(100, 100, 110)));
|
||||
}
|
||||
|
||||
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, 8.0, Color32::from_rgb(255, 255, 255));
|
||||
painter.rect_stroke(rect, 8.0, Stroke::new(1.0, Color32::from_rgb(220, 220, 230)));
|
||||
|
||||
if data.is_empty() {
|
||||
let text = "暂无数据";
|
||||
painter.text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
text,
|
||||
FontId::proportional(14.0),
|
||||
Color32::from_rgb(150, 150, 160),
|
||||
);
|
||||
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 = 15.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..=4 {
|
||||
let y = rect.max.y - padding - (i as f32) * (draw_height / 4.0);
|
||||
painter.line_segment(
|
||||
[egui::pos2(rect.min.x + padding, y), egui::pos2(rect.max.x - padding, y)],
|
||||
Stroke::new(1.0, Color32::from_rgba_unmultiplied(200, 200, 210, 100)),
|
||||
);
|
||||
}
|
||||
|
||||
// 绘制数据线
|
||||
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, 123, 255)),
|
||||
);
|
||||
}
|
||||
|
||||
// 绘制数据点
|
||||
for i in 0..data.len() {
|
||||
let x = rect.min.x + padding + (i as f32) * step_x;
|
||||
let y = rect.max.y - padding - ((values[i] - min_val) / range * draw_height as f64) as f32;
|
||||
|
||||
painter.circle_filled(egui::pos2(x, y), 3.0, Color32::from_rgb(0, 150, 255));
|
||||
}
|
||||
}
|
||||
1
rust/target/.rustc_info.json
Normal file
1
rust/target/.rustc_info.json
Normal file
@@ -0,0 +1 @@
|
||||
{"rustc_fingerprint":7781841955443070316,"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":""},"12004014463585500860":{"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
3
rust/target/CACHEDIR.TAG
Normal 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/
|
||||
0
rust/target/debug/.cargo-lock
Normal file
0
rust/target/debug/.cargo-lock
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
4847f7e9f4da1166
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"default\", \"gvar-alloc\", \"std\", \"variable-fonts\"]","declared_features":"[\"default\", \"gvar-alloc\", \"libm\", \"std\", \"variable-fonts\"]","target":11794240345726188307,"profile":2241668132362809309,"path":1115666445740884175,"deps":[[4945662571602681759,"ab_glyph_rasterizer",false,16024756347710541569],[5327495677235252177,"owned_ttf_parser",false,7560861568512246035]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\ab_glyph-bcff49c6bcf4e959\\dep-lib-ab_glyph","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
010f79d5fe5e63de
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"libm\", \"std\"]","target":4335109392423587462,"profile":2241668132362809309,"path":17331483400901201052,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\ab_glyph_rasterizer-ea78cc11d10f44eb\\dep-lib-ab_glyph_rasterizer","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
b42071b46bc19916
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[]","declared_features":"[\"core\", \"default\", \"rustc-dep-of-std\", \"std\"]","target":6569825234462323107,"profile":2241668132362809309,"path":14005576116579692866,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\adler2-848c7795595b4eee\\dep-lib-adler2","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
1f4c1e64e9627184
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"perf-literal\", \"std\"]","declared_features":"[\"default\", \"logging\", \"perf-literal\", \"std\"]","target":7534583537114156500,"profile":2241668132362809309,"path":6330493255813786891,"deps":[[1363051979936526615,"memchr",false,5209290497240175256]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\aho-corasick-8c7f373e7f598f44\\dep-lib-aho_corasick","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
d13559803ab75614
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"auto\", \"wincon\"]","declared_features":"[\"auto\", \"default\", \"test\", \"wincon\"]","target":11278316191512382530,"profile":3955859983594325544,"path":16521012920632208164,"deps":[[384403243491392785,"colorchoice",false,9421125508953714795],[3838739200784931934,"anstyle_wincon",false,4711876006903878989],[5652275617566266604,"anstyle_query",false,3836625914155588317],[7483871650937086505,"anstyle",false,15042275264504572659],[7727459912076845739,"is_terminal_polyfill",false,14006602505513963871],[11410867133969439143,"anstyle_parse",false,11921232090383382216],[17716308468579268865,"utf8parse",false,7597293533415014150]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\anstream-f2579b506e3e0bfc\\dep-lib-anstream","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
f3aa70daa7e5c0d0
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":6165884447290141869,"profile":3955859983594325544,"path":11438166778606052570,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\anstyle-45c3bf4db1af0155\\dep-lib-anstyle","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
c8f649c06cb970a5
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"default\", \"utf8\"]","declared_features":"[\"core\", \"default\", \"utf8\"]","target":10225663410500332907,"profile":3955859983594325544,"path":7867407816319815545,"deps":[[17716308468579268865,"utf8parse",false,7597293533415014150]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\anstyle-parse-f0be0be4bdf64e20\\dep-lib-anstyle_parse","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
dd5a4d0df16e3e35
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[]","declared_features":"[]","target":10705714425685373190,"profile":112744067883639982,"path":7204225166645560339,"deps":[[6568467691589961976,"windows_sys",false,5597386605632096345]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\anstyle-query-2ee58c4a29363595\\dep-lib-anstyle_query","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
4d15318143f26341
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[]","declared_features":"[]","target":2212462823922218827,"profile":112744067883639982,"path":15733721433072046161,"deps":[[6568467691589961976,"windows_sys",false,5597386605632096345],[7483871650937086505,"anstyle",false,15042275264504572659],[10122175328121400386,"once_cell_polyfill",false,7342119466612756690]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\anstyle-wincon-11e81c383a2b0c9a\\dep-lib-anstyle_wincon","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
757413c21f0ee346
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[]","declared_features":"[\"borsh\", \"default\", \"serde\", \"std\", \"zeroize\"]","target":12564975964323158710,"profile":2241668132362809309,"path":16532264511490728365,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\arrayvec-26d0fa1551dc192c\\dep-lib-arrayvec","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
27d043a8dcb06a6e
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":2241668132362809309,"path":9409909198274581351,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\atomic-waker-37dc9b4ba20b5be1\\dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
f38f021464a68e70
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":2225463790103693989,"path":12167893325356064478,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\autocfg-0d9fab24a14ca87a\\dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
b1f68fecf083c8f4
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":2241668132362809309,"path":11577642178216725785,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\base64-3a49147d849773fa\\dep-lib-base64","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
61a19489d1d4f555
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":2241668132362809309,"path":14798093905395933029,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\base64-f02bcad3099b98e1\\dep-lib-base64","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
a56f7fc2bad8aae6
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":13691508551864173732,"profile":2241668132362809309,"path":3847603730721498418,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\byteorder-lite-0929753d936ffea7\\dep-lib-byteorder_lite","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
77e0000b0f9cf504
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"extra-platforms\", \"serde\", \"std\"]","target":11402411492164584411,"profile":13827760451848848284,"path":337730501308155450,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\bytes-d1b03353c603f31d\\dep-lib-bytes","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
BIN
rust/target/debug/.fingerprint/cc-084eedec936242fd/dep-lib-cc
Normal file
BIN
rust/target/debug/.fingerprint/cc-084eedec936242fd/dep-lib-cc
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
382e0fd6f4fa1191
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":8323788817864214825,"features":"[]","declared_features":"[\"jobserver\", \"parallel\"]","target":11042037588551934598,"profile":4333757155065362140,"path":15224487477377180014,"deps":[[8410525223747752176,"shlex",false,12860003960247248179],[9159843920629750842,"find_msvc_tools",false,16935371212187053638]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\cc-084eedec936242fd\\dep-lib-cc","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user