gangtise-openapi-cli 0.10.9 → 0.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -193,6 +193,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
193
193
  - **有时间范围时**(传了 `--start-time/--end-time` 或 `--start-date/--end-date`):**省略 `--size`**,CLI 自动翻页查全
194
194
  - **无时间范围时**(未传时间参数):默认 `--size 200`,防止一次查询数据量过大
195
195
  - 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
196
+ - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
196
197
  - 安全上限:自动翻页最多 1000 页,防止异常循环
197
198
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`),同时 stderr 输出 `Total: N, showing: M`
198
199
 
@@ -362,10 +363,23 @@ gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
362
363
 
363
364
  所有格式均支持 `--output <path>` 输出到文件(自动创建父目录)。
364
365
 
366
+ ## 参数校验
367
+
368
+ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到 API:
369
+
370
+ - `--from`:非负整数
371
+ - `--size` / `--limit` / `--top`:正整数
372
+ - `--file-type` / `--resource-type` 以及数值型列表参数:有限数字
373
+ - 公告 `--start-time` / `--end-time`:可解析的时间字符串或 Unix 时间戳
374
+
375
+ 校验失败会输出 `ValidationError: Invalid ...` 并以非 0 状态退出。
376
+
365
377
  ## 常见错误
366
378
 
367
- | 错误码 | 说明 |
368
- |--------|------|
379
+ | 错误/错误码 | 说明 |
380
+ |-----------|------|
381
+ | `ValidationError` | 本地参数校验失败,检查 `--size` / `--limit` / `--from` / `--file-type` 等数值参数 |
382
+ | `API error (HTTP 4xx/5xx)` | HTTP 层失败;CLI 会把 4xx/5xx 响应视为错误,即使响应体不是标准 `{code,msg,data}` 信封 |
369
383
  | `8000014` | `GANGTISE_ACCESS_KEY` 错误 |
370
384
  | `8000015` | `GANGTISE_SECRET_KEY` 错误 |
371
385
  | `8000016` | 开发账号状态异常 |
package/dist/src/cli.js CHANGED
@@ -1,244 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Option } from "commander";
3
- import { collectKeyValue, collectList, collectNumberList, maybeArray, toTimestamp13 } from "./core/args.js";
3
+ import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
4
+ import { readTokenCache } from "./core/auth.js";
5
+ import { collectKeyValue, collectList, collectNumberList, maybeArray, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
4
6
  import { loadConfig } from "./core/config.js";
5
- import { ApiError, ConfigError, DownloadError } from "./core/errors.js";
7
+ import { resolveTitle, saveDownloadResult } from "./core/download.js";
8
+ import { ApiError, ConfigError } from "./core/errors.js";
9
+ import { normalizeRows } from "./core/normalize.js";
10
+ import { parseOutputFormat } from "./core/output.js";
11
+ import { printData } from "./core/printer.js";
6
12
  // --- Lazy-loaded modules (deferred to action handlers) ---
7
13
  async function createClient() {
8
14
  const { GangtiseClient } = await import("./core/client.js");
9
15
  return new GangtiseClient(loadConfig());
10
16
  }
11
- async function readTokenCache(...args) {
12
- return (await import("./core/auth.js")).readTokenCache(...args);
13
- }
14
- async function normalizeRows(...args) {
15
- return (await import("./core/normalize.js")).normalizeRows(...args);
16
- }
17
- async function renderOutput(...args) {
18
- return (await import("./core/output.js")).renderOutput(...args);
19
- }
20
- async function saveOutputIfNeeded(...args) {
21
- return (await import("./core/output.js")).saveOutputIfNeeded(...args);
22
- }
23
- function parseFormat(value) {
24
- const format = value ?? "table";
25
- if (["table", "json", "jsonl", "csv", "markdown"].includes(format)) {
26
- return format;
27
- }
28
- throw new ConfigError(`Unsupported format: ${format}`);
29
- }
30
- // --- Title cache: list writes, download reads ---
31
- let _titleCachePath;
32
- function getTitleCachePath() {
33
- if (!_titleCachePath) {
34
- const path = require("node:path");
35
- const os = require("node:os");
36
- _titleCachePath = path.join(os.homedir(), ".config", "gangtise", "title-cache.json");
37
- }
38
- return _titleCachePath;
39
- }
40
- const TITLE_LOOKUP_SIZE = 200;
41
- const TITLE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
42
- async function readTitleCache() {
43
- try {
44
- const fs = await import("node:fs/promises");
45
- return JSON.parse(await fs.readFile(getTitleCachePath(), "utf8"));
46
- }
47
- catch {
48
- return {};
49
- }
50
- }
51
- async function writeTitleCache(endpoint, titles) {
52
- const fs = await import("node:fs/promises");
53
- const path = await import("node:path");
54
- const data = await readTitleCache();
55
- data[endpoint] = { titles, ts: Date.now() };
56
- await fs.mkdir(path.dirname(getTitleCachePath()), { recursive: true });
57
- await fs.writeFile(getTitleCachePath(), JSON.stringify(data), "utf8");
58
- }
59
- function lookupTitleCache(data, endpoint, id) {
60
- const entry = data[endpoint];
61
- if (!entry || Date.now() - entry.ts > TITLE_CACHE_TTL_MS)
62
- return undefined;
63
- return entry.titles[id];
64
- }
65
- async function printData(data, format, output, cache) {
66
- const normalized = await normalizeRows(data);
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) {
73
- const titleField = cache.titleField ?? "title";
74
- const titles = {};
75
- for (const row of items) {
76
- if (row && typeof row === "object") {
77
- const r = row;
78
- const id = r[cache.idField];
79
- const title = r[titleField];
80
- if (id != null && typeof title === "string" && title)
81
- titles[String(id)] = title;
82
- }
83
- }
84
- if (Object.keys(titles).length > 0)
85
- writeTitleCache(cache.endpointKey, titles).catch(() => { });
86
- }
87
- if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
88
- const meta = normalized;
89
- if (typeof meta.total === "number" && format !== "json") {
90
- const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
91
- process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
92
- }
93
- }
94
- const content = await renderOutput(normalized, format);
95
- if (output) {
96
- await saveOutputIfNeeded(content, output);
97
- process.stdout.write(`${output}\n`);
98
- return;
99
- }
100
- process.stdout.write(`${content}\n`);
101
- }
102
- const MIME_EXT = {
103
- "application/pdf": ".pdf",
104
- "application/msword": ".doc",
105
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
106
- "application/vnd.ms-excel": ".xls",
107
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
108
- "application/vnd.ms-powerpoint": ".ppt",
109
- "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
110
- "application/zip": ".zip",
111
- "application/x-rar-compressed": ".rar",
112
- "application/gzip": ".gz",
113
- "application/x-7z-compressed": ".7z",
114
- "application/json": ".json",
115
- "application/xml": ".xml",
116
- "text/plain": ".txt",
117
- "text/html": ".html",
118
- "text/csv": ".csv",
119
- "image/png": ".png",
120
- "image/jpeg": ".jpg",
121
- "image/gif": ".gif",
122
- "image/webp": ".webp",
123
- "image/svg+xml": ".svg",
124
- "audio/mpeg": ".mp3",
125
- "video/mp4": ".mp4",
126
- "application/octet-stream": ".bin",
127
- };
128
- function extFromContentType(contentType) {
129
- if (!contentType)
130
- return "";
131
- const mime = contentType.split(";")[0].trim().toLowerCase();
132
- return MIME_EXT[mime] ?? "";
133
- }
134
- /** Resolve a human-readable filename by looking up the title from cache or list endpoint. */
135
- async function resolveTitle(client, result, listEndpoint, idField, idValue, titleField = "title") {
136
- const { extname } = await import("node:path");
137
- const file = result;
138
- const serverExt = file.filename ? extname(file.filename) : extFromContentType(file.contentType);
139
- function buildFilename(rawTitle) {
140
- let title = rawTitle.replace(/[/\\:*?"<>|]/g, "_").trim();
141
- if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
142
- title += serverExt;
143
- }
144
- return title;
145
- }
146
- // 1. Check file-based title cache (populated by prior list command)
147
- try {
148
- const cacheData = await readTitleCache();
149
- const cached = lookupTitleCache(cacheData, listEndpoint, idValue);
150
- if (cached)
151
- return buildFilename(cached);
152
- }
153
- catch { /* ignore */ }
154
- // 2. Fallback: query list API (scan recent 200 items)
155
- try {
156
- const resp = await client.call(listEndpoint, { from: 0, size: TITLE_LOOKUP_SIZE });
157
- const items = Array.isArray(resp) ? resp : (resp.list ?? []);
158
- const match = items.find(f => String(f[idField]) === String(idValue));
159
- const rawTitle = match?.[titleField];
160
- if (typeof rawTitle === "string" && rawTitle)
161
- return buildFilename(rawTitle);
162
- }
163
- catch { /* ignore */ }
164
- return undefined;
165
- }
166
- async function saveDownloadResult(result, fallbackName, output) {
167
- if (!(result && typeof result === "object")) {
168
- throw new DownloadError("Unexpected download response");
169
- }
170
- const file = result;
171
- if (file.data instanceof Uint8Array) {
172
- const outputPath = output ?? file.filename ?? (fallbackName + extFromContentType(file.contentType));
173
- await saveOutputIfNeeded(file.data, outputPath);
174
- process.stdout.write(`${outputPath}\n`);
175
- return;
176
- }
177
- if (typeof file.text === 'string') {
178
- const outputPath = output ?? `${fallbackName}.txt`;
179
- await saveOutputIfNeeded(file.text, outputPath);
180
- process.stdout.write(`${outputPath}\n`);
181
- return;
182
- }
183
- if (typeof file.url === 'string') {
184
- if (output) {
185
- await saveOutputIfNeeded(file.url, output);
186
- process.stdout.write(`${output}\n`);
187
- return;
188
- }
189
- process.stdout.write(`${file.url}\n`);
190
- return;
191
- }
192
- throw new DownloadError("Unexpected download response");
193
- }
194
- const POLL_MAX_ATTEMPTS = 12;
195
- const POLL_DELAY_MS = 15_000;
196
- function isAsyncPending(error) {
197
- return error instanceof ApiError && error.code === "410110";
198
- }
199
- async function pollAsyncContent(client, getContentEndpoint, dataId, format, output) {
200
- for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
201
- try {
202
- const result = await client.call(getContentEndpoint, { dataId });
203
- if (result?.content != null) {
204
- await printData(result, format, output);
205
- return true;
206
- }
207
- }
208
- catch (error) {
209
- if (error instanceof ApiError && error.code === "410111") {
210
- process.stderr.write("Content generation failed (terminal). Do not retry.\n");
211
- return false;
212
- }
213
- if (!isAsyncPending(error))
214
- throw error;
215
- }
216
- if (attempt < POLL_MAX_ATTEMPTS) {
217
- process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: content not ready, retrying in 15s...\n`);
218
- await new Promise(resolve => setTimeout(resolve, POLL_DELAY_MS));
219
- }
220
- }
221
- return false;
222
- }
223
- async function checkAsyncContent(client, getContentEndpoint, dataId, format, output) {
224
- try {
225
- const result = await client.call(getContentEndpoint, { dataId });
226
- if (result?.content != null) {
227
- await printData(result, format, output);
228
- return;
229
- }
230
- }
231
- catch (error) {
232
- if (error instanceof ApiError && error.code === "410111") {
233
- process.stderr.write("Content generation failed (terminal). Do not retry.\n");
234
- process.exitCode = 1;
235
- return;
236
- }
237
- if (!isAsyncPending(error))
238
- throw error;
239
- }
240
- process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
241
- }
242
17
  function addTimeFilters(command) {
243
18
  return command
244
19
  .option("--from <number>", "Starting offset", "0")
@@ -257,48 +32,48 @@ program
257
32
  .option("--format <format>", "Output format", "json")
258
33
  .action(async (options) => {
259
34
  const client = await createClient();
260
- await printData(await client.login(), parseFormat(options.format));
35
+ await printData(await client.login(), parseOutputFormat(options.format));
261
36
  }))
