gangtise-openapi-cli 0.34.0 → 0.34.1

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 CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  README 仅列最近 5 个版本摘要:
8
8
 
9
+ - **v0.34.1 — 2026-08-15**:EDE 报告期类指标传错日期参数时,报错里直接给出该改的 CLI 写法(此前只有服务端那句「缺少必填参数 reportDate」,要自己推断该用 `--indicator-param`);该提示对 `100001` / `100003` 两个错误码都生效,并注明少数指标两个日期都要、另有指标要 `fiscalYear`,一律以 `indicator search` 的 `parameterList` 为准。另修复下载文件名缓存在并发写入下可能丢条目的问题(单进程使用不受影响)。
9
10
  - **v0.34.0 — 2026-08-15**:跟进 2026-08-14 服务端更新。🔴 **破坏性**:`quote day-kline --security all` 已失效(服务端停止支持),改用 `aShares` / `hkStocks` / `usStocks`,且市场关键字必须单独传(不能与证券代码或另一个关键字混填)——这两种写法 CLI 都会在发请求前报错并指出正确写法;`ai stock-summary` 同理不再接受市场关键字。`day-kline` 现覆盖 A股/港股/美股个股与交易所/概念/行业指数(可混查),三个旧命令 `day-kline-hk`/`day-kline-us`/`index-day-kline` 标记为已下线;`minute-kline` 支持指数。**另两处影响取数完整性的修复**:`quote fund-flow` 把市场关键字与证券代码混填时,此前会只返回那几个代码的数据且不报错("全市场 + 这只"静默变成"只有这只"),现改为本地拦截;`quote index-day-kline --security all` 跨 30 天以上时分片过宽会撞行数上限被截断(标 `partial` + 退出 3),现已调细粒度,同区间可完整取回。另:三大报表新增 `earliestAnncDate`(做时点对齐用它,不要用 `announcementDate`),EDE 报告期类指标改为必须显式传 `reportDate`,错误码提示按新行为更新。
10
11
  - **v0.33.0 — 2026-08-09**:四处行为变更,都是把「静默给出看着正常的错结果」改成显式失败——分页端点返回异形首包(含 `data: null`)改退出码 3;`total` 被服务端封顶时探测并标 `totalCapped` + 退出 3(三个 opinion 端点的 `total` 恒为 10000 而实际远不止,全量导出此前会被静默截断);空结果不再在 stdout 留空行、`null` 不再被渲染成一条记录。另补多处帮助文案与 EDE 占位值(个别指标填 `0` 而非 `null`)的说明。
11
12
  - **v0.32.0 — 2026-08-08**:新增帕米尔专家纪要列表与下载;跟进 2026-08-07 服务端更新——EDE 缺数据不再整列/整行消失(退出码 3 现在专指「代码没被识别」),条件选股重复指标的 `unreliable` 告警随之移除;`--search-type`/`--rank-type`/`--file-type` 等枚举改为本地拦截(此前传非法值不会报错,会拿到未过滤的全量)。
@@ -128,6 +128,31 @@ const ERROR_HINTS = {
128
128
  "999997": "联系客户经理开通。",
129
129
  "10011401": "联系客户经理开通白名单。",
130
130
  };
