删除无用文件

This commit is contained in:
2026-01-09 15:17:42 +08:00
parent f00068f804
commit b37e8ce581
37 changed files with 644 additions and 14407 deletions
-161
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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 # 最大页数
+218 -11
View File
@@ -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)}")
+1
View File
@@ -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
View File
@@ -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 '未启用'}")
+8 -7
View File
@@ -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:
-555
View File
@@ -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)
+161
View File
@@ -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:
"""
清理存储中的过期数据
+200 -60
View File
@@ -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
-4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-625
View File
@@ -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
View File
@@ -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"
-44
View File
@@ -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()
-50
View File
@@ -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,"采购意向公开"]# 采购意向公开
}
-191
View File
@@ -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("公告栏目分割线————————————————————————————————————————————————公告栏目分割线")
-84
View File
@@ -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文件中")
-8
View File
@@ -1,8 +0,0 @@
📋 关键词搜索: `未知` - 总公告数: `0`
**更新时间: 2026-01-09 09:14:30**
## 无匹配公告
在指定时间范围内没有找到符合条件的公告。
-65
View File
@@ -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)
-115
View File
@@ -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)
-168
View File
@@ -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
View File
@@ -1 +0,0 @@
1782404
+2 -2
View File
@@ -24,5 +24,5 @@ vacuum = true
log-date = true
# 自动重载和内存报告配置
py-autoreload = 1
memory-report = true
; py-autoreload = 1
; memory-report = true
-77
View File
@@ -1,77 +0,0 @@
发送应用消息
最后更新:2025/09/24
接口定义
应用支持推送文本、图片、视频、文件、图文等类型。
请求方式:POSTHTTPS
请求地址: 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属性即可,具体用法请参考上面的示例。