262
37
  .addCommand(new Command("status")
263
38
  .option("--format <format>", "Output format", "json")
264
39
  .action(async (options) => {
265
40
  const config = loadConfig();
266
41
  const cache = await readTokenCache(config.tokenCachePath);
267
- await printData({ hasEnvToken: Boolean(config.token), hasCachedToken: Boolean(cache?.accessToken), cache }, parseFormat(options.format));
42
+ await printData({ hasEnvToken: Boolean(config.token), hasCachedToken: Boolean(cache?.accessToken), cache }, parseOutputFormat(options.format));
268
43
  }));
269
44
  const lookup = new Command("lookup").description("Lookup helper APIs");
270
45
  lookup
271
46
  .addCommand(new Command("research-area").addCommand(new Command("list").option("--format <format>", "Output format", "table").action(async (options) => {
272
47
  const client = await createClient();
273
- await printData(await client.call("lookup.research-areas.list"), parseFormat(options.format));
48
+ await printData(await client.call("lookup.research-areas.list"), parseOutputFormat(options.format));
274
49
  })))
275
50
  .addCommand(new Command("broker-org").addCommand(new Command("list").option("--format <format>", "Output format", "table").action(async (options) => {
276
51
  const client = await createClient();
277
- await printData(await client.call("lookup.broker-orgs.list"), parseFormat(options.format));
52
+ await printData(await client.call("lookup.broker-orgs.list"), parseOutputFormat(options.format));
278
53
  })))
279
54
  .addCommand(new Command("meeting-org").addCommand(new Command("list").option("--format <format>", "Output format", "table").action(async (options) => {
280
55
  const client = await createClient();
281
- await printData(await client.call("lookup.meeting-orgs.list"), parseFormat(options.format));
56
+ await printData(await client.call("lookup.meeting-orgs.list"), parseOutputFormat(options.format));
282
57
  })))
283
58
  .addCommand(new Command("industry").addCommand(new Command("list").option("--format <format>", "Output format", "table").action(async (options) => {
284
59
  const client = await createClient();
285
- await printData(await client.call("lookup.industries.list"), parseFormat(options.format));
60
+ await printData(await client.call("lookup.industries.list"), parseOutputFormat(options.format));
286
61
  })))
287
62
  .addCommand(new Command("region").description("Foreign report region codes").addCommand(new Command("list").option("--format <format>", "Output format", "table").action(async (options) => {
288
63
  const client = await createClient();
289
- await printData(await client.call("lookup.regions.list"), parseFormat(options.format));
64
+ await printData(await client.call("lookup.regions.list"), parseOutputFormat(options.format));
290
65
  })))
291
66
  .addCommand(new Command("announcement-category").description("Announcement category codes").addCommand(new Command("list").option("--format <format>", "Output format", "table").action(async (options) => {
292
67
  const client = await createClient();
293
- await printData(await client.call("lookup.announcement-categories.list"), parseFormat(options.format));
68
+ await printData(await client.call("lookup.announcement-categories.list"), parseOutputFormat(options.format));
294
69
  })))
295
70
  .addCommand(new Command("industry-code").description("Shenwan industry codes for security-clue --gts-code").addCommand(new Command("list").option("--format <format>", "Output format", "table").action(async (options) => {
296
71
  const client = await createClient();
297
- await printData(await client.call("lookup.industry-codes.list"), parseFormat(options.format));
72
+ await printData(await client.call("lookup.industry-codes.list"), parseOutputFormat(options.format));
298
73
  })))
299
74
  .addCommand(new Command("theme-id").description("Theme IDs for theme-tracking --theme-id").addCommand(new Command("list").option("--format <format>", "Output format", "table").action(async (options) => {
300
75
  const client = await createClient();
301
- await printData(await client.call("lookup.theme-ids.list"), parseFormat(options.format));
76
+ await printData(await client.call("lookup.theme-ids.list"), parseOutputFormat(options.format));
302
77
  })));
303
78
  program.addCommand(lookup);
304
79
  const insight = new Command("insight").description("Insight APIs");
@@ -314,20 +89,20 @@ const announcement = new Command("announcement");
314
89
  addTimeFilters(opinion.command("list").option("--rank-type <number>", "Rank type", "1").option("--research-area <id>", "Research area ID", collectList, []).option("--chief <id>", "Chief ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--concept <id>", "Concept ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--source <source>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
315
90
  const client = await createClient();
316
91
  await printData(await client.call("insight.opinion.list", {
317
- from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size), startTime: options.startTime, endTime: options.endTime,
318
- rankType: Number(options.rankType), keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), chiefList: maybeArray(options.chief),
92
+ from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
93
+ rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), chiefList: maybeArray(options.chief),
319
94
  securityList: maybeArray(options.security), brokerList: maybeArray(options.broker), industryList: maybeArray(options.industry), conceptList: maybeArray(options.concept),
320
95
  llmTagList: maybeArray(options.llmTag), sourceList: maybeArray(options.source),
321
- }), parseFormat(options.format), options.output);
96
+ }), parseOutputFormat(options.format), options.output);
322
97
  });
323
98
  addTimeFilters(summary.command("list").option("--search-type <number>", "Search type", "1").option("--rank-type <number>", "Rank type", "1").option("--source <number>", "Source type", collectNumberList, []).option("--research-area <id>", "Research area", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Category", collectList, []).option("--market <name>", "Market", collectList, []).option("--participant-role <name>", "Participant role", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
324
99
  const client = await createClient();
325
100
  await printData(await client.call("insight.summary.list", {
326
- from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size), startTime: options.startTime, endTime: options.endTime,
327
- searchType: Number(options.searchType), rankType: Number(options.rankType), keyword: options.keyword, sourceList: options.source.length ? options.source : undefined,
101
+ from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
102
+ searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, sourceList: options.source.length ? options.source : undefined,
328
103
  researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution),
329
104
  categoryList: maybeArray(options.category), marketList: maybeArray(options.market), participantRoleList: maybeArray(options.participantRole),
330
- }), parseFormat(options.format), options.output, { endpointKey: "insight.summary.list", idField: "summaryId" });
105
+ }), parseOutputFormat(options.format), options.output, { endpointKey: "insight.summary.list", idField: "summaryId" });
331
106
  });
332
107
  summary.command("download").requiredOption("--summary-id <id>").option("--output <path>").action(async (options) => {
333
108
  const client = await createClient();
@@ -338,11 +113,11 @@ summary.command("download").requiredOption("--summary-id <id>").option("--output
338
113
  const addScheduleList = (command, endpointKey) => addTimeFilters(command.command("list").option("--research-area <id>", "Research area", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--category <name>", "Category", collectList, []).option("--market <name>", "Market", collectList, []).option("--participant-role <name>", "Participant role", collectList, []).option("--broker-type <name>", "Broker type", collectList, []).option("--object <type>", "Object type: company/industry", collectList, []).option("--permission <number>", "Permission", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
339
114
  const client = await createClient();
340
115
  await printData(await client.call(endpointKey, {
341
- from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
116
+ from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
342
117
  researchAreaList: maybeArray(options.researchArea), institutionList: maybeArray(options.institution), securityList: maybeArray(options.security),
343
118
  categoryList: maybeArray(options.category), marketList: maybeArray(options.market), participantRoleList: maybeArray(options.participantRole),
344
119
  brokerTypeList: maybeArray(options.brokerType), objectList: maybeArray(options.object), permission: options.permission.length ? options.permission : undefined,
345
- }), parseFormat(options.format), options.output);
120
+ }), parseOutputFormat(options.format), options.output);
346
121
  });
347
122
  addScheduleList(roadshow, "insight.roadshow.list");
348
123
  addScheduleList(siteVisit, "insight.site-visit.list");
@@ -351,49 +126,49 @@ addScheduleList(forum, "insight.forum.list");
351
126
  addTimeFilters(research.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--broker <id>", "Broker ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--category <name>", "Report category", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--min-pages <number>", "Min report pages").option("--max-pages <number>", "Max report pages").option("--source <type>", "Source type", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
352
127
  const client = await createClient();
353
128
  await printData(await client.call("insight.research.list", {
354
- from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
355
- searchType: Number(options.searchType), rankType: Number(options.rankType),
129
+ from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
130
+ searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
356
131
  brokerList: maybeArray(options.broker), securityList: maybeArray(options.security), industryList: maybeArray(options.industry),
357
132
  categoryList: maybeArray(options.category), llmTagList: maybeArray(options.llmTag), ratingList: maybeArray(options.rating),
358
- ratingChangeList: maybeArray(options.ratingChange), minReportPages: options.minPages ? Number(options.minPages) : undefined,
359
- maxReportPages: options.maxPages ? Number(options.maxPages) : undefined, sourceList: maybeArray(options.source),
360
- }), parseFormat(options.format), options.output, { endpointKey: "insight.research.list", idField: "reportId" });
133
+ ratingChangeList: maybeArray(options.ratingChange), minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }),
134
+ maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }), sourceList: maybeArray(options.source),
135
+ }), parseOutputFormat(options.format), options.output, { endpointKey: "insight.research.list", idField: "reportId" });
361
136
  });
362
137
  research.command("download").requiredOption("--report-id <id>").option("--file-type <number>", "File type: 1=PDF 2=Markdown", "1").option("--output <path>").action(async (options) => {
363
138
  const client = await createClient();
364
- const result = await client.call("insight.research.download", undefined, { reportId: options.reportId, fileType: Number(options.fileType) });
139
+ const result = await client.call("insight.research.download", undefined, { reportId: options.reportId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) });
365
140
  const title = options.output ? undefined : await resolveTitle(client, result, "insight.research.list", "reportId", options.reportId);
366
141
  await saveDownloadResult(result, `research-${options.reportId}`, options.output ?? title);
367
142
  });
368
143
  addTimeFilters(foreignReport.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code", collectList, []).option("--region <id>", "Region ID", collectList, []).option("--category <name>", "Report category", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--min-pages <number>", "Min report pages").option("--max-pages <number>", "Max report pages").option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
369
144
  const client = await createClient();
370
145
  await printData(await client.call("insight.foreign-report.list", {
371
- from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
372
- searchType: Number(options.searchType), rankType: Number(options.rankType),
146
+ from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
147
+ searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
373
148
  securityList: maybeArray(options.security), regionList: maybeArray(options.region), categoryList: maybeArray(options.category),
374
149
  industryList: maybeArray(options.industry), brokerList: maybeArray(options.broker), llmTagList: maybeArray(options.llmTag),
375
150
  ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
376
- minReportPages: options.minPages ? Number(options.minPages) : undefined, maxReportPages: options.maxPages ? Number(options.maxPages) : undefined,
377
- }), parseFormat(options.format), options.output, { endpointKey: "insight.foreign-report.list", idField: "reportId" });
151
+ minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }), maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }),
152
+ }), parseOutputFormat(options.format), options.output, { endpointKey: "insight.foreign-report.list", idField: "reportId" });
378
153
  });
379
154
  foreignReport.command("download").requiredOption("--report-id <id>").option("--file-type <number>", "File type: 1=PDF 2=Markdown 3=CN-PDF 4=CN-Markdown", "1").option("--output <path>").action(async (options) => {
380
155
  const client = await createClient();
381
- const result = await client.call("insight.foreign-report.download", undefined, { reportId: options.reportId, fileType: Number(options.fileType) });
156
+ const result = await client.call("insight.foreign-report.download", undefined, { reportId: options.reportId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) });
382
157
  const title = options.output ? undefined : await resolveTitle(client, result, "insight.foreign-report.list", "reportId", options.reportId);
383
158
  await saveDownloadResult(result, `foreign-report-${options.reportId}`, options.output ?? title);
384
159
  });
385
160
  addTimeFilters(announcement.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code", collectList, []).option("--announcement-type <type>", "Announcement type", collectList, []).option("--category <id>", "Category ID", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
386
161
  const client = await createClient();
387
162
  await printData(await client.call("insight.announcement.list", {
388
- from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size),
389
- startTime: toTimestamp13(options.startTime), endTime: toTimestamp13(options.endTime),
390
- searchType: Number(options.searchType), rankType: Number(options.rankType), keyword: options.keyword,
163
+ from: parseFrom(options.from), size: parseSize(options.size),
164
+ startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time"),
165
+ searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword,
391
166
  securityList: maybeArray(options.security), announcementTypeList: maybeArray(options.announcementType), categoryList: maybeArray(options.category),
392
- }), parseFormat(options.format), options.output, { endpointKey: "insight.announcement.list", idField: "announcementId" });
167
+ }), parseOutputFormat(options.format), options.output, { endpointKey: "insight.announcement.list", idField: "announcementId" });
393
168
  });
394
169
  announcement.command("download").requiredOption("--announcement-id <id>").option("--file-type <number>", "File type: 1=PDF 2=Markdown", "1").option("--output <path>").action(async (options) => {
395
170
  const client = await createClient();
396
- const result = await client.call("insight.announcement.download", undefined, { announcementId: options.announcementId, fileType: Number(options.fileType) });
171
+ const result = await client.call("insight.announcement.download", undefined, { announcementId: options.announcementId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) });
397
172
  const title = options.output ? undefined : await resolveTitle(client, result, "insight.announcement.list", "announcementId", options.announcementId);
398
173
  await saveDownloadResult(result, `announcement-${options.announcementId}`, options.output ?? title);
399
174
  });
@@ -410,45 +185,45 @@ program.addCommand(insight);
410
185
  const quote = new Command("quote").description("Quote APIs");
411
186
  quote.command("day-kline").option("--security <code>", "Security code (A-share: .SH/.SZ/.BJ, or 'all' for full market)", collectList, []).option("--start-date <date>", "Start date (default: 1 year before end-date)").option("--end-date <date>", "End date (default: latest)").option("--limit <number>", "Max rows per request (default: 6000, max: 10000)").option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
412
187
  const client = await createClient();
413
- await printData(await client.call("quote.day-kline", { securityList: maybeArray(options.security), startDate: options.startDate, endDate: options.endDate, limit: options.limit ? Number(options.limit) : undefined, fieldList: maybeArray(options.field) }), parseFormat(options.format), options.output);
188
+ await printData(await client.call("quote.day-kline", { securityList: maybeArray(options.security), startDate: options.startDate, endDate: options.endDate, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
414
189
  });
415
190
  quote.command("day-kline-hk").option("--security <code>", "Security code (HK stock: .HK, or 'all' for full market)", collectList, []).option("--start-date <date>", "Start date (default: 1 year before end-date)").option("--end-date <date>", "End date (default: latest)").option("--limit <number>", "Max rows per request (default: 6000, max: 10000)").option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
416
191
  const client = await createClient();
417
- await printData(await client.call("quote.day-kline-hk", { securityList: maybeArray(options.security), startDate: options.startDate, endDate: options.endDate, limit: options.limit ? Number(options.limit) : undefined, fieldList: maybeArray(options.field) }), parseFormat(options.format), options.output);
192
+ await printData(await client.call("quote.day-kline-hk", { securityList: maybeArray(options.security), startDate: options.startDate, endDate: options.endDate, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
418
193
  });
419
194
  quote.command("minute-kline").option("--security <code>", "Security code (A-share only: .SH/.SZ/.BJ)").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss)").option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss)").option("--limit <number>", "Max rows per request (default: 5000, max: 10000)").option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
420
195
  const client = await createClient();
