mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 15:44:15 +08:00
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:
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
|
本文件记录 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
|
## [1.15.2] — 2026-06-27
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
@@ -840,14 +840,13 @@ easy-tdx f10 000001 --type llb --table # 平安现金流量表,表格输
|
|||||||
```bash
|
```bash
|
||||||
easy-tdx finance-info SH 600519 --table # 最新财务快照(30+ 项单期指标)
|
easy-tdx finance-info SH 600519 --table # 最新财务快照(30+ 项单期指标)
|
||||||
easy-tdx company-info SH 600519 # F10 板块目录(最新提示/公司概况/...)
|
easy-tdx company-info SH 600519 # F10 板块目录(最新提示/公司概况/...)
|
||||||
easy-tdx company-info SH 600519 "公司概况" # 读板块正文,自动解析板块名→文件
|
easy-tdx company-info SH 600519 "公司概况" # 读板块完整正文(自动解析+读全,无需 offset/length)
|
||||||
easy-tdx company-info SH 600519 "分红扩股" --length 2048 # 加长读取
|
easy-tdx company-info SH 600519 600519.txt # 也可直接传文件名(此时用 --offset/--length)
|
||||||
easy-tdx company-info SH 600519 600519.txt # 也可直接传文件名
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- ``finance-info``:最新一期财务快照,含股本结构、资产负债、利润、现金流、每股指标(37 字段)。与 ``f10`` 互补——前者是单期快照,后者是多期三表。
|
- ``finance-info``:最新一期财务快照,含股本结构、资产负债、利润、现金流、每股指标(37 字段)。与 ``f10`` 互补——前者是单期快照,后者是多期三表。
|
||||||
- ``company-info``:**一个命令两种用法**——无板块名参数列 F10 板块目录,有板块名参数读正文。目录含 16 个板块(最新提示、公司概况、财务分析、股东研究、股本结构、资本运作、业内点评、行业分析、公司大事、研究报告、经营分析、主力追踪、分红扩股、高层治理、龙虎榜单、关联个股)。
|
- ``company-info``:**一个命令两种用法**——无板块名参数列 F10 板块目录,有板块名参数读正文。目录含 16 个板块(最新提示、公司概况、财务分析、股东研究、股本结构、资本运作、业内点评、行业分析、公司大事、研究报告、经营分析、主力追踪、分红扩股、高层治理、龙虎榜单、关联个股)。
|
||||||
- 读正文时 ``name_or_filename`` 既可传板块名(自动定位到该板块起点),也可传文件名;``--offset`` 语义随入参而定(板块名=板块内相对偏移,文件名=文件绝对偏移),``--length`` 控制读取字节数。
|
- 读正文时传板块名即可自动读取完整内容(按目录 length 分块循环,大板块如「公司大事」也能一次读全);``--offset``/``--length`` 仅在传文件名时生效。通达信多服务器目录版本不一致时自动重试命中。
|
||||||
|
|
||||||
|
|
||||||
### 扩展市场(港股/美股/期货)
|
### 扩展市场(港股/美股/期货)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ easy-tdx company-info SH 600519 "公司概况"
|
|||||||
easy-tdx company-info SH 600519 "分红扩股" --length 2048
|
easy-tdx company-info SH 600519 "分红扩股" --length 2048
|
||||||
```
|
```
|
||||||
|
|
||||||
读正文时板块名也可换成文件名(`600519.txt`),此时 `--offset` 为文件绝对偏移。
|
读正文时**只需传板块名即可读完整内容**(自动按目录 length 分块循环,大板块也一次读全),无需关心 offset/length。也可换成文件名(`600519.txt`),此时用 `--offset`/`--length` 控制。
|
||||||
|
|
||||||
## F10 板块完整列表
|
## F10 板块完整列表
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "easy-tdx"
|
name = "easy-tdx"
|
||||||
version = "1.15.2"
|
version = "1.15.3"
|
||||||
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -56,9 +56,14 @@ def finance_info(market: str, code: str, use_table: bool, output_fmt: str) -> No
|
|||||||
"--offset",
|
"--offset",
|
||||||
default=0,
|
default=0,
|
||||||
type=int,
|
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(
|
def company_info(
|
||||||
market: str,
|
market: str,
|
||||||
code: str,
|
code: str,
|
||||||
@@ -76,14 +81,15 @@ def company_info(
|
|||||||
# 1. 列出 F10 板块目录(最新提示/公司概况/财务分析/... 共 16 板块)
|
# 1. 列出 F10 板块目录(最新提示/公司概况/财务分析/... 共 16 板块)
|
||||||
easy-tdx company-info SH 600519 --table
|
easy-tdx company-info SH 600519 --table
|
||||||
|
|
||||||
# 2. 读取指定板块正文(板块名自动解析定位)
|
# 2. 读取指定板块的完整正文(板块名自动解析定位,自动读全,无需 offset/length)
|
||||||
easy-tdx company-info SH 600519 "公司概况"
|
easy-tdx company-info SH 600519 "公司概况"
|
||||||
easy-tdx company-info SH 600519 "分红扩股" --length 2048
|
easy-tdx company-info SH 600519 "公司大事" # 大板块自动分块循环读全
|
||||||
|
|
||||||
\b
|
\b
|
||||||
读正文时,name_or_filename 既可传板块名(如 ``最新提示``),也可直接传文件名
|
传板块名时自动读取该板块完整内容(按目录里的 length 分块循环,单次上限
|
||||||
(如 ``600519.txt``)。``--offset`` 语义随入参而定:传板块名时为板块内相对
|
30720 字节),用户无需关心 offset/length。
|
||||||
偏移(0 = 板块起点);传文件名时为文件绝对偏移。
|
也可直接传文件名(如 ``601088.txt``),此时用 --offset(文件绝对偏移,默认 0)
|
||||||
|
和 --length(读取字节数,默认 1024)控制读取范围。
|
||||||
"""
|
"""
|
||||||
mkt = Market(parse_market(market))
|
mkt = Market(parse_market(market))
|
||||||
if name_or_filename is None:
|
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(
|
def _run_content(
|
||||||
market: Market, code: str, name_or_filename: str, offset: int, length: int
|
market: Market, code: str, name_or_filename: str, offset: int, length: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""读取 F10 板块正文并输出。"""
|
"""读取 F10 板块正文并输出。
|
||||||
|
|
||||||
|
传板块名时自动读取该板块**完整内容**(按目录里的 length 分块循环,单次上限
|
||||||
|
30720 字节);--offset/--length 仅在传文件名时生效。
|
||||||
|
|
||||||
|
通达信多服务器返回的 F10 目录版本可能不一致(有的含新板块名、有的含旧板块名),
|
||||||
|
因此传板块名时若未命中会重试多个服务器(新建连接),直至命中或耗尽。
|
||||||
|
"""
|
||||||
from ..exceptions import TdxError
|
from ..exceptions import TdxError
|
||||||
from .conn import get_tdx_client
|
from .conn import get_tdx_client
|
||||||
from .output import print_error
|
from .output import print_error
|
||||||
|
|
||||||
try:
|
# 形如 '601088.txt' 的文件名,直接读,无需查目录
|
||||||
with get_tdx_client() as client:
|
if "." in name_or_filename:
|
||||||
filename, seg_start, matched_board = _resolve_filename(
|
try:
|
||||||
client, market, code, name_or_filename
|
with get_tdx_client() as client:
|
||||||
)
|
content = client.get_company_info_content(
|
||||||
# 板块名命中:--offset 解释为板块内相对偏移(默认 0 = 板块起点)
|
market, code, name_or_filename, offset, length
|
||||||
# 文件名:--offset 解释为文件绝对偏移
|
)
|
||||||
effective_offset = seg_start + offset if matched_board else offset
|
except TdxError as e:
|
||||||
content = client.get_company_info_content(
|
print_error(str(e))
|
||||||
market, code, filename, effective_offset, length
|
raise SystemExit(1) from e
|
||||||
)
|
click.echo(content)
|
||||||
except _ResolveError as e:
|
return
|
||||||
print_error(str(e))
|
|
||||||
raise SystemExit(1) from e
|
# 板块名:多服务器重试,直到命中
|
||||||
except TdxError as e:
|
max_attempts = 4
|
||||||
print_error(str(e))
|
last_available = ""
|
||||||
raise SystemExit(1) from e
|
for _ in range(max_attempts):
|
||||||
click.echo(content)
|
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):
|
class _ResolveError(Exception):
|
||||||
@@ -171,29 +229,25 @@ class _ResolveError(Exception):
|
|||||||
|
|
||||||
def _resolve_filename(
|
def _resolve_filename(
|
||||||
client: object, market: Market, code: str, name_or_filename: str
|
client: object, market: Market, code: str, name_or_filename: str
|
||||||
) -> tuple[str, int, bool]:
|
) -> tuple[str, int, int, bool]:
|
||||||
"""把板块名或文件名解析为 (filename, 板块起始 offset, 是否板块名命中)。
|
"""把板块名解析为 (filename, 板块起始 offset, 板块长度, True)。
|
||||||
|
|
||||||
策略:先查 F10 目录,若 ``name_or_filename`` 命中某个板块名则返回
|
注意:通达信多服务器目录版本不一致,命中与否取决于连到哪台服务器,
|
||||||
(对应 filename, 该板块 start, True);否则视为文件名返回 (name_or_filename, 0, False)。
|
调用方应配合重试(见 _run_content)。未命中抛 _ResolveError(含可用板块名)。
|
||||||
返回的 ``matched`` 决定 ``--offset`` 的语义:板块名时为相对偏移,文件名时为绝对偏移。
|
仅处理板块名;文件名解析在 _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]
|
df = client.get_company_info_category(market, code) # type: ignore[attr-defined]
|
||||||
if not df.empty:
|
if not df.empty:
|
||||||
row = df.loc[df["name"] == name_or_filename]
|
row = df.loc[df["name"] == name_or_filename]
|
||||||
if not row.empty:
|
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')
|
available = "、".join(df["name"].tolist()) if not df.empty else ""
|
||||||
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())
|
|
||||||
raise _ResolveError(
|
raise _ResolveError(
|
||||||
f"未找到板块名 '{name_or_filename}'。"
|
f"可用板块:{available}" if available else "请先运行 `easy-tdx company-info` 查看目录。"
|
||||||
+ (f"可用板块:{available}" if available else "请先运行 `easy-tdx company-info` 查看目录。")
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user