diff --git a/alembic/env.py b/alembic/env.py index bf137cc..fe12c5a 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,34 +1,33 @@ +import asyncio from alembic import context -from sqlalchemy import engine_from_config, pool +from sqlalchemy.ext.asyncio import create_async_engine from app.models.announcement import Base - -config = context.config +from app.config import settings target_metadata = Base.metadata def run_migrations_offline(): - from app.config import settings - url = settings.database_url - context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + context.configure(url=settings.database_url, target_metadata=target_metadata, + literal_binds=True, dialect_opts={"paramstyle": "named"}) with context.begin_transaction(): context.run_migrations() -def run_migrations_online(): - from app.config import settings - connectable = engine_from_config( - {"sqlalchemy.url": settings.database_url}, - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) - with context.begin_transaction(): - context.run_migrations() +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online(): + connectable = create_async_engine(settings.database_url, echo=True) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() if context.is_offline_mode(): run_migrations_offline() else: - run_migrations_online() + asyncio.run(run_migrations_online()) diff --git a/app.py b/app.py deleted file mode 100644 index d4dad41..0000000 --- a/app.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python3 -""" -企业微信回调服务器 WSGI 应用入口 -用于 uWSGI 等部署环境 -""" - -import sys -import os - -# 添加项目路径 -sys.path.insert(0, os.path.dirname(__file__)) - -try: - from gx_gp_monitor.wechat.callback_server import create_callback_app - - # 创建 WSGI 应用对象 - app = create_callback_app() - -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) diff --git a/gx_gp_monitor/README.md b/gx_gp_monitor/README.md deleted file mode 100644 index afe146d..0000000 --- a/gx_gp_monitor/README.md +++ /dev/null @@ -1,281 +0,0 @@ -# 广西政府采购网公告监控系统 - -广西政府采购网公告爬取和监控的智能系统,支持多种公告类型的自动爬取、智能筛选、数据存储和通知推送。 - -## 功能特性 - -### 🕷️ 智能爬虫监控 -- 自动爬取广西政府采购网(zfcg.gxzf.gov.cn)的各类采购公告 -- 支持10+种公告类型:采购公告、结果公告、合同公告、更正公告、招标文件预公示、单一来源公示、电子卖场公示、履约验收公示、工程类公告、框架协议征集公告等 -- 智能反爬虫机制:随机User-Agent、代理轮换、请求延迟控制 - -### 🎯 多维度智能筛选 -- **关键词过滤**:支持多关键词精确匹配和模糊匹配 -- **日期范围筛选**:支持开始日期和结束日期过滤 -- **自动去重**:基于内容哈希的智能去重机制 -- **来源筛选**:按公告类型进行筛选 - -### 💾 数据持久化存储 -- **PostgreSQL数据库**:高效的数据存储和查询 -- **自动清理**:默认90天数据保留,可配置 -- **性能优化**:连接池、索引优化、批量操作 - -### 🔔 高可用性保障 -- **重试机制**:指数退避重试策略 -- **超时控制**:连接超时和读取超时设置 -- **幂等操作**:防止重复处理 -- **异常处理**:完善的异常捕获和恢复 -- **结构化日志**:详细的运行日志记录 - -### 📱 通知系统 -- **企业微信通知**:支持文本和Markdown格式消息 -- **实时推送**:新公告发现即时通知 -- **系统告警**:异常情况自动告警 - -### 📊 Markdown输出 -- **自动生成**:将公告输出为Markdown格式文件(onu.md) -- **结构化展示**:按来源分组、时间排序 -- **今日高亮**:突出显示今日发布的公告 - -## 安装和使用 - -### 环境要求 -- Python 3.8+ -- PostgreSQL 12+ - -### 安装步骤 - -1. **克隆项目** -```bash -cd /path/to/your/workspace -# 项目已创建在 gx_gp_monitor 目录下 -``` - -2. **创建虚拟环境** -```bash -python3 -m venv venv -source venv/bin/activate # Linux/Mac -# 或 -venv\Scripts\activate # Windows -``` - -3. **安装依赖** -```bash -pip install -r requirements.txt -``` - -4. **配置数据库** -```bash -# 创建PostgreSQL数据库 -createdb gx-gp-notify - -# 修改配置文件 config/config.yaml 中的数据库连接信息 -``` - -5. **修改配置** -```bash -# 编辑 config/config.yaml 文件 -# 设置数据库连接、企业微信配置、关键词等 -``` - -### 使用方法 - -#### 命令行使用 - -```bash -# 查看帮助 -python main.py --help - -# 执行一次爬取 -python main.py crawl - -# 带参数爬取 -python main.py crawl --keywords "大化" "信息化" --max-pages 5 - -# 启动调度器(定时任务) -python main.py scheduler - -# 数据清理 -python main.py cleanup --days 30 - -# 查看系统状态 -python main.py status -``` - -#### 编程接口使用 - -```python -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 save_announcements_to_storage -from gx_gp_monitor.storage.md_generator import generate_onu_md -from gx_gp_monitor.notification.wechat import send_announcements_notification - -# 执行爬取 -results = crawl_announcements() - -# 筛选公告 -filter_obj = filter_from_config() -filtered_announcements, stats = filter_obj.filter(all_announcements) - -# 保存到数据库 -saved_count = save_announcements_to_storage(filtered_announcements) - -# 生成Markdown文件 -generate_onu_md(filtered_announcements) - -# 发送通知 -send_announcements_notification(filtered_announcements) -``` - -## 配置说明 - -### 主要配置文件:`config/config.yaml` - -```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 - keyword: ["大化", "信息化"] # 关键词筛选 - max_pages: 10 - -# 数据库配置 -database: - enabled: true - host: "localhost" - port: 5432 - name: "gx-gp-notify" - user: "your_user" - password: "your_password" - data_retention_days: 90 - -# 企业微信配置 -wechat_app: - enabled: true - corp_id: "your_corp_id" - agent_id: "your_agent_id" - secret: "your_secret" - token: "your_token" - encoding_aes_key: "your_aes_key" - -# 调度配置 -scheduler: - enabled: true - jobs: - - name: "daily_crawl" - cron: "0 8,14,18 * * *" - enabled: true -``` - -### 公告来源配置 - -系统支持以下公告类型: - -- `ZcyAnnouncement1`: 采购公告 -- `ZcyAnnouncement2`: 结果公告 -- `ZcyAnnouncement3`: 合同公告 -- `ZcyAnnouncement4`: 更正公告 -- `ZcyAnnouncement5`: 招标文件预公示 -- `ZcyAnnouncement6`: 单一来源公示 -- `ZcyAnnouncement7`: 电子卖场公示 -- `ZcyAnnouncement10`: 履约验收公示 -- `ZcyAnnouncement11`: 工程类公告 -- `ZcyAnnouncement20`: 框架协议征集公告 -- `ZcyAnnouncement21`: 框架协议入围结果公告 -- `ZcyAnnouncement23`: 框架协议成交结果汇总公告 -- `61-266648`: 采购意向公开 - -## 架构设计 - -``` -gx_gp_monitor/ -├── config/ # 配置管理 -├── core/ # 核心模块 -│ ├── models.py # 数据模型 -│ ├── config_manager.py # 配置管理 -│ ├── database.py # 数据库操作 -│ ├── logger.py # 日志管理 -│ └── reliability.py # 高可用性 -├── crawler/ # 爬虫模块 -│ ├── spider.py # 爬虫核心 -│ └── parsers.py # 数据解析 -├── filters/ # 筛选模块 -├── storage/ # 存储模块 -│ ├── postgresql.py # PostgreSQL存储 -│ └── md_generator.py # Markdown生成 -├── notification/ # 通知模块 -├── scheduler/ # 调度模块 -└── main.py # 主程序入口 -``` - -## 日志和监控 - -### 日志文件 -- 默认日志文件:`logs/gx_gp_monitor.log` -- 日志轮转:10MB大小限制,保留5个备份文件 -- 日志级别:DEBUG、INFO、WARNING、ERROR、CRITICAL - -### 监控指标 -- 爬取统计:成功/失败次数、响应时间 -- 数据统计:公告数量、新增数量、去重统计 -- 系统状态:内存使用、磁盘空间、健康检查 - -## 故障排除 - -### 常见问题 - -1. **数据库连接失败** - - 检查PostgreSQL服务是否运行 - - 验证数据库连接配置 - - 确认用户权限 - -2. **爬虫请求失败** - - 检查网络连接 - - 验证目标网站是否可访问 - - 调整请求间隔和重试策略 - -3. **企业微信通知失败** - - 检查企业微信配置 - - 验证应用ID和密钥 - - 确认网络能访问企业微信API - -4. **权限问题** - - 确保日志目录和输出文件目录有写入权限 - - 检查数据库用户权限 - -### 调试模式 - -启用调试模式获取更详细的日志: - -```yaml -debug: true -log_level: DEBUG -``` - -## 许可证 - -本项目采用 MIT 许可证。 - -## 贡献 - -欢迎提交Issue和Pull Request来改进这个项目。 - -## 版本历史 - -- **v1.0.0** (2024-01-XX) - - 初始版本发布 - - 支持10+种公告类型爬取 - - 实现智能筛选和去重 - - PostgreSQL数据存储 - - 企业微信通知 - - Markdown文件输出 - - 定时任务调度 diff --git a/gx_gp_monitor/__init__.py b/gx_gp_monitor/__init__.py deleted file mode 100644 index 72e54cf..0000000 --- a/gx_gp_monitor/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -广西政府采购网公告监控系统 -广西政府采购网公告爬取和监控的智能系统 -""" - -__version__ = "1.0.0" -__author__ = "GX GP Monitor Team" diff --git a/gx_gp_monitor/__main__.py b/gx_gp_monitor/__main__.py deleted file mode 100644 index ba76a4b..0000000 --- a/gx_gp_monitor/__main__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -包的main入口,使项目可以直接通过 python -m gx_gp_monitor 运行 -""" - -try: - from .main import main -except ImportError: - from main import main - -if __name__ == "__main__": - main() diff --git a/gx_gp_monitor/config/__init__.py b/gx_gp_monitor/config/__init__.py deleted file mode 100644 index b062df6..0000000 --- a/gx_gp_monitor/config/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""配置管理模块""" diff --git a/gx_gp_monitor/core/__init__.py b/gx_gp_monitor/core/__init__.py deleted file mode 100644 index 9377aea..0000000 --- a/gx_gp_monitor/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""核心模块""" diff --git a/gx_gp_monitor/core/config_manager.py b/gx_gp_monitor/core/config_manager.py deleted file mode 100644 index bbe738d..0000000 --- a/gx_gp_monitor/core/config_manager.py +++ /dev/null @@ -1,394 +0,0 @@ -""" -配置管理模块 -负责加载、验证和管理系统配置 -""" - -import os -import yaml -from typing import Dict, Any, Optional -from pathlib import Path -from dataclasses import dataclass -from enum import Enum - - -class LogLevel(Enum): - """日志级别枚举""" - DEBUG = "DEBUG" - INFO = "INFO" - WARNING = "WARNING" - ERROR = "ERROR" - CRITICAL = "CRITICAL" - - -@dataclass -class CrawlerConfig: - """爬虫配置""" - base_url: str - timeout: int - max_retries: int - retry_delay: float - max_retry_delay: float - backoff_factor: float - user_agents: list - proxies: list - request_delay: float - request_delay_max: float - keyword: list - start_date: str - end_date: str - max_pages: int - page_size: int - - -@dataclass -class DatabaseConfig: - """数据库配置""" - enabled: bool - type: str - host: str - port: int - name: str - user: str - password: str - pool_size: int - max_overflow: int - pool_timeout: int - pool_recycle: int - data_retention_days: int - auto_cleanup: bool - - -@dataclass -class WeChatConfig: - """企业微信配置""" - enabled: bool - corp_id: str - agent_id: str - secret: str - token: str - encoding_aes_key: str - port: int - host: str - debug: bool - use_proxy: bool - proxy_api_url: str - - -@dataclass -class MarkdownConfig: - """Markdown配置""" - enabled: bool - output_file: str - max_entries: int - include_today_highlight: bool - template_file: str - - -@dataclass -class SchedulerConfig: - """调度配置""" - enabled: bool - timezone: str - jobs: list - - -@dataclass -class MonitoringConfig: - """监控配置""" - enabled: bool - health_check_interval: int - alert_on_failure: bool - max_consecutive_failures: int - metrics_enabled: bool - - -@dataclass -class SystemConfig: - """系统配置""" - debug: bool - log_level: LogLevel - log_file: str - log_max_size: int - log_backup_count: int - crawler: CrawlerConfig - database: DatabaseConfig - wechat_app: WeChatConfig - markdown: MarkdownConfig - scheduler: Optional[SchedulerConfig] - monitoring: MonitoringConfig - sources: Dict[str, Dict[str, Any]] - - -class ConfigManager: - """配置管理器""" - - def __init__(self, config_file: Optional[str] = None): - """ - 初始化配置管理器 - - Args: - config_file: 配置文件路径,如果为None则使用默认路径 - """ - if config_file is None: - # 默认配置文件路径 - current_dir = Path(__file__).parent.parent - self.config_file = current_dir / "config" / "config.yaml" - else: - self.config_file = Path(config_file) - - self._config_data = {} - self._config = None - - def load_config(self) -> SystemConfig: - """ - 加载配置文件 - - Returns: - SystemConfig: 系统配置对象 - - Raises: - FileNotFoundError: 配置文件不存在 - yaml.YAMLError: 配置文件格式错误 - ValueError: 配置验证失败 - """ - if not self.config_file.exists(): - raise FileNotFoundError(f"配置文件不存在: {self.config_file}") - - try: - with open(self.config_file, 'r', encoding='utf-8') as f: - self._config_data = yaml.safe_load(f) - except yaml.YAMLError as e: - raise yaml.YAMLError(f"配置文件格式错误: {e}") - - # 验证配置 - self._validate_config() - - # 解析配置 - self._config = self._parse_config() - return self._config - - def _validate_config(self): - """验证配置完整性""" - required_keys = [ - 'debug', 'log_level', 'log_file', 'log_max_size', 'log_backup_count', - 'crawler', 'database', 'wechat_app', 'markdown', - 'monitoring', 'sources' - ] - - for key in required_keys: - if key not in self._config_data: - raise ValueError(f"配置文件缺少必需的配置项: {key}") - - # 验证爬虫配置 - crawler_required = [ - 'base_url', 'timeout', 'max_retries', 'retry_delay', 'max_retry_delay', - 'backoff_factor', 'user_agents', 'proxies', 'request_delay', - 'request_delay_max', 'keyword', 'max_pages', 'page_size' - ] - - for key in crawler_required: - if key not in self._config_data['crawler']: - raise ValueError(f"爬虫配置缺少必需项: {key}") - - # 验证数据库配置 - if self._config_data.get('database', {}).get('enabled', False): - db_required = ['type', 'host', 'port', 'name', 'user', 'password'] - for key in db_required: - if key not in self._config_data['database']: - raise ValueError(f"数据库配置缺少必需项: {key}") - - # 验证企业微信配置 - if self._config_data.get('wechat_app', {}).get('enabled', False): - wechat_required = ['corp_id', 'agent_id', 'secret', 'token', 'encoding_aes_key'] - for key in wechat_required: - if key not in self._config_data['wechat_app']: - raise ValueError(f"企业微信配置缺少必需项: {key}") - - def _parse_config(self) -> SystemConfig: - """解析配置数据""" - crawler_data = self._config_data['crawler'] - crawler = CrawlerConfig( - base_url=crawler_data['base_url'], - timeout=crawler_data['timeout'], - max_retries=crawler_data['max_retries'], - retry_delay=crawler_data['retry_delay'], - max_retry_delay=crawler_data['max_retry_delay'], - backoff_factor=crawler_data['backoff_factor'], - user_agents=crawler_data['user_agents'], - proxies=crawler_data['proxies'], - request_delay=crawler_data['request_delay'], - request_delay_max=crawler_data['request_delay_max'], - keyword=crawler_data['keyword'], - start_date=crawler_data.get('start_date', ''), - end_date=crawler_data.get('end_date', ''), - max_pages=crawler_data['max_pages'], - page_size=crawler_data['page_size'] - ) - - db_data = self._config_data['database'] - database = DatabaseConfig( - enabled=db_data.get('enabled', False), - type=db_data.get('type', 'postgresql'), - host=db_data.get('host', 'localhost'), - port=db_data.get('port', 5432), - name=db_data.get('name', ''), - user=db_data.get('user', ''), - password=db_data.get('password', ''), - pool_size=db_data.get('pool_size', 5), - max_overflow=db_data.get('max_overflow', 10), - pool_timeout=db_data.get('pool_timeout', 30), - pool_recycle=db_data.get('pool_recycle', 3600), - data_retention_days=db_data.get('data_retention_days', 90), - auto_cleanup=db_data.get('auto_cleanup', True) - ) - - wechat_data = self._config_data['wechat_app'] - wechat_app = WeChatConfig( - enabled=wechat_data.get('enabled', False), - corp_id=wechat_data.get('corp_id', ''), - agent_id=wechat_data.get('agent_id', ''), - secret=wechat_data.get('secret', ''), - token=wechat_data.get('token', ''), - encoding_aes_key=wechat_data.get('encoding_aes_key', ''), - port=wechat_data.get('port', 18001), - host=wechat_data.get('host', '0.0.0.0'), - debug=wechat_data.get('debug', False), - use_proxy=wechat_data.get('use_proxy', False), - proxy_api_url=wechat_data.get('proxy_api_url', 'https://api.v6ole.top') - ) - - md_data = self._config_data['markdown'] - markdown = MarkdownConfig( - enabled=md_data.get('enabled', True), - output_file=md_data.get('output_file', 'onu.md'), - max_entries=md_data.get('max_entries', 1000), - include_today_highlight=md_data.get('include_today_highlight', True), - template_file=md_data.get('template_file', 'templates/announcement.md') - ) - - # scheduler配置为可选 - scheduler = None - if 'scheduler' in self._config_data: - scheduler_data = self._config_data['scheduler'] - scheduler = SchedulerConfig( - enabled=scheduler_data.get('enabled', False), - timezone=scheduler_data.get('timezone', 'Asia/Shanghai'), - jobs=scheduler_data.get('jobs', []) - ) - - monitoring_data = self._config_data['monitoring'] - monitoring = MonitoringConfig( - enabled=monitoring_data.get('enabled', True), - health_check_interval=monitoring_data.get('health_check_interval', 300), - alert_on_failure=monitoring_data.get('alert_on_failure', True), - max_consecutive_failures=monitoring_data.get('max_consecutive_failures', 3), - metrics_enabled=monitoring_data.get('metrics_enabled', True) - ) - - return SystemConfig( - debug=self._config_data['debug'], - log_level=LogLevel(self._config_data['log_level']), - log_file=self._config_data['log_file'], - log_max_size=self._config_data['log_max_size'], - log_backup_count=self._config_data['log_backup_count'], - crawler=crawler, - database=database, - wechat_app=wechat_app, - markdown=markdown, - scheduler=scheduler, - monitoring=monitoring, - sources=self._config_data['sources'] - ) - - def get_config(self) -> SystemConfig: - """ - 获取配置对象 - - Returns: - SystemConfig: 系统配置对象 - - Raises: - RuntimeError: 配置未加载 - """ - if self._config is None: - raise RuntimeError("配置未加载,请先调用 load_config()") - return self._config - - def reload_config(self) -> SystemConfig: - """ - 重新加载配置 - - Returns: - SystemConfig: 重新加载的系统配置对象 - """ - self._config = None - return self.load_config() - - def get_value(self, key_path: str, default=None) -> Any: - """ - 通过路径获取配置值 - - Args: - key_path: 配置路径,如 'crawler.timeout' 或 'database.host' - default: 默认值 - - Returns: - 配置值或默认值 - """ - keys = key_path.split('.') - value = self._config_data - - try: - for key in keys: - value = value[key] - return value - except (KeyError, TypeError): - return default - - -# 全局配置管理器实例 -_config_manager = None - - -def get_config_manager(config_file: Optional[str] = None) -> ConfigManager: - """ - 获取全局配置管理器实例 - - Args: - config_file: 配置文件路径 - - Returns: - ConfigManager: 配置管理器实例 - """ - global _config_manager - if _config_manager is None: - _config_manager = ConfigManager(config_file) - return _config_manager - - -def load_config(config_file: Optional[str] = None) -> SystemConfig: - """ - 加载系统配置 - - Args: - config_file: 配置文件路径 - - Returns: - SystemConfig: 系统配置对象 - """ - manager = get_config_manager(config_file) - return manager.load_config() - - -def get_config() -> SystemConfig: - """ - 获取当前加载的配置 - - Returns: - SystemConfig: 系统配置对象 - - Raises: - RuntimeError: 配置未加载 - """ - manager = get_config_manager() - return manager.get_config() diff --git a/gx_gp_monitor/core/database.py b/gx_gp_monitor/core/database.py deleted file mode 100644 index 835f68d..0000000 --- a/gx_gp_monitor/core/database.py +++ /dev/null @@ -1,975 +0,0 @@ -""" -PostgreSQL数据库连接和操作模块 -提供数据库连接池、CRUD操作、数据清理等功能 -""" - -import psycopg2 -from psycopg2 import pool, extras -from psycopg2.extras import RealDictCursor -from contextlib import contextmanager -from typing import List, Dict, Any, Optional, Generator -from datetime import datetime, timedelta -import threading -from dataclasses import asdict - -from .models import Announcement, AnnouncementSource, AnnouncementType, CrawlResult, CrawlStatus -from .config_manager import get_config -from .logger import get_logger -from .reliability import retry_on_exception, RetryConfig - - -logger = get_logger(__name__) - - -class DatabaseConnectionPool: - """数据库连接池管理器""" - - _instance = None - _pool = None - _lock = threading.Lock() - - def __new__(cls): - if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self): - if self._pool is None: - self._pool = None - self._config = None - - def init_pool(self, config): - """ - 初始化连接池 - - Args: - config: 数据库配置 - """ - if self._pool is not None: - return - - try: - self._config = config - self._pool = psycopg2.pool.SimpleConnectionPool( - minconn=config.pool_size, - maxconn=config.pool_size + config.max_overflow, - host=config.host, - port=config.port, - database=config.name, - user=config.user, - password=config.password, - connect_timeout=config.pool_timeout - ) - logger.info("数据库连接池初始化成功") - except Exception as e: - logger.error(f"数据库连接池初始化失败: {str(e)}") - raise - - def get_connection(self): - """ - 获取数据库连接 - - Returns: - 数据库连接对象 - - Raises: - Exception: 获取连接失败 - """ - if self._pool is None: - raise Exception("数据库连接池未初始化") - - try: - conn = self._pool.getconn() - # 设置自动提交为False,需要手动提交 - conn.autocommit = False - return conn - except Exception as e: - logger.error(f"获取数据库连接失败: {str(e)}") - raise - - def return_connection(self, conn): - """ - 返回数据库连接到连接池 - - Args: - conn: 数据库连接对象 - """ - if self._pool and conn: - try: - self._pool.putconn(conn) - except Exception as e: - logger.warning(f"返回数据库连接失败: {str(e)}") - - def close_all(self): - """关闭所有连接""" - if self._pool: - try: - self._pool.closeall() - logger.info("数据库连接池已关闭") - except Exception as e: - logger.error(f"关闭数据库连接池失败: {str(e)}") - - -# 全局连接池实例 -_connection_pool = DatabaseConnectionPool() - - -@contextmanager -def get_db_connection(): - """ - 获取数据库连接的上下文管理器 - - Yields: - 数据库连接对象 - """ - conn = None - try: - conn = _connection_pool.get_connection() - yield conn - except Exception as e: - logger.error(f"数据库连接错误: {str(e)}") - raise - finally: - if conn: - _connection_pool.return_connection(conn) - - -@contextmanager -def get_db_cursor(commit: bool = True): - """ - 获取数据库游标的上下文管理器 - - Args: - commit: 是否自动提交事务 - - Yields: - 数据库游标对象 - """ - with get_db_connection() as conn: - cursor = None - try: - cursor = conn.cursor(cursor_factory=RealDictCursor) - yield cursor - if commit: - conn.commit() - except Exception as e: - conn.rollback() - logger.error(f"数据库操作错误: {str(e)}") - raise - finally: - if cursor: - cursor.close() - - -class DatabaseManager: - """数据库管理器""" - - def __init__(self): - self.config = get_config() - if self.config.database.enabled: - _connection_pool.init_pool(self.config.database) - else: - logger.warning("数据库功能已禁用") - - def init_database(self): - """初始化数据库表结构""" - if not self.config.database.enabled: - return - - logger.info("开始初始化数据库表结构") - - # 创建表的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, - 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, - crawl_mode VARCHAR(20) DEFAULT 'auto', - 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 announcement_sources ( - code VARCHAR(50) PRIMARY KEY, - category_id INTEGER NOT NULL, - name VARCHAR(100) NOT NULL, - type VARCHAR(50) NOT NULL - ); - - -- 爬取结果表 - CREATE TABLE IF NOT EXISTS crawl_results ( - id SERIAL PRIMARY KEY, - source_code VARCHAR(50) NOT NULL, - status VARCHAR(20) NOT NULL, - total_count INTEGER DEFAULT 0, - new_count INTEGER DEFAULT 0, - error_message TEXT, - crawled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - duration FLOAT DEFAULT 0.0, - FOREIGN KEY (source_code) REFERENCES announcement_sources(code) - ); - - -- 大化县政府网采购公告表(全部推送,不筛选) - CREATE TABLE IF NOT EXISTS dahuagov_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 DEFAULT 'dahuagov', - source_name VARCHAR(100) NOT NULL DEFAULT '大化县政府网采购公告', - announcement_type VARCHAR(50) NOT NULL DEFAULT 'purchase', - crawled_at TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - content_hash VARCHAR(32) UNIQUE, - is_new BOOLEAN DEFAULT TRUE - ); - - -- 创建索引 - CREATE INDEX IF NOT EXISTS idx_announcements_publish_date ON announcements(publish_date DESC); - CREATE INDEX IF NOT EXISTS idx_announcements_source_code ON announcements(source_code); - CREATE INDEX IF NOT EXISTS idx_announcements_content_hash ON announcements(content_hash); - CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_crawl_results_crawled_at ON crawl_results(crawled_at DESC); - CREATE INDEX IF NOT EXISTS idx_dahuagov_publish_date ON dahuagov_announcements(publish_date DESC); - CREATE INDEX IF NOT EXISTS idx_dahuagov_content_hash ON dahuagov_announcements(content_hash); - CREATE INDEX IF NOT EXISTS idx_dahuagov_created_at ON dahuagov_announcements(created_at DESC); - - -- 创建更新时间触发器 - CREATE OR REPLACE FUNCTION update_updated_at_column() - RETURNS TRIGGER AS $$ - BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; - END; - $$ language 'plpgsql'; - - DROP TRIGGER IF EXISTS update_announcements_updated_at ON announcements; - CREATE TRIGGER update_announcements_updated_at - BEFORE UPDATE ON announcements - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - """ - - with get_db_cursor() as cursor: - cursor.execute(create_tables_sql) - logger.info("数据库表结构初始化完成") - - @retry_on_exception(RetryConfig(max_retries=3)) - def save_announcement(self, announcement: Announcement) -> bool: - """ - 保存公告到数据库 - - Args: - announcement: 公告对象 - - Returns: - bool: 保存是否成功 - """ - if not self.config.database.enabled: - return False - - # 生成内容哈希(如果还没有) - if not announcement.content_hash: - announcement.generate_content_hash() - - sql = """ - INSERT INTO announcements ( - 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 = ( - 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: - cursor.execute(sql, values) - affected_rows = cursor.rowcount - if affected_rows > 0: - logger.debug(f"成功保存公告: {announcement.title[:50]}...") - return True - else: - logger.debug(f"公告已存在,跳过保存: {announcement.title[:50]}...") - return False - except Exception as e: - logger.error(f"保存公告失败: {str(e)}") - return False - - @retry_on_exception(RetryConfig(max_retries=3)) - def save_announcements_batch(self, announcements: List[Announcement]) -> int: - """ - 批量保存公告 - - Args: - 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() - - sql = """ - INSERT INTO announcements ( - title, publish_date, purchase_name, content_url, source_code, source_name, - 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, %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.crawl_mode, - 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"批量保存公告完成,成功保存 {affected_rows} 条") - return affected_rows - except Exception as e: - 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, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - limit: int = 100, - offset: int = 0) -> List[Announcement]: - """ - 查询公告 - - Args: - source_code: 来源代码过滤 - start_date: 开始日期 - end_date: 结束日期 - limit: 限制数量 - offset: 偏移量 - - Returns: - List[Announcement]: 公告列表 - """ - if not self.config.database.enabled: - return [] - - sql = """ - SELECT * FROM announcements - WHERE 1=1 - """ - params = [] - - if source_code: - sql += " AND source_code = %s" - params.append(source_code) - - if start_date: - sql += " AND publish_date >= %s" - params.append(start_date) - - if end_date: - sql += " AND publish_date <= %s" - params.append(end_date) - - sql += " ORDER BY publish_date DESC LIMIT %s OFFSET %s" - params.extend([limit, offset]) - - try: - with get_db_cursor() as cursor: - cursor.execute(sql, params) - rows = cursor.fetchall() - - announcements = [] - for row in rows: - # 转换数据类型 - row_dict = dict(row) - row_dict['announcement_type'] = AnnouncementType(row_dict['announcement_type']) - announcements.append(Announcement.from_dict(row_dict)) - - return announcements - except Exception as e: - logger.error(f"查询公告失败: {str(e)}") - return [] - - @retry_on_exception(RetryConfig(max_retries=3)) - def save_crawl_result(self, result: CrawlResult) -> bool: - """ - 保存爬取结果 - - Args: - result: 爬取结果对象 - - Returns: - bool: 保存是否成功 - """ - if not self.config.database.enabled: - return False - - sql = """ - INSERT INTO crawl_results ( - source_code, status, total_count, new_count, error_message, - crawled_at, duration - ) VALUES (%s, %s, %s, %s, %s, %s, %s) - """ - - values = ( - result.source.code, - result.status.value, - result.total_count, - result.new_count, - result.error_message, - result.crawled_at, - result.duration - ) - - try: - with get_db_cursor() as cursor: - cursor.execute(sql, values) - logger.debug(f"保存爬取结果: {result.source.name}") - return True - except Exception as e: - logger.error(f"保存爬取结果失败: {str(e)}") - return False - - @retry_on_exception(RetryConfig(max_retries=3)) - def cleanup_expired_data(self, days: int = 90) -> int: - """ - 清理过期数据 - - Args: - days: 保留天数 - - Returns: - int: 清理的记录数 - """ - if not self.config.database.enabled: - return 0 - - cutoff_date = datetime.now() - timedelta(days=days) - - sql = "DELETE FROM announcements WHERE created_at < %s" - try: - with get_db_cursor() as cursor: - cursor.execute(sql, (cutoff_date,)) - deleted_count = cursor.rowcount - logger.info(f"清理过期数据完成,删除 {deleted_count} 条记录") - return deleted_count - except Exception as e: - logger.error(f"清理过期数据失败: {str(e)}") - return 0 - - @retry_on_exception(RetryConfig(max_retries=3)) - def get_statistics(self) -> Dict[str, Any]: - """ - 获取统计信息 - - Returns: - Dict[str, Any]: 统计数据 - """ - if not self.config.database.enabled: - return {} - - # 统计所有表的综合信息 - sql = """ - SELECT - 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(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() - 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 - UNION ALL - SELECT - 'dahuagov_announcements' as table_name, - COUNT(*) as count, - COUNT(DISTINCT source_code) as sources, - MAX(crawled_at) as last_crawl - FROM dahuagov_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 {} - - def is_announcement_exists(self, content_hash: str) -> bool: - """ - 检查公告是否已存在 - - Args: - content_hash: 内容哈希 - - Returns: - bool: 是否存在 - """ - if not self.config.database.enabled: - return False - - # 检查所有表中是否存在 - 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 - UNION ALL - SELECT content_hash FROM dahuagov_announcements WHERE content_hash = %s - ) as combined_check LIMIT 1 - """ - - try: - with get_db_cursor() as cursor: - cursor.execute(sql, (content_hash, content_hash, content_hash, content_hash)) - return cursor.fetchone() is not None - except Exception as e: - logger.error(f"检查公告存在性失败: {str(e)}") - return False - - def get_recent_announcements(self, hours: int = 24) -> List[Announcement]: - """ - 获取最近的公告 - - Args: - hours: 最近小时数 - - Returns: - List[Announcement]: 公告列表 - """ - if not self.config.database.enabled: - return [] - - cutoff_time = datetime.now() - timedelta(hours=hours) - - sql = """ - SELECT * FROM announcements - WHERE crawled_at >= %s - ORDER BY crawled_at DESC - """ - - try: - with get_db_cursor() as cursor: - cursor.execute(sql, (cutoff_time,)) - rows = cursor.fetchall() - - announcements = [] - for row in rows: - row_dict = dict(row) - row_dict['announcement_type'] = AnnouncementType(row_dict['announcement_type']) - announcements.append(Announcement.from_dict(row_dict)) - - return announcements - except Exception as e: - logger.error(f"获取最近公告失败: {str(e)}") - return [] - - @retry_on_exception(RetryConfig(max_retries=3)) - def save_dahuagov_announcements(self, announcements: List[Announcement]) -> int: - """ - 保存大化县政府网公告(全部推送,不筛选关键词) - - Args: - 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() - - sql = """ - INSERT INTO dahuagov_announcements ( - title, publish_date, purchase_name, content_url, source_code, source_name, - announcement_type, crawled_at, content_hash, is_new - ) VALUES (%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.is_new - )) - - try: - with get_db_cursor() as cursor: - extras.execute_batch(cursor, sql, values) - affected_rows = cursor.rowcount - logger.info(f"保存大化县公告完成,新增 {affected_rows} 条") - return affected_rows - except Exception as e: - logger.error(f"保存大化县公告失败: {str(e)}") - return 0 - - @retry_on_exception(RetryConfig(max_retries=3)) - def get_new_dahuagov_announcements(self) -> List[Announcement]: - """ - 获取大化县未推送的新公告(is_new = TRUE) - - Returns: - List[Announcement]: 未推送的公告列表 - """ - if not self.config.database.enabled: - return [] - - sql = """ - SELECT * FROM dahuagov_announcements - WHERE is_new = TRUE - ORDER BY publish_date DESC - """ - - try: - with get_db_cursor() as cursor: - cursor.execute(sql) - rows = cursor.fetchall() - - announcements = [] - for row in rows: - row_dict = dict(row) - row_dict['announcement_type'] = AnnouncementType(row_dict['announcement_type']) - announcements.append(Announcement.from_dict(row_dict)) - - return announcements - except Exception as e: - logger.error(f"获取大化县新公告失败: {str(e)}") - return [] - - @retry_on_exception(RetryConfig(max_retries=3)) - def mark_dahuagov_announcements_sent(self, announcements: List[Announcement]) -> int: - """ - 标记大化县公告已发送(is_new = FALSE) - - Args: - announcements: 已发送的公告列表 - - Returns: - int: 更新的记录数 - """ - if not self.config.database.enabled: - return 0 - - if not announcements: - return 0 - - # 获取所有公告的哈希值 - hashes = [ann.content_hash for ann in announcements if ann.content_hash] - - if not hashes: - return 0 - - sql = """ - UPDATE dahuagov_announcements - SET is_new = FALSE, updated_at = CURRENT_TIMESTAMP - WHERE content_hash = ANY(%s) - """ - - try: - with get_db_cursor() as cursor: - cursor.execute(sql, (hashes,)) - affected_rows = cursor.rowcount - logger.info(f"标记大化县公告已发送完成,更新 {affected_rows} 条") - return affected_rows - except Exception as e: - logger.error(f"标记大化县公告已发送失败: {str(e)}") - return 0 - - -# 全局数据库管理器实例 -_db_manager = None - - -def get_database_manager() -> DatabaseManager: - """ - 获取数据库管理器实例 - - Returns: - DatabaseManager: 数据库管理器实例 - """ - global _db_manager - if _db_manager is None: - _db_manager = DatabaseManager() - return _db_manager - - -def init_database(): - """初始化数据库""" - manager = get_database_manager() - manager.init_database() - - -def cleanup_database(): - """清理数据库连接""" - _connection_pool.close_all() diff --git a/gx_gp_monitor/core/logger.py b/gx_gp_monitor/core/logger.py deleted file mode 100644 index 5d8f31e..0000000 --- a/gx_gp_monitor/core/logger.py +++ /dev/null @@ -1,371 +0,0 @@ -""" -统一日志管理模块 -提供结构化日志记录功能,支持控制台和文件输出 -""" - -import os -import sys -import logging -import logging.handlers -from pathlib import Path -from typing import Optional, Dict, Any -from datetime import datetime - -from .config_manager import get_config - - -class ColoredFormatter(logging.Formatter): - """带颜色的日志格式化器""" - - # ANSI颜色代码 - COLORS = { - 'DEBUG': '\033[36m', # 青色 - 'INFO': '\033[32m', # 绿色 - 'WARNING': '\033[33m', # 黄色 - 'ERROR': '\033[31m', # 红色 - 'CRITICAL': '\033[35m', # 紫色 - } - RESET = '\033[0m' # 重置颜色 - - def format(self, record): - # 检查是否已经包含ANSI颜色代码 - if '\033[' in record.levelname: - # 如果已经着色,直接返回原始格式 - return super().format(record) - - # 保存原始值 - original_levelname = record.levelname - original_msg = record.msg - - # 添加颜色 - if record.levelname in self.COLORS: - # 为levelname添加颜色 - record.levelname = f"{self.COLORS[record.levelname]}{record.levelname}{self.RESET}" - # 为消息添加颜色 - record.msg = f"{self.COLORS[original_levelname]}{record.msg}{self.RESET}" - - # 格式化 - result = super().format(record) - - # 恢复原始值,避免影响其他处理器 - record.levelname = original_levelname - record.msg = original_msg - - return result - - -class Logger: - """统一日志管理器""" - - _instance = None - _initialized = False - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self): - if not self._initialized: - self._loggers = {} - self._config = None - self._file_handler = None # 全局文件处理器,避免重复创建 - self._initialized = True - - def init_logger(self, name: str = "gx_gp_monitor", config=None) -> logging.Logger: - """ - 初始化日志器 - - Args: - name: 日志器名称 - config: 配置对象,如果为None则从全局配置加载 - - Returns: - logging.Logger: 配置好的日志器实例 - """ - if name in self._loggers: - return self._loggers[name] - - # 获取配置 - if config is None: - try: - self._config = get_config() - except RuntimeError: - # 配置未加载,使用默认配置 - self._config = self._get_default_config() - else: - self._config = config - - # 创建日志器 - logger = logging.getLogger(name) - logger.setLevel(getattr(logging, self._config.log_level.value)) - - # 防止消息传播到父logger,避免重复记录 - logger.propagate = False - - # 避免重复添加处理器 - if logger.handlers: - return logger - - # 创建格式化器 - formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - - # 控制台处理器 - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(getattr(logging, self._config.log_level.value)) - - # 使用彩色格式化器(如果支持) - if sys.platform != 'win32' and 'TERM' in os.environ: - colored_formatter = ColoredFormatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - console_handler.setFormatter(colored_formatter) - else: - console_handler.setFormatter(formatter) - - logger.addHandler(console_handler) - - # 文件处理器(如果配置了日志文件)- 使用全局文件处理器避免重复 - if self._config.log_file and self._file_handler is None: - log_dir = Path(self._config.log_file).parent - log_dir.mkdir(parents=True, exist_ok=True) - - self._file_handler = logging.handlers.RotatingFileHandler( - self._config.log_file, - maxBytes=self._config.log_max_size, - backupCount=self._config.log_backup_count, - encoding='utf-8' - ) - self._file_handler.setLevel(getattr(logging, self._config.log_level.value)) - self._file_handler.setFormatter(formatter) - - # 为所有logger添加全局文件处理器 - if self._file_handler is not None: - logger.addHandler(self._file_handler) - - self._loggers[name] = logger - return logger - - def _get_default_config(self): - """获取默认配置""" - from .config_manager import LogLevel - - class DefaultConfig: - def __init__(self): - self.log_level = LogLevel.INFO - self.log_file = "logs/gx_gp_monitor.log" - self.log_max_size = 10485760 # 10MB - self.log_backup_count = 5 - - return DefaultConfig() - - def get_logger(self, name: str = "gx_gp_monitor") -> logging.Logger: - """ - 获取日志器 - - Args: - name: 日志器名称 - - Returns: - logging.Logger: 日志器实例 - """ - if name not in self._loggers: - return self.init_logger(name) - return self._loggers[name] - - def log_crawl_start(self, source_name: str, logger: Optional[logging.Logger] = None): - """记录爬取开始""" - if logger is None: - logger = self.get_logger() - logger.info(f"开始爬取 {source_name}") - - def log_crawl_success(self, source_name: str, count: int, duration: float, - logger: Optional[logging.Logger] = None): - """记录爬取成功""" - if logger is None: - logger = self.get_logger() - logger.info(f"{source_name} 爬取完成,共获取 {count} 条公告,耗时 {duration:.2f}秒") - - def log_crawl_error(self, source_name: str, error: str, - logger: Optional[logging.Logger] = None): - """记录爬取错误""" - if logger is None: - logger = self.get_logger() - logger.error(f"{source_name} 爬取失败: {error}") - - def log_announcement_filtered(self, reason: str, count: int, - logger: Optional[logging.Logger] = None): - """记录公告筛选信息""" - if logger is None: - logger = self.get_logger() - logger.info(f"公告筛选 - {reason}: {count} 条") - - def log_database_operation(self, operation: str, table: str, count: int = 0, - logger: Optional[logging.Logger] = None): - """记录数据库操作""" - if logger is None: - logger = self.get_logger() - if count > 0: - logger.info(f"数据库操作 - {operation} {table}: {count} 条记录") - else: - logger.info(f"数据库操作 - {operation} {table}") - - def log_notification_sent(self, channel: str, recipient_count: int, - logger: Optional[logging.Logger] = None): - """记录通知发送""" - if logger is None: - logger = self.get_logger() - logger.info(f"通知发送 - {channel}: 向 {recipient_count} 个接收者发送") - - def log_system_metrics(self, metrics: Dict[str, Any], - logger: Optional[logging.Logger] = None): - """记录系统指标""" - if logger is None: - logger = self.get_logger() - metrics_str = ", ".join([f"{k}={v}" for k, v in metrics.items()]) - logger.info(f"系统指标: {metrics_str}") - - def log_performance_warning(self, operation: str, duration: float, threshold: float, - logger: Optional[logging.Logger] = None): - """记录性能警告""" - if logger is None: - logger = self.get_logger() - logger.warning(f"性能警告 - {operation} 耗时 {duration:.2f}秒,超过阈值 {threshold:.2f}秒") - - -# 全局日志管理器实例 -_logger_manager = Logger() - - -def get_logger(name: str = "gx_gp_monitor") -> logging.Logger: - """ - 获取日志器 - - Args: - name: 日志器名称 - - Returns: - logging.Logger: 日志器实例 - """ - return _logger_manager.get_logger(name) - - -def init_logger(name: str = "gx_gp_monitor", config=None) -> logging.Logger: - """ - 初始化并获取日志器 - - Args: - name: 日志器名称 - config: 配置对象 - - Returns: - logging.Logger: 日志器实例 - """ - return _logger_manager.init_logger(name, config) - - -def log_function_call(func_name: str, args: Optional[Dict[str, Any]] = None, - logger: Optional[logging.Logger] = None): - """ - 装饰器:记录函数调用 - - Args: - func_name: 函数名称 - args: 函数参数 - logger: 日志器实例 - """ - def decorator(func): - def wrapper(*args, **kwargs): - nonlocal logger - if logger is None: - logger = get_logger() - - start_time = datetime.now() - logger.debug(f"调用函数: {func_name}") - - try: - result = func(*args, **kwargs) - duration = (datetime.now() - start_time).total_seconds() - logger.debug(f"函数 {func_name} 执行完成,耗时 {duration:.3f}秒") - return result - except Exception as e: - duration = (datetime.now() - start_time).total_seconds() - logger.error(f"函数 {func_name} 执行失败,耗时 {duration:.3f}秒: {str(e)}") - raise - - return wrapper - return decorator - - -# 便捷函数 -def log_info(message: str, logger: Optional[logging.Logger] = None): - """记录信息日志""" - if logger is None: - logger = get_logger() - logger.info(message) - - -def log_warning(message: str, logger: Optional[logging.Logger] = None): - """记录警告日志""" - if logger is None: - logger = get_logger() - logger.warning(message) - - -def log_error(message: str, logger: Optional[logging.Logger] = None): - """记录错误日志""" - if logger is None: - logger = get_logger() - logger.error(message) - - -def log_debug(message: str, logger: Optional[logging.Logger] = None): - """记录调试日志""" - if logger is None: - logger = get_logger() - logger.debug(message) - - -# 便捷的爬取日志记录函数 -def log_crawl_start(source_name: str): - """记录爬取开始""" - _logger_manager.log_crawl_start(source_name) - - -def log_crawl_success(source_name: str, count: int, duration: float): - """记录爬取成功""" - _logger_manager.log_crawl_success(source_name, count, duration) - - -def log_crawl_error(source_name: str, error: str): - """记录爬取错误""" - _logger_manager.log_crawl_error(source_name, error) - - -def log_announcement_filtered(reason: str, count: int): - """记录公告筛选信息""" - _logger_manager.log_announcement_filtered(reason, count) - - -def log_database_operation(operation: str, table: str, count: int = 0): - """记录数据库操作""" - _logger_manager.log_database_operation(operation, table, count) - - -def log_notification_sent(channel: str, recipient_count: int): - """记录通知发送""" - _logger_manager.log_notification_sent(channel, recipient_count) - - -def log_system_metrics(metrics: Dict[str, Any]): - """记录系统指标""" - _logger_manager.log_system_metrics(metrics) - - -def log_performance_warning(operation: str, duration: float, threshold: float): - """记录性能警告""" - _logger_manager.log_performance_warning(operation, duration, threshold) diff --git a/gx_gp_monitor/core/models.py b/gx_gp_monitor/core/models.py deleted file mode 100644 index 94ebf7a..0000000 --- a/gx_gp_monitor/core/models.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -数据模型定义 -定义系统使用的数据结构和模型 -""" - -from dataclasses import dataclass, field -from datetime import datetime -from typing import Optional, List, Dict, Any -from enum import Enum - - -class AnnouncementType(Enum): - """公告类型枚举""" - PURCHASE = "purchase" # 采购公告 - RESULT = "result" # 结果公告 - CONTRACT = "contract" # 合同公告 - CORRECTION = "correction" # 更正公告 - PRE_ANNOUNCEMENT = "pre_announcement" # 招标文件预公示 - SINGLE_SOURCE = "single_source" # 单一来源公示 - ELECTRONIC_MARKET = "electronic_market" # 电子卖场公示 - ACCEPTANCE = "acceptance" # 履约验收公示 - ENGINEERING = "engineering" # 工程类公告 - FRAMEWORK_AGREEMENT = "framework_agreement" # 框架协议征集公告 - FRAMEWORK_RESULT = "framework_result" # 框架协议入围结果公告 - FRAMEWORK_SUMMARY = "framework_summary" # 框架协议成交结果汇总公告 - INTENTION = "intention" # 采购意向公开 - - -class CrawlStatus(Enum): - """爬取状态枚举""" - PENDING = "pending" # 待爬取 - RUNNING = "running" # 爬取中 - SUCCESS = "success" # 成功 - FAILED = "failed" # 失败 - PARTIAL = "partial" # 部分成功 - - -@dataclass -class AnnouncementSource: - """公告来源""" - code: str # 来源代码,如 "ZcyAnnouncement1" - category_id: int # 分类ID - name: str # 显示名称,如 "采购公告" - type: AnnouncementType # 公告类型 - - -@dataclass -class Announcement: - """公告数据模型""" - id: Optional[int] = None # 数据库ID - title: str = "" # 公告标题 - publish_date: datetime = field(default_factory=datetime.now) # 发布时间 - purchase_name: str = "" # 发布单位 - content_url: str = "" # 内容链接 - source_code: str = "" # 来源代码 - source_name: str = "" # 来源名称 - announcement_type: AnnouncementType = AnnouncementType.PURCHASE # 公告类型 - - # 爬取相关字段 - 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 # 内容哈希,用于去重 - - # 筛选相关 - keyword_matched: bool = False # 是否匹配关键词 - date_filtered: bool = True # 是否在日期范围内 - - # 业务字段 - is_new: bool = True # 是否为新公告 - is_today: bool = False # 是否为今日公告 - - def __post_init__(self): - """后初始化处理""" - if isinstance(self.announcement_type, str): - self.announcement_type = AnnouncementType(self.announcement_type) - - if self.publish_date and isinstance(self.publish_date, str): - try: - self.publish_date = datetime.fromisoformat(self.publish_date.replace('Z', '+00:00')) - except ValueError: - # 如果解析失败,使用当前时间 - self.publish_date = datetime.now() - - # 判断是否为今日公告 - today = datetime.now().date() - if self.publish_date: - self.is_today = self.publish_date.date() == today - - @property - def publish_date_str(self) -> str: - """获取发布日期字符串""" - return self.publish_date.strftime("%Y-%m-%d") if self.publish_date else "" - - @property - def crawled_at_str(self) -> str: - """获取爬取时间字符串""" - return self.crawled_at.strftime("%Y-%m-%d %H:%M:%S") if self.crawled_at else "" - - def to_dict(self) -> Dict[str, Any]: - """转换为字典""" - return { - "id": self.id, - "title": self.title, - "publish_date": self.publish_date.isoformat() if self.publish_date else None, - "purchase_name": self.purchase_name, - "content_url": self.content_url, - "source_code": self.source_code, - "source_name": self.source_name, - "announcement_type": self.announcement_type.value, - "crawled_at": self.crawled_at.isoformat() if self.crawled_at else None, - "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None, - "content_hash": self.content_hash, - "keyword_matched": self.keyword_matched, - "date_filtered": self.date_filtered, - "is_new": self.is_new, - "is_today": self.is_today - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Announcement': - """从字典创建实例""" - # 处理枚举类型 - if 'announcement_type' in data and isinstance(data['announcement_type'], str): - data['announcement_type'] = AnnouncementType(data['announcement_type']) - - # 处理日期时间 - for date_field in ['publish_date', 'crawled_at', 'created_at', 'updated_at']: - if date_field in data and data[date_field] and isinstance(data[date_field], str): - try: - data[date_field] = datetime.fromisoformat(data[date_field].replace('Z', '+00:00')) - except ValueError: - data[date_field] = None - - return cls(**data) - - def generate_content_hash(self) -> str: - """生成内容哈希用于去重""" - import hashlib - content = f"{self.title}|{self.publish_date_str}|{self.purchase_name}|{self.content_url}|{self.source_code}" - self.content_hash = hashlib.md5(content.encode('utf-8')).hexdigest() - return self.content_hash - - def matches_keywords(self, keywords: List[str]) -> bool: - """检查是否匹配关键词""" - if not keywords: - return True - - search_text = f"{self.title} {self.purchase_name}".lower() - for keyword in keywords: - if keyword.lower() in search_text: - self.keyword_matched = True - return True - - self.keyword_matched = False - return False - - def in_date_range(self, start_date: Optional[str], end_date: Optional[str]) -> bool: - """检查是否在日期范围内""" - if not self.publish_date: - self.date_filtered = False - return False - - publish_date = self.publish_date.date() - - try: - if start_date: - start = datetime.fromisoformat(start_date).date() - if publish_date < start: - self.date_filtered = False - return False - - if end_date: - end = datetime.fromisoformat(end_date).date() - if publish_date > end: - self.date_filtered = False - return False - - self.date_filtered = True - return True - except ValueError: - # 日期格式错误时,默认通过 - self.date_filtered = True - return True - - -@dataclass -class CrawlResult: - """爬取结果""" - source: AnnouncementSource # 公告来源 - status: CrawlStatus # 爬取状态 - total_count: int = 0 # 总公告数 - new_count: int = 0 # 新增公告数 - error_message: Optional[str] = None # 错误信息 - announcements: List[Announcement] = field(default_factory=list) # 公告列表 - crawled_at: datetime = field(default_factory=datetime.now) # 爬取时间 - duration: float = 0.0 # 爬取耗时(秒) - - -@dataclass -class CrawlSession: - """爬取会话""" - session_id: str # 会话ID - start_time: datetime # 开始时间 - end_time: Optional[datetime] = None # 结束时间 - status: CrawlStatus = CrawlStatus.PENDING # 会话状态 - total_sources: int = 0 # 总来源数 - completed_sources: int = 0 # 已完成来源数 - total_announcements: int = 0 # 总公告数 - new_announcements: int = 0 # 新增公告数 - results: List[CrawlResult] = field(default_factory=list) # 各来源结果 - - @property - def duration(self) -> float: - """获取会话持续时间""" - if self.end_time and self.start_time: - return (self.end_time - self.start_time).total_seconds() - elif self.start_time: - return (datetime.now() - self.start_time).total_seconds() - return 0.0 - - @property - def progress(self) -> float: - """获取完成进度(0-1)""" - if self.total_sources == 0: - return 0.0 - return self.completed_sources / self.total_sources - - -@dataclass -class NotificationMessage: - """通知消息""" - title: str # 消息标题 - content: str # 消息内容 - message_type: str = "text" # 消息类型:text, markdown, card - recipients: List[str] = field(default_factory=lambda: ["@all"]) # 接收者列表 - attachments: Optional[Dict[str, Any]] = None # 附件信息 - created_at: datetime = field(default_factory=datetime.now) # 创建时间 - - -@dataclass -class SystemMetrics: - """系统指标""" - timestamp: datetime = field(default_factory=datetime.now) # 时间戳 - total_announcements: int = 0 # 总公告数 - today_announcements: int = 0 # 今日公告数 - new_announcements_today: int = 0 # 今日新增公告数 - crawl_sessions_today: int = 0 # 今日爬取会话数 - last_crawl_duration: float = 0.0 # 最后一次爬取耗时 - database_size: int = 0 # 数据库大小(字节) - memory_usage: float = 0.0 # 内存使用率 - disk_usage: float = 0.0 # 磁盘使用率 diff --git a/gx_gp_monitor/core/reliability.py b/gx_gp_monitor/core/reliability.py deleted file mode 100644 index edf6f47..0000000 --- a/gx_gp_monitor/core/reliability.py +++ /dev/null @@ -1,489 +0,0 @@ -""" -高可用性模块 -提供重试机制、超时控制、幂等操作、异常处理和恢复功能 -""" - -import time -import random -import hashlib -from contextlib import contextmanager -from functools import wraps -from typing import Callable, Any, Optional, Type, Union, List -from datetime import datetime, timedelta -import threading -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -from .logger import get_logger -from .config_manager import get_config - - -logger = get_logger(__name__) - - -class RetryConfig: - """重试配置""" - - def __init__(self, - max_retries: int = 3, - initial_delay: float = 1.0, - max_delay: float = 60.0, - backoff_factor: float = 2.0, - jitter: bool = True): - """ - 初始化重试配置 - - Args: - max_retries: 最大重试次数 - initial_delay: 初始延迟时间(秒) - max_delay: 最大延迟时间(秒) - backoff_factor: 退避因子 - jitter: 是否添加随机抖动 - """ - self.max_retries = max_retries - self.initial_delay = initial_delay - self.max_delay = max_delay - self.backoff_factor = backoff_factor - self.jitter = jitter - - -class TimeoutConfig: - """超时配置""" - - def __init__(self, - connect_timeout: float = 10.0, - read_timeout: float = 30.0, - total_timeout: Optional[float] = None): - """ - 初始化超时配置 - - Args: - connect_timeout: 连接超时时间(秒) - read_timeout: 读取超时时间(秒) - total_timeout: 总超时时间(秒) - """ - self.connect_timeout = connect_timeout - self.read_timeout = read_timeout - self.total_timeout = total_timeout or (connect_timeout + read_timeout) - - -class CircuitBreakerState: - """熔断器状态""" - CLOSED = "closed" # 关闭状态,正常工作 - OPEN = "open" # 打开状态,快速失败 - HALF_OPEN = "half_open" # 半开状态,测试恢复 - - -class CircuitBreaker: - """熔断器实现""" - - def __init__(self, - failure_threshold: int = 5, - recovery_timeout: int = 60, - expected_exception: Type[Exception] = Exception): - """ - 初始化熔断器 - - Args: - failure_threshold: 失败阈值 - recovery_timeout: 恢复超时时间(秒) - expected_exception: 期望的异常类型 - """ - self.failure_threshold = failure_threshold - self.recovery_timeout = recovery_timeout - self.expected_exception = expected_exception - - self.state = CircuitBreakerState.CLOSED - self.failure_count = 0 - self.last_failure_time = None - self._lock = threading.Lock() - - def __call__(self, func: Callable) -> Callable: - """装饰器实现""" - @wraps(func) - def wrapper(*args, **kwargs): - return self._execute_with_circuit_breaker(func, *args, **kwargs) - return wrapper - - def _execute_with_circuit_breaker(self, func: Callable, *args, **kwargs) -> Any: - """使用熔断器执行函数""" - if self.state == CircuitBreakerState.OPEN: - if self._should_attempt_reset(): - self.state = CircuitBreakerState.HALF_OPEN - logger.info("熔断器半开,尝试恢复") - else: - raise CircuitBreakerOpenException("熔断器已打开") - - try: - result = func(*args, **kwargs) - self._on_success() - return result - except self.expected_exception as e: - self._on_failure() - raise - - def _should_attempt_reset(self) -> bool: - """检查是否应该尝试重置""" - if self.last_failure_time is None: - return True - return (datetime.now() - self.last_failure_time).total_seconds() >= self.recovery_timeout - - def _on_success(self): - """成功时的处理""" - with self._lock: - if self.state == CircuitBreakerState.HALF_OPEN: - self.state = CircuitBreakerState.CLOSED - self.failure_count = 0 - logger.info("熔断器关闭,服务恢复正常") - - def _on_failure(self): - """失败时的处理""" - with self._lock: - self.failure_count += 1 - self.last_failure_time = datetime.now() - - if self.failure_count >= self.failure_threshold: - self.state = CircuitBreakerState.OPEN - logger.warning(f"熔断器打开,失败次数达到阈值: {self.failure_count}") - - -class CircuitBreakerOpenException(Exception): - """熔断器打开异常""" - pass - - -class IdempotencyKey: - """幂等性键生成器""" - - @staticmethod - def generate(*args, **kwargs) -> str: - """ - 生成幂等性键 - - Args: - *args: 位置参数 - **kwargs: 关键字参数 - - Returns: - str: 幂等性键 - """ - # 将参数转换为字符串并排序 - key_parts = [] - - # 处理位置参数 - for i, arg in enumerate(args): - key_parts.append(f"arg_{i}:{str(arg)}") - - # 处理关键字参数(排序以保证一致性) - for key in sorted(kwargs.keys()): - key_parts.append(f"{key}:{str(kwargs[key])}") - - # 生成哈希 - key_string = "|".join(key_parts) - return hashlib.md5(key_string.encode('utf-8')).hexdigest() - - -class IdempotencyManager: - """幂等性管理器""" - - def __init__(self): - self._executed_keys = set() - self._lock = threading.Lock() - - def is_executed(self, key: str) -> bool: - """ - 检查操作是否已执行 - - Args: - key: 幂等性键 - - Returns: - bool: 是否已执行 - """ - with self._lock: - return key in self._executed_keys - - def mark_executed(self, key: str): - """ - 标记操作已执行 - - Args: - key: 幂等性键 - """ - with self._lock: - self._executed_keys.add(key) - - def clear_expired_keys(self, max_age_seconds: int = 3600): - """ - 清理过期的键(简化实现,实际应该使用时间戳) - - Args: - max_age_seconds: 最大年龄(秒) - """ - # 这里简化实现,实际项目中应该记录时间戳 - pass - - -def retry_on_exception(retry_config: Optional[RetryConfig] = None, - exceptions: tuple = (Exception,), - logger: Optional[Any] = None) -> Callable: - """ - 重试装饰器 - - Args: - retry_config: 重试配置 - exceptions: 需要重试的异常类型 - logger: 日志器 - - Returns: - Callable: 装饰器函数 - """ - if retry_config is None: - retry_config = RetryConfig() - - if logger is None: - logger = get_logger() - - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs): - last_exception = None - - for attempt in range(retry_config.max_retries + 1): - try: - return func(*args, **kwargs) - except exceptions as e: - last_exception = e - - if attempt < retry_config.max_retries: - # 计算延迟时间 - delay = min( - retry_config.initial_delay * (retry_config.backoff_factor ** attempt), - retry_config.max_delay - ) - - # 添加随机抖动 - if retry_config.jitter: - delay = delay * (0.5 + random.random() * 0.5) - - logger.warning( - f"函数 {func.__name__} 执行失败 (尝试 {attempt + 1}/{retry_config.max_retries + 1}): {str(e)}," - f"等待 {delay:.2f} 秒后重试" - ) - time.sleep(delay) - else: - logger.error( - f"函数 {func.__name__} 在 {retry_config.max_retries + 1} 次尝试后仍然失败: {str(e)}" - ) - - raise last_exception - - return wrapper - return decorator - - -def timeout_wrapper(timeout_config: Optional[TimeoutConfig] = None) -> Callable: - """ - 超时装饰器 - - Args: - timeout_config: 超时配置 - - Returns: - Callable: 装饰器函数 - """ - if timeout_config is None: - timeout_config = TimeoutConfig() - - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs): - import signal - - def timeout_handler(signum, frame): - raise TimeoutError(f"函数 {func.__name__} 执行超时") - - # 设置信号处理器 - old_handler = signal.signal(signal.SIGALRM, timeout_handler) - signal.alarm(int(timeout_config.total_timeout)) - - try: - result = func(*args, **kwargs) - signal.alarm(0) # 取消闹钟 - return result - finally: - signal.signal(signal.SIGALRM, old_handler) - - return wrapper - return decorator - - -@contextmanager -def session_with_retry(timeout_config: Optional[TimeoutConfig] = None, - retry_config: Optional[RetryConfig] = None): - """ - 创建带有重试机制的HTTP会话 - - Args: - timeout_config: 超时配置 - retry_config: 重试配置 - - Yields: - requests.Session: 配置好的会话对象 - """ - if timeout_config is None: - timeout_config = TimeoutConfig() - - if retry_config is None: - retry_config = RetryConfig() - - session = requests.Session() - - # 配置重试策略 - retry_strategy = Retry( - total=retry_config.max_retries, - backoff_factor=retry_config.backoff_factor, - status_forcelist=[429, 500, 502, 503, 504], - ) - - adapter = HTTPAdapter(max_retries=retry_strategy) - session.mount("http://", adapter) - session.mount("https://", adapter) - - # 设置默认超时 - session.timeout = (timeout_config.connect_timeout, timeout_config.read_timeout) - - try: - yield session - finally: - session.close() - - -def safe_execute(func: Callable, - fallback: Optional[Callable] = None, - exceptions: tuple = (Exception,), - logger: Optional[Any] = None) -> Any: - """ - 安全执行函数,提供降级处理 - - Args: - func: 要执行的函数 - fallback: 降级函数 - exceptions: 需要捕获的异常类型 - logger: 日志器 - - Returns: - Any: 函数执行结果或降级结果 - """ - if logger is None: - logger = get_logger() - - try: - return func() - except exceptions as e: - logger.error(f"函数执行失败: {str(e)}") - if fallback: - try: - logger.info("执行降级函数") - return fallback() - except Exception as fallback_e: - logger.error(f"降级函数也执行失败: {str(fallback_e)}") - return None - - -class HealthChecker: - """健康检查器""" - - def __init__(self, check_interval: int = 300): - """ - 初始化健康检查器 - - Args: - check_interval: 检查间隔(秒) - """ - self.check_interval = check_interval - self.last_check = None - self.is_healthy = True - self.consecutive_failures = 0 - self.max_consecutive_failures = 3 - - def check_health(self) -> bool: - """ - 执行健康检查 - - Returns: - bool: 健康状态 - """ - current_time = datetime.now() - - # 检查是否需要执行检查 - if (self.last_check and - (current_time - self.last_check).total_seconds() < self.check_interval): - return self.is_healthy - - self.last_check = current_time - - try: - # 执行健康检查逻辑 - self._perform_health_check() - self.is_healthy = True - self.consecutive_failures = 0 - logger.info("健康检查通过") - return True - except Exception as e: - self.consecutive_failures += 1 - logger.warning(f"健康检查失败 ({self.consecutive_failures}/{self.max_consecutive_failures}): {str(e)}") - - if self.consecutive_failures >= self.max_consecutive_failures: - self.is_healthy = False - logger.error("连续健康检查失败,系统标记为不健康") - - return False - - def _perform_health_check(self): - """执行具体的健康检查逻辑""" - # 这里可以添加数据库连接检查、外部服务检查等 - config = get_config() - - # 检查数据库连接(如果启用) - if config.database.enabled: - # 这里应该检查数据库连接 - pass - - # 检查网络连接 - try: - requests.get("https://www.baidu.com", timeout=5) - except: - raise Exception("网络连接检查失败") - - -# 全局实例 -_circuit_breaker = CircuitBreaker() -_idempotency_manager = IdempotencyManager() -_health_checker = HealthChecker() - - -def get_circuit_breaker() -> CircuitBreaker: - """获取全局熔断器实例""" - return _circuit_breaker - - -def get_idempotency_manager() -> IdempotencyManager: - """获取全局幂等性管理器实例""" - return _idempotency_manager - - -def get_health_checker() -> HealthChecker: - """获取全局健康检查器实例""" - return _health_checker - - -def check_system_health() -> bool: - """ - 检查系统健康状态 - - Returns: - bool: 系统是否健康 - """ - return _health_checker.check_health() diff --git a/gx_gp_monitor/crawler/__init__.py b/gx_gp_monitor/crawler/__init__.py deleted file mode 100644 index 1962ccd..0000000 --- a/gx_gp_monitor/crawler/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""爬虫模块""" diff --git a/gx_gp_monitor/crawler/dahuagov_spider.py b/gx_gp_monitor/crawler/dahuagov_spider.py deleted file mode 100644 index db69b11..0000000 --- a/gx_gp_monitor/crawler/dahuagov_spider.py +++ /dev/null @@ -1,355 +0,0 @@ -""" -大化瑶族自治县政府采购公告爬虫 -爬取大化县政府网站的采购公告页面 -""" - -import time -import random -from typing import List, Optional, Tuple -from datetime import datetime -from urllib.parse import urljoin, urlparse -from bs4 import BeautifulSoup -import requests -from fake_useragent import UserAgent - -try: - from ..core.models import Announcement, AnnouncementSource, AnnouncementType, CrawlResult, CrawlStatus - from ..core.config_manager import get_config - from ..core.logger import get_logger, log_crawl_start, log_crawl_success, log_crawl_error - from ..core.reliability import ( - retry_on_exception, RetryConfig, session_with_retry, - TimeoutConfig, safe_execute, check_system_health - ) -except ImportError: - from core.models import Announcement, AnnouncementSource, AnnouncementType, CrawlResult, CrawlStatus - from core.config_manager import get_config - from core.logger import get_logger, log_crawl_start, log_crawl_success, log_crawl_error - from core.reliability import ( - retry_on_exception, RetryConfig, session_with_retry, - TimeoutConfig, safe_execute, check_system_health - ) - - -logger = get_logger(__name__) - - -class DahuagovSpider: - """大化县政府网站爬虫""" - - # 大化县政府网站配置 - BASE_URL = "http://www.gxdh.gov.cn" - ANNOUNCEMENT_PATH = "/xxgk/zdlyxxgk/ggzypzly/zfcgly/cggg/" - - def __init__(self): - self.config = get_config() - self.ua = UserAgent() - self.session = None - self.request_count = 0 - self.error_count = 0 - - def _get_random_user_agent(self) -> str: - """获取随机User-Agent""" - try: - from fake_useragent import UserAgent - return self.ua.random - except: - return random.choice(self.config.crawler.user_agents) - - def init_session(self): - """初始化会话""" - if self.session is None: - timeout_config = TimeoutConfig( - connect_timeout=self.config.crawler.timeout, - read_timeout=self.config.crawler.timeout - ) - - retry_config = RetryConfig( - max_retries=self.config.crawler.max_retries, - initial_delay=self.config.crawler.retry_delay, - max_delay=self.config.crawler.max_retry_delay, - backoff_factor=self.config.crawler.backoff_factor - ) - - self.session = requests.Session() - - adapter = requests.adapters.HTTPAdapter( - pool_connections=10, - pool_maxsize=20, - max_retries=0 - ) - self.session.mount('http://', adapter) - self.session.mount('https://', adapter) - - self.session.timeout = (timeout_config.connect_timeout, timeout_config.read_timeout) - - return self.session - - def close_session(self): - """关闭会话""" - if self.session: - self.session.close() - self.session = None - - def _fetch_page(self, url: str) -> Tuple[Optional[str], Optional[str]]: - """ - 获取页面内容 - - Args: - url: 页面URL - - Returns: - Tuple[Optional[str], Optional[str]]: (页面内容, 错误信息) - """ - try: - session = self.init_session() - user_agent = self._get_random_user_agent() - - headers = { - "User-Agent": user_agent, - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", - "Connection": "keep-alive", - "Referer": self.BASE_URL - } - - # 添加随机延迟 - delay = random.uniform( - self.config.crawler.request_delay, - self.config.crawler.request_delay_max - ) - time.sleep(delay) - - response = session.get(url, headers=headers, timeout=self.session.timeout) - - self.request_count += 1 - - if response.status_code == 200: - # 确保使用正确的编码 - response.encoding = response.apparent_encoding or 'utf-8' - return response.text, None - else: - return None, f"请求失败, 状态码: {response.status_code}" - - except Exception as e: - self.error_count += 1 - logger.error(f"获取页面异常: {str(e)}") - return None, f"请求异常: {str(e)}" - - def _parse_page(self, html: str, crawled_at: datetime) -> List[Announcement]: - """ - 解析页面内容 - - Args: - html: 页面HTML内容 - crawled_at: 爬取时间 - - Returns: - List[Announcement]: 解析后的公告列表 - """ - announcements = [] - - try: - soup = BeautifulSoup(html, 'html.parser') - - # 查找公告列表 - # 大化县政府网站使用 ul.more-list 结构 - lists = soup.find_all('ul', class_='more-list') - - if not lists: - logger.info("未找到公告列表") - return announcements - - for ul in lists: - lis = ul.find_all('li') - - for li in lis: - try: - announcement = self._parse_li_element(li, crawled_at) - if announcement: - announcements.append(announcement) - except Exception as e: - logger.warning(f"解析单个公告失败: {str(e)}") - continue - - logger.info(f"成功解析 {len(announcements)} 条公告") - - except Exception as e: - logger.error(f"解析页面失败: {str(e)}") - - return announcements - - def _parse_li_element(self, li, crawled_at: datetime) -> Optional[Announcement]: - """ - 解析单个li元素 - - Args: - li: BeautifulSoup li元素 - crawled_at: 爬取时间 - - Returns: - Optional[Announcement]: 解析后的公告对象 - """ - try: - # 查找日期 span - date_span = li.find('span') - if not date_span: - return None - - date_text = date_span.get_text(strip=True) - if not date_text: - return None - - # 解析日期 - try: - publish_date = datetime.strptime(date_text, "%Y-%m-%d") - except ValueError: - logger.warning(f"日期格式无法解析: {date_text}") - return None - - # 查找链接和标题 - link_tag = li.find('a') - if not link_tag: - return None - - title = link_tag.get('title', '') or link_tag.get_text(strip=True) - if not title: - return None - - href = link_tag.get('href', '') - if not href: - return None - - # 构建完整URL - if href.startswith('./') or href.startswith('../'): - content_url = urljoin(self.BASE_URL + self.ANNOUNCEMENT_PATH, href) - elif href.startswith('/'): - content_url = self.BASE_URL + href - elif href.startswith('http'): - content_url = href - else: - content_url = urljoin(self.BASE_URL + self.ANNOUNCEMENT_PATH, href) - - # 创建公告对象 - announcement = Announcement( - title=title, - publish_date=publish_date, - purchase_name="大化瑶族自治县", # 默认采购单位 - content_url=content_url, - source_code="dahuagov", - source_name="大化县政府网采购公告", - announcement_type=AnnouncementType.PURCHASE, - crawled_at=crawled_at, - is_new=True - ) - - # 生成内容哈希用于去重 - announcement.generate_content_hash() - - return announcement - - except Exception as e: - logger.warning(f"解析li元素失败: {str(e)}") - return None - - @retry_on_exception(RetryConfig(max_retries=2)) - def crawl(self) -> CrawlResult: - """ - 爬取公告(只爬取第一页) - - Returns: - CrawlResult: 爬取结果 - """ - log_crawl_start("大化县政府网采购公告") - - start_time = datetime.now() - result = CrawlResult( - source=AnnouncementSource( - code="dahuagov", - category_id=0, - name="大化县政府网采购公告", - type=AnnouncementType.PURCHASE - ), - status=CrawlStatus.RUNNING, - crawled_at=start_time - ) - - try: - # 构建完整URL(只爬取第一页) - url = self.BASE_URL + self.ANNOUNCEMENT_PATH - - logger.info(f"开始爬取大化县政府网站: {url}") - - # 获取页面内容 - html, error_msg = self._fetch_page(url) - - if error_msg: - logger.warning(f"获取页面失败: {error_msg}") - result.status = CrawlStatus.FAILED - result.error_message = error_msg - return result - - if not html: - logger.info("页面内容为空") - result.status = CrawlStatus.SUCCESS - result.total_count = 0 - result.new_count = 0 - return result - - # 解析页面 - announcements = self._parse_page(html, start_time) - - # 更新结果 - result.announcements = announcements - result.total_count = len(announcements) - result.new_count = len(announcements) - result.status = CrawlStatus.SUCCESS - - duration = (datetime.now() - start_time).total_seconds() - result.duration = duration - - log_crawl_success("大化县政府网采购公告", len(announcements), duration) - - except Exception as e: - duration = (datetime.now() - start_time).total_seconds() - result.duration = duration - result.status = CrawlStatus.FAILED - result.error_message = str(e) - - log_crawl_error("大化县政府网采购公告", str(e)) - - return result - - def get_stats(self) -> dict: - """获取爬虫统计信息""" - return { - "request_count": self.request_count, - "error_count": self.error_count, - "error_rate": self.error_count / max(self.request_count, 1), - "session_active": self.session is not None - } - - -def create_dahuagov_spider() -> DahuagovSpider: - """ - 创建大化县爬虫实例 - - Returns: - DahuagovSpider: 爬虫实例 - """ - return DahuagovSpider() - - -def crawl_dahuagov_announcements() -> List[CrawlResult]: - """ - 便捷函数:爬取大化县公告 - - Returns: - List[CrawlResult]: 爬取结果列表 - """ - spider = create_dahuagov_spider() - - try: - result = spider.crawl() - return [result] - finally: - spider.close_session() diff --git a/gx_gp_monitor/crawler/parsers.py b/gx_gp_monitor/crawler/parsers.py deleted file mode 100644 index 8ed8ad5..0000000 --- a/gx_gp_monitor/crawler/parsers.py +++ /dev/null @@ -1,301 +0,0 @@ -""" -数据解析器模块 -负责解析广西政府采购网的API响应数据 -""" - -import json -from typing import List, Dict, Any, Optional -from datetime import datetime - -try: - from ..core.models import Announcement, AnnouncementSource, AnnouncementType - from ..core.logger import get_logger -except ImportError: - from core.models import Announcement, AnnouncementSource, AnnouncementType - from core.logger import get_logger - - -logger = get_logger(__name__) - - -class AnnouncementParser: - """公告数据解析器""" - - @staticmethod - def parse_api_response(response_data: Dict[str, Any], - source: AnnouncementSource, - crawled_at: datetime) -> List[Announcement]: - """ - 解析API响应数据 - - Args: - response_data: API响应数据 - source: 公告来源 - crawled_at: 爬取时间 - - Returns: - List[Announcement]: 解析后的公告列表 - """ - if not response_data or not isinstance(response_data, dict): - logger.warning("API响应数据无效") - return [] - - try: - # 检查响应状态 - if not response_data.get("success", False): - logger.warning(f"API响应失败: {response_data.get('message', '未知错误')}") - return [] - - # 获取数据部分 - result = response_data.get("result", {}) - data = result.get("data", {}) - records = data.get("data", []) - - if not records: - logger.info(f"来源 {source.name} 没有新数据") - return [] - - announcements = [] - for record in records: - try: - announcement = AnnouncementParser._parse_single_record( - record, source, crawled_at) - if announcement: - announcements.append(announcement) - except Exception as e: - logger.warning(f"解析公告记录失败: {str(e)}, 记录: {record}") - continue - - logger.info(f"成功解析 {len(announcements)}/{len(records)} 条公告记录") - return announcements - - except Exception as e: - logger.error(f"解析API响应数据失败: {str(e)}") - return [] - - @staticmethod - def _parse_single_record(record: Dict[str, Any], - source: AnnouncementSource, - crawled_at: datetime) -> Optional[Announcement]: - """ - 解析单个公告记录 - - Args: - record: 公告记录数据 - source: 公告来源 - crawled_at: 爬取时间 - - Returns: - Optional[Announcement]: 解析后的公告对象 - """ - try: - # 提取基本字段 - title_raw = record.get("title", "") - title = str(title_raw).strip() if title_raw is not None else "" - if not title: - return None - - # 解析发布时间 - publish_timestamp = record.get("publishDate") - if not publish_timestamp: - logger.warning(f"公告缺少发布时间: {title[:50]}...") - return None - - try: - # 时间戳转换为datetime - publish_date = datetime.fromtimestamp(int(publish_timestamp) / 1000) - except (ValueError, TypeError) as e: - logger.warning(f"发布时间格式错误: {publish_timestamp}, 错误: {str(e)}") - return None - - # 提取其他字段 - purchase_name_raw = record.get("purchaseName", "") - purchase_name = str(purchase_name_raw).strip() if purchase_name_raw is not None else "" - article_id = record.get("articleId") - - if not article_id: - logger.warning(f"公告缺少文章ID: {title[:50]}...") - return None - - # 构建内容链接 - content_url = AnnouncementParser._build_content_url( - source.category_id, source.code, article_id) - - # 创建公告对象 - announcement = Announcement( - title=title, - publish_date=publish_date, - purchase_name=purchase_name, - content_url=content_url, - source_code=source.code, - source_name=source.name, - announcement_type=source.type, - crawled_at=crawled_at, - is_new=True # 默认标记为新公告 - ) - - # 生成内容哈希用于去重 - announcement.generate_content_hash() - - return announcement - - except Exception as e: - logger.error(f"解析单个公告记录失败: {str(e)}") - return None - - @staticmethod - def _build_content_url(category_id: int, source_code: str, article_id: int) -> str: - """ - 构建公告内容链接 - - Args: - category_id: 分类ID - source_code: 来源代码 - article_id: 文章ID - - Returns: - str: 内容链接 - """ - return f"https://zfcg.gxzf.gov.cn/site/detail?parentId={category_id}&articleId={article_id}" - - @staticmethod - def validate_response_structure(response_data: Dict[str, Any]) -> bool: - """ - 验证API响应数据结构 - - Args: - response_data: API响应数据 - - Returns: - bool: 结构是否有效 - """ - try: - if not isinstance(response_data, dict): - return False - - # 检查必需的字段 - if "success" not in response_data: - return False - - if not response_data.get("success", False): - return False - - result = response_data.get("result", {}) - if not isinstance(result, dict): - return False - - data = result.get("data", {}) - if not isinstance(data, dict): - return False - - records = data.get("data", []) - if not isinstance(records, list): - return False - - return True - - except Exception as e: - logger.warning(f"验证响应结构失败: {str(e)}") - return False - - @staticmethod - def extract_pagination_info(response_data: Dict[str, Any]) -> Dict[str, Any]: - """ - 提取分页信息 - - Args: - response_data: API响应数据 - - Returns: - Dict[str, Any]: 分页信息 - """ - try: - result = response_data.get("result", {}) - data = result.get("data", {}) - - return { - "total": data.get("total", 0), - "page_no": data.get("pageNo", 1), - "page_size": data.get("pageSize", 15), - "pages": data.get("pages", 0), - "empty": data.get("empty", True), - "has_next": data.get("hasNext", False), - "has_previous": data.get("hasPrevious", False) - } - except Exception as e: - logger.warning(f"提取分页信息失败: {str(e)}") - return { - "total": 0, - "page_no": 1, - "page_size": 15, - "pages": 0, - "empty": True, - "has_next": False, - "has_previous": False - } - - -class SensitiveWordChecker: - """敏感词检查器""" - - @staticmethod - def parse_check_response(response_data: Dict[str, Any]) -> bool: - """ - 解析敏感词检查响应 - - Args: - response_data: 检查响应数据 - - Returns: - bool: 检查是否通过 - """ - try: - if not isinstance(response_data, dict): - logger.warning("敏感词检查响应格式无效") - return False - - success = response_data.get("success", False) - if not success: - message = response_data.get("message", "未知错误") - logger.warning(f"敏感词检查失败: {message}") - return False - - return True - - except Exception as e: - logger.error(f"解析敏感词检查响应失败: {str(e)}") - return False - - -class ErrorResponseParser: - """错误响应解析器""" - - @staticmethod - def parse_error(response_data: Dict[str, Any]) -> str: - """ - 解析错误响应 - - Args: - response_data: 错误响应数据 - - Returns: - str: 错误信息 - """ - try: - if not isinstance(response_data, dict): - return "响应格式无效" - - # 尝试提取错误信息 - error_msg = response_data.get("message") or response_data.get("msg") - if error_msg: - return str(error_msg) - - # 检查状态码 - errcode = response_data.get("errcode") - if errcode: - return f"错误码: {errcode}" - - return "未知错误" - - except Exception as e: - return f"解析错误响应失败: {str(e)}" diff --git a/gx_gp_monitor/crawler/spider.py b/gx_gp_monitor/crawler/spider.py deleted file mode 100644 index e6ca2b0..0000000 --- a/gx_gp_monitor/crawler/spider.py +++ /dev/null @@ -1,458 +0,0 @@ -""" -爬虫核心模块 -实现广西政府采购网公告的智能爬取功能 -""" - -import time -import random -import json -from typing import List, Dict, Any, Optional, Tuple -from datetime import datetime -from urllib.parse import urljoin -import requests -from fake_useragent import UserAgent - -try: - # 尝试相对导入 - from ..core.models import Announcement, AnnouncementSource, AnnouncementType, CrawlResult, CrawlStatus - from ..core.config_manager import get_config - from ..core.logger import get_logger, log_crawl_start, log_crawl_success, log_crawl_error - from ..core.reliability import ( - retry_on_exception, RetryConfig, session_with_retry, - TimeoutConfig, safe_execute, check_system_health - ) - from .parsers import AnnouncementParser, SensitiveWordChecker, ErrorResponseParser -except ImportError: - # 尝试绝对导入 - from core.models import Announcement, AnnouncementSource, AnnouncementType, CrawlResult, CrawlStatus - from core.config_manager import get_config - from core.logger import get_logger, log_crawl_start, log_crawl_success, log_crawl_error - from core.reliability import ( - retry_on_exception, RetryConfig, session_with_retry, - TimeoutConfig, safe_execute, check_system_health - ) - from crawler.parsers import AnnouncementParser, SensitiveWordChecker, ErrorResponseParser - - -logger = get_logger(__name__) - - -class GXGPSpider: - """广西政府采购网爬虫""" - - def __init__(self): - self.config = get_config() - self.ua = UserAgent() - - # API端点 - self.base_url = self.config.crawler.base_url - self.announcement_api = urljoin(self.base_url, "/portal/category") - self.sensitive_check_api = urljoin(self.base_url, "/portal/sensitiveWords/check") - - # 会话管理 - self.session = None - - # 统计信息 - self.request_count = 0 - self.error_count = 0 - - def init_session(self): - """初始化会话""" - if self.session is None: - timeout_config = TimeoutConfig( - connect_timeout=self.config.crawler.timeout, - read_timeout=self.config.crawler.timeout - ) - - retry_config = RetryConfig( - max_retries=self.config.crawler.max_retries, - initial_delay=self.config.crawler.retry_delay, - max_delay=self.config.crawler.max_retry_delay, - backoff_factor=self.config.crawler.backoff_factor - ) - - self.session = requests.Session() - - # 配置重试和超时 - adapter = requests.adapters.HTTPAdapter( - pool_connections=10, - pool_maxsize=20, - max_retries=0 # 我们使用自己的重试逻辑 - ) - self.session.mount('http://', adapter) - self.session.mount('https://', adapter) - - # 设置默认超时 - self.session.timeout = (timeout_config.connect_timeout, timeout_config.read_timeout) - - return self.session - - def close_session(self): - """关闭会话""" - if self.session: - self.session.close() - self.session = None - - def get_random_user_agent(self) -> str: - """获取随机User-Agent""" - try: - return self.ua.random - except: - # fallback到配置的user agents - return random.choice(self.config.crawler.user_agents) - - def get_random_proxy(self) -> Optional[Dict[str, str]]: - """获取随机代理""" - if not self.config.crawler.proxies: - return None - - proxy = random.choice(self.config.crawler.proxies) - return { - "http": proxy, - "https": proxy - } - - def check_sensitive_words(self, payload: Dict[str, Any], - category_code: str, childrencode: str) -> bool: - """ - 执行敏感词检查 - - Args: - payload: 请求参数 - category_code: 分类代码 - childrencode: 子分类代码 - - Returns: - bool: 检查是否通过 - """ - try: - session = self.init_session() - user_agent = self.get_random_user_agent() - - headers = { - "User-Agent": user_agent, - "Content-Type": "application/json;charset=UTF-8", - "Origin": self.base_url, - "Referer": f"{self.base_url}/site/category?parentId={category_code}&childrenCode={childrencode}", - "Cookie": "_zcy_log_client_uuid=71e283e0-23d2-11f0-844a-eb67dfa7ab64" - } - - proxies = self.get_random_proxy() - - logger.debug(f"执行敏感词检查: {category_code}/{childrencode}") - - response = session.post( - self.sensitive_check_api, - json=payload, - headers=headers, - proxies=proxies, - timeout=self.session.timeout - ) - - self.request_count += 1 - - if response.status_code == 200: - result = response.json() - return SensitiveWordChecker.parse_check_response(result) - else: - logger.warning(f"敏感词检查请求失败, 状态码: {response.status_code}") - return False - - except Exception as e: - logger.error(f"敏感词检查异常: {str(e)}") - self.error_count += 1 - return False - - def fetch_announcements_page(self, source: AnnouncementSource, - page_no: int = 1) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - """ - 获取公告列表页数据 - - Args: - source: 公告来源 - page_no: 页码 - - Returns: - Tuple[Optional[Dict[str, Any]], Optional[str]]: (响应数据, 错误信息) - """ - try: - session = self.init_session() - - # 构建请求参数 - payload = { - "keyword": "", # 关键词筛选,我们在筛选模块处理 - "publishDateBegin": self.config.crawler.start_date or "", - "publishDateEnd": self.config.crawler.end_date or "", - "pageNo": page_no, - "pageSize": self.config.crawler.page_size, - "categoryCode": source.code, - "_t": int(time.time() * 1000) - } - - # 先执行敏感词检查 - if not self.check_sensitive_words(payload, str(source.category_id), source.code): - return None, "敏感词检查失败" - - # 执行公告数据请求 - user_agent = self.get_random_user_agent() - headers = { - "User-Agent": user_agent, - "Content-Type": "application/json;charset=UTF-8", - "Origin": self.base_url, - "Referer": f"{self.base_url}/site/category?parentId={source.category_id}&childrenCode={source.code}", - "Cookie": "_zcy_log_client_uuid=71e283e0-23d2-11f0-844a-eb67dfa7ab64" - } - - proxies = self.get_random_proxy() - - logger.debug(f"请求公告数据: {source.name} 第{page_no}页") - - # 添加请求间延迟 - if page_no > 1: - delay = random.uniform( - self.config.crawler.request_delay, - self.config.crawler.request_delay_max - ) - time.sleep(delay) - - response = session.post( - self.announcement_api, - json=payload, - headers=headers, - proxies=proxies, - timeout=self.session.timeout - ) - - self.request_count += 1 - - if response.status_code == 200: - data = response.json() - if data.get("success", False): - return data, None - else: - error_msg = ErrorResponseParser.parse_error(data) - return None, f"API返回失败: {error_msg}" - else: - return None, f"请求失败, 状态码: {response.status_code}" - - except requests.exceptions.Timeout as e: - self.error_count += 1 - return None, f"请求超时: {str(e)}" - except requests.exceptions.ProxyError as e: - self.error_count += 1 - return None, f"代理错误: {str(e)}" - except Exception as e: - self.error_count += 1 - logger.error(f"获取公告数据异常: {str(e)}") - return None, f"请求异常: {str(e)}" - - def crawl_source(self, source: AnnouncementSource, - max_pages: Optional[int] = None) -> CrawlResult: - """ - 爬取单个来源的公告 - - Args: - source: 公告来源 - max_pages: 最大页数限制 - - Returns: - CrawlResult: 爬取结果 - """ - if max_pages is None: - max_pages = self.config.crawler.max_pages - - log_crawl_start(source.name) - - start_time = datetime.now() - result = CrawlResult( - source=source, - status=CrawlStatus.RUNNING, - crawled_at=start_time - ) - - try: - page = 1 - all_announcements = [] - - while page <= max_pages: - # 获取页面数据 - response_data, error_msg = self.fetch_announcements_page(source, page) - - if error_msg: - logger.warning(f"{source.name} 第{page}页获取失败: {error_msg}") - result.status = CrawlStatus.FAILED - result.error_message = error_msg - break - - if not response_data: - logger.info(f"{source.name} 第{page}页无数据") - break - - # 解析分页信息 - pagination = AnnouncementParser.extract_pagination_info(response_data) - result.total_count = pagination["total"] - - # 解析公告数据 - announcements = AnnouncementParser.parse_api_response( - response_data, source, start_time) - - if not announcements: - logger.info(f"{source.name} 第{page}页解析到0条公告") - break - - all_announcements.extend(announcements) - - # 检查是否还有下一页 - if not pagination["has_next"] or pagination["empty"]: - break - - page += 1 - - # 更新结果 - result.announcements = all_announcements - result.new_count = len(all_announcements) # 这里的新增数需要在筛选后确定 - - if result.status != CrawlStatus.FAILED: - result.status = CrawlStatus.SUCCESS - - duration = (datetime.now() - start_time).total_seconds() - result.duration = duration - - log_crawl_success(source.name, len(all_announcements), duration) - - except Exception as e: - duration = (datetime.now() - start_time).total_seconds() - result.duration = duration - result.status = CrawlStatus.FAILED - result.error_message = str(e) - - log_crawl_error(source.name, str(e)) - - return result - - @retry_on_exception(RetryConfig(max_retries=2)) - def crawl_all_sources(self, sources: Optional[List[AnnouncementSource]] = None) -> List[CrawlResult]: - """ - 爬取所有来源的公告 - - Args: - sources: 指定的来源列表,如果为None则使用配置中的所有来源 - - Returns: - List[CrawlResult]: 所有来源的爬取结果 - """ - # 系统健康检查 - if not check_system_health(): - logger.error("系统健康检查失败,跳过爬取") - return [] - - if sources is None: - sources = self._load_sources_from_config() - - logger.info(f"开始爬取 {len(sources)} 个公告来源") - - results = [] - - for source in sources: - try: - result = self.crawl_source(source) - results.append(result) - - # 检查是否需要暂停 - if result.status == CrawlStatus.FAILED: - logger.warning(f"来源 {source.name} 爬取失败,继续下一个来源") - continue - - except Exception as e: - logger.error(f"爬取来源 {source.name} 时发生未预期错误: {str(e)}") - # 创建失败结果 - failed_result = CrawlResult( - source=source, - status=CrawlStatus.FAILED, - error_message=str(e), - crawled_at=datetime.now() - ) - results.append(failed_result) - - # 统计总结果 - total_announcements = sum(len(r.announcements) for r in results if r.status == CrawlStatus.SUCCESS) - success_count = sum(1 for r in results if r.status == CrawlStatus.SUCCESS) - failed_count = len(results) - success_count - - logger.info( - f"爬取完成: 共处理 {len(results)} 个来源," - f"成功 {success_count} 个,失败 {failed_count} 个," - f"获取 {total_announcements} 条公告" - ) - - return results - - def _load_sources_from_config(self) -> List[AnnouncementSource]: - """从配置加载公告来源""" - sources = [] - - for code, source_config in self.config.sources.items(): - try: - source = AnnouncementSource( - code=code, - category_id=source_config["category_id"], - name=source_config["name"], - type=AnnouncementType(source_config["type"]) - ) - sources.append(source) - except Exception as e: - logger.warning(f"加载来源配置失败 {code}: {str(e)}") - continue - - return sources - - def get_stats(self) -> Dict[str, Any]: - """获取爬虫统计信息""" - return { - "request_count": self.request_count, - "error_count": self.error_count, - "error_rate": self.error_count / max(self.request_count, 1), - "session_active": self.session is not None - } - - def reset_stats(self): - """重置统计信息""" - self.request_count = 0 - self.error_count = 0 - - -def create_spider() -> GXGPSpider: - """ - 创建爬虫实例 - - Returns: - GXGPSpider: 爬虫实例 - """ - return GXGPSpider() - - -def crawl_announcements(keywords: Optional[List[str]] = None, - sources: Optional[List[str]] = None) -> List[CrawlResult]: - """ - 便捷函数:爬取公告 - - Args: - keywords: 关键词过滤(暂时未使用,在筛选模块处理) - sources: 来源代码列表 - - Returns: - List[CrawlResult]: 爬取结果 - """ - spider = create_spider() - - try: - # 过滤来源 - if sources: - all_sources = spider._load_sources_from_config() - filtered_sources = [s for s in all_sources if s.code in sources] - else: - filtered_sources = None - - return spider.crawl_all_sources(filtered_sources) - finally: - spider.close_session() diff --git a/gx_gp_monitor/cron_crawl.py b/gx_gp_monitor/cron_crawl.py deleted file mode 100755 index 5dd9d53..0000000 --- a/gx_gp_monitor/cron_crawl.py +++ /dev/null @@ -1,239 +0,0 @@ -#!/usr/bin/env python3 -""" -定时搜索脚本 -执行搜索、筛选关键词、保存到数据库并发送企业微信卡片通知 -""" - -import sys -import os -from pathlib import Path - -# 添加项目根目录到路径(gx_gp_monitor的父目录) -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -try: - from gx_gp_monitor.core.config_manager import load_config, get_config - from gx_gp_monitor.core.logger import init_logger, get_logger - from gx_gp_monitor.crawler.spider import crawl_announcements - from gx_gp_monitor.crawler.dahuagov_spider import crawl_dahuagov_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, save_auto_announcements_to_storage - from gx_gp_monitor.notification.wechat import send_announcements_notification, send_system_notification - - logger = get_logger(__name__) - - def main(): - """主函数:执行定时搜索任务""" - try: - # 加载配置 - config = load_config() - if not config: - logger.error("无法加载配置") - return False - - # 初始化日志 - init_logger(config=config) - - logger.info("=== 开始定时搜索任务 ===") - - # 初始化存储 - init_storage() - - # 导入数据库模块 - import gx_gp_monitor.core.database as db_module - db_manager = db_module.get_database_manager() - - # 执行搜索(爬取所有公告) - logger.info("开始执行定时搜索任务") - - # 收集所有爬取结果 - all_crawl_results = [] - - # 1. 爬取广西政府采购网 - logger.info("开始爬取广西政府采购网...") - gxgp_results = crawl_announcements() - if gxgp_results: - all_crawl_results.extend(gxgp_results) - logger.info(f"广西政府采购网爬取完成,获取 {sum(len(r.announcements) for r in gxgp_results)} 条公告") - - # 2. 爬取大化县政府网采购公告(全部推送,不筛选) - logger.info("开始爬取大化县政府网采购公告(全部推送)...") - dahua_results = crawl_dahuagov_announcements() - if dahua_results: - all_crawl_results.extend(dahua_results) - logger.info(f"大化县政府网爬取完成,获取 {sum(len(r.announcements) for r in dahua_results)} 条公告") - - if not all_crawl_results: - logger.info("爬取完成:无数据") - return True - - # 收集所有公告 - all_announcements = [] - for result in all_crawl_results: - if result.announcements: - all_announcements.extend(result.announcements) - - total_crawled = len(all_announcements) - logger.info(f"搜索到 {total_crawled} 条原始公告") - - if not all_announcements: - logger.info("没有获取到任何公告") - return True - - # 分离广西政府采购网和大化县政府网的公告 - gxgp_all_announcements = [a for a in all_announcements if a.source_code != 'dahuagov'] - dahua_all_announcements = [a for a in all_announcements if a.source_code == 'dahuagov'] - - logger.info(f"广西政府采购网: {len(gxgp_all_announcements)} 条") - logger.info(f"大化县政府网: {len(dahua_all_announcements)} 条") - - # ========== 处理广西政府采购网(关键词筛选)========== - gxgp_filtered = [] - if gxgp_all_announcements: - # 对广西政府采购网公告进行关键词筛选 - from gx_gp_monitor.filters.filters import KeywordFilter, DateFilter - from datetime import date - - keyword_filter = KeywordFilter() - gxgp_keyword_filtered = keyword_filter.filter_announcements( - gxgp_all_announcements, keywords=config.crawler.keyword) - - # 日期筛选(只处理今天的) - date_filter = DateFilter() - gxgp_today_filtered = date_filter.filter_announcements( - gxgp_keyword_filtered, - start_date=date.today(), - end_date=date.today() - ) - - logger.info(f"广西政府采购网关键词筛选后: {len(gxgp_keyword_filtered)} 条") - logger.info(f"广西政府采购网今日匹配: {len(gxgp_today_filtered)} 条") - - # 检查是否已存在 - for ann in gxgp_today_filtered: - try: - with db_module.get_db_cursor() as cursor: - cursor.execute( - "SELECT 1 FROM auto_announcements WHERE content_hash = %s LIMIT 1", - (ann.content_hash,) - ) - exists = cursor.fetchone() is not None - if not exists: - gxgp_filtered.append(ann) - except Exception as e: - logger.warning(f"检查公告是否存在失败: {str(e)}") - pass - - logger.info(f"广西政府采购网新增公告: {len(gxgp_filtered)} 条") - - # ========== 处理大化县政府网(全部推送,不筛选)========== - dahua_new_announcements = [] - if dahua_all_announcements: - # 大化县公告不需要关键词筛选,直接检查是否已存在于dahuagov_announcements表 - for ann in dahua_all_announcements: - try: - with db_module.get_db_cursor() as cursor: - cursor.execute( - "SELECT 1 FROM dahuagov_announcements WHERE content_hash = %s LIMIT 1", - (ann.content_hash,) - ) - exists = cursor.fetchone() is not None - if not exists: - # 标记为新公告 - ann.is_new = True - dahua_new_announcements.append(ann) - except Exception as e: - logger.warning(f"检查大化县公告是否存在失败: {str(e)}") - pass - - logger.info(f"大化县政府网新增公告: {len(dahua_new_announcements)} 条") - - # 如果没有新增公告,直接结束 - if not gxgp_filtered and not dahua_new_announcements: - logger.info("没有新增公告,任务完成") - return True - - # ========== 保存到数据库 ========== - # 保存广西政府采购网公告 - if gxgp_filtered: - saved_gxgp = save_auto_announcements_to_storage(gxgp_filtered) - logger.info(f"保存广西政府采购网公告: {saved_gxgp} 条") - - # 保存大化县政府网公告到专用表 - if dahua_new_announcements: - saved_dahua = db_manager.save_dahuagov_announcements(dahua_new_announcements) - logger.info(f"保存大化县政府网公告: {saved_dahua} 条") - - # ========== 发送企业微信通知 ========== - if config.wechat_app.enabled: - logger.info("开始发送企业微信卡片通知...") - notify_success = True - - # 发送广西政府采购网通知 - if gxgp_filtered: - logger.info(f"发送广西政府采购网通知,共 {len(gxgp_filtered)} 条...") - gxgp_success = send_announcements_notification(gxgp_filtered) - if gxgp_success: - logger.info("广西政府采购网通知发送成功") - else: - logger.error("广西政府采购网通知发送失败") - notify_success = False - - # 发送大化县政府网通知 - if dahua_new_announcements: - logger.info(f"发送大化县政府网通知,共 {len(dahua_new_announcements)} 条...") - dahua_success = send_announcements_notification(dahua_new_announcements) - if dahua_success: - logger.info("大化县政府网通知发送成功") - # 标记为已发送 - db_manager.mark_dahuagov_announcements_sent(dahua_new_announcements) - else: - logger.error("大化县政府网通知发送失败") - notify_success = False - - else: - logger.info("企业微信通知未启用,跳过发送") - notify_success = True - - # ========== 输出统计信息 ========== - print("\n=== 定时搜索任务完成 ===") - print(f"总共爬取: {total_crawled} 条公告") - print(f"广西政府采购网:") - print(f" - 爬取: {len(gxgp_all_announcements)} 条") - print(f" - 关键词匹配: {len(gxgp_filtered)} 条") - print(f"大化县政府网:") - print(f" - 爬取: {len(dahua_all_announcements)} 条") - print(f" - 新增推送: {len(dahua_new_announcements)} 条") - print(f"企业微信通知: {'成功' if notify_success else '失败' if config.wechat_app.enabled else '未启用'}") - - logger.info("=== 定时搜索任务完成 ===") - return True - - except Exception as e: - logger.error(f"定时搜索任务执行失败: {str(e)}") - print(f"❌ 定时搜索任务失败: {str(e)}", file=sys.stderr) - - # 尝试发送错误通知 - try: - if config and config.wechat_app.enabled: - send_system_notification( - "定时搜索任务失败", - f"错误信息: {str(e)}" - ) - except Exception as notify_error: - logger.error(f"发送错误通知失败: {notify_error}") - - return False - - if __name__ == "__main__": - success = main() - sys.exit(0 if success else 1) - -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) diff --git a/gx_gp_monitor/filters/__init__.py b/gx_gp_monitor/filters/__init__.py deleted file mode 100644 index 3c23a98..0000000 --- a/gx_gp_monitor/filters/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""筛选模块""" diff --git a/gx_gp_monitor/filters/filters.py b/gx_gp_monitor/filters/filters.py deleted file mode 100644 index d815381..0000000 --- a/gx_gp_monitor/filters/filters.py +++ /dev/null @@ -1,471 +0,0 @@ -""" -智能筛选模块 -提供关键词过滤、日期范围筛选、自动去重等功能 -""" - -from typing import List, Optional, Dict, Any, Set -from datetime import datetime, date -from dataclasses import dataclass -import re - -try: - from ..core.models import Announcement - from ..core.logger import get_logger - from ..core.database import get_database_manager -except ImportError: - from core.models import Announcement - from core.logger import get_logger - from core.database import get_database_manager - - -logger = get_logger(__name__) - - -@dataclass -class FilterCriteria: - """筛选条件""" - keywords: List[str] = None # 关键词列表 - start_date: Optional[date] = None # 开始日期 - end_date: Optional[date] = None # 结束日期 - sources: List[str] = None # 来源代码列表 - exclude_today: bool = False # 排除今日公告 - case_sensitive: bool = False # 关键词匹配是否区分大小写 - fuzzy_match: bool = False # 是否启用模糊匹配 - - def __post_init__(self): - if self.keywords is None: - self.keywords = [] - if self.sources is None: - self.sources = [] - - -@dataclass -class FilterResult: - """筛选结果""" - total_count: int = 0 # 总数 - filtered_count: int = 0 # 筛选后数量 - keyword_filtered: int = 0 # 关键词筛选数量 - date_filtered: int = 0 # 日期筛选数量 - duplicate_filtered: int = 0 # 去重筛选数量 - source_filtered: int = 0 # 来源筛选数量 - today_excluded: int = 0 # 排除今日数量 - - -class KeywordFilter: - """关键词筛选器""" - - def __init__(self, case_sensitive: bool = False, fuzzy_match: bool = False): - """ - 初始化关键词筛选器 - - Args: - case_sensitive: 是否区分大小写 - fuzzy_match: 是否启用模糊匹配 - """ - self.case_sensitive = case_sensitive - self.fuzzy_match = fuzzy_match - self._compiled_patterns = {} - - def _compile_patterns(self, keywords: List[str]): - """编译关键词模式""" - flags = 0 if self.case_sensitive else re.IGNORECASE - - for keyword in keywords: - if keyword not in self._compiled_patterns: - if self.fuzzy_match: - # 简单的模糊匹配:将关键词中的空格替换为灵活匹配 - pattern = r'.*'.join(re.escape(word) for word in keyword.split()) - else: - pattern = re.escape(keyword) - - self._compiled_patterns[keyword] = re.compile(pattern, flags) - - def matches(self, text: str, keywords: List[str]) -> bool: - """ - 检查文本是否匹配关键词 - - Args: - text: 要检查的文本 - keywords: 关键词列表 - - Returns: - bool: 是否匹配 - """ - if not keywords or not text: - return True # 没有关键词或文本时,认为匹配 - - if not self.fuzzy_match: - # 精确匹配 - search_text = text if self.case_sensitive else text.lower() - search_keywords = keywords if self.case_sensitive else [k.lower() for k in keywords] - - for keyword in search_keywords: - if keyword in search_text: - return True - return False - else: - # 正则匹配 - self._compile_patterns(keywords) - for keyword in keywords: - pattern = self._compiled_patterns[keyword] - if pattern.search(text): - return True - return False - - def filter_announcements(self, announcements: List[Announcement], - keywords: List[str]) -> List[Announcement]: - """ - 筛选匹配关键词的公告 - - Args: - announcements: 公告列表 - keywords: 关键词列表 - - Returns: - List[Announcement]: 筛选后的公告列表 - """ - if not keywords: - # 没有关键词时,所有公告都匹配 - for announcement in announcements: - announcement.keyword_matched = True - return announcements - - filtered = [] - for announcement in announcements: - # 构建搜索文本 - search_text = f"{announcement.title} {announcement.purchase_name}" - - if self.matches(search_text, keywords): - announcement.keyword_matched = True - filtered.append(announcement) - else: - announcement.keyword_matched = False - - logger.info(f"关键词筛选: {len(announcements)} -> {len(filtered)} 条公告") - return filtered - - -class DateFilter: - """日期筛选器""" - - def filter_announcements(self, announcements: List[Announcement], - start_date: Optional[date] = None, - end_date: Optional[date] = None, - exclude_today: bool = False) -> List[Announcement]: - """ - 筛选日期范围内的公告 - - Args: - announcements: 公告列表 - start_date: 开始日期 - end_date: 结束日期 - exclude_today: 是否排除今日公告 - - Returns: - List[Announcement]: 筛选后的公告列表 - """ - if not start_date and not end_date and not exclude_today: - # 没有日期限制时,所有公告都通过 - for announcement in announcements: - announcement.date_filtered = True - return announcements - - filtered = [] - today = date.today() - - for announcement in announcements: - if not announcement.publish_date: - # 没有发布日期的公告不通过筛选 - announcement.date_filtered = False - continue - - publish_date = announcement.publish_date.date() - - # 检查日期范围 - date_in_range = True - - if start_date and publish_date < start_date: - date_in_range = False - - if end_date and publish_date > end_date: - date_in_range = False - - # 检查是否排除今日 - if exclude_today and publish_date == today: - date_in_range = False - - announcement.date_filtered = date_in_range - - if date_in_range: - filtered.append(announcement) - - logger.info(f"日期筛选: {len(announcements)} -> {len(filtered)} 条公告") - return filtered - - -class DuplicateFilter: - """去重筛选器""" - - def __init__(self, use_database: bool = True): - """ - 初始化去重筛选器 - - Args: - use_database: 是否使用数据库检查重复 - """ - self.use_database = use_database - self.db_manager = get_database_manager() if use_database else None - - def filter_announcements(self, announcements: List[Announcement]) -> List[Announcement]: - """ - 去除重复的公告 - - Args: - announcements: 公告列表 - - Returns: - List[Announcement]: 去重后的公告列表 - """ - if not announcements: - return announcements - - seen_hashes = set() - filtered = [] - - for announcement in announcements: - # 生成内容哈希 - if not announcement.content_hash: - announcement.generate_content_hash() - - content_hash = announcement.content_hash - - # 检查是否已存在 - is_duplicate = False - - if self.use_database and self.db_manager: - # 数据库检查 - is_duplicate = self.db_manager.is_announcement_exists(content_hash) - else: - # 内存检查 - if content_hash in seen_hashes: - is_duplicate = True - seen_hashes.add(content_hash) - - if not is_duplicate: - filtered.append(announcement) - # 注意:这里不设置is_new标志,因为这是在爬取后的筛选阶段 - - duplicate_count = len(announcements) - len(filtered) - if duplicate_count > 0: - logger.info(f"去重筛选: 移除了 {duplicate_count} 条重复公告") - - return filtered - - -class SourceFilter: - """来源筛选器""" - - def filter_announcements(self, announcements: List[Announcement], - allowed_sources: List[str]) -> List[Announcement]: - """ - 筛选指定来源的公告 - - Args: - announcements: 公告列表 - allowed_sources: 允许的来源代码列表 - - Returns: - List[Announcement]: 筛选后的公告列表 - """ - if not allowed_sources: - return announcements - - filtered = [] - for announcement in announcements: - if announcement.source_code in allowed_sources: - filtered.append(announcement) - - logger.info(f"来源筛选: {len(announcements)} -> {len(filtered)} 条公告") - return filtered - - -class AnnouncementFilter: - """公告智能筛选器""" - - def __init__(self, criteria: FilterCriteria = None): - """ - 初始化公告筛选器 - - Args: - criteria: 筛选条件 - """ - self.criteria = criteria or FilterCriteria() - - # 初始化各个筛选器 - self.keyword_filter = KeywordFilter( - case_sensitive=self.criteria.case_sensitive, - fuzzy_match=self.criteria.fuzzy_match - ) - self.date_filter = DateFilter() - self.duplicate_filter = DuplicateFilter() - self.source_filter = SourceFilter() - - def filter(self, announcements: List[Announcement]) -> tuple[List[Announcement], FilterResult]: - """ - 执行完整的筛选流程 - - Args: - announcements: 原始公告列表 - - Returns: - tuple: (筛选后的公告列表, 筛选结果统计) - """ - result = FilterResult() - result.total_count = len(announcements) - - # 1. 来源筛选 - if self.criteria.sources: - announcements = self.source_filter.filter_announcements( - announcements, self.criteria.sources) - result.source_filtered = result.total_count - len(announcements) - - # 2. 去重筛选 - announcements = self.duplicate_filter.filter_announcements(announcements) - result.duplicate_filtered = result.total_count - len(announcements) - result.source_filtered - - # 3. 日期筛选 - announcements = self.date_filter.filter_announcements( - announcements, - self.criteria.start_date, - self.criteria.end_date, - self.criteria.exclude_today - ) - result.date_filtered = result.total_count - len(announcements) - result.source_filtered - result.duplicate_filtered - - # 4. 关键词筛选 - if self.criteria.keywords: - announcements = self.keyword_filter.filter_announcements( - announcements, self.criteria.keywords) - result.keyword_filtered = result.total_count - len(announcements) - result.source_filtered - result.duplicate_filtered - result.date_filtered - - result.filtered_count = len(announcements) - - logger.info( - f"筛选完成: 总数 {result.total_count} -> 筛选后 {result.filtered_count} " - f"(关键词: {result.keyword_filtered}, 日期: {result.date_filtered}, " - f"去重: {result.duplicate_filtered}, 来源: {result.source_filtered})" - ) - - return announcements, result - - def quick_filter(self, announcement: Announcement) -> bool: - """ - 快速筛选单个公告(用于实时筛选) - - Args: - announcement: 公告对象 - - Returns: - bool: 是否通过筛选 - """ - # 检查来源 - if self.criteria.sources and announcement.source_code not in self.criteria.sources: - return False - - # 检查日期 - if not announcement.in_date_range( - self.criteria.start_date.isoformat() if self.criteria.start_date else None, - self.criteria.end_date.isoformat() if self.criteria.end_date else None - ): - return False - - # 检查关键词 - if self.criteria.keywords: - search_text = f"{announcement.title} {announcement.purchase_name}" - if not self.keyword_filter.matches(search_text, self.criteria.keywords): - return False - - return True - - def update_criteria(self, criteria: FilterCriteria): - """ - 更新筛选条件 - - Args: - criteria: 新的筛选条件 - """ - self.criteria = criteria - - # 重新初始化筛选器 - self.keyword_filter = KeywordFilter( - case_sensitive=self.criteria.case_sensitive, - fuzzy_match=self.criteria.fuzzy_match - ) - - -def create_default_filter(keywords: List[str] = None, - start_date: str = None, - end_date: str = None) -> AnnouncementFilter: - """ - 创建默认筛选器 - - Args: - keywords: 关键词列表 - start_date: 开始日期字符串 (YYYY-MM-DD) - end_date: 结束日期字符串 (YYYY-MM-DD) - - Returns: - AnnouncementFilter: 筛选器实例 - """ - criteria = FilterCriteria() - - if keywords: - criteria.keywords = keywords - - if start_date: - try: - criteria.start_date = datetime.fromisoformat(start_date).date() - except ValueError: - logger.warning(f"无效的开始日期格式: {start_date}") - - if end_date: - try: - criteria.end_date = datetime.fromisoformat(end_date).date() - except ValueError: - logger.warning(f"无效的结束日期格式: {end_date}") - - return AnnouncementFilter(criteria) - - -def filter_from_config() -> AnnouncementFilter: - """ - 从配置文件创建筛选器 - - Returns: - AnnouncementFilter: 配置化的筛选器 - """ - try: - from ..core.config_manager import get_config - except ImportError: - from core.config_manager import get_config - - config = get_config() - - criteria = FilterCriteria() - criteria.keywords = config.crawler.keyword - criteria.sources = list(config.sources.keys()) # 默认包含所有来源 - - # 日期范围 - if config.crawler.start_date: - try: - criteria.start_date = datetime.fromisoformat(config.crawler.start_date).date() - except ValueError: - pass - - if config.crawler.end_date: - try: - criteria.end_date = datetime.fromisoformat(config.crawler.end_date).date() - except ValueError: - pass - - return AnnouncementFilter(criteria) diff --git a/gx_gp_monitor/main.py b/gx_gp_monitor/main.py deleted file mode 100644 index add7d56..0000000 --- a/gx_gp_monitor/main.py +++ /dev/null @@ -1,505 +0,0 @@ -#!/usr/bin/env python3 -""" -广西政府采购网公告监控系统主程序 -广西政府采购网公告爬取和监控的智能系统 -""" - -import sys -import argparse -import signal -from pathlib import Path -from typing import Dict, Any - -# 添加项目根目录到Python路径 -project_root = Path(__file__).parent -sys.path.insert(0, str(project_root)) - -try: - # 尝试相对导入 - from .core.config_manager import load_config, get_config - from .core.logger import init_logger, get_logger - 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, 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 - from .wechat.menu_manager import WeChatMenuManager -except ImportError: - try: - # 尝试绝对导入(直接运行脚本时) - from core.config_manager import load_config, get_config - from core.logger import init_logger, get_logger - 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.md_generator import generate_onu_md - from notification.wechat import send_announcements_notification, send_system_notification - # 企业微信模块动态导入,避免循环导入问题 - wechat_available = True - try: - from wechat.callback_server import get_callback_server - from wechat.menu_manager import WeChatMenuManager - except ImportError: - wechat_available = False - print("企业微信模块不可用", file=sys.stderr) - except ImportError as e: - print(f"导入错误: {e}", file=sys.stderr) - print("请确保依赖已正确安装: pip install -r requirements.txt", file=sys.stderr) - sys.exit(1) - - -logger = get_logger(__name__) - - -class GXGPMonitorApp: - """广西政府采购网监控系统应用""" - - def __init__(self): - self.config = None - self.logger = None - self.running = False - - def initialize(self, config_file: str = None): - """初始化应用""" - try: - # 加载配置 - self.config = load_config(config_file) - - # 初始化日志 - self.logger = init_logger(config=self.config) - - logger.info("=== 广西政府采购网公告监控系统启动 ===") - logger.info(f"版本: 1.0.0") - logger.info(f"配置文件: {config_file or '默认配置'}") - - # 系统健康检查 - if not check_system_health(): - logger.warning("系统健康检查失败,但继续运行") - - # 初始化存储 - init_storage() - - logger.info("应用初始化完成") - return True - - except Exception as e: - print(f"应用初始化失败: {str(e)}", file=sys.stderr) - return False - - def run_crawl(self, keywords: list = None, sources: list = None, max_pages: int = None, manual_crawl: bool = False): - """执行搜索任务 - - Args: - keywords: 关键词列表 - sources: 来源列表 - max_pages: 最大页数 - manual_crawl: 是否为手动搜索(只筛选今天的数据) - """ - try: - logger.info(f"开始执行搜索任务 (手动搜索: {manual_crawl})") - - # 执行爬取 - crawl_results = crawl_announcements(keywords, sources) - - if not crawl_results: - logger.info("搜索完成:无数据") - return {"success": True, "results": []} - - # 收集所有公告 - all_announcements = [] - for result in crawl_results: - if result.announcements: - all_announcements.extend(result.announcements) - - logger.info(f"搜索到 {len(all_announcements)} 条原始公告") - - # 保存公告到对应的专用表 - if not manual_crawl: - # 自动爬取:保存到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}") - else: - # 手动搜索:保存到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: - # 手动搜索:只筛选今天的公告和用户指定的关键词,不进行去重 - from .filters.filters import KeywordFilter, DateFilter, SourceFilter - from datetime import date - - # 1. 日期筛选:只保留今天的公告 - date_filter = DateFilter() - date_filtered = date_filter.filter_announcements(all_announcements, start_date=date.today(), end_date=date.today()) - - # 2. 关键词筛选 - keyword_filter = KeywordFilter() - keyword_filtered = keyword_filter.filter_announcements(date_filtered, keywords=keywords or []) - - # 3. 来源筛选 - source_filter = SourceFilter() - filtered_announcements = source_filter.filter_announcements(keyword_filtered, sources or list(self.config.sources.keys())) - - # 计算统计信息 - filter_stats = type('FilterResult', (), { - "keyword_filtered": len(date_filtered) - len(keyword_filtered), - "date_filtered": len(all_announcements) - len(date_filtered), - "duplicate_filtered": 0, # 手动搜索不进行去重 - "source_filtered": len(keyword_filtered) - len(filtered_announcements) - })() - else: - # 自动爬取:筛选出新公告并应用关键词筛选 - # 1. 筛选出数据库中没有的新公告 - new_announcements = [ann for ann in all_announcements if ann.is_new] - logger.info(f"筛选出 {len(new_announcements)} 条新公告") - - # 2. 对新公告应用关键词筛选等 - if new_announcements: - filter_obj = filter_from_config() - filtered_announcements, filter_stats = filter_obj.filter(new_announcements) - # 更新统计信息,加上未筛选的新公告数量 - filter_stats.keyword_filtered += len(new_announcements) - len(filtered_announcements) - else: - filtered_announcements = [] - filter_stats = type('FilterResult', (), { - "keyword_filtered": 0, - "date_filtered": 0, - "duplicate_filtered": len(all_announcements), - "source_filtered": 0 - })() - - logger.info(f"筛选后剩余 {len(filtered_announcements)} 条公告") - - # 对于手动爬取,不保存筛选后的公告到数据库 - if not manual_crawl: - # 保存筛选后的公告(用于标记关键词匹配等) - saved_count = save_announcements_to_storage(filtered_announcements) - else: - saved_count = 0 - logger.info("手动爬取模式:跳过筛选后公告的数据库保存") - - # 生成Markdown文件(只在自动爬取时生成) - md_success = False - if not manual_crawl: - md_success = generate_onu_md(filtered_announcements) - - # 发送通知 - notify_success = False - if not manual_crawl and filtered_announcements and self.config.wechat_app.enabled: - # 自动爬取时发送卡片消息 - notify_success = send_announcements_notification(filtered_announcements) - elif manual_crawl and filtered_announcements and self.config.wechat_app.enabled: - # 手动爬取时不在这里发送消息,由消息处理器负责发送markdown消息 - notify_success = True # 标记为成功,因为消息会通过其他方式发送 - - result = { - "success": True, - "total_crawled": len(all_announcements), - "filtered": len(filtered_announcements), - "saved": saved_count, - "markdown_generated": md_success, - "notification_sent": notify_success, - "filter_stats": { - "keyword_filtered": filter_stats.keyword_filtered, - "date_filtered": filter_stats.date_filtered, - "duplicate_filtered": filter_stats.duplicate_filtered, - "source_filtered": filter_stats.source_filtered - } - } - - # 对于手动爬取,额外返回筛选后的公告列表 - if manual_crawl: - result["filtered_announcements"] = filtered_announcements - - logger.info(f"爬取任务完成: {result}") - return result - - except Exception as e: - logger.error(f"爬取任务执行失败: {str(e)}") - return {"success": False, "error": str(e)} - - def run_cleanup(self, days: int = None): - """执行数据清理任务""" - try: - logger.info("开始执行数据清理任务") - - deleted_count = cleanup_storage(days) - - logger.info(f"数据清理完成:删除 {deleted_count} 条过期数据") - - # 发送通知 - if deleted_count > 0 and self.config.wechat_app.enabled: - send_system_notification( - "数据清理完成", - f"已清理 {deleted_count} 条过期数据" - ) - - return {"success": True, "deleted": deleted_count} - - except Exception as e: - logger.error(f"数据清理任务执行失败: {str(e)}") - return {"success": False, "error": str(e)} - - def run_wechat_server(self, host: str = '0.0.0.0', port: int = 18001): - """启动企业微信回调服务器""" - if not wechat_available: - return {"success": False, "error": "企业微信模块不可用"} - - try: - logger.info("启动企业微信回调服务器") - - # 获取回调服务器 - callback_server = get_callback_server() - - # 启动服务器 - callback_server.run(host=host, port=port, debug=self.config.debug) - - return {"success": True, "host": host, "port": port} - - except Exception as e: - logger.error(f"启动企业微信回调服务器失败: {str(e)}") - return {"success": False, "error": str(e)} - - def manage_wechat_menu(self, action: str) -> Dict[str, Any]: - """管理企业微信菜单""" - if not wechat_available: - return {"success": False, "error": "企业微信模块不可用"} - - try: - logger.info(f"执行企业微信菜单操作: {action}") - - menu_manager = WeChatMenuManager() - - if action == 'create': - success = menu_manager.create_menu() - result = {"success": success, "action": "create"} - elif action == 'delete': - success = menu_manager.delete_menu() - result = {"success": success, "action": "delete"} - elif action == 'get': - menu_info = menu_manager.get_menu() - result = {"success": menu_info is not None, "action": "get", "menu": menu_info} - elif action == 'test': - test_results = menu_manager.test_menu_operations() - result = {"success": True, "action": "test", "results": test_results} - else: - result = {"success": False, "error": f"未知操作: {action}"} - - if result["success"]: - logger.info(f"企业微信菜单操作成功: {action}") - else: - logger.error(f"企业微信菜单操作失败: {action}") - - return result - - except Exception as e: - logger.error(f"企业微信菜单管理异常: {str(e)}") - return {"success": False, "error": str(e)} - - def show_status(self): - """显示系统状态""" - try: - status = { - "system": { - "version": "1.0.0", - "healthy": check_system_health() - }, - "config": { - "debug": self.config.debug, - "log_level": self.config.log_level.value - }, - "database": { - "enabled": self.config.database.enabled, - "type": self.config.database.type - }, - "wechat": { - "enabled": self.config.wechat_app.enabled - } - } - - # 格式化输出 - print("\n=== 系统状态 ===") - print(f"系统健康: {'正常' if status['system']['healthy'] else '异常'}") - print(f"调试模式: {'开启' if status['config']['debug'] else '关闭'}") - print(f"日志级别: {status['config']['log_level']}") - print(f"数据库: {'启用' if status['database']['enabled'] else '禁用'} ({status['database']['type']})") - print(f"企业微信: {'启用' if status['wechat']['enabled'] else '禁用'}") - - return status - - except Exception as e: - logger.error(f"获取系统状态失败: {str(e)}") - return {"error": str(e)} - - -def create_argument_parser(): - """创建命令行参数解析器""" - parser = argparse.ArgumentParser( - description="广西政府采购网公告监控系统", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -使用示例: - python main.py crawl # 执行一次爬取 - python main.py crawl --keywords "大化" # 爬取指定关键词 - python main.py cleanup # 执行数据清理 - python main.py status # 查看系统状态 - python main.py wechat-server # 启动企业微信回调服务器 - python main.py wechat-menu --action create # 创建企业微信菜单 - """ - ) - - parser.add_argument( - 'command', - choices=['crawl', 'cleanup', 'status', 'wechat-server', 'wechat-menu'], - help='要执行的命令' - ) - - parser.add_argument( - '--config', '-c', - help='配置文件路径' - ) - - # crawl命令的参数 - parser.add_argument( - '--keywords', '-k', - nargs='+', - help='关键词过滤(多个关键词用空格分隔)' - ) - - parser.add_argument( - '--sources', '-s', - nargs='+', - help='来源代码过滤(多个来源用空格分隔)' - ) - - parser.add_argument( - '--max-pages', - type=int, - help='最大爬取页数' - ) - - # cleanup命令的参数 - parser.add_argument( - '--days', '-d', - type=int, - help='清理多少天前的过期数据' - ) - - # wechat-server命令的参数 - parser.add_argument( - '--host', '-H', - default='0.0.0.0', - help='服务器监听主机地址 (默认: 0.0.0.0)' - ) - - parser.add_argument( - '--port', '-P', - type=int, - default=18001, - help='服务器监听端口 (默认: 18001)' - ) - - # wechat-menu命令的参数 - parser.add_argument( - '--action', '-a', - choices=['create', 'delete', 'get', 'test'], - default='create', - help='菜单操作类型 (默认: create)' - ) - - return parser - - -def main(): - """主函数""" - parser = create_argument_parser() - args = parser.parse_args() - - # 创建应用实例 - app = GXGPMonitorApp() - - # 初始化应用 - if not app.initialize(args.config): - sys.exit(1) - - try: - if args.command == 'crawl': - # 执行爬取 - result = app.run_crawl( - keywords=args.keywords, - sources=args.sources, - max_pages=args.max_pages - ) - - if result["success"]: - print("✅ 爬取任务执行成功") - print(f" 爬取公告: {result['total_crawled']}") - print(f" 筛选后: {result['filtered']}") - print(f" 保存数量: {result['saved']}") - if result.get("markdown_generated"): - print(" Markdown文件: 已生成") - if result.get("notification_sent"): - print(" 通知发送: 已发送") - else: - print(f"❌ 爬取任务执行失败: {result.get('error', '未知错误')}") - sys.exit(1) - - elif args.command == 'cleanup': - # 执行清理 - result = app.run_cleanup(days=args.days) - if result["success"]: - print(f"✅ 数据清理完成,删除 {result['deleted']} 条记录") - else: - print(f"❌ 数据清理失败: {result.get('error', '未知错误')}") - sys.exit(1) - - elif args.command == 'status': - # 显示状态 - app.show_status() - - elif args.command == 'wechat-server': - # 启动企业微信回调服务器 - result = app.run_wechat_server(host=args.host, port=args.port) - if result["success"]: - print(f"✅ 企业微信回调服务器已启动: {result['host']}:{result['port']}") - print(" 回调地址: /api/v1/wechat/callback") - else: - print(f"❌ 企业微信回调服务器启动失败: {result.get('error', '未知错误')}") - sys.exit(1) - - elif args.command == 'wechat-menu': - # 企业微信菜单管理 - result = app.manage_wechat_menu(action=args.action) - if result["success"]: - if args.action == 'create': - print("✅ 企业微信菜单创建成功") - elif args.action == 'delete': - print("✅ 企业微信菜单删除成功") - elif args.action == 'get': - print("✅ 企业微信菜单获取成功") - if result.get("menu"): - print("菜单信息:", json.dumps(result["menu"], indent=2, ensure_ascii=False)) - elif args.action == 'test': - print("✅ 企业微信菜单测试完成") - print("测试结果:", result.get("results")) - else: - error = result.get("error", "未知错误") - print(f"❌ 企业微信菜单操作失败: {error}") - sys.exit(1) - - except KeyboardInterrupt: - logger.info("收到中断信号,正在退出...") - except Exception as e: - logger.error(f"程序执行异常: {str(e)}") - print(f"❌ 程序执行异常: {str(e)}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/gx_gp_monitor/notification/__init__.py b/gx_gp_monitor/notification/__init__.py deleted file mode 100644 index 3ca4a8b..0000000 --- a/gx_gp_monitor/notification/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""通知模块""" diff --git a/gx_gp_monitor/notification/wechat.py b/gx_gp_monitor/notification/wechat.py deleted file mode 100644 index 155c506..0000000 --- a/gx_gp_monitor/notification/wechat.py +++ /dev/null @@ -1,896 +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 - - @retry_on_exception(RetryConfig(max_retries=3)) - def send_textcard_message(self, title: str, description: str, url: str, - to_user: str = "@all", to_party: str = "", to_tag: str = "", - btn_txt: str = "查看详情") -> bool: - """ - 发送文本卡片消息 - - Args: - title: 标题 - description: 描述内容(支持HTML) - url: 点击跳转的链接 - to_user: 接收者用户ID - to_party: 接收者部门ID - to_tag: 接收者标签ID - btn_txt: 按钮文字 - - 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_endpoint = f"{self.config.proxy_api_url}/cgi-bin/message/send" - else: - url_endpoint = "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": "textcard", - "agentid": self.config.agent_id, - "textcard": { - "title": title, - "description": description, - "url": url, - "btntxt": btn_txt - }, - "enable_id_trans": 0, - "enable_duplicate_check": 0, - "duplicate_check_interval": 1800 - } - - logger.debug(f"发送文本卡片消息: {title}") - - response = requests.post(url_endpoint, 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 - - 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 - - success_count = 0 - total_count = len(announcements) - - logger.info(f"开始发送 {total_count} 条公告通知,每条单独发送") - - for i, announcement in enumerate(announcements[:max_count], 1): - try: - logger.debug(f"发送第 {i}/{min(total_count, max_count)} 条公告: {announcement.title[:30]}...") - - # 为每条公告生成单独的文本卡片 - if self.send_single_announcement_notification(announcement): - success_count += 1 - logger.debug(f"第 {i} 条公告发送成功") - else: - logger.warning(f"第 {i} 条公告发送失败: {announcement.title[:30]}...") - - # 添加短暂延迟,避免发送过快 - if i < len(announcements[:max_count]): - import time - time.sleep(0.5) - - except Exception as e: - logger.error(f"发送第 {i} 条公告时发生异常: {str(e)}") - continue - - logger.info(f"公告通知发送完成: {success_count}/{min(total_count, max_count)} 条成功") - - if total_count > max_count: - logger.info(f"还有 {total_count - max_count} 条公告未发送(超过最大数量限制)") - - return success_count > 0 - - def send_single_announcement_notification(self, announcement: Announcement) -> bool: - """ - 发送单条公告的通知(文本卡片消息) - - Args: - announcement: 单条公告 - - Returns: - bool: 发送是否成功 - """ - try: - # 生成单条公告的文本卡片内容 - title, description, url = self._generate_single_textcard_notification(announcement) - - # 发送文本卡片消息 - return self.send_textcard_message(title, description, url, btn_txt="查看详情") - - except Exception as e: - logger.error(f"发送单条公告通知失败: {str(e)}") - return False - - def _generate_single_textcard_notification(self, announcement: Announcement) -> tuple[str, str, str]: - """ - 生成单条公告的文本卡片内容 - - 新格式示例: - --- - **北海市涠洲岛旅游区管理委员会关于办公桌的网上超市采购项目成交公告** - - 工程类公告 | 北海市涠洲岛旅游区管理委员会 | 2026-01-08 09:28 - --- - - Args: - announcement: 单条公告 - - Returns: - tuple[str, str, str]: (标题, 描述HTML, URL) - """ - # 标题:公告标题(加粗显示,作为卡片标题) - announcement_title = announcement.title - if len(announcement_title) > 128: # 企业微信卡片标题限制128字符 - announcement_title = announcement_title[:125] + "..." - title = announcement_title - - # 公告类型映射(英文枚举值 -> 中文显示名称) - type_mapping = { - "PURCHASE": "采购公告", - "RESULT": "结果公告", - "CONTRACT": "合同公告", - "CORRECTION": "更正公告", - "PRE_ANNOUNCEMENT": "招标文件预公示", - "SINGLE_SOURCE": "单一来源公示", - "ELECTRONIC_MARKET": "电子卖场公示", - "ACCEPTANCE": "履约验收公示", - "ENGINEERING": "工程类公告", - "FRAMEWORK_AGREEMENT": "框架协议征集公告", - "FRAMEWORK_RESULT": "框架协议入围结果公告", - "FRAMEWORK_SUMMARY": "框架协议成交结果汇总公告", - "INTENTION": "采购意向公开" - } - - # 获取公告类型的中文显示名称 - announcement_type_enum = str(announcement.announcement_type).split('.')[-1] - announcement_type_display = type_mapping.get(announcement_type_enum, announcement_type_enum) - - # 确定来源名称 - source_name = announcement.purchase_name if announcement.purchase_name else announcement.source_name - if len(source_name) > 25: # 限制来源名称长度 - source_name = source_name[:22] + "..." - - # 根据来源代码添加前缀标识 - source_prefix = "" - if announcement.source_code == 'dahuagov': - source_prefix = "【大化县政府网】" - else: - source_prefix = "【广西政府采购网】" - - # 时间格式化 - if announcement.publish_date: - time_str = announcement.publish_date.strftime("%Y-%m-%d %H:%M") - else: - time_str = "时间未知" - - # 生成描述:来源标识 | 类型 | 来源单位 | 时间 - description = f'