Compare commits

...

17 Commits

Author SHA1 Message Date
3ae0eaa9c1 Update Rust version: fix console window, add API test, update README 2026-04-07 17:27:38 +08:00
e065c41d6b 生成了exe文件 2026-03-23 17:30:20 +08:00
bf77212793 chore: 更新Rust构建缓存和依赖文件 2026-03-23 15:38:55 +08:00
9103da519c build: 添加构建rust脚本并配置MSVC链接器路径
添加PowerShell和批处理构建脚本
配置Cargo使用Visual Studio的链接器
更新依赖项的构建指纹文件
2026-03-02 16:52:24 +08:00
b35e235682 feat: 新增Rust项目基础框架和核心功能模块
- 添加Cargo.toml配置文件,定义项目元信息和依赖项
- 实现配置管理模块(ConfigManager),支持JSON配置读写
- 添加爬虫模块(SpiderManager),支持网页内容抓取和解析
- 实现数据库模块(DatabaseManager),使用SQLite存储评论数据
- 添加LLM分析模块(LLMAnalyzer),支持调用AI接口进行情绪分析
- 实现UI界面模块,包含指标显示和波形图绘制功能
- 添加项目文档和截图资源
2026-02-27 17:03:32 +08:00
9096a38ad2 修订readme文件 2026-02-05 17:08:49 +08:00
421fea062b docs(README): 更新PyInstaller打包说明并添加参数解释
添加详细的打包命令和参数说明,包括清理脚本和打包日志信息
2026-02-03 17:19:24 +08:00
eb8de0808e chore: 添加main.exe和更新sse_screenshot.png 2026-01-29 16:28:51 +08:00
de9edf255d 多了一个截图 2026-01-29 13:02:44 +08:00
ee721e9abe feat(ui): 重构配置对话框和主窗口界面
- 将配置对话框改为标签页布局,分为API配置、爬虫配置和界面设置
- 优化主窗口UI,包括指示灯样式、分数显示和截图区域
- 添加窗口圆角和半透明效果
- 改进右键菜单功能,增加刷新操作
- 优化状态显示和分数颜色标识
2026-01-28 17:30:30 +08:00
f256bd0852 refactor: 重构配置管理和截图功能,切换LLM提供商至智谱AI
重构配置管理器以支持打包环境路径处理
将LLM分析器从OpenAI迁移至智谱AI API
替换Playwright截图功能为Selenium实现
更新默认配置中的API端点和模型
2026-01-27 11:11:24 +08:00
e8210b4d88 refactor(配置管理): 将playwright配置替换为chrome路径配置并更新使用说明
更新配置管理器、主窗口界面和使用说明文档,将原有的playwright目录配置改为chrome浏览器路径配置
2026-01-23 14:12:10 +08:00
df9348ca95 refactor(spider): 将Playwright替换为Selenium实现网页截图功能。
这个版本的exe可以运行了。

移除Playwright相关代码,改用Selenium实现上证所网页截图功能。修改包括:
1. 删除Playwright依赖和配置逻辑
2. 添加Selenium相关配置和异常处理
3. 优化截图流程和日志记录
同时删除不再需要的build_new.bat打包脚本。
2026-01-23 11:55:31 +08:00
5d79cd9e8f feat(配置对话框): 添加Playwright目录浏览功能
在配置对话框中新增Playwright目录选择功能,允许用户手动指定浏览器路径
2026-01-22 16:19:57 +08:00
10ce2ba17b feat(配置): 增加playwright_dir配置项并优化浏览器路径查找逻辑
添加playwright_dir配置项以支持自定义浏览器路径
优化打包环境和开发环境下的浏览器路径查找逻辑,优先使用配置中的路径
删除不再使用的build.bat打包脚本
2026-01-21 11:54:21 +08:00
4b406a3727 build: 更新构建配置并添加打包脚本
- 修改build.spec中的路径处理逻辑,使用sys.argv[0]获取当前目录
- 移除PySide6.Qt6Compat的隐藏导入
- 新增build_new.bat打包脚本,包含依赖安装和清理流程
2026-01-21 09:08:29 +08:00
346d4a7c99 fix(打包): 改进打包脚本和Playwright浏览器路径处理
- 更新build.bat添加依赖检查和清理步骤
- 修改build.spec配置以正确处理Playwright依赖
- 增强SpiderManager中Playwright路径的查找逻辑
- 添加错误处理和调试信息
2026-01-20 15:11:03 +08:00
1018 changed files with 24280 additions and 437 deletions

9
.gitignore vendored
View File

@@ -52,3 +52,12 @@ Thumbs.db
*.tmp
*.bak
test*.*
# 其它
main.exe
sse_screen*.png
# Rust
target/
rust/target/
Cargo.lock

View File

@@ -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

File diff suppressed because it is too large Load Diff

63
Cargo.toml Normal file
View 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
View File

@@ -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

View File

@@ -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

View File

@@ -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',
)

View File

@@ -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()

View File

@@ -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

BIN
main.exe

Binary file not shown.

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

52
rust/Cargo.toml Normal file
View 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
View 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
View 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
View 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子系统为windowsGUI程序不显示控制台
res.set("Subsystem", "windows");
res.compile()
}
#[cfg(not(windows))]
fn main() {
// 非Windows平台不需要特殊处理
}

1
rust/run_build.ps1 Normal file
View File

@@ -0,0 +1 @@
& "C:\Users\dxzq\.cargo\bin\rustup.exe" run stable cargo build --release

265
rust/src/analyzer.rs Normal file
View 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
View File

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

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

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

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

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

477
rust/src/main.rs Normal file
View 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
View File

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

183
rust/src/ui.rs Normal file
View 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));
}
}

View 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
View File

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

View File

View File

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

View File

@@ -0,0 +1 @@
4847f7e9f4da1166

View File

@@ -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}

View File

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

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
b42071b46bc19916

View File

@@ -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}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
1f4c1e64e9627184

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
d13559803ab75614

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
f3aa70daa7e5c0d0

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
c8f649c06cb970a5

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
dd5a4d0df16e3e35

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
4d15318143f26341

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
757413c21f0ee346

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
27d043a8dcb06a6e

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
f38f021464a68e70

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
b1f68fecf083c8f4

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
61a19489d1d4f555

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
a56f7fc2bad8aae6

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
77e0000b0f9cf504

View File

@@ -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}

View File

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

View File

@@ -0,0 +1 @@
382e0fd6f4fa1191

View File

@@ -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}

View File

@@ -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