gangtise-openapi-cli 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +77 -49
  2. package/dist/src/cli.js +29 -8
  3. package/dist/src/commands/ai.js +12 -12
  4. package/dist/src/commands/alternative.js +2 -2
  5. package/dist/src/commands/bond.js +4 -4
  6. package/dist/src/commands/fund.js +1 -1
  7. package/dist/src/commands/fundamental.js +11 -11
  8. package/dist/src/commands/indicator.js +5 -5
  9. package/dist/src/commands/insight.js +38 -17
  10. package/dist/src/commands/quote.js +6 -6
  11. package/dist/src/commands/raw.js +3 -3
  12. package/dist/src/commands/reference.js +12 -3
  13. package/dist/src/commands/shared.js +20 -8
  14. package/dist/src/commands/tool.js +9 -4
  15. package/dist/src/commands/update.js +11 -0
  16. package/dist/src/commands/vault.js +15 -15
  17. package/dist/src/core/client.js +18 -2
  18. package/dist/src/core/download.js +12 -3
  19. package/dist/src/core/driveUpload.js +6 -2
  20. package/dist/src/core/dryRun.js +68 -0
  21. package/dist/src/core/endpoints.js +61 -6
  22. package/dist/src/core/etfComponents.js +45 -8
  23. package/dist/src/core/exitStatus.js +4 -0
  24. package/dist/src/core/fileParse.js +10 -3
  25. package/dist/src/core/printer.js +39 -7
  26. package/dist/src/core/statePaths.js +41 -0
  27. package/dist/src/core/transport.js +7 -0
  28. package/gangtise-openapi/SKILL.md +34 -154
  29. package/gangtise-openapi/references/billing.md +142 -0
  30. package/gangtise-openapi/references/commands/fund.md +3 -3
  31. package/gangtise-openapi/references/commands/fundamental.md +1 -0
  32. package/gangtise-openapi/references/commands/indicator.md +51 -8
  33. package/gangtise-openapi/references/commands/insight.md +31 -6
  34. package/gangtise-openapi/references/commands/reference-and-lookup.md +22 -4
  35. package/gangtise-openapi/references/commands/tool.md +2 -2
  36. package/gangtise-openapi/references/commands/vault.md +1 -1
  37. package/gangtise-openapi/references/errors.md +2 -2
  38. package/gangtise-openapi/references/examples.md +3 -3
  39. package/gangtise-openapi/references/lookup-ids.md +13 -0
  40. package/gangtise-openapi/references/response-schema.md +4 -1
  41. package/package.json +2 -2
@@ -3,13 +3,13 @@ import { collectList, collectNames, parseFrom, parseSize } from "../core/args.js
3
3
  import { uploadDriveFile } from "../core/driveUpload.js";
4
4
  import { ValidationError } from "../core/errors.js";
5
5
  import { flagFailedItems } from "../core/normalize.js";
6
- import { emit, addDownloadCommand, assertConfirmed, field, value, required, requiredList, list, numberList, format, output, from, size, startTime, endTime, query, RESEARCH_AREA_CITIC_OR_DIRECTION } from "./shared.js";
6
+ import { emit, addDownloadCommand, assertConfirmed, field, value, required, requiredList, list, numberList, format, output, from, size, startTime, endTime, query, RESEARCH_AREA_CITIC_OR_DIRECTION, OUTPUT_HELP } from "./shared.js";
7
7
  export const vault = new Command("vault").description("Vault APIs");
