gangtise-openapi-cli 0.43.0 → 0.44.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.
Files changed (42) hide show
  1. package/README.md +93 -19
  2. package/dist/src/cli.js +29 -18
  3. package/dist/src/commands/ai.js +20 -29
  4. package/dist/src/commands/auth.js +5 -4
  5. package/dist/src/commands/bond.js +5 -4
  6. package/dist/src/commands/fund.js +100 -0
  7. package/dist/src/commands/fundamental.js +2 -0
  8. package/dist/src/commands/groups.js +14 -0
  9. package/dist/src/commands/indicator.js +5 -5
  10. package/dist/src/commands/insight.js +3 -4
  11. package/dist/src/commands/raw.js +4 -3
  12. package/dist/src/commands/shared.js +35 -6
  13. package/dist/src/commands/tool.js +2 -2
  14. package/dist/src/commands/vault.js +30 -30
  15. package/dist/src/core/args.js +23 -7
  16. package/dist/src/core/asyncContent.js +1 -1
  17. package/dist/src/core/auth.js +2 -10
  18. package/dist/src/core/client.js +96 -35
  19. package/dist/src/core/commandBodies.js +1 -27
  20. package/dist/src/core/config.js +9 -5
  21. package/dist/src/core/download.js +47 -10
  22. package/dist/src/core/endpoints.js +198 -3
  23. package/dist/src/core/errors.js +1 -1
  24. package/dist/src/core/opinionDetail.js +16 -6
  25. package/dist/src/core/output.js +103 -72
  26. package/dist/src/core/printer.js +10 -5
  27. package/dist/src/core/quoteSharding.js +28 -4
  28. package/dist/src/core/rowSink.js +24 -10
  29. package/dist/src/core/titleCache.js +2 -10
  30. package/dist/src/core/transport.js +25 -7
  31. package/dist/src/core/updateCheck.js +3 -9
  32. package/gangtise-openapi/SKILL.md +53 -17
  33. package/gangtise-openapi/references/commands/fund.md +192 -0
  34. package/gangtise-openapi/references/commands/fundamental.md +2 -2
  35. package/gangtise-openapi/references/commands/indicator.md +7 -7
  36. package/gangtise-openapi/references/commands/insight.md +3 -3
  37. package/gangtise-openapi/references/commands/quote.md +1 -1
  38. package/gangtise-openapi/references/commands/tool.md +1 -1
  39. package/gangtise-openapi/references/commands/vault.md +1 -1
  40. package/gangtise-openapi/references/errors.md +5 -5
  41. package/gangtise-openapi/references/response-schema.md +10 -0
  42. package/package.json +1 -1
@@ -1,3 +1,31 @@
1
+ import { beijingDateString, parseDateOption } from "./args.js";
2
+ import { ValidationError } from "./errors.js";
3
+ export const COSTLY_FETCH_CREDITS = 1000;
4
+ const FORECAST_ROWS_PER_DATE = 3;
5
+ function billedDate(value, key) {
6
+ if (typeof value !== "string")
7
+ throw new ValidationError(`${key} is not a date string`);
8
+ return parseDateOption(value, key);
9
+ }
10
+ export function weekdaysBetween(start, end) {
11
+ const from = Date.parse(`${start}T00:00:00Z`);
12
+ const to = Date.parse(`${end}T00:00:00Z`);
13
+ if (!(to >= from))
14
+ return 0;
15
+ let count = 0;
16
+ for (let t = from; t <= to; t += 86_400_000) {
17
+ const day = new Date(t).getUTCDay();
18
+ if (day !== 0 && day !== 6)
19
+ count++;
20
+ }
21
+ return count;
22
+ }
23
+ export function maxUnitsOf(key) {
24
+ const max = ENDPOINTS[key]?.billing?.maxUnits;
25
+ if (max === undefined)
26
+ throw new Error(`${key} declares no billing.maxUnits`);
27
+ return max;
28
+ }
1
29
  export function resolveTimeoutMs(configTimeoutMs, endpoint) {
2
30
  return Math.max(configTimeoutMs, endpoint.timeoutMs ?? 0);
3
31
  }
@@ -502,6 +530,11 @@ const ENDPOINT_DEFS = {
502
530
  kind: "json",
503
531
  description: "Query earning forecast (consensus estimates)",
504
532
  billing: { per: "row", price: 0.5, maxUnits: Number.POSITIVE_INFINITY },
533
+ estimateBilledUnits: (body) => {
534
+ const end = body.endDate == null ? beijingDateString(new Date()) : billedDate(body.endDate, "endDate");
535
+ const start = body.startDate == null ? new Date(Date.parse(`${end}T00:00:00Z`) - 365 * 86_400_000).toISOString().slice(0, 10) : billedDate(body.startDate, "startDate");
536
+ return weekdaysBetween(start, end) * FORECAST_ROWS_PER_DATE;
537
+ },
505
538
  retry: "no-replay",
506
539
  },
