gangtise-openapi-cli 0.40.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -12,6 +12,7 @@ import { releaseClaim, resolveTitle, saveDownloadResult, uniquePath } from "./co
12
12
  import { resolveCalendarType } from "./core/calendarType.js";
13
13
  import { ENDPOINTS, listEndpoints } from "./core/endpoints.js";
14
14
  import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
15
+ import { uploadDriveFile } from "./core/driveUpload.js";
15
16
  import { fetchFileParseResult, pollFileParseResult, submitFileParse } from "./core/fileParse.js";
16
17
  import { assertColumnarHeader, flagFailedItems, flagMissingFields, normalizeRows, zipFieldRow } from "./core/normalize.js";
17
18
  import { parseOutputFormat } from "./core/output.js";
@@ -172,7 +173,7 @@ const independentOpinion = new Command("independent-opinion");
172
173
  const officialAccount = new Command("official-account");
173
174
  const qa = new Command("qa");
174
175
  const reportImage = new Command("report-image");
175
- addTimeFilters(opinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--research-area <id>", "Research area ID: citicIndustry code (1008001xx) or gangtiseIndustry direction code (122000xxx: macro/strategy/fixed-income/quant/overseas). swIndustry (104xx0000) returns 0 here", 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((options) => emit(options, (client) => client.call("insight.opinion.list", {
176
+ addTimeFilters(opinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--research-area <id>", "Research area ID: citicIndustry code (1008001xx) or gangtiseIndustry direction code (122000xxx: macro/strategy/fixed-income/quant/overseas). swIndustry (104xx0000) returns 0 here", 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("--with-content", "Return the full body inline (v1 list, 30 credits/row) instead of the 200-char brief (1 credit/row). v1 shape: title and body sit under contentList.title / contentList.content, and there is no brief").option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call(options.withContent ? "insight.opinion.list-with-content" : "insight.opinion.list", {
176
177
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
177
178
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), chiefList: maybeArray(options.chief),
178
179
  securityList: maybeArray(options.security), brokerList: maybeArray(options.broker), industryList: maybeArray(options.industry), conceptList: maybeArray(options.concept),
@@ -336,7 +337,7 @@ addTimeFilters(announcementUs.command("list").addOption(new Option("--search-typ
336
337
  securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
337
338
  }), { endpointKey: "insight.announcement-us.list", idField: "announcementId" }));
338
339
  addDownloadCommand(announcementUs, { endpointKey: "insight.announcement-us.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-us", fileType: { description: "File type: 1=original PDF 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.announcement-us.list" });
339
- addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code -- this endpoint accepts only cn/cnHk/cnTw/us/jp/uk; the other 13 values of regionCategory (sea/gl/fr/de/kr/in/ca/me/othAs/othEur/latAm/oce/af) are rejected here with 100005 though they all work on foreign-report", collectList, []).option("--industry <id>", "Industry ID -- swIndustry codes only (104xx0000); citicIndustry codes are rejected with 100005 even where constant-category declares them", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-opinion.list", {
340
+ addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code -- this endpoint accepts only cn/cnHk/cnTw/us/jp/uk; the other 13 values of regionCategory (sea/gl/fr/de/kr/in/ca/me/othAs/othEur/latAm/oce/af) are rejected here with 100005 though they all work on foreign-report", collectList, []).option("--industry <id>", "Industry ID -- swIndustry codes only (104xx0000); citicIndustry codes are rejected with 100005 even where constant-category declares them", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--with-content", "Return the full body inline (v1 list, 30 credits/row) as content / contentTranslate, instead of the 200-char brief (1 credit/row)").option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call(options.withContent ? "insight.foreign-opinion.list-with-content" : "insight.foreign-opinion.list", {
340
341
  from: parseFrom(options.from), size: parseSize(options.size),
341
342
  startTime: options.startTime, endTime: options.endTime,
342
343
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
@@ -345,6 +346,53 @@ addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type
345
346
  securityList: maybeArray(options.security), brokerList: maybeArray(options.broker),
346
347
  ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
347
348
  })));