131
+ /** Hints keyed on the server's message rather than its code. Used where one code
132
+ * covers many causes and the message identifies which — `100003` in particular is
133
+ * the catch-all for every EDE input error, so its per-code hint can only be generic.
134
+ *
135
+ * Kept to cases where the server names the problem but not the CLI flag that fixes
136
+ * it: the caller is told "缺少必填参数 reportDate" and still has to work out that
137
+ * such indicators need `--indicator-param`, not `--date`.
138
+ *
139
+ * ⚠️ Do NOT restate this as a rule about code prefixes or as a clean two-way split.
140
+ * Survey of 170 indicators (2026-08-15) says otherwise: 117 take `reportDate` only,
141
+ * 51 take `tradeDate` only, `div_cash_yld` takes BOTH (both required), and
142
+ * `div_cash_yr` / `div_cash_paid_ratio` take NEITHER (they want `fiscalYear`).
143
+ * Prefixes cut across it too — 7 `finc_*` and 3 `div_*` want `reportDate`, while 8
144
+ * `is_*` and 4 `cf_*` want `tradeDate`. The hint therefore points at
145
+ * `indicator search` rather than asserting which indicators are affected.
146
+ *
147
+ * Both codes are listed because the server sends this same sentence under either:
148
+ * `is_op_rev` answers 100003, `div_cash_yld` answers 100001 (probed 2026-08-15). */
149
+ const MESSAGE_HINTS = [
150
+ {
151
+ codes: ["100001", "100003"],
152
+ match: /不支持参数\s*tradeDate|缺少必填参数[::]?\s*reportDate/,
153
+ hint: "这个指标要的是 reportDate(报告期),不是 --date 下发的 tradeDate。补 --indicator-param \"<指标code>:reportDate=YYYY-MM-DD\"(screener 用 \"F1:reportDate=...\"),--date 仍要保留。⚠️ 少数指标两个日期都要(如 div_cash_yld),补完 reportDate 若再报缺 tradeDate,就把 tradeDate 也显式传上;另有指标要的是 fiscalYear。**以 `indicator search` 返回的 parameterList 为准,别按 code 前缀推断**。",
154
+ },
155
+ ];
131
156
  export class ApiError extends CliError {
132
157
  code;
133
158
  statusCode;
@@ -148,7 +173,13 @@ export class ApiError extends CliError {
148
173
  this.statusCode = statusCode;
149
174
  this.details = details;
150
175
  this.retryAfterMs = retryAfterMs;
151
- this.hint = hintOverride ?? (code ? ERROR_HINTS[code] : undefined);
176
+ // Precedence: explicit override > message-specific > per-code. The message rule
177
+ // sits in the middle because it identifies a narrower cause than the code alone,
178
+ // but a call site that already knows the context still knows better.
179
+ const byMessage = code
180
+ ? MESSAGE_HINTS.find((rule) => rule.codes.includes(code) && rule.match.test(message))?.hint
181
+ : undefined;
182
+ this.hint = hintOverride ?? byMessage ?? (code ? ERROR_HINTS[code] : undefined);
152
183
  }
153
184
  /** Server-side correlation id from the 2026-07-17 envelope
154
185
  * (`{code, errorType, msg, status, data, traceId}`). Read off `details` rather
@@ -20,39 +20,80 @@ export const MAX_TITLES_PER_ENDPOINT = 5_000;
20
20
  let memoryCache = null;
21
21
  let memoryCachePath = null;
22
22
  let pendingWrite = null;
23
+ let pendingLoad = null;
24
+ /** Which file `pendingLoad` is loading. Keyed alongside the promise because the
25
+ * cache is a single-slot module global: coalescing on the promise ALONE would hand
26
+ * a caller asking for file B the contents of file A. */
27
+ let pendingLoadPath = null;
23
28
  let dirty = false;
24
29
  async function loadInto(filePath) {
25
30
  if (memoryCache && memoryCachePath === filePath)
26
31
  return memoryCache;
32
+ // Coalesce concurrent first-loads. Without this, two callers that both miss the
33
+ // cache each run their own readFile and each assign their OWN object to
34
+ // `memoryCache`; the last assignment wins, and entries written against the
35
+ // loser's reference are silently dropped. Sharing one promise means every
36
+ // caller gets the same object, so merges land in one place.
37
+ //
38
+ // The path check is what keeps that from becoming a worse bug than the one it
39
+ // fixes: same promise for the same file is deduplication, same promise for a
40
+ // different file is handing back the wrong file's data.
41
+ if (pendingLoad && pendingLoadPath === filePath)
42
+ return pendingLoad;
43
+ const load = (async () => {
44
+ try {
45
+ const content = await fs.readFile(filePath, "utf8");
46
+ const parsed = JSON.parse(content);
47
+ memoryCache = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
48
+ }
49
+ catch {
50
+ memoryCache = {};
51
+ }
52
+ memoryCachePath = filePath;
53
+ return memoryCache;
54
+ })();
55
+ pendingLoad = load;
56
+ pendingLoadPath = filePath;
27
57
  try {
28
- const content = await fs.readFile(filePath, "utf8");
29
- const parsed = JSON.parse(content);
30
- memoryCache = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
58
+ return await load;
31
59
  }
32
- catch {
33
- memoryCache = {};
60
+ finally {
61
+ // Only clear if still ours — a load for another path may have replaced it.
62
+ if (pendingLoad === load) {
63
+ pendingLoad = null;
64
+ pendingLoadPath = null;
65
+ }
34
66
  }
35
- memoryCachePath = filePath;
36
- return memoryCache;
37
67
  }
38
68
  export async function readTitleCache(filePath = DEFAULT_TITLE_CACHE_PATH) {
39
69
  return loadInto(filePath);
40
70
  }
41
71
  async function flush(filePath) {
42
- if (!dirty || !memoryCache)
43
- return;
44
- dirty = false;
45
- const snapshot = JSON.stringify(memoryCache);
46
- await fs.mkdir(path.dirname(filePath), { recursive: true });
47
- // Atomic-ish: write to temp file then rename (rename is atomic within a fs).
48
- const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
49
- await fs.writeFile(tmp, snapshot, { encoding: "utf8", mode: 0o600 });
50
- try {
51
- await fs.rename(tmp, filePath);
52
- }
53
- catch (error) {
54
- await fs.unlink(tmp).catch(() => { });
55
- throw error;
72
+ // Loop rather than snapshot once. `dirty` is cleared before the snapshot, so an
73
+ // entry added while we await the rename sets it again — and `writeTitleCache`
74
+ // hands that caller THIS in-flight promise instead of starting a second flush.
75
+ // Returning after one pass would leave that entry in memory only, never on disk.
76
+ for (;;) {
77
+ // Deciding to stop and releasing the in-flight handle must happen in ONE
78
+ // synchronous block. If they were split by an await, a write landing between
79
+ // them would attach to an already-resolving flush and never be written.
80
+ if (!dirty || !memoryCache) {
81
+ pendingWrite = null;
82
+ return;
83
+ }
84
+ dirty = false;
85
+ const snapshot = JSON.stringify(memoryCache);
86
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
87
+ // Atomic-ish: write to temp file then rename (rename is atomic within a fs).
88
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
89
+ await fs.writeFile(tmp, snapshot, { encoding: "utf8", mode: 0o600 });
90
+ try {
91
+ await fs.rename(tmp, filePath);
92
+ }
93
+ catch (error) {
94
+ await fs.unlink(tmp).catch(() => { });
95
+ throw error;
96
+ }
56
97
  }
57
98
  }
58
99
  /**
@@ -118,14 +159,34 @@ export async function writeTitleCache(endpoint, titles, filePath = DEFAULT_TITLE
118
159
  pruneCache(data, endpoint, Object.keys(titles));
119
160
  dirty = true;
120
161
  // Coalesce concurrent writes: the in-flight flush picks up everything dirty.
162
+ //
163
+ // `flush` clears `pendingWrite` itself, in the same synchronous block as the
164
+ // `if (!dirty || !memoryCache)` test that decides to stop looping. Clearing it out
165
+ // here — in a `finally` after `await flush(...)` — would leave a gap of microtasks
166
+ // between "flush decided nothing is left" and "flush is no longer in flight": a
167
+ // write landing in that gap sets `dirty`, sees a non-null `pendingWrite`, and
168
+ // attaches to a promise that is already resolving, so its entry never reaches
169
+ // disk while its `await` still resolves normally. Same silent loss the loop was
170
+ // added to fix, just a narrower window.
121
171
  if (pendingWrite)
122
172
  return pendingWrite;
123
173
  pendingWrite = (async () => {
124
174
  try {
125
175
  await flush(filePath);
126
176
  }
127
- finally {
177
+ catch (error) {
178
+ // flush throws before reaching its own clear, so release the handle here.
179
+ // Safe to null unconditionally: nothing else can have replaced it, since a
180
+ // non-null pendingWrite is exactly what stops another flush from starting.
181
+ //
182
+ // ⚠️ Only the SUCCESS path is atomic. A write landing between the throw and
183
+ // this line still attaches to the rejecting promise and never reaches disk;
184
+ // it recovers on the next successful write (which re-serialises the whole
185
+ // in-memory cache), and is lost only if no write ever follows. Left as-is
186
+ // deliberately — retrying against a filesystem that is already failing is
187
+ // worse than deferring to the next write.
128
188
  pendingWrite = null;
189
+ throw error;
129
190
  }
130
191
  })();
131
192
  return pendingWrite;
@@ -156,5 +217,10 @@ export function __resetTitleCacheForTests() {
156
217
  memoryCache = null;
157
218
  memoryCachePath = null;
158
219
  pendingWrite = null;
220
+ // Must include the load handles: a reset that leaves `pendingLoad` set would hand
221
+ // the next readTitleCache the pre-reset promise, breaking the "force a fresh read
222
+ // from disk" contract this helper exists to provide.
223
+ pendingLoad = null;
224
+ pendingLoadPath = null;
159
225
  dirty = false;
160
226
  }
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.34.0";
2
+ export const CLI_VERSION = "0.34.1";
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gangtise-openapi
3
- version: "0.34.0"
3
+ version: "0.34.1"
4
4
  description: |-
5
5
  通过 gangtise CLI 直接调用 Gangtise OpenAPI,拉取投研原始数据、批量导出、下载文件、调用 AI 能力。
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.34.0",
3
+ "version": "0.34.1",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {