diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a2bf4d..beee068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 变更 diff --git a/README.md b/README.md index eecc89f..d934df2 100644 --- a/README.md +++ b/README.md @@ -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`` 仅在传文件名时生效。通达信多服务器目录版本不一致时自动重试命中。 ### 扩展市场(港股/美股/期货) diff --git a/examples/06_finance/README.md b/examples/06_finance/README.md index c7a4165..00baf9f 100644 --- a/examples/06_finance/README.md +++ b/examples/06_finance/README.md @@ -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 板块完整列表 diff --git a/pyproject.toml b/pyproject.toml index 1cf318c..83445e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/cli/cmd_company.py b/src/easy_tdx/cli/cmd_company.py index 1b5fe4b..4bd2d58 100644 --- a/src/easy_tdx/cli/cmd_company.py +++ b/src/easy_tdx/cli/cmd_company.py @@ -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 - 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 - ) - 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) + # 形如 '601088.txt' 的文件名,直接读,无需查目录 + if "." in name_or_filename: + try: + with get_tdx_client() as client: + content = client.get_company_info_content( + market, code, name_or_filename, offset, length + ) + 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` 查看目录。" )