gangtise-openapi-cli 0.34.0 → 0.35.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,32 +1,14 @@
1
1
  import { screenerExpressionFields, screenerExpressionIsEvaluable } from "./args.js";
2
2
  import { ApiError, attachEnvelopeTraceId, ENVELOPE_TRACE_ID } from "./errors.js";
3
- // The EDE endpoints double-wrap on success: the shared client strips the outer
4
- // envelope but leaves an inner { code, status, data } around the real payload.
5
- // Peel that inner envelope so the list (search) / matrix (cross-section,
6
- // time-series) is reachable. A failure code carried only by the inner envelope
7
- // must still surface instead of rendering its null payload as success.
8
3
  export function unwrapIndicatorData(raw) {
9
4
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
10
5
  const record = raw;
11
6
  if ("code" in record || "status" in record) {
12
7
  const code = record.code === undefined ? undefined : String(record.code);
13
8
  const ok = record.status === true || code === "000000" || code === "0";
14
- // A failure envelope may omit `data` entirely ({ code, status: false, msg })
15
- // — gating on the data key would let a permission/quota error flow through
16
- // as "successful" payload. Still require some envelope evidence
17
- // (status/msg/data) so a non-envelope object that merely carries a `code`
18
- // field can't be misread as a failure.
19
9
  if (!ok && ("status" in record || "msg" in record || "data" in record)) {
20
- // Pass `record` as details: the inner envelope carries no traceId of its
21
- // own, but unwrapEnvelope attached the outer one to this object and
22
- // ApiError.traceId falls back to it. Without it these failures — the EDE
23
- // 999999 / 130001 that most need reporting — reach the user trace-less.
24
10
  throw new ApiError(typeof record.msg === "string" && record.msg ? record.msg : "Indicator API request failed", code, undefined, record);
25
11
  }
26
- // Carry the envelope's traceId onto the inner payload. `unwrapEnvelope`
27
- // attached it to THIS object, but everything downstream (the flatteners'
28
- // shape guards) only ever sees `record.data` — without this hand-off those
29
- // failures, exactly the ones support needs to trace, arrive trace-less.
30
12
  if (ok && "data" in record)
31
13
  return attachEnvelopeTraceId(record.data, record[ENVELOPE_TRACE_ID] ?? record.traceId);
32
14
  }
@@ -36,12 +18,6 @@ export function unwrapIndicatorData(raw) {
36
18
  function asStringArray(value) {
37
19
  return Array.isArray(value) ? value.map((item) => String(item)) : undefined;
38
20
  }
39
- /** An IDENTITY axis — the security codes a row belongs to, the dates a column
40
- * belongs to. `String(item)` would turn a `null` into the literal `"null"` and
41
- * render it as a perfectly plausible label (probed 2026-08-02: `dates: [null]`
42
- * produced `date: "null"`, `securityCodeList: [null]` produced
43
- * `security: "null"`), so a fabricated identity would reach the output at exit
44
- * 0. Nothing here may be coerced: every entry must already be a non-empty string. */
45
21
  function asIdentityArray(data, value, key) {
46
22
  if (!Array.isArray(value))
47
23
  return undefined;
@@ -51,9 +27,6 @@ function asIdentityArray(data, value, key) {
51
27
  }
52
28
  return value;
53
29
  }
54
- /** Indicator metadata is an identity axis too: `--key-by code` addresses columns
55
- * by `code`, so an entry without one cannot be mapped back to what was asked
56
- * for. A non-object entry used to collapse into `{}` and surface as `col0`. */
57
30
  function asIndicatorMetaList(data, value) {
58
31
  if (!Array.isArray(value))
59
32
  return undefined;
@@ -71,11 +44,6 @@ function rowOf(values, index) {
71
44
  const row = values[index];
72
45
  return Array.isArray(row) ? row : undefined;
73
46
  }
74
- // Build one column header per series. Prefer the human-readable name; on a
75
- // duplicate name append a disambiguator so a column is never silently
76
- // overwritten. `reserved` pre-seeds the metadata column names, so a series
77
- // literally named "date" / "security" / "name" gets suffixed rather than
78
- // clobbering the metadata.
79
47
  function buildHeaders(bases, suffixes, reserved) {
80
48
  const used = new Set(reserved);
81
49
  const headers = [];
@@ -93,21 +61,10 @@ function buildHeaders(bases, suffixes, reserved) {
93
61
  }
94
62
  return headers;
95
63
  }
96
- // Column headers for a list of indicator metadata. keyBy "code" makes each
97
- // column its `code` (unique, and independent of the display name or the
98
- // server's column order) instead of the human name — required for batch
99
- // code→value mapping, where names collide (many indicators share a display
100
- // name) and the server reorders columns relative to the request. The screener
101
- // disambiguates by `field` instead, because there the SAME code may legitimately
102
- // appear twice under two variables with different parameters.
103
64
  function indicatorHeaders(indicators, keyBy, reserved) {
104
65
  const codes = indicators.map((meta) => optionalString(meta.code));
105
66
  const fields = indicators.map((meta, i) => optionalString(meta.field) ?? codes[i]);
106
67
  const bases = keyBy === "code" ? codes : indicators.map((meta) => optionalString(meta.name));
107
- // On a screener payload every column carries its variable, so a repeated base
108
- // can suffix ALL of its occurrences instead of leaving the first one bare.
109
- // A bare `收盘价` next to `收盘价 (F2)` reads as "the" close price when it is
110
- // really just whichever variable the server happened to list first.
111
68
  const screener = indicators.some((meta) => meta.field !== undefined);
112
69
  if (!screener)
113
70
  return buildHeaders(bases, fields, reserved);
@@ -115,32 +72,6 @@ function indicatorHeaders(indicators, keyBy, reserved) {
115
72
  return buildHeaders(bases.map((base, i) => (base !== undefined && repeated.has(base) ? `${base} (${fields[i] ?? i})` : base)), fields, reserved);
116
73
  }
117
74
  const CROSS_SECTION_COLUMNS = ["security", "name"];
118
- /** A response carrying neither securities nor indicators: the query as a whole
119
- * resolved to nothing. This is NOT a dropped axis, so it must not be reported as
120
- * partial — calling every requested code "omitted" would be false metadata, the
121
- * diff against the request is total by construction.
122
- *
123
- * What it MEANS changed on 2026-08-07: a genuine no-data answer (non-trading
124
- * date, uncovered market, future date) now comes back as a null cell with its
125
- * row and column intact, so an all-empty matrix no longer means "no data". It
126
- * now means nothing in the request RESOLVED — every security code or every
127
- * indicator code was unrecognised — or a parameter name is wrong. The caller's
128
- * stderr note says exactly that.
129
- *
130
- * The check covers every STRUCTURAL array, not just the two axis lists, because
131
- * accepting anything looser would let a malformed payload — `values: null`, a
132
- * missing `values`, or dates with no matrix — pass as "legitimately empty" and
133
- * exit 0, which is precisely the protocol regression this release's shape guards
134
- * exist to catch. Required empty: `securityCodeList`, `indicatorList`, `values`;
135
- * plus `dates` when present. Probed 2026-08-02 (back when this shape still meant
136
- * "no data"): a time-series all-empty answer is five empty arrays, a
137
- * cross-section one is four (it carries no `dates` key at all), hence "absent or
138
- * empty" rather than a flat count. The STRUCTURE is unchanged; only its meaning
139
- * moved — since 2026-08-07 it signals an unresolved axis, not missing data.
140
- *
141
- * `securityNameList` is deliberately NOT checked: it holds display labels, not
142
- * structure, so a `null` there cannot misalign anything — and rejecting it would
143
- * re-introduce the false-partial this function exists to prevent. */
144
75
  export function isEmptyMatrix(data) {
145
76
  if (!data || typeof data !== "object")
146
77
  return false;
@@ -151,28 +82,12 @@ export function isEmptyMatrix(data) {
151
82
  return false;
152
83
  if (!Array.isArray(d.values) || d.values.length !== 0)
153
84
  return false;
154
- // `dates` is time-series only; when present it must be empty too.
155
85
  return d.dates === undefined || (Array.isArray(d.dates) && d.dates.length === 0);
156
86
  }
157
- /** Validate the variable bindings a screener answered with, returning the bound
158
- * variables that are merely missing (see below).
159
- *
160
- * The screener answers with the variable each column was requested under, and
161
- * that binding is the ONLY thing tying a column back to the filter it came from
162
- * — nothing else in the payload can catch it going wrong. A swapped or unknown
163
- * `field` renders as a perfectly ordinary result (probed 2026-08-02: a response
164
- * labelling a requested `F1` as `F9` printed a normal table at exit 0), which
165
- * would make every screening decision downstream unfounded.
166
- *
167
- * Every returned entry must therefore name a REQUESTED variable and carry that
168
- * variable's code, and no variable may appear twice. A wholly empty result binds
169
- * nothing and is left alone. */
170
87
  export function checkScreenerBindings(data, requested, expression) {
171
88
  if (!data || typeof data !== "object")
172
89
  return [];
173
90
  const d = data;
174
- // A result with no securities matched nothing and binds nothing — the
175
- // canonical empty answer, left alone.
176
91
  if (!Array.isArray(d.securityCodeList) || d.securityCodeList.length === 0)
177
92
  return [];
178
93
  const indicators = Array.isArray(d.indicatorList) ? d.indicatorList : [];
@@ -192,43 +107,13 @@ export function checkScreenerBindings(data, requested, expression) {
192
107
  }
193
108
  seen.add(field);
194
109
  }
195
- // Securities came back, so the filter ran. A column vanishing is never "it got
196
- // filtered out" — filtering removes securities (rows), never indicators
197
- // (columns). Since 2026-08-07 a covered-but-empty indicator keeps a null
198
- // column, so an absent column means the server did not resolve that indicator
199
- // code at all. What that implies depends on how the expression combines terms:
200
- //
201
- // The answer follows the expression's BOOLEAN STRUCTURE, not the mere presence
202
- // of a `||` — see screenerExpressionIsEvaluable. If no branch survives the
203
- // missing columns, the rows cannot be shown to satisfy anything and the result
204
- // is fatal; if some branch still could have matched, the absence proves nothing
205
- // wrong and it degrades to partial.
206
110
  const missing = [...wanted.keys()].filter((field) => !seen.has(field));
207
111
  if (!screenerExpressionIsEvaluable(expression, seen)) {
208
112
  const absent = [...new Set(screenerExpressionFields(expression))].filter((field) => !seen.has(field));
209
113
  throw new ApiError(`Screener binding mismatch: the expression cannot be evaluated from what came back — ${absent.join(", ")} ${absent.length > 1 ? "have" : "has"} no column, and no branch of the expression survives without ${absent.length > 1 ? "them" : "it"}, so the rows cannot be shown to satisfy it`, undefined, undefined, data);
210
114
  }
211
- // Whatever survives is an output column that went missing: information lost,
212
- // correctness intact.
213
115
  return missing;
214
116
  }
215
- /** What the caller asked for that the response does not contain.
216
- *
217
- * The server used to drop any axis it had no DATA for, which made this a
218
- * coverage check. Re-probed 2026-08-08: a coverage gap is now padded with `null`
219
- * and keeps its row and column, down to the 1×1 case (`finc_pb_mrq` × 09992.HK,
220
- * `mgn_bal` × 00700.HK — null, present, alone in the request). What still
221
- * disappears is a code the server cannot RESOLVE: an unknown indicator code, or
222
- * a security code with the wrong market suffix (`AAPL.US` vanishes, `AAPL.O`
223
- * returns).
224
- *
225
- * So the remaining shape is the dangerous one: a misspelled code is invisible
226
- * otherwise — `--key-by code` batch mapping finds no key at all rather than a
227
- * null, and the pull quietly returns fewer rows than requested with exit code 0.
228
- *
229
- * Universe entries with no `.` are skipped — those are sector IDs, which the
230
- * server expands into constituents, so their absence from the response is
231
- * expected rather than a dropped row. */
232
117
  export function droppedFromMatrix(data, requestedSecurities, requestedIndicators) {
233
118
  const empty = { securities: [], indicators: [] };
234
119
  if (!data || typeof data !== "object")
@@ -241,11 +126,6 @@ export function droppedFromMatrix(data, requestedSecurities, requestedIndicators
241
126
  indicators: requestedIndicators.filter((code) => !returnedIndicators.has(code)),
242
127
  };
243
128
  }
244
- /** True only when the payload carries NONE of the EDE matrix keys — a foreign
245
- * shape. Nothing legitimately reaches the flatteners in that state — `indicator
246
- * search` prints its unwrapped list directly and `raw call` bypasses them — so
247
- * this is a protocol failure, not a payload to hand back: returning it would
248
- * print the raw envelope and exit 0, indistinguishable from success. */
249
129
  function assertMatrixPayload(data, d) {
250
130
  const bare = d.securityCodeList === undefined && d.securityNameList === undefined
251
131
  && d.indicatorList === undefined && d.dates === undefined && d.values === undefined;
@@ -258,19 +138,6 @@ function assertAxis(data, axis, key) {
258
138
  throw new ApiError(`Indicator matrix shape mismatch: the response is missing \`${key}\` or it is not an array — the response layout may have changed`, undefined, undefined, data);
259
139
  }
260
140
  }
261
- /** Resolve the display names for a security axis, degrading instead of failing.
262
- *
263
- * Names are consumed POSITIONALLY, so a list that does not line up cannot be
264
- * trusted at all: `["泡泡玛特"]` against `["600519.SH","09992.HK"]` labels 茅台's
265
- * series 泡泡玛特, and a `[null]` element used to render a column literally
266
- * headed `"null"` (both probed 2026-08-02). Neither may reach the output.
267
- *
268
- * But a name is a LABEL, not structure — `securityCodeList` already carries
269
- * identity, and every header falls back to the code. So an anomaly here drops
270
- * the names and keeps the values, rather than killing an EDE command whose
271
- * numbers are all correct. That is a deliberate asymmetry with the other guards
272
- * in this module: they protect against MISATTRIBUTED VALUES and must be fatal;
273
- * this one protects a caption. */
274
141
  function resolveSecurityNames(names, codes) {
275
142
  if (names === undefined || names === null)
276
143
  return undefined;
@@ -282,9 +149,6 @@ function resolveSecurityNames(names, codes) {
282
149
  return names.map((name, i) => {
283
150
  if (typeof name === "string" && name.trim() !== "")
284
151
  return name;
285
- // A blank or null name is a plausible gap for one security — fall back
286
- // quietly. A non-string (an object, a number) means the field changed type,
287
- // which is worth saying once.
288
152
  if (typeof name !== "string" && name !== null && name !== undefined && !warned) {
289
153
  warned = true;
290
154
  process.stderr.write(`[gangtise] warning: securityNameList holds non-string entries; those columns fall back to the security code.\n`);
@@ -292,13 +156,6 @@ function resolveSecurityNames(names, codes) {
292
156
  return codes[i];
293
157
  });
294
158
  }
295
- /** The matrix endpoints cannot legitimately answer with a non-object payload:
296
- * `indicator search` never reaches the flatteners (it prints the unwrapped list
297
- * directly) and `raw call` bypasses them entirely, so there is no caller for
298
- * which `null`, an array, or a foreign object is a valid cross-section /
299
- * time-series / screener body. Checked here, BEFORE the envelope is discarded,
300
- * because a `data: null` cannot carry the non-enumerable traceId — passing the
301
- * envelope as details is the only way such a failure stays traceable. */
302
159
  export function requireIndicatorMatrix(raw) {
303
160
  const data = unwrapIndicatorData(raw);
304
161
  if (!data || typeof data !== "object" || Array.isArray(data)) {
@@ -311,23 +168,6 @@ function assertValuesPresent(data, values) {
311
168
  throw new ApiError(`Indicator matrix shape mismatch: the response carries axis lists but no \`values\` array — the response layout may have changed`, undefined, undefined, data);
312
169
  }
313
170
  }
314
- /** The matrix and its axis labels must agree on BOTH dimensions, or cells are
315
- * dropped or misread. The 2026-08-01 revision transposed cross-section without a
316
- * version marker, so a re-transpose has to fail loudly rather than silently
317
- * relabel columns — with the caveat that this only catches a NON-SQUARE change.
318
- * A 2×2 or 1×1 matrix keeps its dimensions when transposed and would still be
319
- * read with the axes swapped; nothing in the payload distinguishes the two.
320
- *
321
- * Row length is checked exactly, not leniently: the server pads a row with
322
- * `null` rather than truncating it — probed 2026-08-02 across A/HK/US, where a
323
- * US security missing 3 of 4 indicators still came back with a full-length row,
324
- * and a cross-market time series padded every security to the union of trading
325
- * days (each market's own holidays land as `null`). A short or long row is
326
- * therefore a structural change, not missing data.
327
- *
328
- * `data` rides along as ApiError details so the failure keeps the response's
329
- * traceId — a shape mismatch is precisely the kind of thing support needs to
330
- * trace, and without it the error reaches the user trace-less. */
331
171
  function assertMatrixShape(data, values, rows, rowAxis, cols, colAxis) {
332
172
  if (values.length !== rows) {
333
173
  throw new ApiError(`Indicator matrix shape mismatch: got ${values.length} value rows for ${rows} ${rowAxis} — the response layout may have changed`, undefined, undefined, data);
@@ -339,19 +179,12 @@ function assertMatrixShape(data, values, rows, rowAxis, cols, colAxis) {
339
179
  }
340
180
  }
341
181
  }
342
- // Cross-section (and the screener, whose payload is the same shape plus a
343
- // per-indicator `field`): one row per security, one column per indicator.
344
- // `values` is [security][indicator], so security i's value for indicator j is
345
- // values[i][j]. There is no row-level date any more — the query date now lives
346
- // in each indicator's own parameters and may legitimately differ per column.
347
182
  export function flattenCrossSection(data, keyBy = "name") {
348
183
  if (!data || typeof data !== "object" || Array.isArray(data)) {
349
184
  throw new ApiError(`Indicator API returned no matrix object (got ${data === null ? "null" : Array.isArray(data) ? "an array" : typeof data}) — the response layout may have changed`, undefined, undefined, data);
350
185
  }
351
186
  const d = data;
352
187
  assertMatrixPayload(data, d);
353
- // Past this point the payload claims to be a matrix, so every axis it needs
354
- // must actually be there. A `null` or absent axis is a broken response.
355
188
  const securityCode = asIdentityArray(data, d.securityCodeList, "securityCodeList");
356
189
  const indicators = asIndicatorMetaList(data, d.indicatorList);
357
190
  assertAxis(data, securityCode, "securityCodeList");
@@ -361,8 +194,6 @@ export function flattenCrossSection(data, keyBy = "name") {
361
194
  const securityName = resolveSecurityNames(d.securityNameList, securityCode);
362
195
  const headers = indicatorHeaders(indicators, keyBy, CROSS_SECTION_COLUMNS);
363
196
  const list = securityCode.map((code, i) => {
364
- // Only create `name` when there IS one: an always-present `name: undefined`
365
- // is invisible in JSON but renders as a real empty column in CSV/table.
366
197
  const row = securityName ? { security: code, name: securityName[i] } : { security: code };
367
198
  const cells = rowOf(d.values, i);
368
199
  for (let j = 0; j < headers.length; j++) {
@@ -372,25 +203,6 @@ export function flattenCrossSection(data, keyBy = "name") {
372
203
  });
373
204
  return { list, total: list.length };
374
205
  }
375
- // Time-series: one row per date. Columns are the indicators (single-security
376
- // case) or the securities (single-indicator case) — exactly one dimension
377
- // varies, per the API contract. `values` stays a 2D [series][date] matrix.
378
- //
379
- // Axis rule, in priority order:
380
- // 1. More than one indicator came back → multi-indicator × single-security.
381
- // 2. More than one security came back → single-indicator × multi-security.
382
- // This is what a sector ID produces: the request carries ONE universe entry
383
- // and the server expands it into N constituents, so the request count says
384
- // nothing about the axis.
385
- // 3. Both are 1 → genuinely ambiguous, so fall back to what was REQUESTED:
386
- // - a universe holding a sector ID always takes the security axis. The
387
- // sector may expand to one constituent, or the rest may have been dropped
388
- // for lack of data; either way the caller asked "which securities", and an
389
- // indicator-named column would erase whose series this is.
390
- // - otherwise the entry count decides. The server drops securities with no
391
- // data at all, so a two-security request that comes back with one must
392
- // still be labelled by security (probed 2026-08-02: `finc_pe_ttm` over
393
- // 600519.SH + 09992.HK).
394
206
  export function flattenTimeSeries(data, keyBy = "name", requestedUniverse) {
395
207
  if (!data || typeof data !== "object" || Array.isArray(data)) {
396
208
  throw new ApiError(`Indicator API returned no matrix object (got ${data === null ? "null" : Array.isArray(data) ? "an array" : typeof data}) — the response layout may have changed`, undefined, undefined, data);
@@ -405,28 +217,12 @@ export function flattenTimeSeries(data, keyBy = "name", requestedUniverse) {
405
217
  assertAxis(data, indicators, "indicatorList");
406
218
  assertValuesPresent(data, d.values);
407
219
  const securityName = resolveSecurityNames(d.securityNameList, securityCode);
408
- // The API permits multi-indicator × single-security OR single-indicator ×
409
- // multi-security, never both — the server rejects such a REQUEST with 100003.
410
- // A RESPONSE carrying both axes plural is therefore unattributable: whichever
411
- // axis becomes the columns, the other one's identity is silently discarded
412
- // (probed 2026-08-02: 2×2 rendered as 收盘价/成交量 with both securities gone,
413
- // and droppedFromMatrix sees nothing missing so it would not even flag).
414
- // Data with only one identity axis is unattributable in the other direction:
415
- // `securityCodeList: []` alongside a populated matrix leaves every row
416
- // belonging to no security at all, and the row/column counts still line up so
417
- // no other guard notices.
418
- // Without this, dates-with-no-series still passes the later shape check (0 rows
419
- // for 0 series) and emits one row per date carrying nothing but the date. A
420
- // response that resolved to nothing empties `dates` too (probed 2026-08-02), so dates
421
- // alongside a missing axis is always a broken response.
422
220
  if (dates.length > 0 && (securityCode.length === 0 || indicators.length === 0)) {
423
221
  throw new ApiError(`Indicator matrix shape mismatch: the time-series response carries ${dates.length} dates but ${securityCode.length} securities and ${indicators.length} indicators — the data could not be attributed`, undefined, undefined, data);
424
222
  }
425
223
  if (securityCode.length > 1 && indicators.length > 1) {
426
224
  throw new ApiError(`Indicator matrix shape mismatch: the time-series response carries ${securityCode.length} securities AND ${indicators.length} indicators, which the endpoint does not support — one of the two identities would be lost`, undefined, undefined, data);
427
225
  }
428
- // A universe entry without a `.` is a sector ID — the server expands it, so
429
- // neither its presence nor the entry count says anything about the axis.
430
226
  const requested = requestedUniverse === undefined ? undefined : [...new Set(requestedUniverse)];
431
227
  const hasSector = requested?.some((entry) => !entry.includes(".")) ?? false;
432
228
  const seriesAreIndicators = indicators.length > 1 ? true
@@ -435,9 +231,6 @@ export function flattenTimeSeries(data, keyBy = "name", requestedUniverse) {
435
231
  : (requested?.length ?? securityCode.length) <= 1;
436
232
  const seriesCount = seriesAreIndicators ? indicators.length : securityCode.length;
437
233
  assertMatrixShape(data, d.values, seriesCount, seriesAreIndicators ? "indicators" : "securities", dates.length, "dates");
438
- // Map over securityCode rather than securityNameList directly: a response that
439
- // omits the names must still yield one header per security (falling back to
440
- // the code), not zero columns.
441
234
  const headers = seriesAreIndicators
442
235
  ? indicatorHeaders(indicators, keyBy, ["date"])
443
236
  : buildHeaders(securityCode.map((code, i) => (keyBy === "code" ? code : securityName?.[i])), securityCode, ["date"]);
@@ -1,24 +1,10 @@
1
1
  import { ENVELOPE_TRACE_ID, ValidationError } from "./errors.js";
2
- /** 信封 traceId 由 `client.ts` 挂在每个成功响应的 payload 上(非枚举 symbol)。
3
- * 结构异常报障没这个 id 服务端查不了,所以拍平失败时要一并带出。 */
4
2
  function traceSuffix(source) {
5
3
  if (!source || typeof source !== "object")
6
4
  return "";
7
5
  const traceId = source[ENVELOPE_TRACE_ID];
8
6
  return typeof traceId === "string" ? `(trace ${traceId})` : "";
9
7
  }
10
- /** 按位置把列式响应的一行值拍平成对象。
11
- *
12
- * 上游对「fieldList 里有该接口不存在的字段名」有两套处理,实测 2026-07-24:
13
- * day-kline / minute-kline / fund-flow 把名和值一起丢、三大报表补 null——长度仍相等,安全;
14
- * 但 realtime / main-business / valuation-analysis 是**值只按有效字段返回、字段名却按请求
15
- * 原样回显**。长度一旦不等,按位置拍平就会把值贴到错误的字段上:realtime 传
16
- * ["securityCode","close","turnoverRate"](realtime 根本没有 close)只回 2 个值,
17
- * 换手率 28.5573 被贴成 close,读起来就是「茅台收盘价 28.56」(真实价 1297.41)。
18
- * 不报错、数字看着还合理、却完全是另一个指标——静默错列必须变成显式失败。
19
- *
20
- * 文案不能一口咬定「字段名传错」:`alternative edb-data` 走同一个拍平却根本没有
21
- * `--field`(只有 `--indicator-id`),那里长度不等只可能是上游响应结构变了。 */
22
8
  export function zipFieldRow(fields, row, source) {
23
9
  if (row.length !== fields.length) {
24
10
  throw new ValidationError(`响应字段数与 fieldList 不匹配(fieldList ${fields.length} 项、该行返回 ${row.length} 个值)——按位置拍平会把值贴到错误的字段上,已拒绝输出。带 --field 的命令多为传了该接口不存在的字段名(上游只返回有效字段的值、字段名却按请求回显):核对 --field 取值(如 quote realtime 没有 close,最新价是 latestPrice),不确定就不传 --field(返回全量字段最稳)。没有 --field 的命令(如 alternative edb-data)出现此错,是上游响应结构异常,请报障${traceSuffix(source)}。`);
@@ -8,16 +8,9 @@ export function parseOutputFormat(value) {
8
8
  }
9
9
  throw new ConfigError(`Unsupported format: ${format}`);
10
10
  }
11
- /** Cell text for terminal-facing formats (table/markdown): newlines collapsed for
12
- * alignment, remaining C0/DEL/C1 control chars stripped so server data can't inject
13
- * terminal escape sequences into the user's terminal (U+009B is a one-byte CSI
14
- * that 8-bit-control terminals treat exactly like ESC[). */
15
11
  function sanitizeCell(value) {
16
12
  return value.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f\u0080-\u009f]/g, "");
17
13
  }
18
- /** Terminal display width: CJK/fullwidth chars and emoji occupy 2 columns — padEnd
19
- * counts UTF-16 code units and misaligns every table containing Chinese text or emoji
20
- * (e.g. WeChat group names). */
21
14
  function displayWidth(value) {
22
15
  let width = 0;
23
16
  for (const ch of value) {
@@ -40,10 +33,6 @@ function formatScalar(value) {
40
33
  }
41
34
  return String(value);
42
35
  }
43
- /** Rows for a list: object rows as-is with stray null/scalar rows dropped (one bad
44
- * row must not degrade the whole table to index/value pairs — and the streaming CSV
45
- * path already skips them, so both paths agree). A list with NO object rows at all
46
- * (e.g. plain string codes) still renders as index/value pairs. */
47
36
  function rowsFromList(list) {
48
37
  const objectRows = list.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item)));
49
38
  if (objectRows.length > 0)
@@ -51,11 +40,6 @@ function rowsFromList(list) {
51
40
  return list.map((item, index) => ({ index, value: item }));
52
41
  }
53
42
  function toRows(value) {
54
- // A null/undefined payload means "no data", not "one row whose only column is
55
- // null". Without this guard it fell through to [{ value }] and jsonl emitted
56
- // {"value":null} — a phantom record that makes `wc -l` report 1. Real case:
57
- // insight foreign-opinion --industry answers 200 + data:null.
58
- // Scalars (0, "", false) are left alone; only the two empty values short-circuit.
59
43
  if (value === null || value === undefined) {
60
44
  return [];
61
45
  }
@@ -71,10 +55,7 @@ function toRows(value) {
71
55
  }
72
56
  return [{ value }];
73
57
  }
74
- // One huge cell (a 50KB brief/chat message) would otherwise force every row of
75
- // that column to be padded to the same width — rows × width of pure spaces.
76
58
  const MAX_CELL_DISPLAY_WIDTH = 120;
77
- /** Truncate a cell to the display-width cap, ellipsis included in the budget. */
78
59
  function clampCell(value) {
79
60
  if (displayWidth(value) <= MAX_CELL_DISPLAY_WIDTH)
80
61
  return value;
@@ -94,12 +75,6 @@ function renderTable(rows) {
94
75
  return "(empty)";
95
76
  }
96
77
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
97
- // Format every cell once (formatScalar may JSON.stringify objects), sanitizing
98
- // control chars so multi-line fields don't break alignment. Reuse the matrix for
99
- // both width and rendering — and compute widths with reduce, NOT Math.max(...arr):
100
- // spreading a per-row array overflows the call stack on large results (table is
101
- // the default format, e.g. `quote day-kline --security all`). Widths and padding
102
- // use displayWidth so CJK cells stay aligned.
103
78
  const headerCells = columns.map((column) => clampCell(sanitizeCell(column)));
104
79
  const matrix = rows.map((row) => columns.map((column) => clampCell(sanitizeCell(formatScalar(row[column])))));
105
80
  const widths = columns.map((_, c) => matrix.reduce((max, cells) => Math.max(max, displayWidth(cells[c])), displayWidth(headerCells[c])));
@@ -114,10 +89,6 @@ function renderMarkdown(rows) {
114
89
  return "(empty)";
115
90
  }
116
91
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
117
- // Column names come from server data (e.g. EDE indicator display names) — escape
118
- // them like cell values or a name containing | / , breaks the whole table.
119
- // Backslash must go first: escaping only "|" turns a literal `\|` into `\\|`,
120
- // which GFM reads as an escaped backslash + BARE pipe → an extra column.
121
92
  const mdEscape = (value) => value.replaceAll("\\", "\\\\").replaceAll("|", "\\|");
122
93
  const header = `| ${columns.map((column) => mdEscape(sanitizeCell(column))).join(" | ")} |`;
123
94
  const divider = `| ${columns.map(() => "---").join(" | ")} |`;
@@ -134,8 +105,6 @@ function renderCsv(rows) {
134
105
  return [header, ...body].join("\n");
135
106
  }
136
107
  export function renderOutput(value, format) {
137
- // toRows is computed lazily per branch: json never needs it, and jsonl only
138
- // falls back to it when the value isn't already a {list}/array.
139
108
  switch (format) {
140
109
  case "json":
141
110
  return JSON.stringify(value, null, 2);
@@ -154,18 +123,14 @@ export function renderOutput(value, format) {
154
123
  return renderTable(toRows(value));
155
124
  }
156
125
  }
157
- /** Stream large jsonl/csv output row-by-row to avoid building a full string in memory. */
158
126
  export async function streamOutputToFile(value, format, outputPath) {
159
127
  if (format !== "jsonl" && format !== "csv")
160
128
  return false;
161
129
  const list = pickList(value);
162
130
  if (!list)
163
131
  return false;
164
- // Below this row count the join() approach is cheaper than per-row writes.
165
132
  if (list.length < 1000)
166
133
  return false;
167
- // csv can only stream object rows; an all-scalar list has no columns — fall back
168
- // to renderOutput's index/value shaping instead of writing a BOM-only file.
169
134
  let csvRows = [];
170
135
  let csvColumns = [];
171
136
  if (format === "csv") {
@@ -177,13 +142,8 @@ export async function streamOutputToFile(value, format, outputPath) {
177
142
  const { dirname } = await import("node:path");
178
143
  const { createWriteStream } = await import("node:fs");
179
144
  await fs.mkdir(dirname(outputPath), { recursive: true });
180
- // Stream into a .part sibling and rename over the target only on success, so a
181
- // failed re-export never destroys the previous good file.
182
145
  const partPath = `${outputPath}.part`;
183
146
  const stream = createWriteStream(partPath, { encoding: "utf8" });
184
- // A stream 'error' with no listener (EACCES on open, ENOSPC mid-write) crashes the
185
- // process before any write callback fires. Swallow the event here — the failure
186
- // still surfaces through the write/end callbacks below.
187
147
  stream.on("error", () => { });
188
148
  try {
189
149
  if (format === "jsonl") {
@@ -192,7 +152,6 @@ export async function streamOutputToFile(value, format, outputPath) {
192
152
  }
193
153
  }
194
154
  else {
195
- // BOM so Excel double-click decodes Chinese as UTF-8 instead of ANSI/GBK.
196
155
  await writeLine(stream, "\ufeff" + csvColumns.map(csvEscape).join(","));
197
156
  for (const row of csvRows) {
198
157
  const cells = csvColumns.map((column) => csvEscape(formatScalar(row[column])));
@@ -205,13 +164,7 @@ export async function streamOutputToFile(value, format, outputPath) {
205
164
  await fs.rename(partPath, outputPath);
206
165
  }
207
166
  catch (error) {
208
- // Mirror the download path: never leave a truncated file that looks complete.
209
- // createWriteStream opens lazily — an early abort can reach unlink BEFORE the
210
- // open() creates the file, so wait for 'close' (fd released or open aborted)
211
- // or the .part would reappear right after being "removed".
212
167
  stream.destroy();
213
- // Already-closed happens when the failure was the rename (stream ended fine);
214
- // then 'close' has fired and waiting for it again would hang forever.
215
168
  if (!stream.closed) {
216
169
  await new Promise((resolve) => stream.once("close", resolve));
217
170
  }
@@ -220,8 +173,6 @@ export async function streamOutputToFile(value, format, outputPath) {
220
173
  }
221
174
  return true;
222
175
  }
223
- /** Extract a row array from a value: the array itself, or its `.list` property,
224
- * else null. Shared by streaming, printer's title-cache, and list detection. */
225
176
  export function pickList(value) {
226
177
  if (Array.isArray(value))
227
178
  return value;
@@ -234,9 +185,6 @@ export function pickList(value) {
234
185
  }
235
186
  function csvEscape(value) {
236
187
  let out = value;
237
- // Formula-injection guard, but don't mangle legitimate numbers: a leading
238
- // -/+ only needs escaping when the cell isn't a finite number (e.g. "-1+cmd"),
239
- // so values like "-3.5" stay numeric for Excel/pandas.
240
188
  if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !Number.isFinite(Number(out))))
241
189
  out = "'" + out;
242
190
  if (/[",\n\r]/.test(out))
@@ -250,8 +198,6 @@ function writeLine(stream, line) {
250
198
  resolve();
251
199
  return;
252
200
  }
253
- // Waiting only for 'drain' would hang forever if the stream errors instead;
254
- // race the two and detach the loser so listeners don't pile up per write.
255
201
  const onDrain = () => { stream.off("error", onError); resolve(); };
256
202
  const onError = (err) => { stream.off("drain", onDrain); reject(err); };
257
203
  stream.once("drain", onDrain);
@@ -264,8 +210,6 @@ export async function saveOutputIfNeeded(content, outputPath) {
264
210
  }
265
211
  const { dirname } = await import("node:path");
266
212
  await fs.mkdir(dirname(outputPath), { recursive: true });
267
- // Write to a .part sibling and rename over the target only on success, so a
268
- // failed re-export/download never destroys the previous good file.
269
213
  const partPath = `${outputPath}.part`;
270
214
  try {
271
215
  if (typeof content === "string") {
@@ -1,13 +1,7 @@
1
1
  import { normalizeRows } from "./normalize.js";
2
2
  import { pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
3
3
  import { extractTitles, writeTitleCache } from "./titleCache.js";
4
- /** Rows above which renderOutput's single in-memory string risks high memory / the V8
5
- * max-string-length cap. Well above normal result sizes, so it only fires on huge exports. */
6
4
  const LARGE_RESULT_ROWS = 50_000;
7
- /** Warn when we're about to renderOutput a huge result. Called only on the paths that
8
- * actually render — never after streamOutputToFile streamed — so it can't misfire on a
9
- * genuinely streamed export, and it DOES fire on the all-scalar-csv list that streaming
10
- * declines (which then falls back to a full in-memory string). */
11
5
  function warnIfLargeInMemory(items, format) {
12
6
  if (items && items.length >= LARGE_RESULT_ROWS) {
13
7
  process.stderr.write(`[gangtise] note: ${items.length} rows in '${format}' is built entirely in memory; stream large exports to a file with --format jsonl --output <path> (or csv).\n`);
@@ -28,11 +22,6 @@ export async function printData(data, format, output, cache) {
28
22
  const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
29
23
  process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
30
24
  }
31
- // Incomplete results exit with code 3: the table/csv/jsonl renderers only
32
- // emit the rows, so without a distinct exit code a script or AI consumer
33
- // cannot tell such an export from a clean one. `partial` means rows are
34
- // missing — failed pages/shards, a row cap, or an EDE response that dropped
35
- // whole securities/indicators because it did not recognise those codes.
36
25
  if (meta.partial === true) {
37
26
  process.exitCode = 3;
38
27
  }
@@ -42,22 +31,14 @@ export async function printData(data, format, output, cache) {
42
31
  process.stdout.write(`${output}\n`);
43
32
  return;
44
33
  }
45
- // streamOutputToFile declined (non-stream format, or an all-scalar csv list) \u2192 we
46
- // fall back to renderOutput, which builds the whole result as one string.
47
34
  warnIfLargeInMemory(items, format);
48
35
  const content = renderOutput(normalized, format);
49
- // CSV files get a BOM so Excel double-click decodes Chinese as UTF-8 (stdout
50
- // stays BOM-free for pipes).
51
36
  await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
52
37
  process.stdout.write(`${output}\n`);
53
38
  return;
54
39
  }
55
40
  warnIfLargeInMemory(items, format);
56
41
  const rendered = renderOutput(normalized, format);
57
- // An empty render means "no rows at all". Appending the newline anyway would put
58
- // one blank line on stdout — `wc -l` reports 1 and `while read` yields one empty
59
- // record, which is exactly the phantom row the null-payload guard in toRows()
60
- // removes. Table/markdown render "(empty)" so they are unaffected.
61
42
  if (rendered)
62
43
  process.stdout.write(`${rendered}\n`);
63
44
  }