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/README.md +188 -52
- package/dist/src/cli.js +260 -14
- package/dist/src/core/client.js +33 -8
- package/dist/src/core/driveUpload.js +29 -0
- package/dist/src/core/endpoints.js +226 -4
- package/dist/src/core/errors.js +5 -2
- package/dist/src/core/normalize.js +1 -1
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +71 -20
- package/gangtise-openapi/references/commands/alternative.md +14 -7
- package/gangtise-openapi/references/commands/bond.md +132 -0
- package/gangtise-openapi/references/commands/fundamental.md +3 -1
- package/gangtise-openapi/references/commands/indicator.md +2 -2
- package/gangtise-openapi/references/commands/insight.md +33 -5
- package/gangtise-openapi/references/commands/quote.md +7 -10
- package/gangtise-openapi/references/commands/reference-and-lookup.md +10 -1
- package/gangtise-openapi/references/commands/tool.md +35 -0
- package/gangtise-openapi/references/commands/vault.md +27 -1
- package/gangtise-openapi/references/errors.md +5 -5
- package/gangtise-openapi/references/examples.md +13 -9
- package/gangtise-openapi/references/fields.md +9 -7
- package/gangtise-openapi/references/response-schema.md +19 -5
- package/package.json +2 -2
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
|
|
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,
|
|
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
|
-
|
|
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 &&
|
|
531
|
-
|
|
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];
|
package/dist/src/core/client.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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>'
|
|
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
|
+
}
|