gangtise-openapi-cli 0.41.1 → 0.42.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.
@@ -1,9 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { isAsyncPending, nextPollDelayMs, POLL_MAX_ATTEMPTS } from "./asyncContent.js";
3
+ import { isAsyncPending, pollUntilDone } from "./asyncContent.js";
4
4
  import { saveDownloadResult } from "./download.js";
5
5
  import { ApiError, ValidationError } from "./errors.js";
6
- import { isTransientError } from "./transport.js";
7
6
  export const FILE_PARSE_MAX_BYTES = 100 * 1024 * 1024;
8
7
  export async function submitFileParse(client, filePath) {
9
8
  const resolved = path.resolve(filePath);
@@ -50,22 +49,6 @@ export async function fetchFileParseResult(client, taskId, output) {
50
49
  return "ok";
51
50
  }
52
51
  export async function pollFileParseResult(client, taskId, output) {
53
- for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
54
- try {
55
- if (await fetchFileParseResult(client, taskId, output) === "ok")
56
- return "ok";
57
- }
58
- catch (error) {
59
- if (!isTransientError(error))
60
- throw error;
61
- const msg = error instanceof Error ? error.message : String(error);
62
- process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: transient error (${msg.slice(0, 80)}), continuing to wait...\n`);
63
- }
64
- if (attempt < POLL_MAX_ATTEMPTS) {
65
- const delay = nextPollDelayMs(attempt);
66
- process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: parse result not ready, retrying in ${Math.round(delay / 1000)}s...\n`);
67
- await new Promise(resolve => setTimeout(resolve, delay));
68
- }
69
- }
70
- return "timeout";
52
+ const outcome = await pollUntilDone(async () => await fetchFileParseResult(client, taskId, output), "parse result not ready");
53
+ return outcome === "ok" ? "ok" : "timeout";
71
54
  }
@@ -0,0 +1,39 @@
1
+ import { ApiError, ValidationError } from "./errors.js";
2
+ export async function fetchOpinionDetails(client, endpointKey, idListField, idField, ids) {
3
+ const BATCH = 20;
4
+ const unique = [...new Set(ids)];
5
+ if (!unique.length)
6
+ throw new ValidationError("pass at least one opinion ID");
7
+ const list = [];
8
+ let unfetched = [];
9
+ let unfetchedError;
10
+ for (let i = 0; i < unique.length; i += BATCH) {
11
+ try {
12
+ const rows = await client.call(endpointKey, { [idListField]: unique.slice(i, i + BATCH) });
13
+ list.push(...rows);
14
+ }
15
+ catch (error) {
16
+ if (i === 0)
17
+ throw error;
18
+ unfetched = unique.slice(i);
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ unfetchedError = { message, ...(error instanceof ApiError ? { code: error.code, traceId: error.traceId } : {}) };
21
+ process.stderr.write(`[gangtise] warning: ${endpointKey} failed on batch ${i / BATCH + 1} (${message}); ${unfetched.length} ID(s) not fetched: ${unfetched.join(", ")}. The bodies already returned are kept below — re-run for the IDs in unfetchedIds only.\n`);
22
+ break;
23
+ }
24
+ }
25
+ const returned = new Set(list.map((row) => String(row[idField])));
26
+ const missing = unique.filter((id) => !returned.has(id) && !unfetched.includes(id));
27
+ const out = { total: list.length, list };
28
+ if (missing.length > 0) {
29
+ process.stderr.write(`[gangtise] warning: no body returned for ${missing.length} ID(s): ${missing.join(", ")} — the server skips an ID it has no body for without an error (e.g. a wrong ID; a just-published opinion may also not have its body yet). Result marked partial (exit 3).\n`);
30
+ out.missingIds = missing;
31
+ }
32
+ if (unfetched.length > 0) {
33
+ out.unfetchedIds = unfetched;
34
+ out.unfetchedError = unfetchedError;
35
+ }
36
+ if (missing.length > 0 || unfetched.length > 0)
37
+ out.partial = true;
38
+ return out;
39
+ }
@@ -170,16 +170,26 @@ export async function streamOutputToFile(value, format, outputPath) {
170
170
  stream.on("error", () => { });
171
171
  try {
172
172
  if (format === "jsonl") {
173
+ let chunk = [];
173
174
  for (const item of jsonlItems(value)) {
174
- await writeLine(stream, JSON.stringify(item));
175
+ chunk.push(JSON.stringify(item));
176
+ if (chunk.length >= LINES_PER_WRITE) {
177
+ await writeLines(stream, chunk);
178
+ chunk = [];
179
+ }
175
180
  }
181
+ await writeLines(stream, chunk);
176
182
  }
177
183
  else {
178
- await writeLine(stream, "\ufeff" + csvColumns.map(csvEscape).join(","));
184
+ let chunk = ["\ufeff" + csvColumns.map(csvEscape).join(",")];
179
185
  for (const row of csvRows) {
180
- const cells = csvColumns.map((column) => csvEscape(formatScalar(row[column])));
181
- await writeLine(stream, cells.join(","));
186
+ chunk.push(csvColumns.map((column) => csvEscape(formatScalar(row[column]))).join(","));
187
+ if (chunk.length >= LINES_PER_WRITE) {
188
+ await writeLines(stream, chunk);
189
+ chunk = [];
190
+ }
182
191
  }
192
+ await writeLines(stream, chunk);
183
193
  }
184
194
  await new Promise((resolve, reject) => {
185
195
  stream.end((err) => err ? reject(err) : resolve());
@@ -215,6 +225,10 @@ export function csvEscape(value) {
215
225
  return `"${out.replaceAll("\"", "\"\"")}"`;
216
226
  return out;
217
227
  }
228
+ export const LINES_PER_WRITE = 1000;
229
+ export function writeLines(stream, lines) {
230
+ return lines.length === 0 ? Promise.resolve() : writeLine(stream, lines.join("\n"));
231
+ }
218
232
  export function writeLine(stream, line) {
219
233
  return new Promise((resolve, reject) => {
220
234
  const ok = stream.write(line + "\n", (err) => err ? reject(err) : undefined);
@@ -5,16 +5,16 @@ import { countOutputRows, digestFile, pickList, renderOutput, saveOutputIfNeeded
5
5
  import { getRowSink } from "./rowSink.js";
6
6
  import { extractTitles, writeTitleCache } from "./titleCache.js";
7
7
  import { CLI_VERSION } from "../version.js";
8
+ import { decidedExitCode, EXIT_SUPERSEDED, markIncomplete, markSuperseded } from "./exitStatus.js";
8
9
  const LARGE_RESULT_ROWS = 50_000;
9
- export const SUPERSEDED_EXIT = 4;
10
+ export const SUPERSEDED_EXIT = EXIT_SUPERSEDED;
10
11
  export async function warnIfSuperseded(output, digest) {
11
12
  const actual = await digestFile(output).catch(() => undefined);
12
13
  if (!actual || actual.sha256 === digest.sha256)
13
14
  return;
14
15
  process.stderr.write(`[gangtise] warning: ${output} was replaced by another export to the same path while this one was finishing — the file there is not this command's output `
15
16
  + `(wrote sha256 ${digest.sha256.slice(0, 12)}…, found ${actual.sha256.slice(0, 12)}…). Give concurrent exports distinct --output paths.\n`);
16
- if (process.exitCode !== 3)
17
- process.exitCode = SUPERSEDED_EXIT;
17
+ markSuperseded();
18
18
  }
19
19
  function warnIfLargeInMemory(items, format) {
20
20
  if (items && items.length >= LARGE_RESULT_ROWS) {
@@ -63,7 +63,7 @@ async function stageExportMeta(output, format, rows, columns, normalized, comple
63
63
  format,
64
64
  rows,
65
65
  complete,
66
- exitCode: complete ? 0 : 3,
66
+ exitCode: decidedExitCode(),
67
67
  command: redactSecrets(process.argv.slice(2)),
68
68
  cliVersion: CLI_VERSION,
69
69
  fetchedAt: localIso(new Date()),
@@ -121,11 +121,10 @@ export async function printData(data, format, output, cache) {
121
121
  if (typeof meta.total === "number" && format !== "json") {
122
122
  process.stderr.write(`Total: ${meta.total}, showing: ${showing}\n`);
123
123
  }
124
- if (meta.partial === true) {
125
- process.exitCode = 3;
126
- }
124
+ if (meta.partial === true)
125
+ markIncomplete();
127
126
  }
128
- const complete = process.exitCode !== 3;
127
+ const complete = decidedExitCode() === 0;
129
128
  if (output) {
130
129
  const staged = format === "csv" || format === "jsonl"
131
130
  ? await stageExportMeta(output, format, streamed ? streamed.dataRows : countOutputRows(normalized, format), columns, normalized, complete)
@@ -2,7 +2,7 @@ import { createReadStream, createWriteStream } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { assertColumnarHeader, zipFieldRow } from "./normalize.js";
5
- import { csvEscape, digestFile, formatScalar, stagingPath, writeLine } from "./output.js";
5
+ import { csvEscape, digestFile, formatScalar, stagingPath, writeLine, writeLines, LINES_PER_WRITE } from "./output.js";
6
6
  import { extractTitles, MAX_TITLES_PER_ENDPOINT } from "./titleCache.js";
7
7
  export const ROW_SINK = Symbol("gangtise.rowSink");
8
8
  const STREAM_THRESHOLD = 1000;
@@ -100,6 +100,7 @@ export class ExportSink {
100
100
  }
101
101
  async write(rows) {
102
102
  const stream = this.stream;
103
+ let chunk = [];
103
104
  for (const row of rows) {
104
105
  let item = row;
105
106
  if (Array.isArray(row)) {
@@ -118,7 +119,11 @@ export class ExportSink {
118
119
  }
119
120
  }
120
121
  }
121
- await writeLine(stream, JSON.stringify(item));
122
+ chunk.push(JSON.stringify(item));
123
+ if (chunk.length >= LINES_PER_WRITE) {
124
+ await writeLines(stream, chunk);
125
+ chunk = [];
126
+ }
122
127
  this.rows++;
123
128
  if (this.cache && this.titleCount < MAX_TITLES_PER_ENDPOINT) {
124
129
  for (const [id, title] of Object.entries(extractTitles([item], this.cache))) {
@@ -128,6 +133,7 @@ export class ExportSink {
128
133
  }
129
134
  }
130
135
  }
136
+ await writeLines(stream, chunk);
131
137
  }
132
138
  async assembleCsv() {
133
139
  const out = createWriteStream(this.partPath, { encoding: "utf8" });
@@ -139,17 +145,18 @@ export class ExportSink {
139
145
  const input = createReadStream(this.rowsPath, { encoding: "utf8" });
140
146
  try {
141
147
  let index = 0;
142
- const emit = async (line) => {
148
+ let csvLines = [];
149
+ const emit = (line) => {
143
150
  if (!line)
144
151
  return;
145
152
  const item = JSON.parse(line);
146
153
  if (objectMode) {
147
154
  if (!isObjectRow(item))
148
155
  return;
149
- await writeLine(out, columns.map((column) => csvEscape(formatScalar(item[column]))).join(","));
156
+ csvLines.push(columns.map((column) => csvEscape(formatScalar(item[column]))).join(","));
150
157
  }
151
158
  else {
152
- await writeLine(out, `${csvEscape(String(index++))},${csvEscape(formatScalar(item))}`);
159
+ csvLines.push(`${csvEscape(String(index++))},${csvEscape(formatScalar(item))}`);
153
160
  }
154
161
  };
155
162
  let rest = "";
@@ -157,9 +164,14 @@ export class ExportSink {
157
164
  const lines = (rest + String(chunk)).split("\n");
158
165
  rest = lines.pop() ?? "";
159
166
  for (const line of lines)
160
- await emit(line);
167
+ emit(line);
168
+ if (csvLines.length >= LINES_PER_WRITE) {
169
+ await writeLines(out, csvLines);
170
+ csvLines = [];
171
+ }
161
172
  }
162
- await emit(rest);
173
+ emit(rest);
174
+ await writeLines(out, csvLines);
163
175
  await endStream(out);
164
176
  }
165
177
  catch (error) {
@@ -39,7 +39,7 @@ export function getDispatcher() {
39
39
  cachedDispatcher = new Agent({
40
40
  keepAliveTimeout: 60_000,
41
41
  keepAliveMaxTimeout: 600_000,
42
- connections: 16,
42
+ connections: Math.max(16, PAGE_CONCURRENCY),
43
43
  pipelining: 1,
44
44
  });
45
45
  }
@@ -62,8 +62,9 @@ export async function runWithConcurrency(items, concurrency, fn) {
62
62
  await Promise.all(Array.from({ length: limit }, () => worker()));
63
63
  return results;
64
64
  }
65
- export async function runInOrder(items, concurrency, fn, consume) {
65
+ export async function runInOrder(items, concurrency, fn, consume, window = concurrency) {
66
66
  const width = Math.max(1, concurrency);
67
+ const maxPending = Math.max(width, window);
67
68
  const pending = new Map();
68
69
  let next = 0;
69
70
  let failure = null;
@@ -104,7 +105,7 @@ export async function runInOrder(items, concurrency, fn, consume) {
104
105
  return;
105
106
  pending.set(index, result);
106
107
  drain();
107
- while (!failure && pending.size >= width) {
108
+ while (!failure && pending.size >= maxPending) {
108
109
  await new Promise((resolve) => waiters.push(resolve));
109
110
  }
110
111
  });
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.41.1";
1
+ export const CLI_VERSION = "0.42.0";
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gangtise-openapi
3
- version: "0.41.1"
3
+ version: "0.42.0"
4
4
  description: |-
5
5
  通过 gangtise CLI 直接调用 Gangtise OpenAPI,拉取投研原始数据、批量导出、下载文件、调用 AI 能力。
6
6
 
@@ -115,7 +115,7 @@ gangtise ai theme-tracking --theme-id <id> --date <yyyy-MM-dd> [--type <name>]
115
115
  **示例:**
116
116
  ```bash
117
117
  # 查"核电"主题 2026-05-09 的晚报
118
- GANGTISE_TIMEOUT_MS=120000 gangtise ai theme-tracking --theme-id 121000002 --date 2026-05-09 --type night --format json
118
+ gangtise ai theme-tracking --theme-id 121000002 --date 2026-05-09 --type night --format json
119
119
  # 返回 [{"type":"night","date":"2026-05-09","content":"..."}]
120
120
  ```
121
121
 
@@ -95,7 +95,7 @@ gangtise fundamental valuation-analysis --security-code <code> --indicator <name
95
95
  - 🔴 **序列按自然日逐日一行(含周末)**,行数超过 `--limit` 时接口**保留最近的行、丢掉区间开头**。CLI 撞满即标 `partial`、退出码 3 并在 stderr 说明(首行恰好就是 `--start-date` 时说明没丢,不标)。取长区间把 `--limit` 设到不小于区间天数(账号权限窗口内的十年约 3700 行,可设 `--limit 4000`)
96
96
  - 🔴 **`--start-date` 早于账号回溯下界时,接口从下界起返回、不报错**(整段都在界外才报 `110003`)。首行晚于 `--start-date` 时 CLI 在 stderr 提示——可能是该证券上市较晚,也可能是撞了权限窗口,看首行日期判断
97
97
  - `--skip-null`:丢弃 `value`/`percentileRank` 为 null 的行(最新交易日可能未入库)
98
- - **返回字段只有 7 个**:`tradeDate` `value` `percentileRank` `average` `median` `upper1Std` `lower1Std`,**没有 `securityCode`**。🔴 `--field` 里**至少要有一个数值列**(`tradeDate` 以外的 6 个):只传 `tradeDate`、只传不存在的名字(含 `securityCode`)时接口返回 0 行、**不报错**,读起来像「这只票没有估值数据」;数值列与不存在的名字混传时,回显的字段比每行的值多,CLI 直接报错退出 1。`tradeDate` 总在第一列返回,不用写进 `--field`(写了 CLI 会在发请求前去掉;同一字段写两次也只发一次)。**建议不传 `--field`**,证券代码本来就是你自己传进去的
98
+ - **返回字段只有 7 个**:`tradeDate` `value` `percentileRank` `average` `median` `upper1Std` `lower1Std`,**没有 `securityCode`**。🔴 `--field` 里**至少要有一个数值列**(`tradeDate` 以外的 6 个):只传 `tradeDate`、只传不存在的名字(含 `securityCode`)时接口返回 0 行、**不报错**,读起来像「这只票没有估值数据」(CLI 会在 stderr 提示原因);数值列与不存在的名字混传时,回显的字段比每行的值多,CLI 直接报错退出 1。`tradeDate` 总在第一列返回,不用写进 `--field`(写了 CLI 会在发请求前去掉;同一字段写两次也只发一次)。**建议不传 `--field`**,证券代码本来就是你自己传进去的
99
99
 
100
100
  ## A股盈利预测 `fundamental earning-forecast`
101
101
 
@@ -69,7 +69,8 @@
69
69
  | 情形 | CLI 行为 | Agent 是否介入 |
70
70
  |------|---------|--------------|
71
71
  | HTTP 5xx / `ECONNRESET` / 超时 | **自动指数退避重试 ×2**(🔴 贵档端点不重放) | 仍失败提示用户 |
72
- | `ValidationError` | 本地参数校验失败 | 检查 `--from` / `--size` / `--limit` 数值,**不要重试同命令** |
72
+ | `ValidationError` | 本地参数校验失败 | 检查 `--from` / `--size` / `--limit` 数值(数值只收十进制写法,`1e3` / `0x10` / `5.0` 会被拒),**不要重试同命令** |
73
+ | 返回结构与约定不符(报错含 `returned no … payload` 与 trace) | 不猜测结构、按失败处理:单次请求的命令退出 1;分批 / 分片中某一批出现时,已取到的保留、其余标为未取(退出 3) | 带 trace 反馈;换参数重试没用 |
73
74
 
74
75
  ## 退出码 3:结果少了数据
75
76
 
@@ -81,10 +82,12 @@
81
82
 
82
83
  ## 退出码 4:数据写全了,但文件被另一次导出替换
83
84
 
84
- 本次导出的数据完整写出了,`--output` 指向的文件却在收尾期间被**另一次写向同一路径的导出**替换掉——文件在,只是不是这条命令的产物。stderr 会给出两边的 `sha256` 前缀。给并发的导出各自的 `--output` 即可。
85
+ 本次导出的数据完整写出了,`--output` 指向的文件却在收尾期间被**另一次写向同一路径的导出**替换掉——文件在,只是不是这条命令的产物。stderr 会给出两边的 `sha256` 前缀。给并发的导出各自的 `--output` 即可。本次生成的 `.meta.json` 记录的是替换检查之前的判定;并发导出时,数据文件和元信息都可能被另一次导出覆盖。所以退出码 4 以本次进程的退出码和 stderr 为准,`sha256` 只能说明当前的数据文件与旁边的元信息是否匹配。
85
86
 
86
87
  与 `3` 分开是因为行没有缺、缺的是目的地。**两者同时发生时退出码是 `3`**——少了行是更重的那一个,替换另在 stderr 说明。
87
88
 
89
+ **接管道时**:`gangtise … | head` 这类提前关掉读端的用法,CLI 仍以已判定的退出码(`3` / `4`)结束;shell 默认只看管道里最后一个命令,脚本要 `set -o pipefail` 才看得到。
90
+
88
91
  ## 其他场景
89
92
 
90
93
  - CLI 未安装 → `npm install -g gangtise-openapi-cli`
@@ -128,7 +131,7 @@
128
131
 
129
132
  **`quote day-kline --security all` 被 CLI 拒**(`'all' is not a whole-market keyword`)→ 服务端不支持该写法 → 换成 `aShares` / `hkStocks` / `usStocks` 之一,单独传。
130
133
 
131
- **AI agent 命令(one-pager 等)超时** → 服务端生成耗时长,CLI 默认 30s → `GANGTISE_TIMEOUT_MS=120000` 后重试。
134
+ **AI 生成命令超时** → `one-pager` 等 7 个 agent 类 CLI 已内置 120 秒超时下限,无需再设 `GANGTISE_TIMEOUT_MS`。超时报错时内容可能已在服务端生成并扣费,**同参数重跑会再扣一次**:先告知用户,别自动重跑(详见 `references/commands/ai.md`)。`stock-summary` / `hot-topic` 可前置 `GANGTISE_TIMEOUT_MS=120000`(写整数毫秒;没按所写数值生效时 stderr 会提示)。
132
135
 
133
136
  **估值结果出现大量 `null`** → 最新交易日数据未入库 → 加 `--skip-null` 过滤掉 `value` / `percentileRank` 为 null 的行。
134
137
 
@@ -53,8 +53,8 @@
53
53
  1. 路由 → ai one-pager
54
54
  2. 比亚迪 → 002594.SZ(速查表)
55
55
  注意:one-pager 用 --security-code,不是 --security
56
- 3. Pre-flight:首次生成常 >30s → 前置 `GANGTISE_TIMEOUT_MS=120000` 防超时重试;告知用户"生成中..."
57
- 4. GANGTISE_TIMEOUT_MS=120000 gangtise ai one-pager --security-code 002594.SZ --format json
56
+ 3. Pre-flight:首次生成常 >30s,CLI 已内置 120 秒超时下限,无需前缀;告知用户"生成中..."
57
+ 4. gangtise ai one-pager --security-code 002594.SZ --format json
58
58
  5. 返回 {content: "markdown"} → 直接呈现 content,不要展示 JSON 包装
59
59
  ```
60
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.41.1",
3
+ "version": "0.42.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {