feat(cli): company-info 传板块名自动读完整正文(分块循环 + 多服务器重试)

修复体验问题:此前传板块名仍需用户关心 --offset/--length,太笨拙。
现在传板块名即自动按目录 length 分块循环读取整个板块(单次上限 30720 字节,
大板块如「公司大事」77万字节也能一次读全),--offset/--length 仅传文件名时生效。

- _resolve_filename 返回板块 length,_run_content 分块循环读取完整内容
- 修复分块 offset 推进 bug:原按解码字符串 GBK 重编码计字节,遇 U+FFFD 崩溃;
  改为按请求字节数推进(服务器按字节偏移工作)
- 修复多服务器目录版本不一致:传板块名未命中时自动重试多个服务器(最多4次)
- bump 版本号至 1.15.3
This commit is contained in:
Justin Gu
2026-06-27 04:37:44 +08:00
parent 297a479928
commit 3945800728
5 changed files with 114 additions and 49 deletions
+12
View File
@@ -2,6 +2,18 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.15.3] — 2026-06-27
### 变更
- **`company-info` 传板块名时自动读完整正文** — 此前传板块名仍需用户关心 `--offset`/`--length`,体验笨拙。现在传板块名时自动按目录里的 `length` 分块循环读取整个板块(单次上限 30720 字节,大板块如「公司大事」77 万字节也能一次读全),`--offset`/`--length` 仅在传文件名时生效。
- 用户只需:`easy-tdx company-info SH 601088 "公司概况"` 即可读到该板块完整内容,无需任何 offset/length。
- 修复分块 offset 推进 bug:原按解码后字符串 GBK 重编码计字节数,遇到 GBK 无法解码的字符(U+FFFD)会抛 `UnicodeEncodeError`;改为按请求字节数推进(服务器按字节偏移工作)。
### 修复
- **多服务器 F10 目录版本不一致**(`cmd_company.py`)— 通达信不同服务器返回的 F10 目录板块名版本不一致(新版含「公司大事/研究报告/...」,旧版含「机构持股/分红融资/...」)。传板块名时若当前服务器未命中,现自动重试多个服务器(新建连接,最多 4 次)直至命中,避免「列目录能看到、读正文却找不到」的割裂。
## [1.15.2] — 2026-06-27
### 变更
+3 -4
View File
@@ -840,14 +840,13 @@ easy-tdx f10 000001 --type llb --table # 平安现金流量表,表格输
```bash
easy-tdx finance-info SH 600519 --table # 最新财务快照(30+ 项单期指标)
easy-tdx company-info SH 600519 # F10 板块目录(最新提示/公司概况/...)
easy-tdx company-info SH 600519 "公司概况" # 读板块正文自动解析板块名→文件
easy-tdx company-info SH 600519 "分红扩股" --length 2048 # 加长读取
easy-tdx company-info SH 600519 600519.txt # 也可直接传文件名
easy-tdx company-info SH 600519 "公司概况" # 读板块完整正文自动解析+读全,无需 offset/length
easy-tdx company-info SH 600519 600519.txt # 也可直接传文件名(此时用 --offset/--length
```
- ``finance-info``:最新一期财务快照,含股本结构、资产负债、利润、现金流、每股指标(37 字段)。与 ``f10`` 互补——前者是单期快照,后者是多期三表。
- ``company-info``:**一个命令两种用法**——无板块名参数列 F10 板块目录,有板块名参数读正文。目录含 16 个板块(最新提示、公司概况、财务分析、股东研究、股本结构、资本运作、业内点评、行业分析、公司大事、研究报告、经营分析、主力追踪、分红扩股、高层治理、龙虎榜单、关联个股)。
- 读正文时 ``name_or_filename`` 既可传板块名(自动定位到该板块起点),也可传文件名;``--offset`` 语义随入参而定(板块名=板块内相对偏移,文件名=文件绝对偏移),``--length`` 控制读取字节数
- 读正文时传板块名即可自动读取完整内容(按目录 length 分块循环,大板块如「公司大事」也能一次读全);``--offset``/``--length`` 仅在传文件名时生效。通达信多服务器目录版本不一致时自动重试命中
### 扩展市场(港股/美股/期货)
+1 -1
View File
@@ -23,7 +23,7 @@ easy-tdx company-info SH 600519 "公司概况"
easy-tdx company-info SH 600519 "分红扩股" --length 2048
```
读正文时板块名也可换成文件名(`600519.txt`),此时 `--offset` 为文件绝对偏移
读正文时**只需传板块名即可读完整内容**(自动按目录 length 分块循环,大板块也一次读全),无需关心 offset/length。也可换成文件名(`600519.txt`),此时 `--offset`/`--length` 控制
## F10 板块完整列表
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.15.2"
version = "1.15.3"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+89 -35
View File
@@ -56,9 +56,14 @@ def finance_info(market: str, code: str, use_table: bool, output_fmt: str) -> No
"--offset",
default=0,
type=int,
help="读正文偏移(字节):传板块名时为板块内相对偏移,传文件名时文件绝对偏移,默认 0",
help="传文件名时生效:文件绝对偏移(字节,默认 0",
)
@click.option(
"--length",
default=1024,
type=int,
help="仅传文件名时生效:读取长度(字节,默认 1024)",
)
@click.option("--length", default=1024, type=int, help="读正文长度(字节,默认 1024")
def company_info(
market: str,
code: str,
@@ -76,14 +81,15 @@ def company_info(
# 1. 列出 F10 板块目录(最新提示/公司概况/财务分析/... 共 16 板块)
easy-tdx company-info SH 600519 --table
# 2. 读取指定板块正文(板块名自动解析定位)
# 2. 读取指定板块的完整正文(板块名自动解析定位,自动读全,无需 offset/length
easy-tdx company-info SH 600519 "公司概况"
easy-tdx company-info SH 600519 "分红扩股" --length 2048
easy-tdx company-info SH 600519 "公司大事" # 大板块自动分块循环读全
\b
读正文时,name_or_filename 既可传板块名(如 ``最新提示``),也可直接传文件名
(如 ``600519.txt``)。``--offset`` 语义随入参而定:传板块名时为板块内相对
偏移(0 = 板块起点);传文件名时为文件绝对偏移。
传板块名时自动读取该板块完整内容(按目录里的 length 分块循环,单次上限
30720 字节),用户无需关心 offset/length。
也可直接传文件名(如 ``601088.txt``),此时用 --offset(文件绝对偏移,默认 0)
和 --length(读取字节数,默认 1024)控制读取范围。
"""
mkt = Market(parse_market(market))
if name_or_filename is None:
@@ -140,29 +146,81 @@ def _run_category(market: Market, code: str, use_table: bool, output_fmt: str) -
def _run_content(
market: Market, code: str, name_or_filename: str, offset: int, length: int
) -> None:
"""读取 F10 板块正文并输出。"""
"""读取 F10 板块正文并输出。
传板块名时自动读取该板块**完整内容**(按目录里的 length 分块循环,单次上限
30720 字节);--offset/--length 仅在传文件名时生效。
通达信多服务器返回的 F10 目录版本可能不一致(有的含新板块名、有的含旧板块名),
因此传板块名时若未命中会重试多个服务器(新建连接),直至命中或耗尽。
"""
from ..exceptions import TdxError
from .conn import get_tdx_client
from .output import print_error
# 形如 '601088.txt' 的文件名,直接读,无需查目录
if "." in name_or_filename:
try:
with get_tdx_client() as client:
filename, seg_start, matched_board = _resolve_filename(
client, market, code, name_or_filename
)
# 板块名命中:--offset 解释为板块内相对偏移(默认 0 = 板块起点)
# 文件名:--offset 解释为文件绝对偏移
effective_offset = seg_start + offset if matched_board else offset
content = client.get_company_info_content(
market, code, filename, effective_offset, length
market, code, name_or_filename, offset, length
)
except _ResolveError as e:
print_error(str(e))
raise SystemExit(1) from e
except TdxError as e:
print_error(str(e))
raise SystemExit(1) from e
click.echo(content)
return
# 板块名:多服务器重试,直到命中
max_attempts = 4
last_available = ""
for _ in range(max_attempts):
try:
with get_tdx_client() as client:
filename, seg_start, seg_length, matched_board = _resolve_filename(
client, market, code, name_or_filename
)
except _ResolveError as e:
last_available = str(e)
continue
except TdxError as e:
last_available = str(e)
continue
try:
content = _read_full_section(client, market, code, filename, seg_start, seg_length)
except TdxError as e:
print_error(str(e))
raise SystemExit(1) from e
click.echo(content)
return
# 全部重试未命中
detail = f"\n{last_available}" if last_available else ""
print_error(f"未找到板块名 '{name_or_filename}'(已尝试 {max_attempts} 个服务器)。{detail}")
raise SystemExit(1)
def _read_full_section(
client: object, market: Market, code: str, filename: str, start: int, length: int
) -> str:
"""分块循环读取板块完整内容(单次上限 30720 字节)。
offset/length 均为服务器端字节偏移,按请求的 n 推进 pos
(解码后字符串无法精确还原字节数,且有 GBK 无法解码的字节会产生 U+FFFD)。
"""
chunk_size = 30720
parts: list[str] = []
pos = start
end = start + length
while pos < end:
n = min(chunk_size, end - pos)
chunk = client.get_company_info_content(market, code, filename, pos, n) # type: ignore[attr-defined]
if not chunk:
break
parts.append(chunk)
pos += n # 服务器按请求的字节数推进偏移
return "".join(parts)
class _ResolveError(Exception):
@@ -171,29 +229,25 @@ class _ResolveError(Exception):
def _resolve_filename(
client: object, market: Market, code: str, name_or_filename: str
) -> tuple[str, int, bool]:
"""把板块名或文件名解析为 (filename, 板块起始 offset, 是否板块名命中)。
) -> tuple[str, int, int, bool]:
"""把板块名解析为 (filename, 板块起始 offset, 板块长度, True)。
策略:先查 F10 目录,若 ``name_or_filename`` 命中某个板块名则返回
(对应 filename, 该板块 start, True);否则视为文件名返回 (name_or_filename, 0, False)
返回的 ``matched`` 决定 ``--offset`` 的语义:板块名时为相对偏移,文件名时为绝对偏移
注意:通达信多服务器目录版本不一致,命中与否取决于连到哪台服务器,
调用方应配合重试(见 _run_content)。未命中抛 _ResolveError(含可用板块名)
仅处理板块名;文件名解析在 _run_content 顶部完成
"""
# 形如 '600519.txt' 的文件名,跳过目录查询直接用(offset 为绝对偏移)
if "." in name_or_filename:
return name_or_filename, 0, False
df = client.get_company_info_category(market, code) # type: ignore[attr-defined]
if not df.empty:
row = df.loc[df["name"] == name_or_filename]
if not row.empty:
return str(row["filename"].iloc[0]), int(row["start"].iloc[0]), True
return (
str(row["filename"].iloc[0]),
int(row["start"].iloc[0]),
int(row["length"].iloc[0]),
True,
)
# 未命中板块名:当作不带后缀的纯 ASCII 文件名兜底(如 '600519' → '600519.txt'
if name_or_filename.isascii() and name_or_filename.isalnum():
return f"{name_or_filename}.txt", 0, False
available = "" if df.empty else "".join(df["name"].tolist())
available = "".join(df["name"].tolist()) if not df.empty else ""
raise _ResolveError(
f"未找到板块名 '{name_or_filename}'"
+ (f"可用板块:{available}" if available else "请先运行 `easy-tdx company-info` 查看目录。")
f"可用板块:{available}" if available else "请先运行 `easy-tdx company-info` 查看目录"
)