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
+45 -13
View File
@@ -136,11 +136,15 @@ easy-tdx symbol-info SZ 000001 --table
```bash ```bash
easy-tdx announcement 688017 # 默认 30 条,JSON 输出 easy-tdx announcement 688017 # 默认 30 条,JSON 输出
easy-tdx announcement 600519 --count 50 --page 2 # 翻页 easy-tdx announcement 601088 --count 10 --page 2 # 翻页
easy-tdx announcement 000001 --table # 表格输出 easy-tdx announcement 000001 --table # 表格输出(不截断 url
# 下载最新 5 条公告的 PDF 到 ./pdfs 目录
easy-tdx announcement 601088 --count 5 --download 5 --download-dir ./pdfs
``` ```
> 独立数据源(巨潮资讯网),无需连接 TDX 行情服务器即可使用。 > 独立数据源(巨潮资讯网),无需连接 TDX 行情服务器即可使用。
> 返回的 ``url`` 含 4 参数可直接打开,``pdf_url`` 为 PDF 直链。
### 技术指标 ### 技术指标
@@ -868,7 +872,8 @@ curl "http://localhost:8000/api/v1/mac/server-info"
# ── 公告检索(巨潮资讯网,独立数据源)── # ── 公告检索(巨潮资讯网,独立数据源)──
# 检索公司公告(无需 TDX 行情服务器) # 检索公司公告(无需 TDX 行情服务器)
curl "http://localhost:8000/api/v1/announcements?code=688017&count=30&page=1" curl "http://localhost:8000/api/v1/announcements?code=688017&count=30&page=1"
# 返回{"data": [{"title": "...", "type": "...", "date": "...", "url": "..."}], "count": 30} # 返回每条含 url(4 参数可直点打开)和 pdf_url(PDF 直链):
# {"data": [{"title":"...","type":"...","date":"...","url":".../detail?stockCode=...","pdf_url":"http://static.cninfo.com.cn/.../xxx.PDF",...}], "count": 30}
# ── 排行 / 竞价 / 异动 ── # ── 排行 / 竞价 / 异动 ──
# 全 A 涨幅排行前 20 # 全 A 涨幅排行前 20
@@ -1338,19 +1343,39 @@ client = CninfoClient()
# 检索公告(默认 30 条,最新在前) # 检索公告(默认 30 条,最新在前)
df = client.get_announcements("688017") df = client.get_announcements("688017")
# → DataFrame[title, type, date, url] # → DataFrame[title, type, date, url, code, org_id, announcement_id, announcement_time, pdf_url]
# 翻页 + 自定义数量 # 翻页 + 自定义数量
df = client.get_announcements("600519", count=50, page=2) df = client.get_announcements("601088", count=10, page=2)
# 返回示例: # 返回示例(url 含 4 参数可直点打开,pdf_url 为 PDF 直链)
# title type date url # title type date url pdf_url
# 0 关于召开2025年年度股东大会的通知 股东大会 2025-06-14 https://www.cninfo.com.cn/new/disclosure/detail?annoId=abc123 # 0 关于召开2025年年度股东大会... 股东大会 2025-06-14 .../detail?stockCode=688017&announcementId=... http://static.cninfo.com.cn/.../xxx.PDF
# 1 2024年年度报告 定期报告 2025-03-28 https://www.cninfo.com.cn/new/disclosure/detail?annoId=def456 # 1 2024年年度报告 PDF 2025-03-28 .../detail?stockCode=688017&announcementId=... http://static.cninfo.com.cn/.../yyy.PDF
``` ```
> orgId 解析沿用 #19 修复:动态拉取官方映射表,查不到回退硬编码规则, > - ``type`` 优先取 cninfo 的 ``announcementTypeName``;该字段对很多公告为 null
> 保证 601xxx 等非标 orgId 段也能正常查询 > (数据源限制),此时回退到 ``adjunctType``(如 "PDF"),再为空给空字符串
> - ``url`` 必须含 4 参数(``stockCode``/``announcementId``/``orgId``/``announcementTime``
> 才能打开,少参数会 404。
> - orgId 解析沿用 #19 修复:动态拉取官方映射表,查不到回退硬编码规则,
> 保证 601xxx 等非标 orgId 段也能正常查询。
#### 下载公告 PDF
```python
# 下载最新一条公告的 PDF 到当前目录
df = client.get_announcements("601088", count=5)
path = client.download_pdf(df.iloc[0]) # 接受 Announcement 或 DataFrame 的一行
print(path) # /abs/path/20260605_1225351400.PDF
# 批量下载
for _, row in df.iterrows():
try:
path = client.download_pdf(row, dest_dir="./pdfs")
except Exception as e:
print(f"跳过(无附件或失败): {e}")
```
## 枚举参考 ## 枚举参考
@@ -1560,12 +1585,19 @@ ruff format --check src/ tests/ # format check
**新增巨潮公告检索** — 三层接入(编程 API / CLI / Web API),独立数据源,无需连接 TDX 行情服务器。 **新增巨潮公告检索** — 三层接入(编程 API / CLI / Web API),独立数据源,无需连接 TDX 行情服务器。
- 新模块 `easy_tdx.cninfo``CninfoClient().get_announcements(code, count=, page=)` 返回 `DataFrame[title, type, date, url]` - 新模块 `easy_tdx.cninfo``CninfoClient().get_announcements(code, count=, page=)` 返回 `DataFrame[title, type, date, url, code, org_id, announcement_id, announcement_time, pdf_url]`
- CLI`easy-tdx announcement 688017 [--count N --page N --table]` - CLI`easy-tdx announcement 688017 [--count N --page N --table] [--download N --download-dir DIR]`
- Web`GET /api/v1/announcements?code=&count=&page=` - Web`GET /api/v1/announcements?code=&count=&page=`
- `CninfoClient().download_pdf(announcement, dest_dir=)` 下载公告 PDF(接受 `Announcement` 或 DataFrame 一行)
- 标准库 urllib 实现,零新依赖 - 标准库 urllib 实现,零新依赖
- 沿用 #19 修复的 orgId 动态映射 + 三段硬编码 fallback(保证 601xxx 段可查) - 沿用 #19 修复的 orgId 动态映射 + 三段硬编码 fallback(保证 601xxx 段可查)
**Bug 修复**(实测 601088 暴露):
- `type` 列全 null:cninfo 对很多公告不填 `announcementTypeName`,回退到 `adjunctType`(如 "PDF"
- `url` 404:原仅拼 `announcementId` 一个参数,补全 4 参数 `stockCode`/`announcementId`/`orgId`/`announcementTime`
- 表格输出 url 被截断成 `https://www.cninfo.com.cn/new/``announcement --table` 改用不截断渲染
### 1.12.0 (2026-06-14) ### 1.12.0 (2026-06-14)
**新增 4 个技术指标(30 → 34)** — 按"语义空白"补齐三类现有指标库缺失的维度:止损位、机构成本价、趋势启动时机。均为纯 numpy 实现,零新依赖。 **新增 4 个技术指标(30 → 34)** — 按"语义空白"补齐三类现有指标库缺失的维度:止损位、机构成本价、趋势启动时机。均为纯 numpy 实现,零新依赖。
+68 -3
View File
@@ -11,7 +11,28 @@ import click
@click.option("--page", default=1, type=int, help="页码(1 起始)") @click.option("--page", default=1, type=int, help="页码(1 起始)")
@click.option("--table", "use_table", is_flag=True, help="表格输出") @click.option("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") @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)。 """检索公司公告(巨潮资讯网,独立数据源,无需连接 TDX)。
\b \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 688017
easy-tdx announcement 600519 --count 50 --page 2 easy-tdx announcement 601088 --count 50 --page 2
easy-tdx announcement 000001 --table 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 ..cninfo import CninfoClient, CninfoError
from .output import print_error, print_output 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: except CninfoError as e:
print_error(str(e)) print_error(str(e))
raise SystemExit(1) from 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: def _render_table(df: pd.DataFrame) -> str:
"""将 DataFrame 渲染为人类可读的文本表格。""" """将 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: if df.empty:
return "(无数据)" return "(无数据)"
display_df = df.copy() display_df = df.copy()
for col in display_df.columns: for col in display_df.columns:
if display_df[col].dtype == object: if display_df[col].dtype == object and truncate is not None:
display_df[col] = display_df[col].astype(str).str.slice(0, 30) display_df[col] = display_df[col].astype(str).str.slice(0, truncate)
try: try:
import tabulate import tabulate
@@ -51,10 +61,11 @@ def _render_table(df: pd.DataFrame) -> str:
lines: list[str] = [] lines: list[str] = []
cols = list(display_df.columns) cols = list(display_df.columns)
header = " | ".join(str(c) for c in cols) 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(header)
lines.append(sep) lines.append(sep)
for _, row in display_df.iterrows(): 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) lines.append(line)
return "\n".join(lines) return "\n".join(lines)
+90 -9
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from urllib import parse from urllib import parse
@@ -22,7 +23,7 @@ from urllib import request as urlrequest
import pandas as pd import pandas as pd
from .models import Announcement, CninfoError from .models import Announcement, CninfoError, build_detail_url, build_pdf_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,7 +34,6 @@ _UA = (
) )
_STOCK_MAP_URL = "http://www.cninfo.com.cn/new/data/szse_stock.json" _STOCK_MAP_URL = "http://www.cninfo.com.cn/new/data/szse_stock.json"
_QUERY_URL = "https://www.cninfo.com.cn/new/hisAnnouncement/query" _QUERY_URL = "https://www.cninfo.com.cn/new/hisAnnouncement/query"
_DETAIL_URL = "https://www.cninfo.com.cn/new/disclosure/detail?annoId="
# 模块级 orgId 映射缓存:首次拉取后全程复用(Cpython dict 读写原子, # 模块级 orgId 映射缓存:首次拉取后全程复用(Cpython dict 读写原子,
# 并发下最坏多发一次请求,可接受) # 并发下最坏多发一次请求,可接受)
@@ -137,14 +137,87 @@ class CninfoClient:
page: 页码(1 起始)。 page: 页码(1 起始)。
Returns: Returns:
``DataFrame[title, type, date, url]``,按服务器返回顺序(最新在前)。 ``DataFrame[title, type, date, url, code, org_id, announcement_id,
announcement_time, pdf_url]``,按服务器返回顺序(最新在前)。
无结果时返回空 DataFrame(含列名)。 无结果时返回空 DataFrame(含列名)。
Note:
``type`` 列优先取 cninfo 的 ``announcementTypeName``;该字段对很多
公告为 null(数据源限制),此时回退到 ``adjunctType``(如 "PDF"),
再为空给空字符串。
""" """
rows = self._query_announcements(code, count=count, page=page) 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: 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]) 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]: def _query_announcements(self, code: str, *, count: int, page: int) -> list[Announcement]:
"""POST 公告检索接口,解析为 Announcement 列表。 """POST 公告检索接口,解析为 Announcement 列表。
@@ -177,13 +250,21 @@ class CninfoClient:
for item in items: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
continue 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( result.append(
Announcement( Announcement(
title=item.get("announcementTitle", ""), title=item.get("announcementTitle", "") or "",
type=item.get("announcementTypeName", ""), type=type_name,
date=_ts_to_date(item.get("announcementTime")), date=_ts_to_date(anno_time),
url=f"{_DETAIL_URL}{anno_id}", 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 return result
+40
View File
@@ -6,18 +6,58 @@ from dataclasses import dataclass
from easy_tdx.exceptions import TdxError from easy_tdx.exceptions import TdxError
# PDF 附件直链前缀(adjunctUrl 拼此 base 即真实 PDF 地址)
_PDF_BASE = "http://static.cninfo.com.cn/"
@dataclass(frozen=True) @dataclass(frozen=True)
class Announcement: 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 title: str
type: str type: str
date: str # YYYY-MM-DD date: str # YYYY-MM-DD
url: str 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): class CninfoError(TdxError):
+289 -15
View File
@@ -1,12 +1,13 @@
"""巨潮(cninfo)模块离线测试 —— mock HTTP,零网络依赖。 """巨潮(cninfo)模块离线测试 —— mock HTTP,零网络依赖。
覆盖:日期转换、orgId 解析(动态表/三段 fallback)、公告解析、分页 覆盖:日期转换、orgId 解析(动态表/三段 fallback)、公告解析(含 URL 4 参数
错误转换、模块导出。 type 回退、pdf_url)、PDF 下载、分页、错误转换、模块导出。
""" """
from __future__ import annotations from __future__ import annotations
import json import json
from pathlib import Path
from typing import Any from typing import Any
import pandas as pd import pandas as pd
@@ -27,14 +28,28 @@ def test_public_exports() -> None:
def test_announcement_is_frozen_dataclass() -> None: def test_announcement_is_frozen_dataclass() -> None:
"""Announcement 应为 frozen dataclass,含 title/type/date/url""" """Announcement 应为 frozen dataclass,含全部字段"""
from easy_tdx.cninfo import Announcement from easy_tdx.cninfo import Announcement
a = Announcement(title="t", type="ty", date="2026-06-14", url="http://x") a = Announcement(
title="t",
type="ty",
date="2026-06-14",
url="http://x",
code="688017",
org_id="9900041602",
announcement_id="abc123",
announcement_time=1718323200000,
pdf_url="http://static.cninfo.com.cn/x.PDF",
)
assert a.title == "t" assert a.title == "t"
assert a.type == "ty" assert a.type == "ty"
assert a.date == "2026-06-14" assert a.date == "2026-06-14"
assert a.url == "http://x" assert a.code == "688017"
assert a.org_id == "9900041602"
assert a.announcement_id == "abc123"
assert a.announcement_time == 1718323200000
assert a.pdf_url == "http://static.cninfo.com.cn/x.PDF"
# frozen # frozen
with pytest.raises(Exception): with pytest.raises(Exception):
a.title = "mutated" # type: ignore[misc] a.title = "mutated" # type: ignore[misc]
@@ -49,6 +64,29 @@ def test_cninfo_error_is_exception() -> None:
assert issubclass(CninfoError, TdxError) assert issubclass(CninfoError, TdxError)
def test_build_detail_url_has_four_params() -> None:
"""回归 Bug2:详情页 URL 必须含 4 参数 stockCode/announcementId/orgId/announcementTime。"""
from easy_tdx.cninfo.models import build_detail_url
url = build_detail_url("601088", "1225351323", "9900003701", 1780588800000)
assert "stockCode=601088" in url
assert "announcementId=1225351323" in url
assert "orgId=9900003701" in url
assert "announcementTime=1780588800000" in url
def test_build_pdf_url() -> None:
"""adjunctUrl 应拼成 static.cninfo.com.cn 直链。"""
from easy_tdx.cninfo.models import build_pdf_url
assert (
build_pdf_url("finalpage/2026-06-05/1225351400.PDF")
== "http://static.cninfo.com.cn/finalpage/2026-06-05/1225351400.PDF"
)
assert build_pdf_url("") == ""
assert build_pdf_url(None) == "" # type: ignore[arg-type]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 日期转换 # 日期转换
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -58,7 +96,6 @@ def test_ts_to_date_from_millis() -> None:
"""Unix 毫秒整数应转为 YYYY-MM-DD。""" """Unix 毫秒整数应转为 YYYY-MM-DD。"""
from easy_tdx.cninfo.client import _ts_to_date from easy_tdx.cninfo.client import _ts_to_date
# 1718323200000 ms = 2024-06-14 00:00:00 UTC ≈ 当地日期
assert _ts_to_date(1718323200000) # 非空字符串,长度 10 assert _ts_to_date(1718323200000) # 非空字符串,长度 10
assert len(_ts_to_date(1718323200000)) == 10 assert len(_ts_to_date(1718323200000)) == 10
@@ -207,22 +244,34 @@ _QUERY_RESPONSE: dict[str, Any] = {
"announcementTypeName": "股东大会", "announcementTypeName": "股东大会",
"announcementTime": 1749859200000, "announcementTime": 1749859200000,
"announcementId": "abc123", "announcementId": "abc123",
"adjunctUrl": "finalpage/2026-06-14/abc123.PDF",
"adjunctType": "PDF",
}, },
{ {
"announcementTitle": "2024年年度报告", "announcementTitle": "2024年年度报告",
"announcementTypeName": "定期报告", "announcementTypeName": None, # Bug1 场景:typeName 为 null
"announcementTime": 1740614400000, "announcementTime": 1740614400000,
"announcementId": "def456", "announcementId": "def456",
"adjunctUrl": "finalpage/2026-02-27/def456.PDF",
"adjunctType": "PDF",
},
{
"announcementTitle": "无附件公告",
"announcementTypeName": None,
"announcementTime": 1740614400000,
"announcementId": "ghi789",
"adjunctUrl": "", # 无 PDF 附件
"adjunctType": None,
}, },
], ],
"totalAnnouncement": 2, "totalAnnouncement": 3,
} }
def test_get_announcements_returns_dataframe( def test_get_announcements_returns_dataframe(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None: ) -> None:
"""应返回 DataFrame[title, type, date, url]""" """应返回 DataFrame,含全部新字段"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"}) _patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr( monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form", "easy_tdx.cninfo.client._http_post_form",
@@ -232,13 +281,75 @@ def test_get_announcements_returns_dataframe(
df = CninfoClient().get_announcements("688017", count=30, page=1) df = CninfoClient().get_announcements("688017", count=30, page=1)
assert isinstance(df, pd.DataFrame) assert isinstance(df, pd.DataFrame)
assert list(df.columns) == ["title", "type", "date", "url"] expected_cols = [
assert len(df) == 2 "title",
"type",
"date",
"url",
"code",
"org_id",
"announcement_id",
"announcement_time",
"pdf_url",
]
assert list(df.columns) == expected_cols
assert len(df) == 3
# 第一行:正常 typeName
assert df.iloc[0]["title"] == "关于召开2025年年度股东大会的通知" assert df.iloc[0]["title"] == "关于召开2025年年度股东大会的通知"
assert df.iloc[0]["type"] == "股东大会" assert df.iloc[0]["type"] == "股东大会"
assert len(df.iloc[0]["date"]) == 10 # YYYY-MM-DD assert len(df.iloc[0]["date"]) == 10
assert df.iloc[0]["url"].endswith("abc123") assert df.iloc[0]["pdf_url"].endswith("abc123.PDF")
assert "cninfo.com.cn" in df.iloc[0]["url"]
def test_get_announcements_type_fallback_to_adjunct_type(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""回归 Bug1announcementTypeName 为 null 时回退 adjunctType。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form",
lambda url, payload, timeout=15.0: _QUERY_RESPONSE,
)
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017")
# 第二行 typeName=null 但 adjunctType=PDF → type 应回退为 "PDF"
assert df.iloc[1]["type"] == "PDF"
# 第三行 typeName=null 且 adjunctType=null → type 为空字符串(非 nan)
assert df.iloc[2]["type"] == ""
def test_get_announcements_url_has_four_params(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""回归 Bug2:URL 必须含 4 参数才能打开(否则 404)。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form",
lambda url, payload, timeout=15.0: _QUERY_RESPONSE,
)
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017")
url = df.iloc[0]["url"]
assert "stockCode=688017" in url
assert "announcementId=abc123" in url
assert "orgId=9900041602" in url
assert "announcementTime=" in url
def test_get_announcements_pdf_url(monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any) -> None:
"""pdf_url 应为 static.cninfo.com.cn 直链,无附件时为空。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form",
lambda url, payload, timeout=15.0: _QUERY_RESPONSE,
)
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017")
assert df.iloc[0]["pdf_url"] == "http://static.cninfo.com.cn/finalpage/2026-06-14/abc123.PDF"
assert df.iloc[2]["pdf_url"] == "" # 无附件
def test_get_announcements_empty(monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any) -> None: def test_get_announcements_empty(monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any) -> None:
@@ -253,7 +364,7 @@ def test_get_announcements_empty(monkeypatch: pytest.MonkeyPatch, reset_orgid_ca
df = CninfoClient().get_announcements("688017") df = CninfoClient().get_announcements("688017")
assert isinstance(df, pd.DataFrame) assert isinstance(df, pd.DataFrame)
assert df.empty assert df.empty
assert list(df.columns) == ["title", "type", "date", "url"] assert len(df.columns) == 9
def test_get_announcements_missing_key( def test_get_announcements_missing_key(
@@ -340,6 +451,7 @@ def test_get_announcements_skips_non_dict_items(
"announcementTitle": "ok", "announcementTitle": "ok",
"announcementTime": 1749859200000, "announcementTime": 1749859200000,
"announcementId": "x", "announcementId": "x",
"adjunctUrl": "",
}, },
] ]
}, },
@@ -389,6 +501,168 @@ def test_get_announcements_malformed_timestamp_wrapped_as_cninfo_error(
CninfoClient().get_announcements("688017") CninfoClient().get_announcements("688017")
# ---------------------------------------------------------------------------
# PDF 下载
# ---------------------------------------------------------------------------
def _make_anno(**overrides: Any) -> Any:
"""构造测试用 Announcement(默认有 pdf_url)。"""
from easy_tdx.cninfo import Announcement
defaults: dict[str, Any] = {
"title": "测试公告",
"type": "PDF",
"date": "2026-06-14",
"url": "http://x",
"code": "688017",
"org_id": "9900041602",
"announcement_id": "abc123",
"announcement_time": 1718323200000,
"pdf_url": "http://static.cninfo.com.cn/x.PDF",
}
defaults.update(overrides)
return Announcement(**defaults)
def test_download_pdf_success(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""download_pdf 应写入文件并返回绝对路径。"""
from easy_tdx.cninfo import CninfoClient
pdf_bytes = b"%PDF-1.4\nfake pdf content"
class _FakeResp:
def __init__(self, body: bytes) -> None:
self._body = body
def read(self) -> bytes:
return self._body
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *args: Any) -> None:
pass
def _fake_urlopen(req: Any, timeout: float = 15.0) -> _FakeResp:
return _FakeResp(pdf_bytes)
import easy_tdx.cninfo.client as mod
monkeypatch.setattr(mod.urlrequest, "urlopen", _fake_urlopen)
anno = _make_anno()
path = CninfoClient().download_pdf(anno, dest_dir=tmp_path)
assert Path(path).exists()
assert Path(path).read_bytes() == pdf_bytes
# 默认文件名格式:{date}_{announcement_id}.PDF
assert "abc123" in Path(path).name
assert Path(path).name.endswith(".PDF")
def test_download_pdf_custom_filename(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""自定义 filename 应被使用。"""
from easy_tdx.cninfo import CninfoClient
class _FakeResp:
def read(self) -> bytes:
return b"%PDF-1.4"
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *args: Any) -> None:
pass
import easy_tdx.cninfo.client as mod
monkeypatch.setattr(mod.urlrequest, "urlopen", lambda req, timeout=15.0: _FakeResp())
path = CninfoClient().download_pdf(_make_anno(), dest_dir=tmp_path, filename="custom.pdf")
assert Path(path).name == "custom.pdf"
def test_download_pdf_no_attachment_raises() -> None:
"""pdf_url 为空应抛 CninfoError,不触网。"""
from easy_tdx.cninfo import CninfoClient, CninfoError
anno = _make_anno(pdf_url="")
with pytest.raises(CninfoError):
CninfoClient().download_pdf(anno)
def test_download_pdf_creates_dest_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""目标目录不存在应自动创建。"""
from easy_tdx.cninfo import CninfoClient
class _FakeResp:
def read(self) -> bytes:
return b"%PDF-1.4"
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *args: Any) -> None:
pass
import easy_tdx.cninfo.client as mod
monkeypatch.setattr(mod.urlrequest, "urlopen", lambda req, timeout=15.0: _FakeResp())
nested = tmp_path / "a" / "b" / "c"
path = CninfoClient().download_pdf(_make_anno(), dest_dir=nested)
assert Path(path).exists()
assert nested.is_dir()
def test_download_pdf_accepts_series(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""download_pdf 应兼容 pd.SeriesDataFrame.iloc[i] 的返回类型)。"""
from easy_tdx.cninfo import CninfoClient
class _FakeResp:
def read(self) -> bytes:
return b"%PDF-1.4"
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *args: Any) -> None:
pass
import easy_tdx.cninfo.client as mod
monkeypatch.setattr(mod.urlrequest, "urlopen", lambda req, timeout=15.0: _FakeResp())
# 模拟 DataFrame 的一行
row = pd.Series(
{
"title": "t",
"type": "PDF",
"date": "2026-06-14",
"url": "http://x",
"code": "688017",
"org_id": "9900041602",
"announcement_id": "abc",
"announcement_time": 1718323200000,
"pdf_url": "http://static.cninfo.com.cn/x.PDF",
}
)
path = CninfoClient().download_pdf(row, dest_dir=tmp_path)
assert Path(path).exists()
def test_download_pdf_network_failure_wrapped(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""下载网络失败应转 CninfoError。"""
from easy_tdx.cninfo import CninfoClient, CninfoError
def _boom(req: Any, timeout: float = 15.0) -> Any:
raise OSError("connection reset")
import easy_tdx.cninfo.client as mod
monkeypatch.setattr(mod.urlrequest, "urlopen", _boom)
with pytest.raises(CninfoError):
CninfoClient().download_pdf(_make_anno(), dest_dir=tmp_path)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# urllib helper 烟雾测试(不触网,仅验证 JSON 解码路径) # urllib helper 烟雾测试(不触网,仅验证 JSON 解码路径)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------