349
+ async function fetchOpinionDetails(client, endpointKey, idListField, idField, ids) {
350
+ const BATCH = 20;
351
+ const unique = [...new Set(ids)];
352
+ if (!unique.length)
353
+ throw new ValidationError("pass at least one opinion ID");
354
+ const list = [];
355
+ let unfetched = [];
356
+ let unfetchedError;
357
+ for (let i = 0; i < unique.length; i += BATCH) {
358
+ try {
359
+ const rows = await client.call(endpointKey, { [idListField]: unique.slice(i, i + BATCH) });
360
+ if (Array.isArray(rows))
361
+ list.push(...rows);
362
+ }
363
+ catch (error) {
364
+ if (i === 0)
365
+ throw error;
366
+ unfetched = unique.slice(i);
367
+ const message = error instanceof Error ? error.message : String(error);
368
+ unfetchedError = { message, ...(error instanceof ApiError ? { code: error.code, traceId: error.traceId } : {}) };
369
+ process.stderr.write(`[gangtise] warning: ${endpointKey} failed on batch ${i / BATCH + 1} (${message}); ${unfetched.length} ID(s) not fetched: ${unfetched.join(", ")}. The bodies already returned are kept below — re-run for the IDs in unfetchedIds only.\n`);
370
+ break;
371
+ }
372
+ }
373
+ const returned = new Set(list.map((row) => String(row[idField])));
374
+ const missing = unique.filter((id) => !returned.has(id) && !unfetched.includes(id));
375
+ const out = { total: list.length, list };
376
+ if (missing.length > 0) {
377
+ process.stderr.write(`[gangtise] warning: no body returned for ${missing.length} ID(s): ${missing.join(", ")} — the server skips an ID it has no body for without an error (e.g. a wrong ID; a just-published opinion may also not have its body yet). Result marked partial (exit 3).\n`);
378
+ out.missingIds = missing;
379
+ }
380
+ if (unfetched.length > 0) {
381
+ out.unfetchedIds = unfetched;
382
+ out.unfetchedError = unfetchedError;
383
+ }
384
+ if (missing.length > 0 || unfetched.length > 0)
385
+ out.partial = true;
386
+ return out;
387
+ }
388
+ opinion.command("detail").description("Full bodies of domestic chief opinions by ID (30 credits per returned opinion; batched 20 per call)")
389
+ .requiredOption("--chief-opinion-id <id>", "chiefOpinionId from 'insight opinion list' (repeat or comma-separate)", collectList)
390
+ .option("--format <format>", "Output format", "json").option("--output <path>")
391
+ .action((options) => emit(options, (client) => fetchOpinionDetails(client, "insight.opinion.detail", "chiefOpinionIdList", "chiefOpinionId", options.chiefOpinionId)));
392
+ foreignOpinion.command("detail").description("Full bodies (content + contentTranslate) of foreign opinions by ID (30 credits per returned opinion; batched 20 per call)")
393
+ .requiredOption("--foreign-opinion-id <id>", "foreignOpinionId from 'insight foreign-opinion list' (repeat or comma-separate)", collectList)
394
+ .option("--format <format>", "Output format", "json").option("--output <path>")
395
+ .action((options) => emit(options, (client) => fetchOpinionDetails(client, "insight.foreign-opinion.detail", "foreignOpinionIdList", "foreignOpinionId", options.foreignOpinionId)));
348
396
  addTimeFilters(independentOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. GSK.N)", collectList, []).option("--industry <id>", "Industry ID -- swIndustry codes only (104xx0000); citicIndustry codes are rejected with 100005 even where constant-category declares them", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.independent-opinion.list", {
349
397
  from: parseFrom(options.from), size: parseSize(options.size),
350
398
  startTime: options.startTime, endTime: options.endTime,
@@ -393,6 +441,20 @@ insight.addCommand(independentOpinion);
393
441
  insight.addCommand(officialAccount);
394
442
  insight.addCommand(qa);
395
443
  insight.addCommand(reportImage);
444
+ const highlight = new Command("highlight");
445
+ highlight.command("list").description("Meeting highlights feed: key takeaways per meeting, newest first (5 credits/row)")
446
+ .option("--from <number>", "Starting offset", "0")
447
+ .option("--size <number>", "Total rows to return; omit to fetch all (max page 50). Billed per row — bound this on wide ranges")
448
+ .option("--start-time <datetime>", "Start time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)", datetimeArg("--start-time"))
449
+ .option("--end-time <datetime>", "End time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)", datetimeArg("--end-time"))
450
+ .option("--security <code>", "Security code, e.g. 000001.SZ / 09992.HK / AAPL.O (repeatable)", collectList, [])
451
+ .option("--research-area <id>", "Research area ID: citicIndustry (1008001xx) or gangtiseIndustry direction (122000xxx). swIndustry codes are NOT accepted here", collectList, [])
452
+ .option("--format <format>", "Output format", "table").option("--output <path>")
453
+ .action((options) => emit(options, (client) => client.call("insight.highlight.list", {
454
+ from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
455
+ securityList: maybeArray(options.security), researchAreaList: maybeArray(options.researchArea),
456
+ })));
457
+ insight.addCommand(highlight);
396
458
  program.addCommand(insight);
397
459
  const quote = new Command("quote").description("Quote APIs");
398
460
  const KLINE_MARKETS = { aShares: 1, hkStocks: 2, usStocks: 1 };
@@ -560,6 +622,79 @@ fundamental.command("earning-forecast").requiredOption("--security-code <code>")
560
622
  return client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) });