421
- await printData(await client.call("quote.minute-kline", { securityCode: options.security, startTime: options.startTime, endTime: options.endTime, limit: options.limit ? Number(options.limit) : undefined, fieldList: maybeArray(options.field) }), parseFormat(options.format), options.output);
196
+ await printData(await client.call("quote.minute-kline", { securityCode: options.security, startTime: options.startTime, endTime: options.endTime, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
422
197
  });
423
198
  program.addCommand(quote);
424
199
  const fundamental = new Command("fundamental").description("Fundamental APIs");
425
200
  fundamental.command("income-statement").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period", collectList, []).option("--report-type <type>", "Report type", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
426
201
  const client = await createClient();
427
- await printData(await client.call("fundamental.income-statement", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseFormat(options.format), options.output);
202
+ await printData(await client.call("fundamental.income-statement", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
428
203
  });
429
204
  fundamental.command("income-statement-quarterly").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period: q1/q2/q3/q4/latest", collectList, []).option("--report-type <type>", "Report type", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
430
205
  const client = await createClient();
431
- await printData(await client.call("fundamental.income-statement-quarterly", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseFormat(options.format), options.output);
206
+ await printData(await client.call("fundamental.income-statement-quarterly", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
432
207
  });
433
208
  fundamental.command("balance-sheet").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period", collectList, []).option("--report-type <type>", "Report type", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
434
209
  const client = await createClient();
435
- await printData(await client.call("fundamental.balance-sheet", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseFormat(options.format), options.output);
210
+ await printData(await client.call("fundamental.balance-sheet", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
436
211
  });
437
212
  fundamental.command("cash-flow").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period", collectList, []).option("--report-type <type>", "Report type", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
438
213
  const client = await createClient();
439
- await printData(await client.call("fundamental.cash-flow", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseFormat(options.format), options.output);
214
+ await printData(await client.call("fundamental.cash-flow", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
440
215
  });
441
216
  fundamental.command("cash-flow-quarterly").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period: q1/q2/q3/q4/latest", collectList, []).option("--report-type <type>", "Report type", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
442
217
  const client = await createClient();
443
- await printData(await client.call("fundamental.cash-flow-quarterly", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseFormat(options.format), options.output);
218
+ await printData(await client.call("fundamental.cash-flow-quarterly", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
444
219
  });
445
220
  fundamental.command("main-business").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").addOption(new Option("--breakdown <type>", "Breakdown: product/industry/region").choices(["product", "industry", "region"]).default("product")).option("--period <type>", "Period: interim/annual", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
446
221
  const client = await createClient();
447
- await printData(await client.call("fundamental.main-business", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, breakdown: options.breakdown, periodList: maybeArray(options.period), fieldList: maybeArray(options.field) }), parseFormat(options.format), options.output);
222
+ await printData(await client.call("fundamental.main-business", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, breakdown: options.breakdown, periodList: maybeArray(options.period), fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
448
223
  });
449
224
  fundamental.command("valuation-analysis").requiredOption("--security-code <code>").addOption(new Option("--indicator <name>", "Indicator").choices(["peTtm", "pbMrq", "peg", "psTtm", "pcfTtm", "em"]).makeOptionMandatory()).option("--start-date <date>").option("--end-date <date>").option("--limit <number>").option("--field <field>", "Field", collectList, []).option("--skip-null", "Drop rows where value or percentileRank is null").option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
450
225
  const client = await createClient();
451
- let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit: options.limit ? Number(options.limit) : undefined, fieldList: maybeArray(options.field) });
226
+ let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) });
452
227
  if (options.skipNull) {
453
228
  const normalized = await normalizeRows(data);
454
229
  if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
@@ -464,43 +239,43 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
464
239
  }
465
240
  }
466
241
  }
467
- await printData(data, parseFormat(options.format), options.output);
242
+ await printData(data, parseOutputFormat(options.format), options.output);
468
243
  });
469
244
  fundamental.command("top-holders").requiredOption("--security-code <code>").addOption(new Option("--holder-type <type>", "Holder type: top10/top10Float").choices(["top10", "top10Float"]).makeOptionMandatory()).option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period: q1/interim/q3/annual/latest", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
470
245
  const client = await createClient();
471
- await printData(await client.call("fundamental.top-holders", { securityCode: options.securityCode, holderType: options.holderType, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined }), parseFormat(options.format), options.output);
246
+ await printData(await client.call("fundamental.top-holders", { securityCode: options.securityCode, holderType: options.holderType, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined }), parseOutputFormat(options.format), options.output);
472
247
  });
473
248
  fundamental.command("earning-forecast").requiredOption("--security-code <code>").option("--start-date <date>", "Start date (default: 1 year before end-date)").option("--end-date <date>", "End date (default: today)").option("--consensus <name>", "Consensus indicator: netIncome/netIncomeYoy/eps/pe/bps/pb/peg/roe/ps", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
474
249
  const client = await createClient();
475
250
  const endDate = options.endDate ?? new Date().toISOString().slice(0, 10);
476
251
  const startDate = options.startDate ?? new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
477
- await printData(await client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) }), parseFormat(options.format), options.output);
252
+ await printData(await client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) }), parseOutputFormat(options.format), options.output);
478
253
  });
479
254
  program.addCommand(fundamental);
480
255
  const ai = new Command("ai").description("AI APIs");
481
256
  ai.command("knowledge-batch").requiredOption("--query <text>", "Query", collectList, []).option("--top <number>", "Top", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
482
257
  const client = await createClient();
483
- await printData(await client.call("ai.knowledge-batch", { queries: options.query, top: Number(options.top), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: options.startTime ? Number(options.startTime) : undefined, endTime: options.endTime ? Number(options.endTime) : undefined }), parseFormat(options.format), options.output);
258
+ await printData(await client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) }), parseOutputFormat(options.format), options.output);
484
259
  });
485
260
  ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action(async (options) => {
486
261
  const client = await createClient();
487
- await saveDownloadResult(await client.call("ai.knowledge-resource.download", undefined, { resourceType: Number(options.resourceType), sourceId: options.sourceId }), `resource-${options.sourceId}`, options.output);
262
+ await saveDownloadResult(await client.call("ai.knowledge-resource.download", undefined, { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }), `resource-${options.sourceId}`, options.output);
488
263
  });
489
264
  ai.command("security-clue").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").requiredOption("--start-time <datetime>").requiredOption("--end-time <datetime>").addOption(new Option("--query-mode <mode>").choices(["bySecurity", "byIndustry"]).makeOptionMandatory()).option("--gts-code <code>", "GTS code", collectList, []).option("--source <name>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
490
265
  const client = await createClient();
491
- await printData(await client.call("ai.security-clue.list", { from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size), startTime: options.startTime, endTime: options.endTime, queryMode: options.queryMode, gtsCodeList: maybeArray(options.gtsCode), source: maybeArray(options.source) }), parseFormat(options.format), options.output);
266
+ await printData(await client.call("ai.security-clue.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, queryMode: options.queryMode, gtsCodeList: maybeArray(options.gtsCode), source: maybeArray(options.source) }), parseOutputFormat(options.format), options.output);
492
267
  });
493
268
  ai.command("one-pager").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
494
269
  const client = await createClient();
495
- await printData(await client.call("ai.one-pager", { securityCode: options.securityCode }), parseFormat(options.format), options.output);
270
+ await printData(await client.call("ai.one-pager", { securityCode: options.securityCode }), parseOutputFormat(options.format), options.output);
496
271
  });
497
272
  ai.command("investment-logic").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
498
273
  const client = await createClient();
499
- await printData(await client.call("ai.investment-logic", { securityCode: options.securityCode }), parseFormat(options.format), options.output);
274
+ await printData(await client.call("ai.investment-logic", { securityCode: options.securityCode }), parseOutputFormat(options.format), options.output);
500
275
  });
501
276
  ai.command("peer-comparison").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
502
277
  const client = await createClient();
503
- await printData(await client.call("ai.peer-comparison", { securityCode: options.securityCode }), parseFormat(options.format), options.output);
278
+ await printData(await client.call("ai.peer-comparison", { securityCode: options.securityCode }), parseOutputFormat(options.format), options.output);
504
279
  });
505
280
  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) => {
506
281
  const client = await createClient();
@@ -517,36 +292,36 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
517
292
  return;
518
293
  }
519
294
  process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
520
- if (!await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, parseFormat(options.format), options.output)) {
295
+ if (!await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, parseOutputFormat(options.format), options.output)) {
521
296
  process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
522
297
  process.exitCode = 1;
523
298
  }
524
299
  });