8
8
  query(vault, "drive-list", {
9
9
  endpoint: "vault.drive.list",
10
10
  cache: { endpointKey: "vault.drive.list", idField: "fileId" },
11
11
  fields: [
12
- from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
12
+ from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
13
13
  numberList("--file-type <number>", "File type", "fileTypeList"),
14
14
  numberList("--space-type <number>", "Space type", "spaceTypeList"),
15
15
  format(), output(),
@@ -32,7 +32,7 @@ vault.command("drive-upload").description("Upload a file to the AI drive (free;
32
32
  .addOption(driveSpaceOption("Target space: 1=my drive 2=tenant drive (shared with your whole tenant)"))
33
33
  .option("--folder-id <id>", "Target folder ID in that space; omit (or 'root') for the space's root")
34
34
  .option("--title <name>", "Name in the drive, max 200 characters; defaults to the local file name")
35
- .option("--format <format>", "Output format", "json").option("--output <path>")
35
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
36
36
  .action((options) => emit(options, (client) => uploadDriveFile(client, options.file, { spaceType: Number(options.spaceType), folderId: options.folderId, title: options.title })));
37
37
  query(vault, "drive-create-folder", {
38
38
  description: "Create a drive folder (free). Same-name folders are allowed, so re-running creates a second one",
@@ -57,7 +57,7 @@ query(vault, "drive-rename", {
57
57
  vault.command("drive-move-file").description("Move drive files into a folder of the SAME space (free)")
58
58
  .requiredOption("--file-id <id>", "File ID (repeat or comma-separate)", collectList)
59
59
  .requiredOption("--target-folder-id <id>", "Destination folder ID, or 'root'. Files of the other space land in failList (空间不一致)")
60
- .option("--format <format>", "Output format", "json").option("--output <path>")
60
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
61
61
  .action((options) => emit(options, async (client) => {
62
62
  const data = await client.call("vault.drive.move-file", { fileIdList: options.fileId, targetFolderId: options.targetFolderId });
63
63
  flagFailedItems(data, "vault drive-move-file");
@@ -77,7 +77,7 @@ vault.command("drive-copy").description("Copy files, or one folder with everythi
77
77
  .option("--target-folder-id <id>", "Destination folder ID in the other space, or 'root' for that space's root")
78
78
  .option("--folder-id <id>", "Source folder ID; use with --target-parent-id")
79
79
  .option("--target-parent-id <id>", "Destination parent folder ID in the other space, or 'root' for that space's root")
80
- .option("--format <format>", "Output format", "json").option("--output <path>")
80
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
81
81
  .action((options) => {
82
82
  const byFile = options.fileId.length > 0 || options.targetFolderId !== undefined;
83
83
  const byFolder = options.folderId !== undefined || options.targetParentId !== undefined;
@@ -101,7 +101,7 @@ vault.command("drive-copy").description("Copy files, or one folder with everythi
101
101
  vault.command("drive-delete-file").description("Delete drive files — irreversible (free)")
102
102
  .requiredOption("--file-id <id>", "File ID to delete (repeat or comma-separate)", collectList)
103
103
  .option("--yes", "Required: confirm the deletion")
104
- .option("--format <format>", "Output format", "json").option("--output <path>")
104
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
105
105
  .action(async (options) => {
106
106
  assertConfirmed("vault.drive.delete-file", Boolean(options.yes), options.fileId.join("、"));
107
107
  await emit(options, async (client) => {
@@ -113,7 +113,7 @@ vault.command("drive-delete-file").description("Delete drive files — irreversi
113
113
  vault.command("drive-delete-folder").description("Delete a drive folder AND every subfolder and file inside it — irreversible (free)")
114
114
  .requiredOption("--folder-id <id>", "Folder ID to delete")
115
115
  .option("--yes", "Required: confirm the deletion")
116
- .option("--format <format>", "Output format", "json").option("--output <path>")
116
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
117
117
  .action(async (options) => {
118
118
  assertConfirmed("vault.drive.delete-folder", Boolean(options.yes), options.folderId);
119
119
  await emit(options, (client) => client.call("vault.drive.delete-folder", { folderId: options.folderId }));
@@ -122,7 +122,7 @@ query(vault, "record-list", {
122
122
  endpoint: "vault.record.list",
123
123
  cache: { endpointKey: "vault.record.list", idField: "recordId" },
124
124
  fields: [
125
- from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
125
+ from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
126
126
  list("--category <name>", "Recording type: upload/link/mobile/gtNote/pc/share", "categoryList"),
127
127
  numberList("--space-type <number>", "Space type: 1=my records / 2=tenant records", "spaceTypeList"),
128
128
  format(), output(),
@@ -133,7 +133,7 @@ query(vault, "my-conference-list", {
133
133
  endpoint: "vault.my-conference.list",
134
134
  cache: { endpointKey: "vault.my-conference.list", idField: "conferenceId" },
135
135
  fields: [
136
- from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
136
+ from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
137
137
  list("--research-area <id>", RESEARCH_AREA_CITIC_OR_DIRECTION, "researchAreaList"),
138
138
  list("--security <code>", "Security code", "securityList"),
139
139
  list("--institution <id>", "Institution ID", "institutionList"),
@@ -146,7 +146,7 @@ addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "
146
146
  query(vault, "wechat-message-list", {
147
147
  endpoint: "vault.wechat-message.list",
148
148
  fields: [
149
- from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
149
+ from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
150
150
  list("--security <code>", "Security code (e.g. 000001.SZ)", "securityList"),
151
151
  list("--wechat-group-id <id>", "WeChat group ID", "wechatGroupIdList"),
152
152
  list("--industry <id>", "Industry ID -- citicIndustry codes (1008001xx) only; swIndustry codes and unknown values are rejected with 100005", "industryIdList"),
@@ -193,7 +193,7 @@ vault.command("stock-pool-add-stock")
193
193
  .requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'")
194
194
  .requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList)
195
195
  .option("--format <format>", "Output format", "table")
196
- .option("--output <path>")
196
+ .option("--output <path>", OUTPUT_HELP)
197
197
  .action((options) => emit(options, async (client) => {
198
198
  const data = await client.call("vault.stock-pool.add-stock", { poolId: options.poolId, securityCodeList: options.security });
199
199
  flagFailedItems(data, "vault stock-pool-add-stock");
@@ -203,7 +203,7 @@ vault.command("stock-pool-remove-stock")
203
203
  .requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'")
204
204
  .requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList)
205
205
  .option("--format <format>", "Output format", "table")
206
- .option("--output <path>")
206
+ .option("--output <path>", OUTPUT_HELP)
207
207
  .action((options) => emit(options, async (client) => {
208
208
  const data = await client.call("vault.stock-pool.remove-stock", { poolId: options.poolId, securityCodeList: options.security });
209
209
  flagFailedItems(data, "vault stock-pool-remove-stock");
@@ -213,7 +213,7 @@ vault.command("stock-pool-delete")
213
213
  .requiredOption("--pool-id <id>", "Pool ID to delete; repeat or comma-separate for multiple", collectList)
214
214
  .option("--yes", "Required: confirm the deletion")
215
215
  .option("--format <format>", "Output format", "table")
216
- .option("--output <path>")
216
+ .option("--output <path>", OUTPUT_HELP)
217
217
  .action(async (options) => {
218
218
  assertConfirmed("vault.stock-pool.delete", Boolean(options.yes), options.poolId.join("、"));
219
219
  await emit(options, async (client) => {
@@ -227,7 +227,7 @@ vault.command("stock-note-list").description("List your stock notes: all, by sec
227
227
  .option("--note-id <id>", "Note ID (repeat or comma-separate); not together with --security", collectList)
228
228
  .option("--from <number>", "Starting offset", "0")
229
229
  .option("--size <number>", "Total rows to return; omit to fetch all")
230
- .option("--format <format>", "Output format", "json").option("--output <path>")
230
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
231
231
  .action((options) => {
232
232
  if (options.security?.length && options.noteId?.length)
233
233
  throw new ValidationError("pass --security or --note-id, not both");
@@ -256,7 +256,7 @@ query(vault, "stock-note-update", {
256
256
  vault.command("stock-note-delete").description("Delete stock notes — irreversible (free)")
257
257
  .requiredOption("--note-id <id>", "Note ID to delete; repeat or comma-separate, max 100", collectList)
258
258
  .option("--yes", "Required: confirm the deletion")
259
- .option("--format <format>", "Output format", "json").option("--output <path>")
259
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
260
260
  .action(async (options) => {
261
261
  assertConfirmed("vault.stock-note.delete", Boolean(options.yes), options.noteId.join("、"));
262
262
  await emit(options, async (client) => {
@@ -9,13 +9,16 @@ import { ApiError, attachEnvelopeTraceId, markStructural, ValidationError } from
9
9
  import { COSTLY_FETCH_CREDITS, ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
10
10
  import { getLookupData } from "./lookupData/index.js";
11
11
  import { stagingPath } from "./output.js";
12
- import { decodeResponseBody, getDispatcher, isVerbose, JSON_TOTAL_FACTOR, logTiming, markAuthReplay, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, TRANSFER_TOTAL_FACTOR, withRetry } from "./transport.js";
12
+ import { decodeResponseBody, getDispatcher, isVerbose, JSON_TOTAL_FACTOR, logTiming, markAuthReplay, noteRequestSent, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, TRANSFER_TOTAL_FACTOR, withRetry } from "./transport.js";
13
13
  import { attachRowSink } from "./rowSink.js";
14
14
  import { markIncomplete } from "./exitStatus.js";
15
+ import { isDryRun, stopForDryRun } from "./dryRun.js";
15
16
  const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
16
17
  function hasExpectedShape(expects, payload) {
17
18
  if (expects === "array")
18
19
  return Array.isArray(payload);
20
+ if (expects === "object")
21
+ return Boolean(payload && typeof payload === "object" && !Array.isArray(payload));
19
22
  return Boolean(payload && typeof payload === "object" && Array.isArray(payload.list));
20
23
  }
21
24
  const PROBE_ABOVE_CREDITS = 50;
@@ -79,7 +82,7 @@ export class GangtiseClient {
79
82
  return this.refreshPromise;
80
83
  }
81
84
  async doTokenRefresh() {
82
- const credentials = requireAccessCredentials(this.config.accessKey, this.config.secretKey);
85
+ const credentials = isDryRun() ? { accessKey: "", secretKey: "" } : requireAccessCredentials(this.config.accessKey, this.config.secretKey);
83
86
  const envelope = await this.requestJson(ENDPOINTS["auth.login"], {
84
87
  accessKey: credentials.accessKey,
85
88
  secretKey: credentials.secretKey,
@@ -472,6 +475,8 @@ export class GangtiseClient {
472
475
  if (endpoint.path.startsWith('/guide/')) {
473
476
  return this.readLocalLookup(endpoint);
474
477
  }
478
+ if (isDryRun())
479
+ return stopForDryRun({ endpoint, url: this.buildUrl(endpoint.path), body });
475
480
  const dispatcher = getDispatcher();
476
481
  const url = this.buildUrl(endpoint.path);
477
482
  const authState = {};
@@ -488,6 +493,7 @@ export class GangtiseClient {
488
493
  usedAuthorization = await this.getAuthorizationHeader();
489
494
  headers.Authorization = usedAuthorization;
490
495
  }
496
+ noteRequestSent();
491
497
  const startedAt = Date.now();
492
498
  const response = await request(url, {
493
499
  method: endpoint.method,
@@ -562,6 +568,10 @@ export class GangtiseClient {
562
568
  if (!endpoint || endpoint.kind !== 'upload') {
563
569
  throw new ApiError(`Not an upload endpoint: ${endpointKey}`);
564
570
  }
571
+ if (isDryRun()) {
572
+ const sent = Object.fromEntries(Object.entries(fields ?? {}).filter(([, value]) => value !== undefined));
573
+ return stopForDryRun({ endpoint, url: this.buildUrl(endpoint.path), body: sent, file: { name: file.filename, bytes: file.data.byteLength } });
574
+ }
565
575
  const form = new FormData();
566
576
  form.append('file', new Blob([file.data], { type: file.contentType ?? 'application/octet-stream' }), file.filename);
567
577
  for (const [name, value] of Object.entries(fields ?? {})) {
@@ -580,6 +590,7 @@ export class GangtiseClient {
580
590
  const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
581
591
  return withRetry(async () => {
582
592
  const authorization = await this.getAuthorizationHeader();
593
+ noteRequestSent();
583
594
  const startedAt = Date.now();
584
595
  const signal = AbortSignal.timeout(timeoutMs * TRANSFER_TOTAL_FACTOR);
585
596
  let currentUrl = url;
@@ -731,6 +742,11 @@ export class GangtiseClient {
731
742
  if (endpoint.kind === 'upload') {
732
743
  throw new ValidationError(`${endpointKey} takes a file upload — use its dedicated command ('gangtise tool file-parse --file <path>' / 'gangtise vault drive-upload --file <path>'); 'raw call' cannot send files`);
733
744
  }
745
+ if (isDryRun() && !endpoint.path.startsWith('/guide/')) {
746
+ const url = this.buildUrl(endpoint.path);
747
+ Object.entries(query ?? {}).forEach(([key, value]) => url.searchParams.set(key, String(value)));
748
+ return stopForDryRun({ endpoint, url, body });
749
+ }
734
750
  if (endpoint.kind === 'download') {
735
751
  return this.download(endpoint, query ?? {}, options, body);
736
752
  }
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
- import { dirname, extname } from "node:path";
3
- import { DownloadError } from "./errors.js";
2
+ import { dirname, extname, join, sep } from "node:path";
3
+ import { DownloadError, ValidationError } from "./errors.js";
4
4
  import { saveOutputIfNeeded, stagingPath, trackClaim } from "./output.js";
5
+ import { assertNotStatePath } from "./statePaths.js";
5
6
  import { extractTitles, lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE, writeTitleCache } from "./titleCache.js";
6
7
  function sanitizeFilename(name) {
7
8
  return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
@@ -147,7 +148,7 @@ async function downloadUrlTo(url, outputPath) {
147
148
  const { pipeline } = await import("node:stream/promises");
148
149
  const { dirname } = await import("node:path");
149
150
  const { request } = await import("undici");
150
- const { getDispatcher, logTiming, TRANSFER_TOTAL_FACTOR, withRetry } = await import("./transport.js");
151
+ const { getDispatcher, logTiming, noteRequestSent, TRANSFER_TOTAL_FACTOR, withRetry } = await import("./transport.js");
151
152
  const { loadConfig } = await import("./config.js");
152
153
  const timeoutMs = loadConfig().timeoutMs;
153
154
  await fs.mkdir(dirname(outputPath), { recursive: true });
@@ -162,6 +163,7 @@ async function downloadUrlTo(url, outputPath) {
162
163
  };
163
164
  const partPath = stagingPath(outputPath);
164
165
  await withRetry(async () => {
166
+ noteRequestSent();
165
167
  const startedAt = Date.now();
166
168
  const requestOptions = {
167
169
  method: "GET",
@@ -206,6 +208,13 @@ async function downloadUrlTo(url, outputPath) {
206
208
  throw error;
207
209
  }
208
210
  }
211
+ export async function assertOutputFilePath(output) {
212
+ const endsAsDirectory = output.endsWith("/") || output.endsWith(sep);
213
+ if (endsAsDirectory || (await fs.stat(output).catch(() => undefined))?.isDirectory()) {
214
+ throw new ValidationError(`--output ${output} is a directory: pass a file path (e.g. ${join(output, "<name>")}), or omit --output for an automatic name in the current directory`);
215
+ }
216
+ await assertNotStatePath(output, "--output");
217
+ }
209
218
  export function autoDownloadName(file, fallbackName) {
210
219
  return truncateFilename(file.filename ? sanitizeFilename(file.filename) : sanitizeFilename(fallbackName) + extFromContentType(file.contentType));
211
220
  }
@@ -1,12 +1,15 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { ValidationError } from "./errors.js";
4
+ import { assertNotStatePath } from "./statePaths.js";
4
5
  export const DRIVE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
5
6
  export const DRIVE_NAME_MAX = 200;
6
7
  export async function uploadDriveFile(client, filePath, options) {
7
- const resolved = path.resolve(filePath);
8
+ const shownAs = path.resolve(filePath);
9
+ let resolved;
8
10
  let stat;
9
11
  try {
12
+ resolved = await fs.realpath(filePath);
10
13
  stat = await fs.stat(resolved);
11
14
  }
12
15
  catch {
@@ -14,12 +17,13 @@ export async function uploadDriveFile(client, filePath, options) {
14
17
  }
15
18
  if (!stat.isFile())
16
19
  throw new ValidationError(`Not a file: ${filePath}`);
20
+ await assertNotStatePath(resolved, "--file");
17
21
  if (stat.size === 0)
18
22
  throw new ValidationError(`File is empty: ${filePath}`);
19
23
  if (stat.size > DRIVE_UPLOAD_MAX_BYTES) {
20
24
  throw new ValidationError(`File is ${(stat.size / 1024 / 1024).toFixed(1)}MB — the drive accepts at most 100MB per file`);
21
25
  }
22
- const filename = path.basename(resolved);
26
+ const filename = path.basename(shownAs);
23
27
  const name = options.title ?? filename;
24
28
  if (name.length > DRIVE_NAME_MAX) {
25
29
  throw new ValidationError(`File name is ${name.length} characters — the drive accepts at most ${DRIVE_NAME_MAX} (an emoji counts as 2); pass a shorter --title`);
@@ -0,0 +1,68 @@
1
+ import { removeStagingFiles } from "./output.js";
2
+ let dryRun = false;
3
+ let shown = false;
4
+ export function setDryRun(enabled) {
5
+ dryRun = enabled;
6
+ }
7
+ export function isDryRun() {
8
+ return dryRun;
9
+ }
10
+ const toCents = (credits) => Math.round(credits * 100) / 100;
11
+ function maxCredits(endpoint, body) {
12
+ const billing = endpoint.billing;
13
+ if (!billing)
14
+ return undefined;
15
+ const fields = body && typeof body === "object" ? body : {};
16
+ if (billing.per === "call" || billing.per === "document")
17
+ return billing.price;
18
+ if (endpoint.estimateBilledUnits) {
19
+ try {
20
+ return toCents(endpoint.estimateBilledUnits(fields) * billing.price);
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ if (billing.per === "row" && endpoint.pagination?.enabled)
27
+ return typeof fields.size === "number" ? toCents(fields.size * billing.price) : undefined;
28
+ if (!billing.maxUnits)
29
+ return undefined;
30
+ const asked = billing.unitsFrom?.reduce((n, key) => n + (Array.isArray(fields[key]) ? fields[key].length : 0), 0);
31
+ return toCents(Math.min(billing.maxUnits, asked || billing.maxUnits) * billing.price);
32
+ }
33
+ export function describeRequest(plan) {
34
+ const { endpoint } = plan;
35
+ return {
36
+ dryRun: true,
37
+ endpoint: endpoint.key,
38
+ method: endpoint.method,
39
+ url: plan.url.toString(),
40
+ body: endpoint.key === "auth.login" ? "(credentials not shown)" : plan.body ?? null,
41
+ file: plan.file,
42
+ pagination: endpoint.pagination?.enabled
43
+ ? `The CLI pages this list itself, at most ${endpoint.pagination.maxPageSize} rows a request, until size rows or the total; without size it fetches every row`
44
+ : undefined,
45
+ billing: endpoint.billing
46
+ ? { per: endpoint.billing.per, price: endpoint.billing.price, ...(Number.isFinite(endpoint.billing.maxUnits) ? { maxUnits: endpoint.billing.maxUnits } : {}) }
47
+ : null,
48
+ maxCredits: maxCredits(endpoint, plan.body),
49
+ needsYes: endpoint.destructive ? true : undefined,
50
+ note: "Nothing was sent. Only the command's first request is shown; any later request depends on its answer.",
51
+ };
52
+ }
53
+ export function describeNoRequest(output) {
54
+ return {
55
+ dryRun: true,
56
+ endpoint: null,
57
+ output: output ?? null,
58
+ note: "This command sends no request. Under --dry-run it prints only this and writes no file.",
59
+ };
60
+ }
61
+ export function stopForDryRun(plan) {
62
+ if (!shown) {
63
+ shown = true;
64
+ removeStagingFiles();
65
+ process.stdout.write(`${JSON.stringify(describeRequest(plan), null, 2)}\n`, () => process.exit(0));
66
+ }
67
+ return new Promise(() => { });
68
+ }
@@ -71,7 +71,7 @@ const ENDPOINT_DEFS = {
71
71
  path: "/application/open-insight/chief-opinion/getDetail",
72
72
  kind: "json",
73
73
  description: "Get domestic chief opinion bodies by ID (max 20 IDs per call)",
74
- billing: { per: "row", price: 30, maxUnits: 20 },
74
+ billing: { per: "row", price: 30, maxUnits: 20, unitsFrom: ["chiefOpinionIdList"] },
75
75
  retry: "no-replay",
76
76
  expects: "array",
77
77
  },
@@ -267,7 +267,7 @@ const ENDPOINT_DEFS = {
267
267
  path: "/application/open-insight/foreign-opinion/getDetail",
268
268
  kind: "json",
269
269
  description: "Get foreign opinion bodies by ID (max 20 IDs per call)",
270
- billing: { per: "row", price: 30, maxUnits: 20 },
270
+ billing: { per: "row", price: 30, maxUnits: 20, unitsFrom: ["foreignOpinionIdList"] },
271
271
  retry: "no-replay",
272
272
  expects: "array",
273
273
  },
@@ -288,6 +288,25 @@ const ENDPOINT_DEFS = {
288
288
  description: "Download foreign independent opinion file",
289
289
  billing: { per: "document", price: 30 },
290
290
  },
291
+ "insight.overseas-opinion.list": {
292
+ method: "POST",
293
+ path: "/application/open-insight/overseasOpinion/getList",
294
+ kind: "json",
295
+ description: "List overseas opinions (brief only; body via detail)",
296
+ billing: { per: "row", price: 0.1 },
297
+ pagination: { enabled: true, maxPageSize: 50, maxWindow: 10000 },
298
+ rowId: "overseasOpinionId",
299
+ expects: "list",
300
+ },
301
+ "insight.overseas-opinion.detail": {
302
+ method: "POST",
303
+ path: "/application/open-insight/overseasOpinion/getDetail",
304
+ kind: "json",
305
+ description: "Get overseas opinion bodies by ID (max 20 IDs per call)",
306
+ billing: { per: "row", price: 10, maxUnits: 20, unitsFrom: ["overseasOpinionIdList"] },
307
+ retry: "no-replay",
308
+ expects: "array",
309
+ },
291
310
  "insight.official-account.list": {
292
311
  method: "POST",
293
312
  path: "/application/open-insight/officialAccount/getList",
@@ -317,6 +336,7 @@ const ENDPOINT_DEFS = {
317
336
  path: "/application/open-insight/report-image/getList",
318
337
  kind: "json",
319
338
  description: "Search research report images by keyword (returns chunkId + metadata)",
339
+ expects: "array",
320
340
  },
321
341
  "insight.highlight.list": {
322
342
  method: "POST",
@@ -341,54 +361,72 @@ const ENDPOINT_DEFS = {
341
361
  path: "/application/open-reference/securities/search",
342
362
  kind: "json",
343
363
  description: "Search GTS codes (securities)",
364
+ expects: "list",
344
365
  },
345
366
  "reference.chiefs-search": {
346
367
  method: "POST",
347
368
  path: "/application/open-reference/chiefs/search",
348
369
  kind: "json",
349
370
  description: "Search chief analyst IDs by name / institution / team",
371
+ expects: "list",
350
372
  },
351
373
  "reference.institution-search": {
352
374
  method: "POST",
353
375
  path: "/application/open-reference/institutions/search",
354
376
  kind: "json",
355
377
  description: "Search institution IDs by keyword (domestic broker / foreign / lead / opinion institution)",
378
+ expects: "list",
356
379
  },
357
380
  "reference.official-account-search": {
358
381
  method: "POST",
359
382
  path: "/application/open-reference/officialAccount/search",
360
383
  kind: "json",
361
384
  description: "Search official account (WeChat public account) IDs by name / institution / category",
385
+ expects: "list",
386
+ },
387
+ "reference.overseas-account-search": {
388
+ method: "POST",
389
+ path: "/application/open-reference/overseasAccount/search",
390
+ kind: "json",
391
+ description: "Search overseas opinion account IDs by name fragment",
392
+ pagination: { enabled: true, maxPageSize: 50 },
393
+ rowId: "accountId",
394
+ expects: "list",
362
395
  },
363
396
  "reference.constant-category": {
364
397
  method: "GET",
365
398
  path: "/application/open-reference/constants/category",
366
399
  kind: "json",
367
400
  description: "List constant categories and their API usage scopes",
401
+ expects: "list",
368
402
  },
369
403
  "reference.constant-list": {
370
404
  method: "POST",
371
405
  path: "/application/open-reference/constants/getList",
372
406
  kind: "json",
373
407
  description: "List all constant values of a category",
408
+ expects: "object",
374
409
  },
375
410
  "reference.concept-search": {
376
411
  method: "POST",
377
412
  path: "/application/open-reference/concepts/search",
378
413
  kind: "json",
379
414
  description: "Search concept (theme) IDs by keyword",
415
+ expects: "list",
380
416
  },
381
417
  "reference.sector-search": {
382
418
  method: "POST",
383
419
  path: "/application/open-reference/sectors/search",
384
420
  kind: "json",
385
421
  description: "Search sector IDs by keyword",
422
+ expects: "list",
386
423
  },
387
424
  "reference.sector-constituents": {
388
425
  method: "POST",
389
426
  path: "/application/open-reference/sectors/constituents",
390
427
  kind: "json",
391
428
  description: "List constituent securities of a sector",
429
+ expects: "list",
392
430
  },
393
431
  "reference.index-weight": {
394
432
  method: "POST",
@@ -458,72 +496,84 @@ const ENDPOINT_DEFS = {
458
496
  path: "/application/open-fundamental/financial-report/income-statement/accumulated",
459
497
  kind: "json",
460
498
  description: "Query A-share income statement (accumulated)",
499
+ expects: "list",
461
500
  },
462
501
  "fundamental.income-statement-quarterly": {
463
502
  method: "POST",
464
503
  path: "/application/open-fundamental/financial-report/income-statement/quarterly",
465
504
  kind: "json",
466
505
  description: "Query A-share income statement (quarterly)",
506
+ expects: "list",
467
507
  },
468
508
  "fundamental.balance-sheet": {
469
509
  method: "POST",
470
510
  path: "/application/open-fundamental/financial-report/balance-sheet/accumulated",
471
511
  kind: "json",
472
512
  description: "Query A-share balance sheet (accumulated)",
513
+ expects: "list",
473
514
  },
474
515
  "fundamental.cash-flow": {
475
516
  method: "POST",
476
517
  path: "/application/open-fundamental/financial-report/cash-flow-statement/accumulated",
477
518
  kind: "json",
478
519
  description: "Query A-share cash flow statement (accumulated)",
520
+ expects: "list",
479
521
  },
480
522
  "fundamental.cash-flow-quarterly": {
481
523
  method: "POST",
482
524
  path: "/application/open-fundamental/financial-report/cash-flow-statement/quarterly",
483
525
  kind: "json",
484
526
  description: "Query A-share cash flow statement (quarterly)",
527
+ expects: "list",
485
528
  },
486
529
  "fundamental.income-statement-hk": {
487
530
  method: "POST",
488
531
  path: "/application/open-fundamental/financial-report/income-statement/hk",
489
532
  kind: "json",
490
533
  description: "Query HK income statement (China GAAP)",
534
+ expects: "list",
491
535
  },
492
536
  "fundamental.balance-sheet-hk": {
493
537
  method: "POST",
494
538
  path: "/application/open-fundamental/financial-report/balance-sheet/hk",
495
539
  kind: "json",
496
540
  description: "Query HK balance sheet (China GAAP)",
541
+ expects: "list",
497
542
  },
498
543
  "fundamental.cash-flow-hk": {
499
544
  method: "POST",
500
545
  path: "/application/open-fundamental/financial-report/cash-flow-statement/hk",
501
546
  kind: "json",
502
547
  description: "Query HK cash flow statement (China GAAP)",
548
+ expects: "list",
503
549
  },
504
550
  "fundamental.income-statement-us": {
505
551
  method: "POST",
506
552
  path: "/application/open-fundamental/financial-report/income-statement/us",
507
553
  kind: "json",
508
554
  description: "Query US income statement",
555
+ expects: "list",
509
556
  },
510
557
  "fundamental.balance-sheet-us": {
511
558
  method: "POST",
512
559
  path: "/application/open-fundamental/financial-report/balance-sheet/us",
513
560
  kind: "json",
514
561
  description: "Query US balance sheet",
562
+ expects: "list",
515
563
  },
516
564
  "fundamental.cash-flow-us": {
517
565
  method: "POST",
518
566
  path: "/application/open-fundamental/financial-report/cash-flow-statement/us",
519
567
  kind: "json",
520
568
  description: "Query US cash flow statement",
569
+ expects: "list",
521
570
  },
522
571
  "fundamental.main-business": {
523
572
  method: "POST",
524
573
  path: "/application/open-fundamental/main-business",
525
574
  kind: "json",
526
575
  description: "Query main business composition",
576
+ expects: "list",
527
577
  },
528
578
  "fundamental.valuation-analysis": {
529
579
  method: "POST",
@@ -537,6 +587,7 @@ const ENDPOINT_DEFS = {
537
587
  path: "/application/open-fundamental/capital-structure/top-holders",
538
588
  kind: "json",
539
589
  description: "Query top holders (top10 / top10 float)",
590
+ expects: "list",
540
591
  },
541
592
  "fundamental.earning-forecast": {
542
593
  method: "POST",
@@ -628,7 +679,7 @@ const ENDPOINT_DEFS = {
628
679
  path: "/application/open-fundamental/bond/rating-change",
629
680
  kind: "json",
630
681
  description: "Query bond rating change history (max 10 bonds per call)",
631
- billing: { per: "security", price: 0.4, maxUnits: 10 },
682
+ billing: { per: "security", price: 0.4, maxUnits: 10, unitsFrom: ["securityList"] },
632
683
  retry: "no-replay",
633
684
  expects: "list",
634
685
  },
@@ -637,7 +688,7 @@ const ENDPOINT_DEFS = {
637
688
  path: "/application/open-fundamental/bond/issuer-rating-change",
638
689
  kind: "json",
639
690
  description: "Query issuer rating change history (by bond code or issuer name)",
640
- billing: { per: "issuer", price: 0.4, maxUnits: 10 },
691
+ billing: { per: "issuer", price: 0.4, maxUnits: 10, unitsFrom: ["securityList", "issuerNameList"] },
641
692
  retry: "no-replay",
642
693
  expects: "list",
643
694
  },
@@ -835,7 +886,7 @@ const ENDPOINT_DEFS = {
835
886
  path: "/application/open-ai/stock-summary/getList",
836
887
  kind: "json",
837
888
  description: "Stock highlights (refined research summary per security)",
838
- billing: { per: "row", price: 1, maxUnits: 6000 },
889
+ billing: { per: "row", price: 1, maxUnits: 6000, unitsFrom: ["securityList"] },
839
890
  retry: "no-replay",
840
891
  timeoutMs: 120_000,
841
892
  },
@@ -1031,6 +1082,7 @@ const ENDPOINT_DEFS = {
1031
1082
  path: "/application/open-vault/drive/getFolderList",
1032
1083
  kind: "json",
1033
1084
  description: "List the direct subfolders and files of a drive folder",
1085
+ expects: "object",
1034
1086
  },
1035
1087
  "vault.drive.create-folder": {
1036
1088
  method: "POST",
@@ -1148,6 +1200,7 @@ const ENDPOINT_DEFS = {
1148
1200
  path: "/application/open-vault/stock-pool/getStockList",
1149
1201
  kind: "json",
1150
1202
  description: "List securities in stock pool(s)",
1203
+ expects: "array",
1151
1204
  },
1152
1205
  "vault.stock-pool.create": {
1153
1206
  method: "POST",
@@ -1218,13 +1271,14 @@ const ENDPOINT_DEFS = {
1218
1271
  path: "/application/open-alternative/EDB/search",
1219
1272
  kind: "json",
1220
1273
  description: "Search industry indicator list by keyword",
1274
+ expects: "array",
1221
1275
  },
1222
1276
  "alternative.edb-data": {
1223
1277
  method: "POST",
1224
1278
  path: "/application/open-alternative/EDB/getData",
1225
1279
  kind: "json",
1226
1280
  description: "Get industry indicator time-series data by indicator ID list",
1227
- billing: { per: "indicator", price: 30, maxUnits: 10 },
1281
+ billing: { per: "indicator", price: 30, maxUnits: 10, unitsFrom: ["indicatorIdList"] },
1228
1282
  },
1229
1283
  "alternative.concept-info": {
1230
1284
  method: "POST",
@@ -1264,6 +1318,7 @@ const ENDPOINT_DEFS = {
1264
1318
  kind: "json",
1265
1319
  description: "Search data indicators by keyword (returns indicatorCode + params)",
1266
1320
  retry: "no-999999",
1321
+ expects: "array",
1267
1322
  },
1268
1323
  "indicator.cross-section": {
1269
1324
  method: "POST",