561
623
  }));
562
624
  program.addCommand(fundamental);
625
+ const bond = new Command("bond").description("Bond APIs: profiles, issuers, quotes, valuations, cash flows, ratings, announcements");
626
+ function requireBondCodes(codes, max) {
627
+ if (!codes.length)
628
+ throw new ValidationError("--security is required: pass one or more bond codes, e.g. --security 019742.SH --security 220205.IB");
629
+ const unique = [...new Set(codes)];
630
+ if (max && unique.length > max)
631
+ throw new ValidationError(`${unique.length} distinct bond codes in one call — this endpoint takes at most ${max}. Split them into batches of ${max} and run one call per batch.`);
632
+ return unique;
633
+ }
634
+ const bondSecurityOption = (command) => command
635
+ .option("--security <code>", "Bond code, e.g. 019742.SH / 123456.SZ / 220205.IB (repeatable). Short names and pinyin are rejected — resolve them with 'reference securities-search' first", collectList, [])
636
+ .option("--field <field>", "Field to return (repeatable); omit for all. An unsupported name rejects the whole call with 100003", collectList, [])
637
+ .option("--format <format>", "Output format", "table").option("--output <path>");
638
+ const addBondRange = (name, endpointKey, describe, rangeHelp, max) => bondSecurityOption(bond.command(name).description(describe))
639
+ .option("--start-date <date>", `${rangeHelp} start (yyyy-MM-dd); omit for all history`, dateArg("--start-date"))
640
+ .option("--end-date <date>", `${rangeHelp} end (yyyy-MM-dd); omit for all history`, dateArg("--end-date"))
641
+ .action((options) => emit(options, (client) => client.call(endpointKey, { securityList: requireBondCodes(options.security, max), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
642
+ bondSecurityOption(bond.command("basic-info").description("Bond static profiles: issuance, term, coupon, rating, guarantee, special terms"))
643
+ .action((options) => emit(options, (client) => client.call("bond.basic-info", { securityList: requireBondCodes(options.security, 10000), fieldList: maybeArray(options.field) })));
644
+ bondSecurityOption(bond.command("rating-overview").description("Bond, issuer and guarantor ratings side by side"))
645
+ .action((options) => emit(options, (client) => client.call("bond.rating-overview", { securityList: requireBondCodes(options.security, 10), fieldList: maybeArray(options.field) })));
646
+ addBondRange("cash-flow", "bond.cash-flow", "Interest payment and redemption schedule per bond", "Payment date");
647
+ addBondRange("issuance-detail", "bond.issuance-detail", "Issuance and re-issuance records: bidding, pricing, cover ratios", "Issue announcement date");
648
+ addBondRange("rating-change", "bond.rating-change", "Bond rating change history: current vs previous rating, direction, outlook, agency (max 10 bonds per call)", "Announcement date", 10);
649
+ addBondRange("exercise-notice", "bond.exercise-notice", "Put/call exercise schedule and results for option-embedded bonds", "Exercise date");
650
+ bondSecurityOption(bond.command("daily-quote").description("Daily close quotes (exchange + CFETS): dirty/clean price, YTM, duration, convexity"))
651
+ .requiredOption("--start-date <date>", "Trade date range start (yyyy-MM-dd)", dateArg("--start-date"))
652
+ .requiredOption("--end-date <date>", "Trade date range end (yyyy-MM-dd)", dateArg("--end-date"))
653
+ .action((options) => emit(options, (client) => client.call("bond.daily-quote", { securityList: requireBondCodes(options.security), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
654
+ bondSecurityOption(bond.command("valuation").description("Shanghai Clearing House valuations: price, yield, duration, convexity, PVBP"))
655
+ .requiredOption("--start-date <date>", "Valuation date range start (yyyy-MM-dd)", dateArg("--start-date"))
656
+ .requiredOption("--end-date <date>", "Valuation date range end (yyyy-MM-dd)", dateArg("--end-date"))
657
+ .addOption(new Option("--confidence-level <level>", "Valuation confidence; defaults to 推荐 server-side, which already filters out 不推荐").choices(["推荐", "不推荐"]))
658
+ .action((options) => emit(options, (client) => client.call("bond.valuation", { securityList: requireBondCodes(options.security), startDate: options.startDate, endDate: options.endDate, confidenceLevel: options.confidenceLevel, fieldList: maybeArray(options.field) })));
659
+ function issuerSelector(security, issuer) {
660
+ if (security.length && issuer.length)
661
+ throw new ValidationError("--security and --issuer are mutually exclusive: pass bond codes or issuer names, not both");
662
+ if (!security.length && !issuer.length)
663
+ throw new ValidationError("pass either --security (bond codes) or --issuer (issuer names)");
664
+ return security.length ? { securityList: security } : { issuerNameList: issuer };
665
+ }
666
+ const issuerNameOption = (command) => command.option("--issuer <name>", "Issuer name, full or short (repeatable); fuzzy-matched, one best hit per name", collectList, []);
667
+ issuerNameOption(bondSecurityOption(bond.command("issuer-info").description("Issuer profiles: nature, SW industry, registration, rating, outstanding bonds")))
668
+ .action((options) => emit(options, (client) => client.call("bond.issuer-info", { ...issuerSelector(options.security, options.issuer), fieldList: maybeArray(options.field) })));
669
+ issuerNameOption(bondSecurityOption(bond.command("issuer-rating-change").description("Issuer rating change history (at most 10 issuers matched per call)")))
670
+ .option("--start-date <date>", "Announcement date range start (yyyy-MM-dd); omit for all history", dateArg("--start-date"))
671
+ .option("--end-date <date>", "Announcement date range end (yyyy-MM-dd); omit for all history", dateArg("--end-date"))
672
+ .action((options) => emit(options, (client) => client.call("bond.issuer-rating-change", { ...issuerSelector(options.security, options.issuer), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
673
+ bond.command("issuance-plan").description("Rate-bond issuance calendar over a date range")
674
+ .requiredOption("--start-date <date>", "Issue date range start (yyyy-MM-dd)", dateArg("--start-date"))
675
+ .requiredOption("--end-date <date>", "Issue date range end (yyyy-MM-dd)", dateArg("--end-date"))
676
+ .option("--field <field>", "Field to return (repeatable); omit for all", collectList, [])
677
+ .option("--format <format>", "Output format", "table").option("--output <path>")
678
+ .action((options) => emit(options, (client) => client.call("bond.issuance-plan", { startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
679
+ bondSecurityOption(bond.command("announcement").description("Bond announcements, paged. Filter by bond codes OR by announcement date range, never both"))
680
+ .option("--start-date <date>", "Announcement date range start (yyyy-MM-dd); mutually exclusive with --security", dateArg("--start-date"))
681
+ .option("--end-date <date>", "Announcement date range end (yyyy-MM-dd); mutually exclusive with --security", dateArg("--end-date"))
682
+ .option("--page-no <number>", "Page number, from 1. No total is returned: increment until a page comes back empty", "1")
683
+ .option("--page-size <number>", "Rows per page, 1-200", "50")
684
+ .action((options) => emit(options, (client) => {
685
+ const byDate = Boolean(options.startDate || options.endDate);
686
+ if (options.security.length && byDate)
687
+ throw new ValidationError("--security and --start-date/--end-date are mutually exclusive on bond announcement: filter by bond codes or by date range, not both");
688
+ if (!options.security.length && !byDate)
689
+ throw new ValidationError("pass either --security (bond codes) or --start-date/--end-date (announcement date range)");
690
+ return client.call("bond.announcement", {
691
+ securityList: maybeArray(options.security), startDate: options.startDate, endDate: options.endDate,
692
+ pageNo: parseNumberOption(options.pageNo, "--page-no", { integer: true, min: 1 }),
693
+ pageSize: parseNumberOption(options.pageSize, "--page-size", { integer: true, min: 1, max: 200 }),
694
+ fieldList: maybeArray(options.field),
695
+ });
696
+ }));
697
+ program.addCommand(bond);
563
698
  const ai = new Command("ai").description("AI APIs");
564
699
  ai.command("knowledge-batch").option("--query <text>", "Query text; repeat for up to 5. Each --query is taken whole — commas inside it are part of the question, not separators", collectText, []).option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)").option("--end-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
565
700
  if (!options.query.length)
@@ -696,6 +831,74 @@ program.addCommand(reference);
696
831
  const vault = new Command("vault").description("Vault APIs");
697
832
  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>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).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((options) => emit(options, (client) => 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 }), { endpointKey: "vault.drive.list", idField: "fileId" }));
698
833
  addDownloadCommand(vault, { endpointKey: "vault.drive.download", name: "drive-download", idOption: "--file-id", idField: "fileId", fallbackPrefix: "file", titleListEndpoint: "vault.drive.list" });
834
+ const driveSpaceOption = (help) => new Option("--space-type <n>", help).choices(["1", "2"]).default("1");
835
+ vault.command("drive-folder-list").description("List the direct subfolders and files of a drive folder (free)")
836
+ .addOption(driveSpaceOption("Space: 1=my drive 2=tenant drive"))
837
+ .option("--parent-id <id>", "Folder ID in that space; omit (or 'root') for the space's root. A folder of the other space is rejected with 100003")
838
+ .option("--format <format>", "Output format", "json").option("--output <path>")
839
+ .action((options) => emit(options, (client) => client.call("vault.drive.folder-list", { spaceType: Number(options.spaceType), parentId: options.parentId })));
840
+ vault.command("drive-upload").description("Upload a file to the AI drive (free; max 100MB per file; trial accounts max 500MB/day)")
841
+ .requiredOption("--file <path>", "File to upload")
842
+ .addOption(driveSpaceOption("Target space: 1=my drive 2=tenant drive (shared with your whole tenant)"))
843
+ .option("--folder-id <id>", "Target folder ID in that space; omit (or 'root') for the space's root")
844
+ .option("--title <name>", "Name in the drive, max 200 characters; defaults to the local file name")
845
+ .option("--format <format>", "Output format", "json").option("--output <path>")
846
+ .action((options) => emit(options, (client) => uploadDriveFile(client, options.file, { spaceType: Number(options.spaceType), folderId: options.folderId, title: options.title })));
847
+ vault.command("drive-create-folder").description("Create a drive folder (free). Same-name folders are allowed, so re-running creates a second one")
848
+ .requiredOption("--name <name>", "Folder name, max 200 characters (230004 past that)")
849
+ .addOption(driveSpaceOption("Space: 1=my drive 2=tenant drive (shared with your whole tenant)"))
850
+ .option("--parent-id <id>", "Parent folder ID in that space; omit (or 'root') for the root")
851
+ .option("--format <format>", "Output format", "json").option("--output <path>")
852
+ .action((options) => emit(options, (client) => client.call("vault.drive.create-folder", { folderName: options.name, spaceType: Number(options.spaceType), parentId: options.parentId })));
853
+ vault.command("drive-rename").description("Rename a drive file or folder (free)")
854
+ .addOption(new Option("--type <type>", "What --id names").choices(["file", "folder"]).makeOptionMandatory())
855
+ .requiredOption("--id <id>", "File or folder ID")
856
+ .requiredOption("--name <name>", "New name, max 200 characters")
857
+ .option("--format <format>", "Output format", "json").option("--output <path>")
858
+ .action((options) => emit(options, (client) => client.call("vault.drive.rename", { type: options.type, id: options.id, name: options.name })));
859
+ vault.command("drive-move-file").description("Move drive files into a folder of the SAME space (free)")
860
+ .requiredOption("--file-id <id>", "File ID (repeat or comma-separate)", collectList)
861
+ .requiredOption("--target-folder-id <id>", "Destination folder ID, or 'root'. Files of the other space land in failList (空间不一致)")
862
+ .option("--format <format>", "Output format", "json").option("--output <path>")
863
+ .action((options) => emit(options, async (client) => {
864
+ const data = await client.call("vault.drive.move-file", { fileIdList: options.fileId, targetFolderId: options.targetFolderId });
865
+ flagFailedItems(data, "vault drive-move-file");
866
+ return data;
867
+ }));
868
+ vault.command("drive-move-folder").description("Move a drive folder under another folder of the SAME space (free)")
869
+ .requiredOption("--folder-id <id>", "Folder ID to move")
870
+ .requiredOption("--target-parent-id <id>", "Destination parent folder ID, or 'root'. Not the folder itself, one of its subfolders, or the other space (230005)")
871
+ .option("--format <format>", "Output format", "json").option("--output <path>")
872
+ .action((options) => emit(options, (client) => client.call("vault.drive.move-folder", { folderId: options.folderId, targetParentId: options.targetParentId })));
873
+ vault.command("drive-copy").description("Copy files to the OTHER space: my drive <-> tenant drive (free; same-space copies are rejected)")
874
+ .requiredOption("--file-id <id>", "Source file ID (repeat or comma-separate)", collectList)
875
+ .requiredOption("--target-folder-id <id>", "Destination folder ID in the other space, or 'root' for that space's root")
876
+ .option("--format <format>", "Output format", "json").option("--output <path>")
877
+ .action((options) => emit(options, async (client) => {
878
+ const data = await client.call("vault.drive.copy", { copyType: "file", fileIdList: options.fileId, targetFolderId: options.targetFolderId });
879
+ flagFailedItems(data, "vault drive-copy");
880
+ return data;
881
+ }));
882
+ vault.command("drive-delete-file").description("Delete drive files — irreversible (free)")
883
+ .requiredOption("--file-id <id>", "File ID to delete (repeat or comma-separate)", collectList)
884
+ .option("--yes", "Required: confirm the deletion")
885
+ .option("--format <format>", "Output format", "json").option("--output <path>")
886
+ .action(async (options) => {
887
+ assertConfirmed("vault.drive.delete-file", Boolean(options.yes), options.fileId.join("、"));
888
+ await emit(options, async (client) => {
889
+ const data = await client.call("vault.drive.delete-file", { fileIdList: options.fileId });
890
+ flagFailedItems(data, "vault drive-delete-file");
891
+ return data;
892
+ });
893
+ });
894
+ vault.command("drive-delete-folder").description("Delete a drive folder AND every subfolder and file inside it — irreversible (free)")
895
+ .requiredOption("--folder-id <id>", "Folder ID to delete")
896
+ .option("--yes", "Required: confirm the deletion")
897
+ .option("--format <format>", "Output format", "json").option("--output <path>")
898
+ .action(async (options) => {
899
+ assertConfirmed("vault.drive.delete-folder", Boolean(options.yes), options.folderId);
900
+ await emit(options, (client) => client.call("vault.drive.delete-folder", { folderId: options.folderId }));
901
+ });
699
902
  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>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).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((options) => emit(options, (client) => 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 }), { endpointKey: "vault.record.list", idField: "recordId" }));
700
903
  addDownloadCommand(vault, { endpointKey: "vault.record.download", name: "record-download", idOption: "--record-id", idField: "recordId", fallbackPrefix: "record", contentTypeDescription: "Content type: original/asr/summary", titleListEndpoint: "vault.record.list" });
701
904
  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>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).option("--keyword <text>").option("--research-area <id>", "Research area ID: citicIndustry code (1008001xx) or gangtiseIndustry direction code (122000xxx: macro/strategy/fixed-income/quant/overseas). swIndustry (104xx0000) returns 0 here", 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("--source <number>", "Recording source: 1=企微会议助理 2=会议服务微信群 (repeat)", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => 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), sourceList: options.source.length ? options.source : undefined }), { endpointKey: "vault.my-conference.list", idField: "conferenceId" }));
@@ -753,8 +956,8 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
753
956
  }
754
957
  await printData(data, format, options.output);
755
958
  }));
756
- alternative.command("concept-info").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-info", { conceptId: options.conceptId })));
757
- alternative.command("concept-securities").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-securities", { conceptId: options.conceptId })));
959
+ alternative.command("concept-info").description("Concept profile: definition, investment logic, industry space, competitive landscape (50 credits/call)").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--full", "Also return catalyst events (keyEvents) via the v1 endpoint — 500 credits/call instead of 50").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call(options.full ? "alternative.concept-info-full" : "alternative.concept-info", { conceptId: options.conceptId })));
960
+ alternative.command("concept-securities").description("Concept constituents, grouped (50 credits/call; free when the concept has none)").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--full", "Also return the key-stock flag (isKey) and inclusion reason via the v1 endpoint — 500 credits/call instead of 50").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call(options.full ? "alternative.concept-securities-full" : "alternative.concept-securities", { conceptId: options.conceptId })));
758
961
  program.addCommand(alternative);
759
962
  function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
760
963
  if (isEmptyMatrix(data)) {
@@ -871,6 +1074,28 @@ tool.command("file-parse-check").description("Download a finished file-parse res
871
1074
  process.stdout.write(`${JSON.stringify({ taskId: options.taskId, status: "pending", hint: "Parse not finished yet, retry in ~1 minute" })}\n`);
872
1075
  }
873
1076
  }));
