gangtise-openapi-cli 0.40.1 → 0.41.1

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";
@@ -43,7 +44,7 @@ async function withClient(a, b) {
43
44
  }
44
45
  }
45
46
  const DEFAULT_QUOTE_LIMIT = 6000;
46
- function flagIfLimitTruncated(data, cap, label, rangeFlags = "--start-date/--end-date") {
47
+ function flagIfLimitTruncated(data, cap, label, rangeFlags = "--start-date/--end-date", advice) {
47
48
  if (!data || typeof data !== "object" || Array.isArray(data))
48
49
  return;
49
50
  const rec = data;
@@ -51,7 +52,7 @@ function flagIfLimitTruncated(data, cap, label, rangeFlags = "--start-date/--end
51
52
  return;
52
53
  if (Array.isArray(rec.list) && rec.list.length >= cap) {
53
54
  rec.partial = true;
54
- process.stderr.write(`[gangtise] warning: ${label} returned ${rec.list.length} rows = the ${cap}-row limit; results are likely truncated (this endpoint has no pagination). Narrow ${rangeFlags} or raise --limit (max 10000), fetching in date batches.\n`);
55
+ process.stderr.write(`[gangtise] warning: ${label} returned ${rec.list.length} rows = the ${cap}-row limit; results are likely truncated (this endpoint has no pagination). ${advice ?? `Narrow ${rangeFlags} or raise --limit (max 10000), fetching in date batches.`}\n`);
55
56
  }
56
57
  }
57
58
  async function runDownload(client, endpointKey, query, options) {
@@ -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 };
@@ -401,7 +463,7 @@ const REALTIME_MARKETS = ["aShares", "hkStocks", "usStocks"];
401
463
  const FUND_FLOW_MARKETS = ["aShares"];
402
464
  const MARKET_KEYWORDS = new Set(["all", "ashares", "hkstocks", "usstocks"]);
403
465
  const matchesMarketKeyword = (value, keyword) => value.toLowerCase() === keyword.toLowerCase();
404
- const checkMarketKeywords = (securities, accepted, command) => {
466
+ const checkMarketKeywords = (securities, accepted, command, noKeywordReason) => {
405
467
  const used = securities.filter((s) => MARKET_KEYWORDS.has(s.toLowerCase()));
406
468
  if (used.length === 0)
407
469
  return;
@@ -409,14 +471,14 @@ const checkMarketKeywords = (securities, accepted, command) => {
409
471
  if (unsupported.length > 0) {
410
472
  throw new ValidationError(accepted.length > 0
411
473
  ? `${command}: '${unsupported[0]}' is not a whole-market keyword for this command — use ${accepted.join(" / ")}`
412
- : `${command}: this command takes explicit security codes only — '${unsupported[0]}' and other whole-market keywords are not supported`);
474
+ : `${command}: this command takes explicit security codes only — '${unsupported[0]}' and other whole-market keywords are not supported${noKeywordReason ? `: ${noKeywordReason}` : ""}`);
413
475
  }
414
476
  if (securities.length > 1) {
415
477
  throw new ValidationError(`${command}: a market keyword must be passed alone, got '${securities.join(", ")}' — the API rejects it mixed with security codes or another keyword`);
416
478
  }
417
479
  };
418
480
  const canonicalizeMarketKeywords = (securities, accepted) => securities.map((s) => accepted.find((a) => matchesMarketKeyword(s, a)) ?? s);
419
- const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.command(name)
481
+ const addKlineCommand = (name, endpointKey, securityHelp, markets, noKeywordReason) => quote.command(name)
420
482
  .option("--security <code>", securityHelp, collectList, [])
421
483
  .option("--start-date <date>", "Start date (default: 1 year before end-date)", dateArg("--start-date"))
422
484
  .option("--end-date <date>", "End date (default: latest)", dateArg("--end-date"))
@@ -425,7 +487,7 @@ const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.comm
425
487
  .option("--format <format>", "Output format", "table")
426
488
  .option("--output <path>")
427
489
  .action((options) => {
428
- checkMarketKeywords(options.security, Object.keys(markets), `quote ${name}`);
490
+ checkMarketKeywords(options.security, Object.keys(markets), `quote ${name}`, noKeywordReason);
429
491
  options.security = canonicalizeMarketKeywords(options.security, Object.keys(markets));
430
492
  return withClient(options, async (client) => {
431
493
  const format = parseOutputFormat(options.format);
@@ -454,7 +516,7 @@ const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.comm
454
516
  addKlineCommand("day-kline", "quote.day-kline", "Security code — A-share .SH/.SZ/.BJ, ETF .SH/.SZ (e.g. 512800.SH), HK .HK, US .O/.N/.A, exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI, global index (e.g. SPX.SPI / N225.NKI / HSI.HI); or one market keyword: aShares / hkStocks / usStocks (auto-sharded by date, must be passed alone; keywords cover stocks only — ETFs and indices must be listed by code)", KLINE_MARKETS);
455
517
  addKlineCommand("day-kline-hk", "quote.day-kline-hk", "[deprecated: use 'day-kline'] Security code (HK stock: .HK, or 'all' for full market)", LEGACY_ALL_MARKET(2));
456
518
  addKlineCommand("day-kline-us", "quote.day-kline-us", "[deprecated: use 'day-kline'] Security code (US stock: e.g. AAPL.O, or 'all' for full market)", LEGACY_ALL_MARKET(1));
457
- addKlineCommand("index-day-kline", "quote.index-day-kline", "[deprecated: use 'day-kline'] Index code (.SH/.SZ/.BJ, or 'all' for full market)", LEGACY_ALL_MARKET(15));
519
+ addKlineCommand("index-day-kline", "quote.index-day-kline", "[deprecated: use 'day-kline'] Index code (.SH/.SZ/.BJ), repeat for several; no whole-market keyword — 'all' is refused because the endpoint answers it with an empty result", {}, "the endpoint answers 'all' with an empty result. List the index codes one by one — quote day-kline takes the same codes");
458
520
  quote.command("minute-kline").option("--security <code>", "Security code — A-share .SH/.SZ (SH/SZ only), ETF .SH/.SZ (e.g. 512800.SH), exchange index .SH/.SZ, concept index .GT, industry index .CI/.SWI, global index (e.g. SPX.SPI / N225.NKI / HSI.HI); repeat for several — one request each, run concurrently and merged; no whole-market keyword", collectList, []).option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--start-time")).option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--end-time")).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((options) => withClient(options, async (client) => {
459
521
  const format = parseOutputFormat(options.format);
460
522
  const limit = parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }) ?? DEFAULT_QUOTE_LIMIT;
@@ -523,12 +585,33 @@ addFinancialReport("income-statement-us", "fundamental.income-statement-us", "Pe
523
585
  addFinancialReport("balance-sheet-us", "fundamental.balance-sheet-us", "Period: q1/h1/q3/nsd/annual/latest");
524
586
  addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3/nsd/annual/latest");
525
587
  fundamental.command("main-business").requiredOption("--security-code <code>").option("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-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((options) => emit(options, (client) => 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) })));
526
- 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>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-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((options) => withClient(async (client) => {
588
+ const VALUATION_DEFAULT_LIMIT = 2000;
589
+ function valuationFieldsToSend(fields) {
590
+ const unique = [...new Set(fields)];
591
+ const values = unique.filter((field) => field !== "tradeDate");
592
+ return values.length > 0 ? values : unique;
593
+ }
594
+ function firstRowTradeDate(data) {
595
+ const normalized = normalizeRows(data);
596
+ const list = Array.isArray(normalized) ? normalized : normalized?.list;
597
+ const first = Array.isArray(list) ? list[0] : undefined;
598
+ const value = first && typeof first === "object" ? first.tradeDate : undefined;
599
+ return typeof value === "string" ? value : undefined;
600
+ }
601
+ 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>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--limit <number>", "Max rows (default: 2000). One row per calendar day, weekends included, and the most recent are kept — a range longer than the limit loses its START (flagged partial, exit 3)").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((options) => withClient(async (client) => {
527
602
  const format = parseOutputFormat(options.format);
528
603
  const requested = maybeArray(options.field);
529
604
  const filterFields = options.skipNull && requested ? ["value", "percentileRank"].filter((field) => !requested.includes(field)) : [];
530
- const fieldList = requested && filterFields.length > 0 ? [...requested, ...filterFields] : requested;
531
- 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 });
605
+ const fieldList = requested && valuationFieldsToSend([...requested, ...filterFields]);
606
+ const limit = parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }) ?? VALUATION_DEFAULT_LIMIT;
607
+ let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit, fieldList });
608
+ const firstDate = firstRowTradeDate(data);
609
+ if (!(options.startDate && firstDate === options.startDate)) {
610
+ flagIfLimitTruncated(data, limit, "fundamental valuation-analysis", "--start-date", `The API keeps the most recent rows, so it is the START of the range that is missing. The series has one row per calendar day (weekends included): raise --limit to at least the number of days in the range (e.g. --limit 4000 for ten years within your account's history window), or move --start-date later.`);
611
+ }
612
+ if (options.startDate && firstDate && firstDate > options.startDate && !data.partial) {
613
+ process.stderr.write(`[gangtise] note: the series starts at ${firstDate}, later than --start-date ${options.startDate}. Either the security listed later, or the range reaches past your account's history window — this endpoint returns what lies inside the window without an error.\n`);
614
+ }
532
615
  if (options.skipNull) {
533
616
  const normalized = normalizeRows(data);
534
617
  if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
@@ -560,6 +643,79 @@ fundamental.command("earning-forecast").requiredOption("--security-code <code>")
560
643
  return client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) });
561
644
  }));
