874 lines
29 KiB
Python
874 lines
29 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
沪深交易所公告与数据爬虫模块
|
||
|
||
数据源: sse.com.cn(上交所)、szse.cn(深交所)
|
||
提供 IPO 日历、上市公司列表、公告搜索等功能。
|
||
可作为巨潮资讯 (cninfo.com.cn) 的补充数据源。
|
||
|
||
主要函数:
|
||
- get_ipo_calendar() — 获取近期 IPO 日历
|
||
- get_listed_companies(market="sh") — 获取上市公司列表
|
||
- search_announcements(keyword, market="both") — 搜索公告(跨交易所)
|
||
|
||
API:
|
||
深交所:
|
||
- 上市公司列表: http://www.szse.cn/api/report/ShowReport/data (POST)
|
||
SHOWTYPE=JSON, CATALOGID=1110, TABKEY=tab1
|
||
上交所:
|
||
- 股票列表: http://query.sse.com.cn/security/stock/getStockListData.do (GET)
|
||
jsonCallBack=jsonpCallback, isPagination=false, sqlId=COMMON_SSE_GP_GPLB_C
|
||
Referer: www.sse.com.cn
|
||
|
||
注意事项:
|
||
- 上交所 API 需要 Referer: https://www.sse.com.cn/
|
||
- 两个交易所均可能对请求频率有限制,使用 rate_limit 控制
|
||
- 数据更新频率: 交易日盘后更新
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import logging
|
||
from typing import Optional, Dict, Any, List
|
||
|
||
try:
|
||
from .http_utils import http_get, http_get_json, http_post, rate_limit, get_session
|
||
except ImportError:
|
||
from http_utils import http_get, http_get_json, http_post, rate_limit, get_session
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
# ==================== 常量 ====================
|
||
|
||
# 深交所 API
|
||
SZSE_BASE = "http://www.szse.cn"
|
||
SZSE_LISTED_COMPANIES_URL = "http://www.szse.cn/api/report/ShowReport/data"
|
||
SZSE_IPO_CALENDAR_URL = "http://www.szse.cn/api/report/ShowReport/data"
|
||
SZSE_ANNOUNCEMENT_URL = "http://www.szse.cn/api/disc/announcement/annList"
|
||
|
||
# 上交所 API
|
||
SSE_BASE = "https://www.sse.com.cn"
|
||
SSE_STOCK_LIST_URL = "http://query.sse.com.cn/security/stock/getStockListData.do"
|
||
SSE_IPO_CALENDAR_URL = "http://query.sse.com.cn/security/stock/getStockListData.do"
|
||
|
||
# 深交所报表系统目录/标签参数(提取为常量,便于交易所改版时集中维护)
|
||
SZSE_CATALOG_IPO = "1110" # IPO 日历目录ID
|
||
SZSE_CATALOG_LISTED = "1110" # 上市公司列表目录ID
|
||
SZSE_CATALOG_ANNOUNCEMENT = "1800_cxda" # 公告查询目录ID
|
||
SZSE_TABKEY = "tab1"
|
||
|
||
# 请求头
|
||
SZSE_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"
|
||
),
|
||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||
"Referer": "http://www.szse.cn/",
|
||
"X-Requested-With": "XMLHttpRequest",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
SSE_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"
|
||
),
|
||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||
"Referer": "https://www.sse.com.cn/",
|
||
"X-Requested-With": "XMLHttpRequest",
|
||
}
|
||
|
||
|
||
# ==================== 安全类型转换 ====================
|
||
|
||
def _safe_float(val, default=None):
|
||
"""安全转换为浮点数"""
|
||
if val is None or val == "" or val == "-":
|
||
return default
|
||
try:
|
||
return float(val)
|
||
except (ValueError, TypeError):
|
||
return default
|
||
|
||
|
||
def _safe_str(val, default=""):
|
||
"""安全转换为字符串"""
|
||
if val is None:
|
||
return default
|
||
return str(val)
|
||
|
||
|
||
# ==================== IPO 日历 ====================
|
||
|
||
|
||
def _get_szse_ipo_calendar() -> List[Dict[str, Any]]:
|
||
"""
|
||
获取深交所 IPO 日历(内部函数)。
|
||
"""
|
||
params = {
|
||
"SHOWTYPE": "JSON",
|
||
"CATALOGID": SZSE_CATALOG_IPO,
|
||
"TABKEY": SZSE_TABKEY,
|
||
"PAGENO": "1",
|
||
"random": "0.123456789",
|
||
}
|
||
|
||
rate_limit(url=SZSE_BASE)
|
||
log.info("正在获取深交所 IPO 日历...")
|
||
|
||
try:
|
||
resp = http_post(
|
||
SZSE_LISTED_COMPANIES_URL,
|
||
data=params,
|
||
headers=SZSE_HEADERS,
|
||
timeout=30,
|
||
)
|
||
if resp is None:
|
||
log.warning("深交所 IPO 日历请求失败")
|
||
return []
|
||
|
||
data = resp.json()
|
||
except Exception as e:
|
||
log.error(f"深交所 IPO 日历解析失败: {e}")
|
||
return []
|
||
|
||
# 解析返回数据
|
||
items = data if isinstance(data, list) else data.get("data", [])
|
||
if not items:
|
||
return []
|
||
|
||
result = []
|
||
for item in items:
|
||
result.append({
|
||
"market": "sz",
|
||
"stock_code": _safe_str(item.get("zqdm", "")),
|
||
"stock_name": _safe_str(item.get("zqjc", "")),
|
||
"ipo_date": _safe_str(item.get("ssrq", "")),
|
||
"ipo_price": _safe_float(item.get("fxjg")),
|
||
"ipo_pe": _safe_float(item.get("fxssrpe")),
|
||
"status": _safe_str(item.get("status", "")),
|
||
})
|
||
|
||
log.info(f"深交所 IPO 日历: {len(result)} 条")
|
||
return result
|
||
|
||
|
||
def _get_sse_ipo_calendar() -> List[Dict[str, Any]]:
|
||
"""
|
||
获取上交所 IPO 日历(内部函数)。
|
||
上交所股票查询接口也可查到待上市/新上市股票。
|
||
"""
|
||
params = {
|
||
"jsonCallBack": "jsonpCallback",
|
||
"isPagination": "false",
|
||
"sqlId": "COMMON_SSE_GP_GPLB_C", # 股票列表通用查询
|
||
"pageHelp.cacheSize": "1",
|
||
"pageHelp.beginPage": "1",
|
||
"pageHelp.pageSize": "200",
|
||
"pageHelp.pageNo": "1",
|
||
"pageHelp.endPage": "1",
|
||
}
|
||
|
||
rate_limit(url=SSE_BASE)
|
||
log.info("正在获取上交所 IPO 日历...")
|
||
|
||
resp = http_get(
|
||
SSE_IPO_CALENDAR_URL,
|
||
params=params,
|
||
headers=SSE_HEADERS,
|
||
timeout=30,
|
||
)
|
||
if resp is None:
|
||
log.warning("上交所 IPO 日历请求失败")
|
||
return []
|
||
|
||
# 上交所返回 JSONP 格式,需要提取 JSON 部分
|
||
text = resp.text
|
||
json_match = re.search(r'jsonpCallback\((.*)\)', text, re.DOTALL)
|
||
if not json_match:
|
||
json_match = re.search(r'\{.*\}', text, re.DOTALL)
|
||
if not json_match:
|
||
log.error("上交所 IPO 数据 JSON 提取失败")
|
||
return []
|
||
|
||
try:
|
||
data = json.loads(json_match.group(1) if json_match.lastindex else json_match.group(0))
|
||
except json.JSONDecodeError as e:
|
||
log.error(f"上交所 IPO 数据解析失败: {e}")
|
||
return []
|
||
|
||
items = data.get("result", []) or data.get("pageHelp", {}).get("data", [])
|
||
if not items:
|
||
return []
|
||
|
||
result = []
|
||
for item in items:
|
||
result.append({
|
||
"market": "sh",
|
||
"stock_code": _safe_str(item.get("SECURITY_CODE_A", "")),
|
||
"stock_name": _safe_str(item.get("SECURITY_ABBR_A", "")),
|
||
"list_date": _safe_str(item.get("LISTING_DATE", "")),
|
||
"total_shares": _safe_float(item.get("TOTAL_SHARES")),
|
||
"info": _safe_str(item.get("REMARK", "")),
|
||
})
|
||
|
||
log.info(f"上交所 IPO 日历: {len(result)} 条")
|
||
return result
|
||
|
||
|
||
def get_ipo_calendar(market: str = "both") -> List[Dict[str, Any]]:
|
||
"""
|
||
获取近期 IPO 日历(新上市公司信息)。
|
||
|
||
分别从深交所和上交所获取最近上市/待上市的公司数据。
|
||
|
||
Args:
|
||
market: 交易所选择,可选值:
|
||
- "sh" : 仅上交所
|
||
- "sz" : 仅深交所
|
||
- "both" : 沪深两市(默认)
|
||
|
||
Returns:
|
||
IPO 日历列表,每项包含:
|
||
- market: 交易所 (sh/sz)
|
||
- stock_code: 股票代码
|
||
- stock_name: 股票名称
|
||
- ipo_date / list_date: 上市日期
|
||
失败返回空列表
|
||
"""
|
||
results = []
|
||
|
||
if market in ("sz", "both"):
|
||
try:
|
||
results.extend(_get_szse_ipo_calendar())
|
||
except Exception as e:
|
||
log.error(f"深交所 IPO 日历获取异常: {e}")
|
||
|
||
if market in ("sh", "both"):
|
||
try:
|
||
results.extend(_get_sse_ipo_calendar())
|
||
except Exception as e:
|
||
log.error(f"上交所 IPO 日历获取异常: {e}")
|
||
|
||
log.info(f"IPO 日历汇总: {len(results)} 条(market={market})")
|
||
return results
|
||
|
||
|
||
# ==================== 上市公司列表 ====================
|
||
|
||
|
||
def _get_szse_listed_companies() -> List[Dict[str, Any]]:
|
||
"""
|
||
获取深交所上市公司列表(内部函数)。
|
||
使用深交所报表接口以 POST 请求获取。
|
||
"""
|
||
params = {
|
||
"SHOWTYPE": "JSON",
|
||
"CATALOGID": SZSE_CATALOG_LISTED,
|
||
"TABKEY": SZSE_TABKEY,
|
||
"PAGENO": "1",
|
||
"random": "0.123456789",
|
||
}
|
||
|
||
rate_limit(url=SZSE_BASE)
|
||
log.info("正在获取深交所上市公司列表...")
|
||
|
||
try:
|
||
resp = http_post(
|
||
SZSE_LISTED_COMPANIES_URL,
|
||
data=params,
|
||
headers=SZSE_HEADERS,
|
||
timeout=30,
|
||
)
|
||
if resp is None:
|
||
log.warning("深交所上市公司列表请求失败")
|
||
return []
|
||
|
||
data = resp.json()
|
||
except Exception as e:
|
||
log.error(f"深交所上市公司列表解析失败: {e}")
|
||
return []
|
||
|
||
items = data if isinstance(data, list) else data.get("data", [])
|
||
if not items:
|
||
return []
|
||
|
||
companies = []
|
||
for item in items:
|
||
company = {
|
||
"market": "sz",
|
||
"stock_code": _safe_str(item.get("zqdm", "")),
|
||
"stock_name": _safe_str(item.get("zqjc", "")),
|
||
"company_name": _safe_str(item.get("gsjc", "")),
|
||
"industry": _safe_str(item.get("ssrq", "")), # 上市板块
|
||
"list_date": _safe_str(item.get("ssrq", "")),
|
||
"total_shares": _safe_float(item.get("zgb")),
|
||
"area": _safe_str(item.get("area", "")),
|
||
}
|
||
companies.append(company)
|
||
|
||
log.info(f"深交所上市公司: {len(companies)} 家")
|
||
return companies
|
||
|
||
|
||
def _get_sse_listed_companies() -> List[Dict[str, Any]]:
|
||
"""
|
||
获取上交所上市公司列表(内部函数)。
|
||
"""
|
||
params = {
|
||
"jsonCallBack": "jsonpCallback",
|
||
"isPagination": "false",
|
||
"sqlId": "COMMON_SSE_GP_GPLB_C",
|
||
"pageHelp.cacheSize": "1",
|
||
"pageHelp.beginPage": "1",
|
||
"pageHelp.pageSize": "2000", # 上交所约2000家公司
|
||
"pageHelp.pageNo": "1",
|
||
"pageHelp.endPage": "1",
|
||
}
|
||
|
||
rate_limit(url=SSE_BASE)
|
||
log.info("正在获取上交所上市公司列表...")
|
||
|
||
resp = http_get(
|
||
SSE_STOCK_LIST_URL,
|
||
params=params,
|
||
headers=SSE_HEADERS,
|
||
timeout=30,
|
||
)
|
||
if resp is None:
|
||
log.warning("上交所上市公司列表请求失败")
|
||
return []
|
||
|
||
text = resp.text
|
||
json_match = re.search(r'jsonpCallback\((.*)\)', text, re.DOTALL)
|
||
if not json_match:
|
||
json_match = re.search(r'\{.*\}', text, re.DOTALL)
|
||
if not json_match:
|
||
log.error("上交所股票列表 JSON 提取失败")
|
||
return []
|
||
|
||
try:
|
||
data = json.loads(json_match.group(1) if json_match.lastindex else json_match.group(0))
|
||
except json.JSONDecodeError as e:
|
||
log.error(f"上交所股票列表解析失败: {e}")
|
||
return []
|
||
|
||
items = data.get("result", []) or data.get("pageHelp", {}).get("data", [])
|
||
if not items:
|
||
return []
|
||
|
||
companies = []
|
||
for item in items:
|
||
company = {
|
||
"market": "sh",
|
||
"stock_code": _safe_str(item.get("SECURITY_CODE_A", "")),
|
||
"stock_name": _safe_str(item.get("SECURITY_ABBR_A", "")),
|
||
"company_name": _safe_str(item.get("COMPANY_ABBR", "")),
|
||
"list_date": _safe_str(item.get("LISTING_DATE", "")),
|
||
"total_shares": _safe_float(item.get("TOTAL_SHARES")),
|
||
"remark": _safe_str(item.get("REMARK", "")),
|
||
}
|
||
companies.append(company)
|
||
|
||
log.info(f"上交所上市公司: {len(companies)} 家")
|
||
return companies
|
||
|
||
|
||
def get_listed_companies(market: str = "both") -> List[Dict[str, Any]]:
|
||
"""
|
||
获取沪深上市公司列表。
|
||
|
||
Args:
|
||
market: 交易所选择,可选值:
|
||
- "sh" : 仅上交所
|
||
- "sz" : 仅深交所
|
||
- "both" : 沪深两市(默认)
|
||
|
||
Returns:
|
||
上市公司列表,每项包含: market, stock_code, stock_name, list_date 等
|
||
失败返回空列表
|
||
"""
|
||
results = []
|
||
|
||
if market in ("sz", "both"):
|
||
try:
|
||
results.extend(_get_szse_listed_companies())
|
||
except Exception as e:
|
||
log.error(f"深交所上市公司列表获取异常: {e}")
|
||
|
||
if market in ("sh", "both"):
|
||
try:
|
||
results.extend(_get_sse_listed_companies())
|
||
except Exception as e:
|
||
log.error(f"上交所上市公司列表获取异常: {e}")
|
||
|
||
log.info(f"上市公司列表汇总: {len(results)} 家(market={market})")
|
||
return results
|
||
|
||
|
||
# ==================== 公告搜索 ====================
|
||
|
||
|
||
def _search_szse_announcements(keyword: str,
|
||
max_results: int = 50) -> List[Dict[str, Any]]:
|
||
"""
|
||
搜索深交所公告(内部函数)。
|
||
"""
|
||
params = {
|
||
"SHOWTYPE": "JSON",
|
||
"CATALOGID": SZSE_CATALOG_ANNOUNCEMENT,
|
||
"TABKEY": SZSE_TABKEY,
|
||
"txtK": keyword, # 关键词
|
||
"txtM": "", # 证券代码(可选)
|
||
"PAGENO": "1",
|
||
"pageSize": str(min(max_results, 50)),
|
||
}
|
||
|
||
rate_limit(url=SZSE_BASE)
|
||
log.info(f"正在搜索深交所公告: keyword={keyword}")
|
||
|
||
try:
|
||
resp = http_post(
|
||
SZSE_ANNOUNCEMENT_URL,
|
||
data=params,
|
||
headers=SZSE_HEADERS,
|
||
timeout=30,
|
||
)
|
||
if resp is None:
|
||
log.warning(f"深交所公告搜索请求失败: {keyword}")
|
||
return []
|
||
|
||
data = resp.json()
|
||
except Exception as e:
|
||
log.error(f"深交所公告搜索解析失败: {e}")
|
||
return []
|
||
|
||
items = data if isinstance(data, list) else data.get("data", [])
|
||
if not items:
|
||
return []
|
||
|
||
results = []
|
||
for item in items:
|
||
ann = {
|
||
"market": "sz",
|
||
"title": _safe_str(item.get("title", "")
|
||
or item.get("gsggbt", "")
|
||
or item.get("announcementTitle", "")),
|
||
"stock_code": _safe_str(item.get("stockCode", "")
|
||
or item.get("zqdm", "")),
|
||
"stock_name": _safe_str(item.get("stockName", "")
|
||
or item.get("zqjc", "")),
|
||
"publish_date": _safe_str(item.get("publishDate", "")
|
||
or item.get("ggrq", "")),
|
||
"url": _safe_str(item.get("url", "")
|
||
or item.get("attachPath", "")),
|
||
"category": _safe_str(item.get("category", "")
|
||
or item.get("announcementType", "")),
|
||
}
|
||
results.append(ann)
|
||
|
||
log.info(f"深交所公告搜索结果: {len(results)} 条")
|
||
return results
|
||
|
||
|
||
def _search_sse_announcements(keyword: str,
|
||
max_results: int = 50) -> List[Dict[str, Any]]:
|
||
"""
|
||
搜索上交所公告(内部函数)。
|
||
上交所公告搜索通过披露查询接口。
|
||
"""
|
||
url = "http://query.sse.com.cn/security/stock/queryCompanyBulletin.do"
|
||
params = {
|
||
"jsonCallBack": "jsonpCallback",
|
||
"isPagination": "true",
|
||
"keyWord": keyword,
|
||
"pageHelp.pageSize": str(min(max_results, 50)),
|
||
"pageHelp.pageNo": "1",
|
||
"pageHelp.beginPage": "1",
|
||
"pageHelp.endPage": "1",
|
||
"pageHelp.cacheSize": "1",
|
||
"productId": "",
|
||
"securityType": "0101", # A股
|
||
"reportType2": "DQGG", # 定期公告
|
||
"reportType": "ALL", # 所有类型
|
||
"beginDate": "",
|
||
"endDate": "",
|
||
}
|
||
|
||
rate_limit(url=SSE_BASE)
|
||
log.info(f"正在搜索上交所公告: keyword={keyword}")
|
||
|
||
resp = http_get(url, params=params, headers=SSE_HEADERS, timeout=30)
|
||
if resp is None:
|
||
log.warning(f"上交所公告搜索请求失败: {keyword}")
|
||
return []
|
||
|
||
text = resp.text
|
||
json_match = re.search(r'jsonpCallback\((.*)\)', text, re.DOTALL)
|
||
if not json_match:
|
||
json_match = re.search(r'\{.*\}', text, re.DOTALL)
|
||
if not json_match:
|
||
log.error("上交所公告 JSON 提取失败")
|
||
return []
|
||
|
||
try:
|
||
data = json.loads(json_match.group(1) if json_match.lastindex else json_match.group(0))
|
||
except json.JSONDecodeError as e:
|
||
log.error(f"上交所公告解析失败: {e}")
|
||
return []
|
||
|
||
items = data.get("result", []) or data.get("pageHelp", {}).get("data", [])
|
||
if not items:
|
||
return []
|
||
|
||
results = []
|
||
for item in items:
|
||
ann = {
|
||
"market": "sh",
|
||
"title": _safe_str(item.get("title", "")
|
||
or item.get("bulletinTitle", "")),
|
||
"stock_code": _safe_str(item.get("securityCode", "")
|
||
or item.get("stockCode", "")),
|
||
"stock_name": _safe_str(item.get("securityAbbr", "")
|
||
or item.get("stockName", "")),
|
||
"publish_date": _safe_str(item.get("publishDate", "")
|
||
or item.get("bulletinDate", "")),
|
||
"url": _safe_str(item.get("URL", "")
|
||
or item.get("bulletinUrl", "")),
|
||
"category": _safe_str(item.get("bulletinType", "")
|
||
or item.get("reportType", "")),
|
||
}
|
||
results.append(ann)
|
||
|
||
log.info(f"上交所公告搜索结果: {len(results)} 条")
|
||
return results
|
||
|
||
|
||
def search_announcements(keyword: str,
|
||
market: str = "both",
|
||
max_results: int = 100) -> List[Dict[str, Any]]:
|
||
"""
|
||
搜索沪深交易所公告(跨交易所合并结果)。
|
||
|
||
作为巨潮资讯 (cninfo.com.cn) 的补充数据源,直接从交易所
|
||
官方接口获取公告数据。
|
||
|
||
Args:
|
||
keyword: 搜索关键词(如 "分红"、"增发"、"重组" 等)
|
||
market: 交易所选择,可选值:
|
||
- "sh" : 仅上交所
|
||
- "sz" : 仅深交所
|
||
- "both" : 沪深两市(默认)
|
||
max_results: 每个交易所最大返回条数(默认 100)
|
||
|
||
Returns:
|
||
公告列表,每项包含: market, title, stock_code, stock_name,
|
||
publish_date, url, category
|
||
失败返回空列表
|
||
"""
|
||
results = []
|
||
|
||
per_market_max = max_results if market != "both" else max_results // 2
|
||
|
||
if market in ("sz", "both"):
|
||
try:
|
||
results.extend(_search_szse_announcements(keyword, per_market_max))
|
||
except Exception as e:
|
||
log.error(f"深交所公告搜索异常: {e}")
|
||
|
||
if market in ("sh", "both"):
|
||
try:
|
||
results.extend(_search_sse_announcements(keyword, per_market_max))
|
||
except Exception as e:
|
||
log.error(f"上交所公告搜索异常: {e}")
|
||
|
||
# 按发布日期降序排列
|
||
results.sort(key=lambda x: x.get("publish_date", ""), reverse=True)
|
||
|
||
log.info(f"公告搜索汇总: '{keyword}' 共 {len(results)} 条(market={market})")
|
||
return results
|
||
|
||
|
||
# ==================== 便捷查询 ====================
|
||
|
||
|
||
def get_recent_ipo_list(days: int = 30) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取最近 N 天内 IPO 的公司列表。
|
||
|
||
Args:
|
||
days: 最近天数(默认 30 天)
|
||
|
||
Returns:
|
||
近期 IPO 公司列表
|
||
"""
|
||
from datetime import datetime, timedelta
|
||
cutoff = datetime.now() - timedelta(days=days)
|
||
cutoff_str = cutoff.strftime("%Y-%m-%d")
|
||
|
||
ipo_list = get_ipo_calendar(market="both")
|
||
recent = []
|
||
|
||
for ipo in ipo_list:
|
||
date_str = ipo.get("ipo_date") or ipo.get("list_date", "")
|
||
if date_str and date_str >= cutoff_str:
|
||
recent.append(ipo)
|
||
|
||
recent.sort(key=lambda x: x.get("ipo_date") or x.get("list_date", ""),
|
||
reverse=True)
|
||
log.info(f"近 {days} 天 IPO 公司: {len(recent)} 家")
|
||
return recent
|
||
|
||
|
||
def find_company_announcements(stock_code: str,
|
||
keyword: str = "",
|
||
market: str = "both",
|
||
max_results: int = 30) -> List[Dict[str, Any]]:
|
||
"""
|
||
查找指定公司的公告。
|
||
将公司代码作为关键词的一部分进行搜索。
|
||
|
||
Args:
|
||
stock_code: 股票代码(如 "600519")
|
||
keyword: 额外搜索关键词(可选,如 "分红")
|
||
market: 交易所
|
||
|
||
Returns:
|
||
该公司相关公告列表
|
||
"""
|
||
search_keyword = f"{stock_code} {keyword}".strip()
|
||
return search_announcements(
|
||
keyword=search_keyword,
|
||
market=market,
|
||
max_results=max_results,
|
||
)
|
||
|
||
|
||
# ==================== 北交所(v4.7 新增) ====================
|
||
|
||
# 北交所官网接口
|
||
BSE_BASE = "https://www.bse.cn"
|
||
BSE_LISTED_COMPANIES_API = f"{BSE_BASE}/nqxxController/nqxxCnzq.do"
|
||
BSE_IPO_AUDIT_API = f"{BSE_BASE}/nqxxController/nqxxAuditList.do"
|
||
|
||
# 东方财富北交所板块(fs 参数),作为北交所数据的可靠降级源
|
||
EM_BSE_CLIST_API = "https://push2.eastmoney.com/api/qt/clist/get"
|
||
|
||
|
||
def _fetch_bse_native(params: Dict[str, Any], api_url: str) -> Optional[str]:
|
||
"""调用北交所官网 JSONP 接口,返回剥离回调后的原始文本。"""
|
||
try:
|
||
import urllib.parse
|
||
body = urllib.parse.urlencode(params)
|
||
rate_limit(url=BSE_BASE)
|
||
full = f"{api_url}?{body}&callback=cb"
|
||
resp = http_get(full, headers={
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0",
|
||
"Referer": f"{BSE_BASE}/nq/listedcompany.html",
|
||
}, timeout=20)
|
||
if resp is None:
|
||
return None
|
||
text = resp.text
|
||
# 剥离 JSONP 回调: callback([...]) 或 null([...])
|
||
m = re.search(r"\((.*)\)\s*;?\s*$", text, re.DOTALL)
|
||
return m.group(1) if m else text
|
||
except Exception as e:
|
||
log.error(f"北交所原生接口请求失败: {e}")
|
||
return None
|
||
|
||
|
||
def _fetch_em_bse_stocks(limit: int) -> List[Dict[str, Any]]:
|
||
"""通过东方财富 clist 接口获取北交所上市公司列表(降级源)。"""
|
||
try:
|
||
import urllib.parse
|
||
params = {
|
||
"pn": "1", "pz": str(min(limit, 200)), "po": "1", "np": "1",
|
||
"fltt": "2", "invt": "2", "fid": "f26",
|
||
"fs": "m:0+t:81+t:82+t:83", # 北交所
|
||
"fields": "f12,f14,f13,f26,f100,f2",
|
||
}
|
||
qs = urllib.parse.urlencode(params)
|
||
rate_limit(url="push2.eastmoney.com")
|
||
resp = http_get(f"{EM_BSE_CLIST_API}?{qs}", headers={
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0",
|
||
"Referer": "https://quote.eastmoney.com/",
|
||
}, timeout=20)
|
||
if resp is None:
|
||
return []
|
||
data = json.loads(resp.text)
|
||
items = data.get("data", {}).get("diff", []) if isinstance(data.get("data"), dict) else []
|
||
results = []
|
||
for it in items:
|
||
results.append({
|
||
"code": _safe_str(it.get("f12", "")),
|
||
"name": _safe_str(it.get("f14", "")),
|
||
"short_code": _safe_str(it.get("f13", "")),
|
||
"listing_date": _safe_str(it.get("f26", "")),
|
||
"industry": _safe_str(it.get("f100", "")),
|
||
"market": "bj",
|
||
})
|
||
return results
|
||
except Exception as e:
|
||
log.error(f"东方财富北交所列表请求失败: {e}")
|
||
return []
|
||
|
||
|
||
def get_bse_stocks(limit: int = 200) -> List[Dict[str, Any]]:
|
||
"""获取北交所上市公司列表。
|
||
|
||
优先调用北交所官网接口,失败时降级到东方财富北交所板块数据。
|
||
|
||
Args:
|
||
limit: 返回条数,默认 200,最大 500
|
||
|
||
Returns:
|
||
[
|
||
{"code": "837592", "name": "华信永道", "short_code": "837592",
|
||
"listing_date": "2023-02-16", "industry": "信息技术", "market": "bj"}, ...
|
||
]
|
||
"""
|
||
# 1) 北交所原生接口
|
||
raw = _fetch_bse_native(
|
||
{"page": 0, "typejb": "T", "xxfcbj": 2, "xxzqdm": "",
|
||
"sortfield": "xxzqdm", "sorttype": "asc", "pagesize": min(limit, 100)},
|
||
BSE_LISTED_COMPANIES_API,
|
||
)
|
||
if raw:
|
||
try:
|
||
data = json.loads(raw)
|
||
if isinstance(data, list) and data and isinstance(data[0], dict) and "msg" not in data[0]:
|
||
results = []
|
||
for it in data[:limit]:
|
||
results.append({
|
||
"code": _safe_str(it.get("xxzqdm", it.get("zqdm", ""))),
|
||
"name": _safe_str(it.get("jc", it.get("zqjc", ""))),
|
||
"short_code": _safe_str(it.get("zqdm", "")),
|
||
"listing_date": _safe_str(it.get("ssrq", "")),
|
||
"industry": _safe_str(it.get("hy", it.get("hymc", ""))),
|
||
"market": "bj",
|
||
})
|
||
if results:
|
||
log.info(f"北交所原生接口获取 {len(results)} 条")
|
||
return results
|
||
except Exception as e:
|
||
log.warning(f"北交所原生接口解析失败,降级东方财富: {e}")
|
||
|
||
# 2) 降级:东方财富北交所板块
|
||
results = _fetch_em_bse_stocks(limit)
|
||
log.info(f"北交所列表获取 {len(results)} 条(东方财富降级源)")
|
||
return results
|
||
|
||
|
||
def get_bse_ipo(limit: int = 50) -> List[Dict[str, Any]]:
|
||
"""获取北交所 IPO 审核/发行信息。
|
||
|
||
Args:
|
||
limit: 返回条数,默认 50
|
||
|
||
Returns:
|
||
[
|
||
{"code": "...", "name": "...", "status": "已注册",
|
||
"audit_date": "2026-07-31", "market": "bj"}, ...
|
||
]
|
||
"""
|
||
raw = _fetch_bse_native(
|
||
{"page": 0, "xxfcbj": 2, "typejb": "T"},
|
||
BSE_IPO_AUDIT_API,
|
||
)
|
||
if raw:
|
||
try:
|
||
data = json.loads(raw)
|
||
if isinstance(data, list) and data and isinstance(data[0], dict) and "msg" not in data[0]:
|
||
results = []
|
||
for it in data[:limit]:
|
||
results.append({
|
||
"code": _safe_str(it.get("xxzqdm", it.get("zqdm", ""))),
|
||
"name": _safe_str(it.get("jc", it.get("zqjc", ""))),
|
||
"status": _safe_str(it.get("zt", it.get("status", ""))),
|
||
"audit_date": _safe_str(it.get("sxrq", it.get("ssrq", ""))),
|
||
"market": "bj",
|
||
})
|
||
if results:
|
||
log.info(f"北交所 IPO 审核获取 {len(results)} 条")
|
||
return results
|
||
except Exception as e:
|
||
log.warning(f"北交所 IPO 审核解析失败: {e}")
|
||
|
||
# 降级:东方财富 IPO 日历(北交所部分由 get_ipo_calendar 覆盖)
|
||
ipo = get_ipo_calendar(market="both")
|
||
results = [i for i in ipo if str(i.get("stock_code", "")).startswith(("8", "4"))]
|
||
log.info(f"北交所 IPO 获取 {len(results)} 条(降级源)")
|
||
return results[:limit]
|
||
|
||
|
||
# ==================== 测试入口 ====================
|
||
|
||
if __name__ == "__main__":
|
||
print("=" * 60)
|
||
print("沪深交易所数据爬虫 - 功能测试")
|
||
print("=" * 60)
|
||
|
||
# 测试1: 获取上市公司列表
|
||
print("\n[测试1] 获取上交所上市公司列表(前10家)...")
|
||
companies = get_listed_companies(market="sh")
|
||
if companies:
|
||
print(f"共获取 {len(companies)} 家上交所上市公司")
|
||
for c in companies[:10]:
|
||
print(f" {c.get('market')} {c.get('stock_code')} {c.get('stock_name')} "
|
||
f"上市日:{c.get('list_date', 'N/A')}")
|
||
else:
|
||
print("获取失败(上交所接口可能需要特定 Referer)")
|
||
|
||
# 测试2: 获取深交所上市公司列表
|
||
print("\n[测试2] 获取深交所上市公司列表(前10家)...")
|
||
sz_companies = get_listed_companies(market="sz")
|
||
if sz_companies:
|
||
print(f"共获取 {len(sz_companies)} 家深交所上市公司")
|
||
for c in sz_companies[:10]:
|
||
print(f" {c.get('market')} {c.get('stock_code')} {c.get('stock_name')}")
|
||
else:
|
||
print("获取失败")
|
||
|
||
# 测试3: 搜索公告
|
||
print("\n[测试3] 搜索 '分红' 相关公告(深交所)...")
|
||
anns = search_announcements(keyword="分红", market="sz", max_results=10)
|
||
if anns:
|
||
print(f"搜索到 {len(anns)} 条公告")
|
||
for ann in anns[:5]:
|
||
print(f" [{ann.get('market')}] {ann.get('stock_code')} "
|
||
f"{ann.get('title', '')[:60]} "
|
||
f"日期:{ann.get('publish_date', '')}")
|
||
else:
|
||
print("搜索无结果或接口不可用")
|
||
|
||
# 测试4: 搜索 '增发' 公告(上交所)
|
||
print("\n[测试4] 搜索 '增发' 相关公告(上交所)...")
|
||
anns_sse = search_announcements(keyword="增发", market="sh", max_results=10)
|
||
if anns_sse:
|
||
print(f"搜索到 {len(anns_sse)} 条公告")
|
||
for ann in anns_sse[:5]:
|
||
print(f" [{ann.get('market')}] {ann.get('stock_code')} "
|
||
f"{ann.get('title', '')[:60]}")
|
||
else:
|
||
print("搜索无结果或接口不可用")
|
||
|
||
# 测试5: 查找指定公司公告
|
||
print("\n[测试5] 查找贵州茅台(600519)近期公告...")
|
||
moutai_anns = find_company_announcements("600519", keyword="分红")
|
||
if moutai_anns:
|
||
for ann in moutai_anns[:5]:
|
||
print(f" [{ann.get('market')}] {ann.get('title', '')[:60]} "
|
||
f"日期:{ann.get('publish_date', '')}")
|
||
else:
|
||
print("未找到相关公告或接口不可用")
|
||
|
||
print("\n" + "=" * 60)
|
||
print("测试完成")
|
||
print("提示: 交易所接口可能有反爬限制,如测试失败属正常现象。")
|
||
print(" 实际使用时建议在工作日盘后时段调用。")
|