1077
+ tool.command("web-search").description("Search the public web for research: deduped, source-tiered (T0-T3) results with optional page content (1 credit/call)")
1078
+ .requiredOption("--query <text>", "Search query, 1-200 chars. Sent verbatim — no intent rewriting server-side, so spell out what you want")
1079
+ .option("--size <number>", "Results to return, 1-20 (1-5 with --include-content)", "10")
1080
+ .addOption(new Option("--freshness <window>", "Time window over publishTime; results with no resolvable publish date are dropped by day/week/month").choices(["day", "week", "month", "none"]))
1081
+ .addOption(new Option("--min-tier <tier>", "Lowest source tier to keep; T3 = no filtering").choices(["T0", "T1", "T2", "T3"]))
1082
+ .option("--site <domain>", "Restrict to a registered domain or subdomain, e.g. csrc.gov.cn (repeatable, max 10, OR-ed)", collectList, [])
1083
+ .option("--include-content", "Return page body as Markdown; caps --size at 5")
1084
+ .option("--max-content-chars <number>", "Max chars per body, 1000-20000 (only with --include-content)")
1085
+ .option("--format <format>", "Output format", "table").option("--output <path>")
1086
+ .action((options) => emit(options, (client) => {
1087
+ const size = parseNumberOption(options.size, "--size", { integer: true, min: 1, max: 20 });
1088
+ if (options.includeContent && size > 5)
1089
+ throw new ValidationError(`--size ${size} with --include-content: this endpoint caps size at 5 when page bodies are requested. Lower --size, or drop --include-content to fetch up to 20 snippets.`);
1090
+ const sites = [...new Set(options.site)];
1091
+ if (sites.length > 10)
1092
+ throw new ValidationError(`--site takes at most 10 distinct domains, got ${sites.length}`);
1093
+ return client.call("tool.web-search", {
1094
+ query: options.query, size, freshness: options.freshness, minTier: options.minTier,
1095
+ siteList: maybeArray(sites), includeContent: options.includeContent || undefined,
1096
+ maxContentChars: parseOptionalNumberOption(options.maxContentChars, "--max-content-chars", { integer: true, min: 1000, max: 20000 }),
1097
+ });
1098
+ }));
874
1099
  program.addCommand(tool);
