fix(cninfo): URL 404 + type null + 表格截断 + PDF 下载(实测 601088 暴露)

This commit is contained in:
Justin Gu
2026-06-14 23:58:24 +08:00
parent c0605698f3
commit 574b284dd5
6 changed files with 547 additions and 44 deletions
+68 -3
View File
@@ -11,7 +11,28 @@ import click
@click.option("--page", default=1, type=int, help="页码(1 起始)")
@click.option("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
def announcement(code: str, count: int, page: int, use_table: bool, output_fmt: str) -> None:
@click.option(
"--download",
"download_n",
type=int,
default=0,
help="下载最新 N 条公告的 PDF(0=不下载),需配合 --download-dir",
)
@click.option(
"--download-dir",
"download_dir",
default=".",
help="PDF 保存目录(默认当前目录,自动创建)",
)
def announcement(
code: str,
count: int,
page: int,
use_table: bool,
output_fmt: str,
download_n: int,
download_dir: str,
) -> None:
"""检索公司公告(巨潮资讯网,独立数据源,无需连接 TDX)。
\b
@@ -19,9 +40,12 @@ def announcement(code: str, count: int, page: int, use_table: bool, output_fmt:
easy-tdx announcement 688017
easy-tdx announcement 600519 --count 50 --page 2
easy-tdx announcement 601088 --count 50 --page 2
easy-tdx announcement 000001 --table
# 下载最新 5 条公告的 PDF 到 ./pdfs 目录
easy-tdx announcement 601088 --count 5 --download 5 --download-dir ./pdfs
"""
from ..cninfo import CninfoClient, CninfoError
from .output import print_error, print_output
@@ -33,4 +57,45 @@ def announcement(code: str, count: int, page: int, use_table: bool, output_fmt:
except CninfoError as e:
print_error(str(e))
raise SystemExit(1) from e
print_output(df, fmt)
# 表格模式下不截断长文本列(url/title 经常超 30 字符,默认 output 会切到不可读)
if fmt == "table":
from .output import _render_table_full
click.echo(_render_table_full(df))
else:
print_output(df, fmt)
# PDF 下载
if download_n > 0:
if df.empty:
print_error("无公告可下载")
raise SystemExit(1)
to_download = df.head(download_n)
click.echo(f"开始下载 {len(to_download)} 条公告 PDF 到 {download_dir} ...", err=True)
downloaded = 0
skipped = 0
# 复用 _query_announcements 已解析的 Announcement 对象需要重新查;
# 这里直接从 DataFrame 行构造 Announcement 以避免二次网络请求。
from ..cninfo.models import Announcement
for _, row in to_download.iterrows():
anno = Announcement(
title=row["title"],
type=row["type"],
date=row["date"],
url=row["url"],
code=row["code"],
org_id=row["org_id"],
announcement_id=row["announcement_id"],
announcement_time=row["announcement_time"],
pdf_url=row["pdf_url"],
)
try:
path = client.download_pdf(anno, dest_dir=download_dir)
click.echo(f"{path}", err=True)
downloaded += 1
except CninfoError as e:
click.echo(f" ✗ 跳过({row['title'][:30]}: {e}", err=True)
skipped += 1
click.echo(f"完成:{downloaded} 个下载,{skipped} 个跳过", err=True)
+15 -4
View File
@@ -35,13 +35,23 @@ def print_error(msg: str) -> None:
def _render_table(df: pd.DataFrame) -> str:
"""将 DataFrame 渲染为人类可读的文本表格。"""
return _render_table_impl(df, truncate=30)
def _render_table_full(df: pd.DataFrame) -> str:
"""渲染表格但**不截断**长文本列(适用于 url/title 等长字段)。"""
return _render_table_impl(df, truncate=None)
def _render_table_impl(df: pd.DataFrame, truncate: int | None) -> str:
"""表格渲染实现。``truncate=None`` 时不截断 object 列。"""
if df.empty:
return "(无数据)"
display_df = df.copy()
for col in display_df.columns:
if display_df[col].dtype == object:
display_df[col] = display_df[col].astype(str).str.slice(0, 30)
if display_df[col].dtype == object and truncate is not None:
display_df[col] = display_df[col].astype(str).str.slice(0, truncate)
try:
import tabulate
@@ -51,10 +61,11 @@ def _render_table(df: pd.DataFrame) -> str:
lines: list[str] = []
cols = list(display_df.columns)
header = " | ".join(str(c) for c in cols)
sep = "-+-".join("-" * min(len(str(c)), 30) for c in cols)
cap = truncate if truncate is not None else 100
sep = "-+-".join("-" * min(len(str(c)), cap) for c in cols)
lines.append(header)
lines.append(sep)
for _, row in display_df.iterrows():
line = " | ".join(str(v)[:30] for v in row.values)
line = " | ".join(str(v)[:cap] for v in row.values)
lines.append(line)
return "\n".join(lines)
+90 -9
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from typing import Any
from urllib import parse
@@ -22,7 +23,7 @@ from urllib import request as urlrequest
import pandas as pd
from .models import Announcement, CninfoError
from .models import Announcement, CninfoError, build_detail_url, build_pdf_url
logger = logging.getLogger(__name__)
@@ -33,7 +34,6 @@ _UA = (
)
_STOCK_MAP_URL = "http://www.cninfo.com.cn/new/data/szse_stock.json"
_QUERY_URL = "https://www.cninfo.com.cn/new/hisAnnouncement/query"
_DETAIL_URL = "https://www.cninfo.com.cn/new/disclosure/detail?annoId="
# 模块级 orgId 映射缓存:首次拉取后全程复用(Cpython dict 读写原子,
# 并发下最坏多发一次请求,可接受)
@@ -137,14 +137,87 @@ class CninfoClient:
page: 页码(1 起始)。
Returns:
``DataFrame[title, type, date, url]``,按服务器返回顺序(最新在前)。
``DataFrame[title, type, date, url, code, org_id, announcement_id,
announcement_time, pdf_url]``,按服务器返回顺序(最新在前)。
无结果时返回空 DataFrame(含列名)。
Note:
``type`` 列优先取 cninfo 的 ``announcementTypeName``;该字段对很多
公告为 null(数据源限制),此时回退到 ``adjunctType``(如 "PDF"),
再为空给空字符串。
"""
rows = self._query_announcements(code, count=count, page=page)
cols = [
"title",
"type",
"date",
"url",
"code",
"org_id",
"announcement_id",
"announcement_time",
"pdf_url",
]
if not rows:
return pd.DataFrame(columns=["title", "type", "date", "url"])
return pd.DataFrame(columns=cols)
return pd.DataFrame([r.__dict__ for r in rows])
def download_pdf(
self,
announcement: Announcement | pd.Series[Any],
dest_dir: str | os.PathLike[str] = ".",
*,
filename: str | None = None,
) -> str:
"""下载公告 PDF 附件到本地。
Args:
announcement: ``get_announcements`` 返回的单条记录(需含 pdf_url)。
也接受 ``pd.Series``DataFrame 的一行)。
dest_dir: 目标目录,默认当前目录。不存在会自动创建。
filename: 保存文件名(不含路径)。默认 ``{date}_{announcement_id}.PDF``。
Returns:
下载后的本地文件绝对路径。
Raises:
CninfoError: 该公告无 PDF 附件(pdf_url 为空),或下载失败。
"""
# 统一为字段访问:兼容 pd.SeriesDataFrame.iloc[i])和 Announcement
if isinstance(announcement, Announcement):
pdf_url = announcement.pdf_url
anno_time = announcement.announcement_time
anno_id = announcement.announcement_id
else:
# pd.Series 的 .get/__getitem__ 行为
pdf_url = str(announcement.get("pdf_url", "") or "")
anno_time = int(announcement.get("announcement_time", 0) or 0)
anno_id = str(announcement.get("announcement_id", "x") or "")
if not pdf_url:
raise CninfoError("该公告无 PDF 附件(pdf_url 为空)")
dest_dir = os.fspath(dest_dir)
os.makedirs(dest_dir, exist_ok=True)
if filename is None:
# announcement_time 为毫秒时间戳,转 YYYYMMDD 更可读
try:
date_str = datetime.fromtimestamp(anno_time / 1000).strftime("%Y%m%d")
except (OSError, ValueError, OverflowError):
date_str = "unknown"
filename = f"{date_str}_{anno_id}.PDF"
filepath = os.path.join(dest_dir, filename)
try:
req = urlrequest.Request(pdf_url, headers={"User-Agent": _UA})
with urlrequest.urlopen(req, timeout=self.timeout) as resp:
data = resp.read()
with open(filepath, "wb") as f:
f.write(data)
except Exception as e: # noqa: BLE001 — 下载失败统一转领域异常
raise CninfoError(f"PDF 下载失败: {e}") from e
return os.path.abspath(filepath)
def _query_announcements(self, code: str, *, count: int, page: int) -> list[Announcement]:
"""POST 公告检索接口,解析为 Announcement 列表。
@@ -177,13 +250,21 @@ class CninfoClient:
for item in items:
if not isinstance(item, dict):
continue
anno_id = item.get("announcementId", "")
anno_id = str(item.get("announcementId", "") or "")
anno_time = item.get("announcementTime", 0) or 0
# type 回退:announcementTypeName 常为 null → adjunctType (如 "PDF")
type_name = item.get("announcementTypeName") or item.get("adjunctType") or ""
result.append(
Announcement(
title=item.get("announcementTitle", ""),
type=item.get("announcementTypeName", ""),
date=_ts_to_date(item.get("announcementTime")),
url=f"{_DETAIL_URL}{anno_id}",
title=item.get("announcementTitle", "") or "",
type=type_name,
date=_ts_to_date(anno_time),
url=build_detail_url(code, anno_id, org_id, anno_time),
code=code,
org_id=org_id,
announcement_id=anno_id,
announcement_time=anno_time,
pdf_url=build_pdf_url(item.get("adjunctUrl", "") or ""),
)
)
return result
+40
View File
@@ -6,18 +6,58 @@ from dataclasses import dataclass
from easy_tdx.exceptions import TdxError
# PDF 附件直链前缀(adjunctUrl 拼此 base 即真实 PDF 地址)
_PDF_BASE = "http://static.cninfo.com.cn/"
@dataclass(frozen=True)
class Announcement:
"""单条公告记录。
巨潮公告检索接口返回的标准化结构。
Attributes:
title: 公告标题。
type: 公告类型(优先 ``announcementTypeName``,缺失时回退 ``adjunctType``
"PDF",再缺失给空字符串 — cninfo 对很多公告不填 typeName)。
date: 公告日期 ``YYYY-MM-DD``。
url: 公告详情页 URL(含 stockCode/announcementId/orgId/announcementTime 四参数)。
code: 6 位股票代码。
org_id: 巨潮 orgId(详情页 URL 参数)。
announcement_id: 巨潮公告 ID(详情页 URL 参数 + PDF 文件名构成)。
announcement_time: 原始 Unix 毫秒时间戳(详情页 URL 参数)。
pdf_url: PDF 附件直链(``adjunctUrl`` 拼接 ``static.cninfo.com.cn``),
无附件时为空字符串。
"""
title: str
type: str
date: str # YYYY-MM-DD
url: str
code: str
org_id: str
announcement_id: str
announcement_time: int
pdf_url: str
def build_detail_url(code: str, announcement_id: str, org_id: str, announcement_time: int) -> str:
"""构造公告详情页 URL(4 参数缺一不可,否则 404)。"""
return (
"https://www.cninfo.com.cn/new/disclosure/detail?"
f"stockCode={code}&announcementId={announcement_id}"
f"&orgId={org_id}&announcementTime={announcement_time}"
)
def build_pdf_url(adjunct_url: str) -> str:
"""``adjunctUrl``(如 finalpage/2026-06-05/xxx.PDF)拼成完整 PDF 直链。
无附件(adjunctUrl 为空)返回空字符串。
"""
if not adjunct_url:
return ""
return f"{_PDF_BASE}{adjunct_url}"
class CninfoError(TdxError):