525
300
  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) => {
526
301
  const client = await createClient();
527
- await checkAsyncContent(client, "ai.earnings-review.get-content", options.dataId, parseFormat(options.format), options.output);
302
+ await checkAsyncContent(client, "ai.earnings-review.get-content", options.dataId, parseOutputFormat(options.format), options.output);
528
303
  });
529
304
  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) => {
530
305
  const client = await createClient();
531
306
  const typeList = options.type.length ? options.type : undefined;
532
- await printData(await client.call("ai.theme-tracking", { themeId: options.themeId, date: options.date, type: typeList }), parseFormat(options.format), options.output);
307
+ await printData(await client.call("ai.theme-tracking", { themeId: options.themeId, date: options.date, type: typeList }), parseOutputFormat(options.format), options.output);
533
308
  });
534
309
  ai.command("research-outline").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
535
310
  const client = await createClient();
536
- await printData(await client.call("ai.research-outline", { securityCode: options.securityCode }), parseFormat(options.format), options.output);
311
+ await printData(await client.call("ai.research-outline", { securityCode: options.securityCode }), parseOutputFormat(options.format), options.output);
537
312
  });
538
313
  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) => {
539
314
  const client = await createClient();
540
315
  const ALL_CATEGORIES = ["morningBriefing", "noonBriefing", "afternoonFlash", "eveningBriefing"];
541
316
  await printData(await client.call("ai.hot-topic", {
542
- from: Number(options.from),
543
- size: options.size === undefined ? undefined : Number(options.size),
317
+ from: parseFrom(options.from),
318
+ size: parseSize(options.size),
544
319
  startDate: options.startDate,
545
320
  endDate: options.endDate,
546
321
  categoryList: options.category.length > 0 ? options.category : ALL_CATEGORIES,
547
322
  withRelatedSecurities: options.withRelatedSecurities === false ? undefined : true,
548
323
  withCloseReading: options.withCloseReading === false ? undefined : true,
549
- }), parseFormat(options.format), options.output);
324
+ }), parseOutputFormat(options.format), options.output);
550
325
  });
551
326
  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) => {
552
327
  const client = await createClient();
@@ -554,7 +329,7 @@ ai.command("management-discuss-announcement").requiredOption("--report-date <dat
554
329
  reportDate: options.reportDate,
555
330
  securityCode: options.securityCode,
556
331
  discussionDimension: options.dimension,
557
- }), parseFormat(options.format), options.output);
332
+ }), parseOutputFormat(options.format), options.output);
558
333
  });
559
334
  ai.command("management-discuss-earnings-call").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) => {
560
335
  const client = await createClient();
@@ -562,7 +337,7 @@ ai.command("management-discuss-earnings-call").requiredOption("--report-date <da
562
337
  reportDate: options.reportDate,
563
338
  securityCode: options.securityCode,
564
339
  discussionDimension: options.dimension,
565
- }), parseFormat(options.format), options.output);
340
+ }), parseOutputFormat(options.format), options.output);
566
341
  });
567
342
  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) => {
568
343
  const client = await createClient();
@@ -579,19 +354,19 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
579
354
  return;
580
355
  }
581
356
  process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
582
- if (!await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, parseFormat(options.format), options.output)) {
357
+ if (!await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, parseOutputFormat(options.format), options.output)) {
583
358
  process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
584
359
  process.exitCode = 1;
585
360
  }
586
361
  });
587
362
  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) => {
588
363
  const client = await createClient();
589
- await checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseFormat(options.format), options.output);
364
+ await checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseOutputFormat(options.format), options.output);
590
365
  });
591
366
  const vault = new Command("vault").description("Vault APIs");
