㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐⭐
🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [1️⃣ 标题 && 摘要](#1️⃣ 标题 && 摘要)
- [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
- [3️⃣ 合规与注意事项(必读)](#3️⃣ 合规与注意事项(必读))
- [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
-
- [API vs 网页爬取](#API vs 网页爬取)
- 整体流程架构
- 为什么选这套技术栈?
- [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
- [6️⃣ 核心实现: 巨潮API调用模块](#6️⃣ 核心实现: 巨潮API调用模块)
- [7️⃣ 核心实现: 关键词智能匹配引擎](#7️⃣ 核心实现: 关键词智能匹配引擎)
- [8️⃣ 核心实现: 多渠道通知系统](#8️⃣ 核心实现: 多渠道通知系统)
- [9️⃣ 核心实现: 数据库与缓存管理](#9️⃣ 核心实现: 数据库与缓存管理)
- [1️⃣1️⃣ 主程序整合与运行示例](#1️⃣1️⃣ 主程序整合与运行示例)
-
- 主程序入口
- 运行示例
-
- [1. 启动监控服务](#1. 启动监控服务)
- [2. 单次测试查询](#2. 单次测试查询)
- [3. 查看统计信息](#3. 查看统计信息)
- 后台运行(Linux)
- 使用systemd服务(推荐)
- 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+ ,需要用到 typing 和 dataclasses。
依赖安装
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️⃣ 核心实现: 关键词智能匹配引擎
设计思路
单纯的关键词匹配存在诸多问题:
- 同义词问题: "分红"和"派息"表达同一意思,但字符串不同
- 模糊匹配: "股份回购"应该匹配"回购股份"、"股票回购"等变体
- 优先级判断: 同时出现多个关键词时,如何判断重要程度?
- 误报问题: "不分红"应该被识别为负面信息
我们需要构建一个三层匹配引擎:
- 精确匹配层: 完全相同的关键词
- 模糊匹配层: 使用Levenshtein距离计算相似度
- 同义词匹配层: 基于词典的语义匹配
完整代码实现
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"×tamp={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文档:
- 巨潮资讯网开发文档: http://webapi.cninfo.com.cn
- APScheduler官方文档: https://apscheduler.readthedocs.io
- fuzzywuzzy文档: https://github.com/seatgeek/fuzzywuzzy
🎥 视频教程:
- B站"Python金融量化"频道
- Coursera"金融工程与风险管理"课程
⚖️ 合规提醒:
- 数据仅供个人学习研究使用
- 禁止用于商业目的或非法用途
- 遵守《证券法》相关规定
最后的话
公告监控系统是信息优势的重要来源。在信息爆炸的时代,谁能更快、更准确地获取和分析信息,谁就能抢占先机。
核心原则:
- 合规第一: 尊重数据来源,控制请求频率
- 准确性优先: 宁可漏报,不可误报
- 持续优化: 根据实际效果调整策略
- 风险控制: 不要盲目依赖系统,需人工复核
希望这套系统能帮助你在投资决策中更加从容!📈✨
记住:
信息是资源,速度是优势,准确是生命。
有任何问题,欢迎交流讨论!💬🚀
📋 完整代码仓库结构(最终版)
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 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!