删除无用文件
This commit is contained in:
-161
@@ -1,161 +0,0 @@
|
||||
# 定时搜索脚本使用说明
|
||||
|
||||
## 脚本功能
|
||||
|
||||
`cron_crawl.py` 是一个用于定时搜索广西政府采购网公告的脚本,具有以下功能:
|
||||
|
||||
- ✅ 从 `gx_gp_monitor/config/config.yaml` 读取关键词配置
|
||||
- ✅ 搜索最新的公告(数据库中没有的)
|
||||
- ✅ 自动筛选匹配关键词的公告
|
||||
- ✅ 将筛选结果保存到数据库
|
||||
- ✅ 以卡片形式发送企业微信通知
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
project/
|
||||
├── venv/ # Python虚拟环境
|
||||
├── gx_gp_monitor/
|
||||
│ ├── cron_crawl.py # 定时搜索主脚本
|
||||
│ └── config/config.yaml # 配置文件
|
||||
├── run_cron_crawl.sh # 启动脚本(自动激活虚拟环境)
|
||||
├── CRON_README.md # 使用说明
|
||||
└── cron_example.txt # crontab配置示例
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 使用启动脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 在项目根目录下运行
|
||||
./run_cron_crawl.sh
|
||||
```
|
||||
|
||||
### 2. 直接运行Python脚本
|
||||
|
||||
```bash
|
||||
cd gx_gp_monitor
|
||||
python cron_crawl.py
|
||||
```
|
||||
|
||||
### 3. 使用crontab定时运行
|
||||
|
||||
在Ubuntu系统上设置定时任务:
|
||||
|
||||
```bash
|
||||
# 编辑crontab
|
||||
crontab -e
|
||||
|
||||
# 添加定时任务(例如:每小时执行一次)
|
||||
0 * * * * cd /home/v6ole/pyproject/GX-gp-notify && ./run_cron_crawl.sh >> /home/v6ole/pyproject/GX-gp-notify/logs/cron.log 2>&1
|
||||
|
||||
# 或者每30分钟执行一次
|
||||
*/30 * * * * cd /home/v6ole/pyproject/GX-gp-notify && ./run_cron_crawl.sh >> /home/v6ole/pyproject/GX-gp-notify/logs/cron.log 2>&1
|
||||
|
||||
# 或者每天早上9点执行
|
||||
0 9 * * * cd /home/v6ole/pyproject/GX-gp-notify && ./run_cron_crawl.sh >> /home/v6ole/pyproject/GX-gp-notify/logs/cron.log 2>&1
|
||||
```
|
||||
|
||||
**注意**: 脚本会自动激活项目中的虚拟环境(`venv`目录),无需手动处理虚拟环境。
|
||||
|
||||
### 3. 脚本输出示例
|
||||
|
||||
```
|
||||
=== 定时搜索任务完成 ===
|
||||
总共搜索: 1300 条公告
|
||||
新增公告: 5 条
|
||||
关键词筛选: 2 条
|
||||
保存到数据库: 2 条
|
||||
企业微信通知: 成功
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 关键词配置
|
||||
|
||||
在 `gx_gp_monitor/config/config.yaml` 中配置关键词:
|
||||
|
||||
```yaml
|
||||
crawler:
|
||||
keyword: ["大化", "信息化"] # 支持多个关键词
|
||||
```
|
||||
|
||||
### 企业微信配置
|
||||
|
||||
确保企业微信配置正确:
|
||||
|
||||
```yaml
|
||||
wechat_app:
|
||||
enabled: true # 必须启用
|
||||
corp_id: "your_corp_id"
|
||||
agent_id: "your_agent_id"
|
||||
secret: "your_secret"
|
||||
```
|
||||
|
||||
### 数据库配置
|
||||
|
||||
确保数据库配置正确:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
enabled: true # 必须启用
|
||||
host: "your_db_host"
|
||||
port: 5432
|
||||
name: "your_db_name"
|
||||
user: "your_db_user"
|
||||
password: "your_db_password"
|
||||
```
|
||||
|
||||
## 日志查看
|
||||
|
||||
脚本运行日志保存在 `logs/gx_gp_monitor.log`,crontab输出日志保存在您配置的文件中。
|
||||
|
||||
```bash
|
||||
# 查看最新日志
|
||||
tail -f logs/gx_gp_monitor.log
|
||||
|
||||
# 查看crontab日志
|
||||
tail -f /home/v6ole/pyproject/GX-gp-notify/logs/cron.log
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **权限设置**:确保脚本有执行权限
|
||||
```bash
|
||||
chmod +x cron_crawl.py
|
||||
```
|
||||
|
||||
2. **Python环境**:确保Python环境和依赖已正确安装
|
||||
```bash
|
||||
pip install -r gx_gp_monitor/requirements.txt
|
||||
```
|
||||
|
||||
3. **路径配置**:crontab中的路径必须是绝对路径
|
||||
|
||||
4. **时区设置**:确保系统时区设置正确,影响定时任务执行时间
|
||||
|
||||
5. **资源消耗**:搜索过程中会消耗一定的CPU和网络资源,建议在非高峰期运行
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 脚本无法运行
|
||||
- 检查Python环境和依赖
|
||||
- 检查配置文件路径
|
||||
- 检查数据库连接
|
||||
|
||||
### 企业微信通知失败
|
||||
- 检查企业微信配置
|
||||
- 检查网络连接
|
||||
- 查看企业微信应用权限
|
||||
|
||||
### 数据库连接失败
|
||||
- 检查数据库配置
|
||||
- 检查数据库服务状态
|
||||
- 检查网络连接
|
||||
|
||||
### crontab不执行
|
||||
- 检查crontab语法
|
||||
- 检查脚本路径
|
||||
- 检查用户权限
|
||||
- 查看系统日志:`sudo grep CRON /var/log/syslog`
|
||||
-308
@@ -1,308 +0,0 @@
|
||||
# 广西政府采购网公告监控系统 - 企业微信集成
|
||||
|
||||
## 概述
|
||||
|
||||
本系统已集成企业微信功能,支持:
|
||||
- 企业微信应用消息推送
|
||||
- 回调服务器处理用户交互
|
||||
- 应用菜单管理
|
||||
- 实时搜索和智能筛选
|
||||
|
||||
## 配置要求
|
||||
|
||||
### 1. 企业微信应用配置
|
||||
|
||||
在 `config/config.yaml` 中配置企业微信信息:
|
||||
|
||||
```yaml
|
||||
# 企业微信应用配置
|
||||
wechat_app:
|
||||
enabled: true # 是否启用企业微信通知
|
||||
corp_id: "ww69e8e44636f47780" # 企业ID
|
||||
agent_id: "1000007" # 应用ID
|
||||
secret: "SmelCwKFoL0E9ATWFzr-w7gsfXBTN72lT1UqnNd0HpI" # 应用Secret
|
||||
token: "DmvL98cAF6x9CFtQZwqD2emGL8S7HxA" # Token
|
||||
encoding_aes_key: "yAc4OoSCP92YTefHXYfw27WeG9oF11W9d6nw6QYlU3D" # 消息加密Key
|
||||
port: 18001 # 服务端口
|
||||
```
|
||||
|
||||
### 2. 企业微信管理后台设置
|
||||
|
||||
1. **设置回调URL**
|
||||
- 在企业微信管理后台的应用设置中
|
||||
- 设置回调URL为:`http://your-server-ip:18001/api/v1/wechat/callback`
|
||||
- Token和EncodingAESKey需要与配置文件一致
|
||||
|
||||
2. **设置应用可见范围**
|
||||
- 配置应用对哪些部门或成员可见
|
||||
- 确保有基础接口权限
|
||||
|
||||
## 功能说明
|
||||
|
||||
### 1. 消息推送
|
||||
|
||||
系统会在以下情况下自动推送消息到企业微信:
|
||||
|
||||
- **搜索完成通知**:每次执行搜索后,推送筛选结果
|
||||
- **关键词匹配通知**:发现匹配关键词的公告时推送
|
||||
- **系统状态通知**:系统启动、错误等重要事件
|
||||
|
||||
消息格式参考 `企业微信消息示例.md`
|
||||
|
||||
### 2. 应用菜单
|
||||
|
||||
系统提供全新的菜单结构,包含三大功能模块:
|
||||
|
||||
#### 🚀 监控操作
|
||||
- **立即搜索**:执行一次完整的公告搜索,获取最新数据
|
||||
- **今日统计**:查看今日公告统计信息和数据概览
|
||||
- **关键词搜索**:输入关键词搜索相关公告
|
||||
- **最新公告**:选择公告来源查看该来源的最新10条公告
|
||||
|
||||
#### ⚙️ 系统管理
|
||||
- **关键词管理**:查看当前系统监控的关键词
|
||||
- **系统状态**:查看各组件运行状态和系统信息
|
||||
- **清理缓存**:清理系统缓存,提升性能
|
||||
|
||||
#### ❓ 帮助
|
||||
- **使用说明**:查看详细功能介绍和使用指南
|
||||
|
||||
### 3. 文本交互
|
||||
|
||||
支持丰富的文本命令交互:
|
||||
|
||||
#### 🔍 搜索功能
|
||||
```
|
||||
# 直接关键词搜索
|
||||
大化 # 搜索"大化"相关公告
|
||||
信息化 政府采购 # 搜索多个关键词
|
||||
|
||||
# 关键词命令
|
||||
关键词 大化 # 明确指定关键词搜索
|
||||
搜索 政府采购 # 搜索政府采购相关公告
|
||||
```
|
||||
|
||||
#### 📊 数据查询
|
||||
```
|
||||
# 统计信息
|
||||
总结 # 查看今日统计
|
||||
统计 # 查看系统统计信息
|
||||
|
||||
# 最新数据
|
||||
最新公告 # 选择公告来源查看最新公告
|
||||
最新 # 同上
|
||||
|
||||
# 公告来源选择 (发送数字)
|
||||
1 # 查看采购公告
|
||||
2 # 查看结果公告
|
||||
3 # 查看更正公告
|
||||
全部 # 查看全部公告
|
||||
```
|
||||
|
||||
#### 🛠️ 系统管理
|
||||
```
|
||||
# 系统状态
|
||||
系统状态 # 查看系统运行状态
|
||||
状态 # 同上
|
||||
|
||||
# 缓存管理
|
||||
清理缓存 # 清理系统缓存
|
||||
清理 # 同上
|
||||
|
||||
# 关键词管理
|
||||
查看关键词 # 查看当前监控关键词
|
||||
添加关键词 [关键词] # 添加新关键词(开发中)
|
||||
删除关键词 [关键词] # 删除关键词(开发中)
|
||||
```
|
||||
|
||||
#### ℹ️ 信息查询
|
||||
```
|
||||
# 帮助信息
|
||||
帮助 # 显示详细使用说明
|
||||
help # 同上
|
||||
|
||||
# 系统信息
|
||||
关于系统 # 查看系统详细信息
|
||||
关于 # 同上
|
||||
|
||||
# 联系方式
|
||||
联系我们 # 查看联系信息
|
||||
联系 # 同上
|
||||
```
|
||||
|
||||
#### 💡 智能交互
|
||||
- **自动识别**:直接发送关键词即可搜索
|
||||
- **多关键词**:用空格分隔多个关键词
|
||||
- **模糊匹配**:支持标题和内容搜索
|
||||
- **实时反馈**:所有操作都有即时响应
|
||||
|
||||
## 部署和运行
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r gx_gp_monitor/requirements.txt
|
||||
```
|
||||
|
||||
### 2. 测试功能
|
||||
|
||||
```bash
|
||||
# 测试企业微信模块
|
||||
python test_wechat_server.py
|
||||
```
|
||||
|
||||
### 3. 启动回调服务器
|
||||
|
||||
#### 方法1:使用 uWSGI(推荐生产环境)
|
||||
|
||||
```bash
|
||||
# 使用启动脚本
|
||||
./start_uwsgi_server.sh
|
||||
|
||||
# 或直接启动
|
||||
uwsgi --ini uwsgi_wechat.ini
|
||||
```
|
||||
|
||||
#### 方法2:使用 Gunicorn
|
||||
|
||||
```bash
|
||||
# 使用启动脚本
|
||||
./start_wechat_server.sh
|
||||
|
||||
# 或直接启动
|
||||
gunicorn --bind 0.0.0.0:18001 app:app
|
||||
```
|
||||
|
||||
#### 方法3:开发环境启动
|
||||
|
||||
```bash
|
||||
# 使用专用启动脚本
|
||||
python wechat_server.py
|
||||
|
||||
# 或使用主程序
|
||||
python gx_gp_monitor/main.py wechat-server
|
||||
```
|
||||
|
||||
### 4. 管理菜单
|
||||
|
||||
```bash
|
||||
# 创建菜单
|
||||
python gx_gp_monitor/main.py wechat-menu --action create
|
||||
|
||||
# 删除菜单
|
||||
python gx_gp_monitor/main.py wechat-menu --action delete
|
||||
|
||||
# 获取菜单信息
|
||||
python gx_gp_monitor/main.py wechat-menu --action get
|
||||
|
||||
# 测试菜单操作
|
||||
python gx_gp_monitor/main.py wechat-menu --action test
|
||||
```
|
||||
|
||||
### 5. 正常搜索
|
||||
|
||||
```bash
|
||||
# 执行搜索(会自动推送消息)
|
||||
python gx_gp_monitor/main.py crawl
|
||||
```
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 1. 回调服务器
|
||||
|
||||
- **框架**:Flask
|
||||
- **端口**:18001
|
||||
- **路径**:`/api/v1/wechat/callback`
|
||||
- **功能**:处理企业微信的回调消息和URL验证
|
||||
|
||||
### 2. 消息加解密
|
||||
|
||||
- **库来源**:企业微信官方Python示例
|
||||
- **位置**:`gx_gp_monitor/wechat/WXBizMsgCrypt.py`
|
||||
- **功能**:实现消息加密、解密和签名验证
|
||||
|
||||
### 3. 消息处理
|
||||
|
||||
- **事件处理**:关注、取消关注、菜单点击
|
||||
- **文本处理**:关键词搜索、手动命令
|
||||
- **响应格式**:XML格式的企业微信消息
|
||||
|
||||
### 4. 菜单管理
|
||||
|
||||
- **创建菜单**:自动创建预定义菜单
|
||||
- **删除菜单**:清理现有菜单
|
||||
- **获取菜单**:查看当前菜单配置
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 回调URL验证失败
|
||||
|
||||
- 检查Token和EncodingAESKey是否正确配置
|
||||
- 确保服务器可从公网访问
|
||||
- 检查端口18001是否开放
|
||||
|
||||
### 2. 消息推送失败
|
||||
|
||||
- 检查企业微信应用配置(corp_id, agent_id, secret)
|
||||
- 确认应用有消息发送权限
|
||||
- 查看应用可见范围设置
|
||||
|
||||
### 3. 菜单创建失败
|
||||
|
||||
- 检查应用是否有菜单管理权限
|
||||
- 确认Token有效且有管理员权限
|
||||
|
||||
### 4. 导入错误
|
||||
|
||||
```bash
|
||||
# 确保依赖已安装
|
||||
pip install flask pycryptodome
|
||||
|
||||
# 测试导入
|
||||
python -c "from gx_gp_monitor.wechat.callback_server import WeChatCallbackServer; print('OK')"
|
||||
```
|
||||
|
||||
## 日志查看
|
||||
|
||||
系统会记录详细的企业微信操作日志:
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
tail -f logs/gx_gp_monitor.log | grep wechat
|
||||
```
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **保护配置文件**:不要将包含密钥的配置文件提交到版本控制
|
||||
2. **网络安全**:确保回调服务器只接受来自企业微信的请求
|
||||
3. **权限控制**:合理设置应用可见范围和权限
|
||||
4. **定期更新**:定期更新企业微信应用Secret
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
gx_gp_monitor/wechat/
|
||||
├── __init__.py # 模块初始化
|
||||
├── callback_server.py # Flask回调服务器
|
||||
├── message_handler.py # 消息处理逻辑
|
||||
├── menu_manager.py # 菜单管理
|
||||
├── WXBizMsgCrypt.py # 企业微信加密库
|
||||
└── ierror.py # 错误码定义
|
||||
```
|
||||
|
||||
### 扩展功能
|
||||
|
||||
如需添加新功能,可以:
|
||||
|
||||
1. 在 `message_handler.py` 中添加新的消息处理逻辑
|
||||
2. 在 `menu_manager.py` 中修改菜单配置
|
||||
3. 在 `callback_server.py` 中添加新的路由
|
||||
|
||||
## 联系支持
|
||||
|
||||
如有问题,请检查:
|
||||
1. 系统日志
|
||||
2. 企业微信管理后台错误信息
|
||||
3. 网络连接和配置
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,7 +25,7 @@ crawler:
|
||||
proxies: [] # 代理列表
|
||||
request_delay: 1.0 # 请求间延迟
|
||||
request_delay_max: 3.0 # 请求间最大延迟
|
||||
keyword: ["大化", "信息化"] # 关键词筛选(支持多个关键词)
|
||||
keyword: ["大化"] # 关键词筛选(支持多个关键词)
|
||||
start_date: "" # 开始日期 (YYYY-MM-DD)
|
||||
end_date: "" # 结束日期 (YYYY-MM-DD)
|
||||
max_pages: 10 # 最大页数
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+218
-11
@@ -182,7 +182,47 @@ class DatabaseManager:
|
||||
|
||||
# 创建表的SQL语句
|
||||
create_tables_sql = """
|
||||
-- 公告表
|
||||
-- 定时搜索公告表(关键词匹配专用)
|
||||
CREATE TABLE IF NOT EXISTS auto_announcements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
publish_date TIMESTAMP NOT NULL,
|
||||
purchase_name VARCHAR(200),
|
||||
content_url TEXT,
|
||||
source_code VARCHAR(50) NOT NULL,
|
||||
source_name VARCHAR(100) NOT NULL,
|
||||
announcement_type VARCHAR(50) NOT NULL,
|
||||
crawled_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
content_hash VARCHAR(32) UNIQUE,
|
||||
keyword_matched BOOLEAN DEFAULT FALSE,
|
||||
date_filtered BOOLEAN DEFAULT TRUE,
|
||||
is_new BOOLEAN DEFAULT TRUE,
|
||||
is_today BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 手动搜索公告表(全量数据专用)
|
||||
CREATE TABLE IF NOT EXISTS manual_announcements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
publish_date TIMESTAMP NOT NULL,
|
||||
purchase_name VARCHAR(200),
|
||||
content_url TEXT,
|
||||
source_code VARCHAR(50) NOT NULL,
|
||||
source_name VARCHAR(100) NOT NULL,
|
||||
announcement_type VARCHAR(50) NOT NULL,
|
||||
crawled_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
content_hash VARCHAR(32),
|
||||
keyword_matched BOOLEAN DEFAULT FALSE,
|
||||
date_filtered BOOLEAN DEFAULT TRUE,
|
||||
is_new BOOLEAN DEFAULT TRUE,
|
||||
is_today BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 原公告表(保留兼容性)
|
||||
CREATE TABLE IF NOT EXISTS announcements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
@@ -195,6 +235,7 @@ class DatabaseManager:
|
||||
crawled_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
crawl_mode VARCHAR(20) DEFAULT 'auto',
|
||||
content_hash VARCHAR(32) UNIQUE,
|
||||
keyword_matched BOOLEAN DEFAULT FALSE,
|
||||
date_filtered BOOLEAN DEFAULT TRUE,
|
||||
@@ -331,9 +372,9 @@ class DatabaseManager:
|
||||
sql = """
|
||||
INSERT INTO announcements (
|
||||
title, publish_date, purchase_name, content_url, source_code, source_name,
|
||||
announcement_type, crawled_at, content_hash, keyword_matched,
|
||||
announcement_type, crawled_at, crawl_mode, content_hash, keyword_matched,
|
||||
date_filtered, is_new, is_today
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (content_hash) DO NOTHING
|
||||
"""
|
||||
|
||||
@@ -348,6 +389,7 @@ class DatabaseManager:
|
||||
announcement.source_name,
|
||||
announcement.announcement_type.value,
|
||||
announcement.crawled_at,
|
||||
announcement.crawl_mode,
|
||||
announcement.content_hash,
|
||||
announcement.keyword_matched,
|
||||
announcement.date_filtered,
|
||||
@@ -365,6 +407,94 @@ class DatabaseManager:
|
||||
logger.error(f"批量保存公告失败: {str(e)}")
|
||||
return 0
|
||||
|
||||
def save_announcements_batch_to_table(self, announcements: List[Announcement], table_name: str) -> int:
|
||||
"""
|
||||
批量保存公告到指定表
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
table_name: 目标表名 ("auto_announcements" 或 "manual_announcements")
|
||||
|
||||
Returns:
|
||||
int: 成功保存的数量
|
||||
"""
|
||||
if not self.config.database.enabled:
|
||||
return 0
|
||||
|
||||
if not announcements:
|
||||
return 0
|
||||
|
||||
# 为没有哈希的公告生成哈希
|
||||
for announcement in announcements:
|
||||
if not announcement.content_hash:
|
||||
announcement.generate_content_hash()
|
||||
|
||||
# 根据表名决定是否使用ON CONFLICT
|
||||
if table_name == "manual_announcements":
|
||||
# 手动搜索表不使用唯一约束(允许重复)
|
||||
sql = f"""
|
||||
INSERT INTO {table_name} (
|
||||
title, publish_date, purchase_name, content_url, source_code, source_name,
|
||||
announcement_type, crawled_at, content_hash, keyword_matched,
|
||||
date_filtered, is_new, is_today
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
values = []
|
||||
for announcement in announcements:
|
||||
values.append((
|
||||
announcement.title,
|
||||
announcement.publish_date,
|
||||
announcement.purchase_name,
|
||||
announcement.content_url,
|
||||
announcement.source_code,
|
||||
announcement.source_name,
|
||||
announcement.announcement_type.value,
|
||||
announcement.crawled_at,
|
||||
announcement.content_hash,
|
||||
announcement.keyword_matched,
|
||||
announcement.date_filtered,
|
||||
announcement.is_new,
|
||||
announcement.is_today
|
||||
))
|
||||
else:
|
||||
# 自动搜索表使用唯一约束
|
||||
sql = f"""
|
||||
INSERT INTO {table_name} (
|
||||
title, publish_date, purchase_name, content_url, source_code, source_name,
|
||||
announcement_type, crawled_at, content_hash, keyword_matched,
|
||||
date_filtered, is_new, is_today
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (content_hash) DO NOTHING
|
||||
"""
|
||||
values = []
|
||||
for announcement in announcements:
|
||||
values.append((
|
||||
announcement.title,
|
||||
announcement.publish_date,
|
||||
announcement.purchase_name,
|
||||
announcement.content_url,
|
||||
announcement.source_code,
|
||||
announcement.source_name,
|
||||
announcement.announcement_type.value,
|
||||
announcement.crawled_at,
|
||||
announcement.content_hash,
|
||||
announcement.keyword_matched,
|
||||
announcement.date_filtered,
|
||||
announcement.is_new,
|
||||
announcement.is_today
|
||||
))
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
extras.execute_batch(cursor, sql, values)
|
||||
affected_rows = cursor.rowcount
|
||||
logger.info(f"批量保存公告到{table_name}完成,影响行数: {affected_rows}")
|
||||
return affected_rows
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"批量保存公告到{table_name}失败: {str(e)}")
|
||||
return 0
|
||||
|
||||
@retry_on_exception(RetryConfig(max_retries=3))
|
||||
def get_announcements(self,
|
||||
source_code: Optional[str] = None,
|
||||
@@ -504,21 +634,89 @@ class DatabaseManager:
|
||||
if not self.config.database.enabled:
|
||||
return {}
|
||||
|
||||
# 统计所有表的综合信息
|
||||
sql = """
|
||||
SELECT
|
||||
COUNT(*) as total_announcements,
|
||||
COUNT(CASE WHEN is_today THEN 1 END) as today_announcements,
|
||||
COUNT(CASE WHEN is_new THEN 1 END) as new_announcements,
|
||||
SUM(total_count) as total_announcements,
|
||||
SUM(today_count) as today_announcements,
|
||||
SUM(new_count) as new_announcements,
|
||||
COUNT(DISTINCT source_code) as sources_count,
|
||||
MAX(crawled_at) as last_crawl_time
|
||||
FROM announcements
|
||||
MAX(last_crawl_time) as last_crawl_time
|
||||
FROM (
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
COUNT(CASE WHEN is_today THEN 1 END) as today_count,
|
||||
COUNT(CASE WHEN is_new THEN 1 END) as new_count,
|
||||
source_code,
|
||||
MAX(crawled_at) as last_crawl_time
|
||||
FROM announcements
|
||||
GROUP BY source_code
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
COUNT(CASE WHEN is_today THEN 1 END) as today_count,
|
||||
COUNT(CASE WHEN is_new THEN 1 END) as new_count,
|
||||
source_code,
|
||||
MAX(crawled_at) as last_crawl_time
|
||||
FROM auto_announcements
|
||||
GROUP BY source_code
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
COUNT(CASE WHEN is_today THEN 1 END) as today_count,
|
||||
COUNT(CASE WHEN is_new THEN 1 END) as new_count,
|
||||
source_code,
|
||||
MAX(crawled_at) as last_crawl_time
|
||||
FROM manual_announcements
|
||||
GROUP BY source_code
|
||||
) as combined_stats
|
||||
"""
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql)
|
||||
result = cursor.fetchone()
|
||||
return dict(result) if result else {}
|
||||
stats = dict(result) if result else {}
|
||||
|
||||
# 添加各表详细统计
|
||||
detail_sql = """
|
||||
SELECT
|
||||
'announcements' as table_name,
|
||||
COUNT(*) as count,
|
||||
COUNT(DISTINCT source_code) as sources,
|
||||
MAX(crawled_at) as last_crawl
|
||||
FROM announcements
|
||||
UNION ALL
|
||||
SELECT
|
||||
'auto_announcements' as table_name,
|
||||
COUNT(*) as count,
|
||||
COUNT(DISTINCT source_code) as sources,
|
||||
MAX(crawled_at) as last_crawl
|
||||
FROM auto_announcements
|
||||
UNION ALL
|
||||
SELECT
|
||||
'manual_announcements' as table_name,
|
||||
COUNT(*) as count,
|
||||
COUNT(DISTINCT source_code) as sources,
|
||||
MAX(crawled_at) as last_crawl
|
||||
FROM manual_announcements
|
||||
"""
|
||||
|
||||
cursor.execute(detail_sql)
|
||||
detail_results = cursor.fetchall()
|
||||
|
||||
stats['table_details'] = {row['table_name']: {
|
||||
'count': row['count'],
|
||||
'sources': row['sources'],
|
||||
'last_crawl': row['last_crawl']
|
||||
} for row in detail_results}
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计信息失败: {str(e)}")
|
||||
return {}
|
||||
@@ -536,11 +734,20 @@ class DatabaseManager:
|
||||
if not self.config.database.enabled:
|
||||
return False
|
||||
|
||||
sql = "SELECT 1 FROM announcements WHERE content_hash = %s LIMIT 1"
|
||||
# 检查所有表中是否存在
|
||||
sql = """
|
||||
SELECT 1 FROM (
|
||||
SELECT content_hash FROM announcements WHERE content_hash = %s
|
||||
UNION ALL
|
||||
SELECT content_hash FROM auto_announcements WHERE content_hash = %s
|
||||
UNION ALL
|
||||
SELECT content_hash FROM manual_announcements WHERE content_hash = %s
|
||||
) as combined_check LIMIT 1
|
||||
"""
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql, (content_hash,))
|
||||
cursor.execute(sql, (content_hash, content_hash, content_hash))
|
||||
return cursor.fetchone() is not None
|
||||
except Exception as e:
|
||||
logger.error(f"检查公告存在性失败: {str(e)}")
|
||||
|
||||
@@ -60,6 +60,7 @@ class Announcement:
|
||||
crawled_at: Optional[datetime] = None # 爬取时间
|
||||
created_at: Optional[datetime] = None # 创建时间
|
||||
updated_at: Optional[datetime] = None # 更新时间
|
||||
crawl_mode: str = "auto" # 爬取模式:auto(自动)/manual(手动)
|
||||
|
||||
# 去重字段
|
||||
content_hash: Optional[str] = None # 内容哈希,用于去重
|
||||
|
||||
+44
-23
@@ -17,7 +17,7 @@ try:
|
||||
from gx_gp_monitor.core.logger import init_logger, get_logger
|
||||
from gx_gp_monitor.crawler.spider import crawl_announcements
|
||||
from gx_gp_monitor.filters.filters import filter_from_config
|
||||
from gx_gp_monitor.storage.postgresql import init_storage, save_announcements_to_storage, save_all_announcements_by_source_to_storage
|
||||
from gx_gp_monitor.storage.postgresql import init_storage, save_announcements_to_storage, save_all_announcements_by_source_to_storage, save_auto_announcements_to_storage
|
||||
from gx_gp_monitor.notification.wechat import send_announcements_notification, send_system_notification
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -39,8 +39,8 @@ try:
|
||||
# 初始化存储
|
||||
init_storage()
|
||||
|
||||
# 执行搜索
|
||||
logger.info("开始执行搜索...")
|
||||
# 执行搜索(爬取所有公告,然后进行关键词筛选)
|
||||
logger.info("开始执行定时搜索任务")
|
||||
crawl_results = crawl_announcements()
|
||||
|
||||
if not crawl_results:
|
||||
@@ -56,22 +56,42 @@ try:
|
||||
total_crawled = len(all_announcements)
|
||||
logger.info(f"搜索到 {total_crawled} 条原始公告")
|
||||
|
||||
# 保存所有公告(按来源分组,每源保留最新100条)
|
||||
all_saved_stats = save_all_announcements_by_source_to_storage(all_announcements, max_per_source=100)
|
||||
all_saved_count = sum(all_saved_stats.values())
|
||||
logger.info(f"保存所有公告完成:共保存 {all_saved_count} 条,按来源统计: {all_saved_stats}")
|
||||
|
||||
# 筛选出新公告(数据库中没有的)
|
||||
new_announcements = [ann for ann in all_announcements if ann.is_new]
|
||||
logger.info(f"筛选出 {len(new_announcements)} 条新公告")
|
||||
|
||||
if not new_announcements:
|
||||
logger.info("没有新的公告,任务完成")
|
||||
if not all_announcements:
|
||||
logger.info("没有获取到任何公告")
|
||||
return True
|
||||
|
||||
# 对新公告应用关键词筛选
|
||||
filter_obj = filter_from_config()
|
||||
filtered_announcements, filter_stats = filter_obj.filter(new_announcements)
|
||||
# 对所有公告进行关键词筛选
|
||||
from gx_gp_monitor.filters.filters import KeywordFilter, DateFilter
|
||||
from datetime import date
|
||||
|
||||
# 1. 关键词筛选
|
||||
keyword_filter = KeywordFilter()
|
||||
keyword_filtered = keyword_filter.filter_announcements(all_announcements, keywords=config.crawler.keyword)
|
||||
|
||||
# 2. 日期筛选(只保留今天的)
|
||||
date_filter = DateFilter()
|
||||
today_announcements = date_filter.filter_announcements(
|
||||
keyword_filtered,
|
||||
start_date=date.today(),
|
||||
end_date=date.today()
|
||||
)
|
||||
|
||||
logger.info(f"关键词筛选后剩余 {len(keyword_filtered)} 条公告")
|
||||
logger.info(f"筛选出今天 {len(today_announcements)} 条匹配公告")
|
||||
|
||||
if not today_announcements:
|
||||
logger.info("今天没有匹配关键词的公告")
|
||||
return True
|
||||
|
||||
# 构造筛选统计信息
|
||||
filter_stats = type('FilterResult', (), {
|
||||
"keyword_filtered": len(all_announcements) - len(keyword_filtered),
|
||||
"date_filtered": len(keyword_filtered) - len(today_announcements),
|
||||
"duplicate_filtered": 0,
|
||||
"source_filtered": 0
|
||||
})()
|
||||
|
||||
filtered_announcements = today_announcements
|
||||
|
||||
logger.info(f"关键词筛选后剩余 {len(filtered_announcements)} 条公告")
|
||||
|
||||
@@ -79,9 +99,9 @@ try:
|
||||
logger.info("没有匹配关键词的公告,任务完成")
|
||||
return True
|
||||
|
||||
# 保存筛选后的公告(用于标记关键词匹配等)
|
||||
saved_count = save_announcements_to_storage(filtered_announcements)
|
||||
logger.info(f"保存筛选后公告完成:{saved_count} 条")
|
||||
# 保存筛选后的公告到定时搜索专用表
|
||||
saved_count = save_auto_announcements_to_storage(filtered_announcements)
|
||||
logger.info(f"保存定时搜索公告完成:{saved_count} 条")
|
||||
|
||||
# 发送企业微信卡片通知
|
||||
if config.wechat_app.enabled:
|
||||
@@ -96,10 +116,11 @@ try:
|
||||
logger.info("企业微信通知未启用,跳过发送")
|
||||
|
||||
# 输出统计信息
|
||||
print("\n=== 定时爬取任务完成 ===")
|
||||
print("\n=== 定时搜索任务完成 ===")
|
||||
print(f"总共爬取: {total_crawled} 条公告")
|
||||
print(f"新增公告: {len(new_announcements)} 条")
|
||||
print(f"关键词筛选: {len(filtered_announcements)} 条")
|
||||
print(f"关键词筛选: {len(keyword_filtered)} 条")
|
||||
print(f"今日匹配公告: {len(today_announcements)} 条")
|
||||
print(f"筛选后公告: {len(filtered_announcements)} 条")
|
||||
print(f"保存到数据库: {saved_count} 条")
|
||||
print(f"企业微信通知: {'成功' if notify_success else '失败' if config.wechat_app.enabled else '未启用'}")
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ try:
|
||||
from .core.reliability import check_system_health
|
||||
from .crawler.spider import crawl_announcements
|
||||
from .filters.filters import filter_from_config
|
||||
from .storage.postgresql import init_storage, save_announcements_to_storage, save_all_announcements_by_source_to_storage, cleanup_storage
|
||||
from .storage.postgresql import init_storage, save_announcements_to_storage, save_all_announcements_by_source_to_storage, save_manual_announcements_by_source_to_storage, cleanup_storage
|
||||
from .storage.md_generator import generate_onu_md
|
||||
from .notification.wechat import send_announcements_notification, send_system_notification
|
||||
from .wechat.callback_server import get_callback_server
|
||||
@@ -116,16 +116,17 @@ class GXGPMonitorApp:
|
||||
|
||||
logger.info(f"搜索到 {len(all_announcements)} 条原始公告")
|
||||
|
||||
# 对于手动搜索,不保存公告到数据库,只进行筛选和返回结果
|
||||
# 保存公告到对应的专用表
|
||||
if not manual_crawl:
|
||||
# 先保存所有公告(按来源分组,每源保留最新100条)
|
||||
# 自动爬取:保存到auto_announcements表(关键词匹配专用)
|
||||
all_saved_stats = save_all_announcements_by_source_to_storage(all_announcements, max_per_source=100)
|
||||
all_saved_count = sum(all_saved_stats.values())
|
||||
logger.info(f"保存所有公告完成:共保存 {all_saved_count} 条,按来源统计: {all_saved_stats}")
|
||||
logger.info(f"保存自动爬取公告完成:共保存 {all_saved_count} 条,按来源统计: {all_saved_stats}")
|
||||
else:
|
||||
all_saved_count = 0
|
||||
all_saved_stats = {}
|
||||
logger.info("手动搜索模式:跳过数据库保存")
|
||||
# 手动搜索:保存到manual_announcements表(全量数据专用)
|
||||
all_saved_stats = save_manual_announcements_by_source_to_storage(all_announcements, max_per_source=100)
|
||||
all_saved_count = sum(all_saved_stats.values())
|
||||
logger.info(f"保存手动搜索公告完成:共保存 {all_saved_count} 条,按来源统计: {all_saved_stats}")
|
||||
|
||||
# 筛选公告
|
||||
if manual_crawl:
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
"""
|
||||
企业微信通知模块
|
||||
提供企业微信消息发送功能,支持文本和Markdown格式
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from ..core.config_manager import get_config
|
||||
from ..core.logger import get_logger
|
||||
from ..core.models import Announcement
|
||||
from ..core.reliability import retry_on_exception, RetryConfig, safe_execute
|
||||
from ..storage.md_generator import AnnouncementMarkdownFormatter
|
||||
except ImportError:
|
||||
from core.config_manager import get_config
|
||||
from core.logger import get_logger
|
||||
from core.models import Announcement
|
||||
from core.reliability import retry_on_exception, RetryConfig, safe_execute
|
||||
from storage.md_generator import AnnouncementMarkdownFormatter
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WeChatService:
|
||||
"""企业微信服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config().wechat_app
|
||||
self._access_token = None
|
||||
self._token_expires_at = 0
|
||||
|
||||
logger.info("企业微信服务初始化完成")
|
||||
|
||||
def _get_access_token(self) -> Optional[str]:
|
||||
"""
|
||||
获取访问令牌
|
||||
|
||||
Returns:
|
||||
Optional[str]: 访问令牌
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# 检查令牌是否仍然有效
|
||||
if self._access_token and current_time < self._token_expires_at:
|
||||
return self._access_token
|
||||
|
||||
try:
|
||||
# 构建请求URL
|
||||
if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'):
|
||||
url = f"{self.config.proxy_api_url}/cgi-bin/gettoken"
|
||||
else:
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
|
||||
params = {
|
||||
"corpid": self.config.corp_id,
|
||||
"corpsecret": self.config.secret
|
||||
}
|
||||
|
||||
logger.debug("正在获取企业微信访问令牌")
|
||||
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
self._access_token = result.get("access_token")
|
||||
# 提前5分钟过期
|
||||
expires_in = result.get("expires_in", 7200) - 300
|
||||
self._token_expires_at = current_time + expires_in
|
||||
|
||||
logger.info("成功获取企业微信访问令牌")
|
||||
return self._access_token
|
||||
else:
|
||||
logger.error(f"获取访问令牌失败: {result}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取访问令牌异常: {str(e)}")
|
||||
return None
|
||||
|
||||
@retry_on_exception(RetryConfig(max_retries=3))
|
||||
def send_text_message(self, content: str,
|
||||
to_user: str = "@all",
|
||||
to_party: str = "",
|
||||
to_tag: str = "") -> bool:
|
||||
"""
|
||||
发送文本消息
|
||||
|
||||
Args:
|
||||
content: 消息内容
|
||||
to_user: 接收者用户ID,多个用|分隔,@all表示全体
|
||||
to_party: 接收者部门ID,多个用|分隔
|
||||
to_tag: 接收者标签ID,多个用|分隔
|
||||
|
||||
Returns:
|
||||
bool: 发送是否成功
|
||||
"""
|
||||
try:
|
||||
access_token = self._get_access_token()
|
||||
if not access_token:
|
||||
logger.error("无法获取访问令牌,发送失败")
|
||||
return False
|
||||
|
||||
# 构建请求URL
|
||||
if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'):
|
||||
url = f"{self.config.proxy_api_url}/cgi-bin/message/send"
|
||||
else:
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
|
||||
|
||||
params = {"access_token": access_token}
|
||||
|
||||
data = {
|
||||
"touser": to_user,
|
||||
"toparty": to_party,
|
||||
"totag": to_tag,
|
||||
"msgtype": "text",
|
||||
"agentid": self.config.agent_id,
|
||||
"text": {
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(f"发送文本消息: {content[:100]}...")
|
||||
|
||||
response = requests.post(url, params=params, json=data, timeout=30)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
logger.info("文本消息发送成功")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"文本消息发送失败: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送文本消息异常: {str(e)}")
|
||||
return False
|
||||
|
||||
@retry_on_exception(RetryConfig(max_retries=3))
|
||||
def send_markdown_message(self, content: str,
|
||||
to_user: str = "@all",
|
||||
to_party: str = "",
|
||||
to_tag: str = "") -> bool:
|
||||
"""
|
||||
发送Markdown消息
|
||||
|
||||
Args:
|
||||
content: Markdown格式的消息内容
|
||||
to_user: 接收者用户ID
|
||||
to_party: 接收者部门ID
|
||||
to_tag: 接收者标签ID
|
||||
|
||||
Returns:
|
||||
bool: 发送是否成功
|
||||
"""
|
||||
try:
|
||||
access_token = self._get_access_token()
|
||||
if not access_token:
|
||||
logger.error("无法获取访问令牌,发送失败")
|
||||
return False
|
||||
|
||||
# 构建请求URL
|
||||
if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'):
|
||||
url = f"{self.config.proxy_api_url}/cgi-bin/message/send"
|
||||
else:
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
|
||||
|
||||
params = {"access_token": access_token}
|
||||
|
||||
data = {
|
||||
"touser": to_user,
|
||||
"toparty": to_party,
|
||||
"totag": to_tag,
|
||||
"msgtype": "markdown",
|
||||
"agentid": self.config.agent_id,
|
||||
"markdown": {
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("发送Markdown消息")
|
||||
|
||||
response = requests.post(url, params=params, json=data, timeout=30)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
logger.info("Markdown消息发送成功")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Markdown消息发送失败: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送Markdown消息异常: {str(e)}")
|
||||
return False
|
||||
|
||||
def send_announcement_notification(self, announcements: List[Announcement],
|
||||
max_count: int = 20) -> bool:
|
||||
"""
|
||||
发送公告通知
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
max_count: 最大显示数量
|
||||
|
||||
Returns:
|
||||
bool: 发送是否成功
|
||||
"""
|
||||
if not announcements:
|
||||
logger.info("没有新公告,跳过通知")
|
||||
return True
|
||||
|
||||
try:
|
||||
# 生成通知内容
|
||||
notification_content = self._generate_announcement_notification(announcements, max_count)
|
||||
|
||||
# 发送Markdown消息
|
||||
return self.send_markdown_message(notification_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送公告通知失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def _generate_announcement_notification(self, announcements: List[Announcement],
|
||||
max_count: int) -> str:
|
||||
"""
|
||||
生成公告通知内容
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
max_count: 最大显示数量
|
||||
|
||||
Returns:
|
||||
str: Markdown格式的通知内容
|
||||
"""
|
||||
# 按日期分组
|
||||
today_announcements = []
|
||||
other_announcements = []
|
||||
|
||||
today = datetime.now().date()
|
||||
|
||||
for announcement in announcements:
|
||||
if announcement.publish_date and announcement.publish_date.date() == today:
|
||||
today_announcements.append(announcement)
|
||||
else:
|
||||
other_announcements.append(announcement)
|
||||
|
||||
lines = []
|
||||
|
||||
# 标题
|
||||
total_count = len(announcements)
|
||||
lines.append(f"# 🔔 广西政府采购网公告更新")
|
||||
lines.append("")
|
||||
lines.append(f"**发现 {total_count} 条新公告**")
|
||||
lines.append("")
|
||||
|
||||
# 今日公告
|
||||
if today_announcements:
|
||||
lines.append(f"## 📅 今日公告 ({len(today_announcements)}条)")
|
||||
lines.append("")
|
||||
display_today = today_announcements[:max_count//2]
|
||||
for announcement in display_today:
|
||||
title = announcement.title
|
||||
if len(title) > 40:
|
||||
title = title[:40] + "..."
|
||||
publish_time = announcement.publish_date.strftime("%H:%M") if announcement.publish_date else "N/A"
|
||||
lines.append(f"• [{title}]({announcement.content_url}) - {publish_time}")
|
||||
|
||||
if len(today_announcements) > len(display_today):
|
||||
lines.append(f"• ... 还有 {len(today_announcements) - len(display_today)} 条今日公告")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# 其他公告
|
||||
if other_announcements:
|
||||
lines.append(f"## 📄 其他公告 ({len(other_announcements)}条)")
|
||||
lines.append("")
|
||||
remaining_slots = max_count - len(today_announcements) if today_announcements else max_count
|
||||
display_other = other_announcements[:remaining_slots]
|
||||
|
||||
for announcement in display_other:
|
||||
title = announcement.title
|
||||
if len(title) > 40:
|
||||
title = title[:40] + "..."
|
||||
publish_date = announcement.publish_date.strftime("%m-%d") if announcement.publish_date else "N/A"
|
||||
lines.append(f"• [{title}]({announcement.content_url}) - {publish_date}")
|
||||
|
||||
if len(other_announcements) > len(display_other):
|
||||
lines.append(f"• ... 还有 {len(other_announcements) - len(display_other)} 条公告")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# 统计信息
|
||||
source_stats = {}
|
||||
for announcement in announcements:
|
||||
source = announcement.source_name
|
||||
source_stats[source] = source_stats.get(source, 0) + 1
|
||||
|
||||
lines.append("## 📊 统计信息")
|
||||
lines.append("")
|
||||
for source, count in sorted(source_stats.items()):
|
||||
lines.append(f"• {source}: {count}条")
|
||||
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append(f"*更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
|
||||
lines.append("*点击公告标题查看详情*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def send_system_notification(self, title: str, content: str,
|
||||
message_type: str = "text") -> bool:
|
||||
"""
|
||||
发送系统通知
|
||||
|
||||
Args:
|
||||
title: 通知标题
|
||||
content: 通知内容
|
||||
message_type: 消息类型 (text/markdown)
|
||||
|
||||
Returns:
|
||||
bool: 发送是否成功
|
||||
"""
|
||||
try:
|
||||
if message_type == "markdown":
|
||||
full_content = f"# {title}\n\n{content}"
|
||||
return self.send_markdown_message(full_content)
|
||||
else:
|
||||
full_content = f"{title}\n\n{content}"
|
||||
return self.send_text_message(full_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送系统通知失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def send_error_notification(self, error_message: str, error_details: Optional[str] = None) -> bool:
|
||||
"""
|
||||
发送错误通知
|
||||
|
||||
Args:
|
||||
error_message: 错误消息
|
||||
error_details: 错误详情
|
||||
|
||||
Returns:
|
||||
bool: 发送是否成功
|
||||
"""
|
||||
content = f"## ❌ 系统错误\n\n**错误信息**: {error_message}"
|
||||
|
||||
if error_details:
|
||||
content += f"\n\n**错误详情**:\n```\n{error_details}\n```"
|
||||
|
||||
content += f"\n\n*发生时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*"
|
||||
|
||||
return self.send_markdown_message(content)
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""
|
||||
测试连接
|
||||
|
||||
Returns:
|
||||
bool: 连接是否正常
|
||||
"""
|
||||
try:
|
||||
token = self._get_access_token()
|
||||
return token is not None
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信连接测试失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_service_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取服务状态
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 服务状态信息
|
||||
"""
|
||||
return {
|
||||
"service": "wechat",
|
||||
"enabled": self.config.enabled,
|
||||
"corp_id": self.config.corp_id[:10] + "..." if self.config.corp_id else None,
|
||||
"agent_id": self.config.agent_id,
|
||||
"has_token": self._access_token is not None,
|
||||
"token_expires_at": datetime.fromtimestamp(self._token_expires_at).isoformat() if self._token_expires_at > 0 else None,
|
||||
"use_proxy": self.config.use_proxy,
|
||||
"connection_test": self.test_connection() if self.config.enabled else False
|
||||
}
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
"""通知管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.wechat = WeChatService()
|
||||
self._services = {
|
||||
"wechat": self.wechat
|
||||
}
|
||||
|
||||
def send_announcement_notification(self, announcements: List[Announcement]) -> Dict[str, bool]:
|
||||
"""
|
||||
发送公告通知
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
|
||||
Returns:
|
||||
Dict[str, bool]: 各服务发送结果
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# 企业微信通知
|
||||
if self.wechat.config.enabled:
|
||||
try:
|
||||
results["wechat"] = self.wechat.send_announcement_notification(announcements)
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信通知失败: {str(e)}")
|
||||
results["wechat"] = False
|
||||
else:
|
||||
results["wechat"] = None # 未启用
|
||||
|
||||
return results
|
||||
|
||||
def send_system_notification(self, title: str, content: str) -> Dict[str, bool]:
|
||||
"""
|
||||
发送系统通知
|
||||
|
||||
Args:
|
||||
title: 通知标题
|
||||
content: 通知内容
|
||||
|
||||
Returns:
|
||||
Dict[str, bool]: 发送结果
|
||||
"""
|
||||
results = {}
|
||||
|
||||
if self.wechat.config.enabled:
|
||||
try:
|
||||
results["wechat"] = self.wechat.send_system_notification(title, content, "markdown")
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信系统通知失败: {str(e)}")
|
||||
results["wechat"] = False
|
||||
else:
|
||||
results["wechat"] = None
|
||||
|
||||
return results
|
||||
|
||||
def send_error_notification(self, error_message: str, error_details: Optional[str] = None) -> Dict[str, bool]:
|
||||
"""
|
||||
发送错误通知
|
||||
|
||||
Args:
|
||||
error_message: 错误消息
|
||||
error_details: 错误详情
|
||||
|
||||
Returns:
|
||||
Dict[str, bool]: 发送结果
|
||||
"""
|
||||
results = {}
|
||||
|
||||
if self.wechat.config.enabled:
|
||||
try:
|
||||
results["wechat"] = self.wechat.send_error_notification(error_message, error_details)
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信错误通知失败: {str(e)}")
|
||||
results["wechat"] = False
|
||||
else:
|
||||
results["wechat"] = None
|
||||
|
||||
return results
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取通知服务状态
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 服务状态
|
||||
"""
|
||||
return {
|
||||
"services": {
|
||||
name: service.get_service_status() for name, service in self._services.items()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# 全局通知管理器实例
|
||||
_notification_manager = None
|
||||
|
||||
|
||||
def get_notification_manager() -> NotificationManager:
|
||||
"""
|
||||
获取通知管理器实例
|
||||
|
||||
Returns:
|
||||
NotificationManager: 通知管理器实例
|
||||
"""
|
||||
global _notification_manager
|
||||
if _notification_manager is None:
|
||||
_notification_manager = NotificationManager()
|
||||
return _notification_manager
|
||||
|
||||
|
||||
def send_announcements_notification(announcements: List[Announcement]) -> bool:
|
||||
"""
|
||||
发送公告通知
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
|
||||
Returns:
|
||||
bool: 是否至少有一个服务发送成功
|
||||
"""
|
||||
manager = get_notification_manager()
|
||||
results = manager.send_announcement_notification(announcements)
|
||||
|
||||
# 检查是否有服务发送成功
|
||||
return any(result for result in results.values() if result is True)
|
||||
|
||||
|
||||
def send_system_notification(title: str, content: str) -> bool:
|
||||
"""
|
||||
发送系统通知
|
||||
|
||||
Args:
|
||||
title: 通知标题
|
||||
content: 通知内容
|
||||
|
||||
Returns:
|
||||
bool: 是否至少有一个服务发送成功
|
||||
"""
|
||||
manager = get_notification_manager()
|
||||
results = manager.send_system_notification(title, content)
|
||||
|
||||
return any(result for result in results.values() if result is True)
|
||||
|
||||
|
||||
def send_error_alert(error_message: str, error_details: Optional[str] = None) -> bool:
|
||||
"""
|
||||
发送错误警报
|
||||
|
||||
Args:
|
||||
error_message: 错误消息
|
||||
error_details: 错误详情
|
||||
|
||||
Returns:
|
||||
bool: 是否至少有一个服务发送成功
|
||||
"""
|
||||
manager = get_notification_manager()
|
||||
results = manager.send_error_notification(error_message, error_details)
|
||||
|
||||
return any(result for result in results.values() if result is True)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -62,6 +62,95 @@ class PostgreSQLStorage:
|
||||
# 尝试逐个保存
|
||||
return self._save_announcements_fallback(announcements)
|
||||
|
||||
def save_auto_announcements(self, announcements: List[Announcement]) -> int:
|
||||
"""
|
||||
保存定时搜索公告到专用表(关键词匹配专用)
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
|
||||
Returns:
|
||||
int: 成功保存的数量
|
||||
"""
|
||||
if not announcements:
|
||||
return 0
|
||||
|
||||
logger.info(f"开始保存 {len(announcements)} 条定时搜索公告到专用表")
|
||||
|
||||
try:
|
||||
# 批量保存到auto_announcements表
|
||||
saved_count = self.db_manager.save_announcements_batch_to_table(announcements, "auto_announcements")
|
||||
|
||||
if saved_count > 0:
|
||||
logger.info(f"成功保存 {saved_count} 条定时搜索公告到专用表")
|
||||
|
||||
return saved_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存定时搜索公告到专用表失败: {str(e)}")
|
||||
return 0
|
||||
|
||||
def save_manual_announcements_by_source(self, announcements: List[Announcement],
|
||||
max_per_source: int = 100) -> Dict[str, int]:
|
||||
"""
|
||||
按来源保存手动搜索公告到专用表,每个来源保留最新的max_per_source条
|
||||
|
||||
Args:
|
||||
announcements: 所有公告列表(未经关键词筛选)
|
||||
max_per_source: 每个来源最大保留数量
|
||||
|
||||
Returns:
|
||||
Dict[str, int]: 各来源保存的数量
|
||||
"""
|
||||
if not announcements:
|
||||
return {}
|
||||
|
||||
logger.info(f"开始按来源保存 {len(announcements)} 条手动搜索公告到专用表,每个来源最多保留 {max_per_source} 条")
|
||||
|
||||
try:
|
||||
# 按来源分组
|
||||
source_groups = {}
|
||||
for announcement in announcements:
|
||||
source_code = announcement.source_code
|
||||
if source_code not in source_groups:
|
||||
source_groups[source_code] = []
|
||||
source_groups[source_code].append(announcement)
|
||||
|
||||
saved_stats = {}
|
||||
|
||||
for source_code, source_announcements in source_groups.items():
|
||||
# 对每个来源的公告按发布时间排序(最新的在前)
|
||||
sorted_announcements = sorted(
|
||||
source_announcements,
|
||||
key=lambda x: x.publish_date or datetime.min,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# 为没有哈希的公告生成哈希
|
||||
for announcement in sorted_announcements:
|
||||
if not announcement.content_hash:
|
||||
announcement.generate_content_hash()
|
||||
|
||||
# 批量保存
|
||||
to_save = sorted_announcements[:max_per_source]
|
||||
saved_count = self.db_manager.save_announcements_batch_to_table(to_save, "manual_announcements")
|
||||
saved_stats[source_code] = saved_count
|
||||
|
||||
# 清理该来源超出限制的旧数据
|
||||
if len(sorted_announcements) > max_per_source:
|
||||
self._cleanup_old_announcements_by_source_in_table(source_code, max_per_source, "manual_announcements")
|
||||
|
||||
logger.info(f"来源 {source_code} 保存了 {saved_count} 条手动搜索公告")
|
||||
|
||||
total_saved = sum(saved_stats.values())
|
||||
logger.info(f"按来源保存手动搜索公告完成,总计保存 {total_saved} 条公告")
|
||||
|
||||
return saved_stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"按来源保存手动搜索公告失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
def save_all_announcements_by_source(self, announcements: List[Announcement],
|
||||
max_per_source: int = 100) -> Dict[str, int]:
|
||||
"""
|
||||
@@ -186,6 +275,41 @@ class PostgreSQLStorage:
|
||||
# 由于我们在爬取时已经标记,这里主要是确保数据库中的标记正确
|
||||
pass
|
||||
|
||||
def _mark_new_announcements_in_table(self, announcements: List[Announcement], table_name: str):
|
||||
"""在指定表中标记新公告"""
|
||||
# 这里可以添加新公告标记逻辑
|
||||
pass
|
||||
|
||||
def _cleanup_old_announcements_by_source_in_table(self, source_code: str, max_per_source: int, table_name: str):
|
||||
"""在指定表中清理来源的旧公告"""
|
||||
try:
|
||||
with self.db_manager.get_db_cursor() as cursor:
|
||||
# 获取该来源当前保存的公告数量
|
||||
cursor.execute(f"""
|
||||
SELECT COUNT(*) FROM {table_name}
|
||||
WHERE source_code = %s
|
||||
""", (source_code,))
|
||||
|
||||
current_count = cursor.fetchone()[0]
|
||||
|
||||
if current_count > max_per_source:
|
||||
# 删除超出数量的旧公告
|
||||
delete_count = current_count - max_per_source
|
||||
cursor.execute(f"""
|
||||
DELETE FROM {table_name}
|
||||
WHERE id IN (
|
||||
SELECT id FROM {table_name}
|
||||
WHERE source_code = %s
|
||||
ORDER BY publish_date DESC, created_at DESC
|
||||
OFFSET %s
|
||||
)
|
||||
""", (source_code, max_per_source))
|
||||
|
||||
logger.info(f"清理了 {cursor.rowcount} 条{table_name}表中来源{source_code}的旧公告")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理{table_name}表中来源{source_code}的旧公告失败: {str(e)}")
|
||||
|
||||
def save_crawl_results(self, results: List[CrawlResult]) -> int:
|
||||
"""
|
||||
保存爬取结果
|
||||
@@ -389,6 +513,15 @@ class StorageManager:
|
||||
"""按来源保存所有公告"""
|
||||
return self._current_storage.save_all_announcements_by_source(announcements, max_per_source)
|
||||
|
||||
def save_auto_announcements(self, announcements: List[Announcement]) -> int:
|
||||
"""保存定时搜索公告到专用表"""
|
||||
return self._current_storage.save_auto_announcements(announcements)
|
||||
|
||||
def save_manual_announcements_by_source(self, announcements: List[Announcement],
|
||||
max_per_source: int = 100) -> Dict[str, int]:
|
||||
"""按来源保存手动搜索公告到专用表"""
|
||||
return self._current_storage.save_manual_announcements_by_source(announcements, max_per_source)
|
||||
|
||||
def save_crawl_results(self, results: List[CrawlResult]) -> int:
|
||||
"""保存爬取结果"""
|
||||
return self._current_storage.save_crawl_results(results)
|
||||
@@ -476,6 +609,34 @@ def save_all_announcements_by_source_to_storage(announcements: List[Announcement
|
||||
return get_storage_manager().save_all_announcements_by_source(announcements, max_per_source)
|
||||
|
||||
|
||||
def save_auto_announcements_to_storage(announcements: List[Announcement]) -> int:
|
||||
"""
|
||||
保存定时搜索公告到专用表
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
|
||||
Returns:
|
||||
int: 保存成功的数量
|
||||
"""
|
||||
return get_storage_manager().save_auto_announcements(announcements)
|
||||
|
||||
|
||||
def save_manual_announcements_by_source_to_storage(announcements: List[Announcement],
|
||||
max_per_source: int = 100) -> Dict[str, int]:
|
||||
"""
|
||||
按来源保存手动搜索公告到专用表
|
||||
|
||||
Args:
|
||||
announcements: 所有公告列表
|
||||
max_per_source: 每个来源最大保留数量
|
||||
|
||||
Returns:
|
||||
Dict[str, int]: 各来源保存的数量
|
||||
"""
|
||||
return get_storage_manager().save_manual_announcements_by_source(announcements, max_per_source)
|
||||
|
||||
|
||||
def cleanup_storage(days: Optional[int] = None) -> int:
|
||||
"""
|
||||
清理存储中的过期数据
|
||||
|
||||
Binary file not shown.
@@ -244,32 +244,101 @@ class WeChatMessageHandler:
|
||||
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
stats = storage.get_statistics()
|
||||
from ..core.database import get_db_cursor
|
||||
from datetime import date
|
||||
|
||||
if stats:
|
||||
response = f"""今日公告统计
|
||||
# 获取今日关键词命中公告数(从auto_announcements表)
|
||||
today = date.today()
|
||||
with get_db_cursor() as cursor:
|
||||
# 今日关键词命中总数
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as today_keyword_hits
|
||||
FROM auto_announcements
|
||||
WHERE DATE(publish_date) = %s
|
||||
""", (today,))
|
||||
today_keyword_hits = cursor.fetchone()['today_keyword_hits']
|
||||
|
||||
# 各类型今日关键词命中数
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
announcement_type,
|
||||
COUNT(*) as count
|
||||
FROM auto_announcements
|
||||
WHERE DATE(publish_date) = %s
|
||||
GROUP BY announcement_type
|
||||
ORDER BY count DESC
|
||||
""", (today,))
|
||||
type_stats = {row['announcement_type']: row['count'] for row in cursor.fetchall()}
|
||||
|
||||
# 历史累计关键词命中数
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as total_keyword_hits
|
||||
FROM auto_announcements
|
||||
""")
|
||||
total_keyword_hits = cursor.fetchone()['total_keyword_hits']
|
||||
|
||||
# 今日各来源关键词命中数
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
source_name,
|
||||
COUNT(*) as count
|
||||
FROM auto_announcements
|
||||
WHERE DATE(publish_date) = %s
|
||||
GROUP BY source_name
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
""", (today,))
|
||||
source_stats = cursor.fetchall()
|
||||
|
||||
# 类型名称映射
|
||||
type_name_map = {
|
||||
'purchase': '采购公告',
|
||||
'result': '结果公告',
|
||||
'correction': '更正公告',
|
||||
'contract': '合同公告',
|
||||
'pre_announcement': '预公示',
|
||||
'single_source': '单一来源',
|
||||
'electronic_market': '电子卖场',
|
||||
'acceptance': '履约验收',
|
||||
'engineering': '工程公告',
|
||||
'intention': '采购意向'
|
||||
}
|
||||
|
||||
if today_keyword_hits > 0:
|
||||
response = f"""📊 今日关键词命中统计
|
||||
|
||||
统计时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
数据概览:
|
||||
- 今日新增: {stats.get('today_count', 0)} 条
|
||||
- 累计总数: {stats.get('total_count', 0)} 条
|
||||
- 活跃关键词: {stats.get('active_keywords', 0)} 个
|
||||
🎯 今日关键词命中: {today_keyword_hits} 条
|
||||
📈 历史累计命中: {total_keyword_hits} 条
|
||||
|
||||
分类统计:
|
||||
- 采购公告: {stats.get('purchase_count', 0)} 条
|
||||
- 结果公告: {stats.get('result_count', 0)} 条
|
||||
- 更正公告: {stats.get('correction_count', 0)} 条
|
||||
- 其他类型: {stats.get('other_count', 0)} 条
|
||||
📋 今日命中分类:
|
||||
"""
|
||||
|
||||
提示: 数据每小时更新,点击"立即搜索"可获取最新数据."""
|
||||
# 添加各类型统计
|
||||
for ann_type, count in type_stats.items():
|
||||
type_name = type_name_map.get(ann_type, ann_type)
|
||||
response += f"- {type_name}: {count} 条\n"
|
||||
|
||||
response += "\n🏢 今日命中来源TOP5:\n"
|
||||
for i, source in enumerate(source_stats, 1):
|
||||
response += f"{i}. {source['source_name']}: {source['count']} 条\n"
|
||||
|
||||
response += "\n💡 提示: 这些是关键词自动匹配成功的公告"
|
||||
else:
|
||||
response = """今日公告统计
|
||||
response = f"""📊 今日关键词命中统计
|
||||
|
||||
暂无统计数据,请先执行"立即搜索"获取最新公告。
|
||||
统计时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
建议: 系统会在每日定时搜索,您也可以手动触发更新."""
|
||||
🎯 今日关键词命中: 0 条
|
||||
📈 历史累计命中: {total_keyword_hits} 条
|
||||
|
||||
暂无今日关键词命中公告。
|
||||
|
||||
💡 可能原因:
|
||||
- 今日暂无匹配关键词的公告发布
|
||||
- 系统定时搜索还未执行
|
||||
- 点击"立即搜索"可手动触发更新"""
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败,使用默认统计: {str(db_error)}")
|
||||
@@ -347,22 +416,22 @@ class WeChatMessageHandler:
|
||||
return self._create_text_response("获取最新公告失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_latest_news_by_source(self, source_choice: str, from_user: str) -> Optional[str]:
|
||||
"""处理按来源查看最新公告"""
|
||||
"""处理按来源查看最新公告 - 直接从指定来源爬取最新的10条公告"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 选择公告来源: {source_choice}")
|
||||
|
||||
# 来源映射
|
||||
# 来源映射 - 公告类型到来源代码的映射
|
||||
source_mapping = {
|
||||
"1": ("purchase", "采购公告"),
|
||||
"2": ("result", "结果公告"),
|
||||
"3": ("correction", "更正公告"),
|
||||
"4": ("contract", "合同公告"),
|
||||
"5": ("pre_announcement", "预公示"),
|
||||
"6": ("single_source", "单一来源"),
|
||||
"7": ("electronic_market", "电子卖场"),
|
||||
"8": ("acceptance", "履约验收"),
|
||||
"9": ("engineering", "工程公告"),
|
||||
"10": ("intention", "采购意向")
|
||||
"1": ("ZcyAnnouncement1", "采购公告"),
|
||||
"2": ("ZcyAnnouncement2", "结果公告"),
|
||||
"3": ("ZcyAnnouncement4", "更正公告"),
|
||||
"4": ("ZcyAnnouncement3", "合同公告"),
|
||||
"5": ("ZcyAnnouncement5", "预公示"),
|
||||
"6": ("ZcyAnnouncement6", "单一来源"),
|
||||
"7": ("ZcyAnnouncement7", "电子卖场"),
|
||||
"8": ("ZcyAnnouncement10", "履约验收"),
|
||||
"9": ("ZcyAnnouncement11", "工程公告"),
|
||||
"10": ("61-266648", "采购意向")
|
||||
}
|
||||
|
||||
if source_choice == "全部" or source_choice == "all":
|
||||
@@ -377,48 +446,91 @@ class WeChatMessageHandler:
|
||||
返回公告查询菜单,请点击"最新公告"重新选择。"""
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
ann_type, type_name = source_mapping[source_choice]
|
||||
source_code, type_name = source_mapping[source_choice]
|
||||
|
||||
# 查询该类型的最新公告
|
||||
# 直接从指定来源爬取最新公告
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
# 获取所有最近公告,然后过滤类型
|
||||
all_announcements = storage.get_recent_announcements(hours=168, limit=100) # 最近7天
|
||||
filtered_announcements = [ann for ann in all_announcements if ann.announcement_type.value == ann_type][:10]
|
||||
from ..crawler.spider import crawl_announcements
|
||||
from ..filters.filters import DateFilter
|
||||
from datetime import date
|
||||
|
||||
if filtered_announcements:
|
||||
response = f"""📋 {type_name} - 最新10条
|
||||
# 只爬取指定来源的公告
|
||||
logger.info(f"开始爬取 {type_name} 来源的公告")
|
||||
crawl_results = crawl_announcements(sources=[source_code])
|
||||
|
||||
🕒 更新时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
if not crawl_results or not crawl_results[0].announcements:
|
||||
response = f"❌ 暂无 {type_name} 相关公告"
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
# 获取该来源的所有公告
|
||||
source_announcements = crawl_results[0].announcements
|
||||
|
||||
# 按日期筛选(今天的数据)
|
||||
date_filter = DateFilter()
|
||||
today_announcements = date_filter.filter_announcements(
|
||||
source_announcements,
|
||||
start_date=date.today(),
|
||||
end_date=date.today()
|
||||
)
|
||||
|
||||
# 按发布时间排序,取最新的10条
|
||||
sorted_announcements = sorted(
|
||||
today_announcements,
|
||||
key=lambda x: x.publish_date or x.crawled_at,
|
||||
reverse=True
|
||||
)[:10]
|
||||
|
||||
if not sorted_announcements:
|
||||
response = f"❌ 今天暂无 {type_name} 相关公告"
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
# 生成Markdown格式的结果并发送
|
||||
markdown_content = self._generate_latest_news_markdown(sorted_announcements, type_name)
|
||||
|
||||
try:
|
||||
from ..notification.wechat import get_notification_manager
|
||||
manager = get_notification_manager()
|
||||
if hasattr(manager.wechat, 'send_system_notification'):
|
||||
notify_success = manager.wechat.send_system_notification(
|
||||
title=f"📋 {type_name} - 最新公告",
|
||||
content=markdown_content,
|
||||
message_type="markdown"
|
||||
)
|
||||
if notify_success:
|
||||
# 返回简短确认
|
||||
return self._create_text_response(f"✅ 已发送 {type_name} 最新5条公告到聊天窗口。", from_user)
|
||||
else:
|
||||
# 如果Markdown发送失败,返回文本格式
|
||||
return self._create_text_response(f"发送失败,已获取 {len(sorted_announcements)} 条 {type_name} 公告。", from_user)
|
||||
else:
|
||||
# 如果不支持markdown,返回文本格式
|
||||
response = f"""📋 {type_name} - 最新公告
|
||||
|
||||
共找到 {len(sorted_announcements)} 条公告:
|
||||
|
||||
"""
|
||||
for i, announcement in enumerate(sorted_announcements[:5], 1): # 只显示前5条
|
||||
title = announcement.title[:25] + "..." if len(announcement.title) > 25 else announcement.title
|
||||
time_str = announcement.publish_date.strftime('%m-%d %H:%M') if announcement.publish_date else "未知"
|
||||
response += f"{i}. {title}\n 🕒 {time_str}\n"
|
||||
|
||||
for i, announcement in enumerate(filtered_announcements, 1):
|
||||
title = announcement.title[:25] + "..." if len(announcement.title) > 25 else announcement.title
|
||||
time_str = announcement.publish_date.strftime('%m-%d %H:%M') if announcement.publish_date else "未知"
|
||||
response += f"{i}. {title}\n 🕒 {time_str} | 🏷️ {announcement.purchase_name or '未知'}\n\n"
|
||||
if len(sorted_announcements) > 5:
|
||||
response += f"\n... 还有 {len(sorted_announcements) - 5} 条公告"
|
||||
|
||||
response += "💡 发送关键词可进一步筛选,点击菜单可查看更多功能。"
|
||||
else:
|
||||
response = f"""📋 {type_name}
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
暂无该类型的最新公告数据。
|
||||
except Exception as notify_error:
|
||||
logger.warning(f"发送Markdown通知失败: {str(notify_error)}")
|
||||
# 返回文本格式的结果
|
||||
response = f"""📋 {type_name} - 最新公告
|
||||
|
||||
💡 建议:
|
||||
• 点击"立即搜索"更新数据
|
||||
• 该类型公告可能较少出现
|
||||
• 返回重新选择其他类型"""
|
||||
共找到 {len(sorted_announcements)} 条公告,请查看详细结果。"""
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败: {str(db_error)}")
|
||||
response = f"""📋 {type_name}
|
||||
|
||||
暂时无法获取数据,请稍后重试。
|
||||
|
||||
您可以返回重新选择其他类型。"""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
except Exception as e:
|
||||
logger.error(f"爬取公告失败: {str(e)}")
|
||||
response = f"❌ 获取 {type_name} 公告失败,请稍后重试"
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"按来源查看最新公告异常: {str(e)}")
|
||||
@@ -1005,6 +1117,34 @@ class WeChatMessageHandler:
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def _generate_latest_news_markdown(self, announcements: List, source_type_name: str) -> str:
|
||||
"""生成最新公告的markdown格式"""
|
||||
import datetime
|
||||
|
||||
# 只显示最新的5条公告
|
||||
display_announcements = announcements[:5]
|
||||
|
||||
# 生成markdown内容
|
||||
lines = [
|
||||
f"总公告数: {len(announcements)}\n\n",
|
||||
f"更新时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||||
]
|
||||
|
||||
# 逐条列出公告(最多5条)
|
||||
for i, ann in enumerate(display_announcements, 1):
|
||||
title = ann.title
|
||||
if len(title) > 50:
|
||||
title = title[:50] + "..."
|
||||
|
||||
url = ann.content_url or "#"
|
||||
date_str = ann.publish_date.strftime('%Y-%m-%d') if ann.publish_date else "未知"
|
||||
purchaser = ann.purchase_name or "未知"
|
||||
|
||||
lines.append(f"{i}. [{title}]({url})\n\n")
|
||||
lines.append(f" {date_str} | {purchaser}\n\n")
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def handle_other_message(self, msg_type: str, from_user: str) -> Optional[str]:
|
||||
"""处理其他类型的消息"""
|
||||
try:
|
||||
|
||||
@@ -1,970 +0,0 @@
|
||||
"""
|
||||
企业微信消息处理器
|
||||
处理用户消息和事件,实现菜单功能
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from ..core.config_manager import get_config
|
||||
from ..core.logger import get_logger
|
||||
from ..notification.wechat import send_system_notification
|
||||
from ..storage.postgresql import save_all_announcements_by_source_to_storage
|
||||
from ..storage.md_generator import generate_onu_md
|
||||
from ..core.models import Announcement
|
||||
except ImportError:
|
||||
try:
|
||||
from core.config_manager import get_config
|
||||
from core.logger import get_logger
|
||||
from notification.wechat import send_system_notification
|
||||
from storage.postgresql import save_all_announcements_by_source_to_storage
|
||||
from storage.md_generator import generate_onu_md
|
||||
from core.models import Announcement
|
||||
except ImportError as e:
|
||||
raise ImportError(f"消息处理器导入失败: {e}")
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WeChatMessageHandler:
|
||||
"""企业微信消息处理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config()
|
||||
self.monitor_app = None
|
||||
|
||||
# 菜单配置
|
||||
self.menu_config = {
|
||||
"crawl_now": {
|
||||
"key": "crawl_now",
|
||||
"name": " 立即搜索",
|
||||
"description": "立即执行一次公告搜索"
|
||||
},
|
||||
"today_stats": {
|
||||
"key": "today_stats",
|
||||
"name": " 今日统计",
|
||||
"description": "查看今日公告统计信息"
|
||||
},
|
||||
"keyword_search": {
|
||||
"key": "keyword_search",
|
||||
"name": " 关键词搜索",
|
||||
"description": "输入关键词搜索公告"
|
||||
},
|
||||
"latest_news": {
|
||||
"key": "latest_news",
|
||||
"name": " 最新公告",
|
||||
"description": "查看最新发布的公告"
|
||||
},
|
||||
"keyword_manage": {
|
||||
"key": "keyword_manage",
|
||||
"name": " 关键词管理",
|
||||
"description": "管理监控关键词"
|
||||
},
|
||||
"system_status": {
|
||||
"key": "system_status",
|
||||
"name": " 系统状态",
|
||||
"description": "查看系统运行状态"
|
||||
},
|
||||
"clear_cache": {
|
||||
"key": "clear_cache",
|
||||
"name": " 清理缓存",
|
||||
"description": "清理系统缓存数据"
|
||||
},
|
||||
"help_guide": {
|
||||
"key": "help_guide",
|
||||
"name": " 使用说明",
|
||||
"description": "查看详细使用说明"
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("企业微信消息处理器初始化完成")
|
||||
|
||||
def _get_monitor_app(self):
|
||||
"""获取监控应用实例"""
|
||||
if self.monitor_app is None:
|
||||
# 动态导入避免循环导入
|
||||
try:
|
||||
from ..main import GXGPMonitorApp
|
||||
self.monitor_app = GXGPMonitorApp()
|
||||
# 初始化但不启动服务器
|
||||
if not self.monitor_app.initialize():
|
||||
logger.error("监控应用初始化失败")
|
||||
return None
|
||||
except ImportError:
|
||||
logger.error("无法导入监控应用")
|
||||
return None
|
||||
return self.monitor_app
|
||||
|
||||
def handle_event(self, event: str, event_key: Optional[str], from_user: str) -> Optional[str]:
|
||||
"""处理事件消息"""
|
||||
try:
|
||||
logger.info(f"处理事件: {event}, key: {event_key}, user: {from_user}")
|
||||
|
||||
if event == 'click':
|
||||
# 菜单点击事件
|
||||
if event_key == 'crawl_now':
|
||||
return self._handle_crawl_now(from_user)
|
||||
elif event_key == 'today_stats':
|
||||
return self._handle_today_stats(from_user)
|
||||
elif event_key == 'keyword_search':
|
||||
return self._handle_keyword_search_menu(from_user)
|
||||
elif event_key == 'latest_news':
|
||||
return self._handle_latest_news(from_user)
|
||||
elif event_key == 'latest_announcements':
|
||||
# 兼容旧菜单key
|
||||
return self._handle_latest_news(from_user)
|
||||
elif event_key == 'keyword_manage':
|
||||
return self._handle_keyword_manage(from_user)
|
||||
elif event_key == 'system_status':
|
||||
return self._handle_system_status(from_user)
|
||||
elif event_key == 'clear_cache':
|
||||
return self._handle_clear_cache(from_user)
|
||||
elif event_key == 'help_guide':
|
||||
return self._handle_help_guide(from_user)
|
||||
elif event_key == 'keyword_search':
|
||||
return self._handle_keyword_search_menu(from_user)
|
||||
elif event_key == 'search_announcements':
|
||||
# 兼容旧菜单key
|
||||
return self._handle_keyword_search_menu(from_user)
|
||||
elif event_key == 'announcements_by_type':
|
||||
# 兼容旧菜单key - 按类型查看公告
|
||||
return self._handle_announcements_by_type(from_user)
|
||||
else:
|
||||
return self._create_text_response("未知菜单项", from_user)
|
||||
|
||||
elif event == 'subscribe':
|
||||
# 关注事件
|
||||
welcome_msg = """欢迎关注广西政府采购网公告监控!
|
||||
|
||||
我可以帮您:
|
||||
- 自动监控最新采购公告
|
||||
- 筛选您关心的关键词信息
|
||||
- 及时推送重要更新
|
||||
|
||||
点击下方菜单开始使用."""
|
||||
return self._create_text_response(welcome_msg, from_user)
|
||||
|
||||
elif event == 'unsubscribe':
|
||||
# 取消关注事件
|
||||
logger.info(f"用户 {from_user} 取消关注")
|
||||
return None
|
||||
|
||||
else:
|
||||
logger.info(f"未处理的event类型: {event}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理异常: {str(e)}")
|
||||
return self._create_text_response("处理失败,请稍后重试", from_user)
|
||||
|
||||
def handle_text_message(self, content: str, from_user: str) -> Optional[str]:
|
||||
"""处理文本消息"""
|
||||
try:
|
||||
logger.info(f"处理文本消息: {content}, user: {from_user}")
|
||||
|
||||
# 移除前后空格
|
||||
content = content.strip()
|
||||
|
||||
if content == "帮助" or content == "help":
|
||||
return self._handle_help_guide(from_user)
|
||||
elif content.startswith("爬取"):
|
||||
return self._handle_manual_crawl(content, from_user)
|
||||
elif content.startswith("总结") or content == "统计":
|
||||
return self._handle_today_stats(from_user)
|
||||
elif content.startswith("最新公告") or content.startswith("最新"):
|
||||
return self._handle_latest_news(from_user)
|
||||
elif content.startswith("系统状态") or content.startswith("状态"):
|
||||
return self._handle_system_status(from_user)
|
||||
elif content.startswith("关键词"):
|
||||
return self._handle_keyword_search(content, from_user)
|
||||
elif content.startswith("添加关键词"):
|
||||
# 这里可以实现关键词添加逻辑
|
||||
return self._create_text_response("关键词管理功能正在开发中,请联系管理员", from_user)
|
||||
elif content.startswith("删除关键词"):
|
||||
# 这里可以实现关键词删除逻辑
|
||||
return self._create_text_response("关键词管理功能正在开发中,请联系管理员", from_user)
|
||||
elif content == "查看关键词":
|
||||
# 这里可以实现关键词查看逻辑
|
||||
return self._create_text_response("当前监控关键词:政府采购大化南宁信息化", from_user)
|
||||
elif content.startswith("清理缓存") or content.startswith("清理"):
|
||||
return self._handle_clear_cache(from_user)
|
||||
elif content in ["采购公告", "结果公告", "更正公告", "合同公告", "预公示", "单一来源", "电子卖场", "履约验收", "工程公告"]:
|
||||
# 按类型查询公告
|
||||
return self._handle_search_by_type(content, from_user)
|
||||
else:
|
||||
# 默认当作关键词搜索
|
||||
return self._handle_keyword_search(f"关键词 {content}", from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"文本消息处理异常: {str(e)}")
|
||||
return self._create_text_response("处理失败,请稍后重试", from_user)
|
||||
|
||||
def handle_other_message(self, msg_type: str, from_user: str) -> Optional[str]:
|
||||
"""处理其他类型的消息"""
|
||||
try:
|
||||
logger.info(f"处理其他消息类型: {msg_type}, user: {from_user}")
|
||||
|
||||
if msg_type == 'image':
|
||||
return self._create_text_response("收到图片消息,但我只能处理文本消息", from_user)
|
||||
elif msg_type == 'voice':
|
||||
return self._create_text_response("收到语音消息,但我只能处理文本消息", from_user)
|
||||
else:
|
||||
return self._create_text_response(f"收到{msg_type}消息,暂不支持此类型", from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"其他消息处理异常: {str(e)}")
|
||||
return self._create_text_response("处理失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_crawl_now(self, from_user: str) -> Optional[str]:
|
||||
"""处理立即爬取菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 触发立即搜索")
|
||||
|
||||
# 获取监控应用
|
||||
app = self._get_monitor_app()
|
||||
if not app:
|
||||
return self._create_text_response("系统初始化失败,请稍后重试", from_user)
|
||||
|
||||
# 执行搜索
|
||||
result = app.run_crawl()
|
||||
|
||||
if result.get("success"):
|
||||
total = result.get("total_crawled", 0)
|
||||
filtered = result.get("filtered", 0)
|
||||
saved = result.get("saved", 0)
|
||||
|
||||
response = f"""OK 搜索完成!
|
||||
|
||||
统计信息:
|
||||
- 总共发现: {total} 条公告
|
||||
- 关键词筛选: {filtered} 条
|
||||
- 已保存: {saved} 条
|
||||
|
||||
如有匹配的公告,我会及时推送通知."""
|
||||
else:
|
||||
error = result.get("error", "未知错误")
|
||||
response = f"FAIL 搜索失败: {error}"
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"立即搜索处理异常: {str(e)}")
|
||||
return self._create_text_response("搜索失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_today_stats(self, from_user: str) -> Optional[str]:
|
||||
"""处理今日统计菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求今日统计")
|
||||
|
||||
# 获取监控应用
|
||||
app = self._get_monitor_app()
|
||||
if not app:
|
||||
return self._create_text_response("系统初始化失败,请稍后重试", from_user)
|
||||
|
||||
# 查询今日统计数据
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
stats = storage.get_statistics()
|
||||
|
||||
if stats:
|
||||
response = f""" 今日公告统计
|
||||
|
||||
统计时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
数据概览:
|
||||
- 今日新增:{stats.get('today_count', 0)} 条
|
||||
- 累计总数:{stats.get('total_count', 0)} 条
|
||||
- 活跃关键词:{stats.get('active_keywords', 0)} 个
|
||||
|
||||
分类统计:
|
||||
- 采购公告:{stats.get('purchase_count', 0)} 条
|
||||
- 结果公告:{stats.get('result_count', 0)} 条
|
||||
- 更正公告:{stats.get('correction_count', 0)} 条
|
||||
- 其他类型:{stats.get('other_count', 0)} 条
|
||||
|
||||
热门地区:
|
||||
{chr(10).join([f"- {region}: {count}条" for region, count in stats.get('region_stats', {}).items()][:5])}
|
||||
|
||||
提示:数据每小时更新,点击"立即搜索"可获取最新数据."""
|
||||
else:
|
||||
response = """ 今日公告统计
|
||||
|
||||
暂无统计数据,请先执行"立即搜索"获取最新公告.
|
||||
|
||||
建议:系统会在每日定时搜索,您也可以手动触发更新."""
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败,使用默认统计: {str(db_error)}")
|
||||
response = """ 系统状态
|
||||
|
||||
数据库连接中,请稍后查看详细统计.
|
||||
|
||||
您可以:
|
||||
- 点击"立即搜索"更新数据
|
||||
- 查看"系统状态"了解服务运行情况"""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"今日统计处理异常: {str(e)}")
|
||||
return self._create_text_response("获取统计失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_custom_crawl(self, event_key: str, from_user: str) -> Optional[str]:
|
||||
"""处理自定义搜索菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 触发自定义搜索")
|
||||
|
||||
response = """ 自定义搜索
|
||||
|
||||
请回复您想要搜索的关键词,我将为您执行搜索并筛选相关公告.
|
||||
|
||||
例如:
|
||||
- 大化
|
||||
- 信息化
|
||||
- 政府采购
|
||||
|
||||
发送关键词开始搜索."""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"自定义搜索处理异常: {str(e)}")
|
||||
return self._create_text_response("操作失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_help(self, from_user: str) -> Optional[str]:
|
||||
"""处理帮助命令(兼容旧版本)"""
|
||||
return self._handle_help_guide(from_user)
|
||||
|
||||
def _handle_manual_crawl(self, content: str, from_user: str) -> Optional[str]:
|
||||
"""处理手动搜索命令"""
|
||||
try:
|
||||
# 解析关键词
|
||||
parts = content.split()
|
||||
if len(parts) < 2:
|
||||
return self._create_text_response("请指定搜索关键词,例如:搜索 大化", from_user)
|
||||
|
||||
keywords = parts[1:]
|
||||
logger.info(f"用户 {from_user} 手动搜索关键词: {keywords}")
|
||||
|
||||
# 获取监控应用
|
||||
app = self._get_monitor_app()
|
||||
if not app:
|
||||
return self._create_text_response("系统初始化失败,请稍后重试", from_user)
|
||||
|
||||
# 执行搜索(手动搜索,只筛选今天的公告)
|
||||
result = app.run_crawl(keywords=keywords, manual_crawl=True)
|
||||
|
||||
if result.get("success"):
|
||||
total = result.get("total_crawled", 0)
|
||||
filtered = result.get("filtered", 0)
|
||||
filtered_announcements = result.get("filtered_announcements", [])
|
||||
|
||||
# 获取今天的日期范围
|
||||
from datetime import datetime, date
|
||||
today = date.today()
|
||||
time_period = f"{today.strftime('%Y-%m-%d')} 00:00 至 {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
if filtered > 0:
|
||||
# 生成markdown汇总消息并发送
|
||||
try:
|
||||
from ..storage.md_generator import MarkdownGenerator
|
||||
from ..notification.wechat import send_system_notification
|
||||
|
||||
# 生成markdown内容
|
||||
md_generator = MarkdownGenerator()
|
||||
title = f"手动搜索结果 - 关键词: {' '.join(keywords)}"
|
||||
markdown_content = md_generator.generate_markdown(filtered_announcements, title, time_period)
|
||||
|
||||
# 发送markdown消息
|
||||
notify_success = send_system_notification(
|
||||
title=" 搜索完成",
|
||||
content=markdown_content
|
||||
)
|
||||
|
||||
if notify_success:
|
||||
# 成功发送markdown消息,返回空响应(不发送额外文本消息)
|
||||
response = ""
|
||||
else:
|
||||
response = f"""OK 搜索完成!
|
||||
|
||||
搜索条件:
|
||||
- 关键词: {' '.join(keywords)}
|
||||
- 时间段: {time_period}
|
||||
|
||||
统计结果:
|
||||
- 总共发现: {total} 条公告
|
||||
- 匹配筛选: {filtered} 条
|
||||
|
||||
公告汇总推送失败,但数据已生成."""
|
||||
except Exception as notify_error:
|
||||
logger.error(f"生成或发送公告汇总失败: {notify_error}")
|
||||
# 降级处理:手动构建简单的文本响应
|
||||
announcement_list = []
|
||||
for i, ann in enumerate(filtered_announcements[:10], 1): # 最多显示10条
|
||||
announcement_list.append(f"{i}. {ann.title[:50]}...")
|
||||
|
||||
remaining = len(filtered_announcements) - 10
|
||||
if remaining > 0:
|
||||
announcement_list.append(f"... 还有 {remaining} 条公告")
|
||||
|
||||
response = f"""OK 搜索完成!
|
||||
|
||||
搜索条件:
|
||||
- 关键词: {' '.join(keywords)}
|
||||
- 时间段: {time_period}
|
||||
|
||||
统计结果:
|
||||
- 总共发现: {total} 条公告
|
||||
- 匹配筛选: {filtered} 条
|
||||
|
||||
匹配公告:
|
||||
{chr(10).join(announcement_list)}
|
||||
|
||||
公告详情已保存,可通过其他方式查看."""
|
||||
else:
|
||||
# 没有找到匹配的公告,发送markdown格式的空结果
|
||||
try:
|
||||
from ..storage.md_generator import MarkdownGenerator
|
||||
from ..notification.wechat import send_system_notification
|
||||
|
||||
md_generator = MarkdownGenerator()
|
||||
title = f"手动搜索结果 - 关键词: {' '.join(keywords)}"
|
||||
markdown_content = md_generator.generate_markdown([], title, time_period)
|
||||
|
||||
notify_success = send_system_notification(
|
||||
title=" 搜索完成",
|
||||
content=markdown_content
|
||||
)
|
||||
|
||||
if notify_success:
|
||||
response = ""
|
||||
else:
|
||||
response = f"""OK 搜索完成!
|
||||
|
||||
搜索条件:
|
||||
- 关键词: {' '.join(keywords)}
|
||||
- 时间段: {time_period}
|
||||
|
||||
统计结果:
|
||||
- 总共发现: {total} 条公告
|
||||
- 匹配筛选: 0 条
|
||||
|
||||
FAIL 在指定时间段内没有找到匹配的公告."""
|
||||
except Exception as notify_error:
|
||||
logger.error(f"生成或发送公告汇总失败: {notify_error}")
|
||||
response = f"""OK 搜索完成!
|
||||
|
||||
搜索条件:
|
||||
- 关键词: {' '.join(keywords)}
|
||||
- 时间段: {time_period}
|
||||
|
||||
统计结果:
|
||||
- 总共发现: {total} 条公告
|
||||
- 匹配筛选: 0 条
|
||||
|
||||
FAIL 在指定时间段内没有找到匹配的公告."""
|
||||
else:
|
||||
error = result.get("error", "未知错误")
|
||||
response = f"FAIL 搜索失败: {error}"
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"手动爬取处理异常: {str(e)}")
|
||||
return self._create_text_response("搜索失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_keyword_search(self, content: str, from_user: str) -> Optional[str]:
|
||||
"""处理关键词搜索"""
|
||||
try:
|
||||
# 解析关键词
|
||||
parts = content.split()
|
||||
keywords = parts[1:] if len(parts) > 1 else parts
|
||||
|
||||
if not keywords:
|
||||
return self._create_text_response("请提供搜索关键词", from_user)
|
||||
|
||||
logger.info(f"用户 {from_user} 关键词搜索: {keywords}")
|
||||
|
||||
# 这里可以实现关键词搜索逻辑
|
||||
# 目前先返回提示信息
|
||||
response = f""" 关键词搜索
|
||||
|
||||
搜索关键词: {' '.join(keywords)}
|
||||
|
||||
由于系统正在优化中,搜索功能暂时不可用.
|
||||
|
||||
您可以:
|
||||
- 使用"搜索 [关键词]"执行新的搜索
|
||||
- 点击菜单中的"立即搜索"
|
||||
- 发送"帮助"查看更多功能"""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"关键词搜索处理异常: {str(e)}")
|
||||
return self._create_text_response("搜索失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_keyword_search_menu(self, from_user: str) -> Optional[str]:
|
||||
"""处理关键词搜索菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 触发关键词搜索菜单")
|
||||
|
||||
response = """ 关键词搜索
|
||||
|
||||
请直接发送您想要搜索的关键词,我将为您查找相关的公告信息.
|
||||
|
||||
支持的搜索方式:
|
||||
- 单个关键词:如 "信息化"
|
||||
- 多个关键词:如 "大数据 云计算"
|
||||
- 精确短语:如 "政府采购"
|
||||
|
||||
搜索提示:
|
||||
- 关键词不区分大小写
|
||||
- 支持模糊匹配
|
||||
- 结果按时间倒序显示
|
||||
- 可同时搜索标题和内容"""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"关键词搜索菜单处理异常: {str(e)}")
|
||||
return self._create_text_response("操作失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_announcements_by_type(self, from_user: str) -> Optional[str]:
|
||||
"""处理按类型查看公告菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求按类型查看公告")
|
||||
|
||||
# 查询不同类型的公告统计
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
all_stats = storage.get_statistics()
|
||||
# 从统计信息中提取类型统计
|
||||
type_stats = all_stats.get('announcement_types', {})
|
||||
|
||||
if type_stats:
|
||||
response = f""" 公告类型统计
|
||||
|
||||
统计时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
各类型公告数量:
|
||||
|
||||
"""
|
||||
|
||||
type_name_map = {
|
||||
'purchase': '采购公告',
|
||||
'result': '结果公告',
|
||||
'correction': '更正公告',
|
||||
'contract': '合同公告',
|
||||
'pre_announcement': '预公示',
|
||||
'single_source': '单一来源',
|
||||
'electronic_market': '电子卖场',
|
||||
'acceptance': '履约验收',
|
||||
'engineering': '工程公告'
|
||||
}
|
||||
|
||||
for ann_type, count in type_stats.items():
|
||||
type_name = type_name_map.get(ann_type, ann_type)
|
||||
response += f"- {type_name}: {count} 条\n"
|
||||
|
||||
response += f"\n 发送公告类型名称可查看详情,如发送\"采购公告\""
|
||||
|
||||
else:
|
||||
response = """ 公告类型统计
|
||||
|
||||
暂无类型统计数据.
|
||||
|
||||
建议:
|
||||
- 点击"立即搜索"更新数据
|
||||
- 系统将自动分类统计各种公告"""
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败: {str(db_error)}")
|
||||
# 提供默认的类型说明
|
||||
response = """ 公告类型说明
|
||||
|
||||
系统支持以下类型的政府采购公告:
|
||||
|
||||
采购公告:招标采购等采购信息
|
||||
结果公告:中标成交等结果信息
|
||||
更正公告:变更澄清等修改信息
|
||||
合同公告:合同签订等信息
|
||||
预公示:招标文件预公示
|
||||
单一来源:单一来源采购公示
|
||||
电子卖场:电子化采购平台
|
||||
OK 履约验收:项目验收信息
|
||||
工程公告:工程建设相关
|
||||
|
||||
发送具体类型名称可搜索相关公告."""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"按类型查看公告处理异常: {str(e)}")
|
||||
return self._create_text_response("获取类型统计失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_search_by_type(self, type_name: str, from_user: str) -> Optional[str]:
|
||||
"""处理按类型搜索公告"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 按类型搜索公告: {type_name}")
|
||||
|
||||
# 类型映射
|
||||
type_mapping = {
|
||||
"采购公告": "purchase",
|
||||
"结果公告": "result",
|
||||
"更正公告": "correction",
|
||||
"合同公告": "contract",
|
||||
"预公示": "pre_announcement",
|
||||
"单一来源": "single_source",
|
||||
"电子卖场": "electronic_market",
|
||||
"履约验收": "acceptance",
|
||||
"工程公告": "engineering"
|
||||
}
|
||||
|
||||
ann_type = type_mapping.get(type_name)
|
||||
if not ann_type:
|
||||
return self._create_text_response(f"未知的公告类型: {type_name}", from_user)
|
||||
|
||||
# 查询该类型的公告
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
# 获取所有最近公告,然后过滤类型
|
||||
all_announcements = storage.get_recent_announcements(hours=168, limit=100) # 最近7天
|
||||
announcements = [ann for ann in all_announcements if ann.announcement_type.value == ann_type][:5]
|
||||
|
||||
if announcements:
|
||||
response = f""" {type_name} (最近5条)
|
||||
|
||||
更新时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
"""
|
||||
|
||||
for i, announcement in enumerate(announcements, 1):
|
||||
title = announcement.title[:25] + "..." if len(announcement.title) > 25 else announcement.title
|
||||
time_str = announcement.publish_date.strftime('%m-%d %H:%M') if announcement.publish_date else "未知"
|
||||
response += f"{i}. {title}\n {time_str} | {announcement.purchase_name or '未知'}\n\n"
|
||||
|
||||
response += " 发送关键词可进一步筛选,点击菜单可查看更多功能."
|
||||
else:
|
||||
response = f""" {type_name}
|
||||
|
||||
暂无该类型的公告数据.
|
||||
|
||||
建议:
|
||||
- 点击"立即搜索"更新数据
|
||||
- 该类型公告可能较少出现"""
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败: {str(db_error)}")
|
||||
response = f""" {type_name}
|
||||
|
||||
暂时无法获取数据,请稍后重试.
|
||||
|
||||
您可以先尝试"立即爬取"更新数据."""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"按类型搜索公告处理异常: {str(e)}")
|
||||
return self._create_text_response("搜索失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_latest_news(self, from_user: str) -> Optional[str]:
|
||||
"""处理最新公告菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求最新公告")
|
||||
|
||||
# 查询最新的公告
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
latest_announcements = storage.get_recent_announcements(hours=168, limit=10) # 最近7天
|
||||
|
||||
if latest_announcements:
|
||||
response = f""" 最新公告 (最近10条)
|
||||
|
||||
更新时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
"""
|
||||
|
||||
for i, announcement in enumerate(latest_announcements, 1):
|
||||
title = announcement.title[:25] + "..." if len(announcement.title) > 25 else announcement.title
|
||||
time_str = announcement.publish_date.strftime('%m-%d %H:%M') if announcement.publish_date else "未知"
|
||||
response += f"{i}. {title}\n {time_str} | {announcement.purchase_name or '未知'}\n\n"
|
||||
|
||||
response += " 发送关键词可搜索相关公告,点击菜单可查看更多功能."
|
||||
else:
|
||||
response = """ 最新公告
|
||||
|
||||
暂无最新公告数据.
|
||||
|
||||
建议:
|
||||
- 点击"立即搜索"更新数据
|
||||
- 检查系统状态确保服务正常"""
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败: {str(db_error)}")
|
||||
response = """ 最新公告
|
||||
|
||||
暂时无法获取数据,请稍后重试.
|
||||
|
||||
您可以先尝试"立即爬取"更新数据."""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"最新公告处理异常: {str(e)}")
|
||||
return self._create_text_response("获取最新公告失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_keyword_manage(self, from_user: str) -> Optional[str]:
|
||||
"""处理关键词管理菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求关键词管理")
|
||||
|
||||
# 获取当前配置的关键词
|
||||
current_keywords = self.config.crawler.keyword if hasattr(self.config, 'crawler') and self.config.crawler else ["大化", "信息化"]
|
||||
|
||||
keywords_str = "".join(current_keywords) if current_keywords else "暂无关键词"
|
||||
|
||||
response = f""" 系统关键词配置
|
||||
|
||||
当前监控关键词:
|
||||
{keywords_str}
|
||||
|
||||
监控状态:
|
||||
- 自动监控:系统会定期扫描匹配的公告
|
||||
- 实时推送:发现匹配公告立即推送
|
||||
- 多关键词:支持同时监控多个关键词
|
||||
|
||||
关键词说明:
|
||||
- 关键词区分大小写
|
||||
- 支持模糊匹配
|
||||
- 多个关键词用""分隔
|
||||
- 系统每天定时搜索相关公告
|
||||
|
||||
修改关键词:
|
||||
如需修改关键词配置,请联系系统管理员."""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"关键词管理处理异常: {str(e)}")
|
||||
return self._create_text_response("关键词管理功能暂时不可用", from_user)
|
||||
|
||||
def _handle_system_status(self, from_user: str) -> Optional[str]:
|
||||
"""处理系统状态菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求系统状态")
|
||||
|
||||
# 检查各种系统组件状态
|
||||
status_info = {
|
||||
"database": "检查中...",
|
||||
"crawler": "检查中...",
|
||||
"wechat": "检查中...",
|
||||
"scheduler": "检查中..."
|
||||
}
|
||||
|
||||
# 检查数据库连接
|
||||
try:
|
||||
from ..storage.postgresql import test_database_connection
|
||||
status_info["database"] = "正常" if test_database_connection() else "异常"
|
||||
except Exception as e:
|
||||
status_info["database"] = f"连接失败: {str(e)[:20]}..."
|
||||
|
||||
# 检查爬虫状态
|
||||
try:
|
||||
app = self._get_monitor_app()
|
||||
status_info["crawler"] = "正常" if app else "初始化失败"
|
||||
except Exception as e:
|
||||
status_info["crawler"] = f"异常: {str(e)[:20]}..."
|
||||
|
||||
# 检查微信服务状态
|
||||
try:
|
||||
from ..notification.wechat import WeChatService
|
||||
wechat_service = WeChatService()
|
||||
token = wechat_service._get_access_token()
|
||||
status_info["wechat"] = "正常" if token else "Token获取失败"
|
||||
except Exception as e:
|
||||
status_info["wechat"] = f"异常: {str(e)[:20]}..."
|
||||
|
||||
# 检查调度器状态(简化检查)
|
||||
status_info["scheduler"] = "运行中" # 假设调度器正常运行
|
||||
|
||||
response = f""" 系统状态报告
|
||||
|
||||
检查时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
组件状态:
|
||||
- 数据库:{status_info['database']}
|
||||
- 爬虫服务:{status_info['crawler']}
|
||||
- 微信服务:{status_info['wechat']}
|
||||
- 调度器:{status_info['scheduler']}
|
||||
|
||||
系统信息:
|
||||
- 版本:v2.0.0
|
||||
- 运行时间:正常
|
||||
- 内存使用:正常
|
||||
- 磁盘空间:正常
|
||||
|
||||
维护操作:
|
||||
- 如遇问题可尝试"清理缓存"
|
||||
- 严重故障可尝试"重启服务"
|
||||
- 技术问题请查看"使用说明"
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"系统状态处理异常: {str(e)}")
|
||||
return self._create_text_response("获取系统状态失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_clear_cache(self, from_user: str) -> Optional[str]:
|
||||
"""处理清理缓存菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求清理缓存")
|
||||
|
||||
# 执行缓存清理操作
|
||||
cache_cleared = {
|
||||
"database_cache": False,
|
||||
"file_cache": False,
|
||||
"memory_cache": False
|
||||
}
|
||||
|
||||
# 清理数据库缓存(如果有的话)
|
||||
try:
|
||||
from ..storage.postgresql import clear_database_cache
|
||||
cache_cleared["database_cache"] = clear_database_cache()
|
||||
except Exception as e:
|
||||
logger.warning(f"数据库缓存清理失败: {str(e)}")
|
||||
|
||||
# 清理文件缓存
|
||||
try:
|
||||
import os
|
||||
import shutil
|
||||
cache_dirs = ["cache", "__pycache__", "*.pyc"]
|
||||
# 这里可以实现具体的文件清理逻辑
|
||||
cache_cleared["file_cache"] = True # 暂时标记为成功
|
||||
except Exception as e:
|
||||
logger.warning(f"文件缓存清理失败: {str(e)}")
|
||||
|
||||
# 清理内存缓存
|
||||
try:
|
||||
# 清理可能存在的内存缓存
|
||||
if hasattr(self, '_cache'):
|
||||
self._cache.clear()
|
||||
cache_cleared["memory_cache"] = True
|
||||
except Exception as e:
|
||||
logger.warning(f"内存缓存清理失败: {str(e)}")
|
||||
|
||||
success_count = sum(1 for cleared in cache_cleared.values() if cleared)
|
||||
|
||||
response = f"""缓存清理完成
|
||||
|
||||
清理结果:
|
||||
- 数据库缓存:{"成功" if cache_cleared["database_cache"] else "失败"}
|
||||
- 文件缓存:{"成功" if cache_cleared["file_cache"] else "失败"}
|
||||
- 内存缓存:{"成功" if cache_cleared["memory_cache"] else "失败"}
|
||||
|
||||
总体结果:{success_count}/3 项清理成功
|
||||
|
||||
清理缓存可以:
|
||||
- 释放系统资源
|
||||
- 解决数据不一致问题
|
||||
- 提升系统性能
|
||||
|
||||
如有问题,请查看系统状态或联系技术支持."""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理缓存处理异常: {str(e)}")
|
||||
return self._create_text_response("缓存清理失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_help_guide(self, from_user: str) -> Optional[str]:
|
||||
"""处理使用说明菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求使用说明")
|
||||
|
||||
help_text = """ 广西政府采购网公告监控助手 - 使用说明
|
||||
|
||||
功能概述:
|
||||
我是一个智能的政府采购公告监控助手,能够自动监控广西政府采购网的最新公告,并根据您的需求推送相关信息.
|
||||
|
||||
快速开始:
|
||||
1. 点击"立即搜索"获取最新公告
|
||||
2. 发送关键词进行智能搜索
|
||||
3. 查看"今日统计"了解数据概况
|
||||
|
||||
菜单功能详解:
|
||||
|
||||
监控操作:
|
||||
- 立即搜索:手动触发公告搜索,获取最新数据
|
||||
- 今日统计:查看今日公告统计信息和数据概览
|
||||
- 关键词搜索:输入关键词搜索相关公告
|
||||
- 最新公告:浏览最近发布的10条公告
|
||||
|
||||
系统管理:
|
||||
- 关键词管理:管理监控关键词(需管理员权限)
|
||||
- 系统状态:查看各组件运行状态
|
||||
- 清理缓存:清理系统缓存,提升性能
|
||||
- 重启服务:重启监控服务(需管理员权限)
|
||||
|
||||
帮助支持:
|
||||
- 使用说明:查看详细功能介绍
|
||||
|
||||
文本命令:
|
||||
- 发送关键词直接搜索
|
||||
- "搜索 [关键词]" 指定关键词搜索
|
||||
- "总结" 查看今日统计
|
||||
- "帮助" 显示此说明
|
||||
|
||||
智能推送:
|
||||
系统会自动监控匹配关键词的公告,并通过企业微信实时推送.
|
||||
|
||||
安全提醒:
|
||||
- 管理员功能需要相应权限
|
||||
- 请妥善保管企业微信应用信息
|
||||
- 定期检查系统运行状态
|
||||
|
||||
使用技巧:
|
||||
- 关键词支持中英文混合
|
||||
- 可同时搜索多个关键词
|
||||
- 公告按时间倒序排列
|
||||
- 点击公告可查看详情
|
||||
|
||||
常见问题:
|
||||
Q: 为什么收不到推送?
|
||||
A: 检查关键词设置和系统状态
|
||||
|
||||
Q: 数据不准确怎么办?
|
||||
A: 尝试"立即爬取"更新数据
|
||||
|
||||
Q: 搜索不到结果?
|
||||
A: 检查关键词拼写,尝试更通用的关键词
|
||||
|
||||
如有其他问题,请点击"使用说明"获取帮助."""
|
||||
|
||||
return self._create_text_response(help_text, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"使用说明处理异常: {str(e)}")
|
||||
return self._create_text_response("获取帮助信息失败,请稍后重试", from_user)
|
||||
|
||||
def _create_text_response(self, content: str, to_user: str) -> str:
|
||||
"""创建文本消息响应"""
|
||||
timestamp = str(int(time.time()))
|
||||
|
||||
response_xml = f"""<xml>
|
||||
<ToUserName><![CDATA[{to_user}]]></ToUserName>
|
||||
<FromUserName><![CDATA[{self.config.wechat_app.corp_id}]]></FromUserName>
|
||||
<CreateTime>{timestamp}</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[{content}]]></Content>
|
||||
</xml>"""
|
||||
|
||||
return response_xml
|
||||
@@ -1,824 +0,0 @@
|
||||
"""
|
||||
企业微信消息处理器
|
||||
处理用户消息和事件,实现菜单功能
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from ..core.config_manager import get_config
|
||||
from ..core.logger import get_logger
|
||||
from ..notification.wechat import send_system_notification
|
||||
from ..storage.postgresql import save_all_announcements_by_source_to_storage
|
||||
from ..storage.md_generator import generate_onu_md
|
||||
from ..core.models import Announcement
|
||||
except ImportError:
|
||||
try:
|
||||
from core.config_manager import get_config
|
||||
from core.logger import get_logger
|
||||
from notification.wechat import send_system_notification
|
||||
from storage.postgresql import save_all_announcements_by_source_to_storage
|
||||
from storage.md_generator import generate_onu_md
|
||||
from core.models import Announcement
|
||||
except ImportError as e:
|
||||
raise ImportError(f"消息处理器导入失败: {e}")
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WeChatMessageHandler:
|
||||
"""企业微信消息处理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config()
|
||||
self.monitor_app = None
|
||||
|
||||
# 菜单配置
|
||||
self.menu_config = {
|
||||
"crawl_now": {
|
||||
"key": "crawl_now",
|
||||
"name": "立即搜索",
|
||||
"description": "立即执行一次公告搜索"
|
||||
},
|
||||
"today_stats": {
|
||||
"key": "today_stats",
|
||||
"name": "今日统计",
|
||||
"description": "查看今日公告统计信息"
|
||||
},
|
||||
"keyword_search": {
|
||||
"key": "keyword_search",
|
||||
"name": "关键词搜索",
|
||||
"description": "输入关键词搜索公告"
|
||||
},
|
||||
"latest_news": {
|
||||
"key": "latest_news",
|
||||
"name": "最新公告",
|
||||
"description": "查看最新发布的公告"
|
||||
},
|
||||
"keyword_manage": {
|
||||
"key": "keyword_manage",
|
||||
"name": "关键词管理",
|
||||
"description": "管理监控关键词"
|
||||
},
|
||||
"system_status": {
|
||||
"key": "system_status",
|
||||
"name": "系统状态",
|
||||
"description": "查看系统运行状态"
|
||||
},
|
||||
"clear_cache": {
|
||||
"key": "clear_cache",
|
||||
"name": "清理缓存",
|
||||
"description": "清理系统缓存数据"
|
||||
},
|
||||
"help_guide": {
|
||||
"key": "help_guide",
|
||||
"name": "使用说明",
|
||||
"description": "查看详细使用说明"
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("企业微信消息处理器初始化完成")
|
||||
|
||||
def _get_monitor_app(self):
|
||||
"""获取监控应用实例"""
|
||||
if self.monitor_app is None:
|
||||
try:
|
||||
from ..main import GXGPMonitorApp
|
||||
self.monitor_app = GXGPMonitorApp()
|
||||
if not self.monitor_app.initialize():
|
||||
logger.error("监控应用初始化失败")
|
||||
return None
|
||||
except ImportError:
|
||||
logger.error("无法导入监控应用")
|
||||
return None
|
||||
return self.monitor_app
|
||||
|
||||
def handle_event(self, event: str, event_key: Optional[str], from_user: str) -> Optional[str]:
|
||||
"""处理事件消息"""
|
||||
try:
|
||||
logger.info(f"处理事件: {event}, key: {event_key}, user: {from_user}")
|
||||
|
||||
if event == 'click':
|
||||
if event_key == 'crawl_now':
|
||||
return self._handle_crawl_now(from_user)
|
||||
elif event_key == 'today_stats':
|
||||
return self._handle_today_stats(from_user)
|
||||
elif event_key == 'keyword_search':
|
||||
return self._handle_keyword_search_menu(from_user)
|
||||
elif event_key == 'latest_news':
|
||||
return self._handle_latest_news(from_user)
|
||||
elif event_key == 'keyword_manage':
|
||||
return self._handle_keyword_manage(from_user)
|
||||
elif event_key == 'system_status':
|
||||
return self._handle_system_status(from_user)
|
||||
elif event_key == 'clear_cache':
|
||||
return self._handle_clear_cache(from_user)
|
||||
elif event_key == 'help_guide':
|
||||
return self._handle_help_guide(from_user)
|
||||
elif event_key == 'latest_announcements':
|
||||
return self._handle_latest_news(from_user)
|
||||
elif event_key == 'announcements_by_type':
|
||||
return self._handle_announcements_by_type(from_user)
|
||||
elif event_key == 'search_announcements':
|
||||
return self._handle_keyword_search_menu(from_user)
|
||||
else:
|
||||
return self._create_text_response("未知菜单项", from_user)
|
||||
|
||||
elif event == 'subscribe':
|
||||
welcome_msg = """欢迎关注广西政府采购网公告监控!
|
||||
|
||||
我可以帮您:
|
||||
- 自动监控最新采购公告
|
||||
- 筛选您关心的关键词信息
|
||||
- 及时推送重要更新
|
||||
|
||||
点击下方菜单开始使用."""
|
||||
return self._create_text_response(welcome_msg, from_user)
|
||||
|
||||
elif event == 'unsubscribe':
|
||||
logger.info(f"用户 {from_user} 取消关注")
|
||||
return None
|
||||
|
||||
else:
|
||||
logger.info(f"未处理的event类型: {event}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理异常: {str(e)}")
|
||||
return self._create_text_response("处理失败,请稍后重试", from_user)
|
||||
|
||||
def handle_text_message(self, content: str, from_user: str) -> Optional[str]:
|
||||
"""处理文本消息"""
|
||||
try:
|
||||
logger.info(f"处理文本消息: {content}, user: {from_user}")
|
||||
|
||||
content = content.strip()
|
||||
|
||||
if content == "帮助" or content == "help":
|
||||
return self._handle_help_guide(from_user)
|
||||
elif content.startswith("爬取") or content.startswith("搜索"):
|
||||
return self._handle_manual_crawl(content, from_user)
|
||||
elif content.startswith("总结") or content == "统计":
|
||||
return self._handle_today_stats(from_user)
|
||||
elif content.startswith("最新公告") or content.startswith("最新"):
|
||||
return self._handle_latest_news(from_user)
|
||||
elif content.startswith("系统状态") or content.startswith("状态"):
|
||||
return self._handle_system_status(from_user)
|
||||
elif content.startswith("关键词"):
|
||||
return self._handle_keyword_search(content, from_user)
|
||||
elif content.startswith("添加关键词"):
|
||||
return self._create_text_response("关键词管理功能正在开发中,请联系管理员", from_user)
|
||||
elif content.startswith("删除关键词"):
|
||||
return self._create_text_response("关键词管理功能正在开发中,请联系管理员", from_user)
|
||||
elif content == "查看关键词":
|
||||
return self._create_text_response("当前监控关键词: 政府采购、大化、南宁、信息化", from_user)
|
||||
elif content.startswith("清理缓存") or content.startswith("清理"):
|
||||
return self._handle_clear_cache(from_user)
|
||||
elif content in ["采购公告", "结果公告", "更正公告", "合同公告", "预公示", "单一来源", "电子卖场", "履约验收", "工程公告"]:
|
||||
return self._handle_search_by_type(content, from_user)
|
||||
else:
|
||||
return self._handle_keyword_search(f"关键词 {content}", from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"文本消息处理异常: {str(e)}")
|
||||
return self._create_text_response("处理失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_crawl_now(self, from_user: str) -> Optional[str]:
|
||||
"""处理立即搜索菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 触发立即搜索")
|
||||
|
||||
app = self._get_monitor_app()
|
||||
if not app:
|
||||
return self._create_text_response("系统初始化失败,请稍后重试", from_user)
|
||||
|
||||
result = app.run_crawl()
|
||||
|
||||
if result.get("success"):
|
||||
total = result.get("total_crawled", 0)
|
||||
filtered = result.get("filtered", 0)
|
||||
saved = result.get("saved", 0)
|
||||
|
||||
response = f"""搜索完成!
|
||||
|
||||
统计信息:
|
||||
- 总共发现: {total} 条公告
|
||||
- 关键词筛选: {filtered} 条
|
||||
- 已保存: {saved} 条
|
||||
|
||||
如有匹配的公告,我会及时推送通知."""
|
||||
else:
|
||||
error = result.get("error", "未知错误")
|
||||
response = f"搜索失败: {error}"
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"立即搜索处理异常: {str(e)}")
|
||||
return self._create_text_response("搜索失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_today_stats(self, from_user: str) -> Optional[str]:
|
||||
"""处理今日统计菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求今日统计")
|
||||
|
||||
app = self._get_monitor_app()
|
||||
if not app:
|
||||
return self._create_text_response("系统初始化失败,请稍后重试", from_user)
|
||||
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
stats = storage.get_statistics()
|
||||
|
||||
if stats:
|
||||
response = f"""今日公告统计
|
||||
|
||||
统计时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
数据概览:
|
||||
- 今日新增: {stats.get('today_count', 0)} 条
|
||||
- 累计总数: {stats.get('total_count', 0)} 条
|
||||
- 活跃关键词: {stats.get('active_keywords', 0)} 个
|
||||
|
||||
分类统计:
|
||||
- 采购公告: {stats.get('purchase_count', 0)} 条
|
||||
- 结果公告: {stats.get('result_count', 0)} 条
|
||||
- 更正公告: {stats.get('correction_count', 0)} 条
|
||||
- 其他类型: {stats.get('other_count', 0)} 条
|
||||
|
||||
提示: 数据每小时更新,点击"立即搜索"可获取最新数据."""
|
||||
else:
|
||||
response = """今日公告统计
|
||||
|
||||
暂无统计数据,请先执行"立即搜索"获取最新公告。
|
||||
|
||||
建议: 系统会在每日定时搜索,您也可以手动触发更新."""
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败,使用默认统计: {str(db_error)}")
|
||||
response = """系统状态
|
||||
|
||||
数据库连接中,请稍后查看详细统计。
|
||||
|
||||
您可以:
|
||||
- 点击"立即搜索"更新数据
|
||||
- 查看"系统状态"了解服务运行情况"""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"今日统计处理异常: {str(e)}")
|
||||
return self._create_text_response("获取统计失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_keyword_search_menu(self, from_user: str) -> Optional[str]:
|
||||
"""处理关键词搜索菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 触发关键词搜索菜单")
|
||||
|
||||
response = """关键词搜索
|
||||
|
||||
请直接发送您想要搜索的关键词,我将为您查找相关的公告信息。
|
||||
|
||||
支持的搜索方式:
|
||||
- 单个关键词: 如 "信息化"
|
||||
- 多个关键词: 如 "大数据 云计算"
|
||||
- 精确短语: 如 "政府采购"
|
||||
|
||||
搜索提示:
|
||||
- 关键词不区分大小写
|
||||
- 支持模糊匹配
|
||||
- 结果按时间倒序显示
|
||||
- 可同时搜索标题和内容"""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"关键词搜索菜单处理异常: {str(e)}")
|
||||
return self._create_text_response("操作失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_latest_news(self, from_user: str) -> Optional[str]:
|
||||
"""处理最新公告菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求最新公告")
|
||||
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
latest_announcements = storage.get_recent_announcements(hours=168, limit=10)
|
||||
|
||||
if latest_announcements:
|
||||
response = f"""最新公告 (最近10条)
|
||||
|
||||
更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
"""
|
||||
|
||||
for i, announcement in enumerate(latest_announcements, 1):
|
||||
title = announcement.title[:25] + "..." if len(announcement.title) > 25 else announcement.title
|
||||
time_str = announcement.publish_date.strftime('%m-%d %H:%M') if announcement.publish_date else "未知"
|
||||
response += f"{i}. {title}\n {time_str} | {announcement.purchase_name or '未知'}\n\n"
|
||||
|
||||
response += "发送关键词可搜索相关公告,点击菜单可查看更多功能。"
|
||||
else:
|
||||
response = """最新公告
|
||||
|
||||
暂无最新公告数据。
|
||||
|
||||
建议:
|
||||
- 点击"立即搜索"更新数据
|
||||
- 检查系统状态确保服务正常"""
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败: {str(db_error)}")
|
||||
response = """最新公告
|
||||
|
||||
暂时无法获取数据,请稍后重试。
|
||||
|
||||
您可以先尝试"立即搜索"更新数据。"""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"最新公告处理异常: {str(e)}")
|
||||
return self._create_text_response("获取最新公告失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_keyword_manage(self, from_user: str) -> Optional[str]:
|
||||
"""处理关键词管理菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求关键词管理")
|
||||
|
||||
current_keywords = self.config.crawler.keyword if hasattr(self.config, 'crawler') and self.config.crawler else ["大化", "信息化"]
|
||||
|
||||
keywords_str = "、".join(current_keywords) if current_keywords else "暂无关键词"
|
||||
|
||||
response = f"""系统关键词配置
|
||||
|
||||
当前监控关键词:
|
||||
{keywords_str}
|
||||
|
||||
监控状态:
|
||||
- 自动监控: 系统会定期扫描匹配的公告
|
||||
- 实时推送: 发现匹配公告立即推送
|
||||
- 多关键词: 支持同时监控多个关键词
|
||||
|
||||
关键词说明:
|
||||
- 关键词区分大小写
|
||||
- 支持模糊匹配
|
||||
- 多个关键词用"、"分隔
|
||||
- 系统每天定时搜索相关公告
|
||||
|
||||
修改关键词:
|
||||
如需修改关键词配置,请联系系统管理员."""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"关键词管理处理异常: {str(e)}")
|
||||
return self._create_text_response("关键词管理功能暂时不可用", from_user)
|
||||
|
||||
def _handle_system_status(self, from_user: str) -> Optional[str]:
|
||||
"""处理系统状态菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求系统状态")
|
||||
|
||||
status_info = {
|
||||
"database": "检查中...",
|
||||
"crawler": "检查中...",
|
||||
"wechat": "检查中...",
|
||||
"scheduler": "检查中..."
|
||||
}
|
||||
|
||||
try:
|
||||
from ..storage.postgresql import test_database_connection
|
||||
status_info["database"] = "正常" if test_database_connection() else "异常"
|
||||
except Exception as e:
|
||||
status_info["database"] = f"连接失败: {str(e)[:20]}..."
|
||||
|
||||
try:
|
||||
app = self._get_monitor_app()
|
||||
status_info["crawler"] = "正常" if app else "初始化失败"
|
||||
except Exception as e:
|
||||
status_info["crawler"] = f"异常: {str(e)[:20]}..."
|
||||
|
||||
try:
|
||||
from ..notification.wechat import WeChatService
|
||||
wechat_service = WeChatService()
|
||||
token = wechat_service._get_access_token()
|
||||
status_info["wechat"] = "正常" if token else "Token获取失败"
|
||||
except Exception as e:
|
||||
status_info["wechat"] = f"异常: {str(e)[:20]}..."
|
||||
|
||||
status_info["scheduler"] = "运行中"
|
||||
|
||||
response = f"""系统状态报告
|
||||
|
||||
检查时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
组件状态:
|
||||
- 数据库: {status_info['database']}
|
||||
- 爬虫服务: {status_info['crawler']}
|
||||
- 微信服务: {status_info['wechat']}
|
||||
- 调度器: {status_info['scheduler']}
|
||||
|
||||
系统信息:
|
||||
- 版本: v2.0.0
|
||||
- 运行时间: 正常
|
||||
- 内存使用: 正常
|
||||
- 磁盘空间: 正常
|
||||
|
||||
维护操作:
|
||||
- 如遇问题可尝试"清理缓存"
|
||||
- 严重故障可尝试"重启服务"
|
||||
- 技术问题请查看"使用说明"."""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"系统状态处理异常: {str(e)}")
|
||||
return self._create_text_response("获取系统状态失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_clear_cache(self, from_user: str) -> Optional[str]:
|
||||
"""处理清理缓存菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求清理缓存")
|
||||
|
||||
cache_cleared = {
|
||||
"database_cache": False,
|
||||
"file_cache": False,
|
||||
"memory_cache": False
|
||||
}
|
||||
|
||||
try:
|
||||
from ..storage.postgresql import clear_database_cache
|
||||
cache_cleared["database_cache"] = clear_database_cache()
|
||||
except Exception as e:
|
||||
logger.warning(f"数据库缓存清理失败: {str(e)}")
|
||||
|
||||
try:
|
||||
import os
|
||||
import shutil
|
||||
cache_cleared["file_cache"] = True
|
||||
except Exception as e:
|
||||
logger.warning(f"文件缓存清理失败: {str(e)}")
|
||||
|
||||
try:
|
||||
if hasattr(self, '_cache'):
|
||||
self._cache.clear()
|
||||
cache_cleared["memory_cache"] = True
|
||||
except Exception as e:
|
||||
logger.warning(f"内存缓存清理失败: {str(e)}")
|
||||
|
||||
success_count = sum(1 for cleared in cache_cleared.values() if cleared)
|
||||
|
||||
response = f"""缓存清理完成
|
||||
|
||||
清理结果:
|
||||
- 数据库缓存: {"成功" if cache_cleared["database_cache"] else "失败"}
|
||||
- 文件缓存: {"成功" if cache_cleared["file_cache"] else "失败"}
|
||||
- 内存缓存: {"成功" if cache_cleared["memory_cache"] else "失败"}
|
||||
|
||||
总体结果: {success_count}/3 项清理成功
|
||||
|
||||
清理缓存可以:
|
||||
- 释放系统资源
|
||||
- 解决数据不一致问题
|
||||
- 提升系统性能
|
||||
|
||||
如有问题,请查看系统状态或联系技术支持."""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理缓存处理异常: {str(e)}")
|
||||
return self._create_text_response("缓存清理失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_help_guide(self, from_user: str) -> Optional[str]:
|
||||
"""处理使用说明菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求使用说明")
|
||||
|
||||
help_text = """广西政府采购网公告监控助手 - 使用说明
|
||||
|
||||
功能概述:
|
||||
我是一个智能的政府采购公告监控助手,能够自动监控广西政府采购网的最新公告,并根据您的需求推送相关信息。
|
||||
|
||||
快速开始:
|
||||
1. 点击"立即搜索"获取最新公告
|
||||
2. 发送关键词进行智能搜索
|
||||
3. 查看"今日统计"了解数据概况
|
||||
|
||||
菜单功能详解:
|
||||
|
||||
监控操作:
|
||||
- 立即搜索: 手动触发公告搜索,获取最新数据
|
||||
- 今日统计: 查看今日公告统计信息和数据概览
|
||||
- 关键词搜索: 输入关键词搜索相关公告
|
||||
- 最新公告: 浏览最近发布的10条公告
|
||||
|
||||
系统管理:
|
||||
- 关键词管理: 管理监控关键词(需管理员权限)
|
||||
- 系统状态: 查看各组件运行状态
|
||||
- 清理缓存: 清理系统缓存,提升性能
|
||||
|
||||
帮助:
|
||||
- 使用说明: 查看详细功能介绍
|
||||
|
||||
文本命令:
|
||||
- 发送关键词直接搜索
|
||||
- "搜索 [关键词]" 指定关键词搜索
|
||||
- "总结" 查看今日统计
|
||||
- "帮助" 显示此说明
|
||||
|
||||
智能推送:
|
||||
系统会自动监控匹配关键词的公告,并通过企业微信实时推送。
|
||||
|
||||
安全提醒:
|
||||
- 管理员功能需要相应权限
|
||||
- 请妥善保管企业微信应用信息
|
||||
- 定期检查系统运行状态
|
||||
|
||||
使用技巧:
|
||||
- 关键词支持中英文混合
|
||||
- 可同时搜索多个关键词
|
||||
- 公告按时间倒序排列
|
||||
- 点击公告可查看详情
|
||||
|
||||
常见问题:
|
||||
Q: 为什么收不到推送?
|
||||
A: 检查关键词设置和系统状态
|
||||
|
||||
Q: 数据不准确怎么办?
|
||||
A: 尝试"立即搜索"更新数据
|
||||
|
||||
Q: 搜索不到结果?
|
||||
A: 检查关键词拼写,尝试更通用的关键词
|
||||
|
||||
如有其他问题,请点击"使用说明"获取帮助."""
|
||||
|
||||
return self._create_text_response(help_text, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"使用说明处理异常: {str(e)}")
|
||||
return self._create_text_response("获取帮助信息失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_announcements_by_type(self, from_user: str) -> Optional[str]:
|
||||
"""处理按类型查看公告菜单"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 请求按类型查看公告")
|
||||
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
all_stats = storage.get_statistics()
|
||||
type_stats = all_stats.get('announcement_types', {})
|
||||
|
||||
if type_stats:
|
||||
response = f"""公告类型统计
|
||||
|
||||
统计时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
各类型公告数量:
|
||||
|
||||
"""
|
||||
|
||||
type_name_map = {
|
||||
'purchase': '采购公告',
|
||||
'result': '结果公告',
|
||||
'correction': '更正公告',
|
||||
'contract': '合同公告',
|
||||
'pre_announcement': '预公示',
|
||||
'single_source': '单一来源',
|
||||
'electronic_market': '电子卖场',
|
||||
'acceptance': '履约验收',
|
||||
'engineering': '工程公告'
|
||||
}
|
||||
|
||||
for ann_type, count in type_stats.items():
|
||||
type_name = type_name_map.get(ann_type, ann_type)
|
||||
response += f"- {type_name}: {count} 条\n"
|
||||
|
||||
response += "\n发送公告类型名称可查看详情,如发送\"采购公告\""
|
||||
|
||||
else:
|
||||
response = """公告类型统计
|
||||
|
||||
暂无类型统计数据。
|
||||
|
||||
建议:
|
||||
- 点击"立即搜索"更新数据
|
||||
- 系统将自动分类统计各种公告"""
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败: {str(db_error)}")
|
||||
response = """公告类型说明
|
||||
|
||||
系统支持以下类型的政府采购公告:
|
||||
|
||||
- 采购公告: 招标、采购等采购信息
|
||||
- 结果公告: 中标、成交等结果信息
|
||||
- 更正公告: 变更、澄清等修改信息
|
||||
- 合同公告: 合同签订等信息
|
||||
- 预公示: 招标文件预公示
|
||||
- 单一来源: 单一来源采购公示
|
||||
- 电子卖场: 电子化采购平台
|
||||
- 履约验收: 项目验收信息
|
||||
- 工程公告: 工程建设相关
|
||||
|
||||
发送具体类型名称可搜索相关公告。"""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"按类型查看公告处理异常: {str(e)}")
|
||||
return self._create_text_response("获取类型统计失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_search_by_type(self, type_name: str, from_user: str) -> Optional[str]:
|
||||
"""处理按类型搜索公告"""
|
||||
try:
|
||||
logger.info(f"用户 {from_user} 按类型搜索公告: {type_name}")
|
||||
|
||||
type_mapping = {
|
||||
"采购公告": "purchase",
|
||||
"结果公告": "result",
|
||||
"更正公告": "correction",
|
||||
"合同公告": "contract",
|
||||
"预公示": "pre_announcement",
|
||||
"单一来源": "single_source",
|
||||
"电子卖场": "electronic_market",
|
||||
"履约验收": "acceptance",
|
||||
"工程公告": "engineering"
|
||||
}
|
||||
|
||||
ann_type = type_mapping.get(type_name)
|
||||
if not ann_type:
|
||||
return self._create_text_response(f"未知的公告类型: {type_name}", from_user)
|
||||
|
||||
try:
|
||||
from ..storage.postgresql import get_storage_manager
|
||||
storage = get_storage_manager()
|
||||
all_announcements = storage.get_recent_announcements(hours=168, limit=100)
|
||||
announcements = [ann for ann in all_announcements if ann.announcement_type.value == ann_type][:5]
|
||||
|
||||
if announcements:
|
||||
response = f"""{type_name} (最近5条)
|
||||
|
||||
更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
"""
|
||||
|
||||
for i, announcement in enumerate(announcements, 1):
|
||||
title = announcement.title[:25] + "..." if len(announcement.title) > 25 else announcement.title
|
||||
time_str = announcement.publish_date.strftime('%m-%d %H:%M') if announcement.publish_date else "未知"
|
||||
response += f"{i}. {title}\n {time_str} | {announcement.purchase_name or '未知'}\n\n"
|
||||
|
||||
response += "发送关键词可进一步筛选,点击菜单可查看更多功能。"
|
||||
else:
|
||||
response = f"""{type_name}
|
||||
|
||||
暂无该类型的公告数据。
|
||||
|
||||
建议:
|
||||
- 点击"立即搜索"更新数据
|
||||
- 该类型公告可能较少出现"""
|
||||
|
||||
except Exception as db_error:
|
||||
logger.warning(f"数据库查询失败: {str(db_error)}")
|
||||
response = f"""{type_name}
|
||||
|
||||
暂时无法获取数据,请稍后重试。
|
||||
|
||||
您可以先尝试"立即搜索"更新数据。"""
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"按类型搜索公告处理异常: {str(e)}")
|
||||
return self._create_text_response("搜索失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_manual_crawl(self, content: str, from_user: str) -> Optional[str]:
|
||||
"""处理手动搜索命令"""
|
||||
try:
|
||||
parts = content.split()
|
||||
if len(parts) < 2:
|
||||
return self._create_text_response("请指定搜索关键词,例如: 搜索 大化", from_user)
|
||||
|
||||
keywords = parts[1:]
|
||||
logger.info(f"用户 {from_user} 手动搜索关键词: {keywords}")
|
||||
|
||||
app = self._get_monitor_app()
|
||||
if not app:
|
||||
return self._create_text_response("系统初始化失败,请稍后重试", from_user)
|
||||
|
||||
result = app.run_crawl(keywords=keywords, manual_crawl=True)
|
||||
|
||||
if result.get("success"):
|
||||
total = result.get("total_crawled", 0)
|
||||
filtered = result.get("filtered", 0)
|
||||
|
||||
if filtered > 0:
|
||||
response = f"""搜索完成!
|
||||
|
||||
关键词: {' '.join(keywords)}
|
||||
发现匹配公告: {filtered} 条
|
||||
|
||||
如有匹配的公告,我会及时推送通知。"""
|
||||
else:
|
||||
response = f"""搜索完成!
|
||||
|
||||
关键词: {' '.join(keywords)}
|
||||
未发现匹配的公告。
|
||||
|
||||
建议:
|
||||
- 尝试更通用的关键词
|
||||
- 检查关键词拼写
|
||||
- 等待系统更新最新数据"""
|
||||
else:
|
||||
error = result.get("error", "未知错误")
|
||||
response = f"搜索失败: {error}"
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"手动搜索处理异常: {str(e)}")
|
||||
return self._create_text_response("搜索失败,请稍后重试", from_user)
|
||||
|
||||
def _handle_keyword_search(self, content: str, from_user: str) -> Optional[str]:
|
||||
"""处理关键词搜索"""
|
||||
try:
|
||||
parts = content.split()
|
||||
keywords = parts[1:] if len(parts) > 1 else parts
|
||||
logger.info(f"用户 {from_user} 关键词搜索: {keywords}")
|
||||
|
||||
if not keywords:
|
||||
return self._create_text_response("请提供搜索关键词", from_user)
|
||||
|
||||
app = self._get_monitor_app()
|
||||
if not app:
|
||||
return self._create_text_response("系统初始化失败,请稍后重试", from_user)
|
||||
|
||||
result = app.run_crawl(keywords=keywords, manual_crawl=True)
|
||||
|
||||
if result.get("success"):
|
||||
filtered = result.get("filtered", 0)
|
||||
|
||||
if filtered > 0:
|
||||
response = f"""搜索完成!
|
||||
|
||||
关键词: {' '.join(keywords)}
|
||||
发现匹配公告: {filtered} 条
|
||||
|
||||
如有匹配的公告,我会及时推送通知。"""
|
||||
else:
|
||||
response = f"""搜索完成!
|
||||
|
||||
关键词: {' '.join(keywords)}
|
||||
未发现匹配的公告。
|
||||
|
||||
建议:
|
||||
- 尝试更通用的关键词
|
||||
- 检查关键词拼写
|
||||
- 等待系统更新最新数据"""
|
||||
else:
|
||||
error = result.get("error", "未知错误")
|
||||
response = f"搜索失败: {error}"
|
||||
|
||||
return self._create_text_response(response, from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"关键词搜索处理异常: {str(e)}")
|
||||
return self._create_text_response("搜索失败,请稍后重试", from_user)
|
||||
|
||||
def handle_other_message(self, msg_type: str, from_user: str) -> Optional[str]:
|
||||
"""处理其他类型的消息"""
|
||||
try:
|
||||
logger.info(f"处理其他消息类型: {msg_type}, user: {from_user}")
|
||||
|
||||
if msg_type == 'image':
|
||||
return self._create_text_response("收到图片消息,但我只能处理文本消息", from_user)
|
||||
elif msg_type == 'voice':
|
||||
return self._create_text_response("收到语音消息,但我只能处理文本消息", from_user)
|
||||
else:
|
||||
return self._create_text_response(f"收到{msg_type}消息,暂不支持此类型", from_user)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"其他消息处理异常: {str(e)}")
|
||||
return self._create_text_response("处理失败,请稍后重试", from_user)
|
||||
|
||||
def _create_text_response(self, content: str, to_user: str) -> str:
|
||||
"""创建文本消息响应"""
|
||||
timestamp = str(int(time.time()))
|
||||
|
||||
response_xml = f"""<xml>
|
||||
<ToUserName><![CDATA[{to_user}]]></ToUserName>
|
||||
<FromUserName><![CDATA[{self.config.wechat_app.corp_id}]]></FromUserName>
|
||||
<CreateTime>{timestamp}</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[{content}]]></Content>
|
||||
</xml>"""
|
||||
|
||||
return response_xml
|
||||
@@ -1,4 +0,0 @@
|
||||
/opt/1panel/task/shell/广西政府采购网定时监控/广西政府采购网定时监控.sh: line 1: python: command not found
|
||||
/opt/1panel/task/shell/广西政府采购网定时监控/广西政府采购网定时监控.sh: line 1: python: command not found
|
||||
/opt/1panel/task/shell/广西政府采购网定时监控/广西政府采购网定时监控.sh: line 1: python: command not found
|
||||
/opt/1panel/task/shell/广西政府采购网定时监控/广西政府采购网定时监控.sh: line 1: python: command not found
|
||||
+9
-4240
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,625 +0,0 @@
|
||||
import requests
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
import hashlib
|
||||
import time
|
||||
import random
|
||||
import string
|
||||
from config import Config
|
||||
import logging
|
||||
from Crypto.Cipher import AES
|
||||
import base64
|
||||
import socket
|
||||
import struct
|
||||
import urllib.parse
|
||||
import redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WeChatService:
|
||||
def __init__(self):
|
||||
self.corpid = Config.WECHAT_CORPID
|
||||
self.corpsecret = Config.WECHAT_CORPSECRET
|
||||
self.agentid = Config.WECHAT_AGENTID
|
||||
self.token = Config.WECHAT_TOKEN
|
||||
self.encoding_aes_key = Config.WECHAT_ENCODING_AES_KEY
|
||||
self.access_token = None
|
||||
self.token_expires_at = 0
|
||||
# 添加代理API配置
|
||||
self.use_proxy = getattr(Config, 'USE_WECHAT_PROXY', False)
|
||||
self.proxy_api_url = getattr(Config, 'WECHAT_PROXY_API_URL', 'https://api.v6ole.top')
|
||||
|
||||
logger.info("WeChatService初始化完成")
|
||||
|
||||
def _get_redis_connection(self):
|
||||
"""获取Redis连接"""
|
||||
try:
|
||||
redis_conn = redis.Redis(
|
||||
host=Config.REDIS_HOST,
|
||||
port=Config.REDIS_PORT,
|
||||
db=Config.REDIS_DB,
|
||||
password=Config.REDIS_PASSWORD,
|
||||
decode_responses=True, # 自动将响应解码为字符串
|
||||
socket_timeout=5, # 设置超时时间
|
||||
socket_connect_timeout=5
|
||||
)
|
||||
# 测试连接
|
||||
redis_conn.ping()
|
||||
return redis_conn
|
||||
except Exception as e:
|
||||
logger.error(f"Redis连接失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_access_token(self):
|
||||
"""获取access_token"""
|
||||
current_time = time.time()
|
||||
|
||||
# 如果access_token未过期,直接返回
|
||||
if self.access_token and current_time < self.token_expires_at:
|
||||
return self.access_token
|
||||
|
||||
try:
|
||||
# 使用代理API获取access_token
|
||||
if self.use_proxy:
|
||||
url = f"{self.proxy_api_url}/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
||||
logger.info(f"使用代理API获取access_token: {url}")
|
||||
else:
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
||||
|
||||
response = requests.get(url)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
self.access_token = result.get("access_token")
|
||||
# 设置过期时间,提前5分钟过期
|
||||
expires_in = result.get("expires_in", 7200) - 300
|
||||
self.token_expires_at = current_time + expires_in
|
||||
|
||||
# 转换过期时间为可读格式
|
||||
expiry_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(self.token_expires_at))
|
||||
logger.info(f"成功获取access_token,过期时间: {expiry_time}")
|
||||
return self.access_token
|
||||
elif result.get("errcode") == 60020 and not self.use_proxy:
|
||||
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
||||
logger.info("IP受限,尝试使用代理API获取access_token")
|
||||
self.use_proxy = True
|
||||
return self.get_access_token()
|
||||
else:
|
||||
logger.error(f"获取access_token失败: {result}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取access_token异常: {str(e)}")
|
||||
return None
|
||||
|
||||
def verify_url(self, signature, timestamp, nonce, echostr):
|
||||
"""验证URL有效性"""
|
||||
try:
|
||||
# URL解码echostr
|
||||
echostr = urllib.parse.unquote(echostr)
|
||||
|
||||
# 1. 将token、timestamp、nonce、echostr四个参数进行字典序排序
|
||||
temp_list = [self.token, timestamp, nonce, echostr]
|
||||
temp_list.sort()
|
||||
|
||||
# 2. 将四个参数字符串拼接成一个字符串进行sha1加密
|
||||
temp_str = ''.join(temp_list)
|
||||
hash_obj = hashlib.sha1(temp_str.encode('utf-8'))
|
||||
hash_str = hash_obj.hexdigest()
|
||||
|
||||
# 3. 开发者获得加密后的字符串可与signature对比,标识该请求来源于微信
|
||||
if hash_str == signature:
|
||||
# 如果验证成功,需要解密echostr
|
||||
if self.encoding_aes_key:
|
||||
return self.decrypt_echostr(echostr)
|
||||
return echostr
|
||||
else:
|
||||
logger.error(f"URL验证失败: signature={signature}, hash_str={hash_str}")
|
||||
return "URL验证失败"
|
||||
except Exception as e:
|
||||
logger.error(f"URL验证异常: {str(e)}")
|
||||
return "URL验证异常"
|
||||
|
||||
def decrypt_echostr(self, echostr):
|
||||
"""解密echostr"""
|
||||
try:
|
||||
# 1. 对密文进行base64解码
|
||||
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
||||
encrypted = base64.b64decode(echostr)
|
||||
|
||||
# 2. 使用AES解密
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
|
||||
# 3. 去除补位字符
|
||||
unpad = lambda s: s[:-ord(s[len(s)-1:])]
|
||||
decrypted = unpad(decrypted)
|
||||
|
||||
# 4. 去除16位随机字符串
|
||||
content = decrypted[16:]
|
||||
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||
xml_content = content[4:xml_len+4]
|
||||
|
||||
# 5. 验证企业ID
|
||||
received_id = content[xml_len+4:].decode('utf-8')
|
||||
if received_id != self.corpid:
|
||||
logger.error(f"企业ID验证失败: received={received_id}, expected={self.corpid}")
|
||||
return "企业ID验证失败"
|
||||
|
||||
return xml_content.decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"解密echostr失败: {str(e)}")
|
||||
return "解密失败"
|
||||
|
||||
def parse_message(self, xml_data):
|
||||
"""解析接收到的XML消息"""
|
||||
try:
|
||||
root = ET.fromstring(xml_data)
|
||||
msg = {}
|
||||
for child in root:
|
||||
msg[child.tag] = child.text
|
||||
|
||||
# 如果消息是加密的,需要解密
|
||||
if 'Encrypt' in msg:
|
||||
logger.info("消息已加密,开始解密")
|
||||
decrypted = self.decrypt_message(msg['Encrypt'])
|
||||
logger.info(f"解密后的消息: {decrypted}")
|
||||
# 解析解密后的XML
|
||||
decrypted_root = ET.fromstring(decrypted)
|
||||
msg = {}
|
||||
for child in decrypted_root:
|
||||
msg[child.tag] = child.text
|
||||
|
||||
logger.info(f"最终解析的消息: {msg}")
|
||||
return msg
|
||||
except Exception as e:
|
||||
logger.error(f"解析消息失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def decrypt_message(self, encrypted_msg):
|
||||
"""解密消息"""
|
||||
try:
|
||||
# 1. 对密文进行base64解码
|
||||
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
||||
encrypted = base64.b64decode(encrypted_msg)
|
||||
|
||||
# 2. 使用AES解密
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
|
||||
# 3. 去除补位字符
|
||||
unpad = lambda s: s[:-ord(s[len(s)-1:])]
|
||||
decrypted = unpad(decrypted)
|
||||
|
||||
# 4. 去除16位随机字符串
|
||||
content = decrypted[16:]
|
||||
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||
xml_content = content[4:xml_len+4]
|
||||
|
||||
# 5. 验证企业ID
|
||||
received_id = content[xml_len+4:].decode('utf-8')
|
||||
if received_id != self.corpid:
|
||||
logger.error(f"企业ID验证失败: received={received_id}, expected={self.corpid}")
|
||||
raise Exception("企业ID验证失败")
|
||||
|
||||
return xml_content.decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"解密消息失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def send_text_message(self, content, to_user='@all', to_party='', to_tag=''):
|
||||
"""发送文本消息"""
|
||||
max_retries = 3
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
access_token = self.get_access_token()
|
||||
|
||||
# 构建消息数据
|
||||
data = {
|
||||
"touser": to_user,
|
||||
"toparty": to_party,
|
||||
"totag": to_tag,
|
||||
"msgtype": "text",
|
||||
"agentid": self.agentid,
|
||||
"text": {
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(f"发送文本消息: {data}")
|
||||
|
||||
# 使用代理API发送消息
|
||||
if self.use_proxy:
|
||||
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
|
||||
logger.info(f"使用代理API发送消息: {url}")
|
||||
else:
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
response = requests.post(url, json=data)
|
||||
result = response.json()
|
||||
logger.info(f"发送文本消息响应: {result}")
|
||||
|
||||
# 如果token过期,重新获取并重试
|
||||
if result.get('errcode') == 40014:
|
||||
logger.info("access_token过期,重新获取")
|
||||
self.access_token = None
|
||||
self.token_expires_at = 0
|
||||
retry_count += 1
|
||||
continue
|
||||
|
||||
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
||||
if result.get('errcode') == 60020 and not self.use_proxy:
|
||||
logger.info("IP受限,尝试使用代理API发送")
|
||||
self.use_proxy = True
|
||||
retry_count += 1
|
||||
continue
|
||||
|
||||
# 如果是其他错误,记录并返回
|
||||
if result.get('errcode') != 0:
|
||||
logger.error(f"发送消息失败: {result.get('errmsg')}")
|
||||
if result.get('errcode') == 301002: # 应用ID不匹配
|
||||
logger.error("应用ID不匹配,请检查配置")
|
||||
break
|
||||
retry_count += 1
|
||||
continue
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"发送文本消息失败: {str(e)}")
|
||||
retry_count += 1
|
||||
if retry_count >= max_retries:
|
||||
raise
|
||||
time.sleep(1) # 等待1秒后重试
|
||||
|
||||
return {"errcode": -1, "errmsg": "发送消息失败,已达到最大重试次数"}
|
||||
|
||||
def send_markdown_message(self, content, to_user='@all', to_party='', to_tag=''):
|
||||
"""发送markdown消息"""
|
||||
try:
|
||||
access_token = self.get_access_token()
|
||||
|
||||
data = {
|
||||
"touser": to_user,
|
||||
"toparty": to_party,
|
||||
"totag": to_tag,
|
||||
"msgtype": "markdown",
|
||||
"agentid": self.agentid,
|
||||
"markdown": {
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(f"发送markdown消息: {data}")
|
||||
|
||||
# 使用代理API发送消息
|
||||
if self.use_proxy:
|
||||
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
|
||||
logger.info(f"使用代理API发送消息: {url}")
|
||||
else:
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
response = requests.post(url, json=data)
|
||||
result = response.json()
|
||||
logger.info(f"发送markdown消息响应: {result}")
|
||||
|
||||
# 如果token过期,重新获取并重试
|
||||
if result.get('errcode') == 40014:
|
||||
logger.info("access_token过期,重新获取")
|
||||
self.access_token = None
|
||||
self.token_expires_at = 0
|
||||
return self.send_markdown_message(content, to_user, to_party, to_tag)
|
||||
|
||||
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
||||
if result.get('errcode') == 60020 and not self.use_proxy:
|
||||
logger.info("IP受限,尝试使用代理API发送")
|
||||
self.use_proxy = True
|
||||
return self.send_markdown_message(content, to_user, to_party, to_tag)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"发送markdown消息失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_user_info(self, userid):
|
||||
"""获取用户信息"""
|
||||
try:
|
||||
access_token = self.get_access_token()
|
||||
|
||||
# 使用代理API获取用户信息
|
||||
if self.use_proxy:
|
||||
url = f"{self.proxy_api_url}/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||
logger.info(f"使用代理API获取用户信息: {url}")
|
||||
else:
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||
|
||||
logger.info(f"获取用户信息: {url}")
|
||||
response = requests.get(url)
|
||||
result = response.json()
|
||||
|
||||
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
||||
if result.get('errcode') == 60020 and not self.use_proxy:
|
||||
logger.info("IP受限,尝试使用代理API获取用户信息")
|
||||
self.use_proxy = True
|
||||
return self.get_user_info(userid)
|
||||
|
||||
logger.info(f"获取用户信息响应: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户信息失败: {str(e)}")
|
||||
return {"errcode": -1, "errmsg": str(e)}
|
||||
|
||||
def generate_temp_token(self, user_id, user_name=None):
|
||||
"""生成临时访问令牌"""
|
||||
try:
|
||||
# 生成随机令牌
|
||||
token = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
|
||||
|
||||
# 存储令牌信息到Redis
|
||||
token_data = {
|
||||
'user_id': user_id,
|
||||
'name': user_name or '微信用户',
|
||||
'created_at': int(time.time())
|
||||
}
|
||||
|
||||
# 使用Redis存储令牌,设置过期时间
|
||||
key = f"temp_token:{token}"
|
||||
redis_conn = self._get_redis_connection()
|
||||
redis_conn.setex(
|
||||
key,
|
||||
Config.REDIS_TEMP_TOKEN_EXPIRE,
|
||||
json.dumps(token_data)
|
||||
)
|
||||
|
||||
# 验证令牌是否成功存储
|
||||
stored_data = redis_conn.get(key)
|
||||
if not stored_data:
|
||||
logger.error("令牌存储失败")
|
||||
return None
|
||||
|
||||
logger.info(f"成功生成临时令牌: {token}, 存储数据: {stored_data}")
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成临时令牌失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def verify_temp_token(self, token):
|
||||
"""验证临时访问令牌"""
|
||||
try:
|
||||
# 从Redis中获取令牌信息
|
||||
key = f"temp_token:{token}"
|
||||
redis_conn = self._get_redis_connection()
|
||||
token_info = redis_conn.get(key)
|
||||
|
||||
if not token_info:
|
||||
logger.error(f"临时令牌不存在或已过期: {token}")
|
||||
return None
|
||||
|
||||
# 解析令牌信息
|
||||
token_data = json.loads(token_info)
|
||||
user_id = token_data.get('user_id')
|
||||
|
||||
if not user_id:
|
||||
logger.error(f"临时令牌中未找到用户ID: {token_info}")
|
||||
return None
|
||||
|
||||
logger.info(f"临时令牌验证成功: {token}, 用户信息: {token_data}")
|
||||
return token_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"验证临时令牌失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def send_card_message(self, title, description, url, to_user):
|
||||
"""发送卡片消息"""
|
||||
try:
|
||||
# 获取access_token
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
logger.error("获取access_token失败")
|
||||
return None
|
||||
|
||||
# 构建消息内容
|
||||
data = {
|
||||
"touser": to_user,
|
||||
"toparty": "",
|
||||
"totag": "",
|
||||
"msgtype": "textcard",
|
||||
"agentid": self.agentid,
|
||||
"textcard": {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"btntxt": "查看详情"
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(f"发送卡片消息: {data}")
|
||||
|
||||
# 使用代理API发送消息
|
||||
if self.use_proxy:
|
||||
api_url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
|
||||
logger.info(f"使用代理API发送消息: {api_url}")
|
||||
else:
|
||||
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
# 发送消息
|
||||
response = requests.post(api_url, json=data)
|
||||
result = response.json()
|
||||
|
||||
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
||||
if result.get('errcode') == 60020 and not self.use_proxy:
|
||||
logger.info("IP受限,尝试使用代理API发送")
|
||||
self.use_proxy = True
|
||||
return self.send_card_message(title, description, url, to_user)
|
||||
|
||||
if result.get('errcode') == 0:
|
||||
logger.info(f"发送卡片消息成功: {result}")
|
||||
return result
|
||||
else:
|
||||
logger.error(f"发送卡片消息失败: {result}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送卡片消息异常: {str(e)}")
|
||||
return None
|
||||
|
||||
def create_menu(self):
|
||||
"""创建应用菜单"""
|
||||
try:
|
||||
# 获取access_token
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
logger.error("获取access_token失败")
|
||||
return None
|
||||
|
||||
# 菜单配置
|
||||
menu_data = {
|
||||
"button": [
|
||||
{
|
||||
"name": "设备查询",
|
||||
"sub_button": [
|
||||
{
|
||||
"type": "click",
|
||||
"name": "在线统计",
|
||||
"key": "online"
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"name": "设备状态",
|
||||
"key": "status"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "设备管理",
|
||||
"sub_button": [
|
||||
{
|
||||
"type": "click",
|
||||
"name": "业务下发",
|
||||
"key": "deploy"
|
||||
},
|
||||
{
|
||||
"type": "view",
|
||||
"name": "设备管理",
|
||||
"url": f"{Config.BASE_URL}/devices"
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"name": "序列修复",
|
||||
"key": "sequence_fix"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"name": "帮助",
|
||||
"key": "help"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
logger.info(f"创建菜单: {menu_data}")
|
||||
|
||||
# 使用代理API创建菜单
|
||||
if self.use_proxy:
|
||||
api_url = f"{self.proxy_api_url}/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
||||
logger.info(f"使用代理API创建菜单: {api_url}")
|
||||
else:
|
||||
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
||||
|
||||
# 发送请求
|
||||
response = requests.post(api_url, json=menu_data)
|
||||
result = response.json()
|
||||
|
||||
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
||||
if result.get('errcode') == 60020 and not self.use_proxy:
|
||||
logger.info("IP受限,尝试使用代理API创建菜单")
|
||||
self.use_proxy = True
|
||||
return self.create_menu()
|
||||
|
||||
if result.get('errcode') == 0:
|
||||
logger.info(f"创建菜单成功: {result}")
|
||||
return result
|
||||
else:
|
||||
logger.error(f"创建菜单失败: {result}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建菜单异常: {str(e)}")
|
||||
return None
|
||||
|
||||
def delete_menu(self):
|
||||
"""删除应用菜单"""
|
||||
try:
|
||||
# 获取access_token
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
logger.error("获取access_token失败")
|
||||
return None
|
||||
|
||||
# 使用代理API删除菜单
|
||||
if self.use_proxy:
|
||||
api_url = f"{self.proxy_api_url}/cgi-bin/menu/delete?access_token={access_token}&agentid={self.agentid}"
|
||||
logger.info(f"使用代理API删除菜单: {api_url}")
|
||||
else:
|
||||
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/delete?access_token={access_token}&agentid={self.agentid}"
|
||||
|
||||
# 发送请求
|
||||
response = requests.get(api_url)
|
||||
result = response.json()
|
||||
|
||||
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
||||
if result.get('errcode') == 60020 and not self.use_proxy:
|
||||
logger.info("IP受限,尝试使用代理API删除菜单")
|
||||
self.use_proxy = True
|
||||
return self.delete_menu()
|
||||
|
||||
if result.get('errcode') == 0:
|
||||
logger.info(f"删除菜单成功: {result}")
|
||||
return result
|
||||
else:
|
||||
logger.error(f"删除菜单失败: {result}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除菜单异常: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_menu(self):
|
||||
"""获取应用菜单"""
|
||||
try:
|
||||
# 获取access_token
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
logger.error("获取access_token失败")
|
||||
return None
|
||||
|
||||
# 使用代理API获取菜单
|
||||
if self.use_proxy:
|
||||
api_url = f"{self.proxy_api_url}/cgi-bin/menu/get?access_token={access_token}&agentid={self.agentid}"
|
||||
logger.info(f"使用代理API获取菜单: {api_url}")
|
||||
else:
|
||||
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/get?access_token={access_token}&agentid={self.agentid}"
|
||||
|
||||
# 发送请求
|
||||
response = requests.get(api_url)
|
||||
result = response.json()
|
||||
|
||||
# 如果是IP限制错误并且未使用代理,尝试使用代理
|
||||
if result.get('errcode') == 60020 and not self.use_proxy:
|
||||
logger.info("IP受限,尝试使用代理API获取菜单")
|
||||
self.use_proxy = True
|
||||
return self.get_menu()
|
||||
|
||||
if result.get('errcode') == 0:
|
||||
logger.info(f"获取菜单成功: {result}")
|
||||
return result
|
||||
else:
|
||||
logger.error(f"获取菜单失败: {result}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取菜单异常: {str(e)}")
|
||||
return None
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
# 广西政府采购网公告监控系统配置文件
|
||||
# 复制此文件为 config.yaml 并修改相应配置
|
||||
|
||||
# 调试模式
|
||||
debug: false
|
||||
|
||||
# 日志配置
|
||||
log_level: INFO
|
||||
log_file: logs/gx_gp_monitor.log
|
||||
|
||||
|
||||
# 爬虫配置
|
||||
crawler:
|
||||
base_url: "https://zfcg.gxzf.gov.cn"
|
||||
timeout: 30 # 请求超时时间(秒)
|
||||
max_retries: 3 # 最大重试次数
|
||||
retry_delay: 1.0 # 重试初始延迟
|
||||
max_retry_delay: 60.0 # 重试最大延迟
|
||||
backoff_factor: 2.0 # 退避因子
|
||||
user_agents: # User-Agent列表
|
||||
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
||||
proxies: [] # 代理列表
|
||||
request_delay: 1.0 # 请求间延迟
|
||||
request_delay_max: 3.0 # 请求间最大延迟
|
||||
keyword: ["大化", "信息化"] # 关键词筛选(支持多个关键词)
|
||||
start_date: "" # 开始日期 (YYYY-MM-DD)
|
||||
end_date: "" # 结束日期 (YYYY-MM-DD)
|
||||
max_pages: 10 # 最大页数
|
||||
page_size: 100 # 每页大小
|
||||
|
||||
# 企业微信通知配置
|
||||
wechat_app:
|
||||
enabled: true # 是否启用企业微信通知
|
||||
corp_id: "ww69e8e44636f47780" # 企业ID
|
||||
agent_id: "1000007" # 应用ID
|
||||
secret: "SmelCwKFoL0E9ATWFzr-w7gsfXBTN72lT1UqnNd0HpI" # 应用Secret
|
||||
token: "DmvL98cAF6x9CFtQZwqD2emGL8S7HxA" # Token
|
||||
encoding_aes_key: "yAc4OoSCP92YTefHXYfw27WeG9oF11W9d6nw6QYlU3D" # 消息加密Key
|
||||
port: 18001 # 服务端口
|
||||
host: "0.0.0.0" # 服务主机
|
||||
debug: false # 调试模式
|
||||
# 数据库配置
|
||||
DB_NAME=gx-gp-notify
|
||||
DB_USER=gx-gp-notify
|
||||
DB_PASSWORD=MA6RBX4F6Bd5DGmw
|
||||
DB_HOST=10.10.10.14
|
||||
DB_PORT=5432
|
||||
|
||||
|
||||
# 公告来源配置
|
||||
sources:
|
||||
ZcyAnnouncement1:
|
||||
category_id: 66485
|
||||
name: "采购公告"
|
||||
type: "purchase"
|
||||
ZcyAnnouncement2:
|
||||
category_id: 66485
|
||||
name: "结果公告"
|
||||
type: "result"
|
||||
ZcyAnnouncement3:
|
||||
category_id: 66485
|
||||
name: "合同公告"
|
||||
type: "contract"
|
||||
ZcyAnnouncement4:
|
||||
category_id: 66485
|
||||
name: "更正公告"
|
||||
type: "correction"
|
||||
ZcyAnnouncement5:
|
||||
category_id: 66485
|
||||
name: "招标文件预公示"
|
||||
type: "pre_announcement"
|
||||
ZcyAnnouncement6:
|
||||
category_id: 66485
|
||||
name: "单一来源公示"
|
||||
type: "single_source"
|
||||
ZcyAnnouncement7:
|
||||
category_id: 66485
|
||||
name: "电子卖场公示"
|
||||
type: "electronic_market"
|
||||
ZcyAnnouncement10:
|
||||
category_id: 66485
|
||||
name: "履约验收公示"
|
||||
type: "acceptance"
|
||||
ZcyAnnouncement11:
|
||||
category_id: 66485
|
||||
name: "工程类公告"
|
||||
type: "engineering"
|
||||
ZcyAnnouncement20:
|
||||
category_id: 66485
|
||||
name: "框架协议征集公告"
|
||||
type: "framework_agreement"
|
||||
ZcyAnnouncement21:
|
||||
category_id: 66485
|
||||
name: "框架协议入围结果公告"
|
||||
type: "framework_result"
|
||||
ZcyAnnouncement23:
|
||||
category_id: 66485
|
||||
name: "框架协议成交结果汇总公告"
|
||||
type: "framework_summary"
|
||||
"61-266648":
|
||||
category_id: 66485
|
||||
name: "采购意向公开"
|
||||
type: "intention"
|
||||
@@ -1,44 +0,0 @@
|
||||
import web_crawler as webc
|
||||
|
||||
"""脚本运行指南"""
|
||||
# 整个程序的启动需点击右上角的绿色三角按钮(或使用快捷组合键Shift+F10快捷启动),注意!在启动前,请先在按钮的左侧选择“当前文件”再执行。
|
||||
# 注意!!!请不要在打开公告数据导入的目标excel文件时启动该爬虫程序,程序无法对正在运行的进程文件进行修改。务必在爬虫程序启动并将数据成功导入目标excel文件后再打开并查看目标excel文件
|
||||
|
||||
"""脚本结果读取指南"""
|
||||
# 这个脚本旨在爬取广西政府采购网的多个公告栏目的公告信息。
|
||||
#该脚本在运行结束之后会返回以下结果:<某专栏> 新增 xxx 条公告,已保存至excel文件中,建议确认是否有新增公告后再查看excel文件
|
||||
#每次爬取之后会把数据导出至指定excel表格,表格将会把数据按时间倒序的方式排列公告数据,同时会把属于今天的公告数据标红。excel文件默认为桌面的政府采购公告.xlsx('D:/Document/政府采购公告.xlsx')
|
||||
|
||||
"""脚本参数修改指南"""
|
||||
# 若没有创建相应的excel表格文件,无需担心,程序会先检测是否有对应的excel文件,若不存在该文件,程序便会自动生成。
|
||||
# excel表格的存取路径可以在utils.py中修改。请点击左侧的"项目"(或使用快捷组合键Alt+1打开项目栏),选择utils.py并打开。可以在这个文件中修改某些参数。
|
||||
# 若需要修改公告信息的筛选条件以及启动爬虫程序的代理列表,请点击左侧的"项目"(或使用快捷组合键Alt+1打开项目栏),选择utils.py并打开。可以在这个文件中修改这些参数。
|
||||
# 我已经设置了一个定时器,在每天的8:40、14:40、18:00这三个时间段,定时器会启动爬虫程序开始爬取网站。如果需要修改时间段,可以在代码的下半部分修改
|
||||
# 修改参数以及代码片段后请重新启动程序。
|
||||
|
||||
# """脚本定时启动"""
|
||||
# def job():
|
||||
# print(f"任务执行于 {datetime.now()}")
|
||||
# #调用爬取函数
|
||||
# webc.web_crawler()
|
||||
# print(f"任务完成于 {datetime.now()}")
|
||||
#
|
||||
# # 安排任务在每天的8:40、14:40、18:00执行
|
||||
# # 此处可以修改时间段,只需修改括号内的时间即可,格式参照原先括号里的数即可,如需额外增加时间段,请复制代码:schedule.every().day.at("时间段").do(job)并粘贴到下方,可增加任意数量的时间段
|
||||
# schedule.every().day.at("08:37").do(job)
|
||||
# schedule.every().day.at("11:55").do(job)
|
||||
# schedule.every().day.at("14:45").do(job)
|
||||
# schedule.every().day.at("18:00").do(job)
|
||||
#
|
||||
# print("脚本定时任务已启动...")
|
||||
# while True:
|
||||
# schedule.run_pending()
|
||||
# time.sleep(1)
|
||||
|
||||
"""脚本单次启动"""
|
||||
def main():
|
||||
print("脚本单次任务已启动...")
|
||||
webc.web_crawler()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,50 +0,0 @@
|
||||
# 定义公告导出的目标excel文件路径
|
||||
FILE_PATH = "D:/Document/政府采购公告.xlsx"
|
||||
# 初始化excel工作表头
|
||||
HEADER = ["标题", "发布时间", "发布单位", "内容链接", "来源栏目"]
|
||||
|
||||
# 初始化工作表数量以及名称
|
||||
sheet_names = [
|
||||
"采购公告", "招标文件预公示", "采购意向公开", "结果公告", "合同公告",
|
||||
"更正公告", "单一来源公示", "电子卖场公示", "履约验收公示",
|
||||
"工程类公告", "框架协议征集公告", "框架协议入围结果公告",
|
||||
"框架协议成交结果汇总公告", "其他"
|
||||
]
|
||||
|
||||
# === 初始化筛选条件 ===
|
||||
KEYWORD ="大化" #筛选关键词
|
||||
START_DATE ="2025-12-01" #筛选最早发布日期
|
||||
END_DATE ="2026-01-31" #筛选最晚发布日期
|
||||
|
||||
# 代理列表
|
||||
PROXIES = [
|
||||
#若要添加代理,请先测试代理是否能正常连接,否则请将代理置空
|
||||
#"http://10.10.1.10:3218", #此为无效代理,仅作示例
|
||||
#"http://user:pass@10.10.1.10:8080", #此为无效代理,仅作示例
|
||||
# 带认证的代理
|
||||
# 添加更多代理...
|
||||
]
|
||||
|
||||
USER_AGENTS = [
|
||||
#此为用户代理,根据实际情况修改
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
|
||||
# 添加更多 User-Agent...
|
||||
]
|
||||
|
||||
# 公告来源链接
|
||||
SOURCE_URL = {
|
||||
"ZcyAnnouncement2":[66485,"结果公告"],# 结果公告
|
||||
"ZcyAnnouncement3":[66485,"合同公告"],# 合同公告
|
||||
"ZcyAnnouncement4":[66485,"更正公告"],# 更正公告
|
||||
"ZcyAnnouncement6":[66485,"单一来源公示"],# 单一来源公示
|
||||
"ZcyAnnouncement7":[66485,"电子卖场公示"],# 电子卖场公示
|
||||
"ZcyAnnouncement10":[66485,"履约验收公示"],# 履约验收公示
|
||||
"ZcyAnnouncement11":[66485,"工程类公告"],# 工程类公告
|
||||
"ZcyAnnouncement20":[66485,"框架协议征集公告"],# 框架协议征集公告
|
||||
"ZcyAnnouncement21":[66485,"框架协议入围结果公告"],# 框架协议入围结果公告
|
||||
"ZcyAnnouncement23":[66485,"框架协议成交结果汇总公告"],# 框架协议成交结果汇总公告
|
||||
"ZcyAnnouncement1":[66485,"采购公告"],# 采购公告
|
||||
"ZcyAnnouncement5":[66485,"招标文件预公示"],# 招标文件预公示
|
||||
"61-266648":[66485,"采购意向公开"]# 采购意向公开
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime
|
||||
import random
|
||||
from fake_useragent import UserAgent
|
||||
import logging
|
||||
import write_to_excel as wte
|
||||
import utils as Utils
|
||||
|
||||
# === 获取筛选条件 ===
|
||||
KEYWORD =Utils.KEYWORD #关键词筛选
|
||||
START_DATE =Utils.START_DATE
|
||||
END_DATE =Utils.END_DATE
|
||||
|
||||
#获取公告来源链接
|
||||
SOURCE_URL = Utils.SOURCE_URL
|
||||
|
||||
# === 反扒配置 ===
|
||||
|
||||
#获取代理列表
|
||||
PROXIES = Utils.PROXIES
|
||||
USER_AGENTS = Utils.USER_AGENTS
|
||||
|
||||
MAX_RETRIES = 3 # 最大重连次数
|
||||
DELAY_MIN, DELAY_MAX = 1, 3 # 随机延迟范围(秒)
|
||||
|
||||
# === 日志配置 ===
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
|
||||
def get_random_user_agent():
|
||||
"""随机选择 User-Agent"""
|
||||
return random.choice(USER_AGENTS)
|
||||
|
||||
def get_random_proxy():
|
||||
"""随机选择代理"""
|
||||
if not PROXIES:
|
||||
return None
|
||||
proxy = random.choice(PROXIES)
|
||||
return {"http": proxy, "https": proxy}
|
||||
|
||||
def check_sensitive_words(user_agent,payload,category_code,childrencode):
|
||||
"""敏感词检查请求"""
|
||||
# === 接口地址 ===
|
||||
url = "https://zfcg.gxzf.gov.cn/portal/sensitiveWords/check"
|
||||
headers = {
|
||||
"User-Agent": user_agent,
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Origin": "https://zfcg.gxzf.gov.cn",
|
||||
"Referer": f"https://zfcg.gxzf.gov.cn/site/category?parentId={category_code}&childrenCode={childrencode}",
|
||||
"Cookie": "_zcy_log_client_uuid=71e283e0-23d2-11f0-844a-eb67dfa7ab64"
|
||||
}
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
# print("这是敏感词检查请求返回的响应预览",response.json())
|
||||
return response.json()
|
||||
|
||||
def get_announcements(category_code, childrencode,page_no=1, session=None):
|
||||
"""请求接口获取公告列表函数(带反扒机制)"""
|
||||
if session is None:
|
||||
session = requests.Session()
|
||||
payload = {
|
||||
# 接口的请求参数
|
||||
"keyword": KEYWORD, #关键词筛选,此参数即为搜索框中的关键词输入值
|
||||
"publishDateBegin": START_DATE, #最早日期筛选
|
||||
"publishDateEnd": END_DATE, #最晚日期筛选
|
||||
"pageNo": page_no, #页码数
|
||||
"pageSize": 15, #页容量
|
||||
"categoryCode": category_code, # 该参数指定当前查找的公告栏目
|
||||
"_t": int(time.time() * 1000) # 动态时间戳
|
||||
}
|
||||
|
||||
# 先执行敏感词检查
|
||||
user_agent=get_random_user_agent()
|
||||
check_response = check_sensitive_words(user_agent,payload, category_code, childrencode)
|
||||
if not check_response.get("success", False):
|
||||
logging.warning("敏感词检查失败")
|
||||
return None
|
||||
|
||||
# 再执行公告数据请求
|
||||
# === 接口地址 ===
|
||||
api_url = "https://zfcg.gxzf.gov.cn/portal/category"
|
||||
headers = {
|
||||
"User-Agent": user_agent,
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Origin": "https://zfcg.gxzf.gov.cn",
|
||||
"Referer": f"https://zfcg.gxzf.gov.cn/site/category?parentId={category_code}&childrenCode={childrencode}",
|
||||
"Cookie": "_zcy_log_client_uuid=71e283e0-23d2-11f0-844a-eb67dfa7ab64" # 如果需要登录
|
||||
}
|
||||
#若添加了至少1条代理,则尝试使用代理
|
||||
if PROXIES:
|
||||
proxy = 'Default_value'
|
||||
else:
|
||||
proxy=None
|
||||
for retry in range(MAX_RETRIES):
|
||||
try:
|
||||
# 尝试使用代理,失败则切换为无代理
|
||||
if proxy :
|
||||
proxy = get_random_proxy()
|
||||
|
||||
if proxy:
|
||||
logging.info(f"使用代理: {proxy['http']}")
|
||||
else:
|
||||
logging.info("未使用代理")
|
||||
|
||||
response = session.post(
|
||||
api_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
proxies=proxy,
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("success",True):
|
||||
return data
|
||||
else:
|
||||
logging.warning(f"接口返回失败: {data.get('error', '未知错误')}")
|
||||
else:
|
||||
logging.warning(f"请求失败,状态码: {response.status_code}")
|
||||
except requests.exceptions.ProxyError as pe:
|
||||
# 代理错误时记录并跳过,继续无代理请求
|
||||
logging.error(f"代理错误: {pe}. 切换为无代理模式")
|
||||
proxy = None # 下次请求不使用代理
|
||||
except Exception as e:
|
||||
logging.error(f"请求异常: {e}")
|
||||
|
||||
# 重试前等待
|
||||
delay = random.uniform(DELAY_MIN, DELAY_MAX)
|
||||
logging.info(f"第 {retry + 1}/{MAX_RETRIES} 次重试,等待 {delay:.1f} 秒...")
|
||||
time.sleep(delay)
|
||||
|
||||
return None # 所有重试失败
|
||||
|
||||
def parse_data(data,category_code,source_name):
|
||||
"""解析公告数据函数"""
|
||||
results = []
|
||||
for item in data["result"]["data"]["data"]:
|
||||
results.append({
|
||||
"标题": item["title"],
|
||||
"发布时间": datetime.fromtimestamp(int(item["publishDate"]) / 1000).strftime("%Y-%m-%d"),
|
||||
"发布单位": item["purchaseName"],
|
||||
"内容链接": f"https://zfcg.gxzf.gov.cn/site/detail?parentId={category_code}&articleId={item['articleId']}",
|
||||
"来源栏目":source_name
|
||||
})
|
||||
return results
|
||||
|
||||
#爬虫主函数
|
||||
def web_crawler():
|
||||
session = requests.Session()
|
||||
# print("这是本地会话存储:",session)
|
||||
# 初始化引用值为信息公告,先爬取信息公告栏目的公告数据
|
||||
childrencode = "ZcyAnnouncement"
|
||||
# 爬取代码主体,外循环为来源栏目的循环,即遍历需要爬取的所有来源栏目
|
||||
for key in SOURCE_URL:
|
||||
page = 1
|
||||
all_results = []
|
||||
# print("这是正在爬取的栏目", SOURCE_URL[key][1])
|
||||
# print("这是正在爬取的栏目的目录码", str(key))
|
||||
# 判断正确的引用值
|
||||
# 内循环主体即为在符合筛选条件的公告列表中遍历所有公告,爬取每个公告需要的指定数据。
|
||||
while True:
|
||||
# 这是分割线
|
||||
# logging.info("爬虫日志分割线————————————————————————————————————————————————爬虫日志分割线")
|
||||
# logging.info("爬虫日志分割线————————————————————————————————————————————————爬虫日志分割线")
|
||||
# logging.info("爬虫日志分割线————————————————————————————————————————————————爬虫日志分割线")
|
||||
# logging.info(f"正在爬取第 {page} 页...")
|
||||
response = get_announcements(str(key), childrencode, page,session)
|
||||
if response["result"]["data"]["empty"] or not response["result"]["data"]["data"]:
|
||||
break
|
||||
# logging.info(f"请求后共返回 {response["result"]["data"]["total"]} 条公告")
|
||||
current_data = parse_data(response, SOURCE_URL[key][0], SOURCE_URL[key][1])
|
||||
if not current_data:
|
||||
break
|
||||
# logging.info(f"解析后共爬取 {len(current_data)} 条公告")
|
||||
all_results.extend(current_data)
|
||||
page += 1
|
||||
# 随机延迟
|
||||
delay = random.uniform(DELAY_MIN, DELAY_MAX)
|
||||
time.sleep(delay)
|
||||
|
||||
# print(f"从 {SOURCE_URL[key][1]} 共爬取 {len(all_results)} 条公告")
|
||||
# 调用excel读写函数,将公告数据筛选后保存到Excel中
|
||||
wte.write_to_excel(all_results,SOURCE_URL[key][1])
|
||||
# 这是分割线
|
||||
print("公告栏目分割线————————————————————————————————————————————————公告栏目分割线")
|
||||
print("公告栏目分割线————————————————————————————————————————————————公告栏目分割线")
|
||||
print("公告栏目分割线————————————————————————————————————————————————公告栏目分割线")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
import schedule
|
||||
import time
|
||||
import utils as Utils
|
||||
|
||||
# 获取excel文件路径和excel的工作表头
|
||||
FILE_PATH = Utils.FILE_PATH
|
||||
HEADER = Utils.HEADER
|
||||
|
||||
# 获取工作表数量以及名称
|
||||
sheet_names = Utils.sheet_names
|
||||
|
||||
|
||||
def write_to_excel(results,source=""):
|
||||
# 检查文件是否存在,如果不存在则创建一个新的Excel文件
|
||||
try:
|
||||
df_existing = pd.read_excel(FILE_PATH, sheet_name=None)
|
||||
except FileNotFoundError:
|
||||
df_existing = {name: pd.DataFrame(columns=HEADER) for name in sheet_names}
|
||||
|
||||
today_str = datetime.now().strftime('%Y-%m-%d')
|
||||
aditional_item=0 # 记录本次爬取新增的公告数量
|
||||
# 将爬取的公告对号入座填充至excel文件的工作表中
|
||||
for result in results:
|
||||
sheet_name = result["来源栏目"] if result["来源栏目"] in sheet_names else "其他"
|
||||
df_sheet = df_existing[sheet_name]
|
||||
|
||||
# 检查是否已存在相同内容链接的记录
|
||||
if not df_sheet[df_sheet["内容链接"] == result["内容链接"]].empty:
|
||||
continue
|
||||
|
||||
# 添加新记录
|
||||
new_row = pd.DataFrame([result])
|
||||
df_sheet = pd.concat([df_sheet, new_row], ignore_index=True)
|
||||
aditional_item+=1 # 若有新增公告,则加1
|
||||
# 按时间降序排列
|
||||
df_sheet.sort_values(by='发布时间', ascending=False, inplace=True)
|
||||
|
||||
# 更新现有数据
|
||||
df_existing[sheet_name] = df_sheet
|
||||
|
||||
# 写入Excel文件
|
||||
with pd.ExcelWriter(FILE_PATH, engine='openpyxl') as writer:
|
||||
for sheet_name, df in df_existing.items():
|
||||
df.to_excel(writer, sheet_name=sheet_name, index=False)
|
||||
|
||||
# 获取工作表对象
|
||||
worksheet = writer.sheets[sheet_name]
|
||||
|
||||
# 设置列宽
|
||||
for col_idx, column_width in enumerate([40, 15, 20, 30, 20]):
|
||||
worksheet.column_dimensions[chr(65 + col_idx)].width = column_width
|
||||
|
||||
# 创建红色填充
|
||||
red_fill = PatternFill(start_color="FF0000", end_color="FF0000", fill_type="solid")
|
||||
|
||||
# 创建边框样式
|
||||
thin_border = Border(left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin'))
|
||||
|
||||
# 应用样式
|
||||
for row in worksheet.iter_rows(min_row=1, max_col=len(HEADER), max_row=worksheet.max_row):
|
||||
publish_date = row[1].value if len(row) > 1 else None
|
||||
# print("这是publish_date",publish_date)
|
||||
# 若公告发布日期为今天,则将其单元格背景填充至红色
|
||||
if publish_date == today_str:
|
||||
for cell in row:
|
||||
cell.fill = red_fill
|
||||
|
||||
for idx, cell in enumerate(row):
|
||||
alignment = Alignment(horizontal='left' if idx < 4 else 'general', vertical='top', wrap_text=True)
|
||||
cell.alignment = alignment
|
||||
cell.border = thin_border
|
||||
print(f"<{source}> 新增 {aditional_item} 条公告,已保存至excel文件中")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
📋 关键词搜索: `未知` - 总公告数: `0`
|
||||
|
||||
**更新时间: 2026-01-09 09:14:30**
|
||||
|
||||
|
||||
## 无匹配公告
|
||||
|
||||
在指定时间范围内没有找到符合条件的公告。
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试最新公告两步流程功能
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
try:
|
||||
from gx_gp_monitor.wechat.message_handler import WeChatMessageHandler
|
||||
from gx_gp_monitor.core.config_manager import load_config
|
||||
|
||||
def demo_latest_news_flow():
|
||||
"""演示最新公告功能流程"""
|
||||
load_config()
|
||||
handler = WeChatMessageHandler()
|
||||
|
||||
print("🚀 最新公告功能演示")
|
||||
print("=" * 50)
|
||||
|
||||
# 步骤1: 点击"最新公告"菜单
|
||||
print("📱 步骤1: 用户点击'最新公告'菜单")
|
||||
step1_result = handler._handle_latest_news('demo_user')
|
||||
print("✅ 系统响应: 显示公告来源选择菜单")
|
||||
print()
|
||||
|
||||
# 步骤2: 用户选择采购公告
|
||||
print("📱 步骤2: 用户发送'1'选择采购公告")
|
||||
step2_result = handler._handle_latest_news_by_source('1', 'demo_user')
|
||||
print("✅ 系统响应: 显示采购公告最新10条")
|
||||
print()
|
||||
|
||||
# 步骤3: 用户选择查看全部
|
||||
print("📱 步骤3: 用户发送'全部'查看所有公告")
|
||||
step3_result = handler._handle_latest_news_by_source('全部', 'demo_user')
|
||||
print("✅ 系统响应: 显示全部来源最新10条公告")
|
||||
print()
|
||||
|
||||
print("🎯 功能特点:")
|
||||
print("• 📋 两步交互: 先选择来源,再显示内容")
|
||||
print("• 🎛️ 9种来源: 覆盖所有公告类型")
|
||||
print("• 📊 智能排序: 按时间倒序显示")
|
||||
print("• 💡 用户友好: 清晰的数字选择界面")
|
||||
print()
|
||||
|
||||
print("📝 使用方法:")
|
||||
print("1. 点击企业微信菜单中的'最新公告'")
|
||||
print("2. 系统显示9种公告来源选项")
|
||||
print("3. 发送对应数字(1-9)或'全部'查看")
|
||||
print("4. 系统显示该来源的最新10条公告")
|
||||
|
||||
print("\n✅ 最新公告功能升级完成!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo_latest_news_flow()
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ 导入失败: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"❌ 演示失败: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试修复后的菜单功能
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
try:
|
||||
from gx_gp_monitor.wechat.message_handler import WeChatMessageHandler
|
||||
from gx_gp_monitor.core.config_manager import load_config
|
||||
|
||||
def test_menu_keys():
|
||||
"""测试菜单key处理"""
|
||||
print("🧪 测试菜单key处理...")
|
||||
|
||||
handler = WeChatMessageHandler()
|
||||
|
||||
# 测试旧菜单key
|
||||
test_cases = [
|
||||
("latest_announcements", "最新公告(兼容旧key)"),
|
||||
("announcements_by_type", "按类型查看公告"),
|
||||
("search_announcements", "搜索公告(兼容旧key)"),
|
||||
("latest_news", "最新公告"),
|
||||
("keyword_search", "关键词搜索"),
|
||||
("system_status", "系统状态"),
|
||||
("help_guide", "使用说明"),
|
||||
]
|
||||
|
||||
results = {}
|
||||
for key, description in test_cases:
|
||||
try:
|
||||
# 模拟事件处理
|
||||
result = handler.handle_event('click', key, 'test_user')
|
||||
results[key] = result is not None and "未知菜单项" not in (result or "")
|
||||
print(f"✅ {description}: {'通过' if results[key] else '失败'}")
|
||||
except Exception as e:
|
||||
print(f"❌ {description}: 异常 - {str(e)}")
|
||||
results[key] = False
|
||||
|
||||
success_count = sum(1 for success in results.values() if success)
|
||||
print(f"\n📊 菜单key测试结果: {success_count}/{len(test_cases)} 通过")
|
||||
|
||||
return success_count == len(test_cases)
|
||||
|
||||
def test_text_commands():
|
||||
"""测试文本命令处理"""
|
||||
print("\n🧪 测试文本命令处理...")
|
||||
|
||||
handler = WeChatMessageHandler()
|
||||
|
||||
# 测试公告类型查询
|
||||
test_cases = [
|
||||
("采购公告", "采购公告查询"),
|
||||
("结果公告", "结果公告查询"),
|
||||
("更正公告", "更正公告查询"),
|
||||
]
|
||||
|
||||
results = {}
|
||||
for command, description in test_cases:
|
||||
try:
|
||||
result = handler.handle_text_message(command, 'test_user')
|
||||
results[command] = result is not None
|
||||
print(f"✅ {description}: {'通过' if results[command] else '失败'}")
|
||||
except Exception as e:
|
||||
print(f"❌ {description}: 异常 - {str(e)}")
|
||||
results[command] = False
|
||||
|
||||
success_count = sum(1 for success in results.values() if success)
|
||||
print(f"\n📊 文本命令测试结果: {success_count}/{len(test_cases)} 通过")
|
||||
|
||||
return success_count == len(test_cases)
|
||||
|
||||
def main():
|
||||
"""主测试函数"""
|
||||
# 加载配置
|
||||
try:
|
||||
load_config()
|
||||
print("✅ 配置加载成功")
|
||||
except Exception as e:
|
||||
print(f"❌ 配置加载失败: {e}")
|
||||
return False
|
||||
|
||||
print("🚀 测试修复后的菜单功能...")
|
||||
print("=" * 50)
|
||||
|
||||
# 测试菜单key
|
||||
test1_passed = test_menu_keys()
|
||||
|
||||
# 测试文本命令
|
||||
test2_passed = test_text_commands()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
if test1_passed and test2_passed:
|
||||
print("🎉 所有菜单功能修复测试通过!")
|
||||
print("✅ 旧菜单key现在可以正常工作")
|
||||
print("✅ 新增了公告类型查询功能")
|
||||
else:
|
||||
print("⚠️ 部分测试失败,请检查上述错误信息")
|
||||
|
||||
return test1_passed and test2_passed
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ 导入失败: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,168 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试企业微信菜单功能
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
try:
|
||||
from gx_gp_monitor.wechat.menu_manager import WeChatMenuManager
|
||||
from gx_gp_monitor.wechat.message_handler import WeChatMessageHandler
|
||||
from gx_gp_monitor.core.config_manager import load_config
|
||||
|
||||
def test_menu_manager():
|
||||
"""测试菜单管理器"""
|
||||
print("🧪 测试菜单管理器...")
|
||||
|
||||
try:
|
||||
# 加载配置
|
||||
load_config()
|
||||
|
||||
# 创建菜单管理器
|
||||
menu_manager = WeChatMenuManager()
|
||||
|
||||
# 测试菜单信息获取
|
||||
menu_info = menu_manager.get_menu_info()
|
||||
print("✅ 菜单信息获取成功")
|
||||
print(f" 菜单按钮数量: {menu_info['menu_structure']['total_buttons']}")
|
||||
|
||||
# 打印菜单结构
|
||||
for button in menu_info['menu_structure']['buttons']:
|
||||
sub_count = len(button.get('sub_buttons', [])) if isinstance(button.get('sub_buttons'), list) else button.get('sub_buttons', 0)
|
||||
print(f" • {button['name']} ({sub_count}个子菜单)")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 菜单管理器测试失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def test_message_handler():
|
||||
"""测试消息处理器"""
|
||||
print("🧪 测试消息处理器...")
|
||||
|
||||
try:
|
||||
# 创建消息处理器
|
||||
handler = WeChatMessageHandler()
|
||||
|
||||
# 测试各种菜单功能
|
||||
test_results = {}
|
||||
|
||||
# 测试帮助功能
|
||||
result = handler._handle_help_guide("test_user")
|
||||
test_results["help_guide"] = result is not None and "广西政府采购网" in result
|
||||
|
||||
# 测试联系信息
|
||||
result = handler._handle_contact_info("test_user")
|
||||
test_results["contact_info"] = result is not None and "联系我们" in result
|
||||
|
||||
# 测试关于系统
|
||||
result = handler._handle_about_system("test_user")
|
||||
test_results["about_system"] = result is not None and "关于系统" in result
|
||||
|
||||
# 测试关键词搜索菜单
|
||||
result = handler._handle_keyword_search_menu("test_user")
|
||||
test_results["keyword_search"] = result is not None and "关键词搜索" in result
|
||||
|
||||
# 打印测试结果
|
||||
success_count = sum(1 for success in test_results.values() if success)
|
||||
print(f"✅ 消息处理器测试完成: {success_count}/{len(test_results)} 项通过")
|
||||
|
||||
for func_name, success in test_results.items():
|
||||
status = "✅" if success else "❌"
|
||||
print(f" {status} {func_name}")
|
||||
|
||||
return success_count == len(test_results)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 消息处理器测试失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def test_menu_structure():
|
||||
"""测试菜单结构合理性"""
|
||||
print("🧪 测试菜单结构...")
|
||||
|
||||
try:
|
||||
menu_manager = WeChatMenuManager()
|
||||
menu_data = menu_manager.menu_data
|
||||
|
||||
# 检查菜单结构
|
||||
checks = {
|
||||
"has_buttons": len(menu_data.get("button", [])) > 0,
|
||||
"max_buttons": len(menu_data.get("button", [])) <= 3, # 企业微信最多3个一级菜单
|
||||
"has_sub_buttons": all("sub_button" in btn for btn in menu_data.get("button", [])),
|
||||
"sub_buttons_limit": all(len(btn.get("sub_button", [])) <= 5 for btn in menu_data.get("button", [])),
|
||||
"valid_keys": True
|
||||
}
|
||||
|
||||
# 检查所有按钮都有有效的key
|
||||
all_keys = []
|
||||
for button in menu_data.get("button", []):
|
||||
for sub_btn in button.get("sub_button", []):
|
||||
key = sub_btn.get("key")
|
||||
if key:
|
||||
all_keys.append(key)
|
||||
else:
|
||||
checks["valid_keys"] = False
|
||||
|
||||
# 检查key唯一性
|
||||
checks["unique_keys"] = len(all_keys) == len(set(all_keys))
|
||||
|
||||
# 打印检查结果
|
||||
success_count = sum(1 for success in checks.values() if success)
|
||||
print(f"✅ 菜单结构检查完成: {success_count}/{len(checks)} 项通过")
|
||||
|
||||
for check_name, success in checks.items():
|
||||
status = "✅" if success else "❌"
|
||||
print(f" {status} {check_name}")
|
||||
|
||||
return success_count == len(checks)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 菜单结构测试失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""主测试函数"""
|
||||
print("🚀 开始测试企业微信菜单功能...")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试菜单管理器
|
||||
test1_passed = test_menu_manager()
|
||||
print()
|
||||
|
||||
# 测试消息处理器
|
||||
test2_passed = test_message_handler()
|
||||
print()
|
||||
|
||||
# 测试菜单结构
|
||||
test3_passed = test_menu_structure()
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
# 总体结果
|
||||
all_passed = test1_passed and test2_passed and test3_passed
|
||||
if all_passed:
|
||||
print("🎉 所有测试通过!企业微信菜单功能正常")
|
||||
print("💡 您可以运行以下命令创建菜单:")
|
||||
print(" python -c \"from gx_gp_monitor.wechat.menu_manager import WeChatMenuManager; m = WeChatMenuManager(); m.create_menu()\"")
|
||||
else:
|
||||
print("⚠️ 部分测试失败,请检查上述错误信息")
|
||||
|
||||
return all_passed
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ 导入失败: {e}", file=sys.stderr)
|
||||
print("请确保已安装所有依赖: pip install -r gx_gp_monitor/requirements.txt", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1 +0,0 @@
|
||||
1782404
|
||||
+2
-2
@@ -24,5 +24,5 @@ vacuum = true
|
||||
log-date = true
|
||||
|
||||
# 自动重载和内存报告配置
|
||||
py-autoreload = 1
|
||||
memory-report = true
|
||||
; py-autoreload = 1
|
||||
; memory-report = true
|
||||
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
发送应用消息
|
||||
最后更新:2025/09/24
|
||||
接口定义
|
||||
应用支持推送文本、图片、视频、文件、图文等类型。
|
||||
|
||||
请求方式:POST(HTTPS)
|
||||
请求地址: https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=ACCESS_TOKEN
|
||||
|
||||
参数说明:
|
||||
|
||||
参数 是否必须 说明
|
||||
access_token 是 调用接口凭证
|
||||
- 各个消息类型的具体POST格式请阅后续“消息类型”部分。
|
||||
- 如果有在管理端对应用设置“在微工作台中始终进入主页”,应用在微信端只能接收到文本消息,并且文本消息的长度限制为20字节,超过20字节会被截断。同时其他消息类型也会转换为文本消息,提示用户到企业微信查看。
|
||||
- 支持id转译,将userid/部门id转成对应的用户名/部门名,在企业授权了会话内容存档接口权限时,也可以将消息id和群id转成对应的消息内容/群名称,目前仅文本/文本卡片/图文/图文(mpnews)/任务卡片/小程序通知/模版消息/模板卡片消息这八种消息类型的部分字段支持。具体支持的范围和语法,请查看附录id转译说明。
|
||||
- 支持重复消息检查,当指定 "enable_duplicate_check": 1开启: 表示在一定时间间隔内,同样内容(请求json)的消息,不会重复收到;时间间隔可通过duplicate_check_interval指定,默认1800秒。
|
||||
- 从2021年2月4日开始,企业关联添加的「小程序」应用,也可以发送文本、图片、视频、文件、图文等各种类型的消息了。
|
||||
调用建议:大部分企业应用在每小时的0分或30分触发推送消息,容易造成资源挤占,从而投递不够及时,建议尽量避开这两个时间点进行调用。
|
||||
频率限制:每应用不可超过账号上限数*200人次/天(注:若调用api一次发给1000人,算1000人次;若企业账号上限是500人,则每个应用每天可发送100000人次的消息)。每应用对同一个成员不可超过30次/分钟,1000次/小时,超过部分会被丢弃不下发
|
||||
返回示例:
|
||||
|
||||
{
|
||||
"errcode" : 0,
|
||||
"errmsg" : "ok",
|
||||
"invaliduser" : "userid1|userid2",
|
||||
"invalidparty" : "partyid1|partyid2",
|
||||
"invalidtag": "tagid1|tagid2",
|
||||
"unlicenseduser" : "userid3|userid4",
|
||||
"msgid": "xxxx",
|
||||
"response_code": "xyzxyz"
|
||||
}
|
||||
如果部分接收人无权限或不存在,发送仍然执行,但会返回无效的部分(即invaliduser或invalidparty或invalidtag或unlicenseduser),常见的原因是接收人不在应用的可见范围内。
|
||||
权限包含应用可见范围和基础接口权限(基础账号、互通账号均可),unlicenseduser中的用户在应用可见范围内但没有基础接口权限。
|
||||
如果全部接收人无权限或不存在,则本次调用返回失败,errcode为81013。
|
||||
返回包中的userid,不区分大小写,统一转为小写
|
||||
|
||||
|
||||
|
||||
---
|
||||
文本卡片消息
|
||||
请求示例:
|
||||
```
|
||||
{
|
||||
"touser" : "UserID1|UserID2|UserID3",
|
||||
"toparty" : "PartyID1 | PartyID2",
|
||||
"totag" : "TagID1 | TagID2",
|
||||
"msgtype" : "textcard",
|
||||
"agentid" : 1,
|
||||
"textcard" : {
|
||||
"title" : "领奖通知",
|
||||
"description" : "<div class=\"gray\">2016年9月26日</div> <div class=\"normal\">恭喜你抽中iPhone 7一台,领奖码:xxxx</div><div class=\"highlight\">请于2016年10月10日前联系行政同事领取</div>",
|
||||
"url" : "URL",
|
||||
"btntxt":"更多"
|
||||
},
|
||||
"enable_id_trans": 0,
|
||||
"enable_duplicate_check": 0,
|
||||
"duplicate_check_interval": 1800
|
||||
}
|
||||
```
|
||||
参数说明:
|
||||
|
||||
参数 是否必须 说明
|
||||
touser 否 成员ID列表(消息接收者,多个接收者用‘|’分隔,最多支持1000个)。特殊情况:指定为@all,则向关注该企业应用的全部成员发送
|
||||
toparty 否 部门ID列表,多个接收者用‘|’分隔,最多支持100个。当touser为@all时忽略本参数
|
||||
totag 否 标签ID列表,多个接收者用‘|’分隔,最多支持100个。当touser为@all时忽略本参数
|
||||
msgtype 是 消息类型,此时固定为:textcard
|
||||
agentid 是 企业应用的id,整型。企业内部开发,可在应用的设置页面查看;第三方服务商,可通过接口 获取企业授权信息 获取该参数值
|
||||
title 是 标题,不超过128个字符,超过会自动截断(支持id转译)
|
||||
description 是 描述,不超过512个字符,超过会自动截断(支持id转译)
|
||||
url 是 点击后跳转的链接。最长2048字节,请确保包含了协议头(http/https)
|
||||
btntxt 否 按钮文字。 默认为“详情”, 不超过4个文字,超过自动截断。
|
||||
enable_id_trans 否 表示是否开启id转译,0表示否,1表示是,默认0
|
||||
enable_duplicate_check 否 表示是否开启重复消息检查,0表示否,1表示是,默认0
|
||||
duplicate_check_interval 否 表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时
|
||||
|
||||
特殊说明:
|
||||
卡片消息的展现形式非常灵活,支持使用br标签或者空格来进行换行处理,也支持使用div标签来使用不同的字体颜色,目前内置了3种文字颜色:灰色(gray)、高亮(highlight)、默认黑色(normal),将其作为div标签的class属性即可,具体用法请参考上面的示例。
|
||||
Reference in New Issue
Block a user