562
645
  program.addCommand(fundamental);
646
+ const bond = new Command("bond").description("Bond APIs: profiles, issuers, quotes, valuations, cash flows, ratings, announcements");
647
+ function requireBondCodes(codes, max) {
648
+ if (!codes.length)
649
+ throw new ValidationError("--security is required: pass one or more bond codes, e.g. --security 019742.SH --security 220205.IB");
650
+ const unique = [...new Set(codes)];
651
+ if (max && unique.length > max)
652
+ 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.`);
653
+ return unique;
654
+ }
655
+ const bondSecurityOption = (command) => command
656
+ .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, [])
657
+ .option("--field <field>", "Field to return (repeatable); omit for all. An unsupported name rejects the whole call with 100003", collectList, [])
658
+ .option("--format <format>", "Output format", "table").option("--output <path>");
659
+ const addBondRange = (name, endpointKey, describe, rangeHelp, max) => bondSecurityOption(bond.command(name).description(describe))
660
+ .option("--start-date <date>", `${rangeHelp} start (yyyy-MM-dd); omit for all history`, dateArg("--start-date"))
661
+ .option("--end-date <date>", `${rangeHelp} end (yyyy-MM-dd); omit for all history`, dateArg("--end-date"))
662
+ .action((options) => emit(options, (client) => client.call(endpointKey, { securityList: requireBondCodes(options.security, max), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
663
+ bondSecurityOption(bond.command("basic-info").description("Bond static profiles: issuance, term, coupon, rating, guarantee, special terms"))
664
+ .action((options) => emit(options, (client) => client.call("bond.basic-info", { securityList: requireBondCodes(options.security, 10000), fieldList: maybeArray(options.field) })));
665
+ bondSecurityOption(bond.command("rating-overview").description("Bond, issuer and guarantor ratings side by side"))
666
+ .action((options) => emit(options, (client) => client.call("bond.rating-overview", { securityList: requireBondCodes(options.security, 10), fieldList: maybeArray(options.field) })));
667
+ addBondRange("cash-flow", "bond.cash-flow", "Interest payment and redemption schedule per bond", "Payment date");
668
+ addBondRange("issuance-detail", "bond.issuance-detail", "Issuance and re-issuance records: bidding, pricing, cover ratios", "Issue announcement date");
669
+ 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);
670
+ addBondRange("exercise-notice", "bond.exercise-notice", "Put/call exercise schedule and results for option-embedded bonds", "Exercise date");
671
+ bondSecurityOption(bond.command("daily-quote").description("Daily close quotes (exchange + CFETS): dirty/clean price, YTM, duration, convexity"))
672
+ .requiredOption("--start-date <date>", "Trade date range start (yyyy-MM-dd)", dateArg("--start-date"))
673
+ .requiredOption("--end-date <date>", "Trade date range end (yyyy-MM-dd)", dateArg("--end-date"))
674
+ .action((options) => emit(options, (client) => client.call("bond.daily-quote", { securityList: requireBondCodes(options.security), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
675
+ bondSecurityOption(bond.command("valuation").description("Shanghai Clearing House valuations: price, yield, duration, convexity, PVBP"))
676
+ .requiredOption("--start-date <date>", "Valuation date range start (yyyy-MM-dd)", dateArg("--start-date"))
677
+ .requiredOption("--end-date <date>", "Valuation date range end (yyyy-MM-dd)", dateArg("--end-date"))
678
+ .addOption(new Option("--confidence-level <level>", "Valuation confidence; defaults to 推荐 server-side, which already filters out 不推荐").choices(["推荐", "不推荐"]))
679
+ .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) })));
680
+ function issuerSelector(security, issuer) {
681
+ if (security.length && issuer.length)
682
+ throw new ValidationError("--security and --issuer are mutually exclusive: pass bond codes or issuer names, not both");
683
+ if (!security.length && !issuer.length)
684
+ throw new ValidationError("pass either --security (bond codes) or --issuer (issuer names)");
685
+ return security.length ? { securityList: security } : { issuerNameList: issuer };
686
+ }
687
+ const issuerNameOption = (command) => command.option("--issuer <name>", "Issuer name, full or short (repeatable); fuzzy-matched, one best hit per name", collectList, []);
688
+ issuerNameOption(bondSecurityOption(bond.command("issuer-info").description("Issuer profiles: nature, SW industry, registration, rating, outstanding bonds")))
689
+ .action((options) => emit(options, (client) => client.call("bond.issuer-info", { ...issuerSelector(options.security, options.issuer), fieldList: maybeArray(options.field) })));
690
+ issuerNameOption(bondSecurityOption(bond.command("issuer-rating-change").description("Issuer rating change history (at most 10 issuers matched per call)")))
691
+ .option("--start-date <date>", "Announcement date range start (yyyy-MM-dd); omit for all history", dateArg("--start-date"))
692
+ .option("--end-date <date>", "Announcement date range end (yyyy-MM-dd); omit for all history", dateArg("--end-date"))
693
+ .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) })));
694
+ bond.command("issuance-plan").description("Rate-bond issuance calendar over a date range")
695
+ .requiredOption("--start-date <date>", "Issue date range start (yyyy-MM-dd)", dateArg("--start-date"))
696
+ .requiredOption("--end-date <date>", "Issue date range end (yyyy-MM-dd)", dateArg("--end-date"))
697
+ .option("--field <field>", "Field to return (repeatable); omit for all", collectList, [])
698
+ .option("--format <format>", "Output format", "table").option("--output <path>")
699
+ .action((options) => emit(options, (client) => client.call("bond.issuance-plan", { startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
700
+ bondSecurityOption(bond.command("announcement").description("Bond announcements, paged. Filter by bond codes OR by announcement date range, never both"))
701
+ .option("--start-date <date>", "Announcement date range start (yyyy-MM-dd); mutually exclusive with --security", dateArg("--start-date"))
702
+ .option("--end-date <date>", "Announcement date range end (yyyy-MM-dd); mutually exclusive with --security", dateArg("--end-date"))
703
+ .option("--page-no <number>", "Page number, from 1. No total is returned: increment until a page comes back empty", "1")
704
+ .option("--page-size <number>", "Rows per page, 1-200", "50")
705
+ .action((options) => emit(options, (client) => {
706
+ const byDate = Boolean(options.startDate || options.endDate);
707
+ if (options.security.length && byDate)
708
+ 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");
709
+ if (!options.security.length && !byDate)
710
+ throw new ValidationError("pass either --security (bond codes) or --start-date/--end-date (announcement date range)");
711
+ return client.call("bond.announcement", {
712
+ securityList: maybeArray(options.security), startDate: options.startDate, endDate: options.endDate,
713
+ pageNo: parseNumberOption(options.pageNo, "--page-no", { integer: true, min: 1 }),
714
+ pageSize: parseNumberOption(options.pageSize, "--page-size", { integer: true, min: 1, max: 200 }),
715
+ fieldList: maybeArray(options.field),
716
+ });
717
+ }));
718
+ program.addCommand(bond);
563
719
  const ai = new Command("ai").description("AI APIs");
564
720
  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
721
  if (!options.query.length)
@@ -696,6 +852,74 @@ program.addCommand(reference);
696
852
  const vault = new Command("vault").description("Vault APIs");
697
853
  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
854
  addDownloadCommand(vault, { endpointKey: "vault.drive.download", name: "drive-download", idOption: "--file-id", idField: "fileId", fallbackPrefix: "file", titleListEndpoint: "vault.drive.list" });
855
+ const driveSpaceOption = (help) => new Option("--space-type <n>", help).choices(["1", "2"]).default("1");
856
+ vault.command("drive-folder-list").description("List the direct subfolders and files of a drive folder (free)")
857
+ .addOption(driveSpaceOption("Space: 1=my drive 2=tenant drive"))
858
+ .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")
859
+ .option("--format <format>", "Output format", "json").option("--output <path>")
860
+ .action((options) => emit(options, (client) => client.call("vault.drive.folder-list", { spaceType: Number(options.spaceType), parentId: options.parentId })));
861
+ vault.command("drive-upload").description("Upload a file to the AI drive (free; max 100MB per file; trial accounts max 500MB/day)")
862
+ .requiredOption("--file <path>", "File to upload")
863
+ .addOption(driveSpaceOption("Target space: 1=my drive 2=tenant drive (shared with your whole tenant)"))
864
+ .option("--folder-id <id>", "Target folder ID in that space; omit (or 'root') for the space's root")
865
+ .option("--title <name>", "Name in the drive, max 200 characters; defaults to the local file name")
866
+ .option("--format <format>", "Output format", "json").option("--output <path>")
867
+ .action((options) => emit(options, (client) => uploadDriveFile(client, options.file, { spaceType: Number(options.spaceType), folderId: options.folderId, title: options.title })));
868
+ vault.command("drive-create-folder").description("Create a drive folder (free). Same-name folders are allowed, so re-running creates a second one")
869
+ .requiredOption("--name <name>", "Folder name, max 200 characters (230004 past that)")
870
+ .addOption(driveSpaceOption("Space: 1=my drive 2=tenant drive (shared with your whole tenant)"))
871
+ .option("--parent-id <id>", "Parent folder ID in that space; omit (or 'root') for the root")
872
+ .option("--format <format>", "Output format", "json").option("--output <path>")
873
+ .action((options) => emit(options, (client) => client.call("vault.drive.create-folder", { folderName: options.name, spaceType: Number(options.spaceType), parentId: options.parentId })));
874
+ vault.command("drive-rename").description("Rename a drive file or folder (free)")
875
+ .addOption(new Option("--type <type>", "What --id names").choices(["file", "folder"]).makeOptionMandatory())
876
+ .requiredOption("--id <id>", "File or folder ID")
877
+ .requiredOption("--name <name>", "New name, max 200 characters")
878
+ .option("--format <format>", "Output format", "json").option("--output <path>")
879
+ .action((options) => emit(options, (client) => client.call("vault.drive.rename", { type: options.type, id: options.id, name: options.name })));
880
+ vault.command("drive-move-file").description("Move drive files into a folder of the SAME space (free)")
881
+ .requiredOption("--file-id <id>", "File ID (repeat or comma-separate)", collectList)
882
+ .requiredOption("--target-folder-id <id>", "Destination folder ID, or 'root'. Files of the other space land in failList (空间不一致)")
883
+ .option("--format <format>", "Output format", "json").option("--output <path>")
884
+ .action((options) => emit(options, async (client) => {
885
+ const data = await client.call("vault.drive.move-file", { fileIdList: options.fileId, targetFolderId: options.targetFolderId });
886
+ flagFailedItems(data, "vault drive-move-file");
887
+ return data;
888
+ }));
889
+ vault.command("drive-move-folder").description("Move a drive folder under another folder of the SAME space (free)")
890
+ .requiredOption("--folder-id <id>", "Folder ID to move")
891
+ .requiredOption("--target-parent-id <id>", "Destination parent folder ID, or 'root'. Not the folder itself, one of its subfolders, or the other space (230005)")
892
+ .option("--format <format>", "Output format", "json").option("--output <path>")
893
+ .action((options) => emit(options, (client) => client.call("vault.drive.move-folder", { folderId: options.folderId, targetParentId: options.targetParentId })));
894
+ vault.command("drive-copy").description("Copy files to the OTHER space: my drive <-> tenant drive (free; same-space copies are rejected)")
895
+ .requiredOption("--file-id <id>", "Source file ID (repeat or comma-separate)", collectList)
896
+ .requiredOption("--target-folder-id <id>", "Destination folder ID in the other space, or 'root' for that space's root")
897
+ .option("--format <format>", "Output format", "json").option("--output <path>")
898
+ .action((options) => emit(options, async (client) => {
899
+ const data = await client.call("vault.drive.copy", { copyType: "file", fileIdList: options.fileId, targetFolderId: options.targetFolderId });
900
+ flagFailedItems(data, "vault drive-copy");
901
+ return data;
902
+ }));
903
+ vault.command("drive-delete-file").description("Delete drive files — irreversible (free)")
904
+ .requiredOption("--file-id <id>", "File ID to delete (repeat or comma-separate)", collectList)
905
+ .option("--yes", "Required: confirm the deletion")
906
+ .option("--format <format>", "Output format", "json").option("--output <path>")
907
+ .action(async (options) => {
908
+ assertConfirmed("vault.drive.delete-file", Boolean(options.yes), options.fileId.join("、"));
909
+ await emit(options, async (client) => {
910
+ const data = await client.call("vault.drive.delete-file", { fileIdList: options.fileId });
911
+ flagFailedItems(data, "vault drive-delete-file");
912
+ return data;
913
+ });
914
+ });
915
+ vault.command("drive-delete-folder").description("Delete a drive folder AND every subfolder and file inside it — irreversible (free)")
916
+ .requiredOption("--folder-id <id>", "Folder ID to delete")
917
+ .option("--yes", "Required: confirm the deletion")
918
+ .option("--format <format>", "Output format", "json").option("--output <path>")
919
+ .action(async (options) => {
920
+ assertConfirmed("vault.drive.delete-folder", Boolean(options.yes), options.folderId);
921
+ await emit(options, (client) => client.call("vault.drive.delete-folder", { folderId: options.folderId }));
922
+ });
699
923
  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
924
  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
925
  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 +977,8 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
753
977
  }
754
978
  await printData(data, format, options.output);
755
979
  }));
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 })));
980
+ 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 })));
981
+ 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
982
  program.addCommand(alternative);
759
983
  function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
760
984
  if (isEmptyMatrix(data)) {
@@ -871,6 +1095,28 @@ tool.command("file-parse-check").description("Download a finished file-parse res
871
1095
  process.stdout.write(`${JSON.stringify({ taskId: options.taskId, status: "pending", hint: "Parse not finished yet, retry in ~1 minute" })}\n`);
872
1096
  }
873
1097
  }));
1098
+ tool.command("web-search").description("Search the public web for research: deduped, source-tiered (T0-T3) results with optional page content (1 credit/call)")
1099
+ .requiredOption("--query <text>", "Search query, 1-200 chars. Sent verbatim — no intent rewriting server-side, so spell out what you want")
1100
+ .option("--size <number>", "Results to return, 1-20 (1-5 with --include-content)", "10")
1101
+ .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"]))
1102
+ .addOption(new Option("--min-tier <tier>", "Lowest source tier to keep; T3 = no filtering").choices(["T0", "T1", "T2", "T3"]))
1103
+ .option("--site <domain>", "Restrict to a registered domain or subdomain, e.g. csrc.gov.cn (repeatable, max 10, OR-ed)", collectList, [])
1104
+ .option("--include-content", "Return page body as Markdown; caps --size at 5")
1105
+ .option("--max-content-chars <number>", "Max chars per body, 1000-20000 (only with --include-content)")
1106
+ .option("--format <format>", "Output format", "table").option("--output <path>")
1107
+ .action((options) => emit(options, (client) => {
1108
+ const size = parseNumberOption(options.size, "--size", { integer: true, min: 1, max: 20 });
1109
+ if (options.includeContent && size > 5)
1110
+ 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.`);
1111
+ const sites = [...new Set(options.site)];
1112
+ if (sites.length > 10)
1113
+ throw new ValidationError(`--site takes at most 10 distinct domains, got ${sites.length}`);
1114
+ return client.call("tool.web-search", {
1115
+ query: options.query, size, freshness: options.freshness, minTier: options.minTier,
1116
+ siteList: maybeArray(sites), includeContent: options.includeContent || undefined,
1117
+ maxContentChars: parseOptionalNumberOption(options.maxContentChars, "--max-content-chars", { integer: true, min: 1000, max: 20000 }),
1118
+ });
1119
+ }));
874
1120
  program.addCommand(tool);
875
1121
  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
1122
  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
+ }