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.
- package/README.md +93 -19
- package/dist/src/cli.js +29 -18
- package/dist/src/commands/ai.js +20 -29
- package/dist/src/commands/auth.js +5 -4
- package/dist/src/commands/bond.js +5 -4
- package/dist/src/commands/fund.js +100 -0
- package/dist/src/commands/fundamental.js +2 -0
- package/dist/src/commands/groups.js +14 -0
- package/dist/src/commands/indicator.js +5 -5
- package/dist/src/commands/insight.js +3 -4
- package/dist/src/commands/raw.js +4 -3
- package/dist/src/commands/shared.js +35 -6
- package/dist/src/commands/tool.js +2 -2
- package/dist/src/commands/vault.js +30 -30
- package/dist/src/core/args.js +23 -7
- package/dist/src/core/asyncContent.js +1 -1
- package/dist/src/core/auth.js +2 -10
- package/dist/src/core/client.js +96 -35
- package/dist/src/core/commandBodies.js +1 -27
- package/dist/src/core/config.js +9 -5
- package/dist/src/core/download.js +47 -10
- package/dist/src/core/endpoints.js +198 -3
- package/dist/src/core/errors.js +1 -1
- package/dist/src/core/opinionDetail.js +16 -6
- package/dist/src/core/output.js +103 -72
- package/dist/src/core/printer.js +10 -5
- package/dist/src/core/quoteSharding.js +28 -4
- package/dist/src/core/rowSink.js +24 -10
- package/dist/src/core/titleCache.js +2 -10
- package/dist/src/core/transport.js +25 -7
- package/dist/src/core/updateCheck.js +3 -9
- package/gangtise-openapi/SKILL.md +53 -17
- package/gangtise-openapi/references/commands/fund.md +192 -0
- package/gangtise-openapi/references/commands/fundamental.md +2 -2
- package/gangtise-openapi/references/commands/indicator.md +7 -7
- package/gangtise-openapi/references/commands/insight.md +3 -3
- package/gangtise-openapi/references/commands/quote.md +1 -1
- package/gangtise-openapi/references/commands/tool.md +1 -1
- package/gangtise-openapi/references/commands/vault.md +1 -1
- package/gangtise-openapi/references/errors.md +5 -5
- package/gangtise-openapi/references/response-schema.md +10 -0
- 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: "
|
|
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: "
|
|
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: "
|
|
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",
|
package/dist/src/core/errors.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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) {
|
package/dist/src/core/output.js
CHANGED
|
@@ -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) =>
|
|
126
|
-
const matrix = rows.map((row) => columns.map((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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
152
|
-
const header =
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) && !
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
}
|
package/dist/src/core/printer.js
CHANGED
|
@@ -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)}
|
|
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
|
-
|
|
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
|
-
|
|
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;
|