592
367
  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) => {
593
368
  const client = await createClient();
594
- await printData(await client.call("vault.drive.list", { from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, fileTypeList: options.fileType.length ? options.fileType : undefined, spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), parseFormat(options.format), options.output, { endpointKey: "vault.drive.list", idField: "fileId" });
369
+ await printData(await client.call("vault.drive.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, fileTypeList: options.fileType.length ? options.fileType : undefined, spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), parseOutputFormat(options.format), options.output, { endpointKey: "vault.drive.list", idField: "fileId" });
595
370
  });
596
371
  vault.command("drive-download").requiredOption("--file-id <id>").option("--output <path>").action(async (options) => {
597
372
  const client = await createClient();
@@ -601,7 +376,7 @@ vault.command("drive-download").requiredOption("--file-id <id>").option("--outpu
601
376
  });
602
377
  vault.command("record-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("--category <name>", "Recording type: upload/link/mobile/gtNote/pc/share", collectList, []).option("--space-type <number>", "Space type: 1=my records / 2=tenant records", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
603
378
  const client = await createClient();
604
- await printData(await client.call("vault.record.list", { from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, categoryList: maybeArray(options.category), spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), parseFormat(options.format), options.output, { endpointKey: "vault.record.list", idField: "recordId" });
379
+ await printData(await client.call("vault.record.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, categoryList: maybeArray(options.category), spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), parseOutputFormat(options.format), options.output, { endpointKey: "vault.record.list", idField: "recordId" });
605
380
  });
606
381
  vault.command("record-download").requiredOption("--record-id <id>").requiredOption("--content-type <type>", "Content type: original/asr/summary").option("--output <path>").action(async (options) => {
607
382
  const client = await createClient();
@@ -611,7 +386,7 @@ vault.command("record-download").requiredOption("--record-id <id>").requiredOpti
611
386
  });
612
387
  vault.command("my-conference-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("--research-area <id>", "Research area ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Conference category: earningsCall/strategyMeeting/fundRoadshow/shareholdersMeeting/maMeeting/specialMeeting/companyAnalysis/industryAnalysis/other", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
613
388
  const client = await createClient();
614
- await printData(await client.call("vault.my-conference.list", { from: Number(options.from), size: options.size === undefined ? undefined : Number(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution), categoryList: maybeArray(options.category) }), parseFormat(options.format), options.output, { endpointKey: "vault.my-conference.list", idField: "conferenceId" });
389
+ await printData(await client.call("vault.my-conference.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution), categoryList: maybeArray(options.category) }), parseOutputFormat(options.format), options.output, { endpointKey: "vault.my-conference.list", idField: "conferenceId" });
615
390
  });
616
391
  vault.command("my-conference-download").requiredOption("--conference-id <id>").requiredOption("--content-type <type>", "Content type: asr/summary").option("--output <path>").action(async (options) => {
617
392
  const client = await createClient();
@@ -637,7 +412,7 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
637
412
  await saveDownloadResult(data, "download.bin", options.output);
638
413
  return;
639
414
  }
640
- await printData(data, parseFormat(options.format), options.output);
415
+ await printData(data, parseOutputFormat(options.format), options.output);
641
416
  }));
642
417
  async function main() {
643
418
  try {
@@ -1,3 +1,4 @@
1
+ import { ValidationError } from "./errors.js";
1
2
  export function splitCsv(value) {
2
3
  return value
3
4
  .split(",")
@@ -7,10 +8,35 @@ export function splitCsv(value) {
7
8
  export function collectList(value, previous = []) {
8
9
  return [...previous, ...splitCsv(value)];
9
10
  }
11
+ export function parseNumberOption(value, optionName, config = {}) {
12
+ if (value === undefined || String(value).trim() === "") {
13
+ throw new ValidationError(`Invalid ${optionName}: expected a number`);
14
+ }
15
+ const parsed = typeof value === "number" ? value : Number(value);
16
+ if (!Number.isFinite(parsed)) {
17
+ throw new ValidationError(`Invalid ${optionName}: expected a finite number`);
18
+ }
19
+ if (config.integer && !Number.isInteger(parsed)) {
20
+ throw new ValidationError(`Invalid ${optionName}: expected an integer`);
21
+ }
22
+ if (config.min !== undefined && parsed < config.min) {
23
+ throw new ValidationError(`Invalid ${optionName}: expected a number >= ${config.min}`);
24
+ }
25
+ return parsed;
26
+ }
27
+ export function parseOptionalNumberOption(value, optionName, config = {}) {
28
+ return value === undefined ? undefined : parseNumberOption(value, optionName, config);
29
+ }
30
+ export function parseFrom(value) {
31
+ return parseNumberOption(value ?? "0", "--from", { integer: true, min: 0 });
32
+ }
33
+ export function parseSize(value) {
34
+ return parseOptionalNumberOption(value, "--size", { integer: true, min: 1 });
35
+ }
10
36
  export function collectNumberList(value, previous = []) {
11
37
  return [
12
38
  ...previous,
13
- ...splitCsv(value).map((item) => Number(item)).filter((item) => !Number.isNaN(item)),
39
+ ...splitCsv(value).map((item) => parseNumberOption(item, "number list item")),
14
40
  ];
15
41
  }
16
42
  export function collectKeyValue(value, previous = {}) {
@@ -44,3 +70,12 @@ export function toTimestamp13(value) {
44
70
  return undefined;
45
71
  return ms;
46
72
  }
73
+ export function parseTimestamp13(value, optionName) {
74
+ if (value === undefined)
75
+ return undefined;
76
+ const parsed = toTimestamp13(value);
77
+ if (parsed === undefined) {
78
+ throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or date string`);
79
+ }
80
+ return parsed;
81
+ }
@@ -0,0 +1,50 @@
1
+ import { ApiError } from "./errors.js";
2
+ import { printData } from "./printer.js";
3
+ export const POLL_MAX_ATTEMPTS = 12;
4
+ export const POLL_DELAY_MS = 15_000;
5
+ function isAsyncPending(error) {
6
+ return error instanceof ApiError && error.code === "410110";
7
+ }
8
+ export async function pollAsyncContent(client, getContentEndpoint, dataId, format, output) {
9
+ for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
10
+ try {
11
+ const result = await client.call(getContentEndpoint, { dataId });
12
+ if (result?.content != null) {
13
+ await printData(result, format, output);
14
+ return true;
15
+ }
16
+ }
17
+ catch (error) {
18
+ if (error instanceof ApiError && error.code === "410111") {
19
+ process.stderr.write("Content generation failed (terminal). Do not retry.\n");
20
+ return false;
21
+ }
22
+ if (!isAsyncPending(error))
23
+ throw error;
24
+ }
25
+ if (attempt < POLL_MAX_ATTEMPTS) {
26
+ process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: content not ready, retrying in 15s...\n`);
27
+ await new Promise(resolve => setTimeout(resolve, POLL_DELAY_MS));
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+ export async function checkAsyncContent(client, getContentEndpoint, dataId, format, output) {
33
+ try {
34
+ const result = await client.call(getContentEndpoint, { dataId });
35
+ if (result?.content != null) {
36
+ await printData(result, format, output);
37
+ return;
38
+ }
39
+ }
40
+ catch (error) {
41
+ if (error instanceof ApiError && error.code === "410111") {
42
+ process.stderr.write("Content generation failed (terminal). Do not retry.\n");
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ if (!isAsyncPending(error))
47
+ throw error;
48
+ }
49
+ process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
50
+ }
@@ -37,8 +37,18 @@ export class GangtiseClient {
37
37
  });
38
38
  return accessToken;
39
39
  }
40
+ isEnvelope(parsed) {
41
+ return Boolean(parsed && typeof parsed === 'object' && 'code' in parsed);
42
+ }
43
+ throwHttpError(parsed, statusCode) {
44
+ if (this.isEnvelope(parsed)) {
45
+ const code = parsed.code === undefined ? undefined : String(parsed.code);
46
+ throw new ApiError(parsed.msg || `API request failed (HTTP ${statusCode})`, code, statusCode, parsed);
47
+ }
48
+ throw new ApiError(`API request failed (HTTP ${statusCode})`, undefined, statusCode, parsed);
49
+ }
40
50
  unwrapEnvelope(parsed, statusCode) {
41
- if (!parsed || typeof parsed !== 'object' || !('code' in parsed)) {
51
+ if (!this.isEnvelope(parsed)) {
42
52
  return parsed;
43
53
  }
44
54
  const code = parsed.code === undefined ? undefined : String(parsed.code);
@@ -167,15 +177,18 @@ export class GangtiseClient {
167
177
  bodyTimeout: this.config.timeoutMs,
168
178
  });
169
179
  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
- }
173
180
  let parsed;
174
181
  try {
175
182
  parsed = JSON.parse(text);
176
183
  }
177
184
  catch {
178
- throw new ApiError('Failed to parse API response', undefined, response.statusCode, text.slice(0, 500));
185
+ const message = response.statusCode >= 400
186
+ ? `API request failed (HTTP ${response.statusCode})`
187
+ : 'Failed to parse API response';
188
+ throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500));
189
+ }
190
+ if (response.statusCode >= 400) {
191
+ this.throwHttpError(parsed, response.statusCode);
179
192
  }
180
193
  return this.unwrapEnvelope(parsed, response.statusCode);
181
194
  }
@@ -206,6 +219,9 @@ export class GangtiseClient {
206
219
  }
207
220
  return { text, contentType };
208
221
  }
222
+ if (response.statusCode >= 400) {
223
+ this.throwHttpError(parsed, response.statusCode);
224
+ }
209
225
  const data = this.unwrapEnvelope(parsed, response.statusCode);
210
226
  if (data && typeof data === 'object' && 'url' in data && typeof data.url === 'string') {
211
227
  return { url: String(data.url), contentType };
@@ -8,7 +8,7 @@ export function loadConfig() {
8
8
  const timeoutMs = timeoutValue ? Number(timeoutValue) : DEFAULT_TIMEOUT_MS;
9
9
  return {
10
10
  baseUrl: process.env.GANGTISE_BASE_URL ?? DEFAULT_BASE_URL,
11
- timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : DEFAULT_TIMEOUT_MS,
11
+ timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS,
12
12
  accessKey: process.env.GANGTISE_ACCESS_KEY,
13
13
  secretKey: process.env.GANGTISE_SECRET_KEY,
14
14
  token: process.env.GANGTISE_TOKEN,
@@ -0,0 +1,96 @@
1
+ import { extname } from "node:path";
2
+ import { DownloadError } from "./errors.js";
3
+ import { saveOutputIfNeeded } from "./output.js";
4
+ import { lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE } from "./titleCache.js";
5
+ const MIME_EXT = {
6
+ "application/pdf": ".pdf",
7
+ "application/msword": ".doc",
8
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
9
+ "application/vnd.ms-excel": ".xls",
10
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
11
+ "application/vnd.ms-powerpoint": ".ppt",
12
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
13
+ "application/zip": ".zip",
14
+ "application/x-rar-compressed": ".rar",
15
+ "application/gzip": ".gz",
16
+ "application/x-7z-compressed": ".7z",
17
+ "application/json": ".json",
18
+ "application/xml": ".xml",
19
+ "text/plain": ".txt",
20
+ "text/html": ".html",
21
+ "text/csv": ".csv",
22
+ "image/png": ".png",
23
+ "image/jpeg": ".jpg",
24
+ "image/gif": ".gif",
25
+ "image/webp": ".webp",
26
+ "image/svg+xml": ".svg",
27
+ "audio/mpeg": ".mp3",
28
+ "video/mp4": ".mp4",
29
+ "application/octet-stream": ".bin",
30
+ };
31
+ export function extFromContentType(contentType) {
32
+ if (!contentType)
33
+ return "";
34
+ const mime = contentType.split(";")[0].trim().toLowerCase();
35
+ return MIME_EXT[mime] ?? "";
36
+ }
37
+ export async function resolveTitle(client, result, listEndpoint, idField, idValue, titleField = "title") {
38
+ const file = result;
39
+ const serverExt = file.filename ? extname(file.filename) : extFromContentType(file.contentType);
40
+ function buildFilename(rawTitle) {
41
+ let title = rawTitle.replace(/[/\\:*?"<>|]/g, "_").trim();
42
+ if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
43
+ title += serverExt;
44
+ }
45
+ return title;
46
+ }
47
+ try {
48
+ const cacheData = await readTitleCache();
49
+ const cached = lookupTitleCache(cacheData, listEndpoint, idValue);
50
+ if (cached)
51
+ return buildFilename(cached);
52
+ }
53
+ catch {
54
+ // Ignore corrupt cache data and fall back to the list endpoint.
55
+ }
56
+ try {
57
+ const resp = await client.call(listEndpoint, { from: 0, size: TITLE_LOOKUP_SIZE });
58
+ const items = Array.isArray(resp) ? resp : (resp.list ?? []);
59
+ const match = items.find(item => String(item[idField]) === String(idValue));
60
+ const rawTitle = match?.[titleField];
61
+ if (typeof rawTitle === "string" && rawTitle)
62
+ return buildFilename(rawTitle);
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ return undefined;
68
+ }
69
+ export async function saveDownloadResult(result, fallbackName, output) {
70
+ if (!(result && typeof result === "object")) {
71
+ throw new DownloadError("Unexpected download response");
72
+ }
73
+ const file = result;
74
+ if (file.data instanceof Uint8Array) {
75
+ const outputPath = output ?? file.filename ?? (fallbackName + extFromContentType(file.contentType));
76
+ await saveOutputIfNeeded(file.data, outputPath);
77
+ process.stdout.write(`${outputPath}\n`);
78
+ return;
79
+ }
80
+ if (typeof file.text === "string") {
81
+ const outputPath = output ?? `${fallbackName}.txt`;
82
+ await saveOutputIfNeeded(file.text, outputPath);
83
+ process.stdout.write(`${outputPath}\n`);
84
+ return;
85
+ }
86
+ if (typeof file.url === "string") {
87
+ if (output) {
88
+ await saveOutputIfNeeded(file.url, output);
89
+ process.stdout.write(`${output}\n`);
90
+ return;
91
+ }
92
+ process.stdout.write(`${file.url}\n`);
93
+ return;
94
+ }
95
+ throw new DownloadError("Unexpected download response");
96
+ }
@@ -1,4 +1,13 @@
1
1
  import fs from "node:fs/promises";
2
+ import { ConfigError } from "./errors.js";
3
+ const OUTPUT_FORMATS = ["table", "json", "jsonl", "csv", "markdown"];
4
+ export function parseOutputFormat(value) {
5
+ const format = value ?? "table";
6
+ if (OUTPUT_FORMATS.includes(format)) {
7
+ return format;
8
+ }
9
+ throw new ConfigError(`Unsupported format: ${format}`);
10
+ }
2
11
  function formatScalar(value) {
3
12
  if (value === null || value === undefined) {
4
13
  return "";
@@ -0,0 +1,31 @@
1
+ import { normalizeRows } from "./normalize.js";
2
+ import { renderOutput, saveOutputIfNeeded } from "./output.js";
3
+ import { extractTitles, writeTitleCache } from "./titleCache.js";
4
+ export async function printData(data, format, output, cache) {
5
+ const normalized = normalizeRows(data);
6
+ const items = Array.isArray(normalized)
7
+ ? normalized
8
+ : (normalized && typeof normalized === "object" && Array.isArray(normalized.list))
9
+ ? normalized.list
10
+ : null;
11
+ if (cache && items) {
12
+ const titles = extractTitles(items, cache);
13
+ if (Object.keys(titles).length > 0) {
14
+ writeTitleCache(cache.endpointKey, titles).catch(() => { });
15
+ }
16
+ }
17
+ if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
18
+ const meta = normalized;
19
+ if (typeof meta.total === "number" && format !== "json") {
20
+ const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
21
+ process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
22
+ }
23
+ }
24
+ const content = renderOutput(normalized, format);
25
+ if (output) {
26
+ await saveOutputIfNeeded(content, output);
27
+ process.stdout.write(`${output}\n`);
28
+ return;
29
+ }
30
+ process.stdout.write(`${content}\n`);
31
+ }
@@ -0,0 +1,46 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export const DEFAULT_TITLE_CACHE_PATH = path.join(os.homedir(), ".config", "gangtise", "title-cache.json");
5
+ export const TITLE_LOOKUP_SIZE = 200;
6
+ const TITLE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
7
+ export async function readTitleCache(filePath = DEFAULT_TITLE_CACHE_PATH) {
8
+ try {
9
+ const content = await fs.readFile(filePath, "utf8");
10
+ const parsed = JSON.parse(content);
11
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
12
+ return parsed;
13
+ }
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ return {};
19
+ }
20
+ export async function writeTitleCache(endpoint, titles, filePath = DEFAULT_TITLE_CACHE_PATH) {
21
+ const data = await readTitleCache(filePath);
22
+ data[endpoint] = { titles, ts: Date.now() };
23
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
24
+ await fs.writeFile(filePath, JSON.stringify(data), { encoding: "utf8", mode: 0o600 });
25
+ }
26
+ export function lookupTitleCache(data, endpoint, id) {
27
+ const entry = data[endpoint];
28
+ if (!entry || Date.now() - entry.ts > TITLE_CACHE_TTL_MS)
29
+ return undefined;
30
+ return entry.titles[id];
31
+ }
32
+ export function extractTitles(items, cache) {
33
+ const titleField = cache.titleField ?? "title";
34
+ const titles = {};
35
+ for (const row of items) {
36
+ if (!row || typeof row !== "object")
37
+ continue;
38
+ const record = row;
39
+ const id = record[cache.idField];
40
+ const title = record[titleField];
41
+ if (id != null && typeof title === "string" && title) {
42
+ titles[String(id)] = title;
43
+ }
44
+ }
45
+ return titles;
46
+ }
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.10.9";
2
+ export const CLI_VERSION = "0.10.10";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.10.9",
3
+ "version": "0.10.10",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {