gangtise-openapi-cli 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -72
- package/dist/src/cli.js +35 -12
- package/dist/src/core/args.js +11 -0
- package/dist/src/core/client.js +50 -9
- package/dist/src/core/download.js +65 -14
- package/dist/src/core/endpoints.js +39 -0
- package/dist/src/core/errors.js +1 -0
- package/dist/src/core/output.js +29 -6
- package/dist/src/core/quoteSharding.js +19 -1
- package/dist/src/core/transport.js +13 -4
- package/dist/src/version.js +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -4,6 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
## Changelog
|
|
6
6
|
|
|
7
|
+
### v0.26.0 — 2026-07-11
|
|
8
|
+
|
|
9
|
+
**资金安全(重要)**
|
|
10
|
+
- 13 个贵档端点(`one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `theme-tracking` / `management-discuss-*`×2 / `hot-topic` / `knowledge-batch` / `earnings-review get-id` / `viewpoint-debate get-id` / `concept-info` / `concept-securities`)改为 **no-replay 重试策略**:5xx / 超时 / `999999` 不再自动重放——实测(2026-07-11)平台按次计费且**缓存命中不豁免**,同参数重放每次都扣分;仅连接期错误(`ECONNREFUSED`/DNS,请求未发出)、429 限流和 token 自愈仍重试。便宜按条计费的 list 类维持原全量重试(失败响应没有数据行、不计费)
|
|
11
|
+
- 连接失败 `ECONNREFUSED` / `UND_ERR_CONNECT_TIMEOUT` 纳入默认重试范围(此前这两类不重试)
|
|
12
|
+
|
|
13
|
+
**文件安全**
|
|
14
|
+
- 所有 `--output` 落盘(导出、流式下载主路径、签名 URL 跟随下载)改为原子写:先写同目录 `.part` 成功后 rename——重跑失败不再毁掉已有旧文件;顺带修掉中止路径上 `.part` 因流懒打开竞态残留的问题
|
|
15
|
+
- 签名 URL 跟随下载改走 transport 层:遵守 `GANGTISE_TIMEOUT_MS`(此前裸 `fetch` 无超时,慢滴速 CDN 可无限挂起)、网络错误自动重试、跟随最多 3 跳重定向(undici 不自动跟随,超限/缺 `Location` 报错而非把跳转页存成文件)、`--verbose` 日志剥离签名 query 只留 origin+path
|
|
16
|
+
- 自动命名去重后缀试尽 `-1`…`-99` 仍冲突时报错,不再静默覆盖最早的文件
|
|
17
|
+
|
|
18
|
+
**修复 / 加固**
|
|
19
|
+
- 下载重定向超过 3 跳或缺 `Location` 时报错,不再把跳转页 HTML 当文件内容保存
|
|
20
|
+
- 损坏的 gzip 响应包装为带请求上下文的 `ApiError`(此前抛裸 zlib `Z_DATA_ERROR`,与请求无关联且不可定位)
|
|
21
|
+
- `alternative edb-search --limit` ≤200、`indicator search --limit` ≤100 本地上限校验——实测服务端对超限值静默截断(201→200、101→100),与 v0.25.0 的 `--top` 同类同修法
|
|
22
|
+
|
|
23
|
+
### v0.25.0 — 2026-07-10
|
|
24
|
+
|
|
25
|
+
**新增接口(4)**
|
|
26
|
+
- `insight qa list` — 投资者问答 QA:按证券提取互动平台 / 电话会议 / 调研纪要的提问与回答;`--security-code`(必填)、`--source`(`conference`/`interactive`/`survey`)、`--question-category`(11 类,见 `insight.md`)、`--answer-important`(`1` 是 / `0` 否)、`--start-time`/`--end-time`(字符串直传);自动翻页(单页上限 500);0.1 积分/条
|
|
27
|
+
- `insight report-image list` / `download` — 研报图表:按关键词搜索研报图片,返回 `chunkId` + 元数据(`--keyword` 必填、`--top` 默认 10 上限 20、`--source-id`、时间过滤;**免费**),再 `download --chunk-id` 下二进制原图(JPEG,0.1 积分/张)
|
|
28
|
+
- `reference official-account-search` — 公众号 ID 搜索:输入公众号名 / 机构 / 关键字返回 `accountId`(喂 `insight official-account list --account-id`);`--keyword`(必填)、`--category`(`listedCompany`/`broker`/`government`/`media`,可重复;未分类公众号 `category` 为 `null`,传 `--category` 会漏掉)、`--top`(默认 10 上限 10);免费
|
|
29
|
+
|
|
30
|
+
**变更**
|
|
31
|
+
- `indicator search` / `cross-section` / `time-series` 市场范围从仅 A 股扩展至 A 股 / 港股 / 美股(服务端变更;CLI 早已支持 `--currency` 与多市场证券代码,无需改动)。⚠️ 美股代码用交易所后缀 `.O`(NASDAQ) / `.N`(NYSE),**非 `.US`**——官方示例的 `AAPL.US` 查不到数据,实测须 `AAPL.O`
|
|
32
|
+
|
|
33
|
+
**修复 / 加固**(承接上一批未单独发版的改动)
|
|
34
|
+
- 分页 / 分片 `partial` 检测补全:`requestPaginated` 的短后续页、`MAX_PAGES` 上限、`total` 漂移、失败页四种场景统一触发 `partial`(退出码 3)——失败页独立成判定条件,避免超额返回的兄弟页把行数补满、掩盖失败页空洞;`quote` 全市场分片硬错后熔断、破损形状分片计入 `failedShards`
|
|
35
|
+
- `--top` 本地上限校验(`report-image` / `knowledge-batch` ≤20,reference 六个搜索命令 ≤10)——实测服务端对超限值**静默截断**不报错,现在发请求前本地报错;`securities-search` / `institution-search` / `official-account-search` 的 `--category` 加本地白名单——实测服务端对拼错的分类**不报错**(securities-search 静默忽略过滤返回全类别、另两个静默返回空),拼写错误不再伪装成"无结果"(`insight qa` 的枚举服务端会报 `100003`,故不做本地白名单)
|
|
36
|
+
- 错误码 `100003`(参数值非法)补充中文提示——服务端不指明是哪个参数,提示对照命令 `--help` 检查枚举参数拼写
|
|
37
|
+
- undici `^7.16.0` → `^7.28.0`(修 keep-alive 队列污染 GHSA-35p6-xmwp-9g52),`engines.node` `>=20` → `>=20.18.1` 对齐 undici 实际最低要求
|
|
38
|
+
|
|
7
39
|
### v0.24.0 — 2026-07-07
|
|
8
40
|
|
|
9
41
|
**新增**
|
|
@@ -44,75 +76,6 @@
|
|
|
44
76
|
**文档 / Skill**(随 `/sync-skill` 分发,不影响 CLI 行为)
|
|
45
77
|
- gangtise-openapi Agent Skill 经 fable5 审计 + 多轮 review 优化:官方积分计费速查表 + 高积分 pre-flight 闸门、AI 同步生成命令 `GANGTISE_TIMEOUT_MS=120000` 超时前置、大结果集 `--output` 落盘、异步 `--wait` 主路径、行业码口径收敛到单一权威、市值量纲实测(`qte_mkt_cptl` 仅 A 股 / 默认原始「元」/ `scale`+`currency`)等文档补全与消歧
|
|
46
78
|
|
|
47
|
-
### v0.22.0 — 2026-07-02
|
|
48
|
-
|
|
49
|
-
**行为变更(注意)**
|
|
50
|
-
- ⚠️ 自动翻页接口省略 `--size` 现在一律拉全量(不再区分是否传时间范围);需要只取前 N 条时请显式传 `--size N`。数据量未知时可先用 `--size 1` 从 stderr 的 `Total: N` 探明量级
|
|
51
|
-
- 部分结果可机器识别:翻页页失败、K 线分片失败、或服务端提前短页但仍报告更大 `total` 时,结果会带 `partial: true`(页失败另有 `failedPages`,分片为 `failedShards`),非 json 行式输出仍只输出数据行,但进程退出码为 3
|
|
52
|
-
|
|
53
|
-
**修复(鉴权 / 请求可靠性)**
|
|
54
|
-
- Token 自愈覆盖服务端 `0000001008` 踢线失效,并能处理 HTTP 4xx 错误信封;`GANGTISE_TOKEN` + AK/SK 场景下环境 token 失效后不再反复回放旧 token
|
|
55
|
-
- 并发请求同时遇到旧 token 失效时复用一次刷新结果;若刚拿到的新 token 本身被踢掉,则强制再次登录,避免"刚登录窗口期"误跳过刷新
|
|
56
|
-
- 自动重试范围扩展到 429、DNS/网络临时错误与 undici 超时类错误;`GANGTISE_BASE_URL` 带路径前缀时 URL 拼接不再丢前缀
|
|
57
|
-
|
|
58
|
-
**修复(下载 / 输出 / 数据正确性)**
|
|
59
|
-
- 下载接口跟随最多 3 次 30x 跳转;跨域跳到对象存储签名 URL 时不携带 Authorization;服务端返回 `{url}` 且用户传 `--output` 时会真正下载文件,而不是把 URL 字符串写进文件
|
|
60
|
-
- 自动文件名补齐清洗、截断与去重:服务端文件名、标题缓存名和 fallback 名都不会把 `/`、控制字符、过长中文名或重复标题变成路径/覆盖问题
|
|
61
|
-
- `table`/`markdown` 输出清理控制字符、正确按 CJK 宽字符对齐,并转义 markdown 表头中的 `|`;CSV 输出转义表头、文件输出带 UTF-8 BOM,流式 CSV 遇全标量列表时回退到正常渲染而不是只写 BOM
|
|
62
|
-
- `indicator search` / `cross-section` / `time-series` 的内层失败信封即使没有 `data` 字段也会抛出 `ApiError`,不再把"无权限/参数错误"渲染成成功结果
|
|
63
|
-
- `--indicator-param` 等逗号列表支持全角逗号 `,`;日期型时间参数按本地零点解析,避免 `yyyy-MM-dd` 被当作 UTC 造成查询窗口偏移
|
|
64
|
-
- `fundamental earning-forecast` 省略 `--start-date` 时按传入的 `--end-date` 往前一年计算,不再总是按今天往前一年
|
|
65
|
-
- AI 异步 `--wait` 对 `410111` 终态失败只提示"不要重试",超时才提示稍后用 check 命令查询;等待说明同步为最长约 5 分钟
|
|
66
|
-
|
|
67
|
-
**CLI / 工程**
|
|
68
|
-
- `raw call` 会在本地拒绝 JSON endpoint 的 `--query` 和 download endpoint 的 `--body`,避免静默丢参数;`--format` 在发请求前校验,格式拼错不再先消耗接口调用
|
|
69
|
-
- `gangtise ... | head` 遇 stdout `EPIPE` 时安静退出;只有首个参数是 `--version` / `-V` 时才触发版本快捷路径
|
|
70
|
-
- Endpoint registry 的 `key` 改为由记录键自动派生,减少映射漂移;新增真实 CLI 选项到请求体的 stub 测试;测试 272 → 323
|
|
71
|
-
|
|
72
|
-
### v0.21.0 — 2026-06-29
|
|
73
|
-
|
|
74
|
-
**行为变更(注意)**
|
|
75
|
-
- ⚠️ `vault wechat-chatroom-list` 省略 `--size` 现在**拉全量**(此前默认只返回 20 条)。该接口不返回 `total`,CLI 改为串行翻页(翻到不满页为止,单页上限 50);传 `--size N` 仍只取前 N 条。依赖"默认 20 条"的脚本会拿到全部群。
|
|
76
|
-
|
|
77
|
-
**修复**
|
|
78
|
-
- `quote day-kline --security all` 等大结果集用默认 `table` 格式输出时不再因 `Math.max(...大数组)` 撑爆调用栈崩溃(`RangeError`);`renderTable` 改用 reduce 计算列宽
|
|
79
|
-
- CSV 导出:含回车符 `\r` 的字段现在正确加引号(RFC 4180);`table` / `markdown` 的多行字段折叠换行,保持表格对齐
|
|
80
|
-
- 下载文件名剥离控制字符 / NUL,避免 `fs.writeFile` 报错
|
|
81
|
-
|
|
82
|
-
**修复(安全)**
|
|
83
|
-
- token 缓存文件(`~/.config/gangtise/token.json`)改为临时文件 + 原子 `rename` 写入:从第一字节即 `0600`,消除"旧文件宽松权限残留"与"崩溃截断"两个隐患
|
|
84
|
-
|
|
85
|
-
**内部 / 工程**
|
|
86
|
-
- 依赖 `vitest` 升级到 3.2.6(修复 dev-only 安全告警);新增 `npm run typecheck`;测试 257 → 272
|
|
87
|
-
|
|
88
|
-
### v0.20.0 — 2026-06-26
|
|
89
|
-
|
|
90
|
-
**新增接口**
|
|
91
|
-
- `insight announcement-us list` / `download` — 美股公司公告列表与下载(`--security TSLA.O`、`--category`〔分类用 `reference constant-list --category usShareAnnouncementCategory`,美股独立的 `103980xxx` 段〕、`--search-type`、`--rank-type`、下载 `--file-type 1` 原始 PDF / `2` Markdown);自动翻页,单页上限 50
|
|
92
|
-
- `ai stock-summary` — 个股看点(精炼投研总结):`--security` 传具体代码(A股/港股,可重复,单次最多 6000)或市场关键词 `aShares` / `hkStocks` 拉全市场;无看点的证券不返回、不扣分
|
|
93
|
-
- `fundamental income-statement-us` / `balance-sheet-us` / `cash-flow-us` — 美股三大财务报表(参数同其他财报:`--security-code` / `--period` / `--report-type` / `--fiscal-year` / `--field` 等)
|
|
94
|
-
- `reference chiefs-search` — 首席分析师 ID 搜索(`--keyword` 按姓名/机构/团队匹配,`--top` 默认 10);用于 `insight opinion list --chief` 的入参
|
|
95
|
-
|
|
96
|
-
**变更**
|
|
97
|
-
- `insight announcement-hk download` 新增 `--file-type`(`1` 原始(默认)/ `2` Markdown),此前无格式选项
|
|
98
|
-
|
|
99
|
-
**行为变更(注意)**
|
|
100
|
-
- ⚠️ `auth login` / `auth status` 默认脱敏 access token:`--format json` 输出里 `authorization` 与 `cache.accessToken` 显示为 `<redacted>`,仅保留过期时间 / 用户名 / 产品码 / uid 等非敏感字段。**依赖 `auth login` 原始 token 输出的脚本会拿到 `<redacted>`**,需改用 `auth login --show-token` 获取明文。
|
|
101
|
-
|
|
102
|
-
**修复(安全)**
|
|
103
|
-
- `auth status` / `auth login` token 脱敏:按凭证字段名模式匹配(`token`/`key`/`secret`/`password`/`credential`),覆盖 `apiKey`/`privateKey`/`refreshToken` 等任何可能携带的凭证字段
|
|
104
|
-
- 自愈守卫:同时设 `GANGTISE_TOKEN` + AK/SK 时,注入 token 失效后重新登录不再被旧 token 短路,重试改用登录拿到的新 token
|
|
105
|
-
|
|
106
|
-
**修复(数据正确性 / 健壮性)**
|
|
107
|
-
- ⚠️ **CSV 负数不再被破坏**(影响所有 CSV 导出):此前防公式注入会把负数(如跌幅 `-3.5`)加 `'` 前缀变成文本,Excel/pandas 无法参与计算;现仅对非有限数字的可疑串(`=`/`@`/`-1+cmd` 等)加前缀,合法数字原样输出
|
|
108
|
-
- 自动翻页改为 fail-soft:某页遇不可重试错误(限流 `903301` 等)不再丢弃已取的全部数据,返回已取页 + `partial` / `failedPages` 标记,并在首错后停止继续请求(避免撞限流多烧配额)
|
|
109
|
-
- 下载文件名 fallback(服务端 `Content-Disposition`)补清洗:含 `/`、`:` 等字符的文件名不再写到意外路径
|
|
110
|
-
- `ai stock-summary` / `ai knowledge-batch` 缺 `--security` / `--query` 时本地报错,不再发空请求(stock-summary 借此避免被后台当全市场误扣积分)
|
|
111
|
-
- `ai hot-topic` `--no-with-related-securities` / `--no-with-close-reading` 改为显式发 `false`(语义更明确,不依赖"字段缺失=排除"的隐含约定)
|
|
112
|
-
|
|
113
|
-
**修复(indicator 适配 EDE 后台新结构)**
|
|
114
|
-
- `indicator cross-section` / `time-series` 适配后台改版的返回结构(字段名加 `List` 后缀 `securityCodeList/indicatorCodeList/…`、截面 `values` 改二维 `[指标][证券]`):此前后台改结构后 CLI 拍平失配、退化成原始矩阵,现恢复 `{date, security, name, 指标:值}` 宽表。配合后台同步变化——无数据从 `999999` 报错改为返回 `null`(截面不再 500、不丢行),缺必填参数从笼统 `410106` 改为直接指明缺哪个参数
|
|
115
|
-
|
|
116
79
|
> 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
|
|
117
80
|
|
|
118
81
|
## 首次安装
|
|
@@ -242,9 +205,12 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
|
|
|
242
205
|
| | `foreign-opinion list` | 外资机构观点 |
|
|
243
206
|
| | `independent-opinion list` / `download` | 外资独立分析师观点(含原文/翻译HTML下载) |
|
|
244
207
|
| | `official-account list` / `download` | 产业公众号资讯(含 txt/HTML 下载) |
|
|
208
|
+
| | `qa list` | 投资者问答 QA(互动平台/电话会议/调研纪要,按证券) |
|
|
209
|
+
| | `report-image list` / `download` | 研报图表搜索(按关键词,含原图 JPEG 下载) |
|
|
245
210
|
| **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
|
|
246
211
|
| | `chiefs-search` | 首席分析师 ID 搜索(按姓名/机构/团队匹配) |
|
|
247
212
|
| | `institution-search` | 机构 ID 搜索(内资券商/外资/牵头/观点机构,按名称匹配) |
|
|
213
|
+
| | `official-account-search` | 公众号 ID 搜索(按公众号名/机构/分类匹配,返回 accountId) |
|
|
248
214
|
| | `constant-category` | 常量分类列表(含各分类适用的接口与参数) |
|
|
249
215
|
| | `constant-list` | 按分类导出常量值全量列表(行业/城市/公告分类/区域等) |
|
|
250
216
|
| | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
|
|
@@ -336,9 +302,9 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
|
|
|
336
302
|
- **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
|
|
337
303
|
- **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
|
|
338
304
|
- **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
|
|
339
|
-
- **自动重试**:5xx / 429 / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici
|
|
305
|
+
- **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档生成类端点例外**(one-pager 等 13 个):5xx/超时不重放——按次计费不幂等,重放即重复扣分;仅连接失败、429 与 token 自愈重试。
|
|
340
306
|
- **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
|
|
341
|
-
- **K
|
|
307
|
+
- **K线/资金流向自动分片**:`quote day-kline --security all`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
|
|
342
308
|
- **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
|
|
343
309
|
- **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
|
|
344
310
|
|
|
@@ -359,6 +325,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
|
|
|
359
325
|
- `insight foreign-opinion list`
|
|
360
326
|
- `insight independent-opinion list`
|
|
361
327
|
- `insight official-account list`
|
|
328
|
+
- `insight qa list`
|
|
362
329
|
- `ai security-clue`
|
|
363
330
|
- `vault drive-list`
|
|
364
331
|
- `vault record-list`
|
|
@@ -447,6 +414,12 @@ gangtise insight independent-opinion download --independent-opinion-id 207051900
|
|
|
447
414
|
gangtise insight official-account list --keyword 泡泡玛特 --rank-type 2 --size 20 --format json
|
|
448
415
|
gangtise insight official-account download --article-id 7286248 --file-type 2
|
|
449
416
|
|
|
417
|
+
# 投资者问答 QA(按证券;--source/--question-category/--answer-important 精筛,自动翻页)
|
|
418
|
+
gangtise insight qa list --security-code 601012.SH --source interactive --answer-important 1 --size 20 --format json
|
|
419
|
+
# 研报图表:按关键词搜图拿 chunkId,再下原图(JPEG)
|
|
420
|
+
gangtise insight report-image list --keyword AI --top 5 --format json
|
|
421
|
+
gangtise insight report-image download --chunk-id image_10_384655917758685184_8 --output ./ai-chart.jpg
|
|
422
|
+
|
|
450
423
|
# 纪要下载(会议平台来源可选 HTML 格式)
|
|
451
424
|
gangtise insight summary download --summary-id 4906813 --file-type 2
|
|
452
425
|
```
|
|
@@ -463,8 +436,10 @@ gangtise reference securities-search --keyword "银行" --category stock --categ
|
|
|
463
436
|
# 首席分析师 ID 搜索(按姓名/机构/团队;拿 chiefId 供 insight opinion list --chief 使用)
|
|
464
437
|
gangtise reference chiefs-search --keyword 东吴证券 --top 3 --format json
|
|
465
438
|
gangtise reference chiefs-search --keyword 芦哲 --format json
|
|
466
|
-
# 机构 ID 搜索(--category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution)
|
|
439
|
+
# 机构 ID 搜索(--category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution/foreignOpinionInstitution)
|
|
467
440
|
gangtise reference institution-search --keyword 招商证券 --category domesticBroker --top 3 --format json
|
|
441
|
+
# 公众号 ID 搜索(按名称/机构/分类;拿 accountId 供 insight official-account list --account-id)
|
|
442
|
+
gangtise reference official-account-search --keyword 中信证券 --top 3 --format json
|
|
468
443
|
|
|
469
444
|
# 常量查询:先看分类,再按分类导出全量常量值
|
|
470
445
|
gangtise reference constant-category --format json
|
package/dist/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command, Option } from "commander";
|
|
3
3
|
import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
|
|
4
4
|
import { readTokenCache, redactTokenCache } from "./core/auth.js";
|
|
5
|
-
import { collectKeyValue, collectList, collectNumberList, localDateString, maybeArray, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
|
|
5
|
+
import { collectKeyValue, collectList, collectNumberList, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
|
|
6
6
|
import { buildIndicatorCrossSectionBody, buildIndicatorTimeSeriesBody, buildQuoteKlineBody, buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
|
|
7
7
|
import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
|
|
8
8
|
import { callKlineWithSharding, isAllMarket, isFullMarket } from "./core/quoteSharding.js";
|
|
@@ -169,6 +169,8 @@ const announcementUs = new Command("announcement-us");
|
|
|
169
169
|
const foreignOpinion = new Command("foreign-opinion");
|
|
170
170
|
const independentOpinion = new Command("independent-opinion");
|
|
171
171
|
const officialAccount = new Command("official-account");
|
|
172
|
+
const qa = new Command("qa");
|
|
173
|
+
const reportImage = new Command("report-image");
|
|
172
174
|
addTimeFilters(opinion.command("list").option("--rank-type <number>", "Rank type", "1").option("--research-area <id>", "Research area ID", collectList, []).option("--chief <id>", "Chief ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--concept <id>", "Concept ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--source <source>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.opinion.list", {
|
|
173
175
|
from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
|
|
174
176
|
rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), chiefList: maybeArray(options.chief),
|
|
@@ -310,6 +312,19 @@ addTimeFilters(officialAccount.command("list").option("--search-type <number>",
|
|
|
310
312
|
categoryList: maybeArray(options.category), industryList: maybeArray(options.industry),
|
|
311
313
|
}), { endpointKey: "insight.official-account.list", idField: "articleId" }));
|
|
312
314
|
addDownloadCommand(officialAccount, { endpointKey: "insight.official-account.download", idOption: "--article-id", idField: "articleId", fallbackPrefix: "official-account", fileType: { description: "File type: 1=txt(default) 2=HTML", default: "1" }, titleListEndpoint: "insight.official-account.list" });
|
|
315
|
+
// QA request keys are BARE (source/questionCategory/answerImportant), not the *List
|
|
316
|
+
// convention — the body below mirrors the spec exactly. Datetimes pass through as strings.
|
|
317
|
+
qa.command("list").requiredOption("--security-code <code>", "Security code, e.g. 601012.SH").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all (max page 500)").option("--start-time <datetime>", "Start time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)").option("--end-time <datetime>", "End time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)").option("--source <type>", "Source: conference/interactive/survey (repeat)", collectList, []).option("--question-category <name>", "Question category (repeat): productAndBusiness/capacityAndProjects/ordersAndCustomers/financialData/materialEvents/capitalOperations/shareholdersAndDividends/corporateGovernance/marketAndValuation/macroAndIndustry/risksAndOthers", collectList, []).option("--answer-important <flag>", "Answer involves key info: 1=yes 0=no (repeat; omit for all)", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("insight.qa.list", {
|
|
318
|
+
from: parseFrom(options.from), size: parseSize(options.size),
|
|
319
|
+
securityCode: options.securityCode, startTime: options.startTime, endTime: options.endTime,
|
|
320
|
+
source: maybeArray(options.source), questionCategory: maybeArray(options.questionCategory),
|
|
321
|
+
answerImportant: options.answerImportant.length ? options.answerImportant : undefined,
|
|
322
|
+
})));
|
|
323
|
+
reportImage.command("list").requiredOption("--keyword <text>", "Search keyword, e.g. 'AI' '新能源汽车'").option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--source-id <id>", "Report source ID, to filter to one report (from a report list or knowledge base)").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss; yyyy-MM-dd auto-completed)").option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss; yyyy-MM-dd auto-completed)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("insight.report-image.list", {
|
|
324
|
+
keyword: options.keyword, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }),
|
|
325
|
+
sourceId: options.sourceId, startTime: options.startTime, endTime: options.endTime,
|
|
326
|
+
})));
|
|
327
|
+
addDownloadCommand(reportImage, { endpointKey: "insight.report-image.download", idOption: "--chunk-id", idField: "chunkId", fallbackPrefix: "report-image" });
|
|
313
328
|
insight.addCommand(opinion);
|
|
314
329
|
insight.addCommand(summary);
|
|
315
330
|
insight.addCommand(roadshow);
|
|
@@ -324,6 +339,8 @@ insight.addCommand(announcementUs);
|
|
|
324
339
|
insight.addCommand(foreignOpinion);
|
|
325
340
|
insight.addCommand(independentOpinion);
|
|
326
341
|
insight.addCommand(officialAccount);
|
|
342
|
+
insight.addCommand(qa);
|
|
343
|
+
insight.addCommand(reportImage);
|
|
327
344
|
program.addCommand(insight);
|
|
328
345
|
const quote = new Command("quote").description("Quote APIs");
|
|
329
346
|
const addKlineCommand = (name, endpointKey, securityHelp, shardDays) => quote.command(name)
|
|
@@ -436,10 +453,10 @@ fundamental.command("earning-forecast").requiredOption("--security-code <code>")
|
|
|
436
453
|
}));
|
|
437
454
|
program.addCommand(fundamental);
|
|
438
455
|
const ai = new Command("ai").description("AI APIs");
|
|
439
|
-
ai.command("knowledge-batch").option("--query <text>", "Query", collectList, []).option("--top <number>", "
|
|
456
|
+
ai.command("knowledge-batch").option("--query <text>", "Query", collectList, []).option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
|
|
440
457
|
if (!options.query.length)
|
|
441
458
|
throw new ValidationError("--query is required: pass at least one --query");
|
|
442
|
-
return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) }));
|
|
459
|
+
return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) }));
|
|
443
460
|
});
|
|
444
461
|
ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action((options) => withClient(async (client) => {
|
|
445
462
|
await runDownload(client, "ai.knowledge-resource.download", { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }, {
|
|
@@ -538,28 +555,34 @@ ai.command("stock-summary").description("Stock highlights: refined research summ
|
|
|
538
555
|
const reference = new Command("reference").description("Reference data APIs");
|
|
539
556
|
reference.command("securities-search").requiredOption("--keyword <text>", "Search keyword (name/code/pinyin/English)").option("--category <type>", "Category: stock/dr/index/fund", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.securities-search", {
|
|
540
557
|
keyword: options.keyword,
|
|
541
|
-
category: options.category
|
|
542
|
-
top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
|
|
558
|
+
category: parseChoiceList(options.category, "--category", ["stock", "dr", "index", "fund"]),
|
|
559
|
+
top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
|
|
543
560
|
})));
|
|
544
561
|
reference.command("constant-category").description("List constant categories and which API params accept them").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.constant-category")));
|
|
545
562
|
reference.command("constant-list").requiredOption("--category <code>", "Category code from 'reference constant-category' (e.g. citicIndustry/swIndustry/regionCategory)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.constant-list", { category: options.category })));
|
|
546
563
|
reference.command("concept-search").requiredOption("--keyword <text>", "Search keyword (name/pinyin/group name)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.concept-search", {
|
|
547
564
|
keyword: options.keyword,
|
|
548
|
-
top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
|
|
565
|
+
top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
|
|
549
566
|
})));
|
|
550
567
|
reference.command("sector-search").option("--keyword <text>", "Search keyword (name/pinyin)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.sector-search", {
|
|
551
568
|
keyword: options.keyword,
|
|
552
|
-
top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
|
|
569
|
+
top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
|
|
553
570
|
})));
|
|
554
571
|
reference.command("sector-constituents").requiredOption("--sector-id <id>", "Sector ID from 'reference sector-search'").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.sector-constituents", { sectorId: options.sectorId })));
|
|
555
572
|
reference.command("chiefs-search").requiredOption("--keyword <text>", "Search keyword (chief name / institution / team)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.chiefs-search", {
|
|
556
573
|
keyword: options.keyword,
|
|
557
|
-
top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
|
|
574
|
+
top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
|
|
558
575
|
})));
|
|
559
576
|
reference.command("institution-search").requiredOption("--keyword <text>", "Search keyword (institution name / abbreviation)").option("--category <name>", "Category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution/foreignOpinionInstitution (repeat); omit for all", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.institution-search", {
|
|
560
577
|
keyword: options.keyword,
|
|
561
|
-
categoryList:
|
|
562
|
-
top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
|
|
578
|
+
categoryList: parseChoiceList(options.category, "--category", ["domesticBroker", "foreignInstitution", "leadInstitution", "opinionInstitution", "foreignOpinionInstitution"]),
|
|
579
|
+
top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
|
|
580
|
+
})));
|
|
581
|
+
// Note: request key is BARE `category` here (spec), unlike institution-search's `categoryList`.
|
|
582
|
+
reference.command("official-account-search").requiredOption("--keyword <text>", "Search keyword (account name / institution / keyword, e.g. 东吴证券)").option("--category <name>", "Category: listedCompany/broker/government/media (repeat); omit for all incl. uncategorized", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.official-account-search", {
|
|
583
|
+
keyword: options.keyword,
|
|
584
|
+
category: parseChoiceList(options.category, "--category", ["listedCompany", "broker", "government", "media"]),
|
|
585
|
+
top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
|
|
563
586
|
})));
|
|
564
587
|
program.addCommand(reference);
|
|
565
588
|
const vault = new Command("vault").description("Vault APIs");
|
|
@@ -578,7 +601,7 @@ program.addCommand(ai);
|
|
|
578
601
|
const alternative = new Command("alternative").description("Alternative data APIs");
|
|
579
602
|
alternative.command("edb-search").requiredOption("--keyword <text>", "Search keyword (e.g. '空调')").option("--limit <number>", "Max results (default: 100, max: 200)", "100").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.edb-search", {
|
|
580
603
|
keyword: options.keyword,
|
|
581
|
-
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
|
|
604
|
+
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 200 }),
|
|
582
605
|
})));
|
|
583
606
|
alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (repeat, max 10)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)").requiredOption("--end-date <date>", "End date (yyyy-MM-dd)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
|
|
584
607
|
const format = parseOutputFormat(options.format);
|
|
@@ -605,7 +628,7 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
|
|
|
605
628
|
const format = parseOutputFormat(options.format);
|
|
606
629
|
const raw = await client.call("indicator.search", {
|
|
607
630
|
keyword: options.keyword,
|
|
608
|
-
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
|
|
631
|
+
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 100 }),
|
|
609
632
|
});
|
|
610
633
|
await printData(unwrapIndicatorData(raw), format, options.output);
|
|
611
634
|
}));
|
package/dist/src/core/args.js
CHANGED
|
@@ -62,6 +62,17 @@ export function collectKeyValue(value, previous = {}) {
|
|
|
62
62
|
export function maybeArray(value) {
|
|
63
63
|
return value.length > 0 ? value : undefined;
|
|
64
64
|
}
|
|
65
|
+
// Whitelist for enum-valued repeatable options. Only used where the server was
|
|
66
|
+
// probed NOT to reject bad values (it silently ignores the filter or returns
|
|
67
|
+
// empty instead) — endpoints that answer 100003 keep server-side validation.
|
|
68
|
+
export function parseChoiceList(values, optionName, allowed) {
|
|
69
|
+
for (const value of values) {
|
|
70
|
+
if (!allowed.includes(value)) {
|
|
71
|
+
throw new ValidationError(`Invalid ${optionName}: "${value}" is not one of ${allowed.join("/")}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return maybeArray(values);
|
|
75
|
+
}
|
|
65
76
|
export function toTimestamp13(value) {
|
|
66
77
|
if (value === undefined)
|
|
67
78
|
return undefined;
|
package/dist/src/core/client.js
CHANGED
|
@@ -285,13 +285,30 @@ export class GangtiseClient {
|
|
|
285
285
|
total,
|
|
286
286
|
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
287
287
|
};
|
|
288
|
-
|
|
288
|
+
// Unified completeness backstop. Whatever the cause — a failed/shape-broken page,
|
|
289
|
+
// a short later page (server page cap < maxPageSize), the MAX_PAGES cap, or `total`
|
|
290
|
+
// drifting mid-fetch — the result is partial. A short row count or a total drift each
|
|
291
|
+
// force it; so does any failedPages entry on its own — an over-returning sibling page
|
|
292
|
+
// can lift the row count back to target and mask the hole (short would read false), yet
|
|
293
|
+
// the failedPages branch below still writes "results are partial" to stderr, so the flag
|
|
294
|
+
// must agree. printData maps partial → exit 3 so a script can't read a truncated export
|
|
295
|
+
// as complete. The cap and drift branches above already warned on stderr; failedPages
|
|
296
|
+
// warns below.
|
|
297
|
+
const short = collected.length < target;
|
|
298
|
+
if (short || totalDrift || failedPages.length > 0)
|
|
289
299
|
out.partial = true;
|
|
300
|
+
if (failedPages.length > 0) {
|
|
290
301
|
out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
|
|
291
302
|
const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
|
|
292
303
|
const skippedHint = aborted ? " A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped." : "";
|
|
293
304
|
process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages).${skippedHint}\n`);
|
|
294
305
|
}
|
|
306
|
+
else if (short && !truncatedByPageCap && !totalDrift) {
|
|
307
|
+
// A short later page with no failure, cap, or drift to explain it: the server
|
|
308
|
+
// simply delivered fewer rows than `total` promised. Warn so an interactive run
|
|
309
|
+
// sees why the result is partial (the other causes each warn on their own path).
|
|
310
|
+
process.stderr.write(`[gangtise] warning: server returned ${collected.length} of ${total} rows (a later page came back short); results may be incomplete\n`);
|
|
311
|
+
}
|
|
295
312
|
return out;
|
|
296
313
|
}
|
|
297
314
|
async login() {
|
|
@@ -337,9 +354,22 @@ export class GangtiseClient {
|
|
|
337
354
|
// response reads as text directly (and keeps existing behavior on that path).
|
|
338
355
|
const encoding = response.headers['content-encoding'];
|
|
339
356
|
const gzipped = (Array.isArray(encoding) ? encoding[0] : encoding)?.toLowerCase().trim() === 'gzip';
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
357
|
+
let text;
|
|
358
|
+
if (gzipped) {
|
|
359
|
+
// A proxy/middlebox can declare gzip and deliver garbage — surface that as
|
|
360
|
+
// an ApiError with request context instead of a bare zlib Z_DATA_ERROR.
|
|
361
|
+
const bytes = new Uint8Array(await response.body.arrayBuffer());
|
|
362
|
+
try {
|
|
363
|
+
text = decodeResponseBody(bytes, encoding);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
367
|
+
throw new ApiError(`Failed to decode gzip response for ${endpoint.method} ${endpoint.path}: ${detail}`, undefined, response.statusCode);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
text = await response.body.text();
|
|
372
|
+
}
|
|
343
373
|
logTiming(`${endpoint.method} ${endpoint.path}`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
344
374
|
// Parse Retry-After once so every error path below (JSON parse failure AND the
|
|
345
375
|
// envelope/HTTP-error throw) carries it — a non-JSON 429/503 must still honor
|
|
@@ -368,6 +398,7 @@ export class GangtiseClient {
|
|
|
368
398
|
throw error;
|
|
369
399
|
}
|
|
370
400
|
}, {
|
|
401
|
+
policy: endpoint.retry === "no-replay" ? "no-replay" : "default",
|
|
371
402
|
onRetry: (attempt, error, delay) => {
|
|
372
403
|
if (!isVerbose())
|
|
373
404
|
return;
|
|
@@ -417,6 +448,13 @@ export class GangtiseClient {
|
|
|
417
448
|
dispatcher,
|
|
418
449
|
});
|
|
419
450
|
}
|
|
451
|
+
// The loop above can exit with a 3xx still in hand (hop limit exceeded, or a
|
|
452
|
+
// redirect without Location) — that response must never be treated as file
|
|
453
|
+
// content: its HTML placeholder body would be saved as the "downloaded file".
|
|
454
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
455
|
+
await response.body.text().catch(() => { });
|
|
456
|
+
throw new ApiError(`Download failed: unresolved redirect (HTTP ${response.statusCode})`, undefined, response.statusCode);
|
|
457
|
+
}
|
|
420
458
|
const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
|
|
421
459
|
// From the final (post-redirect) response, so a rate-limited download honors
|
|
422
460
|
// Retry-After too — every error branch below passes it into the ApiError.
|
|
@@ -482,14 +520,17 @@ export class GangtiseClient {
|
|
|
482
520
|
// Stream directly to disk when caller already knows the destination
|
|
483
521
|
if (options?.streamTo) {
|
|
484
522
|
await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
|
|
523
|
+
// Stream into a .part sibling and rename over the target only on success:
|
|
524
|
+
// writing to the target directly would truncate an existing file on the
|
|
525
|
+
// FIRST byte and delete it on failure — a failed re-download (or each
|
|
526
|
+
// withRetry attempt) must never destroy the user's previous good file.
|
|
527
|
+
const partPath = `${options.streamTo}.part`;
|
|
485
528
|
try {
|
|
486
|
-
await pipeline(response.body, createWriteStream(
|
|
529
|
+
await pipeline(response.body, createWriteStream(partPath));
|
|
530
|
+
await fs.rename(partPath, options.streamTo);
|
|
487
531
|
}
|
|
488
532
|
catch (error) {
|
|
489
|
-
|
|
490
|
-
// failed download never looks like a complete one. withRetry may still
|
|
491
|
-
// replay the request (the next attempt re-creates the file).
|
|
492
|
-
await fs.unlink(options.streamTo).catch(() => { });
|
|
533
|
+
await fs.unlink(partPath).catch(() => { });
|
|
493
534
|
throw error;
|
|
494
535
|
}
|
|
495
536
|
logTiming(`GET ${endpoint.path} (stream)`, Date.now() - startedAt, `${response.statusCode}`);
|
|
@@ -26,7 +26,9 @@ function truncateFilename(name, maxBytes = 200) {
|
|
|
26
26
|
/** Pick a non-existing path by suffixing -1, -2, … before the extension, so batch
|
|
27
27
|
* downloads whose titles collide ("2025年第一季度报告" from several companies) don't
|
|
28
28
|
* silently overwrite each other. Only auto-derived names go through this — an
|
|
29
|
-
* explicit --output path keeps plain overwrite semantics.
|
|
29
|
+
* explicit --output path keeps plain overwrite semantics. Throws instead of
|
|
30
|
+
* falling back to the original path once the suffixes run out: returning `p`
|
|
31
|
+
* there would silently overwrite the very first file. */
|
|
30
32
|
export async function uniquePath(p) {
|
|
31
33
|
const exists = (f) => fs.access(f).then(() => true, () => false);
|
|
32
34
|
if (!(await exists(p)))
|
|
@@ -38,7 +40,7 @@ export async function uniquePath(p) {
|
|
|
38
40
|
if (!(await exists(candidate)))
|
|
39
41
|
return candidate;
|
|
40
42
|
}
|
|
41
|
-
|
|
43
|
+
throw new DownloadError(`Refusing to overwrite: 100 files already share the name "${p}" — pass --output or clean up the directory`);
|
|
42
44
|
}
|
|
43
45
|
const MIME_EXT = {
|
|
44
46
|
"application/pdf": ".pdf",
|
|
@@ -106,21 +108,70 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
|
|
|
106
108
|
}
|
|
107
109
|
async function downloadUrlTo(url, outputPath) {
|
|
108
110
|
const { createWriteStream } = await import("node:fs");
|
|
109
|
-
const { Readable } = await import("node:stream");
|
|
110
111
|
const { pipeline } = await import("node:stream/promises");
|
|
111
112
|
const { dirname } = await import("node:path");
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
const { request } = await import("undici");
|
|
114
|
+
const { getDispatcher, logTiming, withRetry } = await import("./transport.js");
|
|
115
|
+
const { loadConfig } = await import("./config.js");
|
|
116
|
+
// Through the transport layer instead of a bare global fetch: the configured
|
|
117
|
+
// timeout applies (a slow-drip CDN can no longer hang the CLI indefinitely),
|
|
118
|
+
// network-level failures retry, and --verbose logs the request.
|
|
119
|
+
const timeoutMs = loadConfig().timeoutMs;
|
|
116
120
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
// Signed URLs carry credentials in the query string — verbose logs must strip
|
|
122
|
+
// search/hash so signatures never land in terminal/CI logs.
|
|
123
|
+
const redactUrl = (u) => {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = new URL(u);
|
|
126
|
+
return parsed.origin + parsed.pathname;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return "signed-url";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
// .part + rename so a failed follow-download never destroys an existing file.
|
|
133
|
+
const partPath = `${outputPath}.part`;
|
|
134
|
+
await withRetry(async () => {
|
|
135
|
+
const startedAt = Date.now();
|
|
136
|
+
const requestOptions = {
|
|
137
|
+
method: "GET",
|
|
138
|
+
headersTimeout: timeoutMs,
|
|
139
|
+
bodyTimeout: timeoutMs,
|
|
140
|
+
dispatcher: getDispatcher(),
|
|
141
|
+
};
|
|
142
|
+
let currentUrl = url;
|
|
143
|
+
let response = await request(currentUrl, requestOptions);
|
|
144
|
+
// undici does not follow redirects (the old global fetch did): a signed URL
|
|
145
|
+
// may 302 to the real CDN object. Follow a bounded number of hops; no
|
|
146
|
+
// Authorization header is involved on this path, so nothing leaks cross-origin.
|
|
147
|
+
for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
|
|
148
|
+
const locationHeader = response.headers.location;
|
|
149
|
+
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
|
|
150
|
+
if (!location)
|
|
151
|
+
break;
|
|
152
|
+
await response.body.text().catch(() => { });
|
|
153
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
154
|
+
response = await request(currentUrl, requestOptions);
|
|
155
|
+
}
|
|
156
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
157
|
+
await response.body.text().catch(() => { });
|
|
158
|
+
throw new DownloadError(`Failed to fetch download URL: unresolved redirect (HTTP ${response.statusCode})`);
|
|
159
|
+
}
|
|
160
|
+
if (response.statusCode >= 400) {
|
|
161
|
+
await response.body.text().catch(() => { });
|
|
162
|
+
// DownloadError (not retried): signed URLs expire — replaying a 403 is useless.
|
|
163
|
+
throw new DownloadError(`Failed to fetch download URL (HTTP ${response.statusCode})`);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
await pipeline(response.body, createWriteStream(partPath));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
await fs.unlink(partPath).catch(() => { });
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
logTiming(`GET ${redactUrl(currentUrl)}`, Date.now() - startedAt, String(response.statusCode));
|
|
173
|
+
});
|
|
174
|
+
await fs.rename(partPath, outputPath);
|
|
124
175
|
}
|
|
125
176
|
export async function saveDownloadResult(result, fallbackName, output) {
|
|
126
177
|
if (!(result && typeof result === "object")) {
|
|
@@ -173,6 +173,26 @@ const ENDPOINT_DEFS = {
|
|
|
173
173
|
kind: "download",
|
|
174
174
|
description: "Download WeChat official account article (txt/HTML)",
|
|
175
175
|
},
|
|
176
|
+
"insight.qa.list": {
|
|
177
|
+
method: "POST",
|
|
178
|
+
// The literal '&' is the vendor's path segment (Q&A-data), not a query separator.
|
|
179
|
+
path: "/application/open-insight/Q&A-data/getList",
|
|
180
|
+
kind: "json",
|
|
181
|
+
description: "List investor Q&A (conference/interactive/survey) for a security",
|
|
182
|
+
pagination: { enabled: true, maxPageSize: 500 },
|
|
183
|
+
},
|
|
184
|
+
"insight.report-image.list": {
|
|
185
|
+
method: "POST",
|
|
186
|
+
path: "/application/open-insight/report-image/getList",
|
|
187
|
+
kind: "json",
|
|
188
|
+
description: "Search research report images by keyword (returns chunkId + metadata)",
|
|
189
|
+
},
|
|
190
|
+
"insight.report-image.download": {
|
|
191
|
+
method: "GET",
|
|
192
|
+
path: "/application/open-insight/report-image/download/file",
|
|
193
|
+
kind: "download",
|
|
194
|
+
description: "Download a research report image by chunkId",
|
|
195
|
+
},
|
|
176
196
|
// ─── reference ───
|
|
177
197
|
"reference.securities-search": {
|
|
178
198
|
method: "POST",
|
|
@@ -192,6 +212,12 @@ const ENDPOINT_DEFS = {
|
|
|
192
212
|
kind: "json",
|
|
193
213
|
description: "Search institution IDs by keyword (domestic broker / foreign / lead / opinion institution)",
|
|
194
214
|
},
|
|
215
|
+
"reference.official-account-search": {
|
|
216
|
+
method: "POST",
|
|
217
|
+
path: "/application/open-reference/officialAccount/search",
|
|
218
|
+
kind: "json",
|
|
219
|
+
description: "Search official account (WeChat public account) IDs by name / institution / category",
|
|
220
|
+
},
|
|
195
221
|
"reference.constant-category": {
|
|
196
222
|
method: "GET",
|
|
197
223
|
path: "/application/open-reference/constants/category",
|
|
@@ -368,6 +394,7 @@ const ENDPOINT_DEFS = {
|
|
|
368
394
|
path: "/application/open-data/ai/search/knowledge/batch",
|
|
369
395
|
kind: "json",
|
|
370
396
|
description: "Batch knowledge search",
|
|
397
|
+
retry: "no-replay",
|
|
371
398
|
},
|
|
372
399
|
"ai.knowledge-resource.download": {
|
|
373
400
|
method: "GET",
|
|
@@ -388,6 +415,7 @@ const ENDPOINT_DEFS = {
|
|
|
388
415
|
kind: "json",
|
|
389
416
|
description: "Generate one pager",
|
|
390
417
|
timeoutMs: 120_000,
|
|
418
|
+
retry: "no-replay",
|
|
391
419
|
},
|
|
392
420
|
"ai.investment-logic": {
|
|
393
421
|
method: "POST",
|
|
@@ -395,6 +423,7 @@ const ENDPOINT_DEFS = {
|
|
|
395
423
|
kind: "json",
|
|
396
424
|
description: "Generate investment logic",
|
|
397
425
|
timeoutMs: 120_000,
|
|
426
|
+
retry: "no-replay",
|
|
398
427
|
},
|
|
399
428
|
"ai.peer-comparison": {
|
|
400
429
|
method: "POST",
|
|
@@ -402,12 +431,14 @@ const ENDPOINT_DEFS = {
|
|
|
402
431
|
kind: "json",
|
|
403
432
|
description: "Generate peer comparison",
|
|
404
433
|
timeoutMs: 120_000,
|
|
434
|
+
retry: "no-replay",
|
|
405
435
|
},
|
|
406
436
|
"ai.earnings-review.get-id": {
|
|
407
437
|
method: "POST",
|
|
408
438
|
path: "/application/open-ai/agent/earnings-review-getid",
|
|
409
439
|
kind: "json",
|
|
410
440
|
description: "Get earnings review ID",
|
|
441
|
+
retry: "no-replay",
|
|
411
442
|
},
|
|
412
443
|
"ai.earnings-review.get-content": {
|
|
413
444
|
method: "POST",
|
|
@@ -421,6 +452,7 @@ const ENDPOINT_DEFS = {
|
|
|
421
452
|
kind: "json",
|
|
422
453
|
description: "Get theme tracking daily report",
|
|
423
454
|
timeoutMs: 120_000,
|
|
455
|
+
retry: "no-replay",
|
|
424
456
|
},
|
|
425
457
|
"ai.research-outline": {
|
|
426
458
|
method: "POST",
|
|
@@ -428,6 +460,7 @@ const ENDPOINT_DEFS = {
|
|
|
428
460
|
kind: "json",
|
|
429
461
|
description: "Get company research outline",
|
|
430
462
|
timeoutMs: 120_000,
|
|
463
|
+
retry: "no-replay",
|
|
431
464
|
},
|
|
432
465
|
"ai.hot-topic": {
|
|
433
466
|
method: "POST",
|
|
@@ -435,6 +468,7 @@ const ENDPOINT_DEFS = {
|
|
|
435
468
|
kind: "json",
|
|
436
469
|
description: "List hot topic reports",
|
|
437
470
|
pagination: { enabled: true, maxPageSize: 20 },
|
|
471
|
+
retry: "no-replay",
|
|
438
472
|
},
|
|
439
473
|
"ai.management-discuss-announcement": {
|
|
440
474
|
method: "POST",
|
|
@@ -442,6 +476,7 @@ const ENDPOINT_DEFS = {
|
|
|
442
476
|
kind: "json",
|
|
443
477
|
description: "Management discussion from financial reports (half-year/annual)",
|
|
444
478
|
timeoutMs: 120_000,
|
|
479
|
+
retry: "no-replay",
|
|
445
480
|
},
|
|
446
481
|
"ai.management-discuss-earnings-call": {
|
|
447
482
|
method: "POST",
|
|
@@ -449,12 +484,14 @@ const ENDPOINT_DEFS = {
|
|
|
449
484
|
kind: "json",
|
|
450
485
|
description: "Management discussion from earnings calls",
|
|
451
486
|
timeoutMs: 120_000,
|
|
487
|
+
retry: "no-replay",
|
|
452
488
|
},
|
|
453
489
|
"ai.viewpoint-debate.get-id": {
|
|
454
490
|
method: "POST",
|
|
455
491
|
path: "/application/open-ai/agent/viewpoint-debate-getid",
|
|
456
492
|
kind: "json",
|
|
457
493
|
description: "Get viewpoint debate ID",
|
|
494
|
+
retry: "no-replay",
|
|
458
495
|
},
|
|
459
496
|
"ai.viewpoint-debate.get-content": {
|
|
460
497
|
method: "POST",
|
|
@@ -547,12 +584,14 @@ const ENDPOINT_DEFS = {
|
|
|
547
584
|
path: "/application/open-alternative/concept/info",
|
|
548
585
|
kind: "json",
|
|
549
586
|
description: "Query latest concept (theme index) profile by conceptId",
|
|
587
|
+
retry: "no-replay",
|
|
550
588
|
},
|
|
551
589
|
"alternative.concept-securities": {
|
|
552
590
|
method: "POST",
|
|
553
591
|
path: "/application/open-alternative/concept/securities",
|
|
554
592
|
kind: "json",
|
|
555
593
|
description: "Query concept (theme index) constituent securities, grouped",
|
|
594
|
+
retry: "no-replay",
|
|
556
595
|
},
|
|
557
596
|
// ─── indicator (EDE: security-level data indicators) ───
|
|
558
597
|
"indicator.search": {
|
package/dist/src/core/errors.js
CHANGED
|
@@ -16,6 +16,7 @@ const ERROR_HINTS = {
|
|
|
16
16
|
"999995": "当前账号积分不足。",
|
|
17
17
|
"900002": "请求缺少 uid。",
|
|
18
18
|
"900001": "请求参数为空或缺少必填项。",
|
|
19
|
+
"100003": "参数值非法——服务端不会指明是哪个参数,多为枚举参数拼写或取值超范围(如 --source / --question-category / --answer-important),对照命令 --help 列出的合法值检查。",
|
|
19
20
|
"0000001008": "Token 已失效(多为他处登录挤掉本会话);有 AK/SK 时 CLI 会自动重新登录重试一次,否则请重新登录。",
|
|
20
21
|
"8000014": "GANGTISE_ACCESS_KEY 错误。",
|
|
21
22
|
"8000015": "GANGTISE_SECRET_KEY 错误。",
|
package/dist/src/core/output.js
CHANGED
|
@@ -147,7 +147,10 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
147
147
|
const { dirname } = await import("node:path");
|
|
148
148
|
const { createWriteStream } = await import("node:fs");
|
|
149
149
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
150
|
-
|
|
150
|
+
// Stream into a .part sibling and rename over the target only on success, so a
|
|
151
|
+
// failed re-export never destroys the previous good file.
|
|
152
|
+
const partPath = `${outputPath}.part`;
|
|
153
|
+
const stream = createWriteStream(partPath, { encoding: "utf8" });
|
|
151
154
|
// A stream 'error' with no listener (EACCES on open, ENOSPC mid-write) crashes the
|
|
152
155
|
// process before any write callback fires. Swallow the event here — the failure
|
|
153
156
|
// still surfaces through the write/end callbacks below.
|
|
@@ -169,11 +172,20 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
169
172
|
await new Promise((resolve, reject) => {
|
|
170
173
|
stream.end((err) => err ? reject(err) : resolve());
|
|
171
174
|
});
|
|
175
|
+
await fs.rename(partPath, outputPath);
|
|
172
176
|
}
|
|
173
177
|
catch (error) {
|
|
174
178
|
// Mirror the download path: never leave a truncated file that looks complete.
|
|
179
|
+
// createWriteStream opens lazily — an early abort can reach unlink BEFORE the
|
|
180
|
+
// open() creates the file, so wait for 'close' (fd released or open aborted)
|
|
181
|
+
// or the .part would reappear right after being "removed".
|
|
175
182
|
stream.destroy();
|
|
176
|
-
|
|
183
|
+
// Already-closed happens when the failure was the rename (stream ended fine);
|
|
184
|
+
// then 'close' has fired and waiting for it again would hang forever.
|
|
185
|
+
if (!stream.closed) {
|
|
186
|
+
await new Promise((resolve) => stream.once("close", resolve));
|
|
187
|
+
}
|
|
188
|
+
await fs.unlink(partPath).catch(() => { });
|
|
177
189
|
throw error;
|
|
178
190
|
}
|
|
179
191
|
return true;
|
|
@@ -222,9 +234,20 @@ export async function saveOutputIfNeeded(content, outputPath) {
|
|
|
222
234
|
}
|
|
223
235
|
const { dirname } = await import("node:path");
|
|
224
236
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
237
|
+
// Write to a .part sibling and rename over the target only on success, so a
|
|
238
|
+
// failed re-export/download never destroys the previous good file.
|
|
239
|
+
const partPath = `${outputPath}.part`;
|
|
240
|
+
try {
|
|
241
|
+
if (typeof content === "string") {
|
|
242
|
+
await fs.writeFile(partPath, content, "utf8");
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
await fs.writeFile(partPath, content);
|
|
246
|
+
}
|
|
247
|
+
await fs.rename(partPath, outputPath);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
await fs.unlink(partPath).catch(() => { });
|
|
251
|
+
throw error;
|
|
228
252
|
}
|
|
229
|
-
await fs.writeFile(outputPath, content);
|
|
230
253
|
}
|
|
@@ -121,13 +121,31 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
|
|
|
121
121
|
// every shard on the first rejection.
|
|
122
122
|
const failedShards = [];
|
|
123
123
|
let firstError = null;
|
|
124
|
+
let aborted = false;
|
|
124
125
|
const results = await runWithConcurrency(shards, config.concurrency ?? PAGE_CONCURRENCY, async (shard) => {
|
|
126
|
+
// A prior shard hit a hard error (rate limit, no-perm, retries exhausted). Stop
|
|
127
|
+
// dispatching the rest rather than burning quota into the same failure; record them
|
|
128
|
+
// as failed so the merged result is flagged partial. Mirrors requestPaginated.
|
|
129
|
+
if (aborted) {
|
|
130
|
+
failedShards.push(shard);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
125
133
|
try {
|
|
126
|
-
|
|
134
|
+
const res = await client.call(endpointKey, { ...allMarketBody, startDate: shard.startDate, endDate: shard.endDate });
|
|
135
|
+
// A shard that resolves but carries no `list` array is shape-broken (an error
|
|
136
|
+
// object, a truncated envelope) — its rows are missing. Treat it exactly like a
|
|
137
|
+
// thrown shard so the result is marked partial, not silently short. Unlike a hard
|
|
138
|
+
// error this doesn't abort the fan-out: one malformed shard isn't systemic.
|
|
139
|
+
if (!(res && typeof res === "object" && Array.isArray(res.list))) {
|
|
140
|
+
failedShards.push(shard);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return res;
|
|
127
144
|
}
|
|
128
145
|
catch (error) {
|
|
129
146
|
if (!firstError)
|
|
130
147
|
firstError = error;
|
|
148
|
+
aborted = true;
|
|
131
149
|
failedShards.push(shard);
|
|
132
150
|
return null;
|
|
133
151
|
}
|
|
@@ -65,13 +65,20 @@ export async function runWithConcurrency(items, concurrency, fn) {
|
|
|
65
65
|
/** Fan-out width for pagination and kline shards — one env knob tunes both. */
|
|
66
66
|
export const PAGE_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
|
|
67
67
|
const RETRYABLE_HTTP_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
68
|
-
const RETRYABLE_NETWORK_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
68
|
+
const RETRYABLE_NETWORK_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
69
69
|
const RETRYABLE_API_CODES = new Set(["999999"]);
|
|
70
|
-
|
|
70
|
+
// Connect-phase / DNS failures: the request provably never reached the server, so a
|
|
71
|
+
// replay cannot double-execute (or double-bill) anything even under "no-replay".
|
|
72
|
+
const NO_REPLAY_NETWORK_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
73
|
+
function isRetryableError(error, policy) {
|
|
71
74
|
if (error && typeof error === "object" && error.__retryable === true) {
|
|
72
75
|
return true;
|
|
73
76
|
}
|
|
74
77
|
if (error instanceof ApiError) {
|
|
78
|
+
if (error.statusCode === 429)
|
|
79
|
+
return true;
|
|
80
|
+
if (policy === "no-replay")
|
|
81
|
+
return false;
|
|
75
82
|
if (error.statusCode != null && RETRYABLE_HTTP_STATUS.has(error.statusCode))
|
|
76
83
|
return true;
|
|
77
84
|
if (error.code && RETRYABLE_API_CODES.has(error.code))
|
|
@@ -80,9 +87,11 @@ function isRetryableError(error) {
|
|
|
80
87
|
}
|
|
81
88
|
if (error && typeof error === "object" && "code" in error) {
|
|
82
89
|
const code = String(error.code);
|
|
83
|
-
if (RETRYABLE_NETWORK_CODES.has(code))
|
|
90
|
+
if ((policy === "no-replay" ? NO_REPLAY_NETWORK_CODES : RETRYABLE_NETWORK_CODES).has(code))
|
|
84
91
|
return true;
|
|
85
92
|
}
|
|
93
|
+
if (policy === "no-replay")
|
|
94
|
+
return false;
|
|
86
95
|
if (error instanceof Error && /timeout|ETIMEDOUT|ECONNRESET|socket hang up/i.test(error.message)) {
|
|
87
96
|
return true;
|
|
88
97
|
}
|
|
@@ -101,7 +110,7 @@ export async function withRetry(fn, options = {}) {
|
|
|
101
110
|
return await fn();
|
|
102
111
|
}
|
|
103
112
|
catch (error) {
|
|
104
|
-
if (attempt >= retries || !isRetryableError(error))
|
|
113
|
+
if (attempt >= retries || !isRetryableError(error, options.policy ?? "default"))
|
|
105
114
|
throw error;
|
|
106
115
|
// A server-sent Retry-After (429) wins over exponential backoff, but is capped
|
|
107
116
|
// so it can't stall the CLI; otherwise fall back to jittered exponential backoff.
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated — DO NOT EDIT
|
|
2
|
-
export const CLI_VERSION = "0.
|
|
2
|
+
export const CLI_VERSION = "0.26.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gangtise-openapi-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "CLI for Gangtise OpenAPI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"README.md"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
+
"prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
26
27
|
"build": "tsc -p tsconfig.json",
|
|
27
28
|
"dev": "tsx src/cli.ts",
|
|
28
29
|
"test": "vitest run",
|
|
@@ -30,11 +31,11 @@
|
|
|
30
31
|
"prepare": "node scripts/prepare.cjs && npm run build"
|
|
31
32
|
},
|
|
32
33
|
"engines": {
|
|
33
|
-
"node": ">=20"
|
|
34
|
+
"node": ">=20.18.1"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
37
|
"commander": "^14.0.0",
|
|
37
|
-
"undici": "^7.
|
|
38
|
+
"undici": "^7.28.0"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@types/node": "^20.19.43",
|