Python爬虫实战:采集巨潮资讯网等上市公司公告数据,通过智能关键词匹配技术识别分红、回购、停牌等重要信息(附CSV导出 + SQLite持久化存储)!

㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!

㊗️爬虫难度指数:⭐⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [1️⃣ 标题 && 摘要](#1️⃣ 标题 && 摘要)
    • [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
    • [3️⃣ 合规与注意事项(必读)](#3️⃣ 合规与注意事项(必读))
    • [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
    • [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
    • [6️⃣ 核心实现: 巨潮API调用模块](#6️⃣ 核心实现: 巨潮API调用模块)
      • 设计要点
      • 完整代码实现
      • 代码详解
        • [1. 时间戳转换](#1. 时间戳转换)
        • [2. 股票代码格式化](#2. 股票代码格式化)
        • [3. 分页逻辑的数学计算](#3. 分页逻辑的数学计算)
        • [4. 指数退避算法](#4. 指数退避算法)
    • [7️⃣ 核心实现: 关键词智能匹配引擎](#7️⃣ 核心实现: 关键词智能匹配引擎)
    • [8️⃣ 核心实现: 多渠道通知系统](#8️⃣ 核心实现: 多渠道通知系统)
      • 设计思路
      • 完整代码实现
      • 代码详解
        • [1. SMTP邮件发送流程](#1. SMTP邮件发送流程)
        • [2. 企业微信加签算法](#2. 企业微信加签算法)
        • [3. HTML邮件样式技巧](#3. HTML邮件样式技巧)
    • [9️⃣ 核心实现: 数据库与缓存管理](#9️⃣ 核心实现: 数据库与缓存管理)
      • 数据库设计
      • 代码详解
        • [1. APScheduler的Cron表达式](#1. APScheduler的Cron表达式)
        • [2. 为什么要动态调整频率?](#2. 为什么要动态调整频率?)
        • [3. 日志轮转机制](#3. 日志轮转机制)
    • [1️⃣1️⃣ 主程序整合与运行示例](#1️⃣1️⃣ 主程序整合与运行示例)
    • 1️⃣2南
      • [Q1: API返回 "您的访问过于频繁,请稍后再试"](#Q1: API返回 "您的访问过于频繁,请稍后再试")
      • [Q2: 邮件发送失败: SMTPAuthenticationError](#Q2: 邮件发送失败: SMTPAuthenticationError)
      • [Q3: 企业微信消息发送失败](#Q3: 企业微信消息发送失败)
      • [Q4: 关键词匹配不准确](#Q4: 关键词匹配不准确)
      • [Q5: 数据库锁定错误: database is locked](#Q5: 数据库锁定错误: database is locked)
      • [Q6: 内存占用持续增长](#Q6: 内存占用持续增长)
    • [1️⃣3️⃣ 进阶优化与扩展](#1️⃣3️⃣ 进阶优化与扩展)
      • [1. PDF正文提取(深度内容分析)](#1. PDF正文提取(深度内容分析))
      • [2. 数据分析与可视化](#2. 数据分析与可视化)
      • [3. Webhook集成(连接量化交易系统)](#3. Webhook集成(连接量化交易系统))
      • [4. 多进程并发优化](#4. 多进程并发优化)
    • [1️⃣4️⃣ 总结与延伸阅读](#1️⃣4️⃣ 总结与延伸阅读)
    • [📋 完整代码仓库结构(最终版)](#📋 完整代码仓库结构(最终版))
    • [🌟 文末](#🌟 文末)
      • [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈

💕订阅后更新会优先推送,按目录学习更高效💯~

1️⃣ 标题 && 摘要

一句话概括: 使用Python爬取巨潮资讯网的上市公司公告数据,通过智能关键词匹配技术识别分红、回购、停牌等重要信息,实现邮件+企业微信的多渠道实时提醒。

你能获得:

  • 掌握金融数据API的专业调用与分页处理技巧
  • 学会构建关键词智能匹配引擎(支持同义词、模糊匹配、权重计算)
  • 实现多渠道消息推送系统(邮件/微信/钉钉)
  • 构建可7×24小时运行的自动化监控服务

2️⃣ 背景与需求(Why)

为什么要监控公司公告?

作为投资者或财经从业者,每天都有海量的上市公司公告发布。手动刷新巨潮资讯网效率极低,而且容易遗漏关键信息:

  • 错过重要公告: 分红公告发布后股价通常会有波动,晚一步就可能错失机会
  • 信息过载: 每天上千条公告,人工筛选耗时耗力
  • 时效性差: 等到晚上看新闻才知道,股价已经大涨或大跌了

如果能建立一个自动化监控系统,就能实现:

  • 实时提醒: 目标公司发布公告后5分钟内收到通知
  • 智能过滤: 只关注"分红/回购/停牌"等关键词,屏蔽无关信息
  • 历史追踪: 自动归档所有公告,方便回溯分析
  • 多股监控: 同时监控自选股列表中的所有公司

目标数据源与字段清单

主数据源: 巨潮资讯网(http://www.cninfo.com.cn) - 中国证监会指定的信息披露网站

API端点 : http://www.cninfo.com.cn/new/hisAnnouncement/query

列表页字段:

  • 公告标题 (announcementTitle)
  • 公告时间 (announcementTime)
  • 股票代码 (secCode)
  • 股票名称 (secName)
  • 公告ID (announcementId)
  • 公告类型 (announcementType)
  • PDF链接 (adjunctUrl)

关键词库:

  • 分红类: 分红、派息、现金红利、股利分配、利润分配
  • 回购类: 回购、回购股份、股份回购进展
  • 停牌类: 停牌、复牌、临时停牌
  • 增减持: 增持、减持、股东减持计划
  • 业绩类: 业绩预告、业绩快报、年报、季报
  • 重组类: 重大资产重组、并购重组、收购

3️⃣ 合规与注意事项(必读)

数据使用合规性

巨潮资讯网是公开信息披露平台,但仍需注意:

可以做的:

  • 个人投资决策参考
  • 学术研究和数据分析
  • 非商业性质的自动化监控

不能做的:

  • 未经授权转售数据
  • 用于商业化产品(如付费资讯服务)
  • 恶意高频请求导致服务器负载过大

API调用规范

频率控制:

  • 建议每次请求间隔 2-5秒
  • 定时任务建议每 30分钟 执行一次(工作日交易时间可缩短至10分钟)
  • 避免在交易日开盘和收盘前后1小时内高频请求(服务器压力大)

User-Agent设置:

python 复制代码
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    'Referer': 'http://www.cninfo.com.cn/new/index'
}

敏感信息处理

邮件配置安全:

  • ❌ 不要在代码中硬编码邮箱密码
  • ✅ 使用环境变量或加密配置文件
  • ✅ 使用SMTP授权码而非真实密码

数据存储:

  • 公告数据本身是公开的,无需加密存储
  • 但个人的 监控配置(如自选股列表)建议加密

4️⃣ 技术选型与整体流程(What/How)

API vs 网页爬取

方案 优势 劣势 适用场景
巨潮JSON API 稳定、结构化、无需解析HTML 字段有限,无公告正文 本次推荐
网页爬取 可获取正文、评论等 反爬严重,维护成本高 深度内容分析
第三方数据源 数据丰富,接口稳定 通常需付费 商业应用

整体流程架构

json 复制代码
┌─────────────────────┐
│  配置自选股列表      │ (stock_list.json)
│  + 关键词规则        │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  定时任务启动        │ (APScheduler: 每30分钟)
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  调用巨潮API         │ → 分页获取公告列表
│  - 构造查询参数      │
│  - 处理分页逻辑      │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  新公告检测          │ → 与数据库比对
│  - 查询历史记录      │
│  - 识别新增公告      │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  关键词智能匹配      │ → 正则+模糊+权重
│  - 标题匹配          │
│  - PDF正文提取(可选) │
│  - 计算匹配度        │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  优先级分级          │ → 高/中/低
│  - 关键词权重计算    │
│  - 多关键词命中加权  │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  多渠道提醒          │ → 邮件 + 微信
│  - 邮件发送(SMTP)    │
│  - 企业微信机器人    │
│  - 钉钉机器人(可选)  │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  存储到数据库        │ → SQLite
│  - 记录提醒历史      │
│  - PDF归档(可选)     │
└─────────────────────┘

为什么选这套技术栈?

  • requests: 轻量级HTTP库,适合API调用
  • APScheduler: Python定时任务框架,比cron更灵活
  • SQLite: 无需安装数据库服务,适合个人使用
  • smtplib: Python内置邮件库,支持各大邮箱
  • fuzzywuzzy: 模糊字符串匹配,提升关键词识别准确率
  • PyPDF2: PDF文本提取,用于深度内容分析

5️⃣ 环境准备与依赖安装(可复现)

Python版本要求

推荐 Python 3.8+ ,需要用到 typingdataclasses

依赖安装

bash 复制代码
pip install requests apscheduler pandas fuzzywuzzy python-Levenshtein PyPDF2

依赖说明:

  • requests: HTTP请求库
  • apscheduler: 定时任务框架
  • pandas: 数据处理(可选,用于数据分析)
  • fuzzywuzzy: 模糊匹配算法
  • python-Levenshtein: 加速模糊匹配(C扩展)
  • PyPDF2: PDF文件解析

项目目录结构

json 复制代码
announcement_monitor/
│
├── config/
│   ├── __init__.py
│   ├── settings.py              # 全局配置
│   ├── stock_list.json          # 自选股列表
│   ├── keywords.json            # 关键词规则库
│   └── email_config.json        # 邮件配置(加密)
│
├── scraper/
│   ├── __init__.py
│   ├── cninfo_api.py            # 巨潮API调用模块
│   ├── pdf_extractor.py         # PDF文本提取
│   └── proxy_pool.py            # 代理池(可选)
│
├── matcher/
│   ├── __init__.py
│   ├── keyword_matcher.py       # 关键词匹配引擎
│   ├── synonym_dict.py          # 同义词词典
│   └── priority_calculator.py   # 优先级计算
│
├── notifier/
│   ├── __init__.py
│   ├── email_sender.py          # 邮件发送
│   ├── wechat_bot.py            # 企业微信机器人
│   └── dingtalk_bot.py          # 钉钉机器人
│
├── storage/
│   ├── __init__.py
│   ├── database.py              # 数据库操作
│   └── cache_manager.py         # 缓存管理
│
├── scheduler/
│   ├── __init__.py
│   └── task_scheduler.py        # 定时任务调度
│
├── data/
│   ├── announcements.db         # SQLite数据库
│   └── pdfs/                    # PDF归档目录
│
├── logs/
│   └── monitor.log              # 运行日志
│
├── main.py                      # 主程序入口
├── requirements.txt             # 依赖清单
└── README.md                   # 项目说明

配置文件示例

python 复制代码
# config/settings.py
import os

# ========== 基础配置 ==========
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DATA_DIR = os.path.join(BASE_DIR, 'data')
LOG_DIR = os.path.join(BASE_DIR, 'logs')
PDF_DIR = os.path.join(DATA_DIR, 'pdfs')

# 创建必要目录
for dir_path in [DATA_DIR, LOG_DIR, PDF_DIR]:
    os.makedirs(dir_path, exist_ok=True)

# ========== API配置 ==========
CNINFO_API_URL = 'http://www.cninfo.com.cn/new/hisAnnouncement/query'
CNINFO_PDF_BASE = 'http://static.cninfo.com.cn/'

# 请求头配置
HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    'Referer': 'http://www.cninfo.com.cn/new/index',
    'Accept': 'application/json, text/javascript, */*; q=0.01',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    'X-Requested-With': 'XMLHttpRequest'
}

# 请求参数
REQUEST_TIMEOUT = 15  # 超时时间(秒)
MAX_RETRIES = 3       # 最大重试次数
REQUEST_DELAY = (2, 5)  # 请求延时范围(秒)

# ========== 数据库配置 ==========
DB_PATH = os.path.join(DATA_DIR, 'announcements.db')

# ========== 定时任务配置 ==========
# 监控间隔(分钟)
MONITOR_INTERVAL = 30  # 每30分钟检查一次

# 工作时间(只在工作日的交易时间内高频监控)
WORK_DAYS = [0, 1, 2, 3, 4]  # 周一到周五
WORK_START_HOUR = 9   # 9:00开始
WORK_END_HOUR = 15    # 15:00结束
INTENSIVE_INTERVAL = 10  # 交易时间内每10分钟检查

# ========== 关键词配置 ==========
# 关键词权重(用于优先级计算)
KEYWORD_WEIGHTS = {
    '分红': 10,
    '派息': 10,
    '现金红利': 9,
    '股利分配': 9,
    '回购': 10,
    '股份回购': 10,
    '停牌': 8,
    '复牌': 7,
    '增持': 6,
    '减持': 6,
    '业绩预告': 5,
    '重大资产重组': 9
}

# 优先级阈值
PRIORITY_THRESHOLDS = {
    'high': 9,    # 权重≥9为高优先级
    'medium': 6,  # 6≤权重<9为中优先级
    'low': 3      # 3≤权重<6为低优先级
}

# ========== 通知配置 ==========
# 是否启用各通知渠道
ENABLE_EMAIL = True
ENABLE_WECHAT = True
ENABLE_DINGTALK = False

# 邮件配置(使用环境变量或加密配置文件)
EMAIL_CONFIG = {
    'smtp_server': 'smtp.qq.com',  # QQ邮箱SMTP服务器
    'smtp_port': 465,               # SSL端口
    'sender': os.getenv('EMAIL_SENDER', 'your_email@qq.com'),
    'password': os.getenv('EMAIL_PASSWORD', 'your_auth_code'),  # 授权码
    'receiver': ['receiver1@example.com', 'receiver2@example.com']
}

# 企业微信机器人配置
WECHAT_WEBHOOK = os.getenv('WECHAT_WEBHOOK', 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY')

# 钉钉机器人配置
DINGTALK_WEBHOOK = os.getenv('DINGTALK_WEBHOOK', 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN')

# ========== 日志配置 ==========
LOG_FILE = os.path.join(LOG_DIR, 'monitor.log')
LOG_LEVEL = 'INFO'
LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
LOG_MAX_BYTES = 10 * 1024 * 1024  # 10MB
LOG_BACKUP_COUNT = 5
json 复制代码
# config/stock_list.json
{
  "stocks": [
    {
      "code": "000858",
      "name": "五粮液",
      "monitor": true,
      "priority_keywords": ["分红", "回购"]
    },
    {
      "code": "600519",
      "name": "贵州茅台",
      "monitor": true,
      "priority_keywords": ["分红", "业绩"]
    },
    {
      "code": "000333",
      "name": "美的集团",
      "monitor": true,
      "priority_keywords": ["回购", "增持"]
    }
  ],
  "update_time": "2026-01-29 10:00:00"
}
json 复制代码
# config/keywords.json
{
  "categories": {
    "dividend": {
      "name": "分红派息",
      "keywords": ["分红", "派息", "现金红利", "股利分配", "利润分配", "每股派息"],
      "weight": 10,
      "synonyms": {
        "分红": ["派息", "现金红利"],
        "股利": ["红利", "股息"]
      }
    },
    "buyback": {
      "name": "股份", "回购进展", "回购实施"],
      "weight": 10,
      "synonyms": {
        "回购": ["回购股份", "股份回购"]
      }
    },
    "suspension": {
      "name": "停复牌",
      "keywords": ["停牌", "复牌", "临时停牌", "继续停牌"],
      "weight": 8,
      "synonyms": {
        "停牌": ["暂停交易"],
        "复牌": ["恢复交易"]
      }
    },
    "shareholding": {
      "name": "股东增减持",
      "keywords": ["增持", "减持", "股东减持", "高管增持", "减持计划"],
      "weight": 6,
      "synonyms": {
        "增持": ["买入", "增加持股"],
        "减持": ["卖出", "减少持股"]
      }
    },
    "performance": {
      "name": "业绩相关",
      "keywords": ["业绩预告", "业绩快报", "年报", "半年报", "季报", "盈利预测"],
      "weight": 5,
      "synonyms": {
        "业绩": ["经营业绩", "财务业绩"]
      }
    },
    "restructuring": {
      "name": "重组并购",
      "keywords": ["重大资产重组", "并购", "收购", "资产注入", "重组进展"],
      "weight": 9,
      "synonyms": {
        "重组": ["资产重组", "并购重组"],
        "收购": ["并购", "兼并"]
      }
    }
  }
}

6️⃣ 核心实现: 巨潮API调用模块

设计要点

巨潮资讯网的API特点:

  • POST请求: 查询参数通过POST的body传递
  • 分页机制: pageNum(页码) + pageSize(每页数量)
  • 股票代码格式: 需要根据市场添加前缀(沪市000000, 深市000001)
  • 时间范围: 支持startDate和endDate参数

完整代码实现

python 复制代码
# scraper/cninfo_api.py
import requests
import time
import random
from typing import List, Dict, Optional
from datetime import datetime, timedelta
from config.settings import (
    CNINFO_API_URL, CNINFO_PDF_BASE, HEADERS,
    REQUEST_TIMEOUT, MAX_RETRIES, REQUEST_DELAY
)

class CninfoAPIFetcher:
    """巨潮资讯网API调用器"""
    
    def __init__(self):
        """
        初始化API调用器
        
        属性说明:
            session: requests会话对象,复用连接
            base_url: API基础URL
            pdf_base: PDF文件基础URL
        """
        self.session = requests.Session()
        self.session.headers.update(HEADERS)
        self.base_url = CNINFO_API_URL
        self.pdf_base = CNINFO_PDF_BASE
    
    def format_stock_code(self, code: str, market: str = 'auto') -> str:
        """
        格式化股票代码为API所需格式
        
        Args:
            code: 6位股票代码,如"600519"
            market: 市场类型('sh'=上交所, 'sz'=深交所, 'auto'=自动识别)
            
        Returns:
            格式化后的代码,如"600519,sh" 或 "000858,sz"
            
        识别规则:
            - 60/68开头: 上交所
            - 00/30开头: 深交所
            - 其他: 默认深交所
        """
        if market == 'auto':
            if code.startswith(('60', '68')):
                market = 'sh'
            elif code.startswith(('00', '30')):
                market = 'sz'
            else:
                market = 'sz'  # 默认深交所
        
        return f"{code},{market}"
    
    def query_announcements(
        self,
        stock_code: str = '',
        start_date: str = '',
        end_date: str = '',
        page_num: int = 1,
        page_size: int = 30,
        category: str = ''
    ) -> Optional[Dict]:
        """
        查询公告列表(单页)
        
        Args:
            stock_code: 股票代码(空字符串表示全市场)
            start_date: 开始日期,格式"2026-01-01"
            end_date: 结束日期,格式"2026-01-29"
            page_num: 页码,从1开始
            page_size: 每页数量,最大100
            category: 公告类别代码(空字符串表示全部)
            
        Returns:
            API响应JSON,包含公告列表和总数
            
        API参数说明:
            - stock: 股票代码(格式化后,如"600519,sh")
            - searchkey: 搜索关键词(可选)
            - plate: 板块(空=全部, shmb=沪市主板, szcyb=深市创业板等)
            - category: 类别(category_ndbg_szsh=年报, category_bndbg_szsh=半年报等)
            - trade: 行业(空=全部)
            - column: 栏目(固定为"szse"或"sse")
            - pageNum: 页码
            - pageSize: 每页数量
            - tabName: 固定为"fulltext"
            - sortName: 排序字段(announcementTime=按时间)
            - sortType: 排序方向(-1=降序, 1=升序)
            - isHLtitle: 是否高亮标题(true/false)
        """
        # 构造查询参数
        data = {
            'pageNum': page_num,
            'pageSize': page_size,
            'tabName': 'fulltext',
            'sortName': 'announcementTime',  # 按发布时间排序
            'sortType': -1,  # 降序(最新的在前)
            'isHLtitle': 'true'
        }
        
        # 添加股票代码(如果指定)
        if stock_code:
            data['stock'] = self.format_stock_code(stock_code)
        
        # 添加时间范围(如果指定)
        if start_date:
            data['seDate'] = f"{start_date}~{end_date}" if end_date else f"{start_date}~"
        
        # 添加类别(如果指定)
        if category:
            data['category'] = category
        
        # 发起请求(带重试机制)
        retry_count = 0
        while retry_count < MAX_RETRIES:
            try:
                # 随机延时(模拟人类行为)
                time.sleep(random.uniform(*REQUEST_DELAY))
                
                response = self.session.post(
                    self.base_url,
                    data=data,
                    timeout=REQUEST_TIMEOUT
                )
                
                # 检查HTTP状态码
                response.raise_for_status()
                
                # 解析JSON
                result = response.json()
                
                # 检查返回码
                # returncode=200表示成功
                if result.get('returncode') == 200:
                    return result
                else:
                    print(f"❌ API返回错误: {result.get('returnmsg')}")
                    return None
                
            except requests.exceptions.Timeout:
                retry_count += 1
                print(f"⏱️ 请求超时,重试 {retry_count}/{MAX_RETRIES}")
                time.sleep(2 ** retry_count)  # 指数退避
                
            except requests.exceptions.HTTPError as e:
                print(f"❗ HTTP错误: {e.response.status_code}")
                return None
                
            except requests.exceptions.RequestException as e:
                retry_count += 1
                print(f"🔌 网络异常: {str(e)}, 重试 {retry_count}/{MAX_RETRIES}")
                time.sleep(2 ** retry_count)
                
            except ValueError as e:
                print(f"⚠️ JSON解析失败: {str(e)}")
                return None
        
        print(f"❌ 达到最大重试次数,放弃请求")
        return None
    
    def query_all_pages(
        self,
        stock_code: str = '',
        start_date: str = '',
        end_date: str = '',
        max_results: int = 100,
        page_size: int = 30
    ) -> List[Dict]:
        """
        查询所有页面的公告(自动翻页)
        
        Args:
            stock_code: 股票代码
            start_date: 开始日期
            end_date: 结束日期
            max_results: 最大返回数量
            page_size: 每页数量
            
        Returns:
            公告列表
            
        实现逻辑:
            1. 先查询第1页,获取总数(totalRecordcount)
            2. 计算总页数
            3. 循环查询每一页,合并结果
            4. 达到max_results或无更多数据时停止
        """
        all_announcements = []
        page_num = 1
        
        print(f"🔍 开始查询公告: 股票={stock_code or '全部'}, 时间={start_date}~{end_date}")
        
        # 查询第一页
        first_page = self.query_announcements(
            stock_code=stock_code,
            start_date=start_date,
            end_date=end_date,
            page_num=page_num,
            page_size=page_size
        )
        
        if not first_page:
            print(f"⚠️ 第一页查询失败")
            return []
        
        # 提取公告列表
        announcements = first_page.get('announcements', [])
        all_announcements.extend(announcements)
        
        # 获取总数
        total_count = first_page.get('totalRecordcount', 0)
        print(f"📊 共找到 {total_count} 条公告")
        
        # 计算总页数
        total_pages = (min(total_count, max_results) + page_size - 1) // page_size
        
        # 查询剩余页面
        for page_num in range(2, total_pages + 1):
            print(f"📄 正在查询第 {page_num}/{total_pages} 页...")
            
            page_data = self.query_announcements(
                stock_code=stock_code,
                start_date=start_date,
                end_date=end_date,
                page_num=page_num,
                page_size=page_size
            )
            
            if not page_data:
                print(f"⚠️ 第{page_num}页查询失败,跳过")
                continue
            
            announcements = page_data.get('announcements', [])
            all_announcements.extend(announcements)
            
            # 达到最大数量时停止
            if len(all_announcements) >= max_results:
                all_announcements = all_announcements[:max_results]
                break
        
        print(f"✅ 查询完成,共获取 {len(all_announcements)} 条公告")
        return all_announcements
    
    def parse_announcement(self, raw_data: Dict) -> Dict:
        """
        解析单条公告数据,提取所需字段
        
        Args:
            raw_data: API返回的原始公告数据
            
        Returns:
            标准化后的公告字典
            
        字段说明(原始数据):
            - announcementId: 公告ID(唯一标识)
            - announcementTitle: 公告标题
            - announcementTime: 发布时间(毫秒时间戳)
            - adjunctUrl: PDF附件相对路径
            - adjunctSize: 附件大小(KB)
            - adjunctType: 附件类型(通常为PDF)
            - secCode: 股票代码
            - secName: 股票名称
            - announcementType: 公告类型
            - orgId: 机构ID
            - columnId: 栏目ID
        """
        # 提取基础字段
        announcement_id = raw_data.get('announcementId', '')
        title = raw_data.get('announcementTitle', '')
        
        # 转换时间戳为日期时间字符串
        time_ms = raw_data.get('announcementTime', 0)
        if time_ms:
            publish_time = datetime.fromtimestamp(time_ms / 1000).strftime('%Y-%m-%d %H:%M:%S')
        else:
            publish_time = ''
        
        # 提取股票信息
        stock_code = raw_data.get('secCode', '')
        stock_name = raw_data.get('secName', '')
        
        # 构造PDF完整URL
        adjunct_url = raw_data.get('adjunctUrl', '')
        if adjunct_url:
            pdf_url = self.pdf_base + adjunct_url
        else:
            pdf_url = ''
        
        # 提取其他信息
        announcement_type = raw_data.get('announcementTypeName', '')  # 类型名称
        adjunct_size = raw_data.get('adjunctSize', 0)  # 附件大小(KB)
        
        return {
            'announcement_id': announcement_id,
            'title': title,
            'publish_time': publish_time,
            'stock_code': stock_code,
            'stock_name': stock_name,
            'announcement_type': announcement_type,
            'pdf_url': pdf_url,
            'pdf_size_kb': adjunct_size,
            'raw_data': raw_data  # 保留原始数据
        }
    
    def download_pdf(self, pdf_url: str, save_path: str) -> bool:
        """
        下载PDF文件
        
        Args:
            pdf_url: PDF完整URL
            save_path: 本地保存路径
            
        Returns:
            成功返回True,失败返回False
        """
        try:
            response = self.session.get(pdf_url, timeout=30)
            response.raise_for_status()
            
            with open(save_path, 'wb') as f:
                f.write(response.content)
            
            return True
            
        except Exception as e:
            print(f"❌ PDF下载失败: {pdf_url} | {str(e)}")
            return False


# ========== 使用示例 ==========
if __name__ == '__main__':
    fetcher = CninfoAPIFetcher()
    
    # 示例1: 查询贵州茅台最近30天的公告
    end_date = datetime.now().strftime('%Y-%m-%d')
    start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
    
    announcements = fetcher.query_all_pages(
        stock_code='600519',
        start_date=start_date,
        end_date=end_date,
        max_results=50
    )
    
    # 解析并打印前5条
    for announcement in announcements[:5]:
        parsed = fetcher.parse_announcement(announcement)
        print(f"标题: {parsed['title']}")
        print(f"时间: {parsed['publish_time']}")
        print(f"股票: {parsed['stock_name']}({parsed['stock_code']})")
        print(f"PDF: {parsed['pdf_url']}")
        print('-' * 80)

代码详解

1. 时间戳转换
python 复制代码
time_ms = 1706515200000  # 毫秒时间戳
publish_time = datetime.fromtimestamp(time_ms / 1000)  # 除以1000转为秒
formatted_time = publish_time.strftime('%Y-%m-%d %H:%M:%S')
# 输出: "2024-01-29 12:00:00"

为什么要除以1000?

  • JavaScript的时间戳是 毫秒 (13位数字)
  • Python的datetime.fromtimestamp()接受 (10位数字)
  • 需要先除以1000转换单位
2. 股票代码格式化
python 复制代码
def format_stock_code(code, market='auto'):
    # 600519 → "600519,sh" (上交所)
    # 000858 → "000858,sz" (深交所)
    if code.startswith(('60', '68')):
        return f"{code},sh"
    else:
        return f"{code},sz"

市场代码规则:

  • 60/68开头: 上交所(sh = Shanghai)

    • 60: 主板
    • 68: 科创板
  • 00开头: 深交所主板(sz = Shenzhen)

  • 30开头: 深交所创业板

3. 分页逻辑的数学计算
python 复制代码
total_count = 237  # 总公告数
page_size = 30     # 每页30条
max_results = 100  # 最多取100条

# 计算需要请求的总页数
total_pages = (min(237, 100) + 30 - 1) // 30
            = (100 + 29) // 30
            = 129 // 30
            = 4页

公式解释:

  • min(total_count, max_results): 取实际需要的数量
  • + page_size - 1: 向上取整的技巧
  • // page_size: 整除得到页数
4. 指数退避算法
python 复制代码
retry_count = 1
time.sleep(2 ** retry_count)  # 第1次: 2^1=2秒
retry_count = 2
time.sleep(2 ** retry_count)  # 第2次: 2^2=4秒
retry_count = 3
time.sleep(2 ** retry_count)  # 第3次: 2^3=8秒

为什么要指数退避?

  • 服务器可能短暂过载,给它时间恢复
  • 避免连续重试加剧服务器压力
  • 指数增长的等待时间更符合人类行为

7️⃣ 核心实现: 关键词智能匹配引擎

设计思路

单纯的关键词匹配存在诸多问题:

  • 同义词问题: "分红"和"派息"表达同一意思,但字符串不同
  • 模糊匹配: "股份回购"应该匹配"回购股份"、"股票回购"等变体
  • 优先级判断: 同时出现多个关键词时,如何判断重要程度?
  • 误报问题: "不分红"应该被识别为负面信息

我们需要构建一个三层匹配引擎:

  1. 精确匹配层: 完全相同的关键词
  2. 模糊匹配层: 使用Levenshtein距离计算相似度
  3. 同义词匹配层: 基于词典的语义匹配

完整代码实现

python 复制代码
# matcher/keyword_matcher.py
import re
import json
from typing import List, Dict, Tuple, Optional
from fuzzywuzzy import fuzz, process
from config.settings import KEYWORD_WEIGHTS, PRIORITY_THRESHOLDS
import os

class KeywordMatcher:
    """关键词智能匹配引擎"""
    
    def __init__(self, keywords_config_path: str = 'config/keywords.json'):
        """
        初始化匹配器
        
        Args:
            keywords_config_path: 关键词配置文件路径
            
        属性说明:
            keywords_dict: 关键词分类字典
            synonym_dict: 同义词字典
            negative_patterns: 负面词模式(如"不分红")
        """
        self.keywords_dict = {}
        self.synonym_dict = {}
        self.negative_patterns = [
            r'不.*?分红', r'未.*?分红', r'无.*?分红',
            r'不.*?派息', r'未.*?派息', r'无.*?派息',
            r'取消.*?回购', r'终止.*?回购',
            r'不.*?增持', r'未.*?增持'
        ]
        
        # 加载配置
        self._load_keywords_config(keywords_config_path)
    
    def _load_keywords_config(self, config_path: str):
        """
        加载关键词配置文件
        
        配置文件格式示例:
        {
          "categories": {
            "dividend": {
              "name": "分红派息",
              "keywords": ["分红", "派息", "现金红利"],
              "weight": 10,
              "synonyms": {
                "分红": ["派息", "现金红利"],
                "股利": ["红利", "股息"]
              }
            }
          }
        }
        """
        if not os.path.exists(config_path):
            print(f"⚠️ 关键词配置文件不存在: {config_path}")
            return
        
        with open(config_path, 'r', encoding='utf-8') as f:
            config = json.load(f)
        
        # 解析配置
        for category_id, category_data in config.get('categories', {}).items():
            category_name = category_data.get('name', '')
            keywords = category_data.get('keywords', [])
            weight = category_data.get('weight', 5)
            synonyms = category_data.get('synonyms', {})
            
            # 存储到字典
            self.keywords_dict[category_id] = {
                'name': category_name,
                'keywords': keywords,
                'weight': weight
            }
            
            # 构建同义词映射
            for word, synonym_list in synonyms.items():
                if word not in self.synonym_dict:
                    self.synonym_dict[word] = []
                self.synonym_dict[word].extend(synonym_list)
        
        print(f"✅ 已加载 {len(self.keywords_dict)} 个关键词类别")
    
    def exact_match(self, text: str, keyword: str) -> bool:
        """
        精确匹配
        
        Args:
            text: 待匹配的文本(公告标题)
            keyword: 关键词
            
        Returns:
            是否匹配成功
            
        实现:
            使用正则表达式的word boundary(\b)确保精确匹配
            例如: "分红"能匹配"现金分红",但不匹配"分红利"
        """
        # 构造正则模式(支持中文词边界)
        pattern = re.compile(r'(?<![a-zA-Z])' + re.escape(keyword) + r'(?![a-zA-Z])')
        return bool(pattern.search(text))
    
    def fuzzy_match(self, text: str, keyword: str, threshold: int = 80) -> Tuple[bool, int]:
        """
        模糊匹配(基于Levenshtein距离)
        
        Args:
            text: 待匹配的文本
            keyword: 关键词
            threshold: 相似度阈值(0-100)
            
        Returns:
            (是否匹配, 相似度得分)
            
        算法说明:
            使用fuzzywuzzy库计算字符串相似度
            - fuzz.ratio(): 完全比较
            - fuzz.partial_ratio(): 部分匹配(子串匹配)
            - fuzz.token_sort_ratio(): 词序无关匹配
        """
        # 使用部分匹配(partial_ratio)
        # 因为公告标题可能很长,关键词只是其中一部分
        score = fuzz.partial_ratio(keyword, text)
        
        return (score >= threshold, score)
    
    def synonym_match(self, text: str, keyword: str) -> List[str]:
        """
        同义词匹配
        
        Args:
            text: 待匹配的文本
            keyword: 关键词
            
        Returns:
            匹配到的同义词列表
            
        实现逻辑:
            1. 查找keyword的所有同义词
            2. 检查每个同义词是否在text中出现
            3. 返回所有命中的同义词
        """
        matched_synonyms = []
        
        # 获取同义词列表
        synonyms = self.synonym_dict.get(keyword, [])
        
        # 检查每个同义词
        for synonym in synonyms:
            if self.exact_match(text, synonym):
                matched_synonyms.append(synonym)
        
        return matched_synonyms
    
    def check_negative(self, text: str) -> bool:
        """
        检查是否包含负面词
        
        Args:
            text: 待检查的文本
            
        Returns:
            True表示包含负面词,应该降低优先级或忽略
            
        负面词示例:
            - "不分红" (明确否定)
            - "未分红" (尚未执行)
            - "取消回购" (取消计划)
        """
        for pattern in self.negative_patterns:
            if re.search(pattern, text):
                return True
        return False
    
    def match_announcement(self, title: str, content: str = '') -> Dict:
        """
        匹配单条公告,返回详细匹配结果
        
        Args:
            title: 公告标题
            content: 公告正文(可选,用于深度匹配)
            
        Returns:
            匹配结果字典:
            {
                'matched': bool,  # 是否匹配
                'categories': [],  # 匹配的类别列表
                'keywords': [],    # 命中的关键词列表
                'score': float,    # 总分(权重求和)
                'priority': str,   # 优先级(high/medium/low)
                'is_negative': bool  # 是否包含负面词
            }
        """
        result = {
            'matched': False,
            'categories': [],
            'keywords': [],
            'score': 0.0,
            'priority': 'low',
            'is_negative': False
        }
        
        # 合并标题和正文(如果有)
        full_text = title + ' ' + content
        
        # 检查负面词
        if self.check_negative(full_text):
            result['is_negative'] = True
            print(f"⚠️ 检测到负面词: {title}")
            return result
        
        matched_keywords = set()
        total_weight = 0
        
        # 遍历所有类别
        for category_id, category_info in self.keywords_dict.items():
            keywords = category_info['keywords']
            weight = category_info['weight']
            category_name = category_info['name']
            
            category_matched = False
            
            # 检查该类别的每个关键词
            for keyword in keywords:
                # 1. 精确匹配
                if self.exact_match(full_text, keyword):
                    matched_keywords.add(keyword)
                    category_matched = True
                    total_weight += weight
                    continue
                
                # 2. 同义词匹配
                synonyms = self.synonym_match(full_text, keyword)
                if synonyms:
                    matched_keywords.update(synonyms)
                    category_matched = True
                    total_weight += weight
                    continue
                
                # 3. 模糊匹配
                is_fuzzy_match, fuzzy_score = self.fuzzy_match(full_text, keyword)
                if is_fuzzy_match:
                    matched_keywords.add(f"{keyword}(模糊)")
                    category_matched = True
                    # 模糊匹配权重打折
                    total_weight += weight * (fuzzy_score / 100)
            
            # 记录匹配的类别
            if category_matched:
                result['categories'].append(category_name)
        
        # 设置结果
        result['keywords'] = list(matched_keywords)
        result['score'] = total_weight
        result['matched'] = len(matched_keywords) > 0
        
        # 计算优先级
        if total_weight >= PRIORITY_THRESHOLDS['high']:
            result['priority'] = 'high'
        elif total_weight >= PRIORITY_THRESHOLDS['medium']:
            result['priority'] = 'medium'
        else:
            result['priority'] = 'low'
        
        return result
    
    def batch_match(self, announcements: List[Dict]) -> List[Dict]:
        """
        批量匹配公告
        
        Args:
            announcements: 公告列表,每个元素包含'title'字段
            
        Returns:
            带匹配结果的公告列表
        """
        results = []
        
        for announcement in announcements:
            title = announcement.get('title', '')
            content = announcement.get('content', '')
            
            match_result = self.match_announcement(title, content)
            
            # 合并匹配结果到原始数据
            announcement.update({
                'match_result': match_result,
                'is_important': match_result['priority'] in ['high', 'medium']
            })
            
            results.append(announcement)
        
        return results
    
    def get_important_announcements(
        self,
        announcements: List[Dict],
        min_priority: str = 'medium'
    ) -> List[Dict]:
        """
        筛选重要公告
        
        Args:
            announcements: 已匹配的公告列表
            min_priority: 最低优先级('high', 'medium', 'low')
            
        Returns:
            重要公告列表(按score降序)
        """
        priority_order = {'high': 3, 'medium': 2, 'low': 1}
        min_level = priority_order.get(min_priority, 2)
        
        important = [
            a for a in announcements
            if priority_order.get(a.get('match_result', {}).get('priority'), 0) >= min_level
        ]
        
        # 按得分降序排序
        important.sort(key=lambda x: x.get('match_result', {}).get('score', 0), reverse=True)
        
        return important


# ========== 使用示例 ==========
if __name__ == '__main__':
    matcher = KeywordMatcher()
    
    # 测试数据
    test_announcements = [
        {'title': '关于2025年度利润分配预案的公告'},
        {'title': '关于回购公司股份的进展公告'},
        {'title': '关于临时停牌的公告'},
        {'title': '关于不进行现金分红的说明'},  # 负面
        {'title': '关于股东减持股份计划的公告'},
        {'title': '2025年年度报告摘要'}
    ]
    
    # 批量匹配
    results = matcher.batch_match(test_announcements)
    
    # 打印结果
    for announcement in results:
        match_result = announcement['match_result']
        print(f"\n标题: {announcement['title']}")
        print(f"匹配: {'✅' if match_result['matched'] else '❌'}")
        print(f"类别: {', '.join(match_result['categories'])}")
        print(f"关键词: {', '.join(match_result['keywords'])}")
        print(f"得分: {match_result['score']:.1f}")
        print(f"优先级: {match_result['priority']}")
        print(f"负面词: {'⚠️是' if match_result['is_negative'] else '否'}")
        print('-' * 60)
    
    # 筛选高优先级公告
    print("\n" + "=" * 60)
    print("高优先级公告:")
    print("=" * 60)
    important = matcher.get_important_announcements(results, min_priority='medium')
    for announcement in important:
        print(f"- {announcement['title']} (得分:{announcement['match_result']['score']:.1f})")

算法详解

1. Levenshtein距离原理
python 复制代码
# 示例: 计算"股份回购"和"回购股份"的相似度
from fuzzywuzzy import fuzz

similarity = fuzz.ratio("股份回购", "回购股份")
print(similarity)  # 输出: 80

# 算法原理:
# Levenshtein距离 = 将一个字符串转换为另一个所需的最少编辑次数
# 编辑操作: 插入、删除、替换

# "股份回购" → "回购股份"
# 需要: 2次替换 + 2次移动 = 4次操作
# 相似度 = (1 - 4/len(max_string)) * 100 ≈ 80%

为什么用partial_ratio而不是ratio?

python 复制代码
# ratio: 完全比较
fuzz.ratio("股份回购", "关于股份回购的公告")  # 低分(长度差距大)

# partial_ratio: 部分匹配(子串)
fuzz.partial_ratio("股份回购", "关于股份回购的公告")  # 高分(100)
2. 权重计算逻辑
python 复制代码
# 假设公告标题: "关于2025年度利润分配及资本公积转增股本的公告"
# 命中关键词: "分红"(权重10) + "股利"(权重9)

total_weight = 0

# 精确匹配"利润分配" → 映射到"分红"类别
total_weight += 10

# 同义词匹配"股本" → 映射到"股利"类别  
total_weight += 9

# 总得分: 19
# 判定: 19 >= 9(高优先级阈值) → priority = 'high'
3. 负面词检测正则
python 复制代码
import re

text = "关于不进行现金分红的说明"
pattern = r'不.*?分红'  # .*? 表示非贪婪匹配

match = re.search(pattern, text)
if match:
    print(f"检测到负面词: {match.group()}")
    # 输出: "不进行现金分红"

为什么用.*?而不是.*?

python 复制代码
text = "不会进行现金分红和股票分红"

# 贪婪匹配(.*): 匹配"金分红和股票分红"(太长)
re.search(r'不.*分红', text).group()

# 非贪婪(.*?): 匹配"不会进行现金分红"(刚好)
re.search(r'不.*?分红', text).group()

8️⃣ 核心实现: 多渠道通知系统

设计思路

不同的通知渠道有不同的特点:

  • 邮件: 支持HTML富文本,适合详细内容
  • 企业微信: 即时性强,支持Markdown格式
  • 钉钉: 办公场景,支持@提醒

我们需要实现统一的通知接口,方便扩展新渠道。

完整代码实现

python 复制代码
# notifier/email_sender.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from typing import List, Dict
from config.settings import EMAIL_CONFIG
import ssl

class EmailNotifier:
    """邮件通知器"""
    
    def __init__(self):
        """
        初始化邮件发送器
        
        配置说明:
            - smtp_server: SMTP服务器地址
            - smtp_port: 端口(25=普通, 465=SSL, 587=TLS)
            - sender: 发件人邮箱
            - password: 授权码(不是邮箱密码!)
            - receiver: 收件人列表
        """
        self.smtp_server = EMAIL_CONFIG['smtp_server']
        self.smtp_port = EMAIL_CONFIG['smtp_port']
        self.sender = EMAIL_CONFIG['sender']
        self.password = EMAIL_CONFIG['password']
        self.receivers = EMAIL_CONFIG['receiver']
    
    def create_html_content(self, announ式的邮件正文
        
        Args:
            announcements: 公告列表
            
        Returns:
            HTML字符串
            
        HTML结构:
            - 使用表格展示公告列表
            - 不同优先级用不同颜色标注
            - 包含跳转链接
        """
        # HTML模板
        html = """
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8">
            <style>
                body {
                    font-family: "Microsoft YaHei", Arial, sans-serif;
                    line-height: 1.6;
                    color: #333;
                    max-width: 900px;
                    margin: 0 auto;
                    padding: 20px;
                }
                h1 {
                    color: #2c3e50;
                    border-bottom: 3px solid #3498db;
                    padding-bottom: 10px;
                }
                table {
                    widthtop: 20px;
                }
                th {
                    background-color: #34495e;
                    color: white;
                    padding: 12px;
                    text-align: left;
                }
                td {
                    padding: 10px;
                    border-bottom: 1px solid #ddd;
                }
                .high-priority {
                    background-color: #ffebee;
                    border-left: 4px solid #e74c3c;
                }
                .medium-priority {
                    background-color: #fff3e0;
                    border-left: 4px solid #f39c12;
                }
                .low-priority {
                    background-color: #f5f5f5;
                    border-left: 4px solid #95a5a6;
                }
                .badge {
                    display: inline-block;
                    padding: 4px 8px;
                    border-radius: 3px;
                    font-size: 12px;
                    font-weight: bold;
                    color: white;
                }
                .badge-high { background-color: #e74c3c; }
                .badge-medium { background-color: #f39c12; }
                .badge-low { background-color: #95a5a6; }
                a {
                    color: #3498db;
                    text-decoration: none;
                }
                a:hover {
                    text-decoration: underline;
                }
                .footer {
                    margin-top: 30px;
                    padding-top: 20px;
                    border-top: 1px solid #ddd;
                    font-size: 12px;
                    color: #999;
                    text-align: center;
                }
            </style>
        </head>
        <body>
            <h1>🔔 公司公告监控提醒</h1>
            <p>检测到 <strong>{count}</strong> 条重要公告,详情如下:</p>
            <table>
                <thead>
                    <tr>
                        <th>优先级</th>
                        <th>股票</th>
                        <th>公告标题</th>
                        <th>发布时间</th>
                        <th>匹配关键词</th>
                    </tr>
                </thead>
                <tbody>
                    {rows}
                </tbody>
            </table>
            <div class="footer">
                <p>本邮件由公司公告监控系统自动发送,请勿回复</p>
                <p>如需停止接收,请联系管理员</p>
            </div>
        </body>
        </html>
        """
        
        # 生成表格行
        rows = []
        for announcement in announcements:
            match_result = announcement.get('match_result', {})
            priority = match_result.get('priority', 'low')
            
            # 优先级样式
            priority_class = f"{priority}-priority"
            badge_class = f"badge-{priority}"
            priority_text = {
                'high': '高',
                'medium': '中',
                'low': '低'
            }.get(priority, '低')
            
            # 关键词列表
            keywords = ', '.join(match_result.get('keywords', []))
            
            # PDF链接
            pdf_url = announcement.get('pdf_url', '#')
            
            row = f"""
            <tr class="{priority_class}">
                <td><span class="badge {badge_class}">{priority_text}</span></td>
                <td><strong>{announcement.get('stock_name', '')}({announcement.get('stock_code', '')})</strong></td>
                <td><a href="{pdf_url}" target="_blank">{announcement.get('title', '')}</a></td>
                <td>{announcement.get('publish_time', '')}</td>
                <td>{keywords}</td>
            </tr>
            """
            rows.append(row)
        
        # 填充模板
        html_content = html.format(
            count=len(announcements),
            rows=''.join(rows)
        )
        
        return html_content
    
    def send(self, announcements: List[Dict], subject: str = None) -> bool:
        """
        发送邮件
        
        Args:
            announcements: 公告列表
            subject: 邮件主题(可选)
            
        Returns:
            成功返回True,失败返回False
        """
        if not announcements:
            print("⚠️ 没有公告需要发送")
            return False
        
        try:
            # 创建邮件对象
            message = MIMEMultipart('alternative')
            
            # 设置主题
            if not subject:
                count = len(announcements)
                high_count = sum(1 for a in announcements if a.get('match_result', {}).get('priority') == 'high')
                subject = f"【重要】检测到{count}条公告提醒(高优先级{high_count}条)"
            
            message['Subject'] = Header(subject, 'utf-8')
            message['From'] = Header(f"公告监控 <{self.sender}>", 'utf-8')
            message['To'] = ', '.join(self.receivers)
            
            # 创建HTML内容
            html_content = self.create_html_content(announcements)
            html_part = MIMEText(html_content, 'html', 'utf-8')
            message.attach(html_part)
            
            # 连接SMTP服务器并发送
            context = ssl.create_default_context()
            
            with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, context=context) as server:
                server.login(self.sender, self.password)
                server.sendmail(self.sender, self.receivers, message.as_string())
            
            print(f"✅ 邮件发送成功: {len(announcements)}条公告")
            return True
            
        except smtplib.SMTPAuthenticationError:
            print("❌ 邮件发送失败: 认证错误,请检查邮箱和授权码")
            return False
        except Exception as e:
            print(f"❌ 邮件发送失败: {str(e)}")
            return False


# notifier/wechat_bot.py
import requests
import json
from typing import List, Dict
from config.settings import WECHAT_WEBHOOK

class WechatNotifier:
    """企业微信机器人通知器"""
    
    def __init__(self, webhook_url: str = WECHAT_WEBHOOK):
        """
        初始化企业微信机器人
        
        Args:
            webhook_url: 机器人Webhook地址
            
        获取方式:
            1. 打开企业微信群聊
            2. 右键 → 添加群机器人
            3. 复制Webhook地址
        """
        self.webhook_url = webhook_url
    
    def create_markdown_content(self, announcements: List[Dict]) -> str:
        """
        创建Markdown格式的消息内容
        
        Args:
            announcements: 公告列表
            
        Returns:
            Markdown字符串
            
        企业微信Markdown支持:
            - 标题: # ## ###
            - 加粗: **文本**
            - 链接: [文本](URL)
            - 引用: > 文本
            - 代码: `代码`
        """
        # 统计信息
        high_count = sum(1 for a in announcements if a.get('match_result', {}).get('priority') == 'high')
        medium_count = sum(1 for a in announcements if a.get('match_result', {}).get('priority') == 'medium')
        
        # 构造Markdown
        markdown = f"""## 🔔 公司公告监控提醒

> 检测到 **{len(announcements)}** 条重要公告
> 高优先级: **{high_count}** 条 | 中优先级: **{medium_count}** 条

 

"""
        
        # 添加公告列表
        for i, announcement in enumerate(announcements, 1):
            match_result = announcement.get('match_result', {})
            priority = match_result.get('priority', 'low')
            
            # 优先级图标
            icon = {
                'high': '🔴',
                'medium': '🟠',
                'low': '⚪'
            }.get(priority, '⚪')
            
            # 关键词
            keywords = ', '.join(match_result.get('keywords', []))
            
            # 公告信息
            stock_info = f"{ '')}({announcement.get('stock_code', '')})"
            title = announcement.get('title', '')
            publish_time = announcement.get('publish_time', '')
            pdf_url = announcement.get('pdf_url', '')
            
            markdown += f"""### {icon} {i}. {stock_info}

**标题**: [{title}]({pdf_url})
**时间**: {publish_time}
**关键词**: `{keywords}`

 

"""
        
        markdown += "\n> 本消息由公告监控系统自动发送"
        
        return markdown
    
    def send(self, announcements: List[Dict]) -> bool:
        """
        发送企业微信消息
        
        Args:
            announcements: 公告列表
            
        Returns:
            成功返回True,失败返回False
            
        消息类型:
            - text: 文本消息
            - markdown: Markdown消息(推荐)
            - news: 图文消息
        """
        if not announcements:
            print("⚠️ 没有公告需要发送")
            return False
        
        try:
            # 创建Markdown内容
            markdown_content = self.create_markdown_content(announcements)
            
            # 构造请求数据
            data = {
                "msgtype": "markdown",
                "markdown": {
                    "content": markdown_content
                }
            }
            
            # 发送请求
            response = requests.post(
                self.webhook_url,
                json=data,
                headers={'Content-Type': 'application/json'},
                timeout=10
            )
            
            # 检查响应
            result = response.json()
            if result.get('errcode') == 0:
                print(f"✅ 企业微信消息发送成功: {len(announcements)}条公告")
                return True
            else:
                print(f"❌ 企业微信消息发送失败: {result.get('errmsg')}")
                return False
                
        except Exception as e:
            print(f"❌ 企业微信消息发送异常: {str(e)}")
            return False


# notifier/dingtalk_bot.py
import requests
import time
import hmac
import hashlib
import base64
from urllib.parse import quote_plus
from typing import List, Dict
from config.settings import DINGTALK_WEBHOOK

class DingtalkNotifier:
    """钉钉机器人通知器"""
    
    def __init__(self, webhook_url: str = DINGTALK_WEBHOOK, secret: str = ''):
        """
        初始化钉钉机器人
        
        Args:
            webhook_url: 机器人Webhook地址
            secret: 加签密钥(安全设置中获取)
        """
        self.webhook_url = webhook_url
        self.secret = secret
    
    def _generate_sign(self) -> tuple:
        """
        生成加签参数
        
        Returns:
            (timestamp, sign)
            
        算法说明:
            1. 获取当前时间戳(毫秒)
            2. 拼接字符串: timestamp + "\n" + secret
            3. 使用HmacSHA256计算签名
            4. Base64编码
            5. URL编码
        """
        timestamp = str(round(time.time() * 1000))
        secret_enc = self.secret.encode('utf-8')
        string_to_sign = f'{timestamp}\n{self.secret}'
        string_to_sign_enc = string_to_sign.encode('utf-8')
        
        hmac_code = hmac.new(
            secret_enc,
            string_to_sign_enc,
            digestmod=hashlib.sha256
        ).digest()
        
        sign = quote_plus(base64.b64encode(hmac_code))
        
        return timestamp, sign
    
    def send(self, announcements: List[Dict]) -> bool:
        """
        发送钉钉消息
        
        Args:
            announcements: 公告列表
            
        Returns:
            成功返回True,失败返回False
        """
        if not announcements:
            return False
        
        try:
            # 生成加签
            timestamp, sign = self._generate_sign() if self.secret else ('', '')
            
            # 构造URL
            url = self.webhook_url
            if self.secret:
                url += f"&timestamp={timestamp}&sign={sign}"
            
            # 构造消息内容(使用Markdown)
            markdown_content = self._create_markdown(announcements)
            
            data = {
                "msgtype": "markdown",
                "markdown": {
                    "title": "公司公告监控提醒",
                    "text": markdown_content
                }
            }
            
            response = requests.post(url, json=data, timeout=10)
            result = response.json()
            
            if result.get('errcode') == 0:
                print(f"✅ 钉钉消息发送成功")
                return True
            else:
                print(f"❌ 钉钉消息发送失败: {result.get('errmsg')}")
                return False
                
        except Exception as e:
            print(f"❌ 钉钉消息发送异常: {str(e)}")
            return False
    
    def _create_markdown(self, announcements: List[Dict]) -> str:
        """创建Markdown内容(类似企业微信)"""
        # 实现与WechatNotifier类似,省略...
        pass


# ========== 统一通知管理器 ==========
# notifier/__init__.py
from .email_sender import EmailNotifier
from .wechat_bot import WechatNotifier
from .dingtalk_bot import DingtalkNotifier
from config.settings import ENABLE_EMAIL, ENABLE_WECHAT, ENABLE_DINGTALK

class NotificationManager:
    """统一通知管理器"""
    
    def __init__(self):
        """初始化所有启用的通知渠道"""
        self.notifiers = []
        
        if ENABLE_EMAIL:
            self.notifiers.append(EmailNotifier())
            print("✅ 邮件通知已启用")
        
        if ENABLE_WECHAT:
            self.notifiers.append(WechatNotifier())
            print("✅ 企业微信通知已启用")
        
        if ENABLE_DINGTALK:
            self.notifiers.append(DingtalkNotifier())
            print("✅ 钉钉通知已启用")
    
    def send_all(self, announcements: List[Dict]) -> dict:
        """
        通过所有渠道发送通知
        
        Args:
            announcements: 公告列表
            
        Returns:
            各渠道发送结果
        """
        results = {}
        
        for notifier in self.notifiers:
            notifier_name = notifier.__class__.__name__
            success = notifier.send(announcements)
            results[notifier_name] = success
        
        return results

代码详解

1. SMTP邮件发送流程
python 复制代码
# 1. 创建邮件对象
message = MIMEMultipart('alternative')  # 支持多种格式(纯文本/HTML)

# 2. 设置头部
message['Subject'] = "主题"
message['From'] = "sender@example.com"
message['To'] = "receiver@example.com"

# 3. 添加HTML内容
html_part = MIMEText('<h1>Hello</h1>', 'html', 'utf-8')
message.attach(html_part)

# 4. 连接SMTP服务器
with smtplib.SMTP_SSL('smtp.qq.com', 465) as server:
    server.login('sender@qq.com', 'auth_code')  # 授权码
    server.sendmail('sender', ['receiver'], message.as_string())

为什么用授权码而不是密码?

  • 邮箱密码泄露风险高
  • 授权码可以单独撤销,不影响邮箱登录
  • 符合安全最佳实践
2. 企业微信加签算法

虽然企业微信机器人目前不强制加签,但未来可能会要求,预留代码:

python 复制代码
import hmac
import hashlib
import base64
import time

def sign_wechat_webhook(secret: str) -> dict:
    """生成企业微信Webhook签名参数"""
    timestamp = int(time.time())
    string_to_sign = f"{timestamp}\n{secret}"
    
    hmac_code = hmac.new(
        secret.encode('utf-8'),
        string_to_sign.encode('utf-8'),
        digestmod=hashlib.sha256
    ).digest()
    
    sign = base64.b64encode(hmac_code).decode('utf-8')
    
    return {
        'timestamp': timestamp,
        'sign': sign
    }
3. HTML邮件样式技巧
html 复制代码
<!-- 使用内联样式(inline style)而不是CSS类 -->
<!-- 因为很多邮箱客户端会过滤<style>标签 -->

<!-- ❌ 不推荐 -->
<style>
  .highlight { color: red; }
</style>
<div class="highlight">重要</div>

<!-- ✅ 推荐 -->
<div style="color: red; font-weight: bold;">重要</div>

响应式邮件设计:

html 复制代码
<table style="width: 100%; max-width: 600px; margin: 0 auto;">
  <!-- 内容 -->
</table>

<!-- max-width确保在手机上不会太宽 -->
<!-- margin: 0 auto居中显示 -->

9️⃣ 核心实现: 数据库与缓存管理

数据库设计

python 复制代码
# storage/database.py
import sqlite3
from datetime import datetime
from typing import List, Dict, Optional
from config.settings import DB_PATH
import json

class AnnouncementDatabase:
    """公告数据库管理类"""
    
    def __init__(self, db_path: str = DB_PATH):
        self.db_path = db_path
        self._init_database()
    
    def _init_database(self):
        """
        初始化数据库表结构
        
        表设计说明:
            1. announcements表: 存储所有公告记录
            2. notifications表: 存储已发送的通知记录
            3. stocks表: 存储监控的股票列表
        """
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        # 创建公告表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS announcements (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            announcement_id TEXT UNIQUE NOT NULL,  -- 巨潮公告ID
            stock_code TEXT NOT NULL,               -- 股票代码
            stock_name TEXT,                        -- 股票名称
            title TEXT NOT NULL,                    -- 公告标题
            publish_time TEXT NOT NULL,             -- 发布时间
            announcement_type TEXT,                 -- 公告类型
            pdf_url TEXT,                           -- PDF链接
            pdf_size_kb INTEGER DEFAULT 0,          -- PDF大小
            
            -- 匹配结果字段
            is_matched BOOLEAN DEFAULT 0,           -- 是否命中关键词
            match_categories TEXT,                  -- 命中的类别(JSON)
            match_keywords TEXT,                    -- 命中的关键词(JSON)
            match_score REAL DEFAULT 0.0,           -- 匹配得分
            priority TEXT DEFAULT 'low',            -- 优先级
            is_negative BOOLEAN DEFAULT 0,          -- 是否负面
            
            -- 管理字段
            crawl_time TEXT NOT NULL,               -- 抓取时间
            is_notified BOOLEAN DEFAULT 0,          -- 是否已通知
            notify_time TEXT,                       -- 通知时间
            
            -- 索引字段
            UNIQUE(announcement_id)
        )
        ''')
        
        # 创建索引(加速查询)
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_stock_code ON announcements(stock_code)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_publish_time ON announcements(publish_time DESC)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_is_matched ON announcements(is_matched)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_is_notified ON announcements(is_notified)')
        
        # 创建通知记录表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS notifications (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            announcement_id TEXT NOT NULL,          -- 关联的公告ID
            notify_channel TEXT NOT NULL,           -- 通知渠道(email/wechat/dingtalk)
            notify_time TEXT NOT NULL,              -- 通知时间
            notify_status TEXT DEFAULT 'success',   -- 通知状态(success/failed)
            error_msg TEXT,                         -- 错误信息
            
            FOREIGN KEY (announcement_id) REFERENCES announcements(announcement_id)
        )
        ''')
        
        # 创建股票监控表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS monitored_stocks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            stock_code TEXT UNIQUE NOT NULL,
            stock_name TEXT,
            monitor_enabled BOOLEAN DEFAULT 1,      -- 是否启用监控
            priority_keywords TEXT,                 -- 优先关键词(JSON)
            add_time TEXT NOT NULL,                 -- 添加时间
            last_check_time TEXT,                   -- 最后检查时间
            
            UNIQUE(stock_code)
        )
        ''')
        
        conn.commit()
        conn.close()
        
        print(f"✅ 数据库初始化完成: {self.db_path}")
    
    def insert_announcement(self, data: Dict) -> bool:
        """
        插入公告记录
        
        Args:
            data: 公告数据字典(包含match_result)
            
        Returns:
            成功返回True,失败返回False
        """
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        try:
            current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            match_result = data.get('match_result', {})
            
            # 将列表转为JSON字符串存储
            match_categories = json.dumps(match_result.get('categories', []), ensure_ascii=False)
            match_keywords = json.dumps(match_result.get('keywords', []), ensure_ascii=False)
            
            cursor.execute('''
            INSERT INTO announcements (
                announcement_id, stock_code, stock_name, title, publish_time,
                announcement_type, pdf_url, pdf_size_kb,
                is_matched, match_categories, match_keywords, match_score,
                priority, is_negative, crawl_time
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ''', (
                data.get('announcement_id'),
                data.get('stock_code'),
                data.get('stock_name'),
                data.get('title'),
                data.get('publish_time'),
                data.get('announcement_type'),
                data.get('pdf_url'),
                data.get('pdf_size_kb', 0),
                match_result.get('matched', False),
                match_categories,
                match_keywords,
                match_result.get('score', 0.0),
                match_result.get('priority', 'low'),
                match_result.get('is_negative', False),
                current_time
            ))
            
            conn.commit()
            return True
            
        except sqlite3.IntegrityError:
            # 公告已存在,跳过
            return False
        except Exception as e:
            print(f"❌ 插入公告失败: {str(e)}")
            return False
        finally:
            conn.close()
    
    def is_announcement_exists(self, announcement_id: str) -> bool:
        """检查公告是否已存在"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        cursor.execute('SELECT COUNT(*) FROM announcements WHERE announcement_id = ?', (announcement_id,))
        count = cursor.fetchone()[0]
        
        conn.close()
        return count > 0
    
    def get_unnotified_announcements(self, min_priority: str = 'medium') -> List[Dict]:
        """
        获取未通知的公告
        
        Args:
            min_priority: 最低优先级
            
        Returns:
            公告列表
        """
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        
        priority_order = "CASE priority WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END"
        min_level = {'high': 3, 'medium': 2, 'low': 1}.get(min_priority, 2)
        
        cursor.execute(f'''
        SELECT * FROM announcements
        WHERE is_matched = 1 AND is_notified = 0 AND {priority_order} >= ?
        ORDER BY priority DESC, match_score DESC, publish_time DESC
        ''', (min_level,))
        
        results = []
        for row in cursor.fetchall():
            announcement = dict(row)
            # 解析JSON字段
            announcement['match_categories'] = json.loads(announcement['match_categories'])
            announcement['match_keywords'] = json.loads(announcement['match_keywords'])
            results.append(announcement)
        
        conn.close()
        return results
    
    def mark_as_notified(self, announcement_ids: List[str], channel: str = 'email') -> int:
        """
        标记公告为已通知
        
        Args:
            announcement_ids: 公告ID列表
            channel: 通知渠道
            
        Returns:
            成功标记的数量
        """
        if not announcement_ids:
            return 0
        
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        notify_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        success_count = 0
        
        for announcement_id in announcement_ids:
            try:
                # 更新announcements表
                cursor.execute('''
                UPDATE announcements
                SET is_notified = 1, notify_time = ?
                WHERE announcement_id = ?
                ''', (notify_time, announcement_id))
                
                # 插入通知记录
                cursor.execute('''
                INSERT INTO notifications (announcement_id, notify_channel, notify_time)
                VALUES (?, ?, ?)
                ''', (announcement_id, channel, notify_time))
                
                success_count += 1
            except Exception as e:
                print(f"❌ 标记失败: {announcement_id} | {str(e)}")
        
        conn.commit()
        conn.close()
        
        return success_count
    
    def get_statistics(self) -> Dict:
        """获取统计信息"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        stats = {}
        
        # 总公告数
        cursor.execute('SELECT COUNT(*) FROM announcements')
        stats['total_announcements'] = cursor.fetchone()[0]
        
        # 匹配公告数
        cursor.execute('SELECT COUNT(*) FROM announcements WHERE is_matched = 1')
        stats['matched_announcements'] = cursor.fetchone()[0]
        
        # 各优先级统计
        cursor.execute('''
        SELECT priority, COUNT(*) as count
        FROM announcements
        WHERE is_matched = 1
        GROUP BY priority
        ''')
        stats['by_priority'] = {row[0]: row[1] for row in cursor.fetchall()}
        
        # 今日新增
        today = datetime.now().strftime('%Y-%m-%d')
        cursor.execute('SELECT COUNT(*) FROM announcements WHERE crawl_time LIKE ?', (f'{today}%',))
        stats['today_new'] = cursor.fetchone()[0]
        
        conn.close()
        return stats


# storage/cache_manager.py
import json
import os
from datetime import datetime, timedelta
from typing import Dict, Optional

class CacheManager:
    """缓存管理器(用于存储API响应,减少重复请求)"""
    
    def __init__(self, cache_dir: str = 'data/cache'):
        self.cache_dir = cache_dir
        os.makedirs(cache_dir, exist_ok=True)
    
    def get_cache_key(self, stock_code: str, date: str) -> str:
        """生成缓存键"""
        return f"{stock_code}_{date}.json"
    
    def get(self, stock_code: str, date: str = None) -> Optional[Dict]:
        """获取缓存"""
        if not date:
            date = datetime.now().strftime('%Y-%m-%d')
        
        cache_key = self.get_cache_key(stock_code, date)
        cache_file = os.path.join(self.cache_dir, cache_key)
        
        if not os.path.exists(cache_file):
            return None
        
        # 检查缓存是否过期(超过1小时)
        file_time = datetime.fromtimestamp(os.path.getmtime(cache_file))
        if datetime.now() - file_time > timedelta(hours=1):
            return None
        
        with open(cache_file, 'r', encoding='utf-8') as f:
            return json.load(f)
    
    def set(self, stock_code: str, data: Dict, date: str = None):
        """设置缓存"""
        if not date:
            date = datetime.now().strftime('%Y-%m-%d')
        
        cache_key = self.get_cache_key(stock_code, date)
        cache_file = os.path.join(self.cache_dir, cache_key)
        
        with open(cache_file, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
    
    def clear_old_cache(self, days: int = 7):
        """清理N天前的缓存"""
        threshold = datetime.now() - timedelta(days=days)
        
        for filename in os.listdir(self.cache_dir):
            filepath = os.path.join(self.cache_dir, filename)
            file_time = datetime.fromtimestamp(os.path.getmtime(filepath))
            
            if file_time < threshold:
                os.remove(filepath)
                print(f"🗑️ 删除过期缓存: {filename}")

【】

好的!让我继续完成剩余的第10-14章,并将完整的教程保存到文件中!

markdown 复制代码
# Python爬虫实战: 上市公司公告智能监控与关键词提醒系统(续 - 第10-14章)

## 🔟 核心实现: 定时任务调度系统

### 设计思路

定时任务需要考虑:
- **智能频率调整**: 交易时间内更频繁,非交易时间降低频率
- **异常处理**: 网络异常时自动重试
- **资源管理**: 避免任务堆积,控制并发
- **日志记录**: 每次执行的详细记录

### 完整代码实现

```python
# scheduler/task_scheduler.py
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_ERROR
from datetime import datetime, timedelta
import logging
from typing import List, Dict
import json

from scraper.cninfo_api import CninfoAPIFetcher
from matcher.keyword_matcher import KeywordMatcher
from notifier import NotificationManager
from storage.database import AnnouncementDatabase
from config.settings import (
    MONITOR_INTERVAL, WORK_DAYS, WORK_START_HOUR, WORK_END_HOUR,
    INTENSIVE_INTERVAL
)

class AnnouncementMonitor:
    """公告监控主控制器"""
    
    def __init__(self):
        """
        初始化监控器
        
        组件说明:
            - fetcher: 数据抓取器
            - matcher: 关键词匹配器
            - notifier: 通知管理器
            - database: 数据库管理器
            - scheduler: 定时调度器
        """
        # 初始化各组件
        self.fetcher = CninfoAPIFetcher()
        self.matcher = KeywordMatcher()
        self.notifier = NotificationManager()
        self.database = AnnouncementDatabase()
        self.scheduler = BlockingScheduler()
        
        # 配置日志
        self._setup_logging()
        
        # 加载监控股票列表
        self.monitored_stocks = self._load_stock_list()
        
        print("✅ 监控器初始化完成")
    
    def _setup_logging(self):
        """配置日志系统"""
        from logging.handlers import RotatingFileHandler
        from config.settings import LOG_FILE, LOG_LEVEL, LOG_FORMAT, LOG_MAX_BYTES, LOG_BACKUP_COUNT
        
        logger = logging.getLogger('announcement_monitor')
        logger.setLevel(LOG_LEVEL)
        
        # 文件处理器(自动轮转)
        file_handler = RotatingFileHandler(
            LOG_FILE,
            maxBytes=LOG_MAX_BYTES,
            backupCount=LOG_BACKUP_COUNT,
            encoding='utf-8'
        )
        file_handler.setLevel(LOG_LEVEL)
        
        # 控制台处理器
        console_handler = logging.StreamHandler()
        console_handler.setLevel(LOG_LEVEL)
        
        # 格式化
        formatter = logging.Formatter(LOG_FORMAT)
        file_handler.setFormatter(formatter)
        console_handler.setFormatter(formatter)
        
        logger.addHandler(file_handler)
        logger.addHandler(console_handler)
        
        self.logger = logger
    
    def _load_stock_list(self) -> List[Dict]:
        """
        加载监控股票列表
        
        Returns:
            股票列表,每个元素包含:
            {
                'code': '600519',
                'name': '贵州茅台',
                'monitor': True,
                'priority_keywords': ['分红', '回购']
            }
        """
        try:
            with open('config/stock_list.json', 'r', encoding='utf-8') as f:
                config = json.load(f)
            
            stocks = [s for s in config.get('stocks', []) if s.get('monitor', True)]
            
            self.logger.info(f"✅ 已加载 {len(stocks)} 只监控股票")
            return stocks
            
        except FileNotFoundError:
            self.logger.warning("⚠️ 股票列表文件不存在,使用空列表")
            return []
        except Exception as e:
            self.logger.error(f"❌ 加载股票列表失败: {str(e)}")
            return []
    
    def fetch_and_process(self, stock_code: str = '', days_back: int = 1) -> List[Dict]:
        """
        抓取并处理公告
        
        Args:
            stock_code: 股票代码(空表示全市场)
            days_back: 查询最近N天
            
        Returns:
            新公告列表(已匹配关键词且未通知的)
            
        流程:
            1. 调用API获取公告
            2. 过滤已存在的公告
            3. 关键词匹配
            4. 存入数据库
            5. 返回需要通知的公告
        """
        self.logger.info(f"🔍 开始抓取公告: {stock_code or '全市场'}")
        
        # 计算时间范围
        end_date = datetime.now().strftime('%Y-%m-%d')
        start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d')
        
        # 调用API
        raw_announcements = self.fetcher.query_all_pages(
            stock_code=stock_code,
            start_date=start_date,
            end_date=end_date,
            max_results=100
        )
        
        if not raw_announcements:
            self.logger.warning(f"⚠️ 未获取到公告: {stock_code}")
            return []
        
        # 解析公告
        announcements = [self.fetcher.parse_announcement(a) for a in raw_announcements]
        
        # 过滤已存在的
        new_announcements = []
        for announcement in announcements:
            announcement_id = announcement['announcement_id']
            if not self.database.is_announcement_exists(announcement_id):
                new_announcements.append(announcement)
        
        self.logger.info(f"📊 获取 {len(announcements)} 条,新增 {len(new_announcements)} 条")
        
        if not new_announcements:
            return []
        
        # 关键词匹配
        matched_announcements = self.matcher.batch_match(new_announcements)
        
        # 存入数据库
        for announcement in matched_announcements:
            self.database.insert_announcement(announcement)
        
        # 统计匹配情况
        matched_count = sum(1 for a in matched_announcements if a.get('match_result', {}).get('matched'))
        self.logger.info(f"✅ 关键词匹配: {matched_count}/{len(matched_announcements)} 条命中")
        
        # 返回需要通知的(匹配且未通知)
        important_announcements = [
            a for a in matched_announcements
            if a.get('match_result', {}).get('matched') and a.get('match_result', {}).get('priority') in ['high', 'medium']
        ]
        
        return important_announcements
    
    def monitor_task(self):
        """
        监控任务(定时执行)
        
        流程:
            1. 遍历监控股票列表
            2. 抓取每只股票的最新公告
            3. 汇总所有需要通知的公告
            4. 发送通知
            5. 标记为已通知
        """
        start_time = datetime.now()
        self.logger.info("=" * 80)
        self.logger.info(f"🚀 开始执行监控任务: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
        self.logger.info("=" * 80)
        
        all_important_announcements = []
        
        # 遍历监控股票
        for stock in self.monitored_stocks:
            stock_code = stock['code']
            stock_name = stock['name']
            
            try:
                important_announcements = self.fetch_and_process(stock_code, days_back=1)
                
                if important_announcements:
                    self.logger.info(f"🎯 {stock_name}({stock_code}): 发现 {len(important_announcements)} 条重要公告")
                    all_important_announcements.extend(important_announcements)
                
            except Exception as e:
                self.logger.error(f"❌ 处理失败 {stock_name}({stock_code}): {str(e)}")
        
        # 如果有重要公告,发送通知
        if all_important_announcements:
            self.logger.info(f"📢 共发现 {len(all_important_announcements)} 条重要公告,准备发送通知")
            
            # 发送通知
            results = self.notifier.send_all(all_important_announcements)
            
            # 标记为已通知
            announcement_ids = [a['announcement_id'] for a in all_important_announcements]
            self.database.mark_as_notified(announcement_ids)
            
            # 记录结果
            for channel, success in results.items():
                status = "✅成功" if success else "❌失败"
                self.logger.info(f"{channel}: {status}")
        else:
            self.logger.info("ℹ️ 本次未发现需要通知的重要公告")
        
        # 执行耗时
        elapsed = (datetime.now() - start_time).total_seconds()
        self.logger.info(f"⏱️ 任务完成,耗时 {elapsed:.2f} 秒")
        self.logger.info("=" * 80 + "\n")
    
    def _is_work_time(self) -> bool:
        """
        判断当前是否为工作时间
        
        Returns:
            True表示当前是交易日的交易时间
            
        规则:
            - 周一至周五
            - 9:00 - 15:00
        """
        now = datetime.now()
        
        # 检查是否为工作日
        if now.weekday() not in WORK_DAYS:
            return False
        
        # 检查时间范围
        current_hour = now.hour
        if WORK_START_HOUR <= current_hour < WORK_END_HOUR:
            return True
        
        return False
    
    def start(self):
        """
        启动监控服务
        
        调度策略:
            - 工作日交易时间: 每10分钟执行一次
            - 其他时间: 每30分钟执行一次
        """
        self.logger.info("🎬 启动公告监控服务...")
        self.logger.info(f"📋 监控股票数量: {len(self.monitored_stocks)}")
        self.logger.info(f"⏰ 交易时间频率: 每 {INTENSIVE_INTERVAL} 分钟")
        self.logger.info(f"⏰ 非交易时间频率: 每 {MONITOR_INTERVAL} 分钟")
        
        # 添加定时任务(动态调整频率)
        def dynamic_task():
            """动态调整执行频率的任务"""
            if self._is_work_time():
                # 交易时间,立即执行
                self.monitor_task()
            else:
                # 非交易时间,检查是否到了执行时间
                now = datetime.now()
                if now.minute % MONITOR_INTERVAL == 0:
                    self.monitor_task()
        
        # 使用cron表达式配置任务
        # 交易时间: 每10分钟执行
        self.scheduler.add_job(
            self.monitor_task,
            'cron',
            day_of_week='mon-fri',
            hour=f'{WORK_START_HOUR}-{WORK_END_HOUR-1}',
            minute=f'*/{INTENSIVE_INTERVAL}',
            id='intensive_monitor'
        )
        
        # 非交易时间: 每30分钟执行
        self.scheduler.add_job(
            self.monitor_task,
            'interval',
            minutes=MONITOR_INTERVAL,
            id='normal_monitor'
        )
        
        # 添加事件监听器
        self.scheduler.add_listener(self._job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
        
        # 启动时立即执行一次
        self.logger.info("🔄 执行首次检查...")
        self.monitor_task()
        
        # 启动调度器
        try:
            self.logger.info("✅ 调度器已启动,按 Ctrl+C 停止")
            self.scheduler.start()
        except (KeyboardInterrupt, SystemExit):
            self.logger.info("⏹️ 监控服务已停止")
    
    def _job_listener(self, event):
        """
        任务执行事件监听器
        
        Args:
            event: 事件对象
        """
        if event.exception:
            self.logger.error(f"❌ 任务执行异常: {event.exception}")
        else:
            self.logger.debug(f"✅ 任务执行成功: {event.job_id}")


# ========== 使用示例 ==========
if __name__ == '__main__':
    monitor = AnnouncementMonitor()
    monitor.start()

代码详解

1. APScheduler的Cron表达式
python 复制代码
# 格式: 分 时 日 月 周
scheduler.add_job(
    func,
    'cron',
    day_of_week='mon-fri',    # 周一到周五
    hour='9-14',              # 9点到14点
    minute='*/10'             # 每10分钟
)

# 等价于Linux cron: */10 9-14 * * 1-5

常用示例:

python 复制代码
# 每天8:30执行
scheduler.add_job(func, 'cron', hour=8, minute=30)

# 每周一9:00执行
scheduler.add_job(func, 'cron', day_of_week='mon', hour=9, minute=0)

# 工作日每2小时执行
scheduler.add_job(func, 'cron', day_of_week='mon-fri', hour='*/2')
2. 为什么要动态调整频率?
python 复制代码
# 场景1: 交易时间(9:00-15:00)
# 公告发布频繁,需要每10分钟检查

# 场景2: 非交易时间(晚上/周末)
# 公告很少,每30分钟检查节省资源

# 节省的资源:
# 每天节约请求次数 = (24 - 6) * (60/10 - 60/30) = 18 * 4 = 72次
# 降低约75%的无效请求
3. 日志轮转机制
python 复制代码
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler(
    'monitor.log',
    maxBytes=10*1024*1024,  # 单文件最大10MB
    backupCount=5           # 保留5个备份
)

# 文件结构:
# monitor.log          (当前文件)
# monitor.log.1        (最近的备份)
# monitor.log.2
# monitor.log.3
# monitor.log.4
# monitor.log.5        (最老的备份)

# 当monitor.log达到10MB时:
# monitor.log → monitor.log.1
# monitor.log.1 → monitor.log.2
# ...
# monitor.log.5 → 删除

1️⃣1️⃣ 主程序整合与运行示例

主程序入口

python 复制代码
# main.py
import argparse
import sys
from scheduler.task_scheduler import AnnouncementMonitor
from storage.database import AnnouncementDatabase
from scraper.cninfo_api import CninfoAPIFetcher
from matcher.keyword_matcher import KeywordMatcher

def run_monitor():
    """运行监控服务(常驻进程)"""
    monitor = AnnouncementMonitor()
    monitor.start()

def run_once(stock_code: str = '', days: int = 7):
    """单次执行(测试用)"""
    print(f"🔍 单次查询: 股票={stock_code or '全市场'}, 天数={days}")
    
    # 初始化组件
    fetcher = CninfoAPIFetcher()
    matcher = KeywordMatcher()
    database = AnnouncementDatabase()
    
    # 查询公告
    from datetime import datetime, timedelta
    end_date = datetime.now().strftime('%Y-%m-%d')
    start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
    
    raw_announcements = fetcher.query_all_pages(
        stock_code=stock_code,
        start_date=start_date,
        end_date=end_date,
        max_results=50
    )
    
    # 解析并匹配
    announcements = [fetcher.parse_announcement(a) for a in raw_announcements]
    matched = matcher.batch_match(announcements)
    
    # 显示结果
    important = [a for a in matched if a.get('match_result', {}).get('matched')]
    
    print(f"\n📊 查询结果:")
    print(f"总数: {len(announcements)} 条")
    print(f"匹配: {len(important)} 条")
    
    if important:
        print(f"\n重要公告:")
        for i, announcement in enumerate(important[:10], 1):
            match_result = announcement['match_result']
            print(f"\n{i}. {announcement['title']}")
            print(f"   股票: {announcement['stock_name']}({announcement['stock_code']})")
            print(f"   时间: {announcement['publish_time']}")
            print(f"   关键词: {', '.join(match_result['keywords'])}")
            print(f"   优先级: {match_result['priority']} (得分:{match_result['score']:.1f})")

def show_stats():
    """显示统计信息"""
    database = AnnouncementDatabase()
    stats = database.get_statistics()
    
    print("\n" + "=" * 60)
    print("📈 数据库统计信息")
    print("=" * 60)
    print(f"总公告数: {stats['total_announcements']}")
    print(f"匹配公告数: {stats['matched_announcements']}")
    print(f"今日新增: {stats['today_new']}")
    print(f"\n优先级分布:")
    for priority, count in stats['by_priority'].items():
        print(f"  {priority}: {count} 条")
    print("=" * 60)

def main():
    """主函数"""
    parser = argparse.ArgumentParser(
        description='公司公告智能监控系统',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog='''
使用示例:
  # 启动监控服务(常驻)
  python main.py --monitor

  # 单次查询贵州茅台最近7天公告
  python main.py --once --stock 600519 --days 7

  # 查看统计信息
  python main.py --stats
        '''
    )
    
    parser.add_argument('--monitor', action='store_true', help='启动监控服务')
    parser.add_argument('--once', action='store_true', help='单次执行(测试)')
    parser.add_argument('--stock', type=str, default='', help='股票代码')
    parser.add_argument('--days', type=int, default=7, help='查询天数')
    parser.add_argument('--stats', action='store_true', help='显示统计信息')
    
    args = parser.parse_args()
    
    if args.monitor:
        run_monitor()
    elif args.once:
        run_once(args.stock, args.days)
    elif args.stats:
        show_stats()
    else:
        parser.print_help()

if __name__ == '__main__':
    main()

运行示例

1. 启动监控服务
bash 复制代码
# 启动常驻监控
python main.py --monitor

# 输出示例:
🎬 启动公告监控服务...
📋 监控股票数量: 3
⏰ 交易时间频率: 每 10 分钟
⏰ 非交易时间频率: 每 30 分钟
✅ 邮件通知已启用
✅ 企业微信通知已启用
🔄 执行首次检查...
================================================================================
🚀 开始执行监控任务: 2026-01-29 10:00:00
================================================================================
🔍 开始抓取公告: 600519
📊 获取 15 条,新增 3 条
✅ 关键词匹配: 1/3 条命中
🎯 贵州茅台(600519): 发现 1 条重要公告
🔍 开始抓取公告: 000858
📊 获取 8 条,新增 2 条
✅ 关键词匹配: 1/2 条命中
🎯 五粮液(000858): 发现 1 条重要公告
📢 共发现 2 条重要公告,准备发送通知
✅ 邮件发送成功: 2条公告
✅ 企业微信消息发送成功: 2条公告
⏱️ 任务完成,耗时 12.35 秒
================================================================================

✅ 调度器已启动,按 Ctrl+C 停止
2. 单次测试查询
bash 复制代码
# 查询贵州茅台最近3天公告
python main.py --once --stock 600519 --days 3

# 输出:
🔍 单次查询: 股票=600519, 天数=3
🔍 开始搜索: 600519
📊 共找到 12 条公告
✅ 搜索完成,共获取 12 条数据

📊 查询结果:
总数: 12 条
匹配: 2 条

重要公告:

1. 关于2年度利润分配预案的公告
   股票: 贵州茅台(600519)
   时间: 2026-01-27 16:30:00
   关键词: 分红, 利润分配
   优先级: high (得分:19.0)

2. 关于回购公司股份的进展公告
   股票: 贵州茅台(600519)
   时间: 2026-01-28 09:15:00
   关键词: 回购, 股份回购
   优先级: high (得分:10.0)
3. 查看统计信息
bash 复制代码
python main.py --stats

# 输出:
============================================================
📈 数据库统计信息
============================================================
总公告数: 487
匹配公告数: 126
今日新增: 23

优先级分布:
  high: 35 条
  medium: 67 条
  low: 24 条
============================================================

后台运行(Linux)

bash 复制代码
# 使用nohup后台运行
nohup python main.py --monitor > monitor.out 2>&1 &

# 查看进程
ps aux | grep main.py

# 停止服务
kill -9 <PID>

# 查看日志
tail -f logs/monitor.log

使用systemd服务(推荐)

ini 复制代码
# /etc/systemd/system/announcement-monitor.service
[Unit]
Description=Stock Announcement Monitor Service
After=network.target

[Service]
Type=simple
User=your_username
WorkingDirectory=/path/to/announcement_monitor
ExecStart=/usr/bin/python3 /path/to/announcement_monitor/main.py --monitor
Restart=on-failure
RestartSec=10
StandardOutput=append:/path/to/announcement_monitor/logs/service.log
StandardError=append:/path/to/announcement_monitor/logs/service_error.log

[Install]
WantedBy=multi-user.target
bash 复制代码
# 启用服务
sudo systemctl daemon-reload
sudo systemctl enable announcement-monitor
sudo systemctl start announcement-monitor

# 查看状态
sudo systemctl status announcement-monitor

# 查看日志
sudo journalctl -u announcement-monitor -f

1️⃣2南

Q1: API返回 "您的访问过于频繁,请稍后再试"

原因: 请求频率过高,触发限流

解决方案:

python 复制代码
# 1. 增加延时
REQUEST_DELAY = (5, 10)  # 改为5-10秒

# 2. 降低监控频率
MONITOR_INTERVAL = 60  # 改为每小时

# 3. 使用代理IP池(高级)
proxies = {
    'http': 'http://proxy1.com:8080',
    'https': 'https://proxy1.com:8080'
}
response = session.post(url, data=data, proxies=proxies)

Q2: 邮件发送失败: SMTPAuthenticationError

原因:

  • 邮箱密码错误
  • 未开启SMTP服务
  • 使用了真实密码而非授权码

排查步骤:

python 复制代码
# 1. 确认SMTP服务已开启
# QQ邮箱: 设置 → 账户 → POP3/SMTP服务 → 生成授权码

# 2. 使用授权码而非密码
EMAIL_CONFIG = {
    'sender': 'your_email@qq.com',
    'password': 'abcdefghijklmnop'  # 16位授权码,不是邮箱密码!
}

# 3. 测试连接
import smtplib
try:
    server = smtplib.SMTP_SSL('smtp.qq.com', 465)
    server.login('your_email@qq.com', 'auth_code')
    print("✅ 连接成功")
    server.quit()
except Exception as e:
    print(f"❌ 连接失败: {e}")

常见邮箱SMTP配置:

邮箱 SMTP服务器 SSL端口 TLS端口
QQ邮箱 smtp.qq.com 465 587
网易163 smtp.163.com 465 587
Gmail smtp.gmail.com 465 587
Outlook smtp-mail.outlook.com 587 -

Q3: 企业微信消息发送失败

常见错误码:

python 复制代码
# errcode=93000: IP不在白名单
# 解决: 企业微信后台 → 机器人 → IP白名单添加服务器IP

# errcode=40035: 不合法的access_token
# 解决: 重新获取Webhook URL

# errcode=45009: 接口调用超限
# 解决: 降低发送频率,合并消息

Q4: 关键词匹配不准确

问题示例:

  • "不分红"被误判为正面信息
  • "分红预案"没有匹配到

优化方案:

python 复制代码
# 1. 完善负面词库
NEGATIVE_PATTERNS = [
    r'不.*?分红',
    r'未.*?分红',
    r'无.*?分红',
    r'取消.*?分红',
    r'暂不.*?分红',
    r'拟不.*?分红'
]

# 2. 添加同义词
SYNONYMS = {
    '分红': ['派息', '现金红利', '分红预案', '分红方案'],
    '回购': ['股份回购', '回购股份', '回购进展', '回购实施']
}

# 3. 调整模糊匹配阈值
FUZZY_THRESHOLD = 85  # 提高到85(更严格)

Q5: 数据库锁定错误: database is locked

原因: SQLite不支持高并发写入

解决方案:

python 复制代码
# 方案1: 增加超时时间
conn = sqlite3.connect('data.db', timeout=30.0)

# 方案2: 使用WAL模式(Write-Ahead Logging)
conn.execute('PRAGMA journal_mode=WAL')

# 方案3: 批量插入而非逐条插入
cursor.executemany('''
INSERT INTO announcements (...) VALUES (?, ?, ...)
''', data_list)

# 方案4: 升级到PostgreSQL/MySQL(生产环境推荐)

Q6: 内存占用持续增长

原因:

  • 日志文件过大未轮转
  • 缓存未清理
  • 对象未正确释放

解决方案:

python 复制代码
# 1. 启用日志轮转(已在代码中实现)
handler = RotatingFileHandler('monitor.log', maxBytes=10*1024*1024, backupCount=5)

# 2. 定期清理缓存
from storage.cache_manager import CacheManager
cache = CacheManager()
cache.clear_old_cache(days=7)  # 清理7天前的缓存

# 3. 显式关闭数据库连接
def query_data():
    conn = sqlite3.connect('data.db')
    try:
        # 查询操作
        pass
    finally:
        conn.close()  # 确保连接关闭

# 4. 监控内存使用
import psutil
process = psutil.Process()
print(f"内存占用: {process.memory_info().rss / 1024 / 1024:.2f} MB")

1️⃣3️⃣ 进阶优化与扩展

1. PDF正文提取(深度内容分析)

python 复制代码
# scraper/pdf_extractor.py
import PyPDF2
import requests
from io import BytesIO

class PDFExtractor:
    """PDF文本提取器"""
    
    @staticmethod
    def extract_text_from_url(pdf_url: str) -> str:
        """
        从URL提取PDF文本
        
        Args:
            pdf_url: PDF文件URL
            
        Returns:
            提取的文本内容
        """
        try:
            # 下载PDF
            response = requests.get(pdf_url, timeout=30)
            response.raise_for_status()
            
            # 读取PDF
            pdf_file = BytesIO(response.content)
            pdf_reader = PyPDF2.PdfReader(pdf_file)
            
            # 提取所有页面文本
            text = ''
            for page in pdf_reader.pages:
                text += page.extract_text()
            
            return text
            
        except Exception as e:
            print(f"❌ PDF提取失败: {str(e)}")
            return ''
    
    @staticmethod
    def extract_key_info(text: str) -> dict:
        """
        从文本中提取关键信息
        
        Args:
            text: PDF文本
            
        Returns:
            关键信息字典
        """
        import re
        
        info = {}
        
        # 提取分红金额
        dividend_pattern = r'每股派息.*?(\d+\.?\d*)元'
        match = re.search(dividend_pattern, text)
        if match:
            info['dividend_per_share'] = float(match.group(1))
        
        # 提取回购金额
        buyback_pattern = r'回购金额.*?不.*?超.*?(\d+\.?\d*)(亿|万)元'
        match = re.search(buyback_pattern, text)
        if match:
            amount = float(match.group(1))
            unit = match.group(2)
            info['buyback_amount'] = amount * 10000 if unit == '亿' else amount
        
        # 提取停牌时间
        suspension_pattern = r'停牌.*?(\d{4}-\d{2}-\d{2})'
        match = re.search(suspension_pattern, text)
        if match:
            info['suspension_date'] = match.group(1)
        
        return info


# 在关键词匹配器中使用
class KeywordMatcher:
    def match_announcement(self, title: str, pdf_url: str = '') -> Dict:
        # 先匹配标题
        title_result = self._match_title(title)
        
        # 如果标题匹配,进一步提取PDF正文
        if title_result['matched'] and pdf_url:
            extractor = PDFExtractor()
            pdf_text = extractor.extract_text_from_url(pdf_url)
            
            # 从正文提取结构化信息
            key_info = extractor.extract_key_info(pdf_text)
            title_result['key_info'] = key_info
        
        return title_result

2. 数据分析与可视化

python 复制代码
# analysis/visualizer.py
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from storage.database import AnnouncementDatabase

class AnnouncementAnalyzer:
    """公告数据分析器"""
    
    def __init__(self):
        self.database = AnnouncementDatabase()
    
    def load_data(self) -> pd.DataFrame:
        """从数据库加载数据为DataFrame"""
        import sqlite3
        
        conn = sqlite3.connect(self.database.db_path)
        df = pd.read_sql_query('SELECT * FROM announcements', conn)
        conn.close()
        
        return df
    
    def plot_announcement_trend(self, days: int = 30):
        """绘制公告发布趋势图"""
        df = self.load_data()
        
        # 转换时间
        df['publish_date'] = pd.to_datetime(df['publish_time']).dt.date
        
        # 按日期统计
        daily_counts = df.groupby('publish_date').size()
        
        # 绘图
        plt.figure(figsize=(12, 6))
        daily_counts.plot(kind='line', marker='o')
        plt.title(f'最近{days}天公告发布趋势')
        plt.xlabel('日期')
        plt.ylabel('公告数量')
        plt.grid(True, alpha=0.3)
        plt.tight_layout()
        plt.savefig('output/announcement_trend.png', dpi=300)
        plt.show()
    
    def plot_keyword_distribution(self):
        """绘制关键词分布饼图"""
        df = self.load_data()
        df_matched = df[df['is_matched'] == 1]
        
        # 统计各类别
        import json
        category_counts = {}
        for categories_json in df_matched['match_categories']:
            categories = json.loads(categories_json)
            for category in categories:
                category_counts[category] = category_counts.get(category, 0) + 1
        
        # 绘制饼图
        plt.figure(figsize=(10, 8))
        plt.pie(
            category_counts.values(),
            labels=category_counts.keys(),
            autopct='%1.1f%%',
            startangle=90
        )
        plt.title('关键词类.png', dpi=300)
        plt.show()
    
    def generate_report(self, output_path: str = 'output/weekly_report.md'):
        """生成周报"""
        df = self.load_data()
        
        # 统计数据
        total = len(df)
        matched = len(df[df['is_matched'] == 1])
        high_priority = len(df[df['priority'] == 'high'])
        
        # 生成Markdown报告
        report = f"""# 公告监控周报

## 📊 数据概览

- **总公告数**: {total} 条
- **匹配公告数**: {matched} 条 ({matched/total*100:.1f}%)
- **高优先级**: {high_priority} 条

## 🔥 热门关键词

"""
        
        # 添加热门公告
        df_high = df[df['priority'] == 'high'].sort_values('match_score', ascending=False).head(10)
        report += "## 🎯 重要公告Top10\n\n"
        for i, row in enumerate(df_high.itertuples(), 1):
            report += f"{i}. **{row.stock_name}({row.stock_code})**: {row.title}\n"
            report += f"   - 发布时间: {row.publish_time}\n"
            report += f"   - 匹配得分: {row.match_score:.1f}\n\n"
        
        # 保存报告
        with open(output_path, 'w', encoding='utf-8') as f:
            f.write(report)
        
        print(f"✅ 周报已生成: {output_path}")

3. Webhook集成(连接量化交易系统)

python 复制代码
# integration/webhook_sender.py
import requests
import json
from typing import Dict

class WebhookIntegration:
    """Webhook集成(与外部系统对接)"""
    
    def __init__(self, webhook_url: str):
        self.webhook_url = webhook_url
    
    def send_signal(self, announcement: Dict) -> bool:
        """
        发送交易信号
        
        Args:
            announcement: 公告数据
            
        Returns:
            成功返回True
            
        应用场景:
            - 连接量化交易系统
            - 触发自动化交易策略
            - 推送到第三方平台
        """
        # 构造信号数据
        signal = {
            'type': 'announcement_alert',
            'stock_code': announcement['stock_code'],
            'stock_name': announcement['stock_name'],
            'title': announcement['title'],
            'keywords': announcement.get('match_result', {}).get('keywords', []),
            'priority': announcement.get('match_result', {}).get('priority'),
            'timestamp': announcement['publish_time']
        }
        
        try:
            response = requests.post(
                self.webhook_url,
                json=signal,
                timeout=10
            )
            
            if response.status_code == 200:
                print(f"✅ Webhook发送成功: {announcement['title']}")
                return True
            else:
                print(f"❌ Webhook发送失败: HTTP {response.status_code}")
                return False
                
        except Exception as e:
            print(f"❌ Webhook发送异常: {str(e)}")
            return False


# 在监控器中集成
class AnnouncementMonitor:
    def __init__(self):
        # ... 原有代码
        
        # 添加Webhook集成
        self.webhook = WebhookIntegration('http://your-trading-system.com/webhook')
    
    def monitor_task(self):
        # ... 原有逻辑
        
        # 发现重要公告后,发送Webhook
        for announcement in all_important_announcements:
            self.webhook.send_signal(announcement)

4. 多进程并发优化

python 复制代码
# 使用多进程加速股票监控
from multiprocessing import Pool
import time

class FastAnnouncementMonitor(AnnouncementMonitor):
    """多进程优化版监控器"""
    
    def monitor_task_parallel(self):
        """并发监控多只股票"""
        start_time = time.time()
        
        # 使用进程池
        with Pool(processes=4) as pool:
            # 并发抓取所有股票
            results = pool.map(
                self._process_single_stock,
                self.monitored_stocks
            )
        
        # 合并结果
        all_important = []
        for result in results:
            if result:
                all_important.extend(result)
        
        # 发送通知(省略...)
        
        elapsed = time.time() - start_time
        self.logger.info(f"⏱️ 并发完成,耗时 {elapsed:.2f} 秒")
    
    def _process_single_stock(self, stock: Dict) -> List[Dict]:
        """处理单只股票(供进程池调用)"""
        return self.fetch_and_process(stock['code'], days_back=1)


# 性能对比:
# 串行处理10只股票: 约60秒
# 并行处理10只股票: 约15秒 (提速4倍)

1️⃣4️⃣ 总结与延伸阅读

我们完成了什么?

从零构建了一个生产级公司公告智能监控系统,核心能力包括:

数据获取层:

  • 巨潮资讯网API的专业调用(分页/重试/缓存)
  • PDF文件下载与文本提取

智能匹配层:

  • 三层匹配引擎(精确/模糊/同义词)
  • 负面词检测(避免误报)
  • 权重计算与优先级分级

通知推送层:

  • 邮件发送(HTML富文本)
  • 企业微信机器人(Markdown格式)
  • 钉钉机器人(加签机制)

数据存储层:

  • SQLite数据库设计(索引优化)
  • 缓存机制(减少重复请求)
  • 数据分析与可视化

调度管理层:

  • 智能频率调整(交易时间加速)
  • 异常处理与自动重试
  • 日志轮转与监控

实际应用场景

1. 个人投资者
python 复制代码
# 监控自选股的关键公告
monitored_stocks = ['600519', '000858', '000333']
keywords = ['分红', '回购', '增持', '业绩预告']

# 每30分钟检查一次,有重要公告立即邮件通知
2. 量化交易团队
python 复制代码
# 公告触发交易信号
if '高送转' in announcement['keywords']:
    send_buy_signal(stock_code)

if '减持' in announcement['keywords']:
    send_sell_signal(stock_code)
3. 财经媒体
python 复制代码
# 自动生成新闻素材
important_announcements = get_today_important()
for announcement in important_announcements:
    generate_news_article(announcement)
    publish_to_website()

技术栈对比

需求场景 推荐方案 理由
个人使用 SQLite + APScheduler 轻量级,易部署
团队协作 PostgreSQL + Celery 支持并发,更稳定
大规模监控 分布式爬虫 + Kafka 高吞吐量
实时性要求高 WebSocket推送 秒级响应

下一步可以做什么?

1. 自然语言处理(NLP)
python 复制代码
# 使用BERT模型进行语义分析
from transformers import BertTokenizer, BertForSequenceClassification

tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
model = BertForSequenceClassification.from_pretrained('your-finetuned-model')

def classify_announcement(title: str) -> str:
    """
    分类公告类型
    
    Returns:
        'positive' | 'negative' | 'neutral'
    """
    inputs = tokenizer(title, return_tensors='pt')
    outputs = model(**inputs)
    prediction = outputs.logits.argmax().item()
    
    return ['negative', 'neutral', 'positive'][prediction]
2. 情感分析
python 复制代码
# 分析公告的市场情绪
import jieba.analyse

def analyze_sentiment(text: str) -> float:
    """
    计算情感得分
    
    Returns:
        -1.0 ~ 1.0 (负面到正面)
    """
    positive_words = ['增长', '上涨', '分红', '高送转', '盈利']
    negative_words = ['下滑', '亏损', '减持', '停牌', '风险']
    
    keywords = jieba.analyse.extract_tags(text, topK=20)
    
    score = 0
    for word in keywords:
        if word in positive_words:
            score += 1
        elif word in negative_words:
            score -= 1
    
    return max(min(score / 10, 1.0), -1.0)
3. 图数据库建模(知识图谱)
python 复制代码
# 使用Neo4j构建公司关系图谱
from py2neo import Graph, Node, Relationship

graph = Graph("bolt://localhost:7687", auth=("neo4j", "password"))

# 创建节点
company = Node("Company", code="600519", name="贵州茅台")
announcement = Node("Announcement", title="分红公告", type="dividend")

# 创建关系
rel = Relationship(company, "PUBLISHED", announcement)
graph.create(rel)

# 查询: 找出所有发布过分红公告的公司
query = """
MATCH (c:Company)-[:PUBLISHED]->(a:Announcement {type: 'dividend'})
RETURN c.name, COUNT(a) as dividend_count
ORDER BY dividend_count DESC
LIMIT 10
"""
results = graph.run(query).data()

推荐学习资源

📚 书籍:

  • 《Python网络数据采集》(Ryan Mitchell)
  • 《Python金融大数据分析》(Yves Hilpisch)
  • 《机器学习实战》(Peter Harrington)

🔗 API文档:

🎥 视频教程:

  • B站"Python金融量化"频道
  • Coursera"金融工程与风险管理"课程

⚖️ 合规提醒:

  • 数据仅供个人学习研究使用
  • 禁止用于商业目的或非法用途
  • 遵守《证券法》相关规定

最后的话

公告监控系统是信息优势的重要来源。在信息爆炸的时代,谁能更快、更准确地获取和分析信息,谁就能抢占先机。

核心原则:

  1. 合规第一: 尊重数据来源,控制请求频率
  2. 准确性优先: 宁可漏报,不可误报
  3. 持续优化: 根据实际效果调整策略
  4. 风险控制: 不要盲目依赖系统,需人工复核

希望这套系统能帮助你在投资决策中更加从容!📈✨

记住:

信息是资源,速度是优势,准确是生命。

有任何问题,欢迎交流讨论!💬🚀

📋 完整代码仓库结构(最终版)

json 复制代码
announcement_monitor/
│
├── README.md                          # 项目说明文档
├── requirements.txt                   # 依赖包清单
├── main.py                            # 主程序入口
│
├── config/                            # 配置目录
│   ├── __init__.py
│   ├── settings.py                    # 全局配置
│   ├── stock_list.json                # 监控股票列表
│   ├── keywords.json                  # 关键词规则库
│   └── email_config.json              # 邮件配置
│
├── scraper/                           # 数据抓取模块
│   ├── __init__.py
│   ├── cninfo_api.py                  # 巨潮API调用器 (500行)
│   ├── pdf_extractor.py               # PDF文本提取 (150行)
│   └── proxy_pool.py                  # 代理IP池(可选)
│
├── matcher/                           # 关键词匹配模块
│   ├── __init__.py
│   ├── keyword_matcher.py             # 智能匹配引擎 (400行)
│   ├── synonym_dict.py                # 同义词词典
│   └── priority_calculator.py         # 优先级计算器
│
├── notifier/                          # 通知推送模块
│   ├── __init__.py
│   ├── email_sender.py                # 邮件发送器 (200行)
│   ├── wechat_bot.py                  # 企业微信机器人 (150行)
│   └── dingtalk_bot.py                # 钉钉机器人 (120行)
│
├── storage/                           # 数据存储模块
│   ├── __init__.py
│   ├── database.py                    # 数据库管理 (350行)
│   └── cache_manager.py               # 缓存管理 (100行)
│
├── scheduler/                         # 定时调度模块
│   ├── __init__.py
│   └── task_scheduler.py              # 任务调度器 (300行)
│
├── analysis/                          # 数据分析模块
│   ├── __init__.py
│   └── visualizer.py                  # 可视化工具 (200行)
│
├── integration/                       # 外部集成模块
│   ├── __init__.py
│   └── webhook_sender.py              # Webhook推送
│
├── data/                              # 数据目录
│   ├── announcements.db               # SQLite数据库
│   ├── cache/                         # API缓存
│   └── pdfs/                          # PDF归档
│
├── logs/                              # 日志目录
│   ├── monitor.log                    # 运行日志
│   └── error.log                      # 错误日志
│
├── output/                            # 输出目录
│   ├── announcement_trend.png         # 趋势图
│   ├── keyword_distribution.png       # 分布图
│   └── weekly_report.md               # 周报
│
└── tests/                             # 测试目录
    ├── test_api.py                    # API测试
    ├── test_matcher.py                # 匹配测试
    └── test_notifier.py               # 通知测试

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!

相关推荐
weixin199701080162 小时前
加盟网 item_search - 根据关键词获取行业列表接口对接全攻略:从入门到精通
java·python
cyforkk2 小时前
11、Java 基础硬核复习:常用类和基础API的核心逻辑与面试考点
java·python·面试
小鸡吃米…2 小时前
机器学习 —— 数据缩放
人工智能·python·机器学习
JHC0000002 小时前
智能体造论子--简单封装大模型输出审核器
开发语言·python·机器学习
diediedei2 小时前
Python字典与集合:高效数据管理的艺术
jvm·数据库·python
【赫兹威客】浩哥2 小时前
可食用野生植物数据集构建与多版本YOLO模型训练实践
开发语言·人工智能·python
气可鼓不可泄2 小时前
将dmpython 封装在容器镜像里
数据库·python
m0_561359672 小时前
超越Python:下一步该学什么编程语言?
jvm·数据库·python
2301_810730102 小时前
python第三次作业
开发语言·python