875
1100
  program.command("raw").description("Raw API calls").addCommand(new Command("call").argument("<endpointKey>").option("--body <json>").option("--query <key=value>", "Query string pair", collectKeyValue, {}).option("--yes", "Confirm an irreversible endpoint (required for the ones marked destructive)").option("--format <format>", "Output format", "json").option("--output <path>").action(async (endpointKey, options) => {
876
1101
  const endpoint = ENDPOINTS[endpointKey];
@@ -157,7 +157,12 @@ export class GangtiseClient {
157
157
  const startFrom = typeof initialBody.from === 'number' && Number.isFinite(initialBody.from) ? initialBody.from : 0;
158
158
  const requestedSize = typeof initialBody.size === 'number' && Number.isFinite(initialBody.size) ? initialBody.size : undefined;
159
159
  const maxPageSize = endpoint.pagination?.maxPageSize ?? requestedSize ?? 20;
160
- const firstPageSize = requestedSize === undefined ? maxPageSize : Math.min(maxPageSize, requestedSize);
160
+ const maxWindow = endpoint.pagination?.maxWindow;
161
+ if (maxWindow !== undefined && startFrom >= maxWindow) {
162
+ throw new ValidationError(`${endpoint.key} serves rows only up to offset ${maxWindow} (from + size ≤ ${maxWindow}); --from ${startFrom} is past it. Narrow the query (e.g. a shorter time range) instead of paging deeper.`);
163
+ }
164
+ const windowRoom = maxWindow === undefined ? Infinity : maxWindow - startFrom;
165
+ const firstPageSize = Math.min(requestedSize === undefined ? maxPageSize : Math.min(maxPageSize, requestedSize), windowRoom);
161
166
  const firstPage = await this.requestJson(endpoint, {
162
167
  ...initialBody,
163
168
  from: startFrom,
@@ -170,7 +175,15 @@ export class GangtiseClient {
170
175
  }
171
176
  const total = firstPage.total;
172
177
  const available = Math.max(total - startFrom, 0);
173
- const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
178
+ const wanted = requestedSize === undefined ? available : Math.min(requestedSize, available);
179
+ const target = Math.min(wanted, windowRoom);
180
+ const probeFits = maxWindow === undefined || total + 1 <= maxWindow;
181
+ const flagWindowCut = (out) => {
182
+ if (target >= wanted)
183
+ return;
184
+ process.stderr.write(`[gangtise] warning: ${endpoint.key} serves rows only up to offset ${maxWindow} (from + size ≤ ${maxWindow}); ${wanted - target} of the ${wanted} requested rows lie past it and were not fetched. Narrow the query (e.g. a shorter time range) and fetch in slices.\n`);
185
+ out.partial = true;
186
+ };
174
187
  const sink = this.claimRowSink();
175
188
  if (sink && Array.isArray(firstPage.fieldList))
176
189
  sink.setFieldList(firstPage.fieldList);
@@ -187,6 +200,11 @@ export class GangtiseClient {
187
200
  for (const row of kept)
188
201
  collected.push(row);
189
202
  };
203
+ const checkTotalCap = async (out) => {
204
+ if (probeFits)
205
+ return this.flagIfTotalCapped(endpoint, initialBody, total, out, count);
206
+ process.stderr.write(`[gangtise] note: ${endpoint.key} reported total=${total}, which reaches its ${maxWindow}-row offset window, so whether that is the real count cannot be checked. Narrow the query (e.g. a shorter time range) to keep total below ${maxWindow} if you need to be sure.\n`);
207
+ };
190
208
  const result = () => {
191
209
  const out = { ...firstPage, total, list: collected };
192
210
  return sink ? attachRowSink(out, sink) : out;
@@ -194,6 +212,7 @@ export class GangtiseClient {
194
212
  await keep(firstPage.list);
195
213
  if (firstPage.list.length < firstPageSize) {
196
214
  const out = result();
215
+ flagWindowCut(out);
197
216
  if (count < target) {
198
217
  process.stderr.write(`[gangtise] warning: server returned a short page (${count} rows) but reported total=${total}; treating it as the end of data — results may be incomplete\n`);
199
218
  out.partial = true;
@@ -202,8 +221,9 @@ export class GangtiseClient {
202
221
  }
203
222
  if (count >= target) {
204
223
  const out = result();
205
- if (requestedSize === undefined && total > firstPageSize)
206
- await this.flagIfTotalCapped(endpoint, initialBody, total, out, count);
224
+ flagWindowCut(out);
225
+ if (requestedSize === undefined && total > firstPageSize && target === wanted)
226
+ await checkTotalCap(out);
207
227
  return out;
208
228
  }
209
229
  const MAX_PAGES = 1000;
@@ -268,8 +288,9 @@ export class GangtiseClient {
268
288
  }
269
289
  const short = count < target;
270
290
  const out = result();
271
- if (requestedSize === undefined && total > 0 && !short && !totalDrift && !truncatedByPageCap && failedPages.length === 0) {
272
- await this.flagIfTotalCapped(endpoint, initialBody, total, out, count);
291
+ flagWindowCut(out);
292
+ if (requestedSize === undefined && total > 0 && target === wanted && !short && !totalDrift && !truncatedByPageCap && failedPages.length === 0) {
293
+ await checkTotalCap(out);
273
294
  }
274
295
  if (short || totalDrift || failedPages.length > 0)
275
296
  out.partial = true;
@@ -397,13 +418,17 @@ export class GangtiseClient {
397
418
  throw error;
398
419
  });
399
420
  }
400
- async uploadFile(endpointKey, file) {
421
+ async uploadFile(endpointKey, file, fields) {
401
422
  const endpoint = ENDPOINTS[endpointKey];
402
423
  if (!endpoint || endpoint.kind !== 'upload') {
403
424
  throw new ApiError(`Not an upload endpoint: ${endpointKey}`);
404
425
  }
405
426
  const form = new FormData();
406
427
  form.append('file', new Blob([file.data], { type: file.contentType ?? 'application/octet-stream' }), file.filename);
428
+ for (const [name, value] of Object.entries(fields ?? {})) {
429
+ if (value !== undefined)
430
+ form.append(name, String(value));
431
+ }
407
432
  return this.requestJson(endpoint, form);
408
433
  }
409
434
  async download(endpoint, query, options, body) {
@@ -546,7 +571,7 @@ export class GangtiseClient {
546
571
  throw new ApiError(`Unknown endpoint key: ${endpointKey}`);
547
572
  }
548
573
  if (endpoint.kind === 'upload') {
549
- throw new ValidationError(`${endpointKey} takes a file upload — use 'gangtise tool file-parse --file <path>' ('raw call' cannot send files)`);
574
+ 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`);
550
575
  }
551
576
  if (endpoint.kind === 'download') {
552
577
  return this.download(endpoint, query ?? {}, options, body);
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ValidationError } from "./errors.js";
4
+ export const DRIVE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
5
+ export const DRIVE_NAME_MAX = 200;
6
+ export async function uploadDriveFile(client, filePath, options) {
7
+ const resolved = path.resolve(filePath);
8
+ let stat;
9
+ try {
10
+ stat = await fs.stat(resolved);
11
+ }
12
+ catch {
13
+ throw new ValidationError(`File not found: ${filePath}`);
14
+ }
15
+ if (!stat.isFile())
16
+ throw new ValidationError(`Not a file: ${filePath}`);
17
+ if (stat.size === 0)
18
+ throw new ValidationError(`File is empty: ${filePath}`);
19
+ if (stat.size > DRIVE_UPLOAD_MAX_BYTES) {
20
+ throw new ValidationError(`File is ${(stat.size / 1024 / 1024).toFixed(1)}MB — the drive accepts at most 100MB per file`);
21
+ }
22
+ const filename = path.basename(resolved);
23
+ const name = options.title ?? filename;
24
+ if (name.length > DRIVE_NAME_MAX) {
25
+ 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`);
26
+ }
27
+ const data = await fs.readFile(resolved);
28
+ return client.uploadFile("vault.drive.upload", { filename, data }, { spaceType: options.spaceType, folderId: options.folderId, title: options.title });
29
+ }