Files

699 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
巨潮资讯网(cninfo.com.cn)公告爬虫 v1.0
巨潮资讯是上市公司法定信息披露平台,覆盖所有 A 股上市公司
的定期报告、临时公告、IPO 文件等法定披露信息。
数据源: www.cninfo.com.cn(官方唯一指定信息披露网站)
特性:
- 按关键词/股票代码/日期范围搜索公告,返回结构化数据
- 分页查询 + 结果自动合并
- PDF 附件直接下载
- 所有公开接口均不需要登录,无需任何凭证
注意:
- 巨潮资讯是法定披露平台,数据权威但单次查询限制 pageSize <= 30
- 频繁请求可能触发反爬,建议调用间隔 >= 1.5 秒
- 日期格式统一为 yyyy-MM-dd
"""
from __future__ import annotations
import json
import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
try:
from .http_utils import http_post, http_get, download_file
except ImportError:
from http_utils import http_post, http_get, download_file
log = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
CNINFO_BASE = "http://www.cninfo.com.cn"
CNINFO_ANNOUNCEMENT_API = f"{CNINFO_BASE}/new/hisAnnouncement/query"
CNINFO_DOWNLOAD_API = f"{CNINFO_BASE}/new/announcement/download"
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Referer": "http://www.cninfo.com.cn/new/commonUrl?url=disclosure/list/notice",
"Origin": "http://www.cninfo.com.cn",
}
# 巨潮资讯各板块代码
MARKET_CODE = {
"主板": "p",
"创业板": "gem",
"科创板": "kcb",
"北交所": "bj",
}
# 公告分类
ANNOUNCEMENT_CATEGORIES = {
"category_ndbg_szsh": "年报",
"category_bndbg_szsh": "半年报",
"category_yjdbg_szsh": "一季度报告",
"category_sjdbg_szsh": "三季度报告",
"category_rcjy_szsh": "日常经营",
"category_zcjy_szsh": "资产交易",
"category_gqbd_szsh": "股权变动",
"category_gdqz_szsh": "股东减持",
"category_gdzc_szsh": "股东增持",
"category_hg_szsh": "回购",
"category_dshgg_szsh": "董事会公告",
"category_jshgg_szsh": "监事会公告",
"category_gddh_szsh": "股东大会",
}
# 默认输出目录
SKILL_DATA_DIR = Path(__file__).parent.parent / "data"
DEFAULT_DOWNLOAD_DIR = SKILL_DATA_DIR / "cninfo_pdfs"
# ── 辅助函数 ──────────────────────────────────────────────────────────────────
def _validate_date(date_str: str) -> bool:
"""验证日期格式是否为 yyyy-MM-dd"""
if not date_str:
return True # 空值是可选的
try:
datetime.strptime(date_str, "%Y-%m-%d")
return True
except ValueError:
return False
def _safe_get(resp_dict: Dict, *keys: str, default=""):
"""安全获取嵌套字典值,任一键不存在返回 default"""
for key in keys:
if isinstance(resp_dict, dict):
resp_dict = resp_dict.get(key, {})
else:
return default
return resp_dict if resp_dict != {} else default
def _format_announcement(raw: Dict) -> Dict:
"""将巨潮资讯原始公告条目转换为统一字典格式
Args:
raw: 巨潮资讯 API 返回的单条公告原始字典
Returns:
标准化后的公告字典,字段:
- announcementId: 公告唯一 ID
- secCode: 股票代码
- secName: 股票简称
- title: 公告标题
- publishDate: 发布日期
- announcementType: 公告类型 (如 "PDF")
- adjunctUrl: PDF 下载地址
- adjunctSize: 附件大小 (KB)
- batchNum: 批次号
- storageTime: 存储时间(时间戳毫秒)
"""
return {
"announcementId": raw.get("announcementId", ""),
"secCode": raw.get("secCode", ""),
"secName": raw.get("secName", ""),
"title": raw.get("announcementTitle", ""),
"publishDate": _format_timestamp(raw.get("announcementTime", 0)),
"announcementType": raw.get("adjunctType", ""),
"adjunctUrl": raw.get("adjunctUrl", ""),
"adjunctSize": raw.get("adjunctSize", 0),
"batchNum": raw.get("batchNum", ""),
"storageTime": raw.get("storageTime", ""),
}
def _format_timestamp(ts_millis: int) -> str:
"""将巨潮资讯的时间戳(毫秒)转换为 yyyy-MM-dd 字符串"""
if not ts_millis:
return ""
try:
return datetime.fromtimestamp(ts_millis / 1000).strftime("%Y-%m-%d")
except (ValueError, OSError):
return ""
# ── 主类 ──────────────────────────────────────────────────────────────────────
class CninfoScraper:
"""巨潮资讯网公告爬虫
用于搜索和下载 A 股上市公司在巨潮资讯网披露的公告文件。
巨潮资讯是上市公司法定信息披露平台,数据具有较高权威性。
无需登录或 API Key,所有接口均为公开访问。
使用示例::
scraper = CninfoScraper()
results = scraper.search_announcements(keyword="年报", stock_code="600519")
pdf_path = scraper.download_pdf("announcement_id_here", "downloads/")
"""
def __init__(self, timeout: int = 30):
"""初始化爬虫
Args:
timeout: HTTP 请求超时时间(秒),默认 30
"""
self._timeout = timeout
self._download_dir = DEFAULT_DOWNLOAD_DIR
self._download_dir.mkdir(parents=True, exist_ok=True)
# ── 公开 API ──────────────────────────────────────────────────────────────
def search_announcements(
self,
keyword: str = "",
stock_code: str = "",
start_date: str = "",
end_date: str = "",
page: int = 1,
page_size: int = 30,
market: str = "szse",
) -> Dict:
"""按条件搜索公告
支持按关键词、股票代码、日期范围和分页参数组合查询。
所有参数均为可选,至少需提供一个有效条件。
Args:
keyword: 搜索关键词,如 "年报"、"分红"、"重组"
stock_code: 6 位股票代码,如 "600519"、"000858"
market: 交易所代码,可选 "szse"(深交所) / "sse"(上交所) / "bjse"(北交所),默认 "szse"
start_date: 起始日期 yyyy-MM-dd,如 "2025-01-01"
end_date: 截止日期 yyyy-MM-dd,如 "2025-12-31"
page: 页码,从 1 开始
page_size: 每页条数,范围 1-30(巨潮限制)
Returns:
{
"total": int, # 总条数
"page": int, # 当前页码
"pageSize": int, # 每页条数
"hasMore": bool, # 是否有下一页
"announcements": [ # 公告列表
{
"announcementId": str,
"secCode": str,
"secName": str,
"title": str,
"publishDate": str,
"announcementType": str,
"adjunctUrl": str,
"adjunctSize": int,
}, ...
]
}
异常时返回 {"total": 0, "page": 1, "hasMore": False, "announcements": []}
"""
# 参数校验
if not any([keyword, stock_code, start_date, end_date]):
log.warning("巨潮资讯搜索:未提供任何查询条件")
return {"total": 0, "page": 1, "pageSize": page_size, "hasMore": False, "announcements": []}
if start_date and not _validate_date(start_date):
log.warning(f"巨潮资讯搜索:起始日期格式错误 {start_date},期望 yyyy-MM-dd")
return {"total": 0, "page": 1, "pageSize": page_size, "hasMore": False, "announcements": []}
if end_date and not _validate_date(end_date):
log.warning(f"巨潮资讯搜索:截止日期格式错误 {end_date},期望 yyyy-MM-dd")
return {"total": 0, "page": 1, "pageSize": page_size, "hasMore": False, "announcements": []}
# 限制 page_size
page_size = max(1, min(page_size, 30))
page = max(1, page)
# 构建请求参数 — 巨潮的 seDate 用 "-" 分隔起止日期
se_date = ""
if start_date and end_date:
se_date = f"{start_date}~{end_date}"
elif start_date:
se_date = f"{start_date}~"
elif end_date:
se_date = f"~{end_date}"
# 股票代码格式校验:确保是6位数字
stock_code = stock_code.strip().replace(".SH", "").replace(".SZ", "").replace(" ", "") if stock_code else ""
# 交易所代码映射到巨潮 column 取值
column_map = {
"szse": "szse", # 深交所
"sse": "sse", # 上交所
"bjse": "bjse", # 北交所
"a": "sse", # 兼容旧别名
}
column = column_map.get(str(market).lower(), "szse")
# 巨潮 stock 参数格式为 "code,orgId,orgIdValue"。
# orgId 旧代码硬编码五粮液(000858)的 gssz0000858,导致搜索其他股票时错挂该 orgId。
# 这里按市场推导 orgId(gssz/gssh/gsbj + 6位代码),对绝大多数 A 股正确;
# 若无法推导则只传股票代码,由巨潮全文检索兜底。
stock_param = ""
if stock_code:
zcode = stock_code.zfill(6)
org_prefix = {"szse": "gssz", "sse": "gssh", "bjse": "gsbj"}.get(column)
if org_prefix and len(stock_code) == 6 and stock_code.isdigit():
stock_param = f"{stock_code},orgId,{org_prefix}{zcode}"
else:
stock_param = stock_code
form_data = {
"pageNum": page,
"pageSize": page_size,
"column": column, # 按市场参数化(默认深交所)
"tabName": "fulltext", # 全文检索
"plate": "", # 板块限制(空=全部)
"stock": stock_param, # 股票代码(按市场推导 orgId,不再硬编码)
"searchkey": keyword, # 关键词
"secid": "", # 证券 ID(可选)
"category": "", # 公告分类(可选)
"trade": "", # 行业分类(可选)
"seDate": se_date, # 日期范围
}
try:
# 使用 form-encoded POST,巨潮要求这种格式
encoded_data = "&".join(
f"{k}={_url_encode(v)}" for k, v in form_data.items()
)
resp = http_post(
CNINFO_ANNOUNCEMENT_API,
data=encoded_data.encode("utf-8"),
headers=DEFAULT_HEADERS,
timeout=self._timeout,
)
if resp is None:
log.warning("巨潮资讯搜索请求失败:无响应")
return {"total": 0, "page": page, "pageSize": page_size, "hasMore": False, "announcements": []}
# 巨潮有时返回 JSON 字符串外面包了括号(JSONP 格式)
raw_text = resp.text.strip() if hasattr(resp, "text") else ""
if not raw_text:
raw_bytes = resp if isinstance(resp, bytes) else resp.content if hasattr(resp, "content") else b""
if isinstance(raw_bytes, bytes):
raw_text = raw_bytes.decode("utf-8", errors="ignore")
data = _parse_cninfo_response(raw_text)
total = data.get("totalRecordNum", 0)
announcements_raw = data.get("announcements") or []
# 计算是否有下一页
if isinstance(total, str):
try:
total = int(total)
except (ValueError, TypeError):
total = 0
has_more = bool(total > page * page_size)
items = [_format_announcement(item) for item in announcements_raw]
return {
"total": total,
"page": page,
"pageSize": page_size,
"hasMore": has_more,
"announcements": items,
}
except Exception as e:
log.error(f"巨潮资讯搜索异常: {e}")
return {"total": 0, "page": page, "pageSize": page_size, "hasMore": False, "announcements": []}
def get_stock_announcements(
self,
stock_code: str,
limit: int = 20,
) -> List[Dict]:
"""获取指定股票的近期公告
返回单个股票的最新公告列表,自动分页拉取直到满足 limit 或没有更多数据。
Args:
stock_code: 6 位股票代码,如 "600519"
limit: 返回公告条数上限,默认 20
Returns:
公告字典列表,结构同 search_announcements 返回的 announcements 字段
"""
if not stock_code or not stock_code.strip():
log.warning("get_stock_announcements: 股票代码为空")
return []
stock_code = stock_code.strip().replace(" ", "")
all_items: List[Dict] = []
page = 1
max_pages = 10 # 安全上限
try:
while len(all_items) < limit and page <= max_pages:
result = self.search_announcements(
stock_code=stock_code,
page=page,
page_size=min(30, limit),
)
items = result.get("announcements", [])
if not items:
break
all_items.extend(items)
if not result.get("hasMore"):
break
page += 1
time.sleep(1.0) # 礼貌延迟
return all_items[:limit]
except Exception as e:
log.error(f"获取股票 {stock_code} 公告失败: {e}")
return all_items # 返回已获取的部分
def get_latest_announcements(
self,
market: str = "",
limit: int = 30,
) -> List[Dict]:
"""获取巨潮资讯全市场或指定板块的最新公告
Args:
market: 板块名称,可选值: "主板"、"创业板"、"科创板"、"北交所"
留空则查询全市场
limit: 返回条数,默认 30
Returns:
最新公告列表
"""
all_items: List[Dict] = []
page = 1
# 根据不同板块定制查询
plate_code = MARKET_CODE.get(market, "")
try:
while len(all_items) < limit and page <= 5:
result = self.search_announcements(page=page, page_size=30)
# 如果指定了板块,后置过滤(巨潮 API 的 plate 参数效果不稳定)
items = result.get("announcements", [])
if not items:
break
if plate_code:
# 板块代码过滤 — 不同板块上市公司代码段不同:
# 主板: 000xxx-003xxx, 600xxx-605xxx
# 创业板: 300xxx-301xxx
# 科创板: 688xxx
# 北交所: 8xxxxx (83/87/88 开头)
filtered = []
for item in items:
code = item.get("secCode", "")
if _match_plate(code, market):
filtered.append(item)
items = filtered
all_items.extend(items)
if not result.get("hasMore") or len(items) < 30:
break
page += 1
time.sleep(1.0)
return all_items[:limit]
except Exception as e:
log.error(f"获取最新公告失败: {e}")
return all_items
def download_pdf(
self,
announcement_id: str,
output_dir: str = "",
) -> Optional[str]:
"""下载公告 PDF 附件
根据公告 ID 下载对应的 PDF 文件到指定目录。
文件名格式: {announcement_id}.pdf
Args:
announcement_id: 公告唯一 ID
output_dir: 输出目录路径,默认使用 data/cninfo_pdfs/
Returns:
下载成功返回本地文件绝对路径,失败返回 None
"""
if not announcement_id:
log.warning("download_pdf: announcement_id 为空")
return None
output_path = Path(output_dir) if output_dir else self._download_dir
output_path.mkdir(parents=True, exist_ok=True)
local_file = output_path / f"{announcement_id}.pdf"
# 如果文件已存在且非空,直接返回
if local_file.exists() and local_file.stat().st_size > 0:
log.info(f"PDF 已存在,跳过下载: {local_file}")
return str(local_file)
# 巨潮 PDF 下载 URL 格式
download_url = f"{CNINFO_DOWNLOAD_API}?announceId={announcement_id}"
try:
resp = http_get(download_url, headers=DEFAULT_HEADERS, timeout=60)
if resp is None:
log.warning(f"PDF 下载请求失败: {announcement_id}")
return None
# 检查响应是否真的是 PDF
content_type = ""
if hasattr(resp, "headers"):
content_type = resp.headers.get("Content-Type", resp.headers.get("content-type", ""))
content = resp if isinstance(resp, bytes) else resp.content if hasattr(resp, "content") else b""
if not isinstance(content, bytes):
log.warning(f"PDF 响应非二进制数据: {announcement_id}")
return None
# 验证是否为 PDF(魔数 %PDF)
if content and not content.startswith(b"%PDF"):
# 可能是重定向到登录页或错误页,检查是否是 JSON 错误信息
text_sample = content[:200].decode("utf-8", errors="ignore")
if text_sample.strip().startswith("{") or text_sample.strip().startswith("<html"):
log.warning(f"PDF 下载可能失败(非 PDF 内容): {announcement_id}, 前200字符: {text_sample[:100]}")
return None
with open(local_file, "wb") as f:
f.write(content)
log.info(f"PDF 下载成功: {local_file} ({len(content)} bytes)")
return str(local_file)
except Exception as e:
log.error(f"PDF 下载异常 {announcement_id}: {e}")
# 清理可能的不完整文件
if local_file.exists():
try:
local_file.unlink()
except OSError:
pass
return None
# ── 内部辅助 ──────────────────────────────────────────────────────────────────
def _url_encode(value) -> str:
"""对表单参数值进行 URL 编码(不使用标准库以避免引号转义差异)"""
import urllib.parse
return urllib.parse.quote(str(value), safe="")
def _parse_cninfo_response(raw_text: str) -> Dict:
"""解析巨潮资讯 API 响应
巨潮接口可能返回:
1. 标准 JSON: {...}
2. JSONP 格式: callback({...})
3. 括号包裹: ({...})
本函数统一解析为字典。
"""
if not raw_text:
return {}
text = raw_text.strip()
# 去掉 JSONP 包装:jQuery_xxx(...) 或 callback(...)
import re
# 匹配 try { callback( 开头的
m = re.search(r'(\{.*\}|\[.*\])', text, re.DOTALL)
if m:
text = m.group(1)
# 去掉外层括号 (仅当整个内容被括号包裹)
while text.startswith("(") and text.endswith(")"):
text = text[1:-1].strip()
try:
return json.loads(text)
except json.JSONDecodeError:
log.debug(f"巨潮响应 JSON 解析失败,原始内容前 200 字符: {text[:200]}")
return {}
def _match_plate(sec_code: str, market: str) -> bool:
"""判断股票代码是否属于指定板块
Args:
sec_code: 6 位股票代码
market: 板块名 ("主板"/"创业板"/"科创板"/"北交所")
Returns:
是否匹配
"""
if not sec_code or len(sec_code) < 6:
return False
code = sec_code[:6]
if market == "创业板":
return code.startswith("300") or code.startswith("301")
elif market == "科创板":
return code.startswith("688")
elif market == "北交所":
# 北交所代码: 83xxxx, 87xxxx, 88xxxx
return code.startswith(("83", "87", "88"))
elif market == "主板":
# 主板: 000xxx-005xxx (深市主板), 600xxx-605xxx (沪市主板)
shenzhen_main = code.startswith(("000", "001", "002", "003", "004", "005"))
shanghai_main = code.startswith(("600", "601", "602", "603", "604", "605"))
return shenzhen_main or shanghai_main
return True # 未知板块一律放行
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def search_cninfo_announcements(
keyword: str,
limit: int = 20,
) -> List[Dict]:
"""快速搜索巨潮资讯公告(便捷函数)
按关键词搜索最近 90 天内的公告,自动翻页直到满足 limit。
Args:
keyword: 搜索关键词,如 "分红"、"减持"、"业绩预告"
limit: 返回条数上限,默认 20
Returns:
公告列表,异常时返回 []
使用示例::
from cninfo_scraper import search_cninfo_announcements
results = search_cninfo_announcements("回购", limit=10)
for r in results:
print(r["title"], r["secCode"])
"""
if not keyword or not keyword.strip():
return []
scraper = CninfoScraper()
all_items: List[Dict] = []
page = 1
try:
while len(all_items) < limit and page <= 20:
result = scraper.search_announcements(
keyword=keyword.strip(),
page=page,
page_size=min(30, limit),
)
items = result.get("announcements", [])
if not items:
break
all_items.extend(items)
if not result.get("hasMore"):
break
page += 1
time.sleep(0.8)
return all_items[:limit]
except Exception as e:
log.error(f"便捷函数搜索公告失败: {e}")
return all_items
# ── 演示入口 ──────────────────────────────────────────────────────────────────
if __name__ == "__main__":
"""演示巨潮资讯公告爬虫的各项功能"""
import sys
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
print("=" * 70)
print(" 巨潮资讯(cninfo.com.cn)公告爬虫 — 功能演示")
print(" 数据源: 巨潮资讯(法定信息披露平台)")
print("=" * 70)
scraper = CninfoScraper()
# ── 演示 1: 按关键词搜索 ──
print("\n[1] 按关键词搜索: '年报' (前5条)")
result = scraper.search_announcements(keyword="年报", page_size=5)
print(f" 总共 {result['total']} 条,当前页 {result['page']},还有更多: {result['hasMore']}")
for i, ann in enumerate(result["announcements"][:5], 1):
print(f" {i}. [{ann['secCode']} {ann['secName']}] {ann['title'][:60]}")
print(f" 日期: {ann['publishDate']} | 附件: {ann['adjunctSize']}KB")
# ── 演示 2: 按股票代码搜索 ──
print("\n[2] 获取指定股票公告: 600519 (贵州茅台) 最近5条")
items = scraper.get_stock_announcements("600519", limit=5)
for i, ann in enumerate(items, 1):
print(f" {i}. {ann['title'][:60]}")
print(f" 日期: {ann['publishDate']} | ID: {ann['announcementId']}")
# ── 演示 3: 便捷函数搜索 ──
print("\n[3] 便捷函数搜索: '回购' (前3条)")
results = search_cninfo_announcements("回购", limit=3)
for i, r in enumerate(results, 1):
print(f" {i}. [{r['secCode']}] {r['title'][:50]} ({r['publishDate']})")
# ── 演示 4: 下载 PDF(仅第一条有效公告) ──
if result["announcements"]:
first_id = result["announcements"][0].get("announcementId")
if first_id:
print(f"\n[4] 下载公告 PDF: {first_id}")
pdf_path = scraper.download_pdf(first_id)
if pdf_path:
print(f" 下载成功: {pdf_path}")
else:
print(" 下载失败(可能附件不存在或需内网访问)")
print("\n" + "=" * 70)
print(" 演示完成")
print("=" * 70)