gangtise-openapi-cli 0.10.4 → 0.10.5
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 +9 -5
- package/dist/src/cli.js +77 -91
- package/dist/src/core/auth.js +7 -3
- package/dist/src/core/client.js +17 -7
- package/dist/src/core/lookupData/index.js +4 -1
- package/dist/src/core/normalize.js +7 -2
- package/dist/src/core/output.js +20 -3
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Gangtise OpenAPI CLI
|
|
2
2
|
|
|
3
|
-
一个可直接调用 Gangtise OpenAPI
|
|
3
|
+
一个可直接调用 Gangtise OpenAPI 获取金融数据的命令行工具,同时提供Agent Skill。
|
|
4
4
|
|
|
5
5
|
## 首次安装
|
|
6
6
|
|
|
@@ -48,7 +48,7 @@ export GANGTISE_BASE_URL="https://open.gangtise.com"
|
|
|
48
48
|
export GANGTISE_TOKEN="Bearer xxx"
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token
|
|
51
|
+
如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
## AI Agent Skill
|
|
@@ -192,6 +192,8 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
|
|
|
192
192
|
- **有时间范围时**(传了 `--start-time/--end-time` 或 `--start-date/--end-date`):**省略 `--size`**,CLI 自动翻页查全
|
|
193
193
|
- **无时间范围时**(未传时间参数):默认 `--size 200`,防止一次查询数据量过大
|
|
194
194
|
- 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
|
|
195
|
+
- 安全上限:自动翻页最多 1000 页,防止异常循环
|
|
196
|
+
- 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`),同时 stderr 输出 `Total: N, showing: M`
|
|
195
197
|
|
|
196
198
|
## 智能文件命名
|
|
197
199
|
|
|
@@ -295,7 +297,7 @@ gangtise ai peer-comparison --security-code 600519.SH
|
|
|
295
297
|
gangtise ai earnings-review --security-code 600519.SH --period 2025q3
|
|
296
298
|
gangtise ai theme-tracking --theme-id 121000131 --date 2026-03-01 --type morning
|
|
297
299
|
gangtise ai hot-topic --start-date 2026-03-22 --end-date 2026-03-27 --category morningBriefing --category noonBriefing --with-related-securities --with-close-reading
|
|
298
|
-
# 不传 --category 默认查全部类型(早报+午报+盘中快报+晚报),--with-related-securities 和 --with-close-reading
|
|
300
|
+
# 不传 --category 默认查全部类型(早报+午报+盘中快报+晚报),--with-related-securities 和 --with-close-reading 默认开启,可用 --no-with-related-securities / --no-with-close-reading 关闭
|
|
299
301
|
gangtise ai hot-topic --start-date 2026-04-15 --end-date 2026-04-17
|
|
300
302
|
gangtise ai research-outline --security-code 600519.SH
|
|
301
303
|
# 管理层讨论-财报
|
|
@@ -344,11 +346,13 @@ gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
|
|
|
344
346
|
支持:
|
|
345
347
|
|
|
346
348
|
- `table`
|
|
347
|
-
- `json`
|
|
348
|
-
- `jsonl
|
|
349
|
+
- `json`(分页结果保留 `{total, list}` 结构)
|
|
350
|
+
- `jsonl`(每行一条记录)
|
|
349
351
|
- `csv`
|
|
350
352
|
- `markdown`
|
|
351
353
|
|
|
354
|
+
所有格式均支持 `--output <path>` 输出到文件(自动创建父目录)。
|
|
355
|
+
|
|
352
356
|
## 常见错误
|
|
353
357
|
|
|
354
358
|
| 错误码 | 说明 |
|
package/dist/src/cli.js
CHANGED
|
@@ -37,7 +37,8 @@ function getTitleCachePath() {
|
|
|
37
37
|
}
|
|
38
38
|
return _titleCachePath;
|
|
39
39
|
}
|
|
40
|
-
const
|
|
40
|
+
const TITLE_LOOKUP_SIZE = 200;
|
|
41
|
+
const TITLE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
41
42
|
async function readTitleCache() {
|
|
42
43
|
try {
|
|
43
44
|
const fs = await import("node:fs/promises");
|
|
@@ -63,11 +64,15 @@ function lookupTitleCache(data, endpoint, id) {
|
|
|
63
64
|
}
|
|
64
65
|
async function printData(data, format, output, cache) {
|
|
65
66
|
const normalized = await normalizeRows(data);
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
const items = Array.isArray(normalized)
|
|
68
|
+
? normalized
|
|
69
|
+
: (normalized && typeof normalized === "object" && Array.isArray(normalized.list))
|
|
70
|
+
? normalized.list
|
|
71
|
+
: null;
|
|
72
|
+
if (cache && items) {
|
|
68
73
|
const titleField = cache.titleField ?? "title";
|
|
69
74
|
const titles = {};
|
|
70
|
-
for (const row of
|
|
75
|
+
for (const row of items) {
|
|
71
76
|
if (row && typeof row === "object") {
|
|
72
77
|
const r = row;
|
|
73
78
|
const id = r[cache.idField];
|
|
@@ -79,6 +84,13 @@ async function printData(data, format, output, cache) {
|
|
|
79
84
|
if (Object.keys(titles).length > 0)
|
|
80
85
|
writeTitleCache(cache.endpointKey, titles).catch(() => { });
|
|
81
86
|
}
|
|
87
|
+
if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
|
|
88
|
+
const meta = normalized;
|
|
89
|
+
if (typeof meta.total === "number") {
|
|
90
|
+
const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
|
|
91
|
+
process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
82
94
|
const content = await renderOutput(normalized, format);
|
|
83
95
|
if (output) {
|
|
84
96
|
await saveOutputIfNeeded(content, output);
|
|
@@ -141,7 +153,7 @@ async function resolveTitle(client, result, listEndpoint, idField, idValue, titl
|
|
|
141
153
|
catch { /* ignore */ }
|
|
142
154
|
// 2. Fallback: query list API (scan recent 200 items)
|
|
143
155
|
try {
|
|
144
|
-
const resp = await client.call(listEndpoint, { from: 0, size:
|
|
156
|
+
const resp = await client.call(listEndpoint, { from: 0, size: TITLE_LOOKUP_SIZE });
|
|
145
157
|
const items = Array.isArray(resp) ? resp : (resp.list ?? []);
|
|
146
158
|
const match = items.find(f => String(f[idField]) === String(idValue));
|
|
147
159
|
const rawTitle = match?.[titleField];
|
|
@@ -179,6 +191,46 @@ async function saveDownloadResult(result, fallbackName, output) {
|
|
|
179
191
|
}
|
|
180
192
|
throw new DownloadError("Unexpected download response");
|
|
181
193
|
}
|
|
194
|
+
const POLL_MAX_ATTEMPTS = 12;
|
|
195
|
+
const POLL_DELAY_MS = 15_000;
|
|
196
|
+
async function pollAsyncContent(client, getContentEndpoint, dataId, format, output) {
|
|
197
|
+
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
|
198
|
+
try {
|
|
199
|
+
const result = await client.call(getContentEndpoint, { dataId });
|
|
200
|
+
if (result?.content) {
|
|
201
|
+
await printData(result, format, output);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (attempt < POLL_MAX_ATTEMPTS) {
|
|
211
|
+
process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: content not ready, retrying in 15s...\n`);
|
|
212
|
+
await new Promise(resolve => setTimeout(resolve, POLL_DELAY_MS));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
function checkAsyncContent(client, getContentEndpoint, dataId, format, output) {
|
|
218
|
+
return (async () => {
|
|
219
|
+
try {
|
|
220
|
+
const result = await client.call(getContentEndpoint, { dataId });
|
|
221
|
+
if (result?.content) {
|
|
222
|
+
await printData(result, format, output);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
232
|
+
})();
|
|
233
|
+
}
|
|
182
234
|
function addTimeFilters(command) {
|
|
183
235
|
return command
|
|
184
236
|
.option("--from <number>", "Starting offset", "0")
|
|
@@ -424,7 +476,6 @@ ai.command("peer-comparison").requiredOption("--security-code <code>").option("-
|
|
|
424
476
|
});
|
|
425
477
|
ai.command("earnings-review").requiredOption("--security-code <code>").requiredOption("--period <period>", "Report period (e.g. 2025q3, 2025interim, 2025annual)").option("--wait", "Wait for content generation (blocking, up to 3 min)").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
426
478
|
const client = await createClient();
|
|
427
|
-
// Step 1: get dataId
|
|
428
479
|
const idResult = await client.call("ai.earnings-review.get-id", { securityCode: options.securityCode, period: options.period });
|
|
429
480
|
const dataId = idResult?.dataId;
|
|
430
481
|
if (!dataId) {
|
|
@@ -432,56 +483,20 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
|
|
|
432
483
|
process.exitCode = 1;
|
|
433
484
|
return;
|
|
434
485
|
}
|
|
435
|
-
// Non-blocking: return dataId immediately
|
|
436
486
|
if (!options.wait) {
|
|
437
487
|
process.stderr.write(`Earnings review task submitted. dataId: ${dataId}\n`);
|
|
438
488
|
process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: `Run 'gangtise ai earnings-review-check --data-id ${dataId}' in ~2 minutes to get results` })}\n`);
|
|
439
489
|
return;
|
|
440
490
|
}
|
|
441
|
-
// Blocking (--wait): poll for content
|
|
442
491
|
process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
while (attempts < maxAttempts) {
|
|
447
|
-
attempts++;
|
|
448
|
-
try {
|
|
449
|
-
const contentResult = await client.call("ai.earnings-review.get-content", { dataId });
|
|
450
|
-
if (contentResult?.content) {
|
|
451
|
-
await printData(contentResult, parseFormat(options.format), options.output);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
catch (error) {
|
|
456
|
-
if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
|
|
457
|
-
throw error;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
if (attempts < maxAttempts) {
|
|
461
|
-
process.stderr.write(`Attempt ${attempts}/${maxAttempts}: content not ready, retrying in 15s...\n`);
|
|
462
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
463
|
-
}
|
|
492
|
+
if (!await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, parseFormat(options.format), options.output)) {
|
|
493
|
+
process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
|
|
494
|
+
process.exitCode = 1;
|
|
464
495
|
}
|
|
465
|
-
process.stderr.write(`Content not available after ${maxAttempts} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
|
|
466
|
-
process.exitCode = 1;
|
|
467
496
|
});
|
|
468
497
|
ai.command("earnings-review-check").requiredOption("--data-id <id>", "dataId from earnings-review").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
469
498
|
const client = await createClient();
|
|
470
|
-
|
|
471
|
-
const contentResult = await client.call("ai.earnings-review.get-content", { dataId: options.dataId });
|
|
472
|
-
if (contentResult?.content) {
|
|
473
|
-
await printData(contentResult, parseFormat(options.format), options.output);
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
477
|
-
}
|
|
478
|
-
catch (error) {
|
|
479
|
-
if (error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中"))) {
|
|
480
|
-
process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
throw error;
|
|
484
|
-
}
|
|
499
|
+
await checkAsyncContent(client, "ai.earnings-review.get-content", options.dataId, parseFormat(options.format), options.output);
|
|
485
500
|
});
|
|
486
501
|
ai.command("theme-tracking").requiredOption("--theme-id <id>", "Theme ID (use lookup theme-id list)").requiredOption("--date <date>", "Date (yyyy-MM-dd)").option("--type <name>", "Report type: morning/night", collectList, []).option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
487
502
|
const client = await createClient();
|
|
@@ -492,7 +507,7 @@ ai.command("research-outline").requiredOption("--security-code <code>").option("
|
|
|
492
507
|
const client = await createClient();
|
|
493
508
|
await printData(await client.call("ai.research-outline", { securityCode: options.securityCode }), parseFormat(options.format), options.output);
|
|
494
509
|
});
|
|
495
|
-
ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--category <name>", "Report type: morningBriefing/noonBriefing/afternoonFlash/eveningBriefing", collectList, []).option("--with-related-securities", "Include related securities info",
|
|
510
|
+
ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--category <name>", "Report type: morningBriefing/noonBriefing/afternoonFlash/eveningBriefing", collectList, []).option("--with-related-securities", "Include related securities info").option("--no-with-related-securities", "Exclude related securities info").option("--with-close-reading", "Include close reading content").option("--no-with-close-reading", "Exclude close reading content").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
496
511
|
const client = await createClient();
|
|
497
512
|
const ALL_CATEGORIES = ["morningBriefing", "noonBriefing", "afternoonFlash", "eveningBriefing"];
|
|
498
513
|
await printData(await client.call("ai.hot-topic", {
|
|
@@ -501,8 +516,8 @@ ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option
|
|
|
501
516
|
startDate: options.startDate,
|
|
502
517
|
endDate: options.endDate,
|
|
503
518
|
categoryList: options.category.length > 0 ? options.category : ALL_CATEGORIES,
|
|
504
|
-
withRelatedSecurities: options.withRelatedSecurities
|
|
505
|
-
withCloseReading: options.withCloseReading
|
|
519
|
+
withRelatedSecurities: options.withRelatedSecurities === false ? undefined : true,
|
|
520
|
+
withCloseReading: options.withCloseReading === false ? undefined : true,
|
|
506
521
|
}), parseFormat(options.format), options.output);
|
|
507
522
|
});
|
|
508
523
|
ai.command("management-discuss-announcement").requiredOption("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)").requiredOption("--security-code <code>", "Security code (e.g. 000001.SZ)").addOption(new Option("--dimension <name>", "Discussion dimension").choices(["businessOperation", "financialPerformance", "developmentAndRisk"]).makeOptionMandatory()).option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
@@ -523,7 +538,6 @@ ai.command("management-discuss-earnings-call").requiredOption("--report-date <da
|
|
|
523
538
|
});
|
|
524
539
|
ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint text (max 1000 chars)").option("--wait", "Wait for content generation (blocking, up to 3 min)").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
525
540
|
const client = await createClient();
|
|
526
|
-
// Step 1: get dataId
|
|
527
541
|
const idResult = await client.call("ai.viewpoint-debate.get-id", { viewpoint: options.viewpoint });
|
|
528
542
|
const dataId = idResult?.dataId;
|
|
529
543
|
if (!dataId) {
|
|
@@ -531,56 +545,20 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
|
|
|
531
545
|
process.exitCode = 1;
|
|
532
546
|
return;
|
|
533
547
|
}
|
|
534
|
-
// Non-blocking: return dataId immediately
|
|
535
548
|
if (!options.wait) {
|
|
536
549
|
process.stderr.write(`Viewpoint debate task submitted. dataId: ${dataId}\n`);
|
|
537
550
|
process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: `Run 'gangtise ai viewpoint-debate-check --data-id ${dataId}' in ~2 minutes to get results` })}\n`);
|
|
538
551
|
return;
|
|
539
552
|
}
|
|
540
|
-
// Blocking (--wait): poll for content
|
|
541
553
|
process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
while (attempts < maxAttempts) {
|
|
546
|
-
attempts++;
|
|
547
|
-
try {
|
|
548
|
-
const contentResult = await client.call("ai.viewpoint-debate.get-content", { dataId });
|
|
549
|
-
if (contentResult?.content) {
|
|
550
|
-
await printData(contentResult, parseFormat(options.format), options.output);
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
catch (error) {
|
|
555
|
-
if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
|
|
556
|
-
throw error;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
if (attempts < maxAttempts) {
|
|
560
|
-
process.stderr.write(`Attempt ${attempts}/${maxAttempts}: content not ready, retrying in 15s...\n`);
|
|
561
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
562
|
-
}
|
|
554
|
+
if (!await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, parseFormat(options.format), options.output)) {
|
|
555
|
+
process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
|
|
556
|
+
process.exitCode = 1;
|
|
563
557
|
}
|
|
564
|
-
process.stderr.write(`Content not available after ${maxAttempts} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
|
|
565
|
-
process.exitCode = 1;
|
|
566
558
|
});
|
|
567
559
|
ai.command("viewpoint-debate-check").requiredOption("--data-id <id>", "dataId from viewpoint-debate").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
568
560
|
const client = await createClient();
|
|
569
|
-
|
|
570
|
-
const contentResult = await client.call("ai.viewpoint-debate.get-content", { dataId: options.dataId });
|
|
571
|
-
if (contentResult?.content) {
|
|
572
|
-
await printData(contentResult, parseFormat(options.format), options.output);
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
576
|
-
}
|
|
577
|
-
catch (error) {
|
|
578
|
-
if (error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中"))) {
|
|
579
|
-
process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
throw error;
|
|
583
|
-
}
|
|
561
|
+
await checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseFormat(options.format), options.output);
|
|
584
562
|
});
|
|
585
563
|
const vault = new Command("vault").description("Vault APIs");
|
|
586
564
|
vault.command("drive-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--file-type <number>", "File type", collectNumberList, []).option("--space-type <number>", "Space type", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
|
|
@@ -617,7 +595,15 @@ program.addCommand(vault);
|
|
|
617
595
|
program.addCommand(ai);
|
|
618
596
|
program.command("raw").description("Raw API calls").addCommand(new Command("call").argument("<endpointKey>").option("--body <json>").option("--query <key=value>", "Query string pair", collectKeyValue, {}).option("--format <format>", "Output format", "json").option("--output <path>").action(async (endpointKey, options) => {
|
|
619
597
|
const client = await createClient();
|
|
620
|
-
|
|
598
|
+
let body;
|
|
599
|
+
if (options.body) {
|
|
600
|
+
try {
|
|
601
|
+
body = JSON.parse(options.body);
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
621
607
|
const data = await client.call(endpointKey, body, options.query);
|
|
622
608
|
if (data && typeof data === "object" && "data" in data && data.data instanceof Uint8Array) {
|
|
623
609
|
await saveDownloadResult(data, "download.bin", options.output);
|
package/dist/src/core/auth.js
CHANGED
|
@@ -4,18 +4,22 @@ import { ConfigError } from "./errors.js";
|
|
|
4
4
|
export async function readTokenCache(filePath) {
|
|
5
5
|
try {
|
|
6
6
|
const content = await fs.readFile(filePath, "utf8");
|
|
7
|
-
|
|
7
|
+
const parsed = JSON.parse(content);
|
|
8
|
+
if (parsed && typeof parsed === "object" && typeof parsed.accessToken === "string" && typeof parsed.expiresAt === "number") {
|
|
9
|
+
return parsed;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
8
12
|
}
|
|
9
13
|
catch (error) {
|
|
10
14
|
if (error.code === "ENOENT") {
|
|
11
15
|
return null;
|
|
12
16
|
}
|
|
13
|
-
|
|
17
|
+
return null;
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
export async function writeTokenCache(filePath, cache) {
|
|
17
21
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
18
|
-
await fs.writeFile(filePath, JSON.stringify(cache, null, 2), "utf8");
|
|
22
|
+
await fs.writeFile(filePath, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
19
23
|
}
|
|
20
24
|
export function isTokenCacheValid(cache, bufferSeconds = 300) {
|
|
21
25
|
if (!cache?.accessToken || !cache.expiresAt) {
|
package/dist/src/core/client.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { request } from "undici";
|
|
2
|
-
import { normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
2
|
+
import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
3
3
|
import { ApiError, ValidationError } from "./errors.js";
|
|
4
4
|
import { ENDPOINTS, ENDPOINT_REGISTRY } from "./endpoints.js";
|
|
5
5
|
import { getLookupData } from "./lookupData/index.js";
|
|
6
6
|
export class GangtiseClient {
|
|
7
7
|
config;
|
|
8
|
+
refreshPromise = null;
|
|
8
9
|
constructor(config) {
|
|
9
10
|
this.config = config;
|
|
10
11
|
}
|
|
@@ -13,17 +14,22 @@ export class GangtiseClient {
|
|
|
13
14
|
return normalizeToken(this.config.token);
|
|
14
15
|
}
|
|
15
16
|
const cache = await readTokenCache(this.config.tokenCachePath);
|
|
16
|
-
if (cache
|
|
17
|
+
if (isTokenCacheValid(cache)) {
|
|
17
18
|
return normalizeToken(cache.accessToken);
|
|
18
19
|
}
|
|
20
|
+
if (!this.refreshPromise) {
|
|
21
|
+
this.refreshPromise = this.doTokenRefresh().finally(() => { this.refreshPromise = null; });
|
|
22
|
+
}
|
|
23
|
+
return this.refreshPromise;
|
|
24
|
+
}
|
|
25
|
+
async doTokenRefresh() {
|
|
19
26
|
const credentials = requireAccessCredentials(this.config.accessKey, this.config.secretKey);
|
|
20
27
|
const envelope = await this.requestJson(ENDPOINTS.authLogin, {
|
|
21
28
|
accessKey: credentials.accessKey,
|
|
22
29
|
secretKey: credentials.secretKey,
|
|
23
30
|
}, false);
|
|
24
31
|
const accessToken = normalizeToken(envelope.accessToken);
|
|
25
|
-
const
|
|
26
|
-
const expiresAt = issuedAt + envelope.expiresIn;
|
|
32
|
+
const expiresAt = Math.floor(Date.now() / 1000) + envelope.expiresIn;
|
|
27
33
|
await writeTokenCache(this.config.tokenCachePath, {
|
|
28
34
|
...envelope,
|
|
29
35
|
accessToken,
|
|
@@ -83,7 +89,8 @@ export class GangtiseClient {
|
|
|
83
89
|
let firstPage;
|
|
84
90
|
let total;
|
|
85
91
|
let nextFrom = startFrom;
|
|
86
|
-
|
|
92
|
+
const MAX_PAGES = 1000;
|
|
93
|
+
for (let pageCount = 0; pageCount < MAX_PAGES; pageCount++) {
|
|
87
94
|
const remaining = requestedSize === undefined
|
|
88
95
|
? maxPageSize
|
|
89
96
|
: Math.min(maxPageSize, requestedSize - collected.length);
|
|
@@ -126,7 +133,7 @@ export class GangtiseClient {
|
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
135
|
if (!firstPage) {
|
|
129
|
-
return
|
|
136
|
+
return { total: 0, list: [] };
|
|
130
137
|
}
|
|
131
138
|
return {
|
|
132
139
|
...firstPage,
|
|
@@ -160,12 +167,15 @@ export class GangtiseClient {
|
|
|
160
167
|
bodyTimeout: this.config.timeoutMs,
|
|
161
168
|
});
|
|
162
169
|
const text = await response.body.text();
|
|
170
|
+
if (response.statusCode >= 500) {
|
|
171
|
+
throw new ApiError(`Server error (HTTP ${response.statusCode})`, undefined, response.statusCode, text.slice(0, 500));
|
|
172
|
+
}
|
|
163
173
|
let parsed;
|
|
164
174
|
try {
|
|
165
175
|
parsed = JSON.parse(text);
|
|
166
176
|
}
|
|
167
177
|
catch {
|
|
168
|
-
throw new ApiError('Failed to parse API response', undefined, response.statusCode, text);
|
|
178
|
+
throw new ApiError('Failed to parse API response', undefined, response.statusCode, text.slice(0, 500));
|
|
169
179
|
}
|
|
170
180
|
return this.unwrapEnvelope(parsed, response.statusCode);
|
|
171
181
|
}
|
|
@@ -13,7 +13,10 @@ export async function getLookupData(key) {
|
|
|
13
13
|
if (cache.has(key))
|
|
14
14
|
return cache.get(key);
|
|
15
15
|
const mod = await loaders[key]();
|
|
16
|
-
const
|
|
16
|
+
const values = Object.values(mod);
|
|
17
|
+
const data = values.find(v => Array.isArray(v));
|
|
18
|
+
if (!data)
|
|
19
|
+
throw new Error(`Lookup module "${key}" has no exported array`);
|
|
17
20
|
cache.set(key, data);
|
|
18
21
|
return data;
|
|
19
22
|
}
|
|
@@ -7,7 +7,7 @@ export function normalizeRows(value) {
|
|
|
7
7
|
}
|
|
8
8
|
const record = value;
|
|
9
9
|
if (Array.isArray(record.fieldList) && Array.isArray(record.list)) {
|
|
10
|
-
|
|
10
|
+
const normalizedList = record.list.map((row) => {
|
|
11
11
|
if (!Array.isArray(row))
|
|
12
12
|
return row;
|
|
13
13
|
return record.fieldList.reduce((acc, field, index) => {
|
|
@@ -15,9 +15,14 @@ export function normalizeRows(value) {
|
|
|
15
15
|
return acc;
|
|
16
16
|
}, {});
|
|
17
17
|
});
|
|
18
|
+
const { fieldList, list, ...meta } = record;
|
|
19
|
+
const hasMeta = Object.keys(meta).length > 0;
|
|
20
|
+
return hasMeta ? { ...meta, list: normalizedList } : normalizedList;
|
|
18
21
|
}
|
|
19
22
|
if (Array.isArray(record.list)) {
|
|
20
|
-
|
|
23
|
+
const { list, ...meta } = record;
|
|
24
|
+
const hasMeta = Object.keys(meta).length > 0;
|
|
25
|
+
return hasMeta ? { ...meta, list } : list;
|
|
21
26
|
}
|
|
22
27
|
return value;
|
|
23
28
|
}
|
package/dist/src/core/output.js
CHANGED
|
@@ -16,7 +16,15 @@ function toRows(value) {
|
|
|
16
16
|
return value.map((item, index) => ({ index, value: item }));
|
|
17
17
|
}
|
|
18
18
|
if (value && typeof value === "object") {
|
|
19
|
-
|
|
19
|
+
const record = value;
|
|
20
|
+
if (Array.isArray(record.list)) {
|
|
21
|
+
const list = record.list;
|
|
22
|
+
if (list.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
|
|
23
|
+
return list;
|
|
24
|
+
}
|
|
25
|
+
return list.map((item, index) => ({ index, value: item }));
|
|
26
|
+
}
|
|
27
|
+
return [record];
|
|
20
28
|
}
|
|
21
29
|
return [{ value }];
|
|
22
30
|
}
|
|
@@ -51,6 +59,9 @@ function renderCsv(rows) {
|
|
|
51
59
|
}
|
|
52
60
|
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
|
53
61
|
const escape = (value) => {
|
|
62
|
+
if (/^[=+\-@\t\r]/.test(value)) {
|
|
63
|
+
value = "'" + value;
|
|
64
|
+
}
|
|
54
65
|
if (/[",\n]/.test(value)) {
|
|
55
66
|
return `"${value.replaceAll("\"", "\"\"")}"`;
|
|
56
67
|
}
|
|
@@ -65,8 +76,12 @@ export function renderOutput(value, format) {
|
|
|
65
76
|
switch (format) {
|
|
66
77
|
case "json":
|
|
67
78
|
return JSON.stringify(value, null, 2);
|
|
68
|
-
case "jsonl":
|
|
69
|
-
|
|
79
|
+
case "jsonl": {
|
|
80
|
+
const items = value && typeof value === "object" && !Array.isArray(value) && Array.isArray(value.list)
|
|
81
|
+
? value.list
|
|
82
|
+
: null;
|
|
83
|
+
return (items ?? rows).map((item) => JSON.stringify(item)).join("\n");
|
|
84
|
+
}
|
|
70
85
|
case "csv":
|
|
71
86
|
return renderCsv(rows);
|
|
72
87
|
case "markdown":
|
|
@@ -80,6 +95,8 @@ export async function saveOutputIfNeeded(content, outputPath) {
|
|
|
80
95
|
if (!outputPath) {
|
|
81
96
|
return;
|
|
82
97
|
}
|
|
98
|
+
const { dirname } = await import("node:path");
|
|
99
|
+
await (await import("node:fs/promises")).mkdir(dirname(outputPath), { recursive: true });
|
|
83
100
|
if (typeof content === "string") {
|
|
84
101
|
await fs.writeFile(outputPath, content, "utf8");
|
|
85
102
|
return;
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated — DO NOT EDIT
|
|
2
|
-
export const CLI_VERSION = "0.10.
|
|
2
|
+
export const CLI_VERSION = "0.10.5";
|