507
540
  "bond.basic-info": {
@@ -572,7 +605,7 @@ const ENDPOINT_DEFS = {
572
605
  path: "/application/open-fundamental/bond/rating-overview",
573
606
  kind: "json",
574
607
  description: "Query bond / issuer / guarantor ratings side by side (max 10 bonds per call)",
575
- billing: { per: "call", price: 0.4 },
608
+ billing: { per: "row", price: 0.4, maxUnits: 10 },
576
609
  retry: "no-replay",
577
610
  expects: "list",
578
611
  },
@@ -581,7 +614,7 @@ const ENDPOINT_DEFS = {
581
614
  path: "/application/open-fundamental/bond/rating-change",
582
615
  kind: "json",
583
616
  description: "Query bond rating change history (max 10 bonds per call)",
584
- billing: { per: "call", price: 0.4 },
617
+ billing: { per: "security", price: 0.4, maxUnits: 10 },
585
618
  retry: "no-replay",
586
619
  expects: "list",
587
620
  },
@@ -590,7 +623,7 @@ const ENDPOINT_DEFS = {
590
623
  path: "/application/open-fundamental/bond/issuer-rating-change",
591
624
  kind: "json",
592
625
  description: "Query issuer rating change history (by bond code or issuer name)",
593
- billing: { per: "call", price: 0.4 },
626
+ billing: { per: "issuer", price: 0.4, maxUnits: 10 },
594
627
  retry: "no-replay",
595
628
  expects: "list",
596
629
  },
@@ -612,6 +645,168 @@ const ENDPOINT_DEFS = {
612
645
  retry: "no-replay",
613
646
  expects: "list",
614
647
  },
648
+ "fund.basic-info": {
649
+ method: "POST",
650
+ path: "/application/open-fundamental/fund/basic-info",
651
+ kind: "json",
652
+ description: "Query fund profiles (category, manager, subscription rules, benchmark, tracked index)",
653
+ billing: { per: "call", price: 0.4 },
654
+ retry: "no-replay",
655
+ expects: "list",
656
+ },
657
+ "fund.nav": {
658
+ method: "POST",
659
+ path: "/application/open-quote/fund/nav",
660
+ kind: "json",
661
+ description: "Query fund daily NAV (unit / accumulated / adjusted; money-market yields)",
662
+ billing: { per: "call", price: 0.4 },
663
+ retry: "no-replay",
664
+ expects: "list",
665
+ },
666
+ "fund.fee-rate": {
667
+ method: "POST",
668
+ path: "/application/open-fundamental/fund/fee-rate",
669
+ kind: "json",
670
+ description: "Query fund fee rates (purchase / redemption / management / custodian / sales service)",
671
+ billing: { per: "call", price: 0.4 },
672
+ retry: "no-replay",
673
+ expects: "list",
674
+ },
675
+ "fund.manager-info": {
676
+ method: "POST",
677
+ path: "/application/open-fundamental/fund/manager-info",
678
+ kind: "json",
679
+ description: "Query fund manager profiles by name (exact match; same-name managers all returned)",
680
+ billing: { per: "call", price: 0.4 },
681
+ retry: "no-replay",
682
+ expects: "list",
683
+ },
684
+ "fund.manager-history": {
685
+ method: "POST",
686
+ path: "/application/open-fundamental/fund/manager-history",
687
+ kind: "json",
688
+ description: "Query a fund's past and present managers (tenure + return over tenure)",
689
+ billing: { per: "call", price: 0.4 },
690
+ retry: "no-replay",
691
+ expects: "list",
692
+ },
693
+ "fund.asset-allocation": {
694
+ method: "POST",
695
+ path: "/application/open-fundamental/fund/asset-allocation",
696
+ kind: "json",
697
+ description: "Query a fund's asset allocation per report date (equity / bonds / cash ...)",
698
+ billing: { per: "call", price: 0.4 },
699
+ retry: "no-replay",
700
+ expects: "list",
701
+ },
702
+ "fund.asset-size": {
703
+ method: "POST",
704
+ path: "/application/open-fundamental/fund/asset-size",
705
+ kind: "json",
706
+ description: "Query a fund's shares, subscriptions / redemptions and net assets per report date",
707
+ billing: { per: "call", price: 0.4 },
708
+ retry: "no-replay",
709
+ expects: "list",
710
+ },
711
+ "fund.holder-structure": {
712
+ method: "POST",
713
+ path: "/application/open-fundamental/fund/holder-structure",
714
+ kind: "json",
715
+ description: "Query a fund's holder structure per report date (institutional / individual / employee)",
716
+ billing: { per: "call", price: 0.4 },
717
+ retry: "no-replay",
718
+ expects: "list",
719
+ },
720
+ "fund.top10-holders": {
721
+ method: "POST",
722
+ path: "/application/open-fundamental/fund/top10-holders",
723
+ kind: "json",
724
+ description: "Query a listed fund's top 10 holders per report date",
725
+ billing: { per: "call", price: 0.4 },
726
+ retry: "no-replay",
727
+ expects: "list",
728
+ },
729
+ "fund.stock-portfolio": {
730
+ method: "POST",
731
+ path: "/application/open-fundamental/fund/stock-portfolio",
732
+ kind: "json",
733
+ description: "Query a fund's stock holdings per report date",
734
+ billing: { per: "call", price: 0.4 },
735
+ retry: "no-replay",
736
+ expects: "list",
737
+ },
738
+ "fund.industry-allocation": {
739
+ method: "POST",
740
+ path: "/application/open-fundamental/fund/industry-allocation",
741
+ kind: "json",
742
+ description: "Query a fund's stock holdings grouped by industry (SW / CITIC level 1)",
743
+ billing: { per: "call", price: 0.4 },
744
+ retry: "no-replay",
745
+ expects: "list",
746
+ },
747
+ "fund.bond-portfolio": {
748
+ method: "POST",
749
+ path: "/application/open-fundamental/fund/bond-portfolio",
750
+ kind: "json",
751
+ description: "Query a fund's bond holdings per report date",
752
+ billing: { per: "call", price: 0.4 },
753
+ retry: "no-replay",
754
+ expects: "list",
755
+ },
756
+ "fund.bond-type-allocation": {
757
+ method: "POST",
758
+ path: "/application/open-fundamental/fund/bond-type-allocation",
759
+ kind: "json",
760
+ description: "Query a fund's bond holdings grouped by bond type",
761
+ billing: { per: "call", price: 0.4 },
762
+ retry: "no-replay",
763
+ expects: "list",
764
+ },
765
+ "fund.fund-portfolio": {
766
+ method: "POST",
767
+ path: "/application/open-fundamental/fund/fund-portfolio",
768
+ kind: "json",
769
+ description: "Query a fund-of-funds' holdings of other funds",
770
+ billing: { per: "call", price: 0.4 },
771
+ retry: "no-replay",
772
+ expects: "list",
773
+ },
774
+ "fund.fund-type-allocation": {
775
+ method: "POST",
776
+ path: "/application/open-fundamental/fund/fund-type-allocation",
777
+ kind: "json",
778
+ description: "Query a fund-of-funds' holdings grouped by fund category",
779
+ billing: { per: "call", price: 0.4 },
780
+ retry: "no-replay",
781
+ expects: "list",
782
+ },
783
+ "fund.etf-pcf-header": {
784
+ method: "POST",
785
+ path: "/application/open-fundamental/fund/etf-pcf-header",
786
+ kind: "json",
787
+ description: "Query ETF creation/redemption parameters (latest trade date)",
788
+ billing: { per: "call", price: 0.4 },
789
+ retry: "no-replay",
790
+ expects: "list",
791
+ },
792
+ "fund.etf-pcf-components": {
793
+ method: "POST",
794
+ path: "/application/open-fundamental/fund/etf-pcf-components",
795
+ kind: "json",
796
+ description: "Query the ETF creation/redemption component list (latest trade date)",
797
+ billing: { per: "call", price: 0.4 },
798
+ retry: "no-replay",
799
+ expects: "list",
800
+ },
801
+ "fund.etf-share-change": {
802
+ method: "POST",
803
+ path: "/application/open-fundamental/fund/etf-share-change",
804
+ kind: "json",
805
+ description: "Query ETF shares and scale per trade date",
806
+ billing: { per: "call", price: 0.4 },
807
+ retry: "no-replay",
808
+ expects: "list",
809
+ },
615
810
  "ai.stock-summary.list": {
616
811
  method: "POST",
617
812
  path: "/application/open-ai/stock-summary/getList",
@@ -48,7 +48,7 @@ const ERROR_HINTS = {
48
48
  "100003": "msg 已指明字段名或取值范围时直接按 msg 改;msg 只说「参数值非法」时多为枚举参数拼写错误(如 --source / --question-category / --answer-important),对照命令 --help 列出的合法值检查。",
49
49
  "100004": "检查 --size / --from 是否为非负数且未超单页上限。",
50
50
  "100005": "对照命令 --help 列出的合法取值检查。",
51
- "100006": "缩短日期范围或调小 --size / --limit(list 类端点单页上限为 50,CLI 自动翻页,直发接口时要自己遵守);indicator cross-section / time-series 撞此码是单次单元格数超 30000(截面=证券数×指标数,时序=证券数或指标数×日期数),按这个乘积拆批重跑。",
51
+ "100006": "缩短日期范围或调小 --size / --limit(list 类端点单页上限为 50,CLI 自动翻页,直发接口时要自己遵守);indicator cross-section / time-series 撞此码是单次单元格数超 30000(截面=证券数×指标数,时序=证券数或指标数×日期数),按这个乘积拆批重跑;fund 命令不分页,撞此码是单次超过 10000 行,减少 --security 只数或缩短日期区间分批查。",
52
52
  "110001": "看参数名:`--*-date` 用 YYYY-MM-DD,`--*-time` 用 \"YYYY-MM-DD HH:mm:ss\"(`ai knowledge-batch` 的 --start-time/--end-time 收时间戳或 datetime,CLI 统一转 13 位毫秒)。",
53
53
  "110002": "起始晚于结束——检查 --start-date/--end-date 或 --start-time/--end-time 的先后。",
54
54
  "110003": "查询时间超出本账号的数据权限范围——把日期移进范围内(整个区间都早于下界时缩短窗口无用),或联系客户经理开通更长历史。",
@@ -1,4 +1,4 @@
1
- import { ApiError, ValidationError } from "./errors.js";
1
+ import { ApiError, markStructural, ValidationError } from "./errors.js";
2
2
  export async function fetchOpinionDetails(client, endpointKey, idListField, idField, ids) {
3
3
  const BATCH = 20;
4
4
  const unique = [...new Set(ids)];
@@ -7,18 +7,28 @@ export async function fetchOpinionDetails(client, endpointKey, idListField, idFi
7
7
  const list = [];
8
8
  let unfetched = [];
9
9
  let unfetchedError;
10
+ let nulls = 0;
10
11
  for (let i = 0; i < unique.length; i += BATCH) {
11
12
  try {
12
13
  const rows = await client.call(endpointKey, { [idListField]: unique.slice(i, i + BATCH) });
13
- list.push(...rows);
14
+ const bodies = rows.filter((row) => row !== null && typeof row === "object" && !Array.isArray(row));
15
+ const odd = rows.filter((row) => row !== null && (typeof row !== "object" || Array.isArray(row))).length;
16
+ list.push(...bodies);
17
+ if (odd > 0) {
18
+ throw markStructural(new ApiError(`${endpointKey} returned ${odd} non-object element(s) in its body list — the response layout may have changed`, undefined, undefined, rows));
19
+ }
20
+ nulls += rows.length - bodies.length;
14
21
  }
15
22
  catch (error) {
16
- if (i === 0)
23
+ if (i === 0 && list.length === 0)
17
24
  throw error;
18
- unfetched = unique.slice(i);
25
+ const kept = new Set(list.map((row) => String(row[idField])));
26
+ unfetched = unique.slice(i).filter((id) => !kept.has(id));
19
27
  const message = error instanceof Error ? error.message : String(error);
20
28
  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`);
29
+ process.stderr.write(unfetched.length > 0
30
+ ? `[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`
31
+ : `[gangtise] warning: ${message} (batch ${i / BATCH + 1}); every ID in it came back with a body.\n`);
22
32
  break;
23
33
  }
24
34
  }
@@ -26,7 +36,7 @@ export async function fetchOpinionDetails(client, endpointKey, idListField, idFi
26
36
  const missing = unique.filter((id) => !returned.has(id) && !unfetched.includes(id));
27
37
  const out = { total: list.length, list };
28
38
  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`);
39
+ process.stderr.write(`[gangtise] warning: no body returned for ${missing.length} ID(s): ${missing.join(", ")}${nulls > 0 ? ` (${nulls} came back as a null element)` : ""} — the server gives no error for an ID it has no body for (e.g. a wrong ID; a just-published opinion may also not have its body yet). Result marked partial (exit 3).\n`);
30
40
  out.missingIds = missing;
31
41
  }
32
42
  if (unfetched.length > 0) {
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { rmSync, statSync } from "node:fs";
2
+ import { renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
+ import path from "node:path";
4
5
  import { ConfigError } from "./errors.js";
5
6
  const OUTPUT_FORMATS = ["table", "json", "jsonl", "csv", "markdown"];
6
7
  export function stagingPath(target, suffix = "part") {
@@ -8,6 +9,46 @@ export function stagingPath(target, suffix = "part") {
8
9
  handedOut.add(staging);
9
10
  return staging;
10
11
  }
12
+ export async function writeFileAtomic(target, content, options = {}) {
13
+ const staging = stagingPath(target, options.suffix);
14
+ try {
15
+ await fs.writeFile(staging, content, options.mode === undefined ? undefined : { mode: options.mode });
16
+ await publishFile(staging, target);
17
+ }
18
+ catch (error) {
19
+ await fs.unlink(staging).catch(() => { });
20
+ throw error;
21
+ }
22
+ }
23
+ const publishedIdentity = new Map();
24
+ export async function publishFile(staging, target) {
25
+ const { dev, ino } = await fs.stat(staging);
26
+ await fs.rename(staging, target);
27
+ publishedIdentity.set(path.resolve(target), { dev, ino });
28
+ }
29
+ export async function stillPublished(target) {
30
+ const mine = publishedIdentity.get(path.resolve(target));
31
+ if (!mine || !mine.ino)
32
+ return undefined;
33
+ const now = await fs.stat(target).catch(() => undefined);
34
+ if (!now)
35
+ return undefined;
36
+ return now.dev === mine.dev && now.ino === mine.ino;
37
+ }
38
+ export function writeFileAtomicSync(target, content, options = {}) {
39
+ const staging = stagingPath(target, options.suffix);
40
+ try {
41
+ writeFileSync(staging, content);
42
+ renameSync(staging, target);
43
+ }
44
+ catch (error) {
45
+ try {
46
+ rmSync(staging, { force: true });
47
+ }
48
+ catch { }
49
+ throw error;
50
+ }
51
+ }
11
52
  const handedOut = new Set();
12
53
  const claimed = new Set();
13
54
  export function trackClaim(name) {
@@ -117,13 +158,14 @@ function clampCell(value) {
117
158
  }
118
159
  return out + "…";
119
160
  }
120
- function renderTable(rows) {
161
+ function renderTable(rows, clamp) {
121
162
  if (rows.length === 0) {
122
163
  return "(empty)";
123
164
  }
165
+ const cell = clamp ? clampCell : (value) => value;
124
166
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
125
- const headerCells = columns.map((column) => clampCell(sanitizeCell(column)));
126
- const matrix = rows.map((row) => columns.map((column) => clampCell(sanitizeCell(formatScalar(row[column])))));
167
+ const headerCells = columns.map((column) => cell(sanitizeCell(column)));
168
+ const matrix = rows.map((row) => columns.map((column) => cell(sanitizeCell(formatScalar(row[column])))));
127
169
  const cellWidths = matrix.map((cells) => cells.map(displayWidth));
128
170
  const headerWidths = headerCells.map(displayWidth);
129
171
  const widths = columns.map((_, c) => cellWidths.reduce((max, cells) => Math.max(max, cells[c]), headerWidths[c]));
@@ -144,14 +186,25 @@ function renderMarkdown(rows) {
144
186
  const body = rows.map((row) => `| ${columns.map((column) => mdEscape(sanitizeCell(formatScalar(row[column])))).join(" | ")} |`);
145
187
  return [header, divider, ...body].join("\n");
146
188
  }
147
- function renderCsv(rows) {
148
- if (rows.length === 0) {
149
- return "";
189
+ export function csvHeader(columns) {
190
+ return columns.map(csvEscape).join(",");
191
+ }
192
+ export function csvRow(columns, row) {
193
+ return columns.map((column) => csvEscape(formatScalar(row[column]))).join(",");
194
+ }
195
+ export function* outputLines(value, format, columns) {
196
+ if (format === "jsonl") {
197
+ for (const item of jsonlItems(value))
198
+ yield JSON.stringify(item);
199
+ return;
150
200
  }
151
- const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
152
- const header = columns.map(csvEscape).join(",");
153
- const body = rows.map((row) => columns.map((column) => csvEscape(formatScalar(row[column]))).join(","));
154
- return [header, ...body].join("\n");
201
+ const rows = toRows(value);
202
+ const header = rows.length > 0 ? Array.from(new Set(rows.flatMap((row) => Object.keys(row)))) : (columns ?? []).map(String);
203
+ if (header.length === 0)
204
+ return;
205
+ yield csvHeader(header);
206
+ for (const row of rows)
207
+ yield csvRow(header, row);
155
208
  }
156
209
  export function jsonlItems(value) {
157
210
  const list = value && typeof value === "object" && !Array.isArray(value) ? value.list : undefined;
@@ -160,71 +213,61 @@ export function jsonlItems(value) {
160
213
  export function countOutputRows(value, format) {
161
214
  return format === "jsonl" ? jsonlItems(value).length : toRows(value).length;
162
215
  }
163
- export function renderOutput(value, format) {
216
+ export function renderOutput(value, format, options = {}) {
164
217
  switch (format) {
165
218
  case "json":
166
219
  return JSON.stringify(value, null, 2);
167
220
  case "jsonl":
168
- return jsonlItems(value).map((item) => JSON.stringify(item)).join("\n");
169
221
  case "csv":
170
- return renderCsv(toRows(value));
222
+ return [...outputLines(value, format, options.columns)].join("\n");
171
223
  case "markdown":
172
224
  return renderMarkdown(toRows(value));
173
225
  case "table":
174
226
  default:
175
- return renderTable(toRows(value));
227
+ return renderTable(toRows(value), options.clampCells ?? true);
228
+ }
229
+ }
230
+ export async function writeOutputLines(stream, value, format, options = {}) {
231
+ const hash = options.digest ? createHash("sha256") : undefined;
232
+ let bytes = 0;
233
+ const flush = async (lines) => {
234
+ if (lines.length === 0)
235
+ return;
236
+ const text = lines.join("\n");
237
+ if (hash) {
238
+ const buf = Buffer.from(`${text}\n`, "utf8");
239
+ hash.update(buf);
240
+ bytes += buf.byteLength;
241
+ }
242
+ await writeLine(stream, text);
243
+ };
244
+ let chunk = [];
245
+ let first = true;
246
+ for (const line of outputLines(value, format, options.columns)) {
247
+ chunk.push(first && options.bom && format === "csv" ? `\ufeff${line}` : line);
248
+ first = false;
249
+ if (chunk.length >= LINES_PER_WRITE) {
250
+ await flush(chunk);
251
+ chunk = [];
252
+ }
176
253
  }
254
+ await flush(chunk);
255
+ return hash ? { bytes, sha256: hash.digest("hex") } : undefined;
177
256
  }
178
- export async function streamOutputToFile(value, format, outputPath) {
257
+ export async function streamOutputToFile(value, format, outputPath, columns) {
179
258
  if (format !== "jsonl" && format !== "csv")
180
259
  return null;
181
- const list = pickList(value);
182
- if (!list)
183
- return null;
184
- if (list.length < 1000)
185
- return null;
186
- let csvRows = [];
187
- let csvColumns = [];
188
- if (format === "csv") {
189
- csvRows = list.filter((row) => Boolean(row && typeof row === "object" && !Array.isArray(row)));
190
- if (csvRows.length === 0)
191
- return null;
192
- csvColumns = Array.from(new Set(csvRows.flatMap((row) => Object.keys(row))));
193
- }
194
- const { dirname } = await import("node:path");
195
260
  const { createWriteStream } = await import("node:fs");
196
- await fs.mkdir(dirname(outputPath), { recursive: true });
261
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
197
262
  const partPath = stagingPath(outputPath);
198
263
  const stream = createWriteStream(partPath, { encoding: "utf8" });
199
264
  stream.on("error", () => { });
200
265
  try {
201
- if (format === "jsonl") {
202
- let chunk = [];
203
- for (const item of jsonlItems(value)) {
204
- chunk.push(JSON.stringify(item));
205
- if (chunk.length >= LINES_PER_WRITE) {
206
- await writeLines(stream, chunk);
207
- chunk = [];
208
- }
209
- }
210
- await writeLines(stream, chunk);
211
- }
212
- else {
213
- let chunk = ["\ufeff" + csvColumns.map(csvEscape).join(",")];
214
- for (const row of csvRows) {
215
- chunk.push(csvColumns.map((column) => csvEscape(formatScalar(row[column]))).join(","));
216
- if (chunk.length >= LINES_PER_WRITE) {
217
- await writeLines(stream, chunk);
218
- chunk = [];
219
- }
220
- }
221
- await writeLines(stream, chunk);
222
- }
266
+ const digest = await writeOutputLines(stream, value, format, { columns, bom: true, digest: true });
223
267
  await new Promise((resolve, reject) => {
224
268
  stream.end((err) => err ? reject(err) : resolve());
225
269
  });
226
- const digest = await digestFile(partPath);
227
- await fs.rename(partPath, outputPath);
270
+ await publishFile(partPath, outputPath);
228
271
  return digest;
229
272
  }
230
273
  catch (error) {
@@ -246,9 +289,10 @@ export function pickList(value) {
246
289
  }
247
290
  return null;
248
291
  }
292
+ const NUMERIC_LOOKING = /^[+\-](?:(?=[^\d]*\d)[\d.,%eE]+)?$/;
249
293
  export function csvEscape(value) {
250
294
  let out = value;
251
- if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !Number.isFinite(Number(out))))
295
+ if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !NUMERIC_LOOKING.test(out)))
252
296
  out = "'" + out;
253
297
  if (/[",\n\r]/.test(out))
254
298
  return `"${out.replaceAll("\"", "\"\"")}"`;
@@ -277,20 +321,7 @@ export async function saveOutputIfNeeded(content, outputPath, withDigest = true)
277
321
  }
278
322
  const { dirname } = await import("node:path");
279
323
  await fs.mkdir(dirname(outputPath), { recursive: true });
280
- const partPath = stagingPath(outputPath);
281
- try {
282
- if (typeof content === "string") {
283
- await fs.writeFile(partPath, content, "utf8");
284
- }
285
- else {
286
- await fs.writeFile(partPath, content);
287
- }
288
- const digest = withDigest ? digestBuffer(content) : undefined;
289
- await fs.rename(partPath, outputPath);
290
- return digest;
291
- }
292
- catch (error) {
293
- await fs.unlink(partPath).catch(() => { });
294
- throw error;
295
- }
324
+ const digest = withDigest ? digestBuffer(content) : undefined;
325
+ await writeFileAtomic(outputPath, content);
326
+ return digest;
296
327
  }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { normalizeRows } from "./normalize.js";
4
- import { countOutputRows, digestFile, pickList, renderOutput, saveOutputIfNeeded, stagingPath, streamOutputToFile } from "./output.js";
4
+ import { countOutputRows, digestFile, pickList, renderOutput, saveOutputIfNeeded, stagingPath, stillPublished, streamOutputToFile, writeOutputLines } from "./output.js";
5
5
  import { getRowSink } from "./rowSink.js";
6
6
  import { extractTitles, writeTitleCache } from "./titleCache.js";
7
7
  import { CLI_VERSION } from "../version.js";
@@ -9,11 +9,13 @@ import { decidedExitCode, EXIT_SUPERSEDED, markIncomplete, markSuperseded } from
9
9
  const LARGE_RESULT_ROWS = 50_000;
10
10
  export const SUPERSEDED_EXIT = EXIT_SUPERSEDED;
11
11
  export async function warnIfSuperseded(output, digest) {
12
+ if (await stillPublished(output) === true)
13
+ return;
12
14
  const actual = await digestFile(output).catch(() => undefined);
13
15
  if (!actual || actual.sha256 === digest.sha256)
14
16
  return;
15
17
  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 `
16
- + `(wrote sha256 ${digest.sha256.slice(0, 12)}…, found ${actual.sha256.slice(0, 12)}…). Give concurrent exports distinct --output paths.\n`);
18
+ + `(this run wrote sha256 ${digest.sha256.slice(0, 12)}…). Give concurrent exports distinct --output paths.\n`);
17
19
  markSuperseded();
18
20
  }
19
21
  function warnIfLargeInMemory(items, format) {
@@ -135,10 +137,9 @@ export async function printData(data, format, output, cache) {
135
137
  await streamed.finish();
136
138
  digest = streamed.digest;
137
139
  }
138
- else if (!(digest = (await streamOutputToFile(normalized, format, output)) ?? undefined)) {
140
+ else if (!(digest = (await streamOutputToFile(normalized, format, output, columns)) ?? undefined)) {
139
141
  warnIfLargeInMemory(items, format);
140
- const content = renderOutput(normalized, format);
141
- digest = await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
142
+ digest = await saveOutputIfNeeded(renderOutput(normalized, format, { clampCells: false }), output);
142
143
  }
143
144
  }
144
145
  catch (error) {
@@ -151,6 +152,10 @@ export async function printData(data, format, output, cache) {
151
152
  process.stdout.write(`${output}\n`);
152
153
  return;
153
154
  }
155
+ if (format === "jsonl" || format === "csv") {
156
+ await writeOutputLines(process.stdout, normalized, format, { columns });
157
+ return;
158
+ }
154
159
  warnIfLargeInMemory(items, format);
155
160
  const rendered = renderOutput(normalized, format);
156
161
  if (rendered)
@@ -1,4 +1,4 @@
1
- import { isStructuralError } from "./errors.js";
1
+ import { ApiError, isStructuralError } from "./errors.js";
2
2
  import { columnarSchemaValid } from "./normalize.js";
3
3
  import { attachRowSink } from "./rowSink.js";
4
4
  import { isVerbose, PAGE_CONCURRENCY, runInOrder } from "./transport.js";
@@ -60,6 +60,12 @@ function buildShards(start, end, shardDays) {
60
60
  flush();
61
61
  return shards;
62
62
  }
63
+ const OUT_OF_WINDOW_CODE = "110003";
64
+ function describeError(error) {
65
+ if (error instanceof ApiError)
66
+ return `${error.code ? `(${error.code}) ` : ""}${error.message}`;
67
+ return error instanceof Error ? error.message : String(error);
68
+ }
63
69
  export async function callKlineWithSharding(client, endpointKey, body, config) {
64
70
  const fullMarketValue = config.fullMarketValue ?? "all";
65
71
  if (!isFullMarket(body, fullMarketValue)) {
@@ -101,9 +107,13 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
101
107
  const failedShards = [];
102
108
  let firstError = null;
103
109
  let aborted = false;
110
+ let unsent = 0;
111
+ const outOfWindow = [];
112
+ let windowError = null;
104
113
  const fetchShard = async (shard) => {
105
114
  if (aborted) {
106
115
  failedShards.push(shard);
116
+ unsent++;
107
117
  return null;
108
118
  }
109
119
  try {
@@ -115,6 +125,12 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
115
125
  return res;
116
126
  }
117
127
  catch (error) {
128
+ if (error instanceof ApiError && error.code === OUT_OF_WINDOW_CODE) {
129
+ outOfWindow.push(shard);
130
+ if (!windowError)
131
+ windowError = error;
132
+ return null;
133
+ }
118
134
  if (!firstError)
119
135
  firstError = error;
120
136
  if (!isStructuralError(error))
@@ -193,13 +209,19 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
193
209
  await keep(map ? rows.map((item) => (Array.isArray(item) ? map.map((k) => item[k]) : item)) : rows);
194
210
  };
195
211
  await runInOrder(shards, config.concurrency ?? PAGE_CONCURRENCY, fetchShard, mergeShard);
196
- if (failedShards.length === shards.length) {
197
- throw firstError ?? new Error(`All ${shards.length} kline shards failed`);
212
+ if (failedShards.length + outOfWindow.length === shards.length) {
213
+ throw firstError ?? windowError ?? new Error(`All ${shards.length} kline shards failed`);
214
+ }
215
+ const skipped = [...outOfWindow].sort((a, b) => a.startDate.localeCompare(b.startDate));
216
+ if (skipped.length > 0) {
217
+ process.stderr.write(`[gangtise] note: ${skipped.length}/${shards.length} shard(s) between ${skipped[0].startDate} and ${skipped[skipped.length - 1].endDate} lie outside your account's history window (110003) and were skipped; the result covers the rest of the range (see outOfWindowShards).\n`);
198
218
  }
199
219
  const base = header ?? fallback ?? {};
200
220
  const out = { ...base, total: count, list: merged };
201
221
  if (sink)
202
222
  attachRowSink(out, sink);
223
+ if (skipped.length > 0)
224
+ out.outOfWindowShards = skipped;
203
225
  if (fieldList) {
204
226
  out.fieldList = fieldList;
205
227
  }
@@ -215,7 +237,9 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
215
237
  if (failedShards.length > 0) {
216
238
  out.partial = true;
217
239
  out.failedShards = failedShards;
218
- process.stderr.write(`[gangtise] warning: ${failedShards.length}/${shards.length} shards failed; results are partial (see failedShards)\n`);
240
+ const notSent = unsent > 0 ? ` (${unsent} of them not sent: the first error stopped the rest)` : "";
241
+ const cause = firstError ? `; first error: ${describeError(firstError)}` : "";
242
+ process.stderr.write(`[gangtise] warning: ${failedShards.length}/${shards.length} shards failed${notSent}${cause}; results are partial (see failedShards)\n`);
219
243
  }
220
244
  if (partialShards.length > 0) {
221
245